"""
PyQt6 Log Viewer Window

Provides comprehensive log viewing capabilities with real-time tailing, search functionality,
and integration with OpenHCS subprocess execution. Reimplements log viewing using Qt widgets
for native desktop integration.
"""

import logging
import sys
import json
import re
from typing import Optional, List, Set, Tuple
from pathlib import Path

from PyQt6.QtWidgets import (
    QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QComboBox,
    QListView, QToolBar, QLineEdit, QCheckBox, QPushButton, QDialog,
    QStyledItemDelegate, QAbstractItemView, QApplication, QStyleOptionViewItem,
    QStyle, QPlainTextEdit,
)
from PyQt6.QtGui import QSyntaxHighlighter, QTextDocument
from PyQt6.QtCore import (
    QObject, QTimer, QFileSystemWatcher, pyqtSignal, pyqtSlot,
    Qt, QRegularExpression, QThread, QAbstractListModel, QModelIndex, QSize,
    QThreadPool, QRunnable, QPoint, QPointF, QProcess,
)
from PyQt6.QtGui import QTextCharFormat, QColor, QAction, QFont, QTextCursor, QPalette, QAbstractTextDocumentLayout, QKeySequence, QFontMetrics

from pyqt_reactive.core.log_utils import LogFileInfo
from pyqt_reactive.protocols import get_log_discovery_provider, get_server_scan_provider
from pyqt_reactive.services.process_tracker import (
    ProcessTracker, extract_pid_from_log_filename, get_log_display_name, get_log_tooltip
)

# Import Pygments for advanced syntax highlighting
from pygments import highlight
from pygments.lexers import PythonLexer, get_lexer_by_name
from pygments.formatters import get_formatter_by_name
from pygments.token import Token
from pygments.style import Style
from pygments.styles import get_style_by_name
from dataclasses import dataclass
from typing import Dict, Tuple

from pyqt_reactive.utils.log_highlight_client import LogHighlightClient, HighlightedSegmentDTO

logger = logging.getLogger(__name__)


# Pre-compiled regex patterns for syntax highlighting (HUGE performance boost)
# These are compiled once at module load and reused for all log lines
_TIMESTAMP_RE = re.compile(r'^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3}')
_LOG_LEVEL_RE = re.compile(r'\b(ERROR|WARNING|INFO|DEBUG|CRITICAL)\b')
_LOGGER_NAME_RE = re.compile(r' - ([\w\.]+) - ')
_FILE_PATH_RE = re.compile(r'(?:/[\w\-\.]+)+\.py')
_PYTHON_STRING_RE = re.compile(r'["\'](?:[^"\'\\]|\\.)*["\']')
_NUMBER_RE = re.compile(r'\b\d+(?:\.\d+)?\b')


@dataclass
class LogColorScheme:
    """
    Centralized color scheme for log highlighting with semantic color names.

    Supports light/dark theme variants and ensures WCAG accessibility compliance.
    All colors meet minimum 4.5:1 contrast ratio for normal text readability.
    """

    # Log level colors with semantic meaning (WCAG 4.5:1 compliant)
    log_critical_fg: Tuple[int, int, int] = (255, 255, 255)  # White text
    log_critical_bg: Tuple[int, int, int] = (139, 0, 0)      # Dark red background
    log_error_color: Tuple[int, int, int] = (255, 85, 85)    # Brighter red - WCAG compliant
    log_warning_color: Tuple[int, int, int] = (255, 140, 0)  # Dark orange - attention grabbing
    log_info_color: Tuple[int, int, int] = (100, 160, 210)   # Brighter steel blue - WCAG compliant
    log_debug_color: Tuple[int, int, int] = (160, 160, 160)  # Lighter gray - better contrast

    # Metadata and structural colors
    timestamp_color: Tuple[int, int, int] = (105, 105, 105)      # Dim gray - unobtrusive
    logger_name_color: Tuple[int, int, int] = (147, 112, 219)   # Medium slate blue - distinctive
    memory_address_color: Tuple[int, int, int] = (255, 182, 193) # Light pink - technical data
    file_path_color: Tuple[int, int, int] = (34, 139, 34)       # Forest green - file system

    # Python syntax colors (following VS Code dark theme conventions)
    python_keyword_color: Tuple[int, int, int] = (86, 156, 214)    # Blue - language keywords
    python_string_color: Tuple[int, int, int] = (206, 145, 120)    # Orange - string literals
    python_number_color: Tuple[int, int, int] = (181, 206, 168)    # Light green - numeric values
    python_operator_color: Tuple[int, int, int] = (212, 212, 212)  # Light gray - operators/punctuation
    python_name_color: Tuple[int, int, int] = (156, 220, 254)      # Light blue - identifiers
    python_function_color: Tuple[int, int, int] = (220, 220, 170)  # Yellow - function names
    python_class_color: Tuple[int, int, int] = (78, 201, 176)      # Teal - class names
    python_builtin_color: Tuple[int, int, int] = (86, 156, 214)    # Blue - built-in functions
    python_comment_color: Tuple[int, int, int] = (106, 153, 85)    # Green - comments

    # Special highlighting colors
    exception_color: Tuple[int, int, int] = (255, 69, 0)       # Red orange - error types
    function_call_color: Tuple[int, int, int] = (255, 215, 0)  # Gold - function invocations
    boolean_color: Tuple[int, int, int] = (86, 156, 214)       # Blue - True/False/None

    # Enhanced syntax colors (Phase 1 additions)
    tuple_parentheses_color: Tuple[int, int, int] = (255, 215, 0)     # Gold - tuple delimiters
    set_braces_color: Tuple[int, int, int] = (255, 140, 0)            # Dark orange - set delimiters
    class_representation_color: Tuple[int, int, int] = (78, 201, 176) # Teal - <class 'name'>
    function_representation_color: Tuple[int, int, int] = (220, 220, 170) # Yellow - <function name>
    module_path_color: Tuple[int, int, int] = (147, 112, 219)         # Medium slate blue - module.path
    hex_number_color: Tuple[int, int, int] = (181, 206, 168)          # Light green - 0xFF
    scientific_notation_color: Tuple[int, int, int] = (181, 206, 168) # Light green - 1.23e-4
    binary_number_color: Tuple[int, int, int] = (181, 206, 168)       # Light green - 0b1010
    octal_number_color: Tuple[int, int, int] = (181, 206, 168)        # Light green - 0o755
    python_special_color: Tuple[int, int, int] = (255, 20, 147)       # Deep pink - __name__
    single_quoted_string_color: Tuple[int, int, int] = (206, 145, 120) # Orange - 'string'
    list_comprehension_color: Tuple[int, int, int] = (156, 220, 254)  # Light blue - [x for x in y]
    generator_expression_color: Tuple[int, int, int] = (156, 220, 254) # Light blue - (x for x in y)

    @classmethod
    def create_dark_theme(cls) -> 'LogColorScheme':
        """
        Create a dark theme variant with adjusted colors for dark backgrounds.

        Returns:
            LogColorScheme: Dark theme color scheme with higher contrast
        """
        return cls(
            # Enhanced colors for dark backgrounds with better contrast
            log_error_color=(255, 100, 100),    # Brighter red
            log_info_color=(120, 180, 230),     # Brighter steel blue
            timestamp_color=(160, 160, 160),    # Lighter gray
            python_string_color=(236, 175, 150), # Brighter orange
            python_number_color=(200, 230, 190), # Brighter green
            # Other colors remain the same as they work well on dark backgrounds
        )

    @classmethod
    def create_light_theme(cls) -> 'LogColorScheme':
        """
        Create a light theme variant with adjusted colors for light backgrounds.

        Returns:
            LogColorScheme: Light theme color scheme with appropriate contrast
        """
        return cls(
            # Darker colors for light backgrounds with WCAG compliance
            log_error_color=(180, 20, 40),       # Darker red
            log_info_color=(30, 80, 130),        # Darker steel blue
            log_warning_color=(200, 100, 0),     # Darker orange
            timestamp_color=(60, 60, 60),        # Darker gray
            logger_name_color=(100, 60, 160),    # Darker slate blue
            python_string_color=(150, 80, 60),   # Darker orange
            python_number_color=(120, 140, 100), # Darker green
            memory_address_color=(200, 120, 140), # Darker pink
            file_path_color=(20, 100, 20),       # Darker forest green
            exception_color=(200, 40, 0),        # Darker red orange
            # Adjust other colors for light background contrast
        )

    def to_qcolor(self, color_tuple: Tuple[int, int, int]) -> QColor:
        """Convert RGB tuple to QColor object.

        Args:
            color_tuple: RGB color tuple (r, g, b)

        Returns:
            QColor: Corresponding QColor instance.
        """
        r, g, b = color_tuple
        return QColor(r, g, b)

class LogListModel(QAbstractListModel):
    """Lightweight list model storing log lines in a bounded ring buffer.

    The model is pure data; rendering and highlighting are handled by a
    delegate on top of this model.
    """

    MAX_LINES = 100_000

    def __init__(self, parent: Optional[QObject] = None):
        super().__init__(parent)
        self._lines: List[str] = []
        self._html_lines: List[Optional[str]] = []
        self._max_line_width: int = 0

    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:  # type: ignore[override]
        if parent.isValid():
            return 0
        return len(self._lines)

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):  # type: ignore[override]
        if not index.isValid():
            return None
        row = index.row()
        if row < 0 or row >= len(self._lines):
            return None
        if role == Qt.ItemDataRole.DisplayRole:
            return self._lines[row]
        return None

    def clear(self) -> None:
        """Clear all stored lines."""
        if not self._lines:
            return
        self.beginResetModel()
        self._lines.clear()
        self._html_lines.clear()
        self._max_line_width = 0
        self.endResetModel()

    def append_lines(self, lines: List) -> None:
        """Append new lines, enforcing the MAX_LINES ring buffer constraint."""
        if not lines:
            return

        normalized = []
        for item in lines:
            if isinstance(item, dict):
                normalized.append((item.get("text", ""), item.get("html")))
            elif isinstance(item, tuple) and len(item) == 2:
                normalized.append((item[0], item[1]))
            else:
                normalized.append((str(item), None))

        new_total = len(self._lines) + len(normalized)
        if new_total > self.MAX_LINES:
            remove_count = new_total - self.MAX_LINES
            if remove_count >= len(self._lines):
                # Replace entire buffer with newest lines only.
                self.beginResetModel()
                self._lines = [item[0] for item in normalized[-self.MAX_LINES :]]
                self._html_lines = [item[1] for item in normalized[-self.MAX_LINES :]]
                self._max_line_width = max((len(line) for line in self._lines), default=0)
                self.endResetModel()
                return

            # Drop oldest lines.
            removed_lines = self._lines[:remove_count]
            removed_had_max_width = any(
                len(line) == self._max_line_width for line in removed_lines
            )
            self.beginRemoveRows(QModelIndex(), 0, remove_count - 1)
            del self._lines[:remove_count]
            del self._html_lines[:remove_count]
            self.endRemoveRows()

            # If the previous max-width line was evicted, recompute from remaining lines.
            if removed_had_max_width:
                self._max_line_width = max((len(line) for line in self._lines), default=0)

        start_row = len(self._lines)
        end_row = start_row + len(normalized) - 1
        self.beginInsertRows(QModelIndex(), start_row, end_row)
        for text, html in normalized:
            self._lines.append(text)
            self._html_lines.append(html)
            if len(text) > self._max_line_width:
                self._max_line_width = len(text)
        self.endInsertRows()

    def max_line_width(self) -> int:
        return self._max_line_width

    def html_for_row(self, row: int) -> Optional[str]:
        if row < 0 or row >= len(self._html_lines):
            return None
        return self._html_lines[row]

    def iter_lines(self) -> List[str]:
        """Expose lines for read-only access (e.g., search)."""
        return self._lines


@dataclass
class HighlightedSegment:
    """A segment of text with formatting information."""
    start: int
    length: int
    color: Tuple[int, int, int]
    bold: bool = False
    italic: bool = False


