#!/usr/bin/env python3
"""
e3po: E3 Python Option - A text editor using curses.
Based on E, E3, E1, and E2 editors.
"""

import curses
import curses.ascii
import sys
import os
import base64
import time
import logging
import traceback
import signal
import tty
import termios
from pathlib import Path
from enum import Enum, auto
from dataclasses import dataclass, field
from typing import Optional, List, Tuple, Dict, Set
import re
import subprocess
import shutil
import getpass
from datetime import datetime

# Check if age encryption is available (prefer pyrage, fall back to CLI)
PYRAGE_AVAILABLE = False
try:
    import pyrage
    PYRAGE_AVAILABLE = True
except ImportError:
    pass

AGE_AVAILABLE = PYRAGE_AVAILABLE or shutil.which('age') is not None

# Set up logging to file
logging.basicConfig(
    filename='editor.log',
    level=logging.DEBUG,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
log = logging.getLogger(__name__)

# Optional syntax highlighting
SYNTAX_AVAILABLE = False
SyntaxHighlighter = None
try:
    # Try relative import first (when installed as package)
    from .syntax.highlighter import get_highlighter, SyntaxHighlighter
    SYNTAX_AVAILABLE = True
except ImportError:
    try:
        # Fall back to absolute import (when run directly)
        from syntax.highlighter import get_highlighter, SyntaxHighlighter
        SYNTAX_AVAILABLE = True
    except ImportError:
        pass

# Optional spell checking
SPELL_AVAILABLE = False
SpellChecker = None
try:
    from spellchecker import SpellChecker
    SPELL_AVAILABLE = True
except ImportError:
    pass


class MarkMode(Enum):
    """Types of text marking."""
    NONE = auto()
    LINE = auto()       # Alt-L: Mark lines
    CHAR = auto()       # Alt-Z: Mark characters
    BLOCK = auto()      # Alt-B: Mark rectangular block


class UndoType(Enum):
    """Types of undo operations."""
    LINE_CHANGE = auto()   # Single line modified
    LINE_INSERT = auto()   # Line(s) inserted
    LINE_DELETE = auto()   # Line(s) deleted
    LINE_SPLIT = auto()    # Line split (Enter key)
    LINE_JOIN = auto()     # Lines joined (Backspace at start)
    BLOCK_CHANGE = auto()  # Block of lines modified


@dataclass
class UndoEntry:
    """Stores information needed to undo an operation."""
    undo_type: UndoType
    line_num: int                    # Starting line number
    col_pos: int                     # Cursor column position
    saved_lines: List[str]           # Saved line contents
    line_count: int = 1              # Number of lines affected


@dataclass
class Mark:
    """Represents a marked region."""
    mode: MarkMode
    start_row: int
    start_col: int
    end_row: Optional[int] = None
    end_col: Optional[int] = None
    pending: bool = True

    @property
    def is_complete(self) -> bool:
        return not self.pending


@dataclass
class Buffer:
    """Text buffer with lines and view state."""
    lines: List[str] = field(default_factory=lambda: [""])
    filename: Optional[str] = None
    modified: bool = False
    undo_stack: List[UndoEntry] = field(default_factory=list)
    redo_stack: List[UndoEntry] = field(default_factory=list)
    max_undo: int = 100  # Maximum undo/redo entries
    clean_undo_depth: int = 0  # Undo stack depth when last saved (for tracking modified state)
    # View state (saved when switching buffers)
    cursor_row: int = 0
    cursor_col: int = 0
    top_row: int = 0      # Scroll position
    left_col: int = 0
    mark: Optional['Mark'] = None
    # Encryption state
    encrypted: bool = False
    passphrase: Optional[str] = None  # Cached in memory, cleared on close
    # Track modified lines (for gutter marker)
    modified_lines: set = field(default_factory=set)

    def get_line(self, row: int) -> str:
        if 0 <= row < len(self.lines):
            return self.lines[row]
        return ""

    def set_line(self, row: int, text: str) -> None:
        while row >= len(self.lines):
            self.lines.append("")
        self.lines[row] = text
        self.modified = True
        self.modified_lines.add(row)

    def insert_char(self, row: int, col: int, ch: str) -> None:
        line = self.get_line(row)
        if col > len(line):
            line = line.ljust(col)
        self.set_line(row, line[:col] + ch + line[col:])

    def delete_char(self, row: int, col: int) -> None:
        line = self.get_line(row)
        if col < len(line):
            self.set_line(row, line[:col] + line[col + 1:])

    def backspace(self, row: int, col: int) -> Tuple[int, int]:
        """Delete char before cursor, return new position."""
        if col > 0:
            self.delete_char(row, col - 1)
            return row, col - 1
        elif row > 0:
            # Join with previous line
            prev_line = self.get_line(row - 1)
            curr_line = self.get_line(row)
            new_col = len(prev_line)
            self.set_line(row - 1, prev_line + curr_line)
            del self.lines[row]
            self.modified = True
            return row - 1, new_col
        return row, col

    def split_line(self, row: int, col: int) -> None:
        """Split line at cursor (Enter key)."""
        line = self.get_line(row)
        self.set_line(row, line[:col])
        self.lines.insert(row + 1, line[col:])
        self.modified = True

    def delete_lines(self, start: int, end: int) -> List[str]:
        """Delete lines from start to end inclusive, return deleted."""
        start = max(0, start)
        end = min(end, len(self.lines) - 1)
        deleted = self.lines[start:end + 1]
        del self.lines[start:end + 1]
        if not self.lines:
            self.lines = [""]
        self.modified = True
        return deleted

    def insert_lines(self, row: int, lines: List[str]) -> None:
        """Insert lines at row."""
        for i, line in enumerate(lines):
            self.lines.insert(row + i, line)
        self.modified = True

    def load(self, filename: str, passphrase: Optional[str] = None) -> bool:
        """Load file into buffer. If passphrase provided, decrypt .age file."""
        try:
            path = Path(filename)
            if path.exists():
                # Check if this is an encrypted file
                if _is_age_file(filename):
                    if not passphrase:
                        return False  # Need passphrase for encrypted file
                    content = _decrypt_age_file(filename, passphrase)
                    if content is None:
                        return False  # Decryption failed
                    self.encrypted = True
                    self.passphrase = passphrase
                else:
                    content = path.read_text()
                    self.encrypted = False
                    self.passphrase = None
                self.lines = content.split('\n')
                if not self.lines:
                    self.lines = [""]
            else:
                self.lines = [""]
                # New .age file
                if filename.endswith('.age'):
                    self.encrypted = True
                    self.passphrase = passphrase
            self.filename = filename
            self.modified = False
            self.undo_stack.clear()  # Clear undo/redo history on load
            self.redo_stack.clear()
            self.clean_undo_depth = 0  # Mark current state as clean
            return True
        except Exception as e:
            log.error(f"Load failed: {e}")
            return False

    def save(self, filename: Optional[str] = None) -> bool:
        """Save buffer to file. Encrypts if buffer is marked encrypted."""
        fname = filename or self.filename
        if not fname:
            return False
        try:
            content = '\n'.join(self.lines)

            if self.encrypted:
                if not self.passphrase:
                    return False  # Need passphrase to save encrypted
                if not _encrypt_to_age_file(content, fname, self.passphrase):
                    return False
            else:
                Path(fname).write_text(content)

            self.filename = fname
            self.modified = False
            self.clean_undo_depth = len(self.undo_stack)  # Mark current state as clean
            self.modified_lines.clear()  # Clear line markers on save
            return True
        except Exception:
            return False

    def push_undo(self, undo_type: UndoType, line_num: int, col_pos: int,
                  start_line: int, end_line: int) -> None:
        """Save lines for undo before modifying them."""
        log.debug(f"push_undo: type={undo_type}, line={line_num}, col={col_pos}, "
                  f"range={start_line}-{end_line}, buffer_lines={len(self.lines)}")
        # Clear redo stack on new edit
        self.redo_stack.clear()

        # Ensure valid range
        start_line = max(0, start_line)
        end_line = min(end_line, len(self.lines) - 1) if end_line is not None else start_line
        if end_line < start_line:
            end_line = start_line

        # Save the lines
        saved = [self.lines[i] if i < len(self.lines) else ""
                 for i in range(start_line, end_line + 1)]

        entry = UndoEntry(
            undo_type=undo_type,
            line_num=line_num,
            col_pos=col_pos,
            saved_lines=saved,
            line_count=end_line - start_line + 1
        )
        self.undo_stack.append(entry)

        # Limit stack size
        if len(self.undo_stack) > self.max_undo:
            self.undo_stack.pop(0)

    def pop_undo(self) -> Optional[UndoEntry]:
        """Pop and return the most recent undo entry."""
        if self.undo_stack:
            return self.undo_stack.pop()
        return None

    def clear_undo(self) -> None:
        """Clear the undo and redo stacks."""
        self.undo_stack.clear()
        self.redo_stack.clear()

    def clear_passphrase(self) -> None:
        """Securely clear passphrase from memory."""
        if self.passphrase:
            # Overwrite the string in memory (best effort in Python)
            self.passphrase = None
        self.passphrase = None


def _is_age_file(filename: str) -> bool:
    """Check if file is an age-encrypted file."""
    if filename and filename.endswith('.age'):
        return True
    # Also check magic bytes
    try:
        with open(filename, 'rb') as f:
            header = f.read(20)
            return header.startswith(b'age-encryption.org/')
    except:
        return False


def _decrypt_age_file(filename: str, passphrase: str) -> Optional[str]:
    """Decrypt an age file using passphrase. Returns content or None on failure."""
    if not AGE_AVAILABLE:
        return None

    if PYRAGE_AVAILABLE:
        try:
            import pyrage
            with open(filename, 'rb') as f:
                encrypted_data = f.read()
            decrypted = pyrage.passphrase.decrypt(encrypted_data, passphrase)
            return decrypted.decode('utf-8')
        except Exception as e:
            log.error(f"Decryption failed: {e}")
            return None
    else:
        # Fall back to CLI (may not work in all environments)
        try:
            import pty
            master_fd, slave_fd = pty.openpty()
            proc = subprocess.Popen(
                ['age', '-d', filename],
                stdin=slave_fd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE
            )
            os.close(slave_fd)
            time.sleep(0.1)
            os.write(master_fd, (passphrase + '\n').encode())
            os.close(master_fd)
            stdout, stderr = proc.communicate(timeout=30)
            if proc.returncode == 0:
                return stdout.decode('utf-8')
            log.error(f"age decrypt failed: {stderr.decode()}")
            return None
        except Exception as e:
            log.error(f"Decryption failed: {e}")
            return None


def _encrypt_to_age_file(content: str, filename: str, passphrase: str) -> bool:
    """Encrypt content to an age file using passphrase. Returns success."""
    if not AGE_AVAILABLE:
        return False

    if PYRAGE_AVAILABLE:
        try:
            import pyrage
            encrypted = pyrage.passphrase.encrypt(content.encode('utf-8'), passphrase)
            with open(filename, 'wb') as f:
                f.write(encrypted)
            return True
        except Exception as e:
            log.error(f"Encryption failed: {e}")
            return False
    else:
        # Fall back to CLI (may not work in all environments)
        try:
            import pty
            master_fd, slave_fd = pty.openpty()
            proc = subprocess.Popen(
                ['age', '-p', '-o', filename],
                stdin=subprocess.PIPE,
                stdout=slave_fd,
                stderr=subprocess.PIPE
            )
            os.close(slave_fd)
            time.sleep(0.1)
            os.write(master_fd, (passphrase + '\n').encode())
            time.sleep(0.1)
            os.write(master_fd, (passphrase + '\n').encode())
            os.close(master_fd)
            proc.stdin.write(content.encode())
            proc.stdin.close()
            proc.wait(timeout=30)
            return proc.returncode == 0
        except Exception as e:
            log.error(f"Encryption failed: {e}")
            return False


def _secure_delete(filename: str) -> bool:
    """Securely delete a file by overwriting then removing."""
    try:
        path = Path(filename)
        if not path.exists():
            return True
        # Overwrite with random data
        size = path.stat().st_size
        with open(filename, 'wb') as f:
            f.write(os.urandom(size))
            f.flush()
            os.fsync(f.fileno())
        # Overwrite with zeros
        with open(filename, 'wb') as f:
            f.write(b'\x00' * size)
            f.flush()
            os.fsync(f.fileno())
        # Delete
        path.unlink()
        return True
    except Exception as e:
        log.error(f"Secure delete failed: {e}")
        return False


class Editor:
    """Main editor class."""

    def __init__(self, filenames: Optional[List[str]] = None, light_mode: bool = False):
        # File ring (multiple buffers)
        self.buffers: List[Buffer] = []
        self.current_buffer_idx: int = 0

        # Current view state (synced with current buffer)
        self.cursor_row = 0
        self.cursor_col = 0
        self.top_row = 0  # First visible row
        self.left_col = 0  # First visible column
        self.mark: Optional[Mark] = None

        self.message = ""
        self.running = True
        self.screen = None
        self.height = 24
        self.width = 80
        self.edit_height = 20  # Lines for editing area
        self.light_mode = light_mode  # Color scheme for light terminals

        # Search state
        self.search_term: Optional[str] = None
        self.search_case_ignore = False

        # Fill mark state (Alt-F waits for fill character)
        self.fill_pending = False

        # Spell checking state
        self.spell_mode = False
        self.spell_errors: List[Tuple[int, int, int, str]] = []  # [(row, start_col, end_col, word), ...]
        self.spell_index = -1  # Current error index for n/p navigation
        self.spell_skip_words: Set[str] = set()  # Words to ignore this session
        self.spell_checker = None  # Lazy-loaded SpellChecker instance
        self.spell_suggestions: List[str] = []  # Current word's suggestions

        # Bookmarks (set of line numbers)
        self.bookmarks: set = set()

        # Double-press detection for Ctrl-A/Ctrl-E
        self.last_ctrl_key: Optional[int] = None
        self.last_ctrl_time: float = 0
        self.last_ctrl_col: int = 0  # Column to restore on double-press

        # Syntax highlighting (can disable with EPYNO_SYNTAX=1 env var)
        self.highlighter: Optional[SyntaxHighlighter] = None
        self.syntax_lang: Optional[str] = None
        self.syntax_last_hash: Optional[int] = None  # Hash of last parsed content

        if SYNTAX_AVAILABLE and not os.environ.get('EPY_NO_SYNTAX'):
            self.highlighter = get_highlighter()

        # Load files into ring
        if filenames:
            for filename in filenames:
                buf = Buffer()
                passphrase = None

                # Check if file needs decryption (before curses starts)
                if _is_age_file(filename):
                    if not AGE_AVAILABLE:
                        print(f"Error: 'age' not installed. Cannot open {filename}")
                        print("Install with: brew install age")
                        sys.exit(1)
                    # Prompt for passphrase before curses starts
                    try:
                        passphrase = getpass.getpass(f"Passphrase for {filename}: ")
                    except (EOFError, KeyboardInterrupt):
                        print("\nCancelled.")
                        sys.exit(0)

                if not buf.load(filename, passphrase):
                    if _is_age_file(filename):
                        print(f"Error: Failed to decrypt {filename}. Wrong passphrase?")
                        sys.exit(1)
                self.buffers.append(buf)

        # Ensure at least one buffer exists
        if not self.buffers:
            self.buffers.append(Buffer())

        self.buffer = self.buffers[0]

    def run(self, screen) -> None:
        """Main loop."""
        self.screen = screen

        # Ignore signals that would interrupt or suspend the editor
        signal.signal(signal.SIGINT, signal.SIG_IGN)   # Ctrl-C
        signal.signal(signal.SIGTSTP, signal.SIG_IGN)  # Ctrl-Z (suspend)
        signal.signal(signal.SIGQUIT, signal.SIG_IGN)  # Ctrl-\ (quit)

        # Disable terminal special characters so we can use them as keystrokes
        try:
            fd = sys.stdin.fileno()
            old_settings = termios.tcgetattr(fd)
            new_settings = termios.tcgetattr(fd)
            # Disable XON/XOFF flow control (Ctrl-S/Ctrl-Q)
            new_settings[0] &= ~termios.IXON
            # Disable all special control characters in the cc array
            # VINTR (Ctrl-C), VQUIT (Ctrl-\), VSUSP (Ctrl-Z), VDSUSP (Ctrl-Y)
            if hasattr(termios, 'VINTR'):
                new_settings[6][termios.VINTR] = 0
            if hasattr(termios, 'VQUIT'):
                new_settings[6][termios.VQUIT] = 0
            if hasattr(termios, 'VSUSP'):
                new_settings[6][termios.VSUSP] = 0
            if hasattr(termios, 'VDSUSP'):
                new_settings[6][termios.VDSUSP] = 0
            termios.tcsetattr(fd, termios.TCSANOW, new_settings)
        except:
            pass  # Ignore if we can't change terminal settings

        curses.start_color()
        curses.use_default_colors()
        curses.raw()  # Pass through all control characters including Ctrl-C

        # Initialize colors with explicit backgrounds (controlled by light/dark mode)
        self._init_colors()

        curses.curs_set(1)  # Show cursor
        screen.keypad(True)
        screen.timeout(-1)  # Blocking input

        # Reduce ESC delay (default can be 1000ms!)
        try:
            curses.set_escdelay(25)  # 25ms is plenty
        except AttributeError:
            pass  # Not available on all systems

        # Enable bracketed paste mode (works on most modern terminals)
        sys.stdout.write('\033[?2004h')
        # Enable modifyOtherKeys mode (xterm) for Ctrl+Backspace/Delete detection
        # Level 2: report modified keys that would otherwise be ambiguous
        sys.stdout.write('\033[>4;2m')
        sys.stdout.flush()

        self.height, self.width = screen.getmaxyx()
        # Layout: edit area + status (1) + command (1)
        self.edit_height = self.height - 2  # Status bar + command line

        self.command_mode = False  # True when typing in command line
        self.command_buffer = ""

        # Initialize syntax highlighting for initial buffer
        self._init_syntax()

        # Show startup message with version and warranty disclaimer
        try:
            from . import __version__
            # Get build time from module file
            mod_file = Path(__file__)
            build_time = datetime.fromtimestamp(mod_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
            file_path = self.buffer.filename or "[No Name]"
            self.message = f"e3po v{__version__} ({build_time}) NO WARRANTY - RUNNING: {file_path}"
        except:
            file_path = self.buffer.filename or "[No Name]"
            self.message = f"e3po - NO WARRANTY - RUNNING: {file_path}"

        try:
            while self.running:
                try:
                    self.draw()
                    self.handle_input()
                except Exception as e:
                    log.error(f"Error in main loop: {e}")
                    log.error(traceback.format_exc())
                    raise
        finally:
            # Disable bracketed paste mode and CSI u protocol on exit
            sys.stdout.write('\033[?2004l')
            sys.stdout.write('\033[<u')  # Pop keyboard mode (disable CSI u)
            sys.stdout.flush()

    def set_color_scheme(self, light: bool) -> None:
        """Set color scheme for light or dark terminal."""
        self.light_mode = light
        self._init_colors()

    def _init_colors(self) -> None:
        """Initialize all color pairs with explicit backgrounds."""
        # Determine background color based on mode
        if self.light_mode:
            # Light mode: white/light gray background
            try:
                bg = 255  # Bright white (256-color)
                fg = 232  # Near black
            except:
                bg = curses.COLOR_WHITE
                fg = curses.COLOR_BLACK
        else:
            # Dark mode: true black background
            bg = curses.COLOR_BLACK  # 0 - most universal black
            fg = curses.COLOR_WHITE

        # Store for use in drawing
        self.edit_bg = bg
        self.edit_fg = fg

        try:
            # Color pair 20: default text (used for edit area)
            curses.init_pair(20, fg, bg)

            # UI elements (keep fixed)
            curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)   # Status bar
            curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_YELLOW)  # Marked text
            curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE)    # Help/prompts

            if self.light_mode:
                curses.init_pair(4, 240, bg)  # Command hint (gray on light bg)
                curses.init_pair(5, 232, 253)  # Command active (dark on light gray)
            else:
                curses.init_pair(4, 250, bg)  # Command hint (light gray on dark bg)
                curses.init_pair(5, 255, 238)  # Command active (white on dark gray)

            # Syntax highlighting with explicit backgrounds
            if self.light_mode:
                # Light mode: dark text colors on light background
                curses.init_pair(10, 19, bg)    # keyword: dark blue
                curses.init_pair(11, 22, bg)    # string: dark green
                curses.init_pair(12, 243, bg)   # comment: medium gray
                curses.init_pair(13, 130, bg)   # function: dark orange/brown
                curses.init_pair(14, 30, bg)    # type: dark cyan/teal
                curses.init_pair(15, 90, bg)    # number: dark magenta
                curses.init_pair(16, 124, bg)   # constant: dark red
                curses.init_pair(17, 232, 229)  # spell error: black on light yellow
                curses.init_pair(18, curses.COLOR_WHITE, curses.COLOR_BLUE)   # current spell
                curses.init_pair(19, curses.COLOR_WHITE, curses.COLOR_RED)    # error msg
                curses.init_pair(21, 232, 157)  # success msg: black on light green
            else:
                # Dark mode: bright text colors on dark background
                curses.init_pair(10, 75, bg)    # keyword: bright blue
                curses.init_pair(11, 114, bg)   # string: bright green
                curses.init_pair(12, 245, bg)   # comment: gray
                curses.init_pair(13, 221, bg)   # function: bright yellow
                curses.init_pair(14, 87, bg)    # type: bright cyan
                curses.init_pair(15, 213, bg)   # number: bright magenta
                curses.init_pair(16, 203, bg)   # constant: bright red
                curses.init_pair(17, 232, 226)  # spell error: black on yellow
                curses.init_pair(18, curses.COLOR_WHITE, curses.COLOR_BLUE)   # current spell
                curses.init_pair(19, curses.COLOR_WHITE, curses.COLOR_RED)    # error msg
                curses.init_pair(21, 232, 83)   # success msg: black on bright green

        except:
            # Fallback for 8-color terminals
            bg = curses.COLOR_WHITE if self.light_mode else curses.COLOR_BLACK
            fg = curses.COLOR_BLACK if self.light_mode else curses.COLOR_WHITE
            self.edit_bg = bg
            self.edit_fg = fg

            curses.init_pair(20, fg, bg)
            curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
            curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_YELLOW)
            curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE)
            curses.init_pair(4, fg, bg)
            curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
            curses.init_pair(10, curses.COLOR_BLUE, bg)
            curses.init_pair(11, curses.COLOR_GREEN, bg)
            curses.init_pair(12, curses.COLOR_WHITE if not self.light_mode else curses.COLOR_BLACK, bg)
            curses.init_pair(13, curses.COLOR_YELLOW, bg)
            curses.init_pair(14, curses.COLOR_CYAN, bg)
            curses.init_pair(15, curses.COLOR_MAGENTA, bg)
            curses.init_pair(16, curses.COLOR_RED, bg)
            curses.init_pair(17, curses.COLOR_BLACK, curses.COLOR_YELLOW)
            curses.init_pair(18, curses.COLOR_WHITE, curses.COLOR_BLUE)
            curses.init_pair(19, curses.COLOR_WHITE, curses.COLOR_RED)
            curses.init_pair(21, curses.COLOR_BLACK, curses.COLOR_GREEN)  # success msg

    def _init_syntax(self) -> None:
        """Initialize syntax highlighting for the current file."""
        if not self.highlighter or not self.buffer.filename:
            return

        lang = self.highlighter.detect_language(self.buffer.filename)
        if not lang:
            return

        if self.highlighter.is_available(lang):
            if self.highlighter.load_language(lang):
                self.syntax_lang = lang
                self.syntax_dirty = True
        # Could offer to download if not available

    def _update_syntax(self) -> None:
        """Re-parse the buffer for syntax highlighting."""
        if not self.highlighter or not self.syntax_lang:
            return

        # Check if buffer content changed since last parse
        source = '\n'.join(self.buffer.lines)
        source_hash = hash(source)

        if source_hash == self.syntax_last_hash:
            return  # No changes

        # Check for large file mode
        self.highlighter.set_large_file_mode(len(self.buffer.lines))

        # Parse the buffer
        self.highlighter.parse(source, self.syntax_lang)
        self.syntax_last_hash = source_hash

    def _get_syntax_color(self, highlight_group: int) -> int:
        """Map highlighter color index to curses color pair."""
        # Map from highlighter HIGHLIGHT_GROUPS values to our color pairs
        # highlighter: 1=keyword, 2=string, 3=comment, 4=function, 5=type, 6=number, 9=constant
        # our pairs:  10=keyword, 11=string, 12=comment, 13=function, 14=type, 15=number, 16=constant
        if highlight_group == 1:  # keyword
            return 10
        elif highlight_group == 2:  # string
            return 11
        elif highlight_group == 3:  # comment
            return 12
        elif highlight_group == 4:  # function
            return 13
        elif highlight_group == 5:  # type
            return 14
        elif highlight_group == 6:  # number
            return 15
        elif highlight_group == 9:  # constant
            return 16
        return 0  # default

    def draw(self) -> None:
        """Draw the screen."""
        self.screen.clear()

        # Update syntax highlighting if needed
        try:
            self._update_syntax()
        except:
            pass  # Ignore syntax errors

        # Rows 0 to edit_height-1: Edit area
        for screen_row in range(self.edit_height):
            buf_row = self.top_row + screen_row
            draw_row = screen_row
            if buf_row < len(self.buffer.lines):
                line = self.buffer.lines[buf_row]
                # Pad line to show cursor position if beyond line end
                display_width = max(len(line), self.cursor_col + 1 if buf_row == self.cursor_row else 0)
                padded_line = line.ljust(display_width)
                # Handle horizontal scroll
                visible_line = padded_line[self.left_col:self.left_col + self.width]

                # Draw with highlighting for marked regions
                self.draw_line(draw_row, buf_row, visible_line)
            else:
                # Empty line indicator with explicit background
                empty_line = "~".ljust(self.width)
                self.safe_addstr(draw_row, 0, empty_line, curses.color_pair(20) | curses.A_DIM)

        # Draw spell popup if in spell mode (overlays edit area)
        self.draw_spell_popup()

        # Second to last row: Status bar
        self.draw_status_bar()

        # Last row: Command line
        self.draw_command_line()

        # Position cursor
        if self.command_mode:
            # Cursor in command line
            cmd_row = self.height - 1
            self.screen.move(cmd_row, len(self.command_buffer))
        else:
            # Cursor in edit area
            cursor_screen_row = self.cursor_row - self.top_row
            cursor_screen_col = self.cursor_col - self.left_col
            if 0 <= cursor_screen_row <= self.edit_height and 0 <= cursor_screen_col < self.width:
                self.screen.move(cursor_screen_row, cursor_screen_col)

        self.screen.refresh()

    def draw_line(self, screen_row: int, buf_row: int, visible_line: str) -> None:
        """Draw a line with mark, syntax, and spell highlighting."""
        # Draw modified line marker on left edge (if scrolled to column 0)
        is_modified_line = buf_row in self.buffer.modified_lines
        if is_modified_line and self.left_col == 0:
            # Draw yellow/orange marker at column 0
            self.safe_addstr(screen_row, 0, "▎", curses.color_pair(13))  # Yellow/orange

        # Get mark highlighting
        marked_cols = self.get_marked_cols(buf_row) if self.mark else set()

        # Get spell checking highlighting (other errors vs current error)
        other_spell_cols, current_spell_cols = (set(), set())
        if self.spell_mode:
            other_spell_cols, current_spell_cols = self.get_misspelled_cols(buf_row)
        all_spell_cols = other_spell_cols | current_spell_cols

        # Get syntax highlighting for this line
        syntax_colors: Dict[int, int] = {}  # col -> color_pair
        if self.highlighter and self.syntax_lang:
            try:
                line_text = self.buffer.get_line(buf_row)
                highlights = self.highlighter.get_highlights(buf_row, buf_row, [line_text])
                if buf_row in highlights:
                    for start_col, end_col, color_idx in highlights[buf_row]:
                        color_pair = self._get_syntax_color(color_idx)
                        if color_pair:
                            for c in range(start_col, end_col):
                                syntax_colors[c] = color_pair
            except:
                pass  # Ignore syntax highlighting errors

        # No highlighting at all - fast path (use color pair 20 for explicit background)
        if not marked_cols and not syntax_colors and not all_spell_cols:
            self.safe_addstr(screen_row, 0, visible_line.ljust(self.width), curses.color_pair(20))
            return

        # For LINE marks, extend visible_line to full screen width
        if self.mark and self.mark.mode == MarkMode.LINE and self.is_row_in_mark(buf_row):
            visible_line = visible_line.ljust(self.width)

        # Draw character by character with highlighting
        for screen_col in range(min(len(visible_line), self.width)):
            ch = visible_line[screen_col] if screen_col < len(visible_line) else ' '
            buf_col = self.left_col + screen_col

            if buf_col in marked_cols:
                # Mark highlighting takes priority
                self.safe_addstr(screen_row, screen_col, ch, curses.color_pair(2))
            elif buf_col in current_spell_cols:
                # Current spell error - blue background (more prominent)
                self.safe_addstr(screen_row, screen_col, ch, curses.color_pair(18))
            elif buf_col in other_spell_cols:
                # Other spell errors - yellow background
                self.safe_addstr(screen_row, screen_col, ch, curses.color_pair(17))
            elif buf_col in syntax_colors:
                # Syntax highlighting
                self.safe_addstr(screen_row, screen_col, ch, curses.color_pair(syntax_colors[buf_col]))
            else:
                # Default text with explicit background
                self.safe_addstr(screen_row, screen_col, ch, curses.color_pair(20))

        # Fill remaining columns with background
        filled_cols = min(len(visible_line), self.width)
        if filled_cols < self.width:
            remaining = ' ' * (self.width - filled_cols)
            self.safe_addstr(screen_row, filled_cols, remaining, curses.color_pair(20))

    def draw_spell_popup(self) -> None:
        """Draw a vertical popup below the current misspelled word with suggestions."""
        if not self.spell_mode or not (0 <= self.spell_index < len(self.spell_errors)):
            return

        if not self.spell_suggestions:
            return  # No suggestions to show

        row, start_col, end_col, word = self.spell_errors[self.spell_index]

        # Calculate popup position (below the word)
        screen_row = row - self.top_row + 1  # One row below the word
        screen_col = start_col - self.left_col

        # Determine popup width (widest suggestion + number prefix)
        max_sugg_len = max(len(s) for s in self.spell_suggestions)
        popup_width = max_sugg_len + 4  # "N) " prefix + space padding

        # Adjust horizontal position if popup would go off screen
        if screen_col + popup_width > self.width:
            screen_col = max(0, self.width - popup_width)
        if screen_col < 0:
            screen_col = 0

        # Draw each suggestion as a row (view already scrolled to fit)
        popup_attr = curses.A_REVERSE
        for i, sugg in enumerate(self.spell_suggestions):
            line = f" {i+1}) {sugg} ".ljust(popup_width)[:popup_width]
            if 0 <= screen_row + i < self.edit_height:
                self.safe_addstr(screen_row + i, screen_col, line, popup_attr)

    def get_marked_cols(self, buf_row: int) -> set:
        """Get set of column indices that are marked on this row."""
        if not self.mark:
            return set()

        # Get mark boundaries
        start_row = self.mark.start_row
        end_row = self.mark.end_row if self.mark.end_row is not None else start_row
        start_col = self.mark.start_col
        end_col = self.mark.end_col if self.mark.end_col is not None else start_col

        # Normalize row order
        if start_row > end_row:
            start_row, end_row = end_row, start_row
            # For CHAR marks, swap cols too (reading order)
            if self.mark.mode == MarkMode.CHAR:
                start_col, end_col = end_col, start_col

        # For BLOCK marks, normalize columns independently
        if self.mark.mode == MarkMode.BLOCK:
            if start_col > end_col:
                start_col, end_col = end_col, start_col
        # For CHAR marks on same row, normalize columns
        elif self.mark.mode == MarkMode.CHAR and self.mark.start_row == self.mark.end_row:
            if start_col > end_col:
                start_col, end_col = end_col, start_col

        if buf_row < start_row or buf_row > end_row:
            return set()

        line_len = len(self.buffer.get_line(buf_row))
        # For display, extend to cursor column if beyond line end
        display_len = max(line_len, self.cursor_col + 1 if buf_row == self.cursor_row else line_len)

        if self.mark.mode == MarkMode.LINE:
            # Entire line is marked - full screen width (2D space)
            return set(range(self.left_col + self.width + 1))

        elif self.mark.mode == MarkMode.CHAR:
            # Character range
            if start_row == end_row:
                # Single line
                if buf_row == start_row:
                    return set(range(start_col, end_col + 1))
            else:
                # Multiple lines
                if buf_row == start_row:
                    return set(range(start_col, max(display_len, end_col) + 1))
                elif buf_row == end_row:
                    return set(range(0, end_col + 1))
                else:
                    return set(range(display_len + 1))

        elif self.mark.mode == MarkMode.BLOCK:
            # Rectangular block - always show full block width
            col_start = min(start_col, end_col if end_col is not None else start_col)
            col_end = max(start_col, end_col if end_col is not None else start_col)
            return set(range(col_start, col_end + 1))

        return set()

    def is_row_in_mark(self, buf_row: int) -> bool:
        """Check if a buffer row is within the mark range."""
        if not self.mark:
            return False
        start_row = min(self.mark.start_row, self.mark.end_row)
        end_row = max(self.mark.start_row, self.mark.end_row)
        return start_row <= buf_row <= end_row

    def safe_addstr(self, row: int, col: int, text: str, attr: int = 0) -> None:
        """Safely add string, handling edge cases."""
        try:
            if 0 <= row < self.height and 0 <= col < self.width:
                max_len = self.width - col
                self.screen.addstr(row, col, text[:max_len], attr)
        except curses.error:
            pass  # Ignore errors at screen edges

    def draw_status_bar(self) -> None:
        """Draw status bar (second to last row)."""
        row = self.height - 2

        # Build status string
        fname = self.buffer.filename or "[No Name]"
        mod = "*" if self.buffer.modified else " "
        mark_str = "---"
        if self.mark:
            mark_str = self.mark.mode.name

        # File ring indicator
        ring_str = ""
        if len(self.buffers) > 1:
            ring_str = f" [{self.current_buffer_idx + 1}/{len(self.buffers)}]"

        # Show message if present, otherwise show file info
        if self.message:
            status = f" {self.message}"
            msg_lower = self.message.lower()
            # Check message type: error (red), warning (yellow), or success (green)
            is_error = any(x in msg_lower for x in [
                'error', 'failed', 'invalid', 'unknown',
                'no mark', 'no file', 'not found', 'cannot', "can't",
                'requires', 'need', 'usage:'
            ])
            is_warning = any(x in msg_lower for x in [
                'modified', 'discard', 'close anyway', 'cancelled'
            ])
            status = status.ljust(self.width)[:self.width]
            if is_error:
                self.safe_addstr(row, 0, status, curses.color_pair(19) | curses.A_BOLD)  # red
            elif is_warning:
                self.safe_addstr(row, 0, status, curses.color_pair(2))  # yellow (same as mark)
            else:
                self.safe_addstr(row, 0, status, curses.color_pair(21))  # success: light green
        else:
            enc_str = "[AGE] " if self.buffer.encrypted else ""
            status = f" {enc_str}{fname}{mod}{ring_str} | Line: {self.cursor_row + 1} Col: {self.cursor_col + 1} | Mark: {mark_str}"
            status = status.ljust(self.width)[:self.width]
            self.safe_addstr(row, 0, status, curses.color_pair(1))

    def draw_command_line(self) -> None:
        """Draw command line at bottom."""
        row = self.height - 1
        if self.command_mode:
            # Show command buffer with grey background (no colon prefix)
            cmd_display = self.command_buffer.ljust(self.width)[:self.width]
            try:
                self.safe_addstr(row, 0, cmd_display, curses.color_pair(5) | curses.A_BOLD)
            except:
                # Fallback if color 238 not supported
                self.safe_addstr(row, 0, cmd_display, curses.A_REVERSE)
        else:
            # Show hint with explicit background
            hint = "ESC: command | /search | c/old/new/ | line#"
            hint = hint.ljust(self.width)[:self.width]
            self.safe_addstr(row, 0, hint, curses.color_pair(4))

    def handle_resize(self) -> None:
        """Handle terminal resize."""
        curses.endwin()
        self.screen.refresh()
        self.height, self.width = self.screen.getmaxyx()
        self.edit_height = self.height - 2  # Status bar + command line
        # Keep cursor in valid range
        if self.cursor_row >= len(self.buffer.lines):
            self.cursor_row = len(self.buffer.lines) - 1
        if self.cursor_row < 0:
            self.cursor_row = 0
        # Adjust scroll if needed
        if self.top_row > self.cursor_row:
            self.top_row = self.cursor_row
        if self.cursor_row >= self.top_row + self.edit_height:
            self.top_row = self.cursor_row - self.edit_height + 1

    def handle_input(self) -> None:
        """Handle keyboard input."""
        try:
            ch = self.screen.getch()
        except KeyboardInterrupt:
            self.running = False
            return

        log.debug(f"Key input: {ch} (0x{ch:x})")

        # Handle terminal resize
        if ch == curses.KEY_RESIZE:
            self.handle_resize()
            return

        self.message = ""  # Clear message on new input

        # Command mode handling
        if self.command_mode:
            self.handle_command_input(ch)
            return

        # Fill pending - waiting for fill character
        if self.fill_pending:
            self.fill_pending = False
            if 32 <= ch < 127:
                self.do_fill_mark(chr(ch))
            else:
                self.message = "Fill cancelled"
            return

        # Ctrl-S - toggle spell mode (and go to first error)
        if ch == 19:  # Ctrl-S
            was_off = not self.spell_mode
            self.toggle_spell_mode()
            # If turning on and there are errors, go to first one
            if was_off and self.spell_mode and self.spell_errors:
                self._save_jump_position()
                self._spell_next()
            return

        # Spell mode input handling
        if self.spell_mode:
            if self._handle_spell_input(ch):
                return
            # If not handled, fall through to normal editing

        # Check for ESC (Alt key prefix in terminal, or enter command mode)
        if ch == 27:  # ESC
            # Use nodelay + minimal sleep for fast ESC detection
            self.screen.nodelay(True)
            time.sleep(0.005)  # 5ms - escape sequences arrive almost instantly
            next_ch = self.screen.getch()
            self.screen.nodelay(False)

            if next_ch == -1 or next_ch == curses.ERR:
                # Plain ESC - enter command mode (and exit spell mode)
                self._clear_spell_mode()
                self.command_mode = True
                self.command_buffer = ""
            elif next_ch == ord('['):
                # Could be bracketed paste or other escape sequence
                self.handle_escape_sequence()
            else:
                # Alt+key
                self.handle_alt_key(next_ch)
            return

        # Function keys
        if curses.KEY_F1 <= ch <= curses.KEY_F12:
            self.handle_function_key(ch)

        # Cursor movement
        elif ch == curses.KEY_UP:
            self.move_cursor(-1, 0)
        elif ch == curses.KEY_DOWN:
            self.move_cursor(1, 0)
        elif ch == curses.KEY_LEFT:
            self.move_cursor(0, -1)
        elif ch == curses.KEY_RIGHT:
            self.move_cursor(0, 1)
        elif ch == curses.KEY_HOME:
            self.cursor_col = 0
        elif ch == curses.KEY_END:
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
        elif ch == curses.KEY_PPAGE:  # Page Up
            self.move_cursor(-self.edit_height, 0)
        elif ch == curses.KEY_NPAGE:  # Page Down
            self.move_cursor(self.edit_height, 0)

        # Ctrl-Home: Top of file
        elif ch == 535 or ch == curses.KEY_HOME + 0x200:
            self.cursor_row = 0
            self.cursor_col = 0
        # Ctrl-End: Bottom of file
        elif ch == 530 or ch == curses.KEY_END + 0x200:
            self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
        # Ctrl-Left: Beginning of word to left
        elif ch == 545 or ch == curses.KEY_LEFT + 0x200:
            self.cursor_to_word_start()
        # Ctrl-Right: Beginning of word to right
        elif ch == 560 or ch == curses.KEY_RIGHT + 0x200:
            self.cursor_to_word_end()
        # Ctrl-PgUp: Top of screen
        elif ch == 555 or ch == curses.KEY_PPAGE + 0x200:
            self.cursor_row = self.top_row
        # Ctrl-PgDn: Bottom of screen
        elif ch == 550 or ch == curses.KEY_NPAGE + 0x200:
            self.cursor_row = min(self.top_row + self.edit_height - 1, len(self.buffer.lines) - 1)
        # Ctrl-Up: Previous bookmark
        elif ch == 566 or ch == curses.KEY_UP + 0x200:
            self._prev_bookmark()
        # Ctrl-Down: Next bookmark
        elif ch == 525 or ch == curses.KEY_DOWN + 0x200:
            self._next_bookmark()
        # Ctrl-Enter: Column 1 of next line
        elif ch == 529 or ch == 10 + 0x200:
            self.cursor_row = min(self.cursor_row + 1, len(self.buffer.lines) - 1)
            self.cursor_col = 0

        # Editing - regular backspace (127 only, not KEY_BACKSPACE which is Ctrl-H)
        elif ch == 127:
            if self.cursor_col > 0:
                # Deleting char on same line
                self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                      self.cursor_col, self.cursor_row, self.cursor_row)
            elif self.cursor_row > 0:
                # Joining with previous line
                self.buffer.push_undo(UndoType.LINE_JOIN, self.cursor_row - 1,
                                      len(self.buffer.get_line(self.cursor_row - 1)),
                                      self.cursor_row - 1, self.cursor_row)
            self.cursor_row, self.cursor_col = self.buffer.backspace(
                self.cursor_row, self.cursor_col
            )
        elif ch == curses.KEY_DC:  # Delete (forward delete)
            # Delete should NOT join lines - only delete char at cursor
            line = self.buffer.get_line(self.cursor_row)
            if self.cursor_col < len(line):
                self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                      self.cursor_col, self.cursor_row, self.cursor_row)
                self.buffer.delete_char(self.cursor_row, self.cursor_col)
            # At/past end of line: do nothing (don't join lines, don't delete line)
        # Ctrl-Delete: Delete current line (if terminal supports it)
        elif ch == 519 or ch == 527 or ch == curses.KEY_DC + 0x200:
            self._delete_current_line()
        elif ch == curses.KEY_ENTER or ch == 10 or ch == 13:
            self.buffer.push_undo(UndoType.LINE_SPLIT, self.cursor_row,
                                  self.cursor_col, self.cursor_row, self.cursor_row)
            self.buffer.split_line(self.cursor_row, self.cursor_col)
            self.cursor_row += 1
            self.cursor_col = 0
        elif ch == 9:  # Tab
            self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                  self.cursor_col, self.cursor_row, self.cursor_row)
            self.buffer.insert_char(self.cursor_row, self.cursor_col, "    ")
            self.cursor_col += 4

        # Ctrl-F: Repeat last search
        elif ch == 6:  # Ctrl-F
            self._save_jump_position()
            self.action_repeat_search()

        # Ctrl-B: Toggle bookmark on current line
        elif ch == 2:  # Ctrl-B
            if self.cursor_row in self.bookmarks:
                self.bookmarks.remove(self.cursor_row)
                self.message = f"Bookmark removed (L{self.cursor_row + 1})"
            else:
                self.bookmarks.add(self.cursor_row)
                self.message = f"Bookmark set (L{self.cursor_row + 1})"

        # Ctrl-N: Next file in edit ring
        elif ch == 14:  # Ctrl-N
            if len(self.buffers) > 1:
                self.switch_to_buffer((self.current_buffer_idx + 1) % len(self.buffers))
            else:
                self.message = "No other files"

        # Ctrl-P: Previous file in edit ring
        elif ch == 16:  # Ctrl-P
            if len(self.buffers) > 1:
                self.switch_to_buffer((self.current_buffer_idx - 1) % len(self.buffers))
            else:
                self.message = "No other files"

        # Ctrl-E: End of line (double: end of file, preserve column)
        elif ch == 5:  # Ctrl-E
            now = time.time()
            if self.last_ctrl_key == 5 and (now - self.last_ctrl_time) < 0.5:
                # Double Ctrl-E: end of file (restore original column)
                self._save_jump_position()
                self.cursor_row = len(self.buffer.lines) - 1
                self.cursor_col = self.last_ctrl_col  # Restore saved column
                self.scroll_to_cursor()
                self.last_ctrl_key = None
            else:
                # Single Ctrl-E: end of line (save column first)
                self.last_ctrl_col = self.cursor_col
                self.cursor_col = len(self.buffer.get_line(self.cursor_row))
                self.last_ctrl_key = 5
                self.last_ctrl_time = now

        # Ctrl-A: Start of line (double: top of file, preserve column)
        elif ch == 1:  # Ctrl-A
            now = time.time()
            if self.last_ctrl_key == 1 and (now - self.last_ctrl_time) < 0.5:
                # Double Ctrl-A: top of file (restore original column)
                self._save_jump_position()
                self.cursor_row = 0
                self.cursor_col = self.last_ctrl_col  # Restore saved column
                self.scroll_to_cursor()
                self.last_ctrl_key = None
            else:
                # Single Ctrl-A: start of line (save column first)
                self.last_ctrl_col = self.cursor_col
                self.cursor_col = 0
                self.last_ctrl_key = 1
                self.last_ctrl_time = now

        # Ctrl-Y or Ctrl-R: Redo
        elif ch == 25 or ch == 18:  # Ctrl-Y or Ctrl-R
            self.action_redo()

        # Ctrl-C: Copy to clipboard
        elif ch == 3:
            self.action_copy_to_clipboard()

        # Ctrl-K: Delete current line (also Ctrl+Delete in iTerm2)
        elif ch == 11:  # Ctrl-K
            self._delete_current_line()

        # Note: Ctrl-Z is NOT mapped - it sends SIGTSTP (suspend) in terminals

        # Printable characters
        elif 32 <= ch < 127:
            self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                  self.cursor_col, self.cursor_row, self.cursor_row)
            self.buffer.insert_char(self.cursor_row, self.cursor_col, chr(ch))
            self.cursor_col += 1

        # Unknown key - show code for debugging
        elif ch > 0:
            self.message = f"KEY: {ch} (0x{ch:x})"

        # Ensure cursor is visible
        self.scroll_to_cursor()

    def handle_function_key(self, ch: int) -> None:
        """Handle function keys F1-F12."""
        if ch == curses.KEY_F1:
            self.show_help()
        elif ch == curses.KEY_F2:
            self.action_save()
        elif ch == curses.KEY_F3:
            self.action_quit()
        elif ch == curses.KEY_F4:
            self.action_save_and_quit()
        elif ch == curses.KEY_F5:
            pass  # Not implemented
        elif ch == curses.KEY_F6:
            pass  # Draw options - not implemented
        elif ch == curses.KEY_F7:
            self.action_shift_mark_left()
        elif ch == curses.KEY_F8:
            self.action_shift_mark_right()
        elif ch == curses.KEY_F9:
            self.action_undo()
        elif ch == curses.KEY_F10:
            self.action_next_file()
        elif ch == curses.KEY_F11:
            self.action_prev_file()
        elif ch == curses.KEY_F12:
            pass  # Not implemented

    def handle_command_input(self, ch: int) -> None:
        """Handle input while in command mode."""
        if ch == 27:  # ESC - cancel command mode
            self.command_mode = False
            self.command_buffer = ""
        # Function keys work in command mode - exit and handle normally
        elif curses.KEY_F1 <= ch <= curses.KEY_F12:
            self.command_mode = False
            self.command_buffer = ""
            self.handle_function_key(ch)
        elif ch == curses.KEY_ENTER or ch == 10 or ch == 13:
            # Execute command
            self.execute_command(self.command_buffer)
            self.command_mode = False
            self.command_buffer = ""
        elif ch == curses.KEY_BACKSPACE or ch == 127 or ch == 8:
            if self.command_buffer:
                self.command_buffer = self.command_buffer[:-1]
            else:
                self.command_mode = False
        elif 32 <= ch < 127:
            self.command_buffer += chr(ch)

    def execute_command(self, cmd: str) -> None:
        """Execute a command line command."""
        cmd = cmd.strip()
        if not cmd:
            return

        # Line number - go to line
        if cmd.isdigit():
            line_num = int(cmd)
            if 1 <= line_num <= len(self.buffer.lines):
                self._save_jump_position()
                self.cursor_row = line_num - 1
                self.cursor_col = 0
                self.scroll_to_cursor()
                self.message = f"Line {line_num}"
            else:
                self.message = f"Line {line_num} out of range"
            return

        # Search: /pattern or /pattern/ or /pattern/c (case insensitive)
        if cmd.startswith('/'):
            self._save_jump_position()
            self._parse_and_search(cmd[1:])
            return

        # Change: C/old/new/ or C /old/new/
        if cmd.upper().startswith('C/') or cmd.upper().startswith('C /'):
            self.do_change_command(cmd)
            return

        # File commands
        cmd_upper = cmd.upper()
        if cmd_upper in ('Q', 'QUIT'):
            self.action_quit()
        elif cmd_upper in ('F', 'FILE'):
            self.action_save_and_quit()
        elif cmd_upper in ('S', 'SAVE'):
            self.action_save()
        elif cmd_upper.startswith('E ') or cmd_upper.startswith('EDIT '):
            filename = cmd.split(None, 1)[1] if ' ' in cmd else None
            if filename:
                self.open_file(filename)
        elif cmd_upper.startswith('N ') or cmd_upper.startswith('NAME '):
            filename = cmd.split(None, 1)[1] if ' ' in cmd else None
            if filename:
                self.buffer.filename = filename
                self.message = f"Filename set to {filename}"
        # Navigation commands
        elif cmd_upper in ('T', 'TOP'):
            self._save_jump_position()
            self.cursor_row = 0
            self.cursor_col = 0
            self.message = "Top of file"
        elif cmd_upper in ('B', 'BOT', 'BOTTOM'):
            self._save_jump_position()
            self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = 0
            self.message = "Bottom of file"
        # Spell checking
        elif cmd_upper in ('SPELL', 'SP'):
            self.toggle_spell_mode()
        # Sort command
        elif cmd_upper.startswith('SORT') or cmd_upper == 'SORT':
            self.do_sort_command(cmd)
        # Uniq command (accepts 'uniq' or 'unique')
        elif cmd_upper.startswith('UNIQ'):
            self.do_uniq_command(cmd)
        # Grep command
        elif cmd_upper.startswith('GREP '):
            self.do_grep_command(cmd)
        # Color scheme commands
        elif cmd_upper == 'LIGHT':
            self.set_color_scheme(light=True)
            self.message = "Light color scheme"
        elif cmd_upper == 'DARK':
            self.set_color_scheme(light=False)
            self.message = "Dark color scheme"
        # Encryption commands
        elif cmd_upper in ('ENCRYPT', 'AGE'):
            self.do_encrypt_command()
        elif cmd_upper in ('PASSWD', 'PASSWORD'):
            self.do_passwd_command()
        else:
            self.message = f"Unknown command: {cmd}"

    def search_forward(self, pattern: str, case_ignore: bool = False) -> bool:
        """Search forward for pattern starting from cursor+1. Returns True if found."""
        if not pattern:
            self.message = "No search pattern"
            return False

        # Store for Ctrl-F repeat
        self.search_term = pattern
        self.search_case_ignore = case_ignore

        return self._do_search()

    def _do_search(self) -> bool:
        """Execute search using stored search_term. Returns True if found."""
        if not self.search_term:
            self.message = "No search in progress"
            return False

        pattern = self.search_term
        # Start searching from current position +1 (so we don't get stuck)
        start_row = self.cursor_row
        start_col = self.cursor_col + 1

        # Prepare for case-insensitive search
        if self.search_case_ignore:
            search_pattern = pattern.lower()
        else:
            search_pattern = pattern

        for row in range(start_row, len(self.buffer.lines)):
            line = self.buffer.get_line(row)
            search_line = line.lower() if self.search_case_ignore else line
            col = start_col if row == start_row else 0
            pos = search_line.find(search_pattern, col)
            if pos != -1:
                self.cursor_row = row
                self.cursor_col = pos
                self.scroll_to_cursor()
                self.message = f"Found: {pattern}"
                return True

        # Wrap around from beginning
        for row in range(0, start_row + 1):
            line = self.buffer.get_line(row)
            search_line = line.lower() if self.search_case_ignore else line
            end_col = start_col if row == start_row else len(line)
            pos = search_line.find(search_pattern)
            if pos != -1 and (row < start_row or pos < start_col):
                self.cursor_row = row
                self.cursor_col = pos
                self.scroll_to_cursor()
                self.message = f"Found (wrapped): {pattern}"
                return True

        self.message = f"Not found: {pattern}"
        return False

    def action_repeat_search(self) -> None:
        """Repeat last search (Ctrl-F)."""
        if not self.search_term:
            self.message = "No search in progress"
            return
        self._do_search()

    def _parse_and_search(self, s: str) -> None:
        """Parse search string: pattern or pattern/ or pattern/c"""
        case_ignore = False

        # Check for trailing /c or /C (case insensitive)
        if len(s) >= 2 and s[-1].lower() == 'c' and s[-2] == '/':
            case_ignore = True
            s = s[:-2]
        # Strip trailing slash
        elif s.endswith('/'):
            s = s[:-1]

        if not s:
            self.message = "Search string cleared"
            self.search_term = None
            return

        self.message = f"Search: \"{s}\""
        self.search_forward(s, case_ignore)

    def do_sort_command(self, cmd: str) -> None:
        """Execute sort command on marked lines or entire file.

        Options: -r (reverse), -n (numeric), -f (case-insensitive), -kN (field N)

        - No mark: sort entire file
        - LINE mark: sort marked lines
        - BLOCK mark: sort lines using block columns as sort key
        """
        # Parse sort options
        args = cmd[4:].strip()  # Remove 'sort' prefix
        reverse = '-r' in args
        numeric = '-n' in args
        fold_case = '-f' in args

        # Parse -k options (field numbers, 1-based) - can have multiple
        import re
        field_nums = [int(m.group(1)) for m in re.finditer(r'-k(\d+)', args)]

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            # No mark: sort entire file
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # Get lines to sort
        lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        # Build sort key function
        if self.mark and self.mark.mode == MarkMode.BLOCK:
            # Block mark: sort by block columns
            start_col = min(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)
            end_col = max(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)

            def get_key(line):
                # Extract block region (pad if line is too short)
                key = line[start_col:end_col + 1] if len(line) > start_col else ""
                if fold_case:
                    key = key.lower()
                if numeric:
                    try:
                        return float(key.strip())
                    except ValueError:
                        return float('-inf') if not reverse else float('inf')
                return key
        elif field_nums:
            # Sort by field number(s) (1-based, whitespace-delimited)
            def get_key(line):
                fields = line.split()
                keys = []
                for field_num in field_nums:
                    if field_num <= len(fields):
                        key = fields[field_num - 1]
                    else:
                        key = ""
                    if fold_case:
                        key = key.lower()
                    if numeric:
                        try:
                            keys.append(float(key.strip()))
                        except ValueError:
                            keys.append(float('-inf') if not reverse else float('inf'))
                    else:
                        keys.append(key)
                return tuple(keys) if len(keys) > 1 else keys[0] if keys else ""
        else:
            # Full line sort
            def get_key(line):
                key = line
                if fold_case:
                    key = key.lower()
                if numeric:
                    try:
                        return float(key.strip())
                    except ValueError:
                        return float('-inf') if not reverse else float('inf')
                return key

        # Sort the lines
        try:
            sorted_lines = sorted(lines, key=get_key, reverse=reverse)
        except Exception as e:
            self.message = f"Sort error: {e}"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Replace lines
        for i, row in enumerate(range(start_row, end_row + 1)):
            if i < len(sorted_lines):
                self.buffer.set_line(row, sorted_lines[i])

        self.buffer.modified = True
        self.mark = None  # Clear mark after sort
        count = end_row - start_row + 1
        self.message = f"Sorted {count} line{'s' if count != 1 else ''}"

    def do_uniq_command(self, cmd: str) -> None:
        """Remove adjacent duplicate lines.

        Options: -i (case-insensitive)

        - No mark: uniq entire file
        - LINE mark: uniq marked lines only
        - BLOCK mark: uniq lines in block range
        """
        # Parse options - handle both 'uniq' and 'unique'
        if cmd.upper().startswith('UNIQUE'):
            args = cmd[6:].strip()
        else:
            args = cmd[4:].strip()  # 'uniq'
        case_insensitive = '-i' in args

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            # No mark: uniq entire file
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # Get lines
        lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        if not lines:
            self.message = "No lines to process"
            return

        # Remove adjacent duplicates
        result = [lines[0]]
        for line in lines[1:]:
            prev = result[-1]
            if case_insensitive:
                is_dup = line.lower() == prev.lower()
            else:
                is_dup = line == prev
            if not is_dup:
                result.append(line)

        removed = len(lines) - len(result)
        if removed == 0:
            self.message = "No duplicates found"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Delete the original lines and insert the unique ones
        # First, remove all lines in range
        for _ in range(start_row, end_row + 1):
            if start_row < len(self.buffer.lines):
                del self.buffer.lines[start_row]

        # Insert unique lines
        for i, line in enumerate(result):
            self.buffer.lines.insert(start_row + i, line)

        # Ensure at least one line exists
        if not self.buffer.lines:
            self.buffer.lines = [""]

        self.buffer.modified = True
        self.mark = None  # Clear mark after uniq
        self.message = f"Removed {removed} duplicate{'s' if removed != 1 else ''}"

    def do_grep_command(self, cmd: str) -> None:
        """Filter lines using grep (keeps matching lines, deletes non-matching).

        Options: -v (inverse), -i (case-insensitive)

        - No mark: filter entire file
        - LINE mark: filter only marked lines
        - BLOCK mark: search within block columns, but keep/delete entire lines
        """
        import subprocess
        import shlex

        # Parse command: grep [-v] [-i] pattern
        # Remove 'grep' prefix
        args_str = cmd[4:].strip()  # Remove 'grep'

        if not args_str:
            self.message = "Usage: grep [-v] [-i] pattern"
            return

        # Parse options and pattern
        inverse = False
        case_insensitive = False

        parts = args_str.split()
        pattern = None

        for part in parts:
            if part == '-v':
                inverse = True
            elif part == '-i':
                case_insensitive = True
            elif part == '-iv' or part == '-vi':
                inverse = True
                case_insensitive = True
            elif pattern is None:
                pattern = part
            else:
                # Pattern with spaces - rejoin remaining
                idx = args_str.find(part)
                pattern = args_str[idx:]
                break

        if not pattern:
            self.message = "Usage: grep [-v] [-i] pattern"
            return

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # For BLOCK mark, determine column range
        block_mode = self.mark and self.mark.mode == MarkMode.BLOCK
        if block_mode:
            start_col = min(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)
            end_col = max(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)

        # Get lines and prepare search text
        original_lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        if block_mode:
            # Extract only block columns for searching
            search_lines = []
            for line in original_lines:
                if len(line) > start_col:
                    search_lines.append(line[start_col:end_col + 1])
                else:
                    search_lines.append("")
        else:
            search_lines = original_lines

        # Build grep command
        grep_cmd = ['grep', '-n']  # -n for line numbers
        if case_insensitive:
            grep_cmd.append('-i')
        grep_cmd.append('--')  # End of options
        grep_cmd.append(pattern)

        # Run grep with search lines as input
        try:
            proc = subprocess.run(
                grep_cmd,
                input='\n'.join(search_lines),
                capture_output=True,
                text=True,
                timeout=10
            )
        except subprocess.TimeoutExpired:
            self.message = "Grep timed out"
            return
        except FileNotFoundError:
            self.message = "grep command not found"
            return
        except Exception as e:
            self.message = f"Grep error: {e}"
            return

        # Parse matching line numbers from grep output (format: "N:content")
        matching_indices = set()
        for line in proc.stdout.splitlines():
            if ':' in line:
                try:
                    line_num = int(line.split(':')[0])
                    matching_indices.add(line_num - 1)  # grep is 1-based
                except ValueError:
                    pass

        # Check for no matches (in normal mode, don't delete everything)
        if not matching_indices and not inverse:
            self.message = "No matches found"
            return

        # Check for all matches (in inverse mode, don't delete everything)
        if len(matching_indices) == len(original_lines) and inverse:
            self.message = "All lines match - nothing to keep"
            return

        # Determine which lines to keep
        if inverse:
            # Keep lines that DON'T match
            keep_indices = [i for i in range(len(original_lines)) if i not in matching_indices]
        else:
            # Keep lines that DO match
            keep_indices = [i for i in range(len(original_lines)) if i in matching_indices]

        kept_lines = [original_lines[i] for i in keep_indices]
        removed = len(original_lines) - len(kept_lines)

        if removed == 0:
            self.message = "No changes needed"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Delete the original lines and insert the kept ones
        for _ in range(start_row, end_row + 1):
            if start_row < len(self.buffer.lines):
                del self.buffer.lines[start_row]

        # Insert kept lines
        for i, line in enumerate(kept_lines):
            self.buffer.lines.insert(start_row + i, line)

        # Ensure at least one line exists
        if not self.buffer.lines:
            self.buffer.lines = [""]

        self.buffer.modified = True
        self.mark = None
        kept = len(kept_lines)
        self.message = f"Kept {kept} line{'s' if kept != 1 else ''}, removed {removed}"

    def do_encrypt_command(self) -> None:
        """Encrypt the current buffer with a passphrase."""
        if not AGE_AVAILABLE:
            self.message = "Error: 'age' not installed. Run: brew install age"
            return

        if self.buffer.encrypted:
            self.message = "Already encrypted"
            return

        # Need to prompt for passphrase - exit curses temporarily
        curses.endwin()
        try:
            passphrase = getpass.getpass("New passphrase: ")
            if not passphrase:
                print("Cancelled - empty passphrase")
                return
            confirm = getpass.getpass("Confirm passphrase: ")
            if passphrase != confirm:
                print("Passphrases don't match")
                return
        except (EOFError, KeyboardInterrupt):
            print("\nCancelled")
            return
        finally:
            # Restore curses
            self.screen = curses.initscr()
            curses.noecho()
            curses.cbreak()
            self.screen.keypad(True)
            curses.curs_set(1)
            try:
                curses.set_escdelay(25)
            except AttributeError:
                pass
            curses.start_color()
            curses.use_default_colors()
            self._init_colors()
            self.screen.clear()

        # Mark as encrypted (keep original filename - detected by magic bytes)
        self.buffer.encrypted = True
        self.buffer.passphrase = passphrase
        self.buffer.modified = True
        self.message = f"Encrypted. Save to apply."

    def do_passwd_command(self) -> None:
        """Change the passphrase for an encrypted buffer."""
        if not self.buffer.encrypted:
            self.message = "File is not encrypted"
            return

        # Need to prompt for passphrase - exit curses temporarily
        curses.endwin()
        try:
            old_pass = getpass.getpass("Current passphrase: ")
            if old_pass != self.buffer.passphrase:
                print("Wrong passphrase")
                return
            new_pass = getpass.getpass("New passphrase: ")
            if not new_pass:
                print("Cancelled - empty passphrase")
                return
            confirm = getpass.getpass("Confirm new passphrase: ")
            if new_pass != confirm:
                print("Passphrases don't match")
                return
        except (EOFError, KeyboardInterrupt):
            print("\nCancelled")
            return
        finally:
            # Restore curses
            self.screen = curses.initscr()
            curses.noecho()
            curses.cbreak()
            self.screen.keypad(True)
            curses.curs_set(1)
            try:
                curses.set_escdelay(25)
            except AttributeError:
                pass
            curses.start_color()
            curses.use_default_colors()
            self._init_colors()
            self.screen.clear()

        self.buffer.passphrase = new_pass
        self.buffer.modified = True
        self.message = "Passphrase changed. Save to apply."

    def do_change_command(self, cmd: str) -> None:
        """Execute change (search & replace) command: c/old/new/[flags]"""
        # Find the delimiter (first char after 'c')
        if len(cmd) < 2:
            self.message = "Usage: c/old/new/[*mc]"
            return

        # Skip the 'c' or 'C'
        rest = cmd[1:]
        if not rest:
            self.message = "Usage: c/old/new/[*mc]"
            return

        delim = rest[0]
        parts = rest[1:].split(delim)

        if len(parts) < 2:
            self.message = "Usage: c/old/new/[*mc]"
            return

        old = parts[0]
        new = parts[1]
        flags = parts[2].lower() if len(parts) > 2 else ""

        if not old:
            self.message = "Can't change nothing to something"
            return

        # Parse flags
        do_all = '*' in flags
        do_mark = 'm' in flags
        case_ignore = 'c' in flags

        # Store search term for Ctrl-F
        self.search_term = old
        self.search_case_ignore = case_ignore

        # Save current mark
        saved_mark = self.mark

        # Determine search range
        if do_mark and saved_mark:
            start_row = saved_mark.start_row
            end_row = saved_mark.end_row + 1
        else:
            start_row = self.cursor_row
            end_row = len(self.buffer.lines)

        replaced_count = 0
        found_count = 0
        finished = False
        row = start_row
        col = self.cursor_col
        last_change_row = self.cursor_row
        last_change_col = self.cursor_col

        while not finished and row < end_row:
            line = self.buffer.get_line(row)

            # Find occurrence
            if case_ignore:
                search_line = line.lower()
                search_old = old.lower()
            else:
                search_line = line
                search_old = old

            pos = search_line.find(search_old, col)

            if pos == -1:
                # Move to next line
                row += 1
                col = 0
                continue

            found_count += 1

            # Move cursor to found position
            self.cursor_row = row
            self.cursor_col = pos
            self.scroll_to_cursor()

            if do_all:
                # Replace without asking
                self.buffer.push_undo(UndoType.LINE_CHANGE, row, pos, row, row)
                line = line[:pos] + new + line[pos + len(old):]
                self.buffer.set_line(row, line)
                replaced_count += 1
                # Update cursor to end of replacement
                self.cursor_col = pos + len(new)
                last_change_row = row
                last_change_col = pos
                col = pos + len(new)
                # Redraw to show progress
                self.message = f"Changing... ({replaced_count} so far)"
                self.draw()
                self.screen.refresh()
            else:
                # Interactive mode - highlight and ask
                # Create temporary block mark on the found text
                self.mark = Mark(
                    mode=MarkMode.BLOCK,
                    start_row=row,
                    start_col=pos,
                    end_row=row,
                    end_col=pos + len(old) - 1,
                    pending=False
                )

                # Show prompt and redraw
                self.message = f"Change \"{old}\" to \"{new}\"? (Y/N/Q/G)"
                self.draw()
                self.screen.refresh()

                # Wait for y/n/q/g
                while True:
                    ch = self.screen.getch()
                    if ch in (ord('y'), ord('Y')):
                        # Replace this one
                        self.buffer.push_undo(UndoType.LINE_CHANGE, row, pos, row, row)
                        line = line[:pos] + new + line[pos + len(old):]
                        self.buffer.set_line(row, line)
                        replaced_count += 1
                        self.cursor_col = pos
                        last_change_row = row
                        last_change_col = pos
                        col = pos + len(new)
                        break
                    elif ch in (ord('n'), ord('N')):
                        # Skip this one
                        col = pos + len(old)
                        break
                    elif ch in (ord('q'), ord('Q'), 27):  # Q or ESC
                        # Quit
                        finished = True
                        break
                    elif ch in (ord('g'), ord('G')):
                        # Go - do this one and all remaining
                        self.buffer.push_undo(UndoType.LINE_CHANGE, row, pos, row, row)
                        line = line[:pos] + new + line[pos + len(old):]
                        self.buffer.set_line(row, line)
                        replaced_count += 1
                        self.cursor_col = pos
                        last_change_row = row
                        last_change_col = pos
                        col = pos + len(new)
                        do_all = True
                        break
                    else:
                        # Invalid key - beep
                        curses.beep()

        # Restore mark
        self.mark = saved_mark

        # Leave cursor at last change position
        if replaced_count > 0:
            self.cursor_row = last_change_row
            self.cursor_col = last_change_col
            self.scroll_to_cursor()

        if replaced_count > 0:
            self.message = f"Changed {replaced_count} of {found_count} occurrence(s)"
        else:
            self.message = f"Not found: {old}"

    def handle_escape_sequence(self) -> None:
        """Handle escape sequences starting with ESC [."""
        # Read more characters to determine sequence type
        self.screen.nodelay(True)
        time.sleep(0.005)  # Brief wait for rest of sequence
        seq = ""
        while True:
            ch = self.screen.getch()
            if ch == -1:
                break
            seq += chr(ch)
            # Check for bracketed paste start: "200~"
            if seq == "200~":
                self.screen.nodelay(False)
                self.handle_bracketed_paste()
                return
            # Don't read forever
            if len(seq) > 10:
                break
        self.screen.nodelay(False)

        # Handle Shift+Arrow keys: ESC[1;2A/B/C/D
        if seq == "1;2D":  # Shift-Left: start of line
            self.cursor_col = 0
        elif seq == "1;2C":  # Shift-Right: end of line
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
        elif seq == "1;2A":  # Shift-Up: top of file
            self._save_jump_position()
            self.cursor_row = 0
            self.cursor_col = 0
            self.scroll_to_cursor()
        elif seq == "1;2B":  # Shift-Down: end of file
            self._save_jump_position()
            self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
            self.scroll_to_cursor()
        # Handle Alt+Arrow keys: ESC[1;3A/B/C/D (same functions)
        elif seq == "1;3D":  # Alt-Left: start of line
            self.cursor_col = 0
        elif seq == "1;3C":  # Alt-Right: end of line
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
        elif seq == "1;3A":  # Alt-Up: top of file
            self._save_jump_position()
            self.cursor_row = 0
            self.cursor_col = 0
            self.scroll_to_cursor()
        elif seq == "1;3B":  # Alt-Down: end of file
            self._save_jump_position()
            self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
            self.scroll_to_cursor()
        # Ctrl+Delete: ESC[3;5~ (modifier 5 = Ctrl)
        elif seq == "3;5~":
            self._delete_current_line()
        # Ctrl+Backspace - various terminal formats:
        # modifyOtherKeys: ESC[27;5;127~  (27=ESC, 5=Ctrl, 127=BS)
        # CSI u / kitty: ESC[127;5u
        # Some terminals: ESC[127;5~
        elif seq in ("27;5;127~", "127;5u", "127;5~"):
            self._delete_current_line()
        # Ctrl+Delete alternative formats
        elif seq in ("27;5;51~", "51;5~"):  # 51 = '3' ASCII
            self._delete_current_line()
        else:
            # Show unknown sequences for debugging
            log.debug(f"Unknown escape sequence: ESC[{seq}")
            self.message = f"ESC[{seq}"

    def _delete_current_line(self) -> None:
        """Delete the current line."""
        if len(self.buffer.lines) > 1 or self.buffer.get_line(0):
            saved_col = self.cursor_col
            self.buffer.push_undo(UndoType.LINE_DELETE, self.cursor_row,
                                  saved_col, self.cursor_row, self.cursor_row)
            del self.buffer.lines[self.cursor_row]
            if not self.buffer.lines:
                self.buffer.lines = [""]
            self.cursor_row = min(self.cursor_row, len(self.buffer.lines) - 1)
            self.cursor_col = saved_col
            self.message = "Line deleted"

    def handle_bracketed_paste(self) -> None:
        """Handle bracketed paste - collect text until end sequence."""
        paste_text = ""
        end_seq = "\033[201~"
        buffer = ""

        while True:
            ch = self.screen.getch()
            if ch == -1:
                continue
            if ch == 27:  # ESC - might be start of end sequence
                buffer = chr(ch)
            elif buffer:
                buffer += chr(ch)
                if buffer == end_seq:
                    # End of paste
                    break
                elif not end_seq.startswith(buffer):
                    # Not end sequence, add to paste text
                    paste_text += buffer
                    buffer = ""
            else:
                paste_text += chr(ch)

        # Insert pasted text at cursor
        if paste_text:
            self.insert_pasted_text(paste_text)

    def insert_pasted_text(self, text: str) -> None:
        """Insert pasted text at cursor position."""
        lines = text.split('\n')

        if len(lines) == 1:
            # Single line paste - insert at cursor
            self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                  self.cursor_col, self.cursor_row, self.cursor_row)
            line = self.buffer.get_line(self.cursor_row)
            if len(line) < self.cursor_col:
                line = line.ljust(self.cursor_col)
            new_line = line[:self.cursor_col] + lines[0] + line[self.cursor_col:]
            self.buffer.set_line(self.cursor_row, new_line)
            self.cursor_col += len(lines[0])
        else:
            # Multi-line paste - save current line for undo
            self.buffer.push_undo(UndoType.LINE_CHANGE, self.cursor_row,
                                  self.cursor_col, self.cursor_row, self.cursor_row)
            first_line = self.buffer.get_line(self.cursor_row)
            if len(first_line) < self.cursor_col:
                first_line = first_line.ljust(self.cursor_col)
            before = first_line[:self.cursor_col]
            after = first_line[self.cursor_col:]

            # First line: before + first pasted line
            self.buffer.set_line(self.cursor_row, before + lines[0])

            # Middle lines: insert as new lines
            for i, pasted_line in enumerate(lines[1:-1], 1):
                self.buffer.lines.insert(self.cursor_row + i, pasted_line)

            # Last line: last pasted line + after
            last_idx = self.cursor_row + len(lines) - 1
            self.buffer.lines.insert(last_idx, lines[-1] + after)

            # Move cursor to end of pasted text
            self.cursor_row = last_idx
            self.cursor_col = len(lines[-1])

        self.buffer.modified = True
        self.message = f"Pasted {len(lines)} line(s)"

    def handle_alt_key(self, ch: int) -> None:
        """Handle Alt+key combinations."""
        key = chr(ch).lower() if 32 <= ch < 127 else ""

        if key == 'l':
            self.action_mark_line()
        elif key == 'z':
            self.action_mark_char()
        elif key == 'b':
            self.action_mark_block()
        elif key == 'w':
            self.action_mark_word()
        elif key == 'u':
            self.action_unmark()
        elif key == 'c':
            self.action_copy()
        elif key == 'm':
            self.action_move()
        elif key == 'd':
            self.action_delete()
        elif key == 'y':
            self.action_goto_mark_start()
        elif key == 'e':
            self.action_goto_mark_end()
        elif key == 'o':
            self.action_overlay()
        elif key == 'v':
            self.message = "Alt-V not used in E3"
        elif key == 'f':
            self.action_fill_mark()
        elif key == 'k':
            self.action_copy_to_clipboard()
        elif key == 's':
            self.action_split_line()
        elif key == 'j':
            self.action_join_line()
        elif key == 'n':
            self.action_next_file()
        elif key == 'p':
            self.action_prev_file()
        else:
            self.message = f"Unknown Alt-{key.upper()}"

    def move_cursor(self, row_delta: int, col_delta: int) -> None:
        """Move cursor by delta. Cursor can move anywhere in 2D space."""
        self.cursor_row = max(0, min(self.cursor_row + row_delta, len(self.buffer.lines) - 1))
        # Column is NOT bounded by line length - can move past end of line
        self.cursor_col = max(0, self.cursor_col + col_delta)

    def cursor_to_word_start(self) -> None:
        """Move cursor to beginning of word to the left (Ctrl-Left)."""
        line = self.buffer.get_line(self.cursor_row)
        col = self.cursor_col

        # If at start of line, go to end of previous line
        if col == 0:
            if self.cursor_row > 0:
                self.cursor_row -= 1
                self.cursor_col = len(self.buffer.get_line(self.cursor_row))
            return

        # Move left, skipping any spaces
        col -= 1
        while col > 0 and (col >= len(line) or not line[col].isalnum()):
            col -= 1

        # Move to start of word
        while col > 0 and col <= len(line) and line[col - 1].isalnum():
            col -= 1

        self.cursor_col = col

    def cursor_to_word_end(self) -> None:
        """Move cursor to beginning of next word (Ctrl-Right)."""
        line = self.buffer.get_line(self.cursor_row)
        col = self.cursor_col
        line_len = len(line)

        # If at or past end of line, go to start of next line
        if col >= line_len:
            if self.cursor_row < len(self.buffer.lines) - 1:
                self.cursor_row += 1
                self.cursor_col = 0
            return

        # Skip current word (alphanumeric chars)
        while col < line_len and line[col].isalnum():
            col += 1

        # Skip spaces/punctuation to next word
        while col < line_len and not line[col].isalnum():
            col += 1

        self.cursor_col = col

    def scroll_to_cursor(self) -> None:
        """Ensure cursor is visible on screen."""
        # Vertical scroll
        if self.cursor_row < self.top_row:
            self.top_row = self.cursor_row
        elif self.cursor_row >= self.top_row + self.edit_height:
            self.top_row = self.cursor_row - self.edit_height + 1

        # Horizontal scroll
        if self.cursor_col < self.left_col:
            self.left_col = self.cursor_col
        elif self.cursor_col >= self.left_col + self.width:
            self.left_col = self.cursor_col - self.width + 1

    # === Mark actions ===

    def action_mark_line(self) -> None:
        """Mark line - E3 style: extends/trims on subsequent presses."""
        if self.mark and self.mark.mode == MarkMode.LINE:
            # Extend or trim existing line mark
            curl = self.cursor_row
            if curl >= self.mark.start_row and curl <= self.mark.end_row:
                # Cursor inside mark - trim to cursor
                self.mark.end_row = curl
            elif curl < self.mark.start_row:
                # Extend toward top
                self.mark.start_row = curl
            else:
                # Extend toward bottom
                self.mark.end_row = curl
            self.mark.pending = False
            start = min(self.mark.start_row, self.mark.end_row) + 1
            end = max(self.mark.start_row, self.mark.end_row) + 1
            self.message = f"Lines {start}-{end} marked"
        else:
            # Start new line mark (unmark any existing block mark)
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=self.cursor_row,
                start_col=0,
                end_row=self.cursor_row,
                end_col=0,
                pending=False
            )
            self.message = f"Line {self.cursor_row + 1} marked"

    def action_mark_char(self) -> None:
        """Mark characters - extends on subsequent presses."""
        if self.mark and self.mark.mode == MarkMode.CHAR:
            # Extend character mark to current position
            self.mark.end_row = self.cursor_row
            self.mark.end_col = self.cursor_col
            self.mark.pending = False
            self.message = "Character mark extended"
        else:
            # Start new character mark
            self.mark = Mark(
                mode=MarkMode.CHAR,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row,
                end_col=self.cursor_col,
                pending=False
            )
            self.message = f"Char mark at ({self.cursor_row + 1}, {self.cursor_col + 1})"

    def action_mark_block(self) -> None:
        """Mark block - E3 style: extends/trims on subsequent presses."""
        if self.mark and self.mark.mode == MarkMode.BLOCK:
            # Extend or trim existing block mark
            curl = self.cursor_row
            curc = self.cursor_col

            # Rows: extend or trim
            if curl >= self.mark.start_row and curl <= self.mark.end_row:
                self.mark.end_row = curl
            elif curl < self.mark.start_row:
                self.mark.start_row = curl
            else:
                self.mark.end_row = curl

            # Cols: extend or trim
            if curc >= self.mark.start_col and curc <= (self.mark.end_col or self.mark.start_col):
                self.mark.end_col = curc
            elif curc < self.mark.start_col:
                self.mark.start_col = curc
            else:
                self.mark.end_col = curc

            self.mark.pending = False
            self.message = "Block mark adjusted"
        else:
            # Start new block mark (unmark any existing line mark)
            self.mark = Mark(
                mode=MarkMode.BLOCK,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row,
                end_col=self.cursor_col,
                pending=False
            )
            self.message = f"Block mark at ({self.cursor_row + 1}, {self.cursor_col + 1})"

    def action_mark_word(self) -> None:
        """Mark word at cursor."""
        line = self.buffer.get_line(self.cursor_row)
        if not line or self.cursor_col >= len(line):
            self.message = "No word at cursor"
            return

        # Find word boundaries
        col = self.cursor_col
        if not line[col].isalnum() and line[col] != '_':
            self.message = "No word at cursor"
            return

        start = col
        while start > 0 and (line[start - 1].isalnum() or line[start - 1] == '_'):
            start -= 1
        end = col
        while end < len(line) - 1 and (line[end + 1].isalnum() or line[end + 1] == '_'):
            end += 1

        self.mark = Mark(
            mode=MarkMode.CHAR,
            start_row=self.cursor_row,
            start_col=start,
            end_row=self.cursor_row,
            end_col=end,
            pending=False
        )
        self.message = f"Word marked: '{line[start:end + 1]}'"

    def action_unmark(self) -> None:
        """Clear current mark."""
        self.mark = None
        self.message = "Mark cleared"

    def action_copy(self) -> None:
        """Copy marked region to cursor position (E3 style - new mark at destination)."""
        if not self.mark:
            self.message = "No mark to copy"
            return

        if self.mark.mode not in (MarkMode.LINE, MarkMode.BLOCK):
            self.message = "Copy works with line or block marks"
            return

        # Get marked text
        text_lines = self.get_marked_text()
        m_lines = len(text_lines)

        if self.mark.mode == MarkMode.LINE:
            # Line copy: insert AFTER current line
            insert_row = self.cursor_row + 1
            # Save empty entry for undo - will delete inserted lines on undo
            self.buffer.push_undo(UndoType.LINE_INSERT, insert_row, 0, insert_row,
                                  insert_row + m_lines - 1)
            self.buffer.insert_lines(insert_row, text_lines)

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=insert_row,
                start_col=0,
                end_row=insert_row + m_lines - 1,
                end_col=0,
                pending=False
            )
            self.message = f"Copied {m_lines} line(s) after line {self.cursor_row + 1}"

        elif self.mark.mode == MarkMode.BLOCK:
            m_width = len(text_lines[0]) if text_lines else 0
            orig_line_count = len(self.buffer.lines)
            dest_end_row = self.cursor_row + m_lines - 1

            # Only save BLOCK_CHANGE for lines that already exist
            if self.cursor_row < orig_line_count:
                save_end = min(dest_end_row, orig_line_count - 1)
                self.buffer.push_undo(UndoType.BLOCK_CHANGE, self.cursor_row,
                                      self.cursor_col, self.cursor_row, save_end)

            # Block copy: insert at cursor position
            self.insert_text_at_cursor(text_lines, MarkMode.BLOCK)

            # If lines were added past EOF, push LINE_INSERT to delete them on undo
            lines_added = len(self.buffer.lines) - orig_line_count
            if lines_added > 0:
                self.buffer.push_undo(UndoType.LINE_INSERT, orig_line_count, 0,
                                      orig_line_count, orig_line_count + lines_added - 1)

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.BLOCK,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row + m_lines - 1,
                end_col=self.cursor_col + m_width - 1,
                pending=False
            )
            self.message = f"Copied block to ({self.cursor_row + 1}, {self.cursor_col + 1})"

    def action_overlay(self) -> None:
        """Overlay marked block at cursor position (replace, not insert). Block marks only."""
        if not self.mark:
            self.message = "No mark to overlay"
            return

        if self.mark.mode != MarkMode.BLOCK:
            self.message = "Can't overlay a line mark - use block mark"
            return

        # Get marked text
        text_lines = self.get_marked_text()
        m_lines = len(text_lines)
        m_width = len(text_lines[0]) if text_lines else 0
        orig_line_count = len(self.buffer.lines)
        dest_end_row = self.cursor_row + m_lines - 1

        # Only save BLOCK_CHANGE for lines that already exist
        if self.cursor_row < orig_line_count:
            save_end = min(dest_end_row, orig_line_count - 1)
            self.buffer.push_undo(UndoType.BLOCK_CHANGE, self.cursor_row,
                                  self.cursor_col, self.cursor_row, save_end)

        # Overlay at cursor position
        self.overlay_text_at_cursor(text_lines, m_width)

        # If lines were added past EOF, push LINE_INSERT to delete them on undo
        lines_added = len(self.buffer.lines) - orig_line_count
        if lines_added > 0:
            self.buffer.push_undo(UndoType.LINE_INSERT, orig_line_count, 0,
                                  orig_line_count, orig_line_count + lines_added - 1)

        # Create new mark at destination
        self.mark = Mark(
            mode=MarkMode.BLOCK,
            start_row=self.cursor_row,
            start_col=self.cursor_col,
            end_row=self.cursor_row + m_lines - 1,
            end_col=self.cursor_col + m_width - 1,
            pending=False
        )
        self.message = f"Overlaid block at ({self.cursor_row + 1}, {self.cursor_col + 1})"

    def action_delete(self) -> None:
        """Delete marked region."""
        if not self.mark:
            self.message = "No mark to delete"
            return

        # Save for undo
        start_row = min(self.mark.start_row, self.mark.end_row)
        end_row = max(self.mark.start_row, self.mark.end_row)
        if self.mark.mode == MarkMode.LINE:
            self.buffer.push_undo(UndoType.LINE_DELETE, start_row,
                                  0, start_row, end_row)
        else:
            self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row,
                                  self.cursor_col, start_row, end_row)

        self.delete_marked_region()
        self.mark = None
        self.message = "Deleted marked region"

    def action_shift_mark_left(self) -> None:
        """Shift marked block/lines left by deleting first char (F7)."""
        log.debug(f"action_shift_mark_left called, mark={self.mark}")
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode not in (MarkMode.LINE, MarkMode.BLOCK):
            self.message = "Shift only works with line or block marks"
            return

        start_row = self.mark.start_row
        end_row = self.mark.end_row
        log.debug(f"Shifting rows {start_row} to {end_row}")

        # Save for undo
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row,
                              self.cursor_col, start_row, end_row)

        # For LINE marks, shift from column 0; for BLOCK, from mark start col
        start_col = 0 if self.mark.mode == MarkMode.LINE else self.mark.start_col

        for row in range(start_row, end_row + 1):
            line = self.buffer.get_line(row)
            if len(line) > start_col:
                # Delete char at start_col
                new_line = line[:start_col] + line[start_col + 1:]
                self.buffer.set_line(row, new_line)

        # Adjust mark end column for block marks
        if self.mark.mode == MarkMode.BLOCK and self.mark.end_col > self.mark.start_col:
            self.mark.end_col -= 1

        self.message = "Shifted left"

    def action_shift_mark_right(self) -> None:
        """Shift marked block/lines right by inserting space (F8)."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode not in (MarkMode.LINE, MarkMode.BLOCK):
            self.message = "Shift only works with line or block marks"
            return

        start_row = self.mark.start_row
        end_row = self.mark.end_row

        # Save for undo
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row,
                              self.cursor_col, start_row, end_row)

        # For LINE marks, insert at column 0; for BLOCK, at mark start col
        start_col = 0 if self.mark.mode == MarkMode.LINE else self.mark.start_col

        for row in range(start_row, end_row + 1):
            line = self.buffer.get_line(row)
            # Pad if needed
            if len(line) < start_col:
                line = line.ljust(start_col)
            # Insert space at start_col
            new_line = line[:start_col] + ' ' + line[start_col:]
            self.buffer.set_line(row, new_line)

        # Adjust mark end column for block marks
        if self.mark.mode == MarkMode.BLOCK:
            self.mark.end_col += 1

        self.message = "Shifted right"

    def action_fill_mark(self) -> None:
        """Start fill operation - waits for fill character (Alt-F)."""
        if not self.mark:
            self.message = "No mark to fill"
            return

        if self.mark.mode not in (MarkMode.LINE, MarkMode.BLOCK):
            self.message = "Fill only works with line or block marks"
            return

        self.fill_pending = True
        self.message = "Fill with character: "

    def do_fill_mark(self, fill_char: str) -> None:
        """Fill the marked region with the specified character."""
        if not self.mark:
            self.message = "No mark to fill"
            return

        start_row = min(self.mark.start_row, self.mark.end_row)
        end_row = max(self.mark.start_row, self.mark.end_row)

        # Save for undo
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row,
                              0, start_row, end_row)

        if self.mark.mode == MarkMode.LINE:
            # Fill entire lines - preserve line length
            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                line_len = len(line) if line else 1  # At least 1 char
                self.buffer.set_line(row, fill_char * line_len)
            self.message = f"Filled {end_row - start_row + 1} line(s) with '{fill_char}'"

        elif self.mark.mode == MarkMode.BLOCK:
            # Fill rectangular region
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)
            width = end_col - start_col + 1
            fill_str = fill_char * width

            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                # Pad line if needed
                if len(line) < end_col + 1:
                    line = line.ljust(end_col + 1)
                # Replace the block region
                new_line = line[:start_col] + fill_str + line[end_col + 1:]
                self.buffer.set_line(row, new_line)
            self.message = f"Filled block with '{fill_char}'"

    def action_copy_to_clipboard(self) -> None:
        """Copy marked text to system clipboard using OSC 52."""
        if not self.mark:
            self.message = "No mark to copy to clipboard"
            return

        text_lines = self.get_marked_text()
        text = '\n'.join(text_lines)

        # OSC 52 escape sequence to set clipboard
        # Format: ESC ] 52 ; c ; BASE64_TEXT BEL
        encoded = base64.b64encode(text.encode('utf-8')).decode('ascii')
        osc52 = f'\033]52;c;{encoded}\007'
        sys.stdout.write(osc52)
        sys.stdout.flush()

        self.message = f"Copied {len(text_lines)} line(s) to clipboard"

    def action_move(self) -> None:
        """Move marked region to cursor position (E3 style)."""
        if not self.mark:
            self.message = "No mark to move"
            return

        if self.mark.mode not in (MarkMode.LINE, MarkMode.BLOCK):
            self.message = "Move works with line or block marks"
            return

        # Get marked text before deleting
        text_lines = self.get_marked_text()
        m_lines = len(text_lines)
        mode = self.mark.mode

        # Save source mark info
        src_start_row = self.mark.start_row
        src_end_row = self.mark.end_row

        if mode == MarkMode.LINE:
            curl = self.cursor_row

            # Check if cursor is inside the mark
            if curl >= src_start_row and curl <= src_end_row:
                self.message = "Can't move lines over themselves"
                return

            # Delete source lines first
            self.buffer.delete_lines(src_start_row, src_end_row)

            # Adjust cursor if it was after the deleted lines
            if curl > src_end_row:
                curl -= m_lines

            # Insert after cursor line
            insert_row = curl + 1
            self.buffer.insert_lines(insert_row, text_lines)

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=insert_row,
                start_col=0,
                end_row=insert_row + m_lines - 1,
                end_col=0,
                pending=False
            )
            self.cursor_row = insert_row
            self.message = f"Moved {m_lines} line(s)"

        elif mode == MarkMode.BLOCK:
            m_width = len(text_lines[0]) if text_lines else 0

            # Check if exact overlap
            if (self.cursor_row == src_start_row and
                self.cursor_col == self.mark.start_col):
                self.message = "Moving block over itself has no effect"
                return

            # Delete source block first
            self.delete_marked_region()

            # Insert at cursor
            self.insert_text_at_cursor(text_lines, MarkMode.BLOCK)

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.BLOCK,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row + m_lines - 1,
                end_col=self.cursor_col + m_width - 1,
                pending=False
            )
            self.message = f"Moved block"

    def insert_text_at_cursor(self, text_lines: List[str], mode: MarkMode) -> None:
        """Insert text at cursor position based on mark mode."""
        if not text_lines:
            return

        if mode == MarkMode.LINE:
            # Insert complete lines at cursor row
            self.buffer.insert_lines(self.cursor_row, text_lines)

        elif mode == MarkMode.BLOCK:
            # Block insert - insert at each row starting at cursor
            for i, text in enumerate(text_lines):
                row = self.cursor_row + i
                while row >= len(self.buffer.lines):
                    self.buffer.lines.append("")
                line = self.buffer.get_line(row)
                # Pad line if cursor is beyond line end
                if len(line) < self.cursor_col:
                    line = line.ljust(self.cursor_col)
                self.buffer.set_line(row, line[:self.cursor_col] + text + line[self.cursor_col:])

        else:  # CHAR mode
            # Insert text at cursor position, handling newlines
            if len(text_lines) == 1:
                # Single line insert
                line = self.buffer.get_line(self.cursor_row)
                self.buffer.set_line(self.cursor_row,
                    line[:self.cursor_col] + text_lines[0] + line[self.cursor_col:])
            else:
                # Multi-line insert
                line = self.buffer.get_line(self.cursor_row)
                before = line[:self.cursor_col]
                after = line[self.cursor_col:]

                # First line: before + first text
                self.buffer.set_line(self.cursor_row, before + text_lines[0])

                # Middle lines
                for i, text in enumerate(text_lines[1:-1], 1):
                    self.buffer.lines.insert(self.cursor_row + i, text)

                # Last line: last text + after
                self.buffer.lines.insert(self.cursor_row + len(text_lines) - 1,
                    text_lines[-1] + after)

    def overlay_text_at_cursor(self, text_lines: List[str], width: int) -> None:
        """Overlay (replace) text at cursor position. Block mode only."""
        if not text_lines:
            return

        for i, text in enumerate(text_lines):
            row = self.cursor_row + i
            while row >= len(self.buffer.lines):
                self.buffer.lines.append("")
            line = self.buffer.get_line(row)
            # Pad line if cursor is beyond line end
            if len(line) < self.cursor_col + width:
                line = line.ljust(self.cursor_col + width)
            # Replace the range [cursor_col : cursor_col + width] with text
            new_line = line[:self.cursor_col] + text + line[self.cursor_col + width:]
            self.buffer.set_line(row, new_line)

    def get_marked_text(self) -> List[str]:
        """Get marked text as list of strings."""
        if not self.mark:
            return []

        start_row = self.mark.start_row
        end_row = self.mark.end_row if self.mark.end_row is not None else start_row
        start_col = self.mark.start_col
        end_col = self.mark.end_col if self.mark.end_col is not None else start_col

        # Normalize
        if start_row > end_row:
            start_row, end_row = end_row, start_row
            start_col, end_col = end_col, start_col
        elif start_row == end_row and start_col > end_col:
            start_col, end_col = end_col, start_col

        result = []

        if self.mark.mode == MarkMode.LINE:
            for r in range(start_row, end_row + 1):
                result.append(self.buffer.get_line(r))

        elif self.mark.mode == MarkMode.CHAR:
            for r in range(start_row, end_row + 1):
                line = self.buffer.get_line(r)
                if r == start_row and r == end_row:
                    result.append(line[start_col:end_col + 1])
                elif r == start_row:
                    result.append(line[start_col:])
                elif r == end_row:
                    result.append(line[:end_col + 1])
                else:
                    result.append(line)

        elif self.mark.mode == MarkMode.BLOCK:
            col_start = min(start_col, end_col)
            col_end = max(start_col, end_col)
            for r in range(start_row, end_row + 1):
                line = self.buffer.get_line(r)
                if len(line) < col_end + 1:
                    line = line.ljust(col_end + 1)
                result.append(line[col_start:col_end + 1])

        return result

    def delete_marked_region(self) -> None:
        """Delete the marked region from buffer."""
        if not self.mark:
            return

        start_row = self.mark.start_row
        end_row = self.mark.end_row if self.mark.end_row is not None else start_row
        start_col = self.mark.start_col
        end_col = self.mark.end_col if self.mark.end_col is not None else start_col

        # Normalize
        if start_row > end_row:
            start_row, end_row = end_row, start_row
            start_col, end_col = end_col, start_col
        elif start_row == end_row and start_col > end_col:
            start_col, end_col = end_col, start_col

        if self.mark.mode == MarkMode.LINE:
            self.buffer.delete_lines(start_row, end_row)
            self.cursor_row = min(start_row, len(self.buffer.lines) - 1)
            self.cursor_col = 0

        elif self.mark.mode == MarkMode.CHAR:
            if start_row == end_row:
                line = self.buffer.get_line(start_row)
                self.buffer.set_line(start_row, line[:start_col] + line[end_col + 1:])
            else:
                first = self.buffer.get_line(start_row)[:start_col]
                last = self.buffer.get_line(end_row)[end_col + 1:]
                self.buffer.set_line(start_row, first + last)
                for _ in range(end_row - start_row):
                    if start_row + 1 < len(self.buffer.lines):
                        del self.buffer.lines[start_row + 1]
            self.cursor_row = start_row
            self.cursor_col = start_col

        elif self.mark.mode == MarkMode.BLOCK:
            col_start = min(start_col, end_col)
            col_end = max(start_col, end_col)
            for r in range(start_row, end_row + 1):
                if r < len(self.buffer.lines):
                    line = self.buffer.get_line(r)
                    self.buffer.set_line(r, line[:col_start] + line[col_end + 1:])

    def action_goto_mark_start(self) -> None:
        """Go to mark start."""
        if self.mark:
            self.cursor_row = self.mark.start_row
            self.cursor_col = self.mark.start_col
            self.message = f"Moved to mark start"
        else:
            self.message = "No mark set"

    def action_goto_mark_end(self) -> None:
        """Go to mark end."""
        if self.mark and self.mark.end_row is not None:
            self.cursor_row = self.mark.end_row
            self.cursor_col = self.mark.end_col or 0
            self.message = f"Moved to mark end"
        else:
            self.message = "Mark not complete"

    def action_split_line(self) -> None:
        """Split line at cursor."""
        self.buffer.push_undo(UndoType.LINE_SPLIT, self.cursor_row,
                              self.cursor_col, self.cursor_row, self.cursor_row)
        self.buffer.split_line(self.cursor_row, self.cursor_col)
        self.cursor_row += 1
        self.cursor_col = 0
        self.message = "Line split"

    def action_join_line(self) -> None:
        """Join current line with next."""
        if self.cursor_row < len(self.buffer.lines) - 1:
            self.buffer.push_undo(UndoType.LINE_JOIN, self.cursor_row,
                                  len(self.buffer.get_line(self.cursor_row)),
                                  self.cursor_row, self.cursor_row + 1)
            curr = self.buffer.get_line(self.cursor_row)
            next_line = self.buffer.get_line(self.cursor_row + 1)
            self.buffer.set_line(self.cursor_row, curr + next_line)
            del self.buffer.lines[self.cursor_row + 1]
            self.message = "Lines joined"

    # === File operations ===

    def action_save(self) -> None:
        """Save file."""
        if self.buffer.save():
            self.message = f"Saved {self.buffer.filename}"
        else:
            self.message = "Save failed - no filename"

    # === Spell checking ===

    def _get_dict_path(self) -> Path:
        """Get path to personal dictionary."""
        return Path.home() / ".e3po" / "dictionary.txt"

    def _init_spell_checker(self) -> None:
        """Initialize spell checker with personal dictionary."""
        if self.spell_checker is None:
            self.spell_checker = SpellChecker()
            # Load personal dictionary
            path = self._get_dict_path()
            if path.exists():
                try:
                    words = path.read_text().strip().split('\n')
                    words = [w.strip() for w in words if w.strip()]
                    if words:
                        self.spell_checker.word_frequency.load_words(words)
                except Exception:
                    pass  # Ignore dictionary load errors

    def _save_personal_dict(self, word: str) -> None:
        """Add word to personal dictionary file."""
        path = self._get_dict_path()
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            with open(path, 'a') as f:
                f.write(word.lower() + '\n')
        except Exception:
            pass  # Ignore save errors

    # Common standard library identifiers to skip during spell check (per language)
    STDLIB_IDENTIFIERS: Dict[str, Set[str]] = {
        'cpp': {
            # iostream
            'cout', 'cin', 'cerr', 'clog', 'endl', 'flush', 'getline',
            # string
            'string', 'wstring', 'substr', 'npos', 'strlen', 'strcpy', 'strcmp', 'strcat',
            # containers
            'vector', 'deque', 'list', 'map', 'multimap', 'set', 'multiset',
            'unordered_map', 'unordered_set', 'array', 'stack', 'queue', 'bitset',
            # iterators
            'begin', 'end', 'rbegin', 'rend', 'cbegin', 'cend', 'iter', 'iterator',
            # algorithms
            'sort', 'find', 'copy', 'swap', 'reverse', 'unique', 'merge',
            # memory
            'malloc', 'calloc', 'realloc', 'free', 'nullptr', 'unique_ptr', 'shared_ptr',
            # types
            'size_t', 'ptrdiff_t', 'uint', 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
            'int8_t', 'int16_t', 'int32_t', 'int64_t', 'wchar_t', 'bool',
            # common
            'std', 'stoi', 'stol', 'stof', 'stod', 'printf', 'scanf', 'sprintf', 'fprintf',
            'argc', 'argv', 'main', 'init', 'fopen', 'fclose', 'fread', 'fwrite',
            'constexpr', 'noexcept', 'decltype', 'nullptr',
        },
        'c': {
            'printf', 'scanf', 'fprintf', 'fscanf', 'sprintf', 'sscanf',
            'malloc', 'calloc', 'realloc', 'free',
            'strlen', 'strcpy', 'strcmp', 'strcat', 'strncpy', 'strncmp', 'memcpy', 'memset',
            'fopen', 'fclose', 'fread', 'fwrite', 'fgets', 'fputs', 'fseek', 'ftell',
            'stdin', 'stdout', 'stderr', 'argc', 'argv', 'main', 'init',
            'size_t', 'ptrdiff_t', 'uint', 'NULL',
        },
        'python': {
            'str', 'int', 'bool', 'dict', 'list', 'tuple', 'set', 'frozenset',
            'len', 'range', 'enumerate', 'zip', 'map', 'filter', 'sorted', 'reversed',
            'print', 'input', 'open', 'repr', 'iter', 'next', 'isinstance', 'issubclass',
            'getattr', 'setattr', 'hasattr', 'delattr', 'vars', 'dir', 'type',
            'self', 'cls', 'args', 'kwargs', 'init', 'main', 'func',
            'def', 'async', 'await',
        },
        'bash': {
            'echo', 'printf', 'read', 'grep', 'sed', 'awk', 'cat', 'ls', 'cd', 'pwd',
            'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'find', 'xargs',
            'stdin', 'stdout', 'stderr', 'args', 'argv', 'argc',
            'fi', 'elif', 'esac', 'done',
        },
    }

    def _scan_spelling(self) -> None:
        """Scan buffer for misspelled words and populate spell_errors."""
        self.spell_errors = []
        if not self.spell_checker:
            return

        # Get syntax highlighting to skip code tokens (keywords, functions, types, etc.)
        # Color indices to skip: 1=keyword/builtin, 4=function, 5=type, 6=number, 9=constant
        code_colors = {1, 4, 5, 6, 9}
        syntax_regions: Dict[int, List[Tuple[int, int, int]]] = {}

        if self.highlighter and self.syntax_lang:
            try:
                syntax_regions = self.highlighter.get_highlights(
                    0, len(self.buffer.lines) - 1, self.buffer.lines
                )
            except:
                pass  # Ignore syntax errors

        # Get language keywords as fallback (in case tree-sitter misses some)
        lang_keywords: Set[str] = set()
        if self.highlighter and self.syntax_lang:
            try:
                from e3po.syntax.highlighter import SIMPLE_PATTERNS
                if self.syntax_lang in SIMPLE_PATTERNS:
                    keywords = SIMPLE_PATTERNS[self.syntax_lang].get('keywords', [])
                    lang_keywords = set(k.lower() for k in keywords)
            except:
                pass

        # Get standard library identifiers for this language
        stdlib_ids: Set[str] = set()
        if self.syntax_lang and self.syntax_lang in self.STDLIB_IDENTIFIERS:
            stdlib_ids = self.STDLIB_IDENTIFIERS[self.syntax_lang]

        # Word pattern: sequences of letters (including apostrophes for contractions)
        word_pattern = re.compile(r"[a-zA-Z']+")

        for row, line in enumerate(self.buffer.lines):
            # Get syntax highlights for this row
            row_highlights = syntax_regions.get(row, [])

            for match in word_pattern.finditer(line):
                word = match.group()
                start_col = match.start()
                end_col = match.end()

                # Skip words with only apostrophes, very short words, or skipped words
                if len(word) < 2 or word.replace("'", "") == "":
                    continue
                if word.lower() in self.spell_skip_words:
                    continue

                # Skip language keywords (fallback check)
                if word.lower() in lang_keywords:
                    continue

                # Skip standard library identifiers (cout, endl, printf, etc.)
                if word.lower() in stdlib_ids:
                    continue

                # Skip if word is in a code region (keyword, function, type, etc.)
                in_code_region = False
                for hl_start, hl_end, color in row_highlights:
                    if color in code_colors and hl_start <= start_col < hl_end:
                        in_code_region = True
                        break
                if in_code_region:
                    continue

                # Check spelling
                if word.lower() not in self.spell_checker:
                    self.spell_errors.append((row, start_col, end_col, word))

    def get_misspelled_cols(self, buf_row: int) -> Tuple[set, set]:
        """Get sets of column indices for spelling errors on this row.
        Returns (other_misspelled_cols, current_misspelled_cols)."""
        other_cols = set()
        current_cols = set()
        for i, (row, start_col, end_col, word) in enumerate(self.spell_errors):
            if row == buf_row:
                if i == self.spell_index:
                    current_cols.update(range(start_col, end_col))
                else:
                    other_cols.update(range(start_col, end_col))
        return other_cols, current_cols

    def _save_jump_position(self) -> None:
        """Deprecated - kept for compatibility. Does nothing."""
        pass

    def _prev_bookmark(self) -> None:
        """Move to previous bookmark."""
        if not self.bookmarks:
            self.message = "No bookmarks"
            return
        # Find bookmarks before current line
        prev_marks = sorted([b for b in self.bookmarks if b < self.cursor_row], reverse=True)
        if prev_marks:
            self.cursor_row = prev_marks[0]
            self.cursor_col = 0
            self.scroll_to_cursor()
            self.message = f"Bookmark L{self.cursor_row + 1}"
        else:
            # Wrap to last bookmark
            self.cursor_row = max(self.bookmarks)
            self.cursor_col = 0
            self.scroll_to_cursor()
            self.message = f"Bookmark L{self.cursor_row + 1} (wrapped)"

    def _next_bookmark(self) -> None:
        """Move to next bookmark."""
        if not self.bookmarks:
            self.message = "No bookmarks"
            return
        # Find bookmarks after current line
        next_marks = sorted([b for b in self.bookmarks if b > self.cursor_row])
        if next_marks:
            self.cursor_row = next_marks[0]
            self.cursor_col = 0
            self.scroll_to_cursor()
            self.message = f"Bookmark L{self.cursor_row + 1}"
        else:
            # Wrap to first bookmark
            self.cursor_row = min(self.bookmarks)
            self.cursor_col = 0
            self.scroll_to_cursor()
            self.message = f"Bookmark L{self.cursor_row + 1} (wrapped)"

    def _clear_spell_mode(self) -> None:
        """Clear all spell checking state."""
        self.spell_mode = False
        self.spell_errors = []
        self.spell_index = -1
        self.spell_suggestions = []

    def toggle_spell_mode(self) -> None:
        """Toggle spell checking mode on/off."""
        if not SPELL_AVAILABLE:
            self.message = "Spell checking unavailable (install pyspellchecker)"
            return

        if self.spell_mode:
            # Turn off
            self._clear_spell_mode()
            self.message = ""
        else:
            # Turn on
            self.spell_mode = True
            self._init_spell_checker()
            self._scan_spelling()
            count = len(self.spell_errors)
            if count == 0:
                self.message = "SPELL: No errors found | o)ff"
            else:
                self.message = f"SPELL: {count} error{'s' if count != 1 else ''} - press 'n' to navigate"

    def _handle_spell_input(self, ch: int) -> bool:
        """Handle spell mode keys. Returns True if handled."""
        if ch == ord('n') or ch == ord('N'):
            self._spell_next()
            return True
        elif ch == ord('p') or ch == ord('P'):
            self._spell_prev()
            return True
        elif ch == ord('o') or ch == ord('O'):
            self.toggle_spell_mode()  # Turn off
            return True
        elif ch == ord('s') or ch == ord('S'):
            self._spell_skip()
            return True
        elif ch == ord('a') or ch == ord('A'):
            self._spell_add_to_dict()
            return True
        elif ch == ord('e') or ch == ord('E'):
            # Let user edit manually - just update message
            self.message = "SPELL: Edit word, then n)ext p)rev o)ff"
            return False  # Don't consume, let normal editing happen
        elif ord('1') <= ch <= ord('9'):
            self._spell_replace(ch - ord('1'))
            return True
        return False  # Not a spell command, handle normally

    def _spell_next(self) -> None:
        """Move to next misspelled word."""
        if not self.spell_errors:
            self.spell_suggestions = []
            self.message = "SPELL: No errors found | o)ff"
            return
        self.spell_index = (self.spell_index + 1) % len(self.spell_errors)
        self._goto_spell_error()

    def _spell_prev(self) -> None:
        """Move to previous misspelled word."""
        if not self.spell_errors:
            self.spell_suggestions = []
            self.message = "SPELL: No errors found | o)ff"
            return
        self.spell_index = (self.spell_index - 1) % len(self.spell_errors)
        self._goto_spell_error()

    def _goto_spell_error(self) -> None:
        """Move cursor to current spell error and show suggestions."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            self.spell_suggestions = []
            return

        # Save position before jumping (but not if we just jumped to first error from Ctrl-S)
        row, start_col, end_col, word = self.spell_errors[self.spell_index]
        if self.cursor_row != row or self.cursor_col != start_col:
            self._save_jump_position()
        self.cursor_row = row
        self.cursor_col = start_col
        self.scroll_to_cursor()

        # Get and cache suggestions for popup
        suggestions = self.spell_checker.candidates(word)
        self.spell_suggestions = list(suggestions)[:9] if suggestions else []

        # Ensure there's room below the word for the popup
        if self.spell_suggestions:
            num_suggestions = len(self.spell_suggestions)
            screen_row = self.cursor_row - self.top_row
            rows_needed_below = num_suggestions + 1  # +1 for the word itself

            # If word is too close to bottom, scroll view up
            if screen_row + rows_needed_below > self.edit_height:
                # Scroll so word is high enough for popup to fit below
                new_top = self.cursor_row - (self.edit_height - rows_needed_below - 1)
                self.top_row = max(0, new_top)

        # Show letter commands in status line
        self.message = f"SPELL [{word}]: n)ext p)rev s)kip e)dit a)dd o)ff"

    def _spell_skip(self) -> None:
        """Skip current word for this session."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return
        word = self.spell_errors[self.spell_index][3]
        self.spell_skip_words.add(word.lower())
        self._scan_spelling()  # Rescan to remove skipped words
        # Adjust index if needed
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    def _spell_add_to_dict(self) -> None:
        """Add current word to personal dictionary."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return
        word = self.spell_errors[self.spell_index][3]
        self.spell_checker.word_frequency.load_words([word.lower()])
        self._save_personal_dict(word)
        self._scan_spelling()
        # Adjust index if needed
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    def _spell_replace(self, suggestion_index: int) -> None:
        """Replace current word with numbered suggestion."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return

        row, start_col, end_col, word = self.spell_errors[self.spell_index]
        suggestions = list(self.spell_checker.candidates(word) or [])[:9]

        if suggestion_index >= len(suggestions):
            self.message = f"No suggestion #{suggestion_index + 1}"
            return

        replacement = suggestions[suggestion_index]

        # Push undo before changing
        self.buffer.push_undo(UndoType.LINE_CHANGE, row, start_col, row, row)

        # Replace in buffer
        line = self.buffer.get_line(row)
        new_line = line[:start_col] + replacement + line[end_col:]
        self.buffer.set_line(row, new_line)
        self.buffer.modified = True

        # Rescan and continue
        self._scan_spelling()
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    # === File ring (multiple buffers) ===

    def _save_view_to_buffer(self) -> None:
        """Save current view state to current buffer."""
        self.buffer.cursor_row = self.cursor_row
        self.buffer.cursor_col = self.cursor_col
        self.buffer.top_row = self.top_row
        self.buffer.left_col = self.left_col
        self.buffer.mark = self.mark

    def _restore_view_from_buffer(self) -> None:
        """Restore view state from current buffer."""
        self.cursor_row = self.buffer.cursor_row
        self.cursor_col = self.buffer.cursor_col
        self.top_row = self.buffer.top_row
        self.left_col = self.buffer.left_col
        self.mark = self.buffer.mark

    def switch_to_buffer(self, idx: int) -> None:
        """Switch to buffer at index, wrapping around."""
        if len(self.buffers) <= 1:
            self.message = "No other files in ring"
            return

        # Clear spell mode when switching files
        self._clear_spell_mode()

        # Save current view state
        self._save_view_to_buffer()

        # Switch to new buffer (wrap around)
        self.current_buffer_idx = idx % len(self.buffers)
        self.buffer = self.buffers[self.current_buffer_idx]

        # Restore view state from new buffer
        self._restore_view_from_buffer()

        # Reinitialize syntax highlighting for new file
        self._init_syntax()

        fname = self.buffer.filename or "[No Name]"
        self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] {fname}"

    def action_next_file(self) -> None:
        """F10 / Alt-N - next buffer in ring."""
        self.switch_to_buffer(self.current_buffer_idx + 1)

    def action_prev_file(self) -> None:
        """F11 / Alt-P - previous buffer in ring."""
        self.switch_to_buffer(self.current_buffer_idx - 1)

    def open_file(self, filename: str) -> None:
        """Open a file, adding to ring or switching if already open."""
        # Check if already in ring
        for i, buf in enumerate(self.buffers):
            if buf.filename == filename:
                self.switch_to_buffer(i)
                return

        # Clear spell mode when opening new file
        self._clear_spell_mode()

        # Check if encrypted file - need passphrase
        passphrase = None
        if _is_age_file(filename):
            if not AGE_AVAILABLE:
                self.message = "Error: 'age' not installed. Run: brew install age"
                return
            # Exit curses to prompt for passphrase
            curses.endwin()
            try:
                passphrase = getpass.getpass(f"Passphrase for {filename}: ")
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled")
                passphrase = None
            finally:
                # Restore curses
                self.screen = curses.initscr()
                curses.noecho()
                curses.cbreak()
                self.screen.keypad(True)
                curses.curs_set(1)
                try:
                    curses.set_escdelay(25)
                except AttributeError:
                    pass
                curses.start_color()
                curses.use_default_colors()
                self._init_colors()
                self.screen.clear()

            if not passphrase:
                self.message = "Cancelled"
                return

        # Save current view state before switching
        self._save_view_to_buffer()

        # Create new buffer and add to ring
        new_buf = Buffer()
        if not new_buf.load(filename, passphrase):
            if _is_age_file(filename):
                self.message = "Decryption failed - wrong passphrase?"
            else:
                self.message = f"Failed to open {filename}"
            return

        self.buffers.append(new_buf)
        self.current_buffer_idx = len(self.buffers) - 1
        self.buffer = new_buf

        # Reset view state for new buffer
        self.cursor_row = 0
        self.cursor_col = 0
        self.top_row = 0
        self.left_col = 0
        self.mark = None

        # Initialize syntax highlighting
        self._init_syntax()

        self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] Opened {filename}"

    def _close_current_buffer(self) -> None:
        """Close current buffer and switch to another, or exit if last buffer."""
        # Clear spell mode when closing file
        self._clear_spell_mode()

        # Clear passphrase from memory
        self.buffer.clear_passphrase()

        if len(self.buffers) == 1:
            # Last buffer - exit editor
            self.running = False
        else:
            # Remove current buffer and switch to next
            del self.buffers[self.current_buffer_idx]
            if self.current_buffer_idx >= len(self.buffers):
                self.current_buffer_idx = 0
            self.buffer = self.buffers[self.current_buffer_idx]
            self._restore_view_from_buffer()
            self._init_syntax()
            self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] {self.buffer.filename or '[No Name]'}"

    def action_quit(self) -> None:
        """Quit current buffer without saving."""
        if self.buffer.modified:
            # Show confirmation prompt
            if len(self.buffers) > 1:
                self.message = "File modified! Close anyway? (Y/N/Esc/F2:Save/F4:Save&Close)"
            else:
                self.message = "File modified! Discard? (Y/N/Esc/F2:Save/F4:Save&Quit)"
            self.draw()
            while True:
                ch = self.screen.getch()
                if ch in (ord('y'), ord('Y')):
                    self._close_current_buffer()
                    break
                elif ch in (ord('n'), ord('N'), 27):  # N or ESC
                    self.message = "Cancelled"
                    break
                elif ch == curses.KEY_F2:
                    # Save and continue editing
                    if self.buffer.save():
                        self.message = "Saved"
                    else:
                        self.message = "Save failed"
                    break
                elif ch == curses.KEY_F4:
                    # Save and close/quit
                    if self.buffer.save():
                        self._close_current_buffer()
                    else:
                        self.message = "Save failed"
                    break
        else:
            self._close_current_buffer()

    def action_save_and_quit(self) -> None:
        """Save and close current buffer (or quit if last buffer)."""
        if self.buffer.save():
            self._close_current_buffer()
        else:
            self.message = "Save failed"

    def action_undo(self) -> None:
        """Undo the last edit operation."""
        log.debug(f"action_undo called, stack size={len(self.buffer.undo_stack)}")

        # Clear spell mode since buffer content will change
        self._clear_spell_mode()

        entry = self.buffer.pop_undo()
        if not entry:
            self.message = "Nothing to undo"
            return

        log.debug(f"Undoing: {entry}")
        # Save current state for redo before restoring
        self._save_for_redo(entry)

        # Restore the previous state
        self._apply_undo_entry(entry)

        # Restore cursor position
        self.cursor_row = entry.line_num
        self.cursor_col = entry.col_pos
        self.scroll_to_cursor()

        # Check if we've returned to clean state
        if len(self.buffer.undo_stack) == self.buffer.clean_undo_depth:
            self.buffer.modified = False
        else:
            self.buffer.modified = True
        self.message = f"Undo: {entry.undo_type.name.lower().replace('_', ' ')}"

    def action_redo(self) -> None:
        """Redo the last undone operation."""
        # Clear spell mode since buffer content will change
        self._clear_spell_mode()

        if not self.buffer.redo_stack:
            self.message = "Nothing to redo"
            return

        entry = self.buffer.redo_stack.pop()

        # Save current state for undo before redoing
        self._save_for_undo_from_redo(entry)

        # Apply the redo (same as undo - swap states)
        self._apply_undo_entry(entry)

        # Restore cursor position
        self.cursor_row = entry.line_num
        self.cursor_col = entry.col_pos
        self.scroll_to_cursor()

        # Check if we've returned to clean state (or moved away from it)
        if len(self.buffer.undo_stack) == self.buffer.clean_undo_depth:
            self.buffer.modified = False
        else:
            self.buffer.modified = True
        self.message = f"Redo: {entry.undo_type.name.lower().replace('_', ' ')}"

    def _save_for_redo(self, entry: UndoEntry) -> None:
        """Save current state to redo stack before undo."""
        # Determine what lines to save based on entry type
        if entry.undo_type == UndoType.LINE_CHANGE:
            saved = [self.buffer.lines[entry.line_num]] if entry.line_num < len(self.buffer.lines) else [""]
            line_count = 1
        elif entry.undo_type == UndoType.LINE_INSERT:
            # Save the inserted lines that will be deleted
            saved = [self.buffer.lines[entry.line_num + i]
                     for i in range(entry.line_count)
                     if entry.line_num + i < len(self.buffer.lines)]
            line_count = len(saved)
        elif entry.undo_type == UndoType.LINE_DELETE:
            # No lines to save (they were deleted, now being restored)
            saved = []
            line_count = len(entry.saved_lines)
        elif entry.undo_type == UndoType.LINE_SPLIT:
            # Save both lines that will be rejoined
            saved = []
            if entry.line_num < len(self.buffer.lines):
                saved.append(self.buffer.lines[entry.line_num])
            if entry.line_num + 1 < len(self.buffer.lines):
                saved.append(self.buffer.lines[entry.line_num + 1])
            line_count = 2
        elif entry.undo_type == UndoType.LINE_JOIN:
            # Save the joined line
            saved = [self.buffer.lines[entry.line_num]] if entry.line_num < len(self.buffer.lines) else [""]
            line_count = 1
        elif entry.undo_type == UndoType.BLOCK_CHANGE:
            saved = [self.buffer.lines[entry.line_num + i]
                     for i in range(entry.line_count)
                     if entry.line_num + i < len(self.buffer.lines)]
            line_count = len(saved)
        else:
            saved = []
            line_count = 0

        redo_entry = UndoEntry(
            undo_type=entry.undo_type,
            line_num=entry.line_num,
            col_pos=entry.col_pos,
            saved_lines=saved,
            line_count=line_count
        )
        self.buffer.redo_stack.append(redo_entry)

        # Limit redo stack size
        if len(self.buffer.redo_stack) > self.buffer.max_undo:
            self.buffer.redo_stack.pop(0)

    def _save_for_undo_from_redo(self, entry: UndoEntry) -> None:
        """Save current state to undo stack before redo (without clearing redo)."""
        # Similar to _save_for_redo but pushes to undo stack
        if entry.undo_type == UndoType.LINE_CHANGE:
            saved = [self.buffer.lines[entry.line_num]] if entry.line_num < len(self.buffer.lines) else [""]
            line_count = 1
        elif entry.undo_type == UndoType.LINE_INSERT:
            saved = []
            line_count = len(entry.saved_lines)
        elif entry.undo_type == UndoType.LINE_DELETE:
            saved = [self.buffer.lines[entry.line_num + i]
                     for i in range(entry.line_count)
                     if entry.line_num + i < len(self.buffer.lines)]
            line_count = len(saved)
        elif entry.undo_type == UndoType.LINE_SPLIT:
            saved = [self.buffer.lines[entry.line_num]] if entry.line_num < len(self.buffer.lines) else [""]
            line_count = 1
        elif entry.undo_type == UndoType.LINE_JOIN:
            saved = []
            if entry.line_num < len(self.buffer.lines):
                saved.append(self.buffer.lines[entry.line_num])
            if entry.line_num + 1 < len(self.buffer.lines):
                saved.append(self.buffer.lines[entry.line_num + 1])
            line_count = 2
        elif entry.undo_type == UndoType.BLOCK_CHANGE:
            saved = [self.buffer.lines[entry.line_num + i]
                     for i in range(entry.line_count)
                     if entry.line_num + i < len(self.buffer.lines)]
            line_count = len(saved)
        else:
            saved = []
            line_count = 0

        undo_entry = UndoEntry(
            undo_type=entry.undo_type,
            line_num=entry.line_num,
            col_pos=entry.col_pos,
            saved_lines=saved,
            line_count=line_count
        )
        self.buffer.undo_stack.append(undo_entry)

    def _apply_undo_entry(self, entry: UndoEntry) -> None:
        """Apply an undo/redo entry to restore state."""
        if entry.undo_type == UndoType.LINE_CHANGE:
            if entry.saved_lines:
                while entry.line_num >= len(self.buffer.lines):
                    self.buffer.lines.append("")
                self.buffer.lines[entry.line_num] = entry.saved_lines[0]

        elif entry.undo_type == UndoType.LINE_INSERT:
            # Lines were inserted - delete them
            for _ in range(entry.line_count):
                if entry.line_num < len(self.buffer.lines):
                    del self.buffer.lines[entry.line_num]
            if not self.buffer.lines:
                self.buffer.lines = [""]

        elif entry.undo_type == UndoType.LINE_DELETE:
            # Lines were deleted - restore them
            for i, line in enumerate(entry.saved_lines):
                self.buffer.lines.insert(entry.line_num + i, line)

        elif entry.undo_type == UndoType.LINE_SPLIT:
            # Line was split - rejoin it
            if entry.line_num + 1 < len(self.buffer.lines):
                del self.buffer.lines[entry.line_num + 1]
            if entry.saved_lines:
                self.buffer.lines[entry.line_num] = entry.saved_lines[0]

        elif entry.undo_type == UndoType.LINE_JOIN:
            # Lines were joined - split them back
            if entry.saved_lines and len(entry.saved_lines) >= 2:
                self.buffer.lines[entry.line_num] = entry.saved_lines[0]
                self.buffer.lines.insert(entry.line_num + 1, entry.saved_lines[1])

        elif entry.undo_type == UndoType.BLOCK_CHANGE:
            for i, line in enumerate(entry.saved_lines):
                row = entry.line_num + i
                while row >= len(self.buffer.lines):
                    self.buffer.lines.append("")
                self.buffer.lines[row] = line

    def show_help(self) -> None:
        """Show help screen with scrolling."""
        help_lines = [
            "",
            "  e3po - E3 Python Option                              Press F3 or Q to exit",
            "  ═══════════════════════════════════════════════════════════════════════════",
            "",
            "  FUNCTION KEYS                        MARK OPERATIONS",
            "  ─────────────                        ───────────────",
            "  F1   Help (this screen)              Alt-C  Copy to cursor",
            "  F2   Save file                       Alt-M  Move to cursor",
            "  F3   Close file (quit if last)       Alt-O  Overlay at cursor",
            "  F4   Save and close                  Alt-D  Delete marked region",
            "  F7   Shift mark left                 Alt-U  Unmark (clear)",
            "  F8   Shift mark right                Alt-Y  Go to mark start",
            "  F9   Undo                            Alt-E  Go to mark end",
            "  F10  Next file (Alt-N)               Alt-K  Copy to clipboard",
            "  F11  Prev file (Alt-P)               Alt-F  Fill mark with char",
            "  Ctrl-Y/R  Redo    Ctrl-C  Copy mark   Ctrl-K/Del  Delete line",
            "",
            "  MARK TYPES                           NAVIGATION",
            "  ──────────                           ──────────",
            "  Alt-L  Line mark                     Ctrl-A  Start of line (x2: top)",
            "  Alt-B  Block mark (rectangular)      Ctrl-E  End of line (x2: EOF)",
            "  Alt-Z  Character mark                Ctrl-B  Jump back to prev position",
            "  Alt-W  Word mark                     Ctrl-F  Repeat last search",
            "                                       Press mark key again to extend/trim",
            "",
            "  CURSOR MOVEMENT                      COMMAND LINE (press ESC)",
            "  ───────────────                      ────────────────────────",
            "  Arrows      Move one position        123         Go to line 123",
            "  Home/End    Start/end of line        /text       Search forward",
            "  PgUp/PgDn   Page up/down             /text/c     Search (no case)",
            "  Ctrl-Home   Top of file              c/old/new/  Change (Y/N/Q/G)",
            "  Ctrl-End    End of file              c/old/new/* Change all",
            "  Ctrl-Left   Previous word            t, top      Top of file",
            "  Ctrl-Right  Next word                b, bot      Bottom of file",
            "  Ctrl-PgUp   Top of screen            s, save     Save file",
            "  Ctrl-PgDn   Bottom of screen         q, quit     Quit",
            "                                       e file      Open/switch to file",
            "",
            "  SORT / UNIQ / GREP                   NOTES",
            "  ────────────────────                 ─────",
            "  sort        Sort entire file         Cursor moves freely in 2D space",
            "  sort -r     Reverse order            Spaces auto-fill past line end",
            "  sort -n     Numeric sort             Paste with Cmd-V or Ctrl-V",
            "  sort -f     Case insensitive         Multiple files: e3po f1 f2 f3",
            "  sort -kN    Sort by field N          Dict: ~/.e3po/dictionary.txt",
            "  uniq/unique Remove adjacent dups",
            "  uniq -i     Case insensitive",
            "  grep pat    Keep matching lines",
            "  grep -v     Inverse (keep non-match)",
            "  grep -i     Case insensitive",
            "  With LINE mark: affects marked lines only",
            "  With BLOCK mark: grep searches block cols",
            "",
            "  SPELL CHECKING (Ctrl-S)",
            "  ──────────────────────",
            "  Ctrl-S  Spell mode (-> 1st error)",
            "  n       Next misspelled word",
            "  p       Previous misspelled word",
            "  1-9     Replace with suggestion",
            "  s       Skip word (this session)",
            "  a       Add word to dictionary",
            "  e       Edit word manually",
            "  o       Turn off spell mode",
            "",
            "  CHANGE PROMPTS",
            "  ──────────────────────               ─────",
            "  Ctrl-S  Spell mode (-> 1st error)    Cursor moves freely in 2D space",
            "  n       Next misspelled word         Spaces auto-fill past line end",
            "  p       Previous misspelled word     Paste with Cmd-V or Ctrl-V",
            "  1-9     Replace with suggestion      Multiple files: e3po f1 f2 f3",
            "  s       Skip word (this session)     Dict: ~/.e3po/dictionary.txt",
            "  a       Add word to dictionary",
            "  e       Edit word manually",
            "  o       Turn off spell mode",
            "",
            "  CHANGE PROMPTS",
            "  ──────────────",
            "  Y  Change this occurrence",
            "  N  Skip this occurrence",
            "  Q  Quit changing",
            "  G  Change all remaining",
            "",
            "  ENCRYPTION (age)",
            "  ─────────────────",
            "  encrypt   Encrypt file with passphrase",
            "  passwd    Change passphrase",
            "  Files detected by magic bytes, not extension",
            "  Status bar shows [AGE] for encrypted files",
            "",
            "  ═══════════════════════════════════════════════════════════════════════════",
            "  e3po v0.3.5                                    Based on E, E3, E1, and E2",
            "",
        ]

        top = 0
        max_top = max(0, len(help_lines) - (self.height - 2))

        while True:
            self.screen.clear()
            for i in range(self.height - 1):
                line_idx = top + i
                if line_idx < len(help_lines):
                    line = help_lines[line_idx].ljust(self.width)
                else:
                    line = " " * self.width
                self.safe_addstr(i, 0, line, curses.color_pair(3))

            # Status line
            status = f" Line {top + 1}-{min(top + self.height - 1, len(help_lines))} of {len(help_lines)} | PgUp/PgDn to scroll | F3 to exit "
            self.safe_addstr(self.height - 1, 0, status.center(self.width), curses.color_pair(1))
            self.screen.refresh()

            ch = self.screen.getch()
            if ch == curses.KEY_F3 or ch == 27 or ch == ord('q') or ch == ord('Q'):
                break
            elif ch == curses.KEY_PPAGE or ch == curses.KEY_UP:
                top = max(0, top - (self.height - 2))
            elif ch == curses.KEY_NPAGE or ch == curses.KEY_DOWN or ch == ord(' '):
                top = min(max_top, top + (self.height - 2))


def detect_terminal_background() -> Optional[bool]:
    """
    Detect if terminal has light background using OSC 11 query.
    Returns True for light, False for dark, None if detection failed.
    """
    import select

    if not sys.stdin.isatty() or not sys.stdout.isatty():
        return None

    try:
        # Save terminal settings
        fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(fd)

        try:
            # Put terminal in raw mode to read response
            tty.setraw(fd)

            # Send OSC 11 query (background color)
            sys.stdout.write('\033]11;?\033\\')
            sys.stdout.flush()

            # Wait for response with timeout
            response = ''
            if select.select([sys.stdin], [], [], 0.1)[0]:
                # Read response: \033]11;rgb:RRRR/GGGG/BBBB\033\\
                while True:
                    if not select.select([sys.stdin], [], [], 0.05)[0]:
                        break
                    ch = sys.stdin.read(1)
                    response += ch
                    if ch == '\\' or len(response) > 50:
                        break

            # Parse response
            # Format: \033]11;rgb:RRRR/GGGG/BBBB\033\\ or similar
            import re
            match = re.search(r'rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)', response)
            if match:
                r, g, b = match.groups()
                # Normalize to 0-255 range (values can be 2 or 4 hex digits)
                r = int(r[:2], 16) if len(r) >= 2 else int(r, 16) * 17
                g = int(g[:2], 16) if len(g) >= 2 else int(g, 16) * 17
                b = int(b[:2], 16) if len(b) >= 2 else int(b, 16) * 17

                # Calculate luminance (perceived brightness)
                luminance = (0.299 * r + 0.587 * g + 0.114 * b)
                return luminance > 128  # Light if > 50% brightness

        finally:
            # Restore terminal settings
            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    except Exception:
        pass

    return None


def _relaunch_in_iterm2(files: List[str]) -> bool:
    """Relaunch e3po in iTerm2 if we're in Apple Terminal. Returns True if relaunching."""
    import shutil
    import subprocess

    # Don't relaunch during tests
    if 'pytest' in sys.modules or os.environ.get('PYTEST_CURRENT_TEST'):
        return False

    # Don't relaunch if already in iTerm2 or if we've already relaunched
    if os.environ.get('TERM_PROGRAM') != 'Apple_Terminal':
        return False
    if os.environ.get('E3PO_RELAUNCHED'):
        return False

    # Check if iTerm2 is installed
    iterm_path = '/Applications/iTerm.app'
    if not os.path.exists(iterm_path):
        return False

    # Build the command to run in iTerm2
    # Find the source directory so we can set PYTHONPATH
    this_file = os.path.abspath(__file__)
    src_dir = os.path.dirname(os.path.dirname(this_file))  # .../src

    # Use PYTHONPATH to ensure the module is found
    e3po_cmd = f'PYTHONPATH="{src_dir}" {sys.executable} -m e3po'

    # Escape single quotes in filenames for AppleScript
    file_args = ' '.join(f"'{f}'" for f in files) if files else ''
    cmd = f'E3PO_RELAUNCHED=1 {e3po_cmd} {file_args}'
    # Escape for AppleScript string (double the backslashes and escape quotes)
    cmd_escaped = cmd.replace('\\', '\\\\').replace('"', '\\"')

    # AppleScript to open iTerm2 in a new window that closes when done
    # Add "; exit" so the shell exits after e3po, window closes automatically
    applescript = f'''
tell application "iTerm"
    activate
    set newWindow to (create window with default profile)
    tell current session of newWindow
        write text "{cmd_escaped}; exit"
    end tell
end tell
'''

    try:
        subprocess.run(['osascript', '-e', applescript], check=True)
        print("Launching e3po in iTerm2 for better keyboard support...")
        return True
    except subprocess.CalledProcessError as e:
        # Debug: print the error
        print(f"Failed to launch iTerm2: {e}", file=sys.stderr)
        return False


def main():
    import argparse
    parser = argparse.ArgumentParser(description='e3po: terminal text editor')
    parser.add_argument('files', nargs='*', help='Files to edit')
    args = parser.parse_args()

    # Relaunch in iTerm2 if we're in Apple Terminal (for Ctrl+Backspace support)
    if _relaunch_in_iterm2(args.files):
        sys.exit(0)

    # Set ESCDELAY before curses init (default is often 1000ms!)
    # Force override - don't use setdefault, as system might have a higher value
    os.environ['ESCDELAY'] = '25'

    # Auto-detect terminal background
    detected = detect_terminal_background()
    light_mode = detected if detected is not None else False

    filenames = args.files if args.files else None
    editor = Editor(filenames, light_mode=light_mode)
    curses.wrapper(editor.run)


if __name__ == "__main__":
    main()