class SelectableDocument(QTextDocument):
    """
    QTextDocument wrapper that manages text cursor and selection state.

    Enables text selection within QListView delegate items by tracking cursor position
    and applying visual selection highlighting using QTextCharFormat.

    Based on Qt Forum solution for interactive elements in delegates.
    """

    def __init__(self, parent: Optional[QObject] = None) -> None:
        super().__init__(parent)
        self.setDocumentMargin(0)
        self._text_cursor = QTextCursor(self)
        self._selected_word_on_double_click = QTextCursor(self)
        self._cached_text_width = -1  # Track last width to avoid unnecessary relayout

    def setTextWidth(self, width: float) -> None:
        """Override to avoid unnecessary relayout when width hasn't changed."""
        # Only call parent if width actually changed (prevents position shifting)
        if int(width) != int(self._cached_text_width):
            super().setTextWidth(width)
            self._cached_text_width = width

    def selectionStart(self) -> int:
        """Get the start position of the current selection."""
        return self._text_cursor.selectionStart()

    def selectionEnd(self) -> int:
        """Get the end position of the current selection."""
        return self._text_cursor.selectionEnd()

    def setCursorPosition(self, pos: int, mode: QTextCursor.MoveMode = QTextCursor.MoveMode.MoveAnchor) -> None:
        """
        Set cursor position and update selection highlighting.

        Args:
            pos: Character position in document
            mode: MoveAnchor (clear selection) or KeepAnchor (extend selection)
        """
        # Clamp position to avoid Qt out-of-range warnings
        max_pos = max(0, self.characterCount() - 1)
        if pos < 0 or pos > max_pos:
            logger.debug(
                "SelectableDocument.setCursorPosition out-of-range pos=%d max=%d mode=%s",
                pos,
                max_pos,
                mode,
            )
        pos = max(0, min(pos, max_pos))

        # Clear previous selection highlighting
        if self._text_cursor.hasSelection():
            self._clearSelection()

        # Update cursor position
        self._text_cursor.setPosition(pos, mode)

        # Apply selection highlighting if we have a selection
        if self._text_cursor.hasSelection():
            self._highlightSelection()

    def moveCursorPosition(
        self,
        op: QTextCursor.MoveOperation,
        mode: QTextCursor.MoveMode = QTextCursor.MoveMode.MoveAnchor,
        n: int = 1
    ) -> bool:
        """
        Move cursor by operation and update highlighting.

        Args:
            op: Move operation (e.g., StartOfWord, EndOfWord)
            mode: MoveAnchor or KeepAnchor
            n: Number of times to repeat operation

        Returns:
            True if move was successful
        """
        # Clear previous selection highlighting
        if self._text_cursor.hasSelection():
            self._clearSelection()

        # Move cursor
        result = self._text_cursor.movePosition(op, mode, n)

        # Apply selection highlighting if we have a selection
        if self._text_cursor.hasSelection():
            self._highlightSelection()

        return result

    def select(self, selection: QTextCursor.SelectionType) -> None:
        """
        Select text by type (word, line, etc.) and update highlighting.

        Args:
            selection: Type of selection (WordUnderCursor, LineUnderCursor, Document)
        """
        # Clear previous selection highlighting
        if self._text_cursor.hasSelection():
            self._clearSelection()

        # Select text
        self._text_cursor.select(selection)

        # Store the word selection for word-wise extension during drag
        self._selected_word_on_double_click = QTextCursor(self._text_cursor)

        # Apply selection highlighting
        if self._text_cursor.hasSelection():
            self._highlightSelection()

    def extendWordwiseSelection(self, newPos: int) -> None:
        """
        Extend selection to word boundaries when dragging after double-click.

        This implements the behavior where double-clicking selects a word, then
        dragging extends the selection by whole words rather than characters.

        Args:
            newPos: New cursor position from mouse drag
        """
        # Clear previous selection highlighting
        if self._text_cursor.hasSelection():
            self._clearSelection()

        max_pos = max(0, self.characterCount() - 1)
        newPos = max(0, min(newPos, max_pos))

        tempCursor = QTextCursor(self._text_cursor)
        tempCursor.setPosition(newPos, QTextCursor.MoveMode.KeepAnchor)

        # Move to start of word at new position
        if not tempCursor.movePosition(QTextCursor.MoveOperation.StartOfWord):
            return
        wordStartPos = tempCursor.position()

        # Move to end of word at new position
        if not tempCursor.movePosition(QTextCursor.MoveOperation.EndOfWord):
            return
        wordEndPos = tempCursor.position()

        # Extend selection to word boundary based on drag direction
        if newPos < self._selected_word_on_double_click.position():
            # Dragging left - extend to start of word
            end_pos = max(0, min(self._text_cursor.selectionEnd(), max_pos))
            start_pos = max(0, min(wordStartPos, max_pos))
            self._text_cursor.setPosition(end_pos)
            self._text_cursor.setPosition(start_pos, QTextCursor.MoveMode.KeepAnchor)
        else:
            # Dragging right - extend to end of word
            start_pos = max(0, min(self._text_cursor.selectionStart(), max_pos))
            end_pos = max(0, min(wordEndPos, max_pos))
            self._text_cursor.setPosition(start_pos)
            self._text_cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)

        # Apply selection highlighting
        if self._text_cursor.hasSelection():
            self._highlightSelection()

    def selectedText(self) -> str:
        """Get the currently selected text."""
        return self._text_cursor.selectedText()

    def _clearSelection(self) -> None:
        """
        Remove selection highlighting from the previously selected region.

        This preserves the underlying syntax highlighting by only clearing
        the background color that was added for selection.
        """
        if not self._text_cursor.hasSelection():
            return

        # Create a cursor for the selected region
        clearCursor = QTextCursor(self._text_cursor)

        # Create format that only clears background (preserves foreground/syntax colors)
        clearFormat = QTextCharFormat()
        clearFormat.setBackground(QColor(0, 0, 0, 0))  # Transparent background

        # Merge so existing foreground/weight/italic syntax formatting is preserved.
        clearCursor.mergeCharFormat(clearFormat)

    def _highlightSelection(self) -> None:
        """
        Apply visual selection highlighting using system palette colors.

        This only modifies the background color, preserving the syntax highlighting
        foreground colors.
        """
        if not self._text_cursor.hasSelection():
            return

        # Create a cursor for the selected region
        highlightCursor = QTextCursor(self._text_cursor)

        # Create format that only sets background (preserves foreground/syntax colors)
        selectFormat = QTextCharFormat()
        selectFormat.setBackground(QApplication.palette().highlight().color())
        # Don't set foreground - preserve syntax highlighting colors

        # Merge so existing syntax formatting is preserved.
        highlightCursor.mergeCharFormat(selectFormat)


class HighlightSignals(QObject):
    """Signals for background highlighting worker."""
    finished = pyqtSignal(object, object)  # (cache_key, List[HighlightedSegment])


class HighlightWorker(QRunnable):
    """Background worker that does expensive regex parsing to extract formatting info.

    This runs all the expensive regex matching in a background thread and returns
    just the formatting information (which segments should be what color).
    The main thread then applies this to a QTextDocument.
    """

    def __init__(self, text: str, cache_key: Tuple[str, str, int],
                 color_scheme: LogColorScheme, signals: HighlightSignals):
        super().__init__()
        self.text = text
        self.cache_key = cache_key
        self.color_scheme = color_scheme
        self.signals = signals

    def run(self):
        """Request formatting segments from subprocess parser."""
        if not LogHighlightClient.is_available():
            return
        segments = LogHighlightClient.parse_line(self.text)
        if segments is None:
            return
        converted = []
        for seg in segments:
            converted.append(
                HighlightedSegment(
                    start=seg.start,
                    length=seg.length,
                    color=seg.color,
                    bold=seg.bold,
                )
            )
        self.signals.finished.emit(self.cache_key, converted)


class LogItemDelegate(QStyledItemDelegate):
    """Delegate responsible for per-line coloring, regex-based syntax, and search highlighting.

    We avoid document-wide highlighting and instead reuse the existing
    LogHighlighter rules on a per-line QTextDocument that is only used
    for visible rows. This keeps work bounded by visible rows and line
    length, independent of total log size.

    Performance optimization:
    1. Cache highlighted QTextDocuments to avoid re-running expensive regex highlighting
    2. Perform highlighting in background thread pool to never block UI
    3. Paint with plain text if highlighting not ready yet (progressive enhancement)
    """

    # Cache configuration
    MAX_CACHE_SIZE = 10000  # Maximum number of unique lines to cache

    def __init__(
        self,
        color_scheme: Optional[LogColorScheme] = None,
        parent: Optional[QWidget] = None,
    ) -> None:
        super().__init__(parent)
        self._color_scheme = color_scheme or LogColorScheme.create_dark_theme()
        self._search_text: str = ""
        self._case_sensitive: bool = False

        # Pre-computed search match cache: set of row indices that match search
        self._search_match_rows: Set[int] = set()

        # Thread pool for background highlighting
        self._thread_pool = QThreadPool.globalInstance()
        self._highlighting_enabled = True

        # Signals for background workers
        self._highlight_signals = HighlightSignals()
        self._highlight_signals.finished.connect(self._on_highlighting_finished)

        # Cache: maps (text, font_family, font_size) -> List[HighlightedSegment]
        # Stores the parsed formatting information from background thread
        self._segment_cache: Dict[Tuple[str, str, int], List[HighlightedSegment]] = {}
        self._cache_access_order: List[Tuple[str, str, int]] = []  # LRU tracking

        # Track which lines are being highlighted in background
        self._pending_highlights: Set[Tuple[str, str, int]] = set()

        # Text selection state - support multi-line selection
        self._interactive_documents: Dict[int, SelectableDocument] = {}  # Map row -> document with selection
        self._log_model: Optional[LogListModel] = None
        self._current_render_row = -1

        # QTextDocument cache: maps (text, font_family, font_size, width) -> QTextDocument
        # Stores rendered documents to avoid recreation on every paint
        self._document_cache: Dict[Tuple[str, str, int, int], QTextDocument] = {}
        self._doc_cache_access_order: List[Tuple[str, str, int, int]] = []
        self.MAX_DOC_CACHE_SIZE = 500  # Cache fewer documents (they're heavier than segments)

        # sizeHint cache: maps (text, font_family, font_size, width) -> QSize
        self._size_hint_cache: Dict[Tuple[str, str, int, int], QSize] = {}

        # Debounce timer for viewport updates
        self._viewport_update_timer = QTimer()
        self._viewport_update_timer.setSingleShot(True)
        self._viewport_update_timer.setInterval(16)  # ~60fps
        self._viewport_update_timer.timeout.connect(self._do_viewport_update)
        self._viewport_needs_update = False
        self._fixed_row_height: Optional[int] = None

    def set_fixed_row_height(self, height: int) -> None:
        self._fixed_row_height = height if height > 0 else None

    def set_search_state(self, text: str, case_sensitive: bool) -> None:
        """Update search text used for line-level search highlighting."""
        self._search_text = text or ""
        self._case_sensitive = case_sensitive
        # Clear search match cache - will be rebuilt on-demand during paint
        self._search_match_rows.clear()

    def _precompute_search_matches(self, model) -> None:
        """Pre-compute which rows match the current search query."""
        self._search_match_rows.clear()
        if not self._search_text:
            return

        needle = self._search_text if self._case_sensitive else self._search_text.lower()
        for row in range(model.rowCount()):
            index = model.index(row, 0)
            text = index.data(Qt.DisplayRole)
            if text:
                haystack = text if self._case_sensitive else text.lower()
                if needle in haystack:
                    self._search_match_rows.add(row)

    def _do_viewport_update(self) -> None:
        """Perform the actual viewport update (called by debounce timer)."""
        if self._viewport_needs_update and self.parent() and isinstance(self.parent(), QListView):
            self.parent().viewport().update()
            self._viewport_needs_update = False

    def hasInteractiveDocument(self, row: int) -> bool:
        """Check if a row has an interactive document with selection."""
        return row in self._interactive_documents

    def getInteractiveDocument(self, row: int) -> Optional[SelectableDocument]:
        """Get the interactive document for a specific row."""
        return self._interactive_documents.get(row)

    def setInteractiveDocument(self, row: int, doc: SelectableDocument) -> None:
        """Set the interactive document for a specific row."""
        self._interactive_documents[row] = doc

    def clearInteractiveDocuments(self) -> None:
        """Clear all interactive documents."""
        self._interactive_documents.clear()

    def createInteractiveDocument(self, text: str, font: QFont, width: int = 0) -> SelectableDocument:
        """
        Create a SelectableDocument with syntax highlighting applied.

        This is used by LogListView when creating a new interactive document
        for text selection. It applies the same syntax highlighting as normal
        rendering.

        Args:
            text: The log line text
            font: The font to use for rendering
            width: The width for text wrapping (optional)

        Returns:
            SelectableDocument with syntax highlighting applied
        """
        # Create the selectable document
        doc = SelectableDocument()
        doc.setDefaultFont(font)
        doc.setPlainText(text)
        # Log viewer uses single-line rows with horizontal scrolling. Keep
        # interactive docs unwrapped so selection does not hide off-viewport text.
        doc.setTextWidth(-1)

        # Try to get cached formatting segments
        segments = self._get_or_request_segments(text, font)

        if segments is not None:
            # Apply syntax highlighting
            self._apply_segments_to_document(doc, segments)

        return doc

    def _on_highlighting_finished(self, cache_key: Tuple[str, str, int],
                                   segments: List[HighlightedSegment]) -> None:
        """
        Cache formatting segments when background parsing completes.

        Args:
            cache_key: Cache key for this line
            segments: List of formatting segments from background thread
        """
        # Remove from pending set
        self._pending_highlights.discard(cache_key)

        # Add to cache
        self._segment_cache[cache_key] = segments
        self._cache_access_order.append(cache_key)

        # Enforce cache size limit (LRU eviction)
        if len(self._segment_cache) > self.MAX_CACHE_SIZE:
            oldest_key = self._cache_access_order.pop(0)
            del self._segment_cache[oldest_key]

        # CRITICAL: Invalidate any cached documents for this text+font combo
        # so they get recreated with the new highlighting
        text, font_family, font_size = cache_key
        keys_to_remove = [k for k in self._document_cache.keys()
                         if k[0] == text and k[1] == font_family and k[2] == font_size]
        for key in keys_to_remove:
            del self._document_cache[key]
            if key in self._doc_cache_access_order:
                self._doc_cache_access_order.remove(key)

        # Trigger debounced repaint (batches multiple highlight completions)
        self._viewport_needs_update = True
        if not self._viewport_update_timer.isActive():
            self._viewport_update_timer.start()

    def _get_or_request_segments(self, text: str, font: QFont) -> Optional[List[HighlightedSegment]]:
        """
        Get cached formatting segments or start async parsing.

        This dramatically improves paint performance by:
        1. Running expensive regex parsing in background thread (never blocks UI)
        2. Caching the results for instant reuse
        3. Returning None if not ready (caller paints plain text as fallback)

        Args:
            text: The log line text
            font: The font to use for rendering

        Returns:
            List[HighlightedSegment] if cached/ready, None if still parsing in background
        """
        # Create cache key from text and font properties
        cache_key = (text, font.family(), font.pointSize())

        # Check cache first
        if cache_key in self._segment_cache:
            # Move to end of access order (LRU)
            self._cache_access_order.remove(cache_key)
            self._cache_access_order.append(cache_key)
            return self._segment_cache[cache_key]

        # Not in cache - start async parsing if not already pending
        if not self._highlighting_enabled:
            return None
        if cache_key not in self._pending_highlights:
            self._pending_highlights.add(cache_key)
            worker = HighlightWorker(text, cache_key, self._color_scheme, self._highlight_signals)
            self._thread_pool.start(worker)

        # Return None - caller will paint plain text until parsing completes
        return None

    def _apply_segments_to_document(self, doc: QTextDocument, segments: List[HighlightedSegment]) -> None:
        """
        Apply formatting segments to a QTextDocument (on main thread).

        Args:
            doc: Document to format
            segments: Formatting segments from background parsing
        """
        cursor = QTextCursor(doc)

        for segment in segments:
            if segment.start < 0 or segment.length <= 0:
                continue
            doc_len = doc.characterCount()
            if segment.start >= doc_len:
                logger.debug(
                    "Skipping highlight segment out of range start=%d doc_len=%d",
                    segment.start,
                    doc_len,
                )
                continue
            end = segment.start + segment.length
            if end > doc_len:
                end = doc_len
                if end <= segment.start:
                    continue
            # Select the segment
            cursor.setPosition(segment.start)
            cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)

            # Create format
            fmt = QTextCharFormat()
            fmt.setForeground(QColor(*segment.color))
            if segment.bold:
                fmt.setFontWeight(QFont.Weight.Bold)
            if segment.italic:
                fmt.setFontItalic(True)

            # Apply format
            cursor.setCharFormat(fmt)

    def _get_or_create_document(self, text: str, font: QFont, width: int) -> QTextDocument:
        """
        Get cached QTextDocument or create a new one with syntax highlighting.

        This avoids recreating QTextDocument objects on every paint event,
        which is one of the main performance bottlenecks.

        Args:
            text: The log line text
            font: The font to use
            width: The width for text wrapping

        Returns:
            Cached or newly created QTextDocument
        """
        # Create cache key including width (important for proper wrapping)
        doc_cache_key = (text, font.family(), font.pointSize(), width)

        # Check document cache first
        if doc_cache_key in self._document_cache:
            # Move to end of access order (LRU)
            self._doc_cache_access_order.remove(doc_cache_key)
            self._doc_cache_access_order.append(doc_cache_key)
            return self._document_cache[doc_cache_key]

        # Not cached - create new document
        doc = QTextDocument()
        doc.setDocumentMargin(0)  # Remove default 4px margin to prevent position shifts
        doc.setDefaultFont(font)
        doc.setPlainText(text)
        # For fixed-height log rows we intentionally keep layout unwrapped and
        # rely on horizontal scrolling for long lines.
        if self._fixed_row_height is not None:
            doc.setTextWidth(-1)
        else:
            doc.setTextWidth(width)

        # Try to apply syntax highlighting if available
        if not self._highlighting_enabled:
            html = None
            if hasattr(self, "_log_model") and self._log_model is not None:
                try:
                    html = self._log_model.html_for_row(self._current_render_row)
                except Exception:
                    html = None
            if html:
                doc.setHtml(html)
            else:
                doc.setPlainText(text)
        else:
            segments = self._get_or_request_segments(text, font)
            if segments is not None:
                self._apply_segments_to_document(doc, segments)

        # Add to cache
        self._document_cache[doc_cache_key] = doc
        self._doc_cache_access_order.append(doc_cache_key)

        # Enforce cache size limit (LRU eviction)
        if len(self._document_cache) > self.MAX_DOC_CACHE_SIZE:
            oldest_doc_key = self._doc_cache_access_order.pop(0)
            del self._document_cache[oldest_doc_key]

        return doc

    def paint(self, painter, option, index):  # type: ignore[override]
        opt = QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        text = opt.text
        row = index.row()
        self._current_render_row = row

        # Draw simple background - skip Qt's drawControl entirely to avoid any focus/hover/selection styling
        painter.save()

        # Apply search highlight background if this row matches
        if self._search_text and row in self._search_match_rows:
            painter.fillRect(opt.rect, QColor(255, 255, 0, 60))

        painter.restore()

        # Check if this row has an interactive document with text selection
        if self.hasInteractiveDocument(row):
            # Use the interactive document which has selection state
            doc = self.getInteractiveDocument(row)
            if doc is None:
                # Fallback to cached document rendering
                doc = self._get_or_create_document(text, opt.font, opt.rect.width())
        else:
            # Normal rendering: use cached document (avoids QTextDocument recreation!)
            doc = self._get_or_create_document(text, opt.font, opt.rect.width())

        # Paint the document
        context = QAbstractTextDocumentLayout.PaintContext()
        context.palette = opt.palette

        painter.save()
        painter.translate(opt.rect.topLeft())
        painter.setClipRect(0, 0, opt.rect.width(), opt.rect.height())
        doc.documentLayout().draw(painter, context)
        painter.restore()

    def sizeHint(self, option, index):  # type: ignore[override]
        """Return height large enough to show wrapped text for this row.

        We compute the document layout for the current line using the
        available viewport width so that long log lines wrap instead of
        being clipped or forcing a horizontal scrollbar.

        This method is heavily optimized with caching since Qt calls it
        frequently during scrolling, resizing, and layout updates.
        """
        if self._fixed_row_height is not None:
            row = index.row()
            if not self.hasInteractiveDocument(row):
                width = option.rect.width() if option.rect.width() > 0 else 800
                if self._log_model is not None:
                    max_chars = self._log_model.max_line_width()
                    if max_chars > 0:
                        metrics = QFontMetrics(option.font)
                        width = metrics.horizontalAdvance("M") * max_chars + 8
                return QSize(width, self._fixed_row_height)

        text = index.data(Qt.DisplayRole)
        if text is None:
            return super().sizeHint(option, index)

        opt = QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        # Determine available width for wrapping. Prefer the parent
        # QListView viewport width when possible so row heights respond
        # to window resizing.
        width = opt.rect.width()
        parent = self.parent()
        if width <= 0 and isinstance(parent, QListView) and parent.viewport() is not None:
            width = parent.viewport().width()
        if width <= 0:
            width = 800  # conservative fallback, not critical for layout

        # Check sizeHint cache first
        cache_key = (text, opt.font.family(), opt.font.pointSize(), width)
        if cache_key in self._size_hint_cache:
            return self._size_hint_cache[cache_key]

        # Not cached - calculate size
        # Reuse document from cache if available (avoids recreation)
        doc = self._get_or_create_document(text, opt.font, width)
        doc_size = doc.size()
        height = int(doc_size.height())

        # Add a small vertical padding so text is not flush with borders.
        size = QSize(width, height + 4)

        # Cache the result
        self._size_hint_cache[cache_key] = size

        return size

    def clear_cache(self) -> None:
        """Clear all caches. Call this when resetting the log viewer or when search changes."""
        self._segment_cache.clear()
        self._cache_access_order.clear()
        self._pending_highlights.clear()
        self._document_cache.clear()
        self._doc_cache_access_order.clear()
        self._size_hint_cache.clear()
        self._search_match_rows.clear()
        logger.debug("LogItemDelegate all caches cleared")


class LogListView(QListView):
    """
    Custom QListView that enables text selection within and across items.

    Handles mouse events to track cursor position in QTextDocument and manage
    text selection state. Supports single-line and multi-line selection.
    Works in conjunction with LogItemDelegate to paint selection highlighting.

    Based on Qt Forum solution for interactive elements in delegates.
    """

    def __init__(self, parent: Optional[QWidget] = None) -> None:
        super().__init__(parent)

        # State for tracking text selection
        self._selection_start_index = QModelIndex()  # Row where selection started
        self._selection_end_index = QModelIndex()  # Row where selection ends
        self._selection_start_pos = 0  # Character position where selection started
        self._selection_end_pos = 0  # Character position where selection ends
        self._is_selecting = False  # Whether we're currently selecting
        self._word_selection = False  # Whether in word-wise selection mode (double-click)

        # Auto-scroll when dragging selection past edges
        self._auto_scroll_timer = QTimer()
        self._auto_scroll_timer.timeout.connect(self._auto_scroll_during_selection)
        self._auto_scroll_distance = 0  # Distance from edge in pixels (negative = up, positive = down)
        self._last_mouse_pos = QPoint()  # Track mouse position for auto-scroll

        # Enable mouse tracking for cursor changes
        self.setMouseTracking(True)

    def updateGeometries(self) -> None:
        super().updateGeometries()
        model = self.model()
        if model is not None and model.rowCount() > 0:
            index = model.index(0, 0)
            if index.isValid():
                rect = self.visualRect(index)
                self.horizontalScrollBar().setPageStep(max(1, rect.width()))

    def mousePressEvent(self, event) -> None:  # type: ignore[override]
        """Handle mouse press to start text selection."""
        mousePos = event.pos()
        clickedIndex = self.indexAt(mousePos)

        if not clickedIndex.isValid():
            # Clicked on empty space - clear any selection
            self._clearSelection()
            event.accept()
            return

        # Clear any previous selection
        self._clearSelection()

        # Get text for this row
        text = clickedIndex.data(Qt.ItemDataRole.DisplayRole)
        if text is None:
            event.accept()
            return

        # Calculate character position at mouse click
        itemRect = self.visualRect(clickedIndex)
        relativePos = mousePos - itemRect.topLeft()
        relativePosF = QPointF(relativePos)

        # Create temporary document to calculate cursor position
        tempDoc = QTextDocument()
        tempDoc.setDefaultFont(self.font())
        tempDoc.setPlainText(str(text))
        # Keep hit testing on an unwrapped line to match visual rendering.
        tempDoc.setTextWidth(-1)
        charPos = tempDoc.documentLayout().hitTest(relativePosF, Qt.HitTestAccuracy.FuzzyHit)

        # Start selection
        self._selection_start_index = clickedIndex
        self._selection_end_index = clickedIndex
        self._selection_start_pos = charPos
        self._selection_end_pos = charPos
        self._is_selecting = True
        self._word_selection = False

        # Update display
        self._updateSelection()

        # Don't call super() - we don't want QListView's row selection behavior
        event.accept()

    def mouseMoveEvent(self, event) -> None:  # type: ignore[override]
        """Handle mouse move to update cursor icon and extend selection."""
        mousePos = event.pos()
        self._last_mouse_pos = mousePos

        # Update cursor icon based on whether we're over a valid item
        if self.indexAt(mousePos).isValid():
            if not QApplication.overrideCursor() or QApplication.overrideCursor().shape() != Qt.CursorShape.IBeamCursor:
                QApplication.setOverrideCursor(Qt.CursorShape.IBeamCursor)
        else:
            QApplication.restoreOverrideCursor()

        # If left button is pressed and we're selecting, update selection
        if self._is_selecting and (event.buttons() & Qt.MouseButton.LeftButton):
            # Check if mouse is past viewport edges for auto-scrolling
            viewport_rect = self.viewport().rect()
            scroll_margin = 50  # Pixels from edge to start scrolling

            if mousePos.y() < 0:
                # Mouse is above viewport - calculate distance from top
                # Distance is negative (how far above), speed increases with distance
                self._auto_scroll_distance = mousePos.y()  # Negative value
                if not self._auto_scroll_timer.isActive():
                    self._auto_scroll_timer.start(50)  # Scroll every 50ms
            elif mousePos.y() > viewport_rect.height():
                # Mouse is below viewport - calculate distance from bottom
                # Distance is positive (how far below), speed increases with distance
                self._auto_scroll_distance = mousePos.y() - viewport_rect.height()  # Positive value
                if not self._auto_scroll_timer.isActive():
                    self._auto_scroll_timer.start(50)
            elif mousePos.y() < scroll_margin:
                # Mouse is near top edge but still inside - slow scroll up
                # Map 0-50px to 0-1.0 factor
                factor = 1.0 - (mousePos.y() / scroll_margin)
                self._auto_scroll_distance = -scroll_margin * factor
                if not self._auto_scroll_timer.isActive():
                    self._auto_scroll_timer.start(50)
            elif mousePos.y() > viewport_rect.height() - scroll_margin:
                # Mouse is near bottom edge but still inside - slow scroll down
                # Map (height-50)-height to 0-1.0 factor
                distance_from_bottom = viewport_rect.height() - mousePos.y()
                factor = 1.0 - (distance_from_bottom / scroll_margin)
                self._auto_scroll_distance = scroll_margin * factor
                if not self._auto_scroll_timer.isActive():
                    self._auto_scroll_timer.start(50)
            else:
                # Mouse is within safe zone - stop auto-scrolling
                self._auto_scroll_timer.stop()
                self._auto_scroll_distance = 0

            currentIndex = self.indexAt(mousePos)

            if not currentIndex.isValid():
                # Mouse is outside items but we're still selecting - keep last valid index
                event.accept()
                return

            # Get text for current row
            text = currentIndex.data(Qt.ItemDataRole.DisplayRole)
            if text is None:
                event.accept()
                return

            # Calculate character position at current mouse position
            itemRect = self.visualRect(currentIndex)
            relativePos = mousePos - itemRect.topLeft()
            relativePosF = QPointF(relativePos)

            # Create temporary document to calculate cursor position
            tempDoc = QTextDocument()
            tempDoc.setDefaultFont(self.font())
            tempDoc.setPlainText(str(text))
            tempDoc.setTextWidth(-1)
            charPos = tempDoc.documentLayout().hitTest(relativePosF, Qt.HitTestAccuracy.FuzzyHit)

            # Update selection end
            self._selection_end_index = currentIndex
            self._selection_end_pos = charPos

            # Update display
            self._updateSelection()

            event.accept()
        else:
            # Not selecting - stop auto-scroll
            self._auto_scroll_timer.stop()
            self._auto_scroll_distance = 0
            super().mouseMoveEvent(event)

    def mouseDoubleClickEvent(self, event) -> None:  # type: ignore[override]
        """Handle double-click to select word."""
        # TODO: Implement word selection for multi-line selection
        # For now, just accept the event
        event.accept()

    def mouseReleaseEvent(self, event) -> None:  # type: ignore[override]
        """Handle mouse release to copy selected text to clipboard."""
        if self._is_selecting:
            # Stop auto-scrolling
            self._auto_scroll_timer.stop()
            self._auto_scroll_distance = 0

            # Get selected text and copy to clipboard
            selectedText = self._getSelectedText()
            if selectedText:
                QApplication.clipboard().setText(selectedText)

            self._is_selecting = False

        # Don't call super() - we don't want QListView's row selection behavior
        event.accept()

    def wheelEvent(self, event) -> None:  # type: ignore[override]
        """Handle mouse wheel with Shift for horizontal scrolling."""
        if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
            # Shift + scroll = horizontal scroll
            delta = event.angleDelta().y()
            if delta != 0:
                scrollbar = self.horizontalScrollBar()
                if scrollbar is not None:
                    # Use a reasonable step size for horizontal scrolling
                    step = scrollbar.singleStep() * 3
                    scrollbar.setValue(scrollbar.value() - int(delta / 120) * step)
                    event.accept()
                    return
        # Normal vertical scrolling
        super().wheelEvent(event)

    def _auto_scroll_during_selection(self) -> None:
        """Auto-scroll the view when dragging selection past edges.

        Scroll speed is proportional to distance from viewport edge:
        - Inside viewport near edge (0-50px): Slow scroll (0-100% of base speed)
        - Outside viewport: Fast scroll (proportional to distance, max 10x base speed)
        """
        if not self._is_selecting or self._auto_scroll_distance == 0:
            return

        scrollbar = self.verticalScrollBar()
        current_value = scrollbar.value()

        # Base scroll speed in pixels per tick
        base_speed = 3
        max_speed_multiplier = 50

        # Calculate scroll speed based on distance from edge
        if abs(self._auto_scroll_distance) <= 50:
            # Near edge but inside viewport - proportional slow scroll
            scroll_pixels = base_speed * (abs(self._auto_scroll_distance) / 50)
        else:
            # Outside viewport - faster scroll proportional to distance
            # Map distance to speed multiplier (50-200px → 1x-10x)
            distance_outside = abs(self._auto_scroll_distance) - 50
            multiplier = min(max_speed_multiplier, 1 + (distance_outside / 150) * (max_speed_multiplier - 1))
            scroll_pixels = base_speed * multiplier

        # Apply direction
        if self._auto_scroll_distance < 0:
            # Scroll up
            new_value = max(0, current_value - int(scroll_pixels))
            scrollbar.setValue(new_value)
        else:
            # Scroll down
            new_value = min(scrollbar.maximum(), current_value + int(scroll_pixels))
            scrollbar.setValue(new_value)

        # Update selection with current mouse position
        if self._last_mouse_pos:
            currentIndex = self.indexAt(self._last_mouse_pos)
            if currentIndex.isValid():
                text = currentIndex.data(Qt.ItemDataRole.DisplayRole)
                if text is not None:
                    itemRect = self.visualRect(currentIndex)
                    relativePos = self._last_mouse_pos - itemRect.topLeft()
                    relativePosF = QPointF(relativePos)

                    tempDoc = QTextDocument()
                    tempDoc.setDefaultFont(self.font())
                    tempDoc.setPlainText(str(text))
                    tempDoc.setTextWidth(-1)
                    charPos = tempDoc.documentLayout().hitTest(relativePosF, Qt.HitTestAccuracy.FuzzyHit)

                    self._selection_end_index = currentIndex
                    self._selection_end_pos = charPos
                    self._updateSelection()

    def _clearSelection(self) -> None:
        """Clear the current selection."""
        delegate = self.itemDelegate()
        if isinstance(delegate, LogItemDelegate):
            # Clear all interactive documents
            delegate.clearInteractiveDocuments()

        # Repaint previously selected rows
        if self._selection_start_index.isValid() and self._selection_end_index.isValid():
            startRow = self._selection_start_index.row()
            endRow = self._selection_end_index.row()
            minRow = min(startRow, endRow)
            maxRow = max(startRow, endRow)

            for row in range(minRow, maxRow + 1):
                index = self.model().index(row, 0)
                self.update(index)

        # Reset selection state
        self._selection_start_index = QModelIndex()
        self._selection_end_index = QModelIndex()
        self._selection_start_pos = 0
        self._selection_end_pos = 0
        self._is_selecting = False

    def _updateSelection(self) -> None:
        """Update the visual selection based on current selection state."""
        if not self._selection_start_index.isValid():
            return

        delegate = self.itemDelegate()
        if not isinstance(delegate, LogItemDelegate):
            return

        # Clear previous interactive documents
        delegate.clearInteractiveDocuments()

        # Determine selection range (start and end might be in different order)
        startRow = self._selection_start_index.row()
        endRow = self._selection_end_index.row()

        # Determine forward or backward selection
        if startRow < endRow or (startRow == endRow and self._selection_start_pos <= self._selection_end_pos):
            # Forward selection
            firstRow = startRow
            firstPos = self._selection_start_pos
            lastRow = endRow
            lastPos = self._selection_end_pos
        else:
            # Backward selection
            firstRow = endRow
            firstPos = self._selection_end_pos
            lastRow = startRow
            lastPos = self._selection_start_pos

        # Create documents for each selected line
        for row in range(firstRow, lastRow + 1):
            index = self.model().index(row, 0)
            text = index.data(Qt.ItemDataRole.DisplayRole)
            if text is None:
                continue

            # Get the viewport width for proper wrapping
            itemRect = self.visualRect(index)
            width = itemRect.width() if itemRect.width() > 0 else self.viewport().width()

            # Create SelectableDocument with wrapping and proper width
            doc = delegate.createInteractiveDocument(str(text), self.font(), width)
            max_pos = max(0, doc.characterCount() - 1)

            def clamp_pos(pos: int) -> int:
                return max(0, min(pos, max_pos))

            if row == firstRow and row == lastRow:
                # Single-line selection
                doc.setCursorPosition(clamp_pos(firstPos))
                doc.setCursorPosition(clamp_pos(lastPos), QTextCursor.MoveMode.KeepAnchor)
            elif row == firstRow:
                # First line - select from start position to end
                doc.setCursorPosition(clamp_pos(firstPos))
                doc.setCursorPosition(clamp_pos(len(str(text))), QTextCursor.MoveMode.KeepAnchor)
            elif row == lastRow:
                # Last line - select from beginning to end position
                doc.setCursorPosition(0)
                doc.setCursorPosition(clamp_pos(lastPos), QTextCursor.MoveMode.KeepAnchor)
            else:
                # Middle lines - select entire line
                doc.setCursorPosition(0)
                doc.setCursorPosition(clamp_pos(len(str(text))), QTextCursor.MoveMode.KeepAnchor)

            # Store document for this row
            delegate.setInteractiveDocument(row, doc)

            # Repaint this row
            self.update(index)

    def _getSelectedText(self) -> str:
        """Get the currently selected text."""
        if not self._selection_start_index.isValid():
            return ""

        startRow = self._selection_start_index.row()
        endRow = self._selection_end_index.row()

        if startRow == endRow:
            # Single-line selection
            text = self._selection_start_index.data(Qt.ItemDataRole.DisplayRole)
            if text is None:
                return ""

            minPos = min(self._selection_start_pos, self._selection_end_pos)
            maxPos = max(self._selection_start_pos, self._selection_end_pos)
            return str(text)[minPos:maxPos]
        else:
            # Multi-line selection
            minRow = min(startRow, endRow)
            maxRow = max(startRow, endRow)

            # Determine if we're selecting forward or backward
            if startRow < endRow or (startRow == endRow and self._selection_start_pos < self._selection_end_pos):
                # Forward selection
                firstRow = startRow
                firstPos = self._selection_start_pos
                lastRow = endRow
                lastPos = self._selection_end_pos
            else:
                # Backward selection
                firstRow = endRow
                firstPos = self._selection_end_pos
                lastRow = startRow
                lastPos = self._selection_start_pos

            lines = []
            for row in range(firstRow, lastRow + 1):
                index = self.model().index(row, 0)
                text = index.data(Qt.ItemDataRole.DisplayRole)
                if text is None:
                    continue

                textStr = str(text)
                if row == firstRow:
                    # First line - take from start position to end
                    lines.append(textStr[firstPos:])
                elif row == lastRow:
                    # Last line - take from beginning to end position
                    lines.append(textStr[:lastPos])
                else:
                    # Middle lines - take entire line
                    lines.append(textStr)

            return "\n".join(lines)

    def selectAllText(self) -> None:
        """Select all text in all log lines (Ctrl+A handler)."""
        if self.model() is None or self.model().rowCount() == 0:
            return

        # Select from first character of first row to last character of last row
        first_index = self.model().index(0, 0)
        last_index = self.model().index(self.model().rowCount() - 1, 0)

        last_text = last_index.data(Qt.ItemDataRole.DisplayRole)
        if last_text is None:
            return

        self._selection_start_index = first_index
        self._selection_start_pos = 0
        self._selection_end_index = last_index
        self._selection_end_pos = len(str(last_text))

        # Update the selection display
        self._updateSelection()

    def keyPressEvent(self, event) -> None:  # type: ignore[override]
        """Handle keyboard events for copy/paste operations."""
        # Ctrl+C to copy selected text
        if event.matches(QKeySequence.StandardKey.Copy):
            selectedText = self._getSelectedText()
            if selectedText:
                QApplication.clipboard().setText(selectedText)
            event.accept()
            return

        # Pass other events to parent
        super().keyPressEvent(event)

    def resizeEvent(self, event) -> None:  # type: ignore[override]
        """Handle resize events to invalidate width-dependent caches."""
        super().resizeEvent(event)

        # Clear width-dependent caches in delegate when viewport width changes
        delegate = self.itemDelegate()
        if isinstance(delegate, LogItemDelegate):
            # Only clear document and sizeHint caches (width-dependent)
            # Keep segment cache (width-independent) for performance
            delegate._document_cache.clear()
            delegate._doc_cache_access_order.clear()
            delegate._size_hint_cache.clear()


class LogFileDetector(QObject):
    """
    Detects new log files in directory using efficient file monitoring.

    Uses QFileSystemWatcher to monitor directory changes and set operations
    for efficient new file detection. Handles base_log_path as file prefix
    and watches the parent directory.
    """

    # Signals
    new_log_detected = pyqtSignal(object)  # LogFileInfo object
    _server_scan_complete = pyqtSignal(list)  # List of LogFileInfo from server scan

    def __init__(self, base_log_path: Optional[str] = None):
        """
        Initialize LogFileDetector.

        Args:
            base_log_path: Base path for subprocess log files (file prefix, not directory)
        """
        super().__init__()
        self._base_log_path = base_log_path
        self._previous_files: Set[Path] = set()
        self._watcher = QFileSystemWatcher()
        self._watcher.directoryChanged.connect(self._on_directory_changed)
        self._watching_directory: Optional[Path] = None

        logger.debug(f"LogFileDetector initialized with base_log_path: {base_log_path}")

    def start_watching(self, directory: Path) -> None:
        """
        Start watching directory for new log files.

        Args:
            directory: Directory to watch for new log files
        """
        if not directory.exists():
            logger.warning(f"Cannot watch non-existent directory: {directory}")
            return

        # Stop any existing watching
        self.stop_watching()

        # Add directory to watcher
        success = self._watcher.addPath(str(directory))
        if success:
            self._watching_directory = directory
            # Initialize previous files set
            self._previous_files = self.scan_directory(directory)
            logger.debug(f"Started watching directory: {directory}")
            logger.debug(f"Initial file count: {len(self._previous_files)}")
        else:
            logger.error(f"Failed to add directory to watcher: {directory}")

    def stop_watching(self) -> None:
        """Stop file watching and cleanup."""
        if self._watching_directory:
            self._watcher.removePath(str(self._watching_directory))
            self._watching_directory = None
            self._previous_files.clear()
            logger.debug("Stopped file watching")

    def scan_directory(self, directory: Path) -> Set[Path]:
        """
        Scan directory for .log files.

        Args:
            directory: Directory to scan

        Returns:
            Set[Path]: Set of Path objects for .log files found
        """
        try:
            log_files = set(directory.glob("*.log"))
            logger.debug(f"Scanned directory {directory}: found {len(log_files)} .log files")
            return log_files
        except (FileNotFoundError, PermissionError) as e:
            logger.warning(f"Error scanning directory {directory}: {e}")
            return set()

    def detect_new_files(self, current_files: Set[Path]) -> Set[Path]:
        """
        Use set.difference() to find new files efficiently.

        Args:
            current_files: Current set of files in directory

        Returns:
            Set[Path]: Set of newly discovered files
        """
        new_files = current_files.difference(self._previous_files)
        if new_files:
            logger.debug(f"Detected {len(new_files)} new files: {[f.name for f in new_files]}")

        # Update previous files set
        self._previous_files = current_files
        return new_files

    def _classify_log_path(self, file_path: Path) -> LogFileInfo:
        """Classify a log path using the discovery provider."""
        try:
            logs = self._log_discovery_provider.discover_logs(
                log_directory=file_path.parent,
                include_main_log=True,
            )
            for log_info in logs:
                if log_info.path == file_path:
                    return log_info
        except Exception as e:
            logger.debug(f"Provider classification failed for {file_path}: {e}")

        return LogFileInfo(path=file_path, log_type="unknown")



    def _on_directory_changed(self, directory_path: str) -> None:
        """
        Handle QFileSystemWatcher directory change signal.

        Args:
            directory_path: Path of directory that changed
        """
        directory = Path(directory_path)
        logger.debug(f"Directory changed: {directory}")

        # Scan directory for current files
        current_files = self.scan_directory(directory)

        # Detect new files
        new_files = self.detect_new_files(current_files)

        # Process new files
        for file_path in new_files:
            if file_path.exists():
                try:
                    log_info = self._classify_log_path(file_path)
                    logger.info(f"New relevant log file detected: {file_path} (type: {log_info.log_type})")
                    self.new_log_detected.emit(log_info)
                except Exception as e:
                    logger.error(f"Error classifying new log file {file_path}: {e}")


class LogHighlighter(QSyntaxHighlighter):
    """
    Advanced syntax highlighter for log files using Pygments.

    Provides sophisticated highlighting for OpenHCS log format with support for:
    - Log levels and timestamps
    - Python code snippets and data structures
    - Memory addresses and function signatures
    - Complex nested dictionaries and lists
    - Exception tracebacks and file paths
    """

    def __init__(self, parent: QTextDocument, color_scheme: LogColorScheme = None):
        """
        Initialize the log highlighter with optional color scheme.

        Args:
            parent: QTextDocument to apply highlighting to
            color_scheme: Color scheme to use (defaults to dark theme)
        """
        super().__init__(parent)
        self.color_scheme = color_scheme or LogColorScheme()
        self.setup_pygments_styles()
        self.setup_highlighting_rules()

    def setup_pygments_styles(self) -> None:
        """Setup Pygments token to QTextCharFormat mapping using color scheme."""
        cs = self.color_scheme  # Shorthand for readability

        # Create a mapping from Pygments tokens to Qt text formats
        self.token_formats = {
            # Log levels with distinct colors and backgrounds
            'log_critical': self._create_format(
                cs.to_qcolor(cs.log_critical_fg),
                cs.to_qcolor(cs.log_critical_bg),
                bold=True
            ),
            'log_error': self._create_format(cs.to_qcolor(cs.log_error_color), bold=True),
            'log_warning': self._create_format(cs.to_qcolor(cs.log_warning_color), bold=True),
            'log_info': self._create_format(cs.to_qcolor(cs.log_info_color), bold=True),
            'log_debug': self._create_format(cs.to_qcolor(cs.log_debug_color)),

            # Timestamps and metadata
            'timestamp': self._create_format(cs.to_qcolor(cs.timestamp_color)),
            'logger_name': self._create_format(cs.to_qcolor(cs.logger_name_color), bold=True),

            # Python syntax highlighting (for complex data structures)
            Token.Keyword: self._create_format(cs.to_qcolor(cs.python_keyword_color), bold=True),
            Token.String: self._create_format(cs.to_qcolor(cs.python_string_color)),
            Token.String.Single: self._create_format(cs.to_qcolor(cs.python_string_color)),
            Token.String.Double: self._create_format(cs.to_qcolor(cs.python_string_color)),
            Token.Number: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Number.Integer: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Number.Float: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Number.Hex: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Number.Oct: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Number.Bin: self._create_format(cs.to_qcolor(cs.python_number_color)),
            Token.Operator: self._create_format(cs.to_qcolor(cs.python_operator_color)),
            Token.Punctuation: self._create_format(cs.to_qcolor(cs.python_operator_color)),
            Token.Name: self._create_format(cs.to_qcolor(cs.python_name_color)),
            Token.Name.Function: self._create_format(cs.to_qcolor(cs.python_function_color), bold=True),
            Token.Name.Class: self._create_format(cs.to_qcolor(cs.python_class_color), bold=True),
            Token.Name.Builtin: self._create_format(cs.to_qcolor(cs.python_builtin_color)),
            Token.Comment: self._create_format(cs.to_qcolor(cs.python_comment_color)),
            Token.Literal: self._create_format(cs.to_qcolor(cs.python_number_color)),

            # Special patterns for log content
            'memory_address': self._create_format(cs.to_qcolor(cs.memory_address_color)),
            'file_path': self._create_format(cs.to_qcolor(cs.file_path_color)),
            'exception': self._create_format(cs.to_qcolor(cs.exception_color), bold=True),
            'function_call': self._create_format(cs.to_qcolor(cs.function_call_color)),
            'dict_key': self._create_format(cs.to_qcolor(cs.python_name_color)),
            'boolean': self._create_format(cs.to_qcolor(cs.boolean_color), bold=True),

            # Enhanced Python syntax elements (Phase 1)
            'tuple_parentheses': self._create_format(cs.to_qcolor(cs.tuple_parentheses_color)),
            'set_braces': self._create_format(cs.to_qcolor(cs.set_braces_color)),
            'class_representation': self._create_format(cs.to_qcolor(cs.class_representation_color), bold=True),
            'function_representation': self._create_format(cs.to_qcolor(cs.function_representation_color), bold=True),
            'module_path': self._create_format(cs.to_qcolor(cs.module_path_color)),
            'hex_number': self._create_format(cs.to_qcolor(cs.hex_number_color)),
            'scientific_notation': self._create_format(cs.to_qcolor(cs.scientific_notation_color)),
            'binary_number': self._create_format(cs.to_qcolor(cs.binary_number_color)),
            'octal_number': self._create_format(cs.to_qcolor(cs.octal_number_color)),
            'python_special': self._create_format(cs.to_qcolor(cs.python_special_color), bold=True),
            'single_quoted_string': self._create_format(cs.to_qcolor(cs.single_quoted_string_color)),
            'list_comprehension': self._create_format(cs.to_qcolor(cs.list_comprehension_color)),
            'generator_expression': self._create_format(cs.to_qcolor(cs.generator_expression_color)),
        }

    def _create_format(self, fg_color: QColor, bg_color: QColor = None, bold: bool = False) -> QTextCharFormat:
        """Create a QTextCharFormat with specified properties."""
        format = QTextCharFormat()
        format.setForeground(fg_color)
        if bg_color:
            format.setBackground(bg_color)
        if bold:
            format.setFontWeight(QFont.Weight.Bold)
        return format

    def setup_highlighting_rules(self) -> None:
        """Setup regex patterns for log-specific highlighting."""
        self.highlighting_rules = []

        # Log level patterns (highest priority)
        log_levels = [
            ("CRITICAL", self.token_formats['log_critical']),
            ("ERROR", self.token_formats['log_error']),
            ("WARNING", self.token_formats['log_warning']),
            ("INFO", self.token_formats['log_info']),
            ("DEBUG", self.token_formats['log_debug']),
        ]

        for level, format in log_levels:
            pattern = QRegularExpression(rf"\b{level}\b")
            self.highlighting_rules.append((pattern, format))

        # Timestamp pattern: YYYY-MM-DD HH:MM:SS,mmm
        timestamp_pattern = QRegularExpression(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}")
        self.highlighting_rules.append((timestamp_pattern, self.token_formats['timestamp']))

        # Logger names (module paths)
        logger_pattern = QRegularExpression(r"\b[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+\b")
        self.highlighting_rules.append((logger_pattern, self.token_formats['logger_name']))

        # Memory addresses (e.g., 0x7f1640dd8e00)
        memory_pattern = QRegularExpression(r"0x[0-9a-fA-F]+")
        self.highlighting_rules.append((memory_pattern, self.token_formats['memory_address']))

        # File paths in tracebacks
        filepath_pattern = QRegularExpression(r'["\']?/[^"\'\s]+\.py["\']?')
        self.highlighting_rules.append((filepath_pattern, self.token_formats['file_path']))

        # Exception names
        exception_pattern = QRegularExpression(r'\b[A-Z][a-zA-Z]*Error\b|\b[A-Z][a-zA-Z]*Exception\b')
        self.highlighting_rules.append((exception_pattern, self.token_formats['exception']))

        # Function calls with parentheses
        function_pattern = QRegularExpression(r'\b[a-zA-Z_][a-zA-Z0-9_]*\(\)')
        self.highlighting_rules.append((function_pattern, self.token_formats['function_call']))

        # Boolean values
        boolean_pattern = QRegularExpression(r'\b(True|False|None)\b')
        self.highlighting_rules.append((boolean_pattern, self.token_formats['boolean']))

        # Enhanced Python syntax elements

        # Single-quoted strings (complement to double-quoted)
        single_quote_pattern = QRegularExpression(r"'[^']*'")
        self.highlighting_rules.append((single_quote_pattern, self.token_formats['single_quoted_string']))

        # Class representations: <class 'module.ClassName'>
        class_repr_pattern = QRegularExpression(r"<class '[^']*'>")
        self.highlighting_rules.append((class_repr_pattern, self.token_formats['class_representation']))

        # Function representations: <function name at 0xaddress>
        function_repr_pattern = QRegularExpression(r"<function [^>]+ at 0x[0-9a-fA-F]+>")
        self.highlighting_rules.append((function_repr_pattern, self.token_formats['function_representation']))

        # Extended module paths
        module_path_pattern = QRegularExpression(r"\b[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*){2,}")
        self.highlighting_rules.append((module_path_pattern, self.token_formats['module_path']))

        # Hexadecimal numbers (beyond memory addresses): 0xFF, 0x1A2B
        hex_number_pattern = QRegularExpression(r"\b0[xX][0-9a-fA-F]+\b")
        self.highlighting_rules.append((hex_number_pattern, self.token_formats['hex_number']))

        # Scientific notation: 1.23e-4, 5.67E+10
        scientific_pattern = QRegularExpression(r"\b\d+\.?\d*[eE][+-]?\d+\b")
        self.highlighting_rules.append((scientific_pattern, self.token_formats['scientific_notation']))

        # Binary literals: 0b1010
        binary_pattern = QRegularExpression(r"\b0[bB][01]+\b")
        self.highlighting_rules.append((binary_pattern, self.token_formats['binary_number']))

        # Octal literals: 0o755
        octal_pattern = QRegularExpression(r"\b0[oO][0-7]+\b")
        self.highlighting_rules.append((octal_pattern, self.token_formats['octal_number']))

        # Python special constants: __name__, __main__, __file__, etc.
        python_special_pattern = QRegularExpression(r"\b__[a-zA-Z_][a-zA-Z0-9_]*__\b")
        self.highlighting_rules.append((python_special_pattern, self.token_formats['python_special']))

        logger.debug(f"Setup {len(self.highlighting_rules)} highlighting rules")

    def set_color_scheme(self, color_scheme: LogColorScheme) -> None:
        """
        Update the color scheme and refresh highlighting.

        Args:
            color_scheme: New color scheme to apply
        """
        self.color_scheme = color_scheme
        self.setup_pygments_styles()
        self.setup_highlighting_rules()
        # Trigger re-highlighting of the entire document
        self.rehighlight()
        logger.debug(f"Applied new color scheme with {len(self.token_formats)} token formats")

    def switch_to_dark_theme(self) -> None:
        """Switch to dark theme color scheme."""
        self.set_color_scheme(LogColorScheme.create_dark_theme())

    def switch_to_light_theme(self) -> None:
        """Switch to light theme color scheme."""
        self.set_color_scheme(LogColorScheme.create_light_theme())

    @classmethod
    def load_color_scheme_from_config(cls, config_path: str = None) -> LogColorScheme:
        """
        Load color scheme from external configuration file.

        Args:
            config_path: Path to JSON/YAML config file (optional)

        Returns:
            LogColorScheme: Loaded color scheme or default if file not found
        """
        if config_path and Path(config_path).exists():
            try:
                import json
                with open(config_path, 'r') as f:
                    config = json.load(f)

                # Create color scheme from config
                scheme_kwargs = {}
                for key, value in config.items():
                    if key.endswith('_color') or key.endswith('_fg') or key.endswith('_bg'):
                        if isinstance(value, list) and len(value) == 3:
                            scheme_kwargs[key] = tuple(value)

                return LogColorScheme(**scheme_kwargs)

            except Exception as e:
                logger.warning(f"Failed to load color scheme from {config_path}: {e}")

        return LogColorScheme()  # Return default scheme

    def highlightBlock(self, text: str) -> None:
        """
        Apply highlighting to text block using regex patterns.

        Uses regex patterns for log-specific content (timestamps, log levels, etc.).
        Fast and doesn't block the UI.
        """
        # Apply log-specific patterns
        for pattern, format in self.highlighting_rules:
            iterator = pattern.globalMatch(text)
            while iterator.hasNext():
                match = iterator.next()
                start = match.capturedStart()
                length = match.capturedLength()
                self.setFormat(start, length, format)


class LogFileLoader(QThread):
    """Background process for loading large log files without blocking UI."""

    # Signals
    chunk_loaded = pyqtSignal(list)  # Emits list[str] chunks
    load_finished = pyqtSignal()     # Emits when all chunks loaded
    load_failed = pyqtSignal(str)    # Emits error message on failure

    def __init__(self, log_path: Path, tail_lines: int = 100_000, chunk_lines: int = 1000):
        super().__init__()
        self.log_path = log_path
        self.tail_lines = tail_lines
        self.chunk_lines = chunk_lines
        self._process: Optional[QProcess] = None
        self._buffer = ""
        self._stderr_chunks: list[str] = []

    def start(self):  # type: ignore[override]
        """Spawn a worker process to load the file content."""
        try:
            if self._process and self._process.state() != QProcess.ProcessState.NotRunning:
                self._process.kill()
                self._process = None

            self._buffer = ""
            self._stderr_chunks = []

            self._process = QProcess()
            self._process.setProgram(sys.executable)
            args = [
                "-m",
                "pyqt_reactive.utils.log_streamer",
                str(self.log_path),
                "--tail-lines",
                str(self.tail_lines),
                "--chunk-lines",
                str(self.chunk_lines),
                "--html",
            ]
            logger.debug("Spawning log_streamer subprocess: %s %s", sys.executable, args)
            self._process.setArguments(args)
            self._process.readyReadStandardOutput.connect(self._on_stdout)
            self._process.readyReadStandardError.connect(self._on_stderr)
            self._process.finished.connect(self._on_finished)
            self._process.start()
        except Exception as e:
            self.load_failed.emit(str(e))

    def isRunning(self) -> bool:
        return bool(self._process and self._process.state() != QProcess.ProcessState.NotRunning)

    def wait(self, msecs: int = 0) -> None:
        if self._process:
            self._process.waitForFinished(msecs)

    def _on_stdout(self) -> None:
        if not self._process:
            return
        data = bytes(self._process.readAllStandardOutput())
        text = data.decode("utf-8", errors="replace")
        if not text:
            return

        self._buffer += text
        while "\n" in self._buffer:
            line, self._buffer = self._buffer.split("\n", 1)
            if not line.strip():
                continue
            try:
                payload = json.loads(line)
            except json.JSONDecodeError:
                continue

            if payload.get("type") == "chunk":
                lines = payload.get("lines", [])
                if lines:
                    self.chunk_loaded.emit(lines)
            elif payload.get("type") == "done":
                self.load_finished.emit()

    def _on_stderr(self) -> None:
        if not self._process:
            return
        data = bytes(self._process.readAllStandardError())
        text = data.decode("utf-8", errors="replace")
        if text:
            self._stderr_chunks.append(text)

    def _on_finished(self) -> None:
        stderr = "".join(self._stderr_chunks).strip()

        if stderr:
            self.load_failed.emit(stderr)
            return


class LogTailer(QThread):
    """Background thread for tailing log files without blocking UI."""

    # Signals
    new_content = pyqtSignal(str, int)  # Emits (new_content, new_file_position)
    log_rotated = pyqtSignal()  # Emits when log rotation detected
    error_occurred = pyqtSignal(str)  # Emits error message

    def __init__(self, log_path: Path, initial_position: int = 0):
        super().__init__()
        self.log_path = log_path
        self.file_position = initial_position
        self._running = False

    def run(self):
        """Continuously tail log file in background thread."""
        self._running = True

        while self._running:
            try:
                if not self.log_path.exists():
                    self.msleep(100)
                    continue

                # Get current file size
                current_size = self.log_path.stat().st_size

                # Handle log rotation (file size decreased)
                if current_size < self.file_position:
                    self.log_rotated.emit()
                    self.file_position = 0

                # Read new content if file grew
                if current_size > self.file_position:
                    with open(self.log_path, 'rb') as f:
                        f.seek(self.file_position)
                        new_data = f.read(current_size - self.file_position)

                    # Decode new content
                    try:
                        new_content = new_data.decode('utf-8', errors='replace')
                    except UnicodeDecodeError:
                        new_content = new_data.decode('latin-1', errors='replace')

                    if new_content:
                        self.new_content.emit(new_content, current_size)
                        self.file_position = current_size

            except (OSError, PermissionError) as e:
                self.error_occurred.emit(str(e))
            except Exception as e:
                self.error_occurred.emit(f"Unexpected error: {e}")

            # Sleep for 100ms before next check
            self.msleep(100)

    def stop(self):
        """Stop the tailing thread."""
        self._running = False


class LogViewerWindow(QMainWindow):
    """Main log viewer window with dropdown, search, and real-time tailing."""

    window_closed = pyqtSignal()
    _subprocess_scan_complete = pyqtSignal(list)  # Internal signal for async subprocess scan
    _server_scan_complete = pyqtSignal(list)  # Internal signal for async server scan

    def __init__(self, file_manager, service_adapter, parent=None):
        super().__init__(parent)
        self.file_manager = file_manager
        self.service_adapter = service_adapter
        self._log_discovery_provider = get_log_discovery_provider()
        self._server_scan_provider = get_server_scan_provider()
        if self._log_discovery_provider is None:
            raise RuntimeError("No log discovery provider registered. Call register_log_discovery_provider(...).")

        # State
        self.current_log_path: Optional[Path] = None
        self.current_file_position: int = 0
        self.auto_scroll_enabled: bool = True
        self.tailing_paused: bool = False
        self._pending_partial_line: str = ""

        # Search state
        self.current_search_text: str = ""
        self._search_case_sensitive: bool = False
        self._search_matches: List[int] = []
        self._search_match_cursor: int = -1

        # Update throttling to reduce UI load
        self._pending_lines: List[str] = []  # Buffer for pending log lines
        self._update_timer = QTimer()
        self._update_timer.setSingleShot(True)
        self._update_timer.timeout.connect(self._flush_pending_lines)
        self._update_throttle_ms = 50  # Minimum time between UI updates
        self._last_scroll_state = True  # Track if we were at bottom

        # Components
        self.log_selector: QComboBox = None
        self.search_toolbar: QToolBar = None
        self.log_view: QListView = None
        self.log_model: Optional[LogListModel] = None
        self.log_delegate: Optional[LogItemDelegate] = None
        self.file_detector: LogFileDetector = None
        self.tail_timer: QTimer = None  # Deprecated - kept for compatibility
        self.log_tailer: Optional[LogTailer] = None  # Async log tailer thread
        self.highlighter: Optional[LogHighlighter] = None
        self.file_loader: Optional[LogFileLoader] = None  # Async file loader
        self.server_scan_timer: QTimer = None  # Periodic ZMQ server scanning
        self._pending_log_to_load: Optional[Path] = None  # Log to load when window is shown

        # Process tracking for alive/dead process indication
        self.process_tracker = ProcessTracker()
        self.process_update_timer: QTimer = None
        self.show_alive_only: bool = False  # Filter to show only logs from running processes

        # Master list of all discovered logs (single source of truth)
        # Dropdown is a filtered VIEW of this list
        self._all_discovered_logs: List[LogFileInfo] = []

        # Track session start time to filter out old logs from previous sessions
        # Use current process start time, not log viewer init time
        self._session_start_time = self._get_process_start_time()

        self.setup_ui()
        self.setup_connections()
        self.initialize_logs()
        self.start_process_tracking()

    def setup_ui(self) -> None:
        """Setup complete UI layout with exact widget hierarchy."""
        self.setWindowTitle("Log Viewer")
        self.setMinimumSize(800, 600)

        # Central widget with main layout
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(central_widget)

        # Log selector dropdown
        self.log_selector = QComboBox()
        self.log_selector.setMinimumHeight(30)
        main_layout.addWidget(self.log_selector)

        # Search toolbar (initially hidden)
        self.search_toolbar = QToolBar("Search")
        self.search_toolbar.setVisible(False)

        # Search input
        self.search_input = QLineEdit()
        self.search_input.setPlaceholderText("Search logs...")
        self.search_toolbar.addWidget(self.search_input)

        # Search options
        self.case_sensitive_cb = QCheckBox("Case sensitive")
        self.search_toolbar.addWidget(self.case_sensitive_cb)

        self.regex_cb = QCheckBox("Regex")
        self.search_toolbar.addWidget(self.regex_cb)

        # Search navigation buttons
        self.prev_button = QPushButton("Previous")
        self.next_button = QPushButton("Next")
        self.close_search_button = QPushButton("Close")

        self.search_toolbar.addWidget(self.prev_button)
        self.search_toolbar.addWidget(self.next_button)
        self.search_toolbar.addWidget(self.close_search_button)

        main_layout.addWidget(self.search_toolbar)

        # Log display area (virtualized with text selection support)
        self.log_model = LogListModel(self)
        self.log_view = LogListView()
        self.log_view.setModel(self.log_model)
        # Enable per-row word wrapping and variable item heights so long
        # log lines are fully visible instead of clipped or forcing
        # horizontal scrolling.
        self.log_view.setUniformItemSizes(True)
        self.log_view.setWordWrap(False)
        self.log_view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.log_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
        self.log_view.setResizeMode(QListView.ResizeMode.Fixed)
        # Disable Qt's native row selection - we use custom text selection instead
        self.log_view.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.log_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.log_view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.log_view.setFont(QFont("Consolas", 10))  # Monospace font for logs
        self.log_delegate = LogItemDelegate(LogColorScheme.create_dark_theme(), self.log_view)
        self.log_delegate._log_model = self.log_model
        self.log_delegate.set_fixed_row_height(18)
        self.log_view.setItemDelegate(self.log_delegate)
        main_layout.addWidget(self.log_view)

        # Control buttons layout
        control_layout = QHBoxLayout()

        self.auto_scroll_btn = QPushButton("Auto-scroll")
        self.auto_scroll_btn.setCheckable(True)
        self.auto_scroll_btn.setChecked(True)

        self.pause_btn = QPushButton("Pause")
        self.pause_btn.setCheckable(True)

        self.clear_btn = QPushButton("Clear")
        self.reset_memory_btn = QPushButton("Reset Memory")
        self.reset_memory_btn.setToolTip("Clear display and reset memory usage (reloads current log from disk)")
        self.bottom_btn = QPushButton("Bottom")

        # Process filter checkbox
        self.show_alive_only_cb = QCheckBox("Show only running processes")
        self.show_alive_only_cb.setToolTip("Filter logs to show only those from currently running processes")

        control_layout.addWidget(self.auto_scroll_btn)
        control_layout.addWidget(self.pause_btn)
        control_layout.addWidget(self.clear_btn)
        control_layout.addWidget(self.reset_memory_btn)
        control_layout.addWidget(self.bottom_btn)
        control_layout.addWidget(self.show_alive_only_cb)
        control_layout.addStretch()  # Push buttons to left

        main_layout.addLayout(control_layout)

        # Setup window-local shortcuts
        search_action = QAction("Search", self)
        search_action.setShortcut("Ctrl+F")
        search_action.triggered.connect(self.toggle_search_toolbar)
        self.addAction(search_action)

        select_all_action = QAction("Select All", self)
        select_all_action.setShortcut("Ctrl+A")
        select_all_action.triggered.connect(self.log_view.selectAllText)
        self.addAction(select_all_action)

        logger.debug("LogViewerWindow UI setup complete")

    def setup_connections(self) -> None:
        """Setup signal/slot connections."""
        # Log selector
        self.log_selector.currentIndexChanged.connect(self.on_log_selection_changed)

        # Search functionality
        self.search_input.returnPressed.connect(self.perform_search)
        self.prev_button.clicked.connect(self.find_previous)
        self.next_button.clicked.connect(self.find_next)
        self.close_search_button.clicked.connect(self.toggle_search_toolbar)

        # Control buttons
        self.auto_scroll_btn.toggled.connect(self.toggle_auto_scroll)
        self.pause_btn.toggled.connect(self.toggle_pause_tailing)
        self.clear_btn.clicked.connect(self.clear_log_display)
        self.reset_memory_btn.clicked.connect(self.reset_memory)
        self.bottom_btn.clicked.connect(self.scroll_to_bottom)
        self.show_alive_only_cb.stateChanged.connect(self.on_filter_changed)

        # Internal signals
        self._subprocess_scan_complete.connect(self._on_subprocess_scan_complete)
        self._server_scan_complete.connect(self._on_server_scan_complete)

        logger.debug("LogViewerWindow connections setup complete")

    def showEvent(self, event):
        """Override showEvent to load log when window is first shown."""
        super().showEvent(event)

        # Load pending log on first show
        if self._pending_log_to_load:
            self.switch_to_log(self._pending_log_to_load)
            self._pending_log_to_load = None

    def _classify_log_path(self, file_path: Path) -> LogFileInfo:
        """Classify a log path using the discovery provider."""
        try:
            logs = self._log_discovery_provider.discover_logs(
                log_directory=file_path.parent,
                include_main_log=True,
            )
            for log_info in logs:
                if log_info.path == file_path:
                    return log_info
        except Exception as e:
            logger.debug(f"Provider classification failed for {file_path}: {e}")

        return LogFileInfo(path=file_path, log_type="unknown")

    def initialize_logs(self) -> None:
        """Initialize with main log only, then scan for subprocess logs in background."""
        # Only discover the main log initially (fast startup)
        initial_logs = []
        try:
            main_log = self._log_discovery_provider.get_current_log_path()
            if main_log.exists():
                log_info = self._classify_log_path(main_log)
                initial_logs.append(log_info)
                logger.debug("Discovered main log")
        except Exception as e:
            logger.warning(f"Error discovering main log: {e}")
            # Continue without main log
            pass

        # Store main log in master list
        self._all_discovered_logs = initial_logs.copy()

        # Populate dropdown with main log immediately (fast)
        if initial_logs:
            self.populate_log_dropdown(initial_logs)
            # Store first log to load when window is shown (defer loading)
            self._pending_log_to_load = initial_logs[0].path

        # Start monitoring for new logs
        self.start_monitoring()

        # Scan for existing subprocess logs in background (async - doesn't block)
        self._scan_subprocess_logs_async()

        # Scan for servers in background (async - doesn't block)
        self._scan_servers_async()

    def _scan_subprocess_logs_async(self) -> None:
        """Scan for existing subprocess logs in background thread (non-blocking)."""
        import threading

        def scan_and_update():
            """Background thread function."""
            subprocess_logs = self._scan_for_subprocess_logs()
            # Emit signal to update UI on main thread
            self._subprocess_scan_complete.emit(subprocess_logs)

        thread = threading.Thread(target=scan_and_update, daemon=True)
        thread.start()
        logger.debug("Started async subprocess log scan in background")

    def _scan_servers_async(self) -> None:
        """Scan for ZMQ servers in background thread (non-blocking)."""
        import threading

        def scan_and_update():
            """Background thread function."""
            server_logs = self._scan_for_server_logs()
            # Emit signal to update UI on main thread
            self._server_scan_complete.emit(server_logs)

        thread = threading.Thread(target=scan_and_update, daemon=True)
        thread.start()
        logger.debug("Started async server scan in background")

    @pyqtSlot(list)
    def _on_subprocess_scan_complete(self, subprocess_logs: List[LogFileInfo]) -> None:
        """Handle subprocess scan completion on UI thread."""
        if not subprocess_logs:
            logger.debug("No subprocess logs found during scan")
            return

        # Add subprocess logs to master list (avoid duplicates by path)
        existing_paths = {log.path for log in self._all_discovered_logs}
        new_logs_added = 0
        for subprocess_log in subprocess_logs:
            if subprocess_log.path not in existing_paths:
                self._all_discovered_logs.append(subprocess_log)
                new_logs_added += 1

        # Repopulate dropdown from master list
        self.populate_log_dropdown(self._all_discovered_logs)

        logger.info(f"Added {new_logs_added} subprocess logs from current session "
                   f"(scanned {len(subprocess_logs)} total)")

    @pyqtSlot(list)
    def _on_server_scan_complete(self, server_logs: List[LogFileInfo]) -> None:
        """Handle server scan completion on UI thread."""
        if not server_logs:
            logger.debug("No server logs found during scan")
            return

        # Add server logs to master list (avoid duplicates by path)
        existing_paths = {log.path for log in self._all_discovered_logs}
        new_logs_added = 0
        for server_log in server_logs:
            if server_log.path not in existing_paths:
                self._all_discovered_logs.append(server_log)
                new_logs_added += 1

        # Repopulate dropdown from master list
        self.populate_log_dropdown(self._all_discovered_logs)

        logger.info(f"Added {new_logs_added} new server logs to dropdown (scanned {len(server_logs)} total)")

    def _scan_for_subprocess_logs(self) -> List[LogFileInfo]:
        """
        Efficiently scan log directory for subprocess logs from current session.
        Uses os.scandir() and filters by mtime FIRST before parsing.
        Returns list of LogFileInfo for discovered subprocess log files.
        """
        logger.debug("Scanning for subprocess logs from current session...")

        try:
            session_logs = []

            # Calculate cutoff time (session start - 5 second buffer)
            cutoff_time = self._session_start_time - 5.0

            logs = self._log_discovery_provider.discover_logs(include_main_log=False)
            for log_info in logs:
                try:
                    if log_info.path.exists() and log_info.path.stat().st_mtime >= cutoff_time:
                        session_logs.append(log_info)
                except OSError:
                    continue

            logger.info(f"Found {len(session_logs)} subprocess logs from current session")

            return session_logs
        except Exception as e:
            logger.warning(f"Error scanning for subprocess logs: {e}")
            return []

    def _scan_for_server_logs(self) -> List[LogFileInfo]:
        """
        Scan for running ZMQ servers and Napari viewers by pinging common ports.
        Returns list of LogFileInfo for discovered server log files.
        """
        logger.debug("Scanning for running ZMQ/streaming servers...")
        if self._server_scan_provider is None:
            return []
        try:
            return self._server_scan_provider.scan_for_server_logs()
        except Exception as e:
            logger.debug(f"Server scan provider failed: {e}")
            return []

    # Dropdown Management Methods
    def populate_log_dropdown(self, log_files: List[LogFileInfo]) -> None:
        """
        Populate QComboBox with log files with process status indicators.

        Args:
            log_files: List of LogFileInfo objects to add to dropdown
        """
        self.log_selector.clear()

        # Sort logs: TUI first, main subprocess, then workers by timestamp
        sorted_logs = sorted(log_files, key=self._log_sort_key)

        # Filter if "show alive only" is enabled
        if self.show_alive_only:
            sorted_logs = [
                log_info for log_info in sorted_logs
                if self._is_log_from_alive_process(log_info)
            ]

        for log_info in sorted_logs:
            # Add process status indicator to display name
            display_name = get_log_display_name(log_info.path, self.process_tracker)
            tooltip = get_log_tooltip(log_info.path, self.process_tracker)

            self.log_selector.addItem(display_name, log_info)
            # Set tooltip for the item
            self.log_selector.setItemData(self.log_selector.count() - 1, tooltip, Qt.ItemDataRole.ToolTipRole)

        logger.debug(f"Populated dropdown with {len(sorted_logs)} log files (filtered: {self.show_alive_only})")

    def _log_sort_key(self, log_info: LogFileInfo) -> tuple:
        """
        Generate sort key for log files.

        Args:
            log_info: LogFileInfo to generate sort key for

        Returns:
            tuple: Sort key (priority, timestamp)
        """
        # Priority: TUI=0, main=1, worker=2, unknown=3
        priority_map = {"tui": 0, "main": 1, "worker": 2, "unknown": 3}
        priority = priority_map.get(log_info.log_type, 3)

        # Use file modification time as secondary sort
        try:
            timestamp = log_info.path.stat().st_mtime
        except (OSError, AttributeError):
            timestamp = 0

        return (priority, -timestamp)  # Negative timestamp for newest first

    def clear_subprocess_logs(self) -> None:
        """Remove all non-TUI logs from dropdown and switch to TUI log."""
        import traceback
        logger.error(f"🔥 DEBUG: clear_subprocess_logs called! Stack trace:")
        for line in traceback.format_stack():
            logger.error(f"🔥 DEBUG: {line.strip()}")

        current_logs = []

        # Collect TUI logs only
        for i in range(self.log_selector.count()):
            log_info = self.log_selector.itemData(i)
            if log_info and log_info.log_type == "tui":
                current_logs.append(log_info)

        # Repopulate with TUI logs only
        self.populate_log_dropdown(current_logs)

        # Auto-select TUI log if available
        if current_logs:
            self.switch_to_log(current_logs[0].path)

        logger.info("Cleared subprocess logs, kept TUI logs")

    def add_new_log(self, log_file_info: LogFileInfo) -> None:
        """
        Add new log to dropdown maintaining sort order.

        Args:
            log_file_info: New LogFileInfo to add
        """
        # Add to master list (avoid duplicates by path)
        existing_paths = {log.path for log in self._all_discovered_logs}
        if log_file_info.path not in existing_paths:
            self._all_discovered_logs.append(log_file_info)

            # Repopulate dropdown from master list
            self.populate_log_dropdown(self._all_discovered_logs)

            logger.info(f"Added new log to dropdown: {log_file_info.display_name}")
        else:
            logger.debug(f"Log already exists, skipping: {log_file_info.display_name}")

    def on_log_selection_changed(self, index: int) -> None:
        """
        Handle dropdown selection change - switch log display.

        Args:
            index: Selected index in dropdown
        """
        if index >= 0:
            log_info = self.log_selector.itemData(index)
            if log_info:
                self.switch_to_log(log_info.path)

    def switch_to_log(self, log_path: Path) -> None:
        """Switch log display to show specified log file.

        Args:
            log_path: Path to log file to display
        """
        try:
            # Stop current tailing
            self.stop_log_tailing()

            # Stop any existing file loader
            if self.file_loader and self.file_loader.isRunning():
                self.file_loader.wait()

            # Reset streaming state
            self._pending_partial_line = ""
            self.current_file_position = 0

            # Validate file exists
            if not log_path.exists():
                self.clear_log_display()
                self.log_model.append_lines([f"Log file not found: {log_path}"])
                return

            # Store path for later use
            self.current_log_path = log_path

            # ALWAYS use async loading to prevent UI blocking
            file_size = log_path.stat().st_size
            logger.debug(f"Loading log file ({file_size} bytes) asynchronously")

            self.clear_log_display()
            self.log_model.append_lines([f"Loading log file ({file_size // 1024} KB)..."])

            # Create and start async loader
            self.file_loader = LogFileLoader(log_path)
            self.file_loader.chunk_loaded.connect(self._on_file_chunk_loaded)
            self.file_loader.load_finished.connect(self._on_file_load_finished)
            self.file_loader.load_failed.connect(self._on_file_load_failed)
            self.file_loader.start()

        except Exception as e:
            logger.error(f"Error switching to log {log_path}: {e}")
            raise

    def _on_file_chunk_loaded(self, lines: list) -> None:
        """Handle a chunk of lines loaded from subprocess."""
        try:
            if not lines:
                return
            if self.log_delegate is not None:
                self.log_delegate._highlighting_enabled = False
            self.log_model.append_lines(lines)
        except Exception as e:
            logger.error(f"Error displaying loaded chunk: {e}")

    def _on_file_load_finished(self) -> None:
        """Finalize after all chunks are loaded."""
        try:
            # Update file position immediately
            if self.current_log_path and self.current_log_path.exists():
                self.current_file_position = self.current_log_path.stat().st_size

            self._finish_batch_insert()
            if self.log_delegate is not None:
                self.log_delegate._highlighting_enabled = True
        except Exception as e:
            logger.error(f"Error finalizing loaded content: {e}")

    def _batch_insert_lines(self, lines: List[str], chunk_size: int = 1000) -> None:
        """
        Insert lines in batches to prevent UI freeze.

        Yields control back to the event loop between batches so the UI
        stays responsive even when loading large log files.

        Args:
            lines: All lines to insert
            chunk_size: Number of lines to insert per batch
        """
        if not lines:
            # No lines to insert - finish up
            self._finish_batch_insert()
            return

        # Take next chunk
        chunk = lines[:chunk_size]
        remaining = lines[chunk_size:]

        # Insert this chunk
        self.log_model.append_lines(chunk)

        if remaining:
            # Schedule next batch on event loop (yields to UI)
            QTimer.singleShot(0, lambda: self._batch_insert_lines(remaining, chunk_size))
        else:
            # All done - finish up
            self._finish_batch_insert()

    def _finish_batch_insert(self) -> None:
        """Called after all batches are inserted."""
        # Start tailing if not paused
        if not self.tailing_paused and self.current_log_path:
            self.start_log_tailing(self.current_log_path)

        # Scroll to bottom if auto-scroll enabled
        if self.auto_scroll_enabled:
            self.scroll_to_bottom()

        logger.info(f"Loaded log file: {self.current_log_path}")

    def _on_file_load_failed(self, error_msg: str) -> None:
        """Handle file load failure."""
        self.clear_log_display()
        self.log_model.append_lines([f"Failed to load log file: {error_msg}"])
        logger.error(f"Failed to load log file: {error_msg}")

    # Search Functionality Methods
    def toggle_search_toolbar(self) -> None:
        """Show/hide search toolbar (Ctrl+F handler)."""
        if self.search_toolbar.isVisible():
            # Hide toolbar and clear highlights
            self.search_toolbar.setVisible(False)
            self.clear_search_highlights()
        else:
            # Show toolbar and focus search input
            self.search_toolbar.setVisible(True)
            self.search_input.setFocus()
            self.search_input.selectAll()

    def perform_search(self) -> None:
        """Search forwards in the current log using the model/view backend."""
        self._advance_search(step=1)

    def _advance_search(self, step: int) -> None:
        """Advance search cursor in the given direction.

        Args:
            step: +1 for next, -1 for previous
        """
        search_text = self.search_input.text()
        if not search_text:
            self.clear_search_highlights()
            return

        case_sensitive = self.case_sensitive_cb.isChecked()

        # Rebuild match list if query or case sensitivity changed.
        if (
            search_text != self.current_search_text
            or case_sensitive != self._search_case_sensitive
        ):
            self.current_search_text = search_text
            self._search_case_sensitive = case_sensitive
            self._search_matches = []

            lines = self.log_model.iter_lines()
            if case_sensitive:
                for row, line in enumerate(lines):
                    if search_text in line:
                        self._search_matches.append(row)
            else:
                needle = search_text.lower()
                for row, line in enumerate(lines):
                    if needle in line.lower():
                        self._search_matches.append(row)

            # Reset cursor for new search set.
            self._search_match_cursor = -1

            # Update delegate highlighting state and pre-compute match rows for fast paint
            if self.log_delegate is not None:
                self.log_delegate.set_search_state(self.current_search_text, self._search_case_sensitive)
                # Pre-populate search match rows for O(1) lookup during paint
                self.log_delegate._search_match_rows = set(self._search_matches)
            if self.log_view is not None:
                self.log_view.viewport().update()

        if not self._search_matches:
            return

        # Step through matches with wrap-around.
        if self._search_match_cursor == -1:
            self._search_match_cursor = 0 if step >= 0 else len(self._search_matches) - 1
        else:
            self._search_match_cursor = (self._search_match_cursor + step) % len(self._search_matches)

        target_row = self._search_matches[self._search_match_cursor]
        index = self.log_model.index(target_row, 0)
        self.log_view.setCurrentIndex(index)
        self.log_view.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)

    def clear_search_highlights(self) -> None:
        """Clear search state and delegate highlights."""
        self.current_search_text = ""
        self._search_case_sensitive = False
        self._search_matches = []
        self._search_match_cursor = -1

        if self.log_delegate is not None:
            self.log_delegate.set_search_state("", False)
        if self.log_view is not None:
            self.log_view.viewport().update()

    def find_next(self) -> None:
        """Find next search result."""
        self._advance_search(step=1)

    def find_previous(self) -> None:
        """Find previous search result."""
        self._advance_search(step=-1)

    # Control Button Methods
    def toggle_auto_scroll(self, enabled: bool) -> None:
        """Toggle auto-scroll to bottom."""
        self.auto_scroll_enabled = enabled
        logger.debug(f"Auto-scroll {'enabled' if enabled else 'disabled'}")

    def toggle_pause_tailing(self, paused: bool) -> None:
        """Toggle pause/resume log tailing."""
        self.tailing_paused = paused
        if paused:
            self.stop_log_tailing()
        elif not paused and self.current_log_path:
            self.start_log_tailing(self.current_log_path)
        logger.debug(f"Log tailing {'paused' if paused else 'resumed'}")

    def clear_log_display(self) -> None:
        """Clear current log display content."""
        self._pending_partial_line = ""
        self.log_model.clear()
        logger.debug("Log display cleared")

    def reset_memory(self) -> None:
        """
        Reset memory usage by clearing the in-memory log model and reloading current log.

        This completely clears the text buffer and reloads the log file from disk,
        freeing memory accumulated from long-running log tailing.
        """
        if not self.current_log_path:
            logger.warning("No log file currently loaded - cannot reset memory")
            return

        logger.info(f"Resetting memory for log: {self.current_log_path}")

        # Stop tailing
        was_paused = self.tailing_paused
        self.stop_log_tailing()

        # Clear the model completely (frees memory)
        self.clear_log_display()

        # Clear the delegate's document cache (frees cached QTextDocuments)
        if self.log_delegate is not None:
            self.log_delegate.clear_cache()

        # Reset file position to reload from beginning
        self.current_file_position = 0

        # Reload the log file
        self.switch_to_log(self.current_log_path)

        # Restore pause state
        if was_paused:
            self.tailing_paused = True
            self.pause_btn.setChecked(True)

        logger.info("Memory reset complete - log reloaded from disk")

    def scroll_to_bottom(self) -> None:
        """Scroll log view to bottom."""
        self.log_view.scrollToBottom()



    # Real-time Tailing Methods
    def start_log_tailing(self, log_path: Path) -> None:
        """
        Start tailing log file with async background thread (never blocks UI).

        Args:
            log_path: Path to log file to tail
        """
        # Stop any existing tailer
        self.stop_log_tailing()

        # Create and start async tailer thread
        self.log_tailer = LogTailer(log_path, self.current_file_position)
        self.log_tailer.new_content.connect(self._on_new_content)
        self.log_tailer.log_rotated.connect(self._on_log_rotated)
        self.log_tailer.error_occurred.connect(self._on_tail_error)
        self.log_tailer.start()

        logger.debug(f"Started async tailing for log file: {log_path}")

    def stop_log_tailing(self) -> None:
        """Stop current log tailing."""
        if self.log_tailer:
            self.log_tailer.stop()
            self.log_tailer.wait(1000)  # Wait up to 1 second for thread to finish
            self.log_tailer = None
        logger.debug("Stopped log tailing")

    def _on_new_content(self, new_content: str, new_file_position: int) -> None:
        """Handle new content from async tailer (runs on UI thread via signal)."""
        # Defer updates if window is minimized or hidden to reduce UI load
        if self.isMinimized() or not self.isVisible():
            # Just update file position, skip rendering
            self.current_file_position = new_file_position
            return

        # Check if user has scrolled up (disable auto-scroll)
        scrollbar = self.log_view.verticalScrollBar()
        was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 10
        self._last_scroll_state = was_at_bottom

        # Merge with any pending partial line and split into full lines.
        chunk = f"{self._pending_partial_line}{new_content}"
        if not chunk:
            return

        lines = chunk.splitlines()
        if not chunk.endswith(("\n", "\r")):
            # Last line is incomplete; keep it for the next chunk.
            self._pending_partial_line = lines.pop() if lines else chunk
        else:
            self._pending_partial_line = ""

        if lines:
            # Add to pending buffer
            self._pending_lines.extend(lines)

            # Start throttle timer if not already running
            if not self._update_timer.isActive():
                self._update_timer.start(self._update_throttle_ms)

        # Update file position
        self.current_file_position = new_file_position

    def _flush_pending_lines(self) -> None:
        """Flush pending lines to the UI (called by throttle timer)."""
        if not self._pending_lines:
            return

        lines = self._pending_lines
        self._pending_lines = []

        # For tailing, batch if we have a large burst (>500 lines)
        if len(lines) > 500:
            self._batch_insert_lines_for_tailing(lines, self._last_scroll_state)
        else:
            # Small batch - insert directly
            self.log_model.append_lines(lines)

            # Auto-scroll if enabled and user was at bottom
            if self.auto_scroll_enabled and self._last_scroll_state:
                self.scroll_to_bottom()

    def _batch_insert_lines_for_tailing(self, lines: List[str], was_at_bottom: bool, chunk_size: int = 500) -> None:
        """
        Batch insert lines during tailing to prevent UI freeze on large bursts.

        Similar to _batch_insert_lines but preserves scroll position for tailing.

        Args:
            lines: Lines to insert
            was_at_bottom: Whether user was scrolled to bottom before insert
            chunk_size: Number of lines per batch
        """
        if not lines:
            return

        # Take next chunk
        chunk = lines[:chunk_size]
        remaining = lines[chunk_size:]

        # Insert this chunk
        self.log_model.append_lines(chunk)

        # Auto-scroll if enabled and user was at bottom
        if self.auto_scroll_enabled and was_at_bottom:
            self.scroll_to_bottom()

        if remaining:
            # Schedule next batch on event loop (yields to UI)
            QTimer.singleShot(0, lambda: self._batch_insert_lines_for_tailing(remaining, was_at_bottom, chunk_size))


    def _on_log_rotated(self) -> None:
        """Handle log rotation detected by async tailer."""
        logger.info(f"Log rotation detected for {self.current_log_path}")
        self.current_file_position = 0
        self._pending_partial_line = ""
        self.log_model.append_lines(["", "--- Log rotated ---", ""])

    def _on_tail_error(self, error_msg: str) -> None:
        """Handle errors from async tailer."""
        logger.warning(f"Tailing error: {error_msg}")

        # Check if file was deleted
        if self.current_log_path and not self.current_log_path.exists():
            logger.info(f"Log file deleted: {self.current_log_path}")
            self.log_model.append_lines([
                "",
                f"--- Log file deleted: {self.current_log_path} ---",
                "",
            ])
            # Try to reconnect after a delay
            QTimer.singleShot(1000, self._attempt_reconnection)

    def read_log_incremental(self) -> None:
        """Deprecated - kept for compatibility. Tailing now handled by LogTailer thread."""
        pass

    def _attempt_reconnection(self) -> None:
        """Attempt to reconnect to log file after deletion."""
        if self.current_log_path and self.current_log_path.exists():
            logger.info(f"Log file recreated, reconnecting: {self.current_log_path}")
            self.current_file_position = 0
            self._pending_partial_line = ""
            self.log_model.append_lines([
                "",
                f"--- Reconnected to: {self.current_log_path} ---",
                "",
            ])
            # File will be read on next timer tick

    # External Integration Methods
    def start_monitoring(self, base_log_path: Optional[str] = None) -> None:
        """Start monitoring for new logs."""
        if self.file_detector:
            self.file_detector.stop_watching()

        # Get log directory
        if base_log_path:
            log_directory = Path(base_log_path).parent
        else:
            try:
                log_directory = self._log_discovery_provider.get_current_log_path().parent
            except Exception:
                log_directory = Path.cwd()

        # Start file watching
        self.file_detector = LogFileDetector(base_log_path)
        self.file_detector.new_log_detected.connect(self.add_new_log)
        self.file_detector.start_watching(log_directory)

    def stop_monitoring(self) -> None:
        """Stop monitoring for new logs."""
        if self.file_detector:
            self.file_detector.stop_watching()
            self.file_detector = None
        logger.info("Stopped monitoring for new logs")

    def start_process_tracking(self) -> None:
        """Start periodic process status updates."""
        # Initial update
        self.process_tracker.update()

        # Setup timer for periodic updates (every 2 seconds)
        self.process_update_timer = QTimer()
        self.process_update_timer.timeout.connect(self.update_process_status)
        self.process_update_timer.start(2000)  # 2 second interval

        logger.debug("Started process tracking")

    def update_process_status(self) -> None:
        """Update process status and refresh dropdown if needed."""
        # Update process tracker
        self.process_tracker.update()

        # Refresh dropdown to update status indicators
        # Only if we have logs loaded
        if self.log_selector.count() > 0:
            # Remember current selection
            current_index = self.log_selector.currentIndex()
            current_log_info = self.log_selector.itemData(current_index) if current_index >= 0 else None

            # Temporarily disconnect signal to avoid triggering reload
            self.log_selector.currentIndexChanged.disconnect(self.on_log_selection_changed)

            try:
                # Repopulate from master list with updated status indicators
                self.populate_log_dropdown(self._all_discovered_logs)

                # Restore selection if possible
                if current_log_info:
                    # Find the same log in the new dropdown
                    for i in range(self.log_selector.count()):
                        log_info = self.log_selector.itemData(i)
                        if log_info and log_info.path == current_log_info.path:
                            self.log_selector.setCurrentIndex(i)
                            break
            finally:
                # Reconnect signal
                self.log_selector.currentIndexChanged.connect(self.on_log_selection_changed)

    def _get_process_start_time(self) -> float:
        """
        Get the start time of the current process.

        Returns:
            float: Process start time as Unix timestamp
        """
        try:
            import psutil
            import os
            process = psutil.Process(os.getpid())
            return process.create_time()
        except Exception as e:
            logger.warning(f"Failed to get process start time: {e}")
            # Fallback to current time
            import time
            return time.time()

    def _is_log_from_current_session(self, log_info: LogFileInfo) -> bool:
        """
        Check if a log file was created during the current session.

        Args:
            log_info: LogFileInfo to check

        Returns:
            bool: True if log was created after session start time
        """
        try:
            # Get file modification time (when log was created/last written)
            mtime = log_info.path.stat().st_mtime
            # Allow a small buffer (5 seconds) to account for timing differences
            return mtime >= (self._session_start_time - 5.0)
        except (OSError, FileNotFoundError):
            # If we can't stat the file, exclude it
            return False

    def _is_log_from_alive_process(self, log_info: LogFileInfo) -> bool:
        """
        Check if a log file is from a currently running process.

        Args:
            log_info: LogFileInfo to check

        Returns:
            bool: True if process is alive or unknown, False if terminated
        """
        pid = extract_pid_from_log_filename(log_info.path)
        if pid is None:
            # No PID found - assume it's a main log (always show)
            return True
        return self.process_tracker.is_alive(pid)

    def on_filter_changed(self, state: int) -> None:
        """
        Handle filter checkbox state change.

        Args:
            state: Qt.CheckState value
        """
        self.show_alive_only = (state == Qt.CheckState.Checked.value)

        # Refresh dropdown with filter applied
        # Always use master list as source, not the current dropdown
        # This ensures all logs are available when filter is toggled off
        if self._all_discovered_logs:
            self.populate_log_dropdown(self._all_discovered_logs)

        logger.debug(f"Filter changed: show_alive_only={self.show_alive_only}")

    def cleanup(self) -> None:
        """Cleanup all resources and background processes."""
        try:
            # Stop async log tailer thread
            if hasattr(self, 'log_tailer') and self.log_tailer:
                self.stop_log_tailing()

            # Stop tailing timer (deprecated, kept for compatibility)
            if hasattr(self, 'tail_timer') and self.tail_timer and self.tail_timer.isActive():
                self.tail_timer.stop()
                self.tail_timer.deleteLater()
                self.tail_timer = None

            # Stop process tracking timer
            if hasattr(self, 'process_update_timer') and self.process_update_timer and self.process_update_timer.isActive():
                self.process_update_timer.stop()
                self.process_update_timer.deleteLater()
                self.process_update_timer = None

            # Stop file monitoring
            self.stop_monitoring()

            # Clean up file detector
            if hasattr(self, 'file_detector') and self.file_detector:
                self.file_detector.stop_watching()
                self.file_detector = None

        except Exception as e:
            logger.warning(f"Error during log viewer cleanup: {e}")

    def closeEvent(self, event) -> None:
        """Handle window close event."""
        # Stop async tailer
        if hasattr(self, 'log_tailer') and self.log_tailer:
            self.stop_log_tailing()

        if self.file_detector:
            self.file_detector.stop_watching()
        if self.tail_timer:
            self.tail_timer.stop()
        if hasattr(self, 'process_update_timer') and self.process_update_timer:
            self.process_update_timer.stop()
        LogHighlightClient.shutdown()
        self.window_closed.emit()
        super().closeEvent(event)
