"""   
Supervertaler
=============
The Ultimate Translation Workbench.
Modern PyQt6 interface with specialised modules to handle any problem.
Framework: PyQt6

This is the modern edition of Supervertaler using PyQt6 framework.
For the classic tkinter edition, see Supervertaler_tkinter.py

Key Features:
- Complete Translation Matching: Termbase + TM + MT + Multi-LLM
- Project Termbases: Dedicated terminology per project with automatic extraction
- Voice: AI-enhanced commands and dictation (100+ languages via OpenAI Whisper)
- Superimage: Extract images from DOCX files with preview
- Google Cloud Translation API integration
- Multi-LLM Support: OpenAI GPT, Claude, Google Gemini
- 2-Layer Prompt Architecture (System + Custom Prompts) with AI Assistant
- AI Assistant with conversational interface for document analysis
- Superlookup with global hotkey (Ctrl+Alt+L)
- Detachable Log window for multi-monitor setups
- Modern theme system (6 themes + custom editor)
- memoQ bilingual DOCX import/export
- Bilingual Table export/import for review workflow
- SQLite-based translation memory with FTS5 search
- Professional TMX editor
- Spellcheck integration with Hunspell and custom dictionary support

Author: Michael Beijer
License: MIT
"""

# Version Information – read from pyproject.toml (single source of truth)
def _read_version():
    """Read version from pyproject.toml, importlib.metadata, or hardcoded fallback."""
    import os as _os
    # 1. Try pyproject.toml (works in dev mode and PyInstaller builds that bundle it)
    try:
        try:
            import tomllib
        except ImportError:
            import tomli as tomllib  # Python < 3.11
        _toml_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "pyproject.toml")
        with open(_toml_path, "rb") as f:
            _data = tomllib.load(f)
        return _data["project"]["version"]
    except Exception:
        pass
    # 2. Try importlib.metadata (works for pip install)
    try:
        from importlib.metadata import version
        return version("supervertaler")
    except Exception:
        pass
    # 3. Last-resort hardcoded fallback
    return "1.9.227"

__version__ = _read_version()
__phase__ = "0.9"
__release_date__ = "2026-05-12"
__edition__ = "Qt"

# --- macOS Finder crash logging (v1.9.275) ---
# When launched via Finder, stdout/stderr go nowhere; write to Desktop log.
import sys as _sys
import os as _os
if getattr(_sys, 'frozen', False) and _sys.platform == 'darwin':
    _crash_log = _os.path.expanduser('~/Desktop/supervertaler_crash.log')
    try:
        with open(_crash_log, 'w') as _f:
            _f.write(f"Launch time: {__import__('datetime').datetime.now()}\n")
            _f.write(f"Version: {__version__}\n")
            _f.write(f"CWD: {_os.getcwd()}\n")
            _f.write(f"sys.executable: {_sys.executable}\n")
            _f.write(f"sys._MEIPASS: {getattr(_sys, '_MEIPASS', 'N/A')}\n")
            _f.write(f"__file__: {__file__}\n")
            _f.write(f"LANG: {_os.environ.get('LANG', 'NOT SET')}\n")
            _f.write(f"LC_CTYPE: {_os.environ.get('LC_CTYPE', 'NOT SET')}\n")
            _f.write("--- Starting app ---\n")
    except Exception:
        pass
del _sys, _os
# --- end crash logging ---

import sys
import json
import os
import platform
import subprocess
import atexit
from pathlib import Path
from typing import List, Optional, Dict, Any, Tuple, Callable
from dataclasses import dataclass, asdict, field
from datetime import datetime
from modules.shortcut_display import format_shortcut_for_display
from modules.platform_helpers import IS_WINDOWS, IS_MACOS, IS_LINUX, open_file, open_folder, get_hidden_subprocess_flags
from modules.ui_scale import scaled_pt


def get_resource_path(relative_path: str) -> Path:
    """Get absolute path to resource, works for dev and for PyInstaller bundled app."""
    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
        # Running as compiled executable (PyInstaller)
        base_path = Path(sys._MEIPASS)
    else:
        # Running in development
        base_path = Path(__file__).parent
    return base_path / relative_path


def get_config_pointer_path() -> Path:
    """
    Get path to the config pointer file that stores the user's chosen data location.
    
    This is stored in a standard config location:
    - Windows: %APPDATA%/Supervertaler/config.json
    - macOS:   ~/Library/Application Support/Supervertaler/config.json
    - Linux:   ~/.config/Supervertaler/config.json
    
    This small file just contains a pointer to where the real data lives.
    """
    if sys.platform == 'win32':
        # Windows: %APPDATA% (Roaming, syncs across machines on domain)
        appdata = os.environ.get('APPDATA', os.path.expanduser('~'))
        return Path(appdata) / "Supervertaler" / "config.json"
    elif sys.platform == 'darwin':
        # macOS: ~/Library/Application Support/
        return Path.home() / "Library" / "Application Support" / "Supervertaler" / "config.json"
    else:
        # Linux/BSD: ~/.config/ (XDG_CONFIG_HOME)
        xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
        return Path(xdg_config) / "Supervertaler" / "config.json"


def get_default_user_data_path() -> Path:
    """
    Get the DEFAULT user data path (visible in home folder).
    
    - Windows: C:/Users/Username/Supervertaler/
    - macOS:   ~/Supervertaler/
    - Linux:   ~/Supervertaler/
    
    This is intentionally a visible, easily-accessible location.
    """
    return Path.home() / "Supervertaler"


def save_user_data_path(path: Path) -> None:
    """Save the user's chosen data path to the config pointer file."""
    pointer_path = get_config_pointer_path()
    pointer_path.parent.mkdir(parents=True, exist_ok=True)
    
    config = {"user_data_path": str(path)}
    with open(pointer_path, 'w', encoding='utf-8') as f:
        json.dump(config, f, indent=2)


def load_user_data_path_from_config() -> Optional[Path]:
    """
    Load the user's chosen data path from config pointer file.
    Returns None if no config exists or path is invalid.
    """
    pointer_path = get_config_pointer_path()
    
    if not pointer_path.exists():
        return None
    
    try:
        with open(pointer_path, 'r', encoding='utf-8') as f:
            config = json.load(f)
        
        path_str = config.get('user_data_path')
        if path_str:
            return Path(path_str)
    except (json.JSONDecodeError, OSError):
        pass
    
    return None


def get_user_data_path() -> Path:
    """
    Get the path to user data directory.
    
    Resolution order:
    1. Check config pointer file for user's saved preference
    2. If pointer missing, check if default location has data (auto-recover)
    3. If nothing found, return default (first-run dialog will be shown later)
    
    All users (EXE, pip, dev) use the same unified system:
    - Default: ~/Supervertaler/ (visible in home folder)
    - User can choose custom location on first run
    - Config pointer remembers their choice
    
    This ensures data survives pip upgrades, EXE updates, and is easy to find/backup.
    """
    # 1. Check if user has a saved preference
    saved_path = load_user_data_path_from_config()
    if saved_path and saved_path.exists():
        return saved_path
    
    # 2. Check if default location has data (auto-recovery if pointer deleted)
    default_path = get_default_user_data_path()
    if default_path.exists():
        # Check if it has actual content
        has_content = False
        try:
            for item in default_path.iterdir():
                if item.is_file() or (item.is_dir() and any(item.iterdir())):
                    has_content = True
                    break
        except OSError:
            pass
        
        if has_content:
            # Auto-recover: recreate the pointer file
            save_user_data_path(default_path)
            print(f"[Data Paths] Auto-recovered config pointer to: {default_path}")
            return default_path
    
    # 3. Return default path (first-run dialog will be shown by MainWindow)
    # Don't create folder yet - let the dialog handle that
    return default_path


def needs_first_run_data_dialog() -> bool:
    """
    Check if we need to show the first-run data location dialog.
    
    Returns True if:
    - No config pointer file exists, AND
    - Default data location doesn't have existing content
    
    This means it's truly a fresh install.
    """
    # If config pointer exists, user has already chosen a location
    pointer_path = get_config_pointer_path()
    if pointer_path.exists():
        return False
    
    # If default location has content, we auto-recovered (no dialog needed)
    default_path = get_default_user_data_path()
    if default_path.exists():
        try:
            for item in default_path.iterdir():
                if item.is_file() or (item.is_dir() and any(item.iterdir())):
                    return False  # Has content, no dialog needed
        except OSError:
            pass
    
    # Truly a fresh install - need to show dialog
    return True

import threading
import time  # For delays in Superlookup
import re

# Fix encoding for Windows console (UTF-8 support)
# Only set if stdout/stderr exist (they're None in PyInstaller --windowed mode)
if sys.platform == 'win32':
    import io
    if sys.stdout is not None and hasattr(sys.stdout, 'buffer'):
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
    if sys.stderr is not None and hasattr(sys.stderr, 'buffer'):
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

# External dependencies
import pyperclip  # For clipboard operations in Superlookup
from modules.superlookup import SuperlookupEngine  # Superlookup engine
from modules.voice_dictation_lite import QuickDictationThread  # Voice dictation
from modules.voice_commands import VoiceCommandManager, VoiceCommand, ContinuousVoiceListener  # Voice commands (Talon-style)
from modules.voice_command_dialog import VoiceCommandEditDialog  # Voice command edit dialog
from modules.styled_widgets import CheckmarkCheckBox, PurpleCheckmarkCheckBox
from modules.statuses import (
    STATUSES,
    DEFAULT_STATUS,
    StatusDefinition,
    get_status,
    match_memoq_status,
    compose_memoq_status,
)
from modules import file_dialog_helper as fdh  # File dialog helper with last directory memory
from modules.spellcheck_manager import SpellcheckManager, get_spellcheck_manager  # Spellcheck with Hunspell
from modules.find_replace_qt import (
    FindReplaceHistory,
    FindReplaceOperation,
    FindReplaceSet,
    FindReplaceSetsManager,
    HistoryComboBox,
)  # F&R History and Sets
from modules.shortcut_manager import ShortcutManager  # Keyboard shortcut management
from modules.termlens_widget import TermLensWidget  # TermLens widget for glossary display
from modules.help_system import Topics as HelpTopics, install as install_help_system, set_topic as set_help_topic, open_help


STATUS_ORDER = [
    "not_started",
    "pretranslated",
    "draft",
    "confirmed",
    "proofread",
    "approved",
    "rejected",
]

STATUS_CYCLE = STATUS_ORDER

TRANSLATABLE_STATUSES = {"not_started", "pretranslated", "draft", "tm_100", "tm_fuzzy"}

# Check for PyQt6 and offer to install if missing
try:
    from PyQt6.QtWidgets import (
        QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
        QTableWidget, QTableWidgetItem, QTableWidgetSelectionRange, QHeaderView, QMenuBar, QMenu,
        QFileDialog, QMessageBox, QToolBar, QLabel, QComboBox,
        QPushButton, QSpinBox, QSplitter, QTextEdit, QStatusBar,
        QStyledItemDelegate, QInputDialog, QDialog, QLineEdit, QRadioButton,
        QButtonGroup, QDialogButtonBox, QTabWidget, QGroupBox, QGridLayout, QCheckBox,
        QProgressBar, QProgressDialog, QFormLayout, QTabBar, QPlainTextEdit, QAbstractItemDelegate,
        QFrame, QListWidget, QListWidgetItem, QStackedWidget, QTreeWidget, QTreeWidgetItem,
        QScrollArea, QSizePolicy, QSlider, QToolButton, QAbstractItemView
    )
    from PyQt6.QtCore import Qt, QSize, QTimer, pyqtSignal, pyqtSlot, QObject, QUrl, QThread, QRunnable, QThreadPool
    from PyQt6.QtGui import QFont, QAction, QKeySequence, QIcon, QTextOption, QColor, QDesktopServices, QTextCharFormat, QTextCursor, QBrush, QSyntaxHighlighter, QPalette, QTextBlockFormat, QCursor, QFontMetrics
    from PyQt6.QtWidgets import QStyleOptionViewItem, QStyle
    from PyQt6.QtCore import QRectF
    from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
    from PyQt6 import sip
except ImportError:
    print("PyQt6 not found. Installing...")
    import subprocess
    subprocess.check_call([sys.executable, "-m", "pip", "install", "PyQt6"])
    print("✓ PyQt6 installed. Please restart the application.")
    sys.exit(0)


# ============================================================================
# PRIVATE DATA PROTECTION SYSTEM
# ============================================================================

# Check for .supervertaler.local file to enable private features (for developers only)
# This ensures that private data (API keys, personal projects, etc.) stays in user_data_private/
# which is .gitignored, preventing accidental upload to GitHub
ENABLE_PRIVATE_FEATURES = os.path.exists(
    os.path.join(os.path.dirname(os.path.abspath(__file__)), ".supervertaler.local")
)
if ENABLE_PRIVATE_FEATURES:
    print("[DEV MODE] Private features enabled (.supervertaler.local found)")
    print("[DEV MODE] Using 'user_data_private/' folder (git-ignored)")
else:
    print("[USER MODE] Using 'user_data/' folder")


# ============================================================================
# GLOBAL AHK PROCESS CLEANUP
# ============================================================================

# Global variables to track hotkey processes for atexit cleanup
_ahk_process = None
_hotkey_manager = None

def cleanup_hotkey_processes():
    """Cleanup function to stop hotkey processes on exit"""
    global _ahk_process, _hotkey_manager
    if _hotkey_manager:
        try:
            _hotkey_manager.stop()
            print("[Hotkeys] pynput hotkey manager stopped on exit")
        except Exception:
            pass
    if _ahk_process:
        try:
            _ahk_process.terminate()
            _ahk_process.wait(timeout=1)
            print("[Hotkeys] AHK process terminated on exit")
        except Exception:
            try:
                _ahk_process.kill()
                print("[Hotkeys] AHK process killed on exit")
            except Exception:
                pass

# Register cleanup function to run on Python exit
atexit.register(cleanup_hotkey_processes)


# ============================================================================
# ============================================================================
# DATA MODELS
# ============================================================================

# ============================================================================
# INLINE FORMATTING TAG UTILITIES
# ============================================================================

def runs_to_tagged_text(paragraphs) -> str:
    """
    Convert Word document paragraphs with run formatting to HTML-tagged text.

    Args:
        paragraphs: List of python-docx Paragraph objects

    Returns:
        String with HTML tags for formatting (e.g., "<b>bold</b> normal <sub>V</sub>")
    """
    result_parts = []

    for paragraph in paragraphs:
        for run in paragraph.runs:
            text = run.text
            if not text:
                continue

            # Determine which tags to apply
            is_bold = run.bold == True
            is_italic = run.italic == True
            is_underline = run.underline == True
            is_subscript = getattr(run.font, 'subscript', None) == True
            is_superscript = getattr(run.font, 'superscript', None) == True

            # Build tagged text
            if is_bold or is_italic or is_underline or is_subscript or is_superscript:
                # Open tags (order: bold, italic, underline, sub/sup)
                if is_bold:
                    text = f"<b>{text}"
                if is_italic:
                    text = f"<i>{text}" if not is_bold else text.replace("<b>", "<b><i>", 1)
                if is_underline:
                    if is_bold and is_italic:
                        text = text.replace("<b><i>", "<b><i><u>", 1)
                    elif is_bold:
                        text = text.replace("<b>", "<b><u>", 1)
                    elif is_italic:
                        text = text.replace("<i>", "<i><u>", 1)
                    else:
                        text = f"<u>{text}"
                if is_subscript:
                    text = f"<sub>{text}"
                if is_superscript:
                    text = f"<sup>{text}"

                # Close tags (reverse order: sup, sub, underline, italic, bold)
                if is_superscript:
                    text = f"{text}</sup>"
                if is_subscript:
                    text = f"{text}</sub>"
                if is_underline:
                    text = f"{text}</u>"
                if is_italic:
                    text = f"{text}</i>"
                if is_bold:
                    text = f"{text}</b>"

            result_parts.append(text)

    return ''.join(result_parts)


def strip_formatting_tags(text: str) -> str:
    """
    Remove HTML formatting tags from text, leaving plain text.

    Args:
        text: Text with HTML tags like <b>, </b>, <i>, </i>, <u>, </u>, <sub>, </sub>, <sup>, </sup>

    Returns:
        Plain text without tags
    """
    import re
    return re.sub(r'</?(?:[biu]|sub|sup)>', '', text)


def strip_outer_wrapping_tags(text: str) -> tuple:
    """
    Strip outer wrapping tags from text if the entire segment is wrapped in a single tag pair.

    This handles structural tags like <li-o>...</li-o>, <p>...</p>, <td>...</td>,
    as well as formatting tags like <b>...</b> when they wrap the *entire* segment.
    Inner formatting (e.g. "Some <b>bold</b> text") is never affected.

    Args:
        text: Text that may be wrapped in outer tags

    Returns:
        Tuple of (stripped_text, tag_name) where tag_name is the outer tag that was stripped,
        or (original_text, None) if no outer wrapping tag was found

    Examples:
        "<li-o>Hello <b>world</b></li-o>" -> ("Hello <b>world</b>", "li-o")
        "<p>Simple text</p>" -> ("Simple text", "p")
        "No tags here" -> ("No tags here", None)
        "<b>Bold text</b>" -> ("Bold text", "b")  # <b> wraps entire segment → stripped
    """
    import re

    if not text or not text.strip():
        return (text, None)

    text = text.strip()

    # Tags that wrap entire segments – structural AND formatting.
    # Formatting tags (b, i, u, …) are included because when they wrap
    # the *entire* segment they act as structural wrappers; the function
    # already guarantees it only strips the single outermost pair, so
    # inner formatting like "Some <b>bold</b> text" is never affected.
    strippable_tags = {
        # Structural / layout
        'li-o', 'li-b', 'li', 'p', 'td', 'th', 'tr', 'div', 'span',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'title', 'caption',
        'blockquote', 'pre', 'code', 'dt', 'dd', 'header', 'footer',
        'article', 'section', 'aside', 'nav', 'main', 'figure', 'figcaption',
        # Formatting (only stripped when wrapping the entire segment)
        'b', 'i', 'u', 'em', 'strong', 's', 'strike', 'sub', 'sup', 'mark',
    }

    # Pattern to match opening tag at start: <tag> or <tag attr="...">
    opening_pattern = r'^<([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[^>]*)?>(.*)$'
    opening_match = re.match(opening_pattern, text, re.DOTALL)

    if not opening_match:
        return (text, None)

    tag_name = opening_match.group(1).lower()
    rest = opening_match.group(2)

    # Only strip recognised tags
    if tag_name not in strippable_tags:
        return (text, None)

    # Check if text ends with matching closing tag
    closing_pattern = rf'^(.*)</{re.escape(tag_name)}>$'
    closing_match = re.match(closing_pattern, rest, re.DOTALL | re.IGNORECASE)

    if not closing_match:
        return (text, None)

    inner_content = closing_match.group(1)

    # Verify this is truly a wrapping pair (no other occurrences of this tag inside)
    # Count opening and closing tags of this type in the inner content
    inner_opening_count = len(re.findall(rf'<{re.escape(tag_name)}(?:\s+[^>]*)?>',
                                          inner_content, re.IGNORECASE))
    inner_closing_count = len(re.findall(rf'</{re.escape(tag_name)}>', inner_content, re.IGNORECASE))

    # If there are nested tags of the same type, don't strip
    if inner_opening_count > 0 or inner_closing_count > 0:
        return (text, None)

    return (inner_content, tag_name)


def get_docx_language_code(lang_name_or_code: str) -> str:
    """
    Convert language name or code to DOCX locale format (e.g., 'nl-NL', 'en-US').

    DOCX files use BCP 47 / RFC 5646 language tags with region codes.

    Args:
        lang_name_or_code: Language name (e.g., 'Dutch', 'English') or code (e.g., 'nl', 'en')

    Returns:
        DOCX-compatible language code with region (e.g., 'nl-NL', 'en-US')
    """
    if not lang_name_or_code:
        return "en-US"  # Default fallback

    lang_input = lang_name_or_code.strip()
    lang_lower = lang_input.lower()

    # Map language names to full locale codes (with region)
    # Priority: Netherlands for Dutch, US for English, etc.
    lang_map = {
        # Major languages with regional defaults
        "dutch": "nl-NL",
        "english": "en-US",
        "german": "de-DE",
        "french": "fr-FR",
        "spanish": "es-ES",
        "italian": "it-IT",
        "portuguese": "pt-PT",
        "russian": "ru-RU",
        "chinese": "zh-CN",
        "japanese": "ja-JP",
        "korean": "ko-KR",
        "arabic": "ar-SA",

        # Dutch variants
        "dutch (netherlands)": "nl-NL",
        "dutch (belgium)": "nl-BE",
        "flemish": "nl-BE",

        # English variants
        "english (us)": "en-US",
        "english (uk)": "en-GB",
        "english (australia)": "en-AU",
        "english (canada)": "en-CA",
        "american english": "en-US",
        "british english": "en-GB",

        # French variants
        "french (france)": "fr-FR",
        "french (canada)": "fr-CA",
        "french (belgium)": "fr-BE",
        "french (switzerland)": "fr-CH",

        # German variants
        "german (germany)": "de-DE",
        "german (austria)": "de-AT",
        "german (switzerland)": "de-CH",

        # Spanish variants
        "spanish (spain)": "es-ES",
        "spanish (mexico)": "es-MX",
        "spanish (latin america)": "es-419",

        # Portuguese variants
        "portuguese (portugal)": "pt-PT",
        "portuguese (brazil)": "pt-BR",
        "brazilian portuguese": "pt-BR",

        # Chinese variants
        "chinese (simplified)": "zh-CN",
        "chinese (traditional)": "zh-TW",
        "simplified chinese": "zh-CN",
        "traditional chinese": "zh-TW",

        # Other European languages
        "afrikaans": "af-ZA",
        "albanian": "sq-AL",
        "armenian": "hy-AM",
        "basque": "eu-ES",
        "bengali": "bn-BD",
        "bulgarian": "bg-BG",
        "catalan": "ca-ES",
        "croatian": "hr-HR",
        "czech": "cs-CZ",
        "danish": "da-DK",
        "estonian": "et-EE",
        "finnish": "fi-FI",
        "galician": "gl-ES",
        "georgian": "ka-GE",
        "greek": "el-GR",
        "hebrew": "he-IL",
        "hindi": "hi-IN",
        "hungarian": "hu-HU",
        "icelandic": "is-IS",
        "indonesian": "id-ID",
        "irish": "ga-IE",
        "latvian": "lv-LV",
        "lithuanian": "lt-LT",
        "macedonian": "mk-MK",
        "malay": "ms-MY",
        "norwegian": "nb-NO",
        "persian": "fa-IR",
        "polish": "pl-PL",
        "romanian": "ro-RO",
        "serbian": "sr-RS",
        "slovak": "sk-SK",
        "slovenian": "sl-SI",
        "swahili": "sw-KE",
        "swedish": "sv-SE",
        "thai": "th-TH",
        "turkish": "tr-TR",
        "ukrainian": "uk-UA",
        "urdu": "ur-PK",
        "vietnamese": "vi-VN",
        "welsh": "cy-GB",
    }

    # Check if it's a full language name
    if lang_lower in lang_map:
        return lang_map[lang_lower]

    # Check if already in locale format (e.g., "nl-NL", "en-US")
    if '-' in lang_input or '_' in lang_input:
        parts = lang_input.replace('_', '-').split('-')
        if len(parts) >= 2 and len(parts[0]) == 2:
            return f"{parts[0].lower()}-{parts[1].upper()}"

    # If it's a 2-letter code, try to add a default region
    code_to_region = {
        "nl": "NL", "en": "US", "de": "DE", "fr": "FR", "es": "ES",
        "it": "IT", "pt": "PT", "ru": "RU", "zh": "CN", "ja": "JP",
        "ko": "KR", "ar": "SA", "pl": "PL", "cs": "CZ", "da": "DK",
        "fi": "FI", "el": "GR", "hu": "HU", "no": "NO", "nb": "NO",
        "sv": "SE", "tr": "TR", "uk": "UA", "he": "IL", "th": "TH",
        "vi": "VN", "id": "ID", "ms": "MY", "ro": "RO", "bg": "BG",
        "hr": "HR", "sk": "SK", "sl": "SI", "et": "EE", "lv": "LV",
        "lt": "LT", "sr": "RS", "mk": "MK", "sq": "AL", "is": "IS",
        "ga": "IE", "cy": "GB", "eu": "ES", "ca": "ES", "gl": "ES",
        "af": "ZA", "sw": "KE", "hi": "IN", "bn": "BD", "ur": "PK",
        "fa": "IR", "hy": "AM", "ka": "GE",
    }

    if len(lang_input) == 2 and lang_lower in code_to_region:
        return f"{lang_lower}-{code_to_region[lang_lower]}"

    # Fallback: return as-is or with generic region
    if len(lang_input) == 2:
        return f"{lang_lower}-{lang_input.upper()}"

    return "en-US"  # Ultimate fallback


def set_docx_language(doc, lang_code: str):
    """
    Set the language for all content in a DOCX document.

    This modifies the document's default paragraph style and existing
    paragraphs to use the specified language for spellcheck and proofing.

    Args:
        doc: A python-docx Document object
        lang_code: Language code in BCP 47 format (e.g., 'nl-NL', 'en-US')
    """
    from docx.oxml.ns import qn
    from docx.oxml import OxmlElement

    # Set language on document's default paragraph properties (styles.xml)
    try:
        # Access the styles part
        styles = doc.styles

        # Get or create Normal style's rPr (run properties)
        normal_style = styles['Normal']

        # Set language on the style's font/run properties
        if normal_style._element.rPr is None:
            rPr = OxmlElement('w:rPr')
            normal_style._element.append(rPr)
        else:
            rPr = normal_style._element.rPr

        # Remove existing lang element if present
        existing_lang = rPr.find(qn('w:lang'))
        if existing_lang is not None:
            rPr.remove(existing_lang)

        # Create and add new lang element
        lang_elem = OxmlElement('w:lang')
        lang_elem.set(qn('w:val'), lang_code)
        lang_elem.set(qn('w:eastAsia'), lang_code)
        lang_elem.set(qn('w:bidi'), lang_code)
        rPr.append(lang_elem)

    except Exception:
        pass  # Style manipulation failed, continue with paragraph-level setting

    # Also set language on all existing paragraphs' runs for immediate effect
    for para in doc.paragraphs:
        for run in para.runs:
            try:
                # Access run's XML element
                run_elem = run._element

                # Get or create rPr
                rPr = run_elem.rPr
                if rPr is None:
                    rPr = OxmlElement('w:rPr')
                    run_elem.insert(0, rPr)

                # Remove existing lang element if present
                existing_lang = rPr.find(qn('w:lang'))
                if existing_lang is not None:
                    rPr.remove(existing_lang)

                # Create and add new lang element
                lang_elem = OxmlElement('w:lang')
                lang_elem.set(qn('w:val'), lang_code)
                rPr.append(lang_elem)
            except Exception:
                continue  # Skip runs that can't be modified

    # Also handle table cells
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for para in cell.paragraphs:
                    for run in para.runs:
                        try:
                            run_elem = run._element
                            rPr = run_elem.rPr
                            if rPr is None:
                                rPr = OxmlElement('w:rPr')
                                run_elem.insert(0, rPr)

                            existing_lang = rPr.find(qn('w:lang'))
                            if existing_lang is not None:
                                rPr.remove(existing_lang)

                            lang_elem = OxmlElement('w:lang')
                            lang_elem.set(qn('w:val'), lang_code)
                            rPr.append(lang_elem)
                        except Exception:
                            continue


def has_formatting_tags(text: str) -> bool:
    """
    Check if text contains any formatting tags.

    Args:
        text: Text to check

    Returns:
        True if text contains <b>, <i>, <u>, <sub>, or <sup> tags
    """
    import re
    return bool(re.search(r'</?(?:[biu]|sub|sup)>', text))


def apply_formatting_tags(text: str, tag: str) -> str:
    """
    Wrap text with the specified formatting tag.
    
    Args:
        text: Text to wrap
        tag: Tag name ('b', 'i', or 'u')
        
    Returns:
        Tagged text like "<b>text</b>"
    """
    if tag in ('b', 'i', 'u'):
        return f"<{tag}>{text}</{tag}>"
    return text


def get_formatted_html_display(text: str) -> str:
    """
    Convert our simple tags to HTML for rich text display.

    Args:
        text: Text with <b>, <i>, <u>, <sub>, <sup> tags

    Returns:
        HTML string suitable for QTextEdit.setHtml()
    """
    # Escape HTML entities first (except our tags)
    import html

    # Temporarily replace our tags with placeholders
    text = text.replace('<b>', '\x00B_OPEN\x00')
    text = text.replace('</b>', '\x00B_CLOSE\x00')
    text = text.replace('<i>', '\x00I_OPEN\x00')
    text = text.replace('</i>', '\x00I_CLOSE\x00')
    text = text.replace('<u>', '\x00U_OPEN\x00')
    text = text.replace('</u>', '\x00U_CLOSE\x00')
    text = text.replace('<sub>', '\x00SUB_OPEN\x00')
    text = text.replace('</sub>', '\x00SUB_CLOSE\x00')
    text = text.replace('<sup>', '\x00SUP_OPEN\x00')
    text = text.replace('</sup>', '\x00SUP_CLOSE\x00')

    # Escape other HTML
    text = html.escape(text)

    # Restore our tags as real HTML
    text = text.replace('\x00B_OPEN\x00', '<b>')
    text = text.replace('\x00B_CLOSE\x00', '</b>')
    text = text.replace('\x00I_OPEN\x00', '<i>')
    text = text.replace('\x00I_CLOSE\x00', '</i>')
    text = text.replace('\x00U_OPEN\x00', '<u>')
    text = text.replace('\x00U_CLOSE\x00', '</u>')
    text = text.replace('\x00SUB_OPEN\x00', '<sub>')
    text = text.replace('\x00SUB_CLOSE\x00', '</sub>')
    text = text.replace('\x00SUP_OPEN\x00', '<sup>')
    text = text.replace('\x00SUP_CLOSE\x00', '</sup>')

    # Preserve whitespace in HTML rendering (prevents collapsing spaces/indentation)
    text = f'<span style="white-space: pre-wrap;">{text}</span>'

    return text


def tagged_text_to_runs(text: str) -> list:
    """
    Parse text with HTML formatting tags and return a list of runs with formatting info.

    Args:
        text: Text with <b>, <i>, <u>, <sub>, <sup> tags (can be nested)

    Returns:
        List of dicts with keys: text, bold, italic, underline, subscript, superscript
    """
    import re

    runs = []

    # Track current formatting state
    is_bold = False
    is_italic = False
    is_underline = False
    is_subscript = False
    is_superscript = False

    # Pattern to match opening/closing tags
    tag_pattern = re.compile(r'(</?(?:[biu]|sub|sup)>)')

    # Split text by tags, keeping the tags as delimiters
    parts = tag_pattern.split(text)

    current_text = ""

    def _flush():
        nonlocal current_text
        if current_text:
            runs.append({
                'text': current_text,
                'bold': is_bold,
                'italic': is_italic,
                'underline': is_underline,
                'subscript': is_subscript,
                'superscript': is_superscript,
            })
            current_text = ""

    for part in parts:
        if part == '<b>':
            _flush()
            is_bold = True
        elif part == '</b>':
            _flush()
            is_bold = False
        elif part == '<i>':
            _flush()
            is_italic = True
        elif part == '</i>':
            _flush()
            is_italic = False
        elif part == '<u>':
            _flush()
            is_underline = True
        elif part == '</u>':
            _flush()
            is_underline = False
        elif part == '<sub>':
            _flush()
            is_subscript = True
        elif part == '</sub>':
            _flush()
            is_subscript = False
        elif part == '<sup>':
            _flush()
            is_superscript = True
        elif part == '</sup>':
            _flush()
            is_superscript = False
        else:
            # Regular text - accumulate it
            current_text += part

    # Don't forget any remaining text
    _flush()

    return runs


# ============================================================================
# MEMOQ TAG UTILITIES (for tag insertion shortcuts)
# ============================================================================

def extract_memoq_tags(text: str) -> list:
    """
    Extract all memoQ-style tags from text in order of appearance.
    
    memoQ uses several tag types:
    - Paired opening tags: [1}, [2}, [3} etc.
    - Paired closing tags: {1], {2], {3] etc.
    - Standalone tags: [1], [2], [3] etc. (e.g., for tabs, special characters)
    
    Args:
        text: Source text containing tags
        
    Returns:
        List of tag strings in order of appearance: ['[1}', '{1]', '[2]', ...]
    """
    import re
    # Match:
    # - Opening paired tags: [N}
    # - Closing paired tags: {N]
    # - Standalone tags: [N]
    pattern = r'(\[\d+\}|\{\d+\]|\[\d+\])'
    return re.findall(pattern, text)


def extract_html_tags(text: str) -> list:
    """
    Extract all HTML/XML tags from text in order of appearance.

    Supports common formatting tags used in translation:
    - Opening tags: <b>, <i>, <u>, <li>, <p>, <span>, etc.
    - Closing tags: </b>, </i>, </u>, </li>, </p>, </span>, etc.
    - Self-closing tags: <br/>, <hr/>, etc.
    - Trados/SDLXLIFF numeric tags: <92>, </92>, etc.

    Args:
        text: Source text containing HTML tags

    Returns:
        List of tag strings in order of appearance: ['<li>', '</li>', '<b>', '</b>', ...]
    """
    import re
    # Match HTML/XML tags: <tagname>, </tagname>, <tagname/>, <tagname attr="value">
    # Also match Trados/SDLXLIFF numeric tags: <N>, </N>
    pattern = r'(</?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?>|</?\d+>)'
    return re.findall(pattern, text)


def compact_tags(text: str, tag_map: dict = None) -> str:
    """
    Replace verbose XML/HTML tags with short numbered placeholders for display.

    Simple formatting tags (<b>, <i>, <u>, <sub>, <sup> and their closing tags)
    are left untouched. Only verbose tags (those with attributes or long names
    like <bmk id="0" name="_Toc219208699" transform="open">) are shortened.

    Opening tags get {1}, closing tags get {/1}, self-closing tags get {1/}.
    Each unique tag name gets a stable number (first seen = 1, etc.).

    Args:
        text: Text containing verbose tags.
        tag_map: Optional dict to populate with placeholder→full-tag mappings
                 (for reversing later). If None, no map is built.

    Returns:
        Text with verbose tags replaced by compact numbered placeholders.
    """
    import re

    # Tags to leave untouched (simple formatting – already short)
    _PASSTHROUGH = {'b', 'i', 'u', 'sub', 'sup', 'em', 'strong', 's'}

    tag_pattern = re.compile(
        r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)(\s[^>]*)?(/?)\s*>'  # named tags with optional attrs
        r'|'
        r'</(\d+)>'          # Trados numeric closing: </92>
        r'|'
        r'<(\d+)>'           # Trados numeric opening: <92>
    )

    tag_name_to_num = {}
    counter = [0]

    def _get_num(name: str) -> int:
        if name not in tag_name_to_num:
            counter[0] += 1
            tag_name_to_num[name] = counter[0]
        return tag_name_to_num[name]

    def _replace(m):
        full_tag = m.group(0)

        # Trados numeric closing </N>
        if m.group(5) is not None:
            n = _get_num(f"trados_{m.group(5)}")
            placeholder = f"{{/{n}}}"
            if tag_map is not None:
                tag_map[placeholder] = full_tag
            return placeholder
        # Trados numeric opening <N>
        if m.group(6) is not None:
            n = _get_num(f"trados_{m.group(6)}")
            placeholder = f"{{{n}}}"
            if tag_map is not None:
                tag_map[placeholder] = full_tag
            return placeholder

        # Named tag
        slash = m.group(1)      # '/' for closing, '' for opening
        name = m.group(2)       # tag name
        attrs = m.group(3)      # attributes (may be None)
        self_close = m.group(4) # '/' for self-closing

        # Pass through simple formatting tags without attributes
        if name.lower() in _PASSTHROUGH and not attrs:
            return full_tag

        # Only shorten if the tag is verbose (has attributes or name > 3 chars)
        if not attrs and len(name) <= 3:
            return full_tag

        n = _get_num(name.lower())
        if slash:
            placeholder = f"{{/{n}}}"
        elif self_close:
            placeholder = f"{{{n}/}}"
        else:
            placeholder = f"{{{n}}}"

        if tag_map is not None:
            tag_map[placeholder] = full_tag
        return placeholder

    return tag_pattern.sub(_replace, text)


def expand_compact_tags(text: str, tag_map: dict) -> str:
    """
    Reverse compact_tags() – replace numbered placeholders back to full tags.

    Args:
        text: Text with compact placeholders like {1}, {/1}.
        tag_map: Dict mapping placeholder → full tag string.

    Returns:
        Text with full tags restored.
    """
    if not tag_map:
        return text
    # Sort by longest placeholder first to avoid partial replacements
    for placeholder, full_tag in sorted(tag_map.items(), key=lambda x: -len(x[0])):
        text = text.replace(placeholder, full_tag)
    return text


def extract_all_tags(text: str) -> list:
    """
    Extract all tags (memoQ, HTML, and Trados numeric) from text in order of appearance.

    Args:
        text: Source text containing tags

    Returns:
        List of all tag strings in order of appearance
    """
    import re
    # Combined pattern for memoQ tags, HTML tags, and Trados/SDLXLIFF numeric tags
    # memoQ: [N}, {N], [N]
    # HTML: <tag>, </tag>, <tag/>, <tag attr="value"> - includes hyphenated tags like li-o, li-b
    # Trados/SDLXLIFF: <N>, </N> (numeric tags from SDLXLIFF paired elements)
    pattern = r'(\[\d+\}|\{\d+\]|\[\d+\]|</?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>|</?\d+>)'
    return re.findall(pattern, text)


def count_pipe_symbols(text: str) -> int:
    """Count the number of CafeTran pipe symbols in text."""
    return text.count('|')


def get_next_pipe_count_needed(source_text: str, target_text: str) -> int:
    """
    Get how many more pipe symbols are needed in target to match source.
    
    Args:
        source_text: Source segment text with pipe symbols
        target_text: Current target text
        
    Returns:
        Number of additional pipe symbols needed (0 if target has enough or more)
    """
    source_pipes = count_pipe_symbols(source_text)
    target_pipes = count_pipe_symbols(target_text)
    return max(0, source_pipes - target_pipes)


def get_tag_pair(tag_number: int) -> tuple:
    """
    Get opening and closing tag pair for a given number.
    
    Args:
        tag_number: The tag number (1, 2, 3, etc.)
        
    Returns:
        Tuple of (opening_tag, closing_tag) e.g. ('[1}', '{1]')
    """
    return (f'[{tag_number}}}', f'{{{tag_number}]')


def find_next_unused_tag(source_text: str, target_text: str) -> str:
    """
    Find the next tag from source that hasn't been used in target yet.
    
    Supports both memoQ tags ([1}, {1], [1]) and HTML tags (<li>, </li>, <b>, etc.)
    
    Args:
        source_text: Source segment text with tags
        target_text: Current target text (may have some tags already)
        
    Returns:
        The next tag to insert, or empty string if all tags are used
    """
    # Use combined extraction for both memoQ and HTML 	
    source_tags = extract_all_tags(source_text)
    target_tags = extract_all_tags(target_text)
    
    # Count occurrences in target
    from collections import Counter
    target_tag_counts = Counter(target_tags)
    source_tag_counts = Counter(source_tags)
    
    # Find first tag that needs more occurrences in target
    for tag in source_tags:
        source_count = source_tag_counts[tag]
        target_count = target_tag_counts.get(tag, 0)
        if target_count < source_count:
            return tag
    
    return ""  # All tags already in target


def get_wrapping_tag_pair(source_text: str, target_text: str) -> tuple:
    """
    Get the next available tag pair for wrapping selected text.
    
    Finds the first tag number from source where either opening or closing
    tag is not yet in target.
    
    Args:
        source_text: Source segment text with tags
        target_text: Current target text
        
    Returns:
        Tuple of (opening_tag, closing_tag) or (None, None) if no pairs available
    """
    import re
    
    # Extract all tag numbers from source
    source_tags = extract_memoq_tags(source_text)
    if not source_tags:
        return (None, None)
    
    # Get unique tag numbers in order
    tag_numbers = []
    for tag in source_tags:
        match = re.search(r'\d+', tag)
        if match:
            num = int(match.group())
            if num not in tag_numbers:
                tag_numbers.append(num)
    
    target_tags = extract_memoq_tags(target_text)
    
    # Find first tag number where pair is not complete in target
    for num in tag_numbers:
        opening, closing = get_tag_pair(num)
        if opening not in target_tags or closing not in target_tags:
            return (opening, closing)

    return (None, None)


def get_html_wrapping_tag_pair(source_text: str, target_text: str) -> tuple:
    """
    Get the next available HTML tag pair for wrapping selected text.

    Finds paired HTML tags (opening + closing) from source that are not yet
    complete in target. Supports common formatting tags: b, i, u, em, strong,
    span, li, p, a, sub, sup, etc. Also supports Trados/SDLXLIFF numeric tags
    like <92>...</92>.

    Args:
        source_text: Source segment text with HTML tags
        target_text: Current target text

    Returns:
        Tuple of (opening_tag, closing_tag) or (None, None) if no pairs available
    """
    import re

    # Find all HTML tags in source
    # Match: <tag>, <tag attr="...">, </tag>, and Trados numeric tags <N>, </N>
    tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*|\d+)(?:\s+[^>]*)?>'
    source_matches = re.findall(tag_pattern, source_text)
    target_matches = re.findall(tag_pattern, target_text)

    if not source_matches:
        return (None, None)

    # Build lists of opening and closing tags in source
    source_opening = []  # [(full_tag, tag_name), ...]
    source_closing = []

    for match in re.finditer(tag_pattern, source_text):
        is_closing = match.group(1) == '/'
        tag_name = match.group(2).lower()
        full_tag = match.group(0)

        if is_closing:
            source_closing.append((full_tag, tag_name))
        else:
            source_opening.append((full_tag, tag_name))

    # Build sets of tag names already in target
    target_opening_names = set()
    target_closing_names = set()

    for is_closing, tag_name in target_matches:
        if is_closing == '/':
            target_closing_names.add(tag_name.lower())
        else:
            target_opening_names.add(tag_name.lower())

    # Find first tag pair where both opening and closing exist in source
    # but at least one is missing from target
    seen_tags = set()
    for full_tag, tag_name in source_opening:
        if tag_name in seen_tags:
            continue
        seen_tags.add(tag_name)

        # Check if there's a matching closing tag in source
        has_closing = any(name == tag_name for _, name in source_closing)
        if not has_closing:
            continue

        # Check if pair is incomplete in target
        opening_in_target = tag_name in target_opening_names
        closing_in_target = tag_name in target_closing_names

        if not opening_in_target or not closing_in_target:
            # Find the actual opening and closing tags from source
            opening_tag = full_tag
            closing_tag = f"</{tag_name}>"
            return (opening_tag, closing_tag)

    return (None, None)


def strip_invisible_markers(text: str) -> str:
    """Remove all Show-Invisibles display markers from a string.

    These markers are inserted by apply_invisible_replacements() for on-screen
    display only and must never appear in segment.target or on disk.

    Handles:
      · + U+200B  → regular space   (space marker)
      ·           → regular space   (fallback, lone middle-dot)
      → + U+200B  → tab             (tab marker)
      →           → tab             (fallback, lone right-arrow)
      ↵ + \\n     → \\n              (line-break marker before newline)
      ↵           → (removed)       (bare line-break marker)
      U+200B      → (removed)       (stray zero-width space)

    NOTE: The degree sign ° is intentionally NOT reversed here because ° is a
    legitimate Unicode character that appears in normal translation text.  The
    NBSP substitution is handled separately by the main-window method that has
    project-level context.
    """
    if not text:
        return text
    result = text
    result = result.replace('\u00B7\u200B', ' ')   # middle-dot + ZWSP → space
    result = result.replace('\u00B7', ' ')          # lone middle-dot → space
    result = result.replace('\u2192\u200B', '\t')   # arrow + ZWSP → tab
    result = result.replace('\u2192', '\t')          # lone arrow → tab
    result = result.replace('\u21B5\n', '\n')        # ↵ + newline → newline (current marker)
    result = result.replace('\u21B5', '')            # bare ↵ → nothing
    result = result.replace('\u00B6\n', '\n')        # pilcrow + newline → newline (legacy, keep for old files)
    result = result.replace('\u00B6', '')            # bare pilcrow → nothing (legacy)
    result = result.replace('\u200B', '')            # stray ZWSP → nothing
    return result


@dataclass
class Comment:
    """A user-authored comment attached to a segment.

    v1.10.57 introduced this as part of the move from single-string
    per-segment notes (``segment.notes``) to a list of structured
    comments (``segment.comments``). The structured form supports:

    * Multiple comments per segment.
    * Each comment carrying its own author + timestamp.
    * Optional anchoring to a specific character range within the
      segment's source or target text. When ``anchor_field`` is set
      to ``"source"`` or ``"target"`` and ``anchor_end > anchor_start``,
      the comment is anchored to that character range (Python-slice
      semantics: start inclusive, end exclusive). DOCX export uses
      this to produce Word comments that highlight only the
      referenced text, the same way Trados and memoQ do it. When
      ``anchor_field`` is empty, the comment is segment-level
      (anchors to the whole paragraph in export).

    Backward-compat: old ``.svproj`` files store user notes as a
    single string in ``segment.notes``. ``Segment.from_dict`` migrates
    those to a single segment-level Comment with the legacy text.
    """
    id: str = ""             # UUID hex; auto-assigned in __post_init__ if blank
    text: str = ""           # Comment body
    author: str = ""         # Author name (translator's name, "AI", etc.)
    created: str = ""        # ISO timestamp; auto-set in __post_init__ if blank
    anchor_field: str = ""   # "source", "target", or "" (segment-level)
    anchor_start: int = 0    # Character offset (inclusive)
    anchor_end: int = 0      # Character offset (exclusive)

    def __post_init__(self):
        if not self.id:
            import uuid as _uuid
            self.id = _uuid.uuid4().hex
        if not self.created:
            self.created = datetime.now().isoformat()

    @property
    def is_anchored(self) -> bool:
        """True iff this comment is anchored to a specific text range."""
        return bool(self.anchor_field) and self.anchor_end > self.anchor_start

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Comment':
        """Build a Comment from a dict, dropping unknown keys."""
        valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
        filtered = {k: v for k, v in data.items() if k in valid_fields}
        return cls(**filtered)


@dataclass
class Segment:
    """Translation segment (matches tkinter version format)"""
    id: int
    source: str
    target: str = ""
    status: str = DEFAULT_STATUS.key
    type: str = "para"  # para, heading, list_item, table_cell
    notes: str = ""  # DEPRECATED: legacy single-string note. New code should use comments[]
                     # instead. Kept for backward-compat with .svproj files (and for code
                     # paths not yet migrated). Kept in sync with comments[] by
                     # _sync_notes_from_comments() and the segment-helper methods below.
    comments: List['Comment'] = field(default_factory=list)  # v1.10.57: structured comments
    proofreading_notes: Dict[str, str] = field(default_factory=dict)  # LLM model name → proofreading issue text
    match_percent: Optional[int] = None  # memoQ match score if provided
    memoQ_status: str = ""  # Raw memoQ status text
    locked: bool = False  # For compatibility with tkinter version
    paragraph_id: int = 0  # Group segments by paragraph for document flow
    style: str = "Normal"  # Heading 1, Heading 2, Title, Subtitle, Normal, etc.
    document_position: int = 0  # Position in original document
    is_table_cell: bool = False  # Whether this segment is in a table
    table_info: Optional[tuple] = None  # (table_idx, row_idx, cell_idx) if is_table_cell
    modified: bool = False  # Track if segment has been edited
    created_at: str = ""  # Creation timestamp
    modified_at: str = ""  # Last modification timestamp
    list_number: Optional[int] = None  # For numbered lists: 1, 2, 3, etc. None for bullets or non-lists
    list_type: str = ""  # "numbered", "bullet", or "" for non-list items
    file_id: Optional[int] = None  # ID of the file this segment belongs to (for multi-file projects)
    file_name: str = ""  # Name of the file this segment belongs to (for multi-file projects)
    dejavu_segment_id: str = ""  # Déjà Vu segment ID for round-trip export
    dejavu_row_index: Optional[int] = None  # Déjà Vu row index for export mapping
    sdl_segment_id: str = ""  # SDLXLIFF segment ID for round-trip export
    okapi_tu_id: str = ""  # Okapi text unit ID for merge round-trip
    okapi_segment_index: int = -1  # Segment index within Okapi text unit (-1 = not from Okapi)

    def __post_init__(self):
        """Initialize timestamps and reconcile comments[] ↔ notes."""
        if not self.created_at:
            self.created_at = datetime.now().isoformat()
        if not self.modified_at:
            self.modified_at = datetime.now().isoformat()
        # v1.10.57: bring comments[] and notes into a consistent state.
        # - If we have notes but no comments → migrate: create one
        #   segment-level Comment from the notes string.
        # - If we have comments but no notes → derive notes for any
        #   legacy code path that still reads segment.notes directly.
        # - If we have both: trust comments[] (it's the new source of
        #   truth); regenerate notes from it so the two agree.
        if self.notes and not self.comments:
            self.comments = [Comment(
                text=self.notes,
                author='',  # Unknown for migrated legacy notes
                created=self.modified_at,
                anchor_field='',
                anchor_start=0,
                anchor_end=0,
            )]
        elif self.comments:
            self.notes = self._joined_comment_text()

    # ── Comment helpers (v1.10.57) ──
    def _joined_comment_text(self) -> str:
        """Return the joined text of all comments (used to keep
        legacy segment.notes in sync with the new comments[] list)."""
        return '\n\n'.join(c.text for c in self.comments if c.text)

    def add_comment(self, text: str, author: str = '',
                    anchor_field: str = '', anchor_start: int = 0,
                    anchor_end: int = 0) -> 'Comment':
        """Append a new Comment, sync legacy notes, return the Comment.

        Use this instead of mutating ``segment.comments`` directly so
        that the legacy ``segment.notes`` mirror stays consistent for
        any code path that still reads it (memoQ bilingual exports,
        tooltips on the grid status column, the all-comments list
        widget, etc.).
        """
        comment = Comment(
            text=text,
            author=author,
            anchor_field=anchor_field,
            anchor_start=anchor_start,
            anchor_end=anchor_end,
        )
        self.comments.append(comment)
        self.notes = self._joined_comment_text()
        return comment

    def update_comment(self, comment_id: str, text: str) -> bool:
        """Update an existing comment's text. Returns True if found."""
        for c in self.comments:
            if c.id == comment_id:
                c.text = text
                self.notes = self._joined_comment_text()
                return True
        return False

    def remove_comment(self, comment_id: str) -> bool:
        """Remove a comment by id. Returns True if found."""
        before = len(self.comments)
        self.comments = [c for c in self.comments if c.id != comment_id]
        if len(self.comments) < before:
            self.notes = self._joined_comment_text()
            return True
        return False

    def get_comment(self, comment_id: str) -> Optional['Comment']:
        for c in self.comments:
            if c.id == comment_id:
                return c
        return None

    def replace_all_comments_with_text(self, text: str, author: str = '') -> None:
        """Legacy bridge: replace the entire comments list with a single
        segment-level Comment whose text is ``text``. Empty text leaves
        the list empty. Used by the pre-v1.10.57 single-string editor
        flow (``_on_bottom_notes_changed``) which writes to segment.notes
        as if it were one string."""
        if text and text.strip():
            # Preserve author/anchor from the previous single comment when
            # possible — a no-op edit shouldn't churn the metadata.
            if (len(self.comments) == 1
                    and not self.comments[0].is_anchored):
                self.comments[0].text = text
            else:
                self.comments = [Comment(
                    text=text,
                    author=author,
                    anchor_field='',
                    anchor_start=0,
                    anchor_end=0,
                )]
        else:
            self.comments = []
        self.notes = self._joined_comment_text()

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for JSON serialization"""
        return asdict(self)

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Segment':
        """Create Segment from dictionary, ignoring unknown fields"""
        # Only use fields that the dataclass knows about
        valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
        filtered_data = {k: v for k, v in data.items() if k in valid_fields}
        # Defensively strip any Show-Invisibles markers that may have been
        # written to disk by an earlier buggy version of the app.
        if 'target' in filtered_data and isinstance(filtered_data['target'], str):
            filtered_data['target'] = strip_invisible_markers(filtered_data['target'])
        if 'source' in filtered_data and isinstance(filtered_data['source'], str):
            filtered_data['source'] = strip_invisible_markers(filtered_data['source'])
        # Migration: move legacy "⚠️ PROOFREAD:" content from notes to proofreading_notes dict
        notes_val = filtered_data.get('notes', '')
        if notes_val and "⚠️ PROOFREAD:" in notes_val and not filtered_data.get('proofreading_notes'):
            parts = notes_val.split("⚠️ PROOFREAD:", 1)
            before = parts[0].strip()
            proofread_and_rest = parts[1] if len(parts) > 1 else ""
            separator_parts = proofread_and_rest.split("---", 1)
            proofread_text = separator_parts[0].strip()
            after = separator_parts[1].strip() if len(separator_parts) > 1 else ""
            # Reconstruct clean notes (user notes only)
            clean_notes = before
            if after:
                clean_notes = (clean_notes + "\n" + after).strip() if clean_notes else after
            filtered_data['notes'] = clean_notes
            # Store proofreading text under "legacy" key (original LLM unknown)
            if proofread_text:
                filtered_data['proofreading_notes'] = {"legacy": proofread_text}
        # v1.10.57: deserialize nested Comment dicts into Comment objects.
        # asdict() on save produces plain dicts; this is the corresponding
        # inverse on load.
        raw_comments = filtered_data.get('comments')
        if raw_comments:
            filtered_data['comments'] = [
                Comment.from_dict(c) if isinstance(c, dict) else c
                for c in raw_comments
            ]
        return cls(**filtered_data)


@dataclass
class Project:
    """Translation project"""
    name: str
    source_lang: str = "en"
    target_lang: str = "nl"
    segments: List[Segment] = None
    created: str = ""
    modified: str = ""
    prompt_settings: Dict[str, Any] = None  # Store active prompt settings
    tm_settings: Dict[str, Any] = None  # Store activated TM settings
    termbase_settings: Dict[str, Any] = None  # Store activated termbase settings
    nt_settings: Dict[str, Any] = None  # Store activated non-translatables settings
    spellcheck_settings: Dict[str, Any] = None  # Store spellcheck settings {enabled, language}
    ui_settings: Dict[str, Any] = None  # Store UI settings (results pane zoom, etc.)
    general_settings_overrides: Dict[str, Any] = None  # Per-project overrides for general settings
    id: int = None  # Unique project ID for TM activation tracking
    original_docx_path: str = None  # Path to original DOCX for structure-preserving export
    trados_source_path: str = None  # Path to original Trados bilingual DOCX for round-trip export
    memoq_source_path: str = None  # Path to original memoQ bilingual DOCX for round-trip export
    mqxliff_source_path: str = None  # Path to original memoQ XLIFF for round-trip export
    cafetran_source_path: str = None  # Path to original CafeTran bilingual DOCX for round-trip export
    sdlppx_source_path: str = None  # Path to original Trados SDLPPX package for SDLRPX export
    sdlxliff_source_paths: list = None  # Paths to standalone .sdlxliff files for round-trip export
    original_txt_path: str = None  # Path to original simple text file for round-trip export
    dejavu_source_path: str = None  # Path to original Déjà Vu bilingual RTF for round-trip export
    concordance_geometry: Dict[str, int] = None  # Window geometry for Concordance Search {x, y, width, height}
    # Multi-file project support
    files: List[Dict[str, Any]] = None  # List of files in project: [{id, name, path, type, segment_count, ...}]
    is_multifile: bool = False  # True if this is a multi-file project
    views: List[Dict[str, Any]] = None  # Saved views: [{"name": "...", "file_ids": [1, 3]}]
    # Scratchpad for private translator notes (stored only in .svproj, never exported to CAT tools)
    scratchpad_notes: str = ""
    import_engine: str = ""  # "okapi" or "" (standard/built-in)

    def __post_init__(self):
        if self.segments is None:
            self.segments = []
        if self.files is None:
            self.files = []
        if self.views is None:
            self.views = []
        if not self.created:
            self.created = datetime.now().isoformat()
        if not self.modified:
            self.modified = datetime.now().isoformat()
        if self.prompt_settings is None:
            self.prompt_settings = {}
        if self.tm_settings is None:
            self.tm_settings = {}
        if self.termbase_settings is None:
            self.termbase_settings = {}
        if self.nt_settings is None:
            self.nt_settings = {}
        if self.spellcheck_settings is None:
            self.spellcheck_settings = {}
        if self.ui_settings is None:
            self.ui_settings = {}
        if self.general_settings_overrides is None:
            self.general_settings_overrides = {}
        # Generate ID if not set (for backward compatibility with old projects)
        if self.id is None:
            import hashlib
            # Create stable ID from project name + created timestamp
            id_source = f"{self.name}_{self.created}"
            self.id = int(hashlib.md5(id_source.encode()).hexdigest()[:8], 16)
    
    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for JSON serialization.
        
        Structure is organized for human readability when opened in a text editor:
        1. Project identification (name, languages, dates, id)
        2. Settings (prompts, TM, termbases, spellcheck, etc.)
        3. Source file paths
        4. UI state (concordance geometry)
        5. Segments (at the end - the actual translation content)
        """
        # Start with core project metadata
        result = {
            'name': self.name,
            'source_lang': self.source_lang,
            'target_lang': self.target_lang,
            'created': self.created,
            'modified': self.modified,
            'id': self.id  # Save project ID
        }
        
        # Add settings (prompts, TM, termbases, etc.)
        if hasattr(self, 'prompt_settings'):
            result['prompt_settings'] = self.prompt_settings
        if self.tm_settings:
            result['tm_settings'] = self.tm_settings
        if self.termbase_settings:
            result['termbase_settings'] = self.termbase_settings
        if self.nt_settings:
            result['nt_settings'] = self.nt_settings
        if self.spellcheck_settings:
            result['spellcheck_settings'] = self.spellcheck_settings
        if self.ui_settings:
            result['ui_settings'] = self.ui_settings
        
        # Add source file paths
        if self.original_docx_path:
            result['original_docx_path'] = self.original_docx_path
        if self.trados_source_path:
            result['trados_source_path'] = self.trados_source_path
        if self.memoq_source_path:
            result['memoq_source_path'] = self.memoq_source_path
        if self.mqxliff_source_path:
            result['mqxliff_source_path'] = self.mqxliff_source_path
        if self.cafetran_source_path:
            result['cafetran_source_path'] = self.cafetran_source_path
        if self.sdlppx_source_path:
            result['sdlppx_source_path'] = self.sdlppx_source_path
        if self.sdlxliff_source_paths:
            result['sdlxliff_source_paths'] = self.sdlxliff_source_paths
        if self.original_txt_path:
            result['original_txt_path'] = self.original_txt_path
        if self.dejavu_source_path:
            result['dejavu_source_path'] = self.dejavu_source_path
        
        # Add UI state
        if self.concordance_geometry:
            result['concordance_geometry'] = self.concordance_geometry
        
        # Add multi-file project data
        if self.is_multifile:
            result['is_multifile'] = self.is_multifile
        if self.files:
            result['files'] = self.files
        
        # Add scratchpad notes (private translator notes, never exported to CAT tools)
        if self.scratchpad_notes:
            result['scratchpad_notes'] = self.scratchpad_notes

        # Add saved views for multi-file projects
        if self.views:
            result['views'] = self.views

        # Add import engine indicator (for Okapi round-trip export)
        if self.import_engine:
            result['import_engine'] = self.import_engine

        # Add segments LAST (so they appear at the end of the file)
        result['segments'] = [seg.to_dict() for seg in self.segments]
        
        return result
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Project':
        """Create Project from dictionary"""
        segments = [Segment.from_dict(seg) for seg in data.get('segments', [])]
        
        # Handle missing name field (use filename or default)
        name = data.get('name', 'Untitled Project')
        
        project = cls(
            name=name,
            source_lang=data.get('source_lang', 'en'),
            target_lang=data.get('target_lang', 'nl'),
            segments=segments,
            created=data.get('created', ''),
            modified=data.get('modified', ''),
            id=data.get('id', None)  # Load project ID (will auto-generate if missing)
        )
        # Store prompt settings if they exist
        if 'prompt_settings' in data:
            project.prompt_settings = data['prompt_settings']
        # Store TM settings if they exist
        if 'tm_settings' in data:
            project.tm_settings = data['tm_settings']
        # Store termbase settings if they exist
        if 'termbase_settings' in data:
            project.termbase_settings = data['termbase_settings']
        # Store non-translatables settings if they exist
        if 'nt_settings' in data:
            project.nt_settings = data['nt_settings']
        # Store spellcheck settings if they exist
        if 'spellcheck_settings' in data:
            project.spellcheck_settings = data['spellcheck_settings']
        # Store UI settings if they exist
        if 'ui_settings' in data:
            project.ui_settings = data['ui_settings']
        # Store original DOCX path if it exists
        if 'original_docx_path' in data:
            project.original_docx_path = data['original_docx_path']
        # Store Trados source path if it exists
        if 'trados_source_path' in data:
            project.trados_source_path = data['trados_source_path']
        # Store memoQ source path if it exists
        if 'memoq_source_path' in data:
            project.memoq_source_path = data['memoq_source_path']
        # Store memoQ XLIFF source path if it exists
        if 'mqxliff_source_path' in data:
            project.mqxliff_source_path = data['mqxliff_source_path']
        # Store CafeTran source path if it exists
        if 'cafetran_source_path' in data:
            project.cafetran_source_path = data['cafetran_source_path']
        # Store SDLPPX source path if it exists
        if 'sdlppx_source_path' in data:
            project.sdlppx_source_path = data['sdlppx_source_path']
        # Store standalone SDLXLIFF source paths if they exist
        if 'sdlxliff_source_paths' in data:
            project.sdlxliff_source_paths = data['sdlxliff_source_paths']
        # Store original TXT path if it exists
        if 'original_txt_path' in data:
            project.original_txt_path = data['original_txt_path']
        # Store Déjà Vu source path if it exists
        if 'dejavu_source_path' in data:
            project.dejavu_source_path = data['dejavu_source_path']
        # Store concordance window geometry if it exists
        if 'concordance_geometry' in data:
            project.concordance_geometry = data['concordance_geometry']
        # Store multi-file project data if it exists
        if 'is_multifile' in data:
            project.is_multifile = data['is_multifile']
        if 'files' in data:
            project.files = data['files']
        # Store scratchpad notes if they exist
        if 'scratchpad_notes' in data:
            project.scratchpad_notes = data['scratchpad_notes']
        # Store saved views if they exist
        if 'views' in data:
            project.views = data['views']
        # Store import engine indicator (for Okapi round-trip export)
        if 'import_engine' in data:
            project.import_engine = data['import_engine']
        return project


# ============================================================================
# CUSTOM DELEGATES AND EDITORS
# ============================================================================


def _cleaned_modifiers(event):
    """Return event modifiers with KeypadModifier stripped.

    macOS Qt sets KeypadModifier on arrow keys (and Home/End/PageUp/PageDown)
    even on MacBook keyboards without a numpad. Bare equality checks like
    `event.modifiers() == Qt.KeyboardModifier.NoModifier` therefore fail on
    macOS, breaking arrow-key segment navigation in the grid. This helper
    returns the modifiers with KeypadModifier removed so equality checks
    work cross-platform.
    """
    return event.modifiers() & ~Qt.KeyboardModifier.KeypadModifier


class _CtrlReturnEventFilter(QObject):
    """App-level event filter to catch Ctrl+Return/Ctrl+Enter for grid confirm.

    This is a workaround for cases where the main keyboard Return key (Key_Return)
    is swallowed before it reaches the source cell editor or QShortcut.
    """

    def __init__(self, main_window):
        super().__init__(main_window)
        self._main_window = main_window

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        from PyQt6.QtWidgets import QApplication

        if event.type() != QEvent.Type.KeyPress:
            return False

        # Only handle Ctrl+Return/Ctrl+Enter (ignore Shift/Alt variants)
        mods = event.modifiers()
        if not (mods & Qt.KeyboardModifier.ControlModifier):
            return False
        if mods & (Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.AltModifier):
            return False
        if event.key() not in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            return False

        mw = self._main_window
        if not mw or not mw.isActiveWindow():
            return False

        table = getattr(mw, 'table', None)
        if table is None:
            return False

        focus = QApplication.focusWidget()
        if focus is None:
            return False

        # Allow triggering when focus is in the grid itself OR in the grid's filter boxes.
        # This keeps the shortcut useful while typing filters (e.g. "Filter Source" box).
        source_filter = getattr(mw, 'source_filter', None)
        target_filter = getattr(mw, 'target_filter', None)
        if focus in (source_filter, target_filter) or focus is table or focus is table.viewport() or table.isAncestorOf(focus) or isinstance(focus, (ReadOnlyGridTextEditor, EditableGridTextEditor)):
            if hasattr(mw, 'confirm_selected_or_next'):
                mw.confirm_selected_or_next()
                return True

        return False


class _GridArrowKeyEventFilter(QObject):
    """App-level event filter to catch Alt+Up/Down and Ctrl+Up/Down in grid cells.

    QAbstractItemView (parent of QTableWidget) intercepts Up/Down arrow keys at
    the viewport level for row navigation, preventing them from ever reaching the
    cell widget's keyPressEvent or event() methods.  Alt+Left is NOT intercepted
    by Qt (horizontal arrow keys are not used for row navigation), which is why
    that shortcut works fine without this filter.

    This filter sits at the application level – above the viewport – and catches
    the four affected key combos when focus is inside a grid cell, then calls the
    appropriate handler directly.
    """

    def __init__(self, main_window):
        super().__init__(main_window)
        self._main_window = main_window

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        from PyQt6.QtWidgets import QApplication

        if event.type() != QEvent.Type.KeyPress:
            return False

        mw = self._main_window
        if not mw or not mw.isActiveWindow():
            return False

        key = event.key()
        mods = _cleaned_modifiers(event)  # strip macOS KeypadModifier from arrow keys

        # Only handle the four combos we care about
        alt_only = mods == Qt.KeyboardModifier.AltModifier
        ctrl_only = mods == Qt.KeyboardModifier.ControlModifier

        is_our_combo = (
            (alt_only  and key in (Qt.Key.Key_Up, Qt.Key.Key_Down)) or
            (ctrl_only and key in (Qt.Key.Key_Up, Qt.Key.Key_Down))
        )
        if not is_our_combo:
            return False

        # Only act when focus is inside a grid cell widget
        focus = QApplication.focusWidget()
        if not isinstance(focus, (ReadOnlyGridTextEditor, EditableGridTextEditor)):
            return False

        if alt_only:
            if key == Qt.Key.Key_Up:
                focus._handle_quick_add_to_glossary_priority(1)
            else:
                focus._handle_quick_add_to_glossary_priority(2)
            return True

        if ctrl_only:
            # Use the focused widget's stored row for reliable navigation.
            # table.currentRow() can be stale after rapid Ctrl+Up/Down because
            # setCurrentCell + setFocus triggers asynchronous Qt signals.
            table = getattr(mw, 'table', None)
            if table is None:
                return False
            widget_row = getattr(focus, 'row', -1)
            actual_row = widget_row if widget_row >= 0 else table.currentRow()

            if key == Qt.Key.Key_Up:
                new_row = actual_row - 1
                while new_row > 0 and table.isRowHidden(new_row):
                    new_row -= 1
                if new_row >= 0 and not table.isRowHidden(new_row):
                    table.clearSelection()
                    table.setCurrentCell(new_row, 3)
                    target_widget = table.cellWidget(new_row, 3)
                    if target_widget:
                        target_widget.setFocus()
                        from PyQt6.QtGui import QTextCursor
                        target_widget.moveCursor(QTextCursor.MoveOperation.End)
            elif key == Qt.Key.Key_Down:
                new_row = actual_row + 1
                while new_row < table.rowCount() - 1 and table.isRowHidden(new_row):
                    new_row += 1
                if new_row < table.rowCount() and not table.isRowHidden(new_row):
                    table.clearSelection()
                    table.setCurrentCell(new_row, 3)
                    target_widget = table.cellWidget(new_row, 3)
                    if target_widget:
                        target_widget.setFocus()
                        from PyQt6.QtGui import QTextCursor
                        target_widget.moveCursor(QTextCursor.MoveOperation.End)
            return True

        return False


class _DoubleTapShiftEventFilter(QObject):
    """App-level event filter to detect double-tap Shift for context menu.

    Replaces the AutoHotkey double-shift detection that was previously in
    supervertaler_hotkeys.ahk.  Only fires when the Supervertaler window
    is active.  Two Shift releases within 350 ms opens the context menu of
    whatever widget currently has focus.
    """

    THRESHOLD_MS = 350

    def __init__(self, main_window):
        super().__init__(main_window)
        self._main_window = main_window
        self._last_shift_release = 0

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent

        if event.type() != QEvent.Type.KeyRelease:
            return False

        if event.key() != Qt.Key.Key_Shift:
            return False

        # Only when our window is active
        mw = self._main_window
        if not mw or not mw.isActiveWindow():
            return False

        # Ignore auto-repeat
        if event.isAutoRepeat():
            return False

        now = time.time() * 1000  # ms
        elapsed = now - self._last_shift_release
        self._last_shift_release = now

        if 0 < elapsed <= self.THRESHOLD_MS:
            # Reset to prevent triple-tap
            self._last_shift_release = 0
            # Open the focus widget's context menu. We send a real
            # QContextMenuEvent rather than a synthetic Shift+F10 key press:
            # Qt only translates the Shift+F10/Menu key into a context-menu
            # event at the windowing layer (QWidgetWindow), so a fake key
            # event delivered via sendEvent never reaches contextMenuEvent()
            # or customContextMenuRequested and the menu never appears.
            from PyQt6.QtWidgets import QApplication
            from PyQt6.QtGui import QContextMenuEvent
            focus = QApplication.focusWidget()
            if focus:
                # Anchor the menu at the text cursor where available,
                # otherwise the centre of the widget.
                pos = None
                try:
                    pos = focus.cursorRect().center()
                except Exception:
                    pos = None
                if pos is None:
                    pos = focus.rect().center()
                global_pos = focus.mapToGlobal(pos)
                ctx_event = QContextMenuEvent(
                    QContextMenuEvent.Reason.Keyboard, pos, global_pos
                )
                QApplication.sendEvent(focus, ctx_event)
            return True

        return False


class _WheelGuard(QObject):
    """Stops a hovered slider / spinbox / combo from eating the mouse wheel.

    By default ``QSlider``, ``QAbstractSpinBox`` and ``QComboBox`` have
    ``Qt.FocusPolicy.WheelFocus`` and respond to wheel events whenever the
    cursor is over them – even without a click – which means scrolling a
    settings page can silently move a slider you didn't mean to touch. We
    install this filter on those widgets and ignore their wheel events
    *unless* the widget actually has keyboard focus (i.e. the user clicked
    or tabbed into it). Result: scrolling the page works as expected;
    explicit interaction still does.
    """

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        if event.type() == QEvent.Type.Wheel:
            if not (hasattr(obj, "hasFocus") and obj.hasFocus()):
                event.ignore()
                return True
        return False


class _LoneCtrlEventFilter(QObject):
    """App-level event filter: triggers the term-insert popup when Ctrl is tapped alone.

    A 'lone Ctrl tap' is defined as:
      - Ctrl KeyPress  (non-repeat)
      - Ctrl KeyRelease (non-repeat)
      - with NO other key pressed, wheel-scrolled, or shortcut fired in between

    This mimics memoQ's behaviour where pressing Ctrl alone opens the glossary /
    non-translatable insert list.  Any other key pressed while Ctrl is held
    (e.g. Ctrl+C, Ctrl+Shift+Q) cancels the detection and lets the normal
    shortcut fire as usual.

    Three cancellation layers guard against false positives:
      1. KeyPress of any non-Ctrl key → immediate cancel  (catches normal shortcuts)
      2. WheelEvent while Ctrl is held → cancel            (catches Ctrl+scroll zoom)
      3. ShortcutOverride event → cancel                   (catches registered QShortcuts
                                                            whose key event may be consumed
                                                            before reaching layer 1)
      4. queryKeyboardModifiers() on Ctrl release → cancel if any other modifier is
         currently held (catches OS-level shortcuts that swallow intermediate keys,
         e.g. Ctrl+Alt+Dash input-method shortcuts on Windows)
    """

    def __init__(self, main_window):
        super().__init__(main_window)
        self._main_window = main_window
        self._ctrl_pressed_alone = False

    def eventFilter(self, obj, event):
        from PyQt6.QtCore import QEvent
        from PyQt6.QtWidgets import QApplication

        try:
            if not self._main_window or not self._main_window.isActiveWindow():
                return False

            etype = event.type()

            # ── Layer 2: Ctrl+scroll-wheel (zoom, etc.) ───────────────────────
            if etype == QEvent.Type.Wheel:
                self._ctrl_pressed_alone = False
                return False

            # ── Layer 3: a registered QShortcut is about to fire ─────────────
            # ShortcutOverride is sent to the focus widget just before a shortcut
            # activates; it fires even when the underlying KeyPress event is later
            # consumed, so we catch Ctrl+= / Ctrl+- / Ctrl+Alt+… shortcuts here.
            if etype == QEvent.Type.ShortcutOverride:
                self._ctrl_pressed_alone = False
                return False

            # ── Layer 1: key presses ──────────────────────────────────────────
            if etype == QEvent.Type.KeyPress:
                if event.isAutoRepeat():
                    self._ctrl_pressed_alone = False
                    return False
                if event.key() == Qt.Key.Key_Control:
                    self._ctrl_pressed_alone = True
                else:
                    self._ctrl_pressed_alone = False
                return False

            # ── Ctrl release: fire only when all layers agree ─────────────────
            if etype == QEvent.Type.KeyRelease:
                if event.key() == Qt.Key.Key_Control and not event.isAutoRepeat():
                    if self._ctrl_pressed_alone:
                        # Layer 4: live OS modifier state – catches OS-swallowed shortcuts
                        # (e.g. AltGr/Ctrl+Alt combinations that consume intermediate keys)
                        live = QApplication.queryKeyboardModifiers()
                        other = live & ~Qt.KeyboardModifier.ControlModifier
                        if other == Qt.KeyboardModifier.NoModifier:
                            self._ctrl_pressed_alone = False
                            self._main_window.show_term_insert_popup()
                            return True   # consume this release event
                    self._ctrl_pressed_alone = False
                return False

            return False

        except RuntimeError:
            # The main window's C++ object has been deleted (app is shutting down).
            # Uninstall this filter so it stops firing, then go silent.
            app = QApplication.instance()
            if app:
                app.removeEventFilter(self)
            self._main_window = None
            self._ctrl_pressed_alone = False
            return False


class GridTableEventFilter:
    """Mixin to pass keyboard shortcuts from editor to table"""
    pass


def _anchored_comments_at(main_window, row, field, char_pos):
    """Return a list of (segment, comment) for anchored comments whose character
    range covers ``char_pos`` in the given ``field`` ('source'/'target') of the
    segment shown at grid ``row``.

    Used by the source/target cell editors to power the hover tooltip and the
    "Open comment" right-click item over the yellow anchored-comment highlight.
    Returns an empty list on any miss (no project, bad row, no covering comment).
    """
    result = []
    try:
        if not main_window or not getattr(main_window, 'current_project', None):
            return result
        table = getattr(main_window, 'table', None)
        if table is None or row is None or row < 0:
            return result
        id_item = table.item(row, 0)
        if not id_item:
            return result
        seg_id = int(id_item.text())
        segment = next(
            (s for s in main_window.current_project.segments if s.id == seg_id),
            None,
        )
        if not segment:
            return result
        for c in getattr(segment, 'comments', []) or []:
            if (getattr(c, 'anchor_field', '') == field and c.is_anchored
                    and c.anchor_start <= char_pos < c.anchor_end):
                result.append((segment, c))
    except Exception:
        pass
    return result


def _segment_comments_for_row(main_window, row):
    """Return [(segment, comment), ...] for every comment on the segment shown
    at grid ``row`` — anchored AND segment-level — in stored order. Empty on miss.

    Used for the "Open comment(s)" cell/status right-click actions so
    segment-level comments (which have no highlighted range to click on) are
    still reachable from the grid.
    """
    result = []
    try:
        if not main_window or not getattr(main_window, 'current_project', None):
            return result
        table = getattr(main_window, 'table', None)
        if table is None or row is None or row < 0:
            return result
        id_item = table.item(row, 0)
        if not id_item:
            return result
        seg_id = int(id_item.text())
        segment = next(
            (s for s in main_window.current_project.segments if s.id == seg_id),
            None,
        )
        if not segment:
            return result
        for c in getattr(segment, 'comments', []) or []:
            if c.text and c.text.strip():
                result.append((segment, c))
    except Exception:
        pass
    return result


def _format_comment_tooltip(pairs):
    """Build the hover-tooltip HTML for the anchored comment(s) under the cursor."""
    import html as _html
    blocks = []
    for _seg, c in pairs:
        author = _html.escape(c.author) if getattr(c, 'author', '') else ''
        text = _html.escape(c.text or '').replace('\n', '<br>')
        head = f"<b>💬 {author}</b><br>" if author else "<b>💬 Comment</b><br>"
        blocks.append(head + text)
    body = "<hr>".join(blocks)
    return (body +
            "<br><span style='color:#888;'><i>Right-click → Open comment</i></span>")


def _add_open_comment_actions(editor, menu, field, click_qpoint):
    """Add "Open comment(s)" items to a grid cell editor's context menu.

    If the click lands on an anchored range, add one "Open comment" per covering
    comment (each opens that specific comment). Otherwise, if the segment has any
    comments, add a single "Open comment(s)" that opens the segment's first one —
    this is how segment-level comments (no highlighted range) are reached.

    Returns True if any action was added (so the caller can add a separator).
    """
    from PyQt6.QtGui import QAction
    mw = editor._get_main_window()
    row = getattr(editor, 'row', -1)
    try:
        click_pos = editor.cursorForPosition(click_qpoint).position()
    except Exception:
        click_pos = -1

    def _open(cid):
        if mw and hasattr(mw, '_open_comment_in_panel'):
            mw._open_comment_in_panel(cid)

    anchored = _anchored_comments_at(mw, row, field, click_pos) if click_pos >= 0 else []
    if anchored:
        for _seg, c in anchored:
            act = QAction("💬 Open comment", editor)
            act.triggered.connect(lambda _checked=False, cid=c.id: _open(cid))
            menu.addAction(act)
        return True

    all_pairs = _segment_comments_for_row(mw, row)
    if all_pairs:
        label = ("💬 Open comment" if len(all_pairs) == 1
                 else f"💬 Open comments ({len(all_pairs)})")
        first_cid = all_pairs[0][1].id
        act = QAction(label, editor)
        act.triggered.connect(lambda _checked=False, cid=first_cid: _open(cid))
        menu.addAction(act)
        return True
    return False


class GridTextEditor(QTextEdit):
    """Custom QTextEdit for grid cells that passes special shortcuts to parent"""
    
    table_widget = None  # Will be set by delegate
    assistance_panel = None  # Will be set by delegate
    current_row = None  # Track which row this editor is in
    
    def __init__(self, parent=None):
        super().__init__(parent)
        # Prevent Tab from changing focus - we handle it manually
        self.setTabChangesFocus(False)
    
    def keyPressEvent(self, event):
        """Override keyPressEvent to handle Tab - this is called AFTER event()"""
        # Ctrl+Tab: Insert actual tab character
        if event.key() == Qt.Key.Key_Tab and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self.insertPlainText('\t')
            event.accept()
            return
        
        # Tab (without Ctrl): Cycle to source cell
        if event.key() == Qt.Key.Key_Tab:
            if self.table_widget and self.current_row is not None:
                # Close current editor
                self.table_widget.closeEditor(self, QAbstractItemDelegate.EndEditHint.NoHint)
                # Open editor for source cell in same row (column 2)
                source_index = self.table_widget.model().index(self.current_row, 2)
                self.table_widget.setCurrentIndex(source_index)
                self.table_widget.edit(source_index)
                event.accept()
                return
        
        # Handle other Ctrl+ shortcuts
        if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
            # Ctrl+1 through Ctrl+9: Insert match by number
            # TODO: Update to work with new results_panels system
            if event.key() >= Qt.Key.Key_1 and event.key() <= Qt.Key.Key_9:
                # Disabled for now - needs update to work with results_panels
                pass
            # Ctrl+Up/Down: Navigate to previous/next segment
            elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down):
                if self.main_window:
                    if event.key() == Qt.Key.Key_Up and hasattr(self.main_window, 'go_to_previous_segment'):
                        self.main_window.go_to_previous_segment()
                    elif event.key() == Qt.Key.Key_Down and hasattr(self.main_window, 'go_to_next_segment'):
                        self.main_window.go_to_next_segment()
                event.accept()
                return
            # Ctrl+Enter: Confirm & Next (call main window method directly)
            elif event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
                if self.main_window and hasattr(self.main_window, 'confirm_selected_or_next'):
                    self.main_window.confirm_selected_or_next()
                event.accept()
                return
            # Ctrl+, (comma): Insert next memoQ tag or wrap selection with tag pair
            elif event.key() == Qt.Key.Key_Comma:
                self._insert_next_tag_or_wrap_selection()
                event.accept()
                return
        
        # Shift+Enter: Insert line break (for multi-line content)
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
                super().keyPressEvent(event)
                event.accept()
                return
        
        # Enter/Return: Don't insert, let delegate handle it (will close editor)
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            # Just ignore it - the table's default behavior will close the editor
            event.ignore()
            return
        
        # All other keys: Handle normally (including ESC for closing editor)
        print(f"🔑 GridTextEditor: Passing to super().keyPressEvent")
        super().keyPressEvent(event)
    
    def _insert_next_tag_or_wrap_selection(self):
        """
        Insert the next memoQ tag, HTML tag, or CafeTran pipe symbol from source, or wrap selection.

        Behavior:
        - If text is selected: Wrap it with the next available tag pair [N}selection{N] or <tag>selection</tag> or |selection|
        - If no selection: Insert the next unused tag/pipe from source at cursor position

        Supports:
        - memoQ tags: [1}, {1], [2}, {2], etc.
        - HTML/XML tags: <li>, </li>, <b>, </b>, <i>, </i>, etc.
        - CafeTran pipe symbols: |

        Shortcut: Ctrl+, (comma)
        """
        # Get the main window and current segment
        if not self.table_widget or self.current_row is None:
            return

        # Navigate up to find main window
        main_window = self.table_widget.parent()
        while main_window and not hasattr(main_window, 'current_project'):
            main_window = main_window.parent()

        if not main_window or not hasattr(main_window, 'current_project'):
            return

        if not main_window.current_project or self.current_row >= len(main_window.current_project.segments):
            return

        segment = main_window.current_project.segments[self.current_row]
        source_text = segment.source
        current_target = self.toPlainText()

        # Check what type of tags are in the source
        has_memoq_tags = bool(extract_memoq_tags(source_text))
        has_html_tags = bool(extract_html_tags(source_text))
        has_any_tags = has_memoq_tags or has_html_tags
        has_pipe_symbols = '|' in source_text

        # Check if there's a selection
        cursor = self.textCursor()
        if cursor.hasSelection():
            selected_text = cursor.selectedText()

            # Try memoQ tag pair first
            if has_memoq_tags:
                opening_tag, closing_tag = get_wrapping_tag_pair(source_text, current_target)
                if opening_tag and closing_tag:
                    wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
                    return

            # Try HTML tag pairs (e.g., <b>...</b>, <i>...</i>)
            if has_html_tags:
                opening_tag, closing_tag = get_html_wrapping_tag_pair(source_text, current_target)
                if opening_tag and closing_tag:
                    wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
                    return

            # Try CafeTran pipe symbols
            if has_pipe_symbols:
                pipes_needed = get_next_pipe_count_needed(source_text, current_target)
                if pipes_needed >= 2:
                    # Wrap with pipes
                    wrapped_text = f"|{selected_text}|"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with |...|")
                    return

            if hasattr(main_window, 'log'):
                main_window.log("⚠️ No tag pairs available from source")
        else:
            # No selection - insert next unused tag or pipe at cursor

            # Try memoQ tags and HTML tags (find_next_unused_tag handles both)
            if has_any_tags:
                # In compact mode, expand target placeholders before comparison
                compact_map = getattr(self, '_compact_tag_map', None)
                compare_target = expand_compact_tags(current_target, compact_map) if compact_map else current_target
                next_tag = find_next_unused_tag(source_text, compare_target)
                if next_tag:
                    # In compact mode, insert the compact placeholder instead of full tag
                    insert_text = next_tag
                    if compact_map:
                        for placeholder, full_tag in compact_map.items():
                            if full_tag == next_tag:
                                insert_text = placeholder
                                break
                    cursor.insertText(insert_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Inserted tag: {insert_text}")
                    return

            # Try CafeTran pipe symbols
            if has_pipe_symbols:
                pipes_needed = get_next_pipe_count_needed(source_text, current_target)
                if pipes_needed > 0:
                    cursor.insertText('|')
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Inserted pipe symbol (|)")
                    return

            if hasattr(main_window, 'log'):
                main_window.log("✓ All tags from source already in target")


class ReadOnlyGridTextEditor(QTextEdit):
    """Read-only QTextEdit for source cells - allows easy text selection"""
    
    # Class variable for tag highlight color (shared across all instances)
    tag_highlight_color = '#7f0001'  # Default memoQ dark red
    
    table_widget = None  # Will be set by delegate
    current_row = None  # Track which row this editor is in
    allow_source_edit = False  # Will be set by delegate based on settings
    
    def __init__(self, text: str = "", parent=None, row: int = -1):
        super().__init__(parent)
        self.row = row  # Store row number for Tab cycling
        self.table_ref = parent  # Store table reference (parent is the table)
        self.setReadOnly(True)  # Prevent typing but allow selection
        self.setPlainText(text)

        # CRITICAL: Enable keyboard focus and text selection
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        # Enable text interaction: selection with keyboard and mouse
        self.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByKeyboard |
            Qt.TextInteractionFlag.TextSelectableByMouse
        )

        # Make inactive selections stay visible with same color
        from PyQt6.QtGui import QPalette
        palette = self.palette()
        palette.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.Highlight, QColor("#000000"))
        palette.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
        palette.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Highlight, QColor("#000000"))
        palette.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
        self.setPalette(palette)

        # Style to look like a normal cell with subtle selection.
        # Background and text colors now managed by theme system.
        # Font size lives in this widget's own stylesheet (not just setFont) so
        # it wins over ThemeManager's app-level `QTextEdit { font-size }` rule
        # that gets emitted whenever the global UI scale != 100% – without this
        # override, grid zoom and per-cell setFont would silently lose to that
        # app-level rule at 125% / 150% etc.
        self._grid_font_pt: float | None = None
        self._grid_bg_color: str | None = None
        self._rebuild_grid_stylesheet()

        # Set document margins to 0 for compact display
        doc = self.document()
        doc.setDocumentMargin(0)

        # Configure text option for minimal line spacing
        # With zero-width spaces inserted after invisible character markers,
        # normal WordWrap will work correctly
        wrap_mode = QTextOption.WrapMode.WordWrap

        self.setWordWrapMode(wrap_mode)
        self.setAcceptRichText(False)
        # v1.10.96 — see EditableGridTextEditor for the rationale.
        # Source cell uses the same row-auto-resize machinery so a
        # spurious scrollbar from QTextEdit's AsNeeded heuristic was
        # showing on the source side too.
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

        text_option = QTextOption()
        text_option.setWrapMode(wrap_mode)
        doc.setDefaultTextOption(text_option)

        # Set minimum height to 0 - let content determine size
        self.setMinimumHeight(0)
        self.setMaximumHeight(16777215)  # Qt's max int

        # Store termbase matches for this cell (for tooltip and double-click)
        self.termbase_matches = {}
        
        # Enable mouse tracking for hover tooltips
        self.setMouseTracking(True)

        # Add syntax highlighter for tags (no spellcheck for source cells)
        # Get invisible char color and tag color from main window (theme-aware)
        main_window = self._get_main_window()
        invisible_char_color = main_window.invisible_char_color if main_window and hasattr(main_window, 'invisible_char_color') else '#999999'

        # Use theme-aware tag color (light pink in dark mode, dark red in light mode)
        is_dark = main_window and hasattr(main_window, 'theme_manager') and main_window.theme_manager and main_window.theme_manager.current_theme.name == "Dark"
        tag_color = '#FFB6C1' if is_dark else self.tag_highlight_color  # Light pink in dark mode

        self.highlighter = TagHighlighter(self.document(), tag_color, invisible_char_color, enable_spellcheck=False)

        # Store raw text (with tags) for mode switching
        self._raw_text = text
    
    def keyPressEvent(self, event):
        """Override to fix clipboard and word navigation when invisible characters shown"""
        from PyQt6.QtCore import Qt
        from PyQt6.QtWidgets import QApplication
        from PyQt6.QtGui import QTextCursor
        
        # Check for Ctrl+C (copy)
        if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C:
            # Get selected text
            cursor = self.textCursor()
            if cursor.hasSelection():
                selected_text = cursor.selectedText()
                # Reverse invisible character replacements before copying
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'reverse_invisible_replacements'):
                    clean_text = main_window.reverse_invisible_replacements(selected_text)
                    # Also replace paragraph separator with newline (Qt uses U+2029)
                    clean_text = clean_text.replace('\u2029', '\n')
                    # Set clipboard with clean text
                    clipboard = QApplication.clipboard()
                    clipboard.setText(clean_text)
                    return  # Don't call parent - we handled it
        
        # Handle Ctrl+Arrow word navigation when invisibles are shown
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'invisible_display_settings'):
            if main_window.invisible_display_settings.get('spaces', False):
                ctrl_only = event.modifiers() == Qt.KeyboardModifier.ControlModifier
                ctrl_shift = event.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)
                
                if (ctrl_only or ctrl_shift) and event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right):
                    cursor = self.textCursor()
                    text = self.toPlainText()
                    pos = cursor.position()
                    word_chars = ('·', '\u200B', '\n', '\t', '→', '°', '↵', ' ')
                    
                    if event.key() == Qt.Key.Key_Right:
                        # Move to end of current word, then skip delimiters to start of next word
                        # First skip any delimiters we're on
                        while pos < len(text) and text[pos] in word_chars:
                            pos += 1
                        # Then skip to end of word (next delimiter)
                        while pos < len(text) and text[pos] not in word_chars:
                            pos += 1
                    else:  # Key_Left
                        # Move backwards: skip delimiters, then find start of word
                        if pos > 0:
                            pos -= 1
                        # Skip any delimiters
                        while pos > 0 and text[pos] in word_chars:
                            pos -= 1
                        # Find start of word
                        while pos > 0 and text[pos - 1] not in word_chars:
                            pos -= 1
                    
                    # Apply cursor movement
                    if ctrl_shift:
                        cursor.setPosition(pos, QTextCursor.MoveMode.KeepAnchor)
                    else:
                        cursor.setPosition(pos)
                    self.setTextCursor(cursor)
                    return
        
        # Arrow Up/Down: memoQ-style segment navigation at cell boundaries
        # When cursor is at top line and Up is pressed, go to previous segment
        # When cursor is at bottom line and Down is pressed, go to next segment
        if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and _cleaned_modifiers(event) == Qt.KeyboardModifier.NoModifier:
            cursor = self.textCursor()
            
            # Get current cursor position info
            current_block = cursor.block()
            doc = self.document()
            first_block = doc.firstBlock()
            last_block = doc.lastBlock()
            
            # Get table reference
            table = self.table_ref if hasattr(self, 'table_ref') else self.parent()
            
            if event.key() == Qt.Key.Key_Up:
                # Check if we're on the first line
                if current_block == first_block:
                    # Navigate to previous segment
                    main_window = self._get_main_window()
                    if main_window and hasattr(main_window, 'go_to_previous_segment'):
                        # Get cursor column position for smart positioning
                        col_in_line = cursor.positionInBlock()
                        # Navigate and let go_to_previous_segment position cursor in target cell
                        main_window.go_to_previous_segment(target_column=col_in_line, to_last_line=True)
                        
                        # Now move focus from target to source cell, preserving column position
                        if table:
                            current_row = table.currentRow()
                            source_widget = table.cellWidget(current_row, 2)  # Column 2 is Source
                            if source_widget and hasattr(source_widget, 'textCursor'):
                                new_cursor = source_widget.textCursor()
                                src_doc = source_widget.document()
                                src_last_block = src_doc.lastBlock()
                                new_cursor.setPosition(src_last_block.position())
                                line_length = src_last_block.length() - 1
                                target_pos = min(col_in_line, max(0, line_length))
                                new_cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, target_pos)
                                source_widget.setTextCursor(new_cursor)
                                source_widget.setFocus()
                        return
            
            elif event.key() == Qt.Key.Key_Down:
                # Check if we're on the last line
                if current_block == last_block:
                    # Navigate to next segment
                    main_window = self._get_main_window()
                    if main_window and hasattr(main_window, 'go_to_next_segment'):
                        # Get cursor column position for smart positioning
                        col_in_line = cursor.positionInBlock()
                        # Navigate and let go_to_next_segment position cursor in target cell
                        main_window.go_to_next_segment(target_column=col_in_line, to_first_line=True)
                        
                        # Now move focus from target to source cell, preserving column position
                        if table:
                            current_row = table.currentRow()
                            source_widget = table.cellWidget(current_row, 2)  # Column 2 is Source
                            if source_widget and hasattr(source_widget, 'textCursor'):
                                new_cursor = source_widget.textCursor()
                                src_doc = source_widget.document()
                                src_first_block = src_doc.firstBlock()
                                new_cursor.setPosition(src_first_block.position())
                                line_length = src_first_block.length() - 1
                                target_pos = min(col_in_line, max(0, line_length))
                                new_cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, target_pos)
                                source_widget.setTextCursor(new_cursor)
                                source_widget.setFocus()
                        return
        
        # Ctrl+Enter: Confirm & Next (same behavior as in target cell)
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            mods = event.modifiers()
            if (mods & Qt.KeyboardModifier.ControlModifier) and not (mods & (Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.AltModifier)):
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'confirm_selected_or_next'):
                    main_window.confirm_selected_or_next()
                    event.accept()
                    return
        
        # Tab key: Cycle to target cell (column 3) in same row
        if event.key() == Qt.Key.Key_Tab and event.modifiers() == Qt.KeyboardModifier.NoModifier:
            table = self.table_ref if hasattr(self, 'table_ref') else self.parent()
            if table and self.row >= 0:
                target_widget = table.cellWidget(self.row, 3)  # Column 3 is Target
                if target_widget:
                    target_widget.setFocus()
                    table.setCurrentCell(self.row, 3)
                    return

        # Ctrl+Up: Navigate to previous segment
        if event.key() == Qt.Key.Key_Up and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_previous_segment'):
                main_window.go_to_previous_segment()
            event.accept()
            return

        # Ctrl+Down: Navigate to next segment
        if event.key() == Qt.Key.Key_Down and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_next_segment'):
                main_window.go_to_next_segment()
            event.accept()
            return

        # Alt+Up: Quick add selected terms to Project glossary (no dialog)
        if event.key() == Qt.Key.Key_Up and _cleaned_modifiers(event) == Qt.KeyboardModifier.AltModifier:
            self._handle_quick_add_to_glossary_priority(1)
            event.accept()
            return

        # Alt+Down: Quick add selected terms to Background glossary (no dialog)
        if event.key() == Qt.Key.Key_Down and _cleaned_modifiers(event) == Qt.KeyboardModifier.AltModifier:
            self._handle_quick_add_to_glossary_priority(2)
            event.accept()
            return

        super().keyPressEvent(event)
    
    def mouseDoubleClickEvent(self, event):
        """Handle double-click to select words properly when invisibles are shown.

        When any invisible-character substitutions are active the text contains
        marker characters (·, →, °, ↵) and zero-width spaces (\u200B).
        We use the visible markers plus regular space as word delimiters.
        \u200B is intentionally excluded – it is a word-wrap hint, not a delimiter.
        """
        from PyQt6.QtGui import QTextCursor

        main_window = self._get_main_window()
        any_invisibles = False
        if main_window and hasattr(main_window, 'invisible_display_settings'):
            any_invisibles = any(main_window.invisible_display_settings.values())

        if any_invisibles:
            DELIMITERS = {' ', '·', '→', '°', '↵', '\n', '\t'}

            cursor = self.cursorForPosition(event.pos())
            pos = cursor.position()
            text = self.toPlainText()

            start = pos
            while start > 0 and text[start - 1] not in DELIMITERS:
                start -= 1

            end = pos
            while end < len(text) and text[end] not in DELIMITERS:
                end += 1

            # Trim leading \u200B (part of '·\u200B' marker pairs)
            while start < end and text[start] == '\u200B':
                start += 1

            cursor.setPosition(start)
            cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
            self.setTextCursor(cursor)
            return

        super().mouseDoubleClickEvent(event)

    def _handle_quick_add_to_glossary_priority(self, priority: int):
        """Handle Alt+Up/Down: Quick add selected terms to the Project/Background glossary (no dialog)"""
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, '_quick_add_term_with_priority'):
            main_window._quick_add_term_with_priority(priority)

    def _get_main_window(self):
        """Get the main application window by traversing the parent hierarchy"""
        from PyQt6.QtWidgets import QTableWidget
        
        # Try multiple ways to get table reference (different creation paths set different attributes)
        # Check table_widget FIRST - delegate explicitly sets this to the correct table
        # table_ref may be set to viewport (wrong) when delegate creates the editor
        table = None
        if hasattr(self, 'table_widget') and self.table_widget and isinstance(self.table_widget, QTableWidget):
            table = self.table_widget
        elif hasattr(self, 'table_ref') and self.table_ref and isinstance(self.table_ref, QTableWidget):
            table = self.table_ref
        else:
            # Walk up parent hierarchy to find QTableWidget
            widget = self.parent()
            while widget and not isinstance(widget, QTableWidget):
                widget = widget.parent() if hasattr(widget, 'parent') else None
            table = widget
        
        if not table:
            return None
        main_window = table.parent()
        while main_window and not hasattr(main_window, 'go_to_first_segment'):
            main_window = main_window.parent()
        return main_window
    
    def update_display_mode(self, text: str, show_tags: bool):
        """
        Update the display based on tag view mode.
        
        Args:
            text: The raw text (with HTML tags like <b>bold</b>)
            show_tags: If True, show raw tags. If False, show formatted WYSIWYG.
        """
        self._raw_text = text
        
        if show_tags:
            # Show raw tags as plain text
            self.setPlainText(text)
        else:
            # Show WYSIWYG - convert tags to actual formatting
            html = get_formatted_html_display(text)
            self.setHtml(html)
    
    def get_raw_text(self) -> str:
        """Get the raw text with tags, regardless of display mode."""
        return getattr(self, '_raw_text', self.toPlainText())

    def highlight_termbase_matches(self, matches_dict: Dict):
        """
        Highlight termbase matches in the text using the configured style.
        Does NOT change the widget - just adds formatting to existing text.
        
        Supported styles (configured in Settings > View Settings):
        - 'background': Pastel background colors for project/background glossary (default)
        - 'dotted': Subtle dotted underline (IDE/code editor style)
        - 'semibold': Slightly bolder text with tinted color (typographic)

        Args:
            matches_dict: Dictionary of {term: {'translation': str, ...}} or {term: str}
        """
        from PyQt6.QtGui import QTextCursor, QTextCharFormat, QColor, QFont

        # Get the document and create a cursor
        doc = self.document()
        display_text = self.toPlainText()

        # Build a mapping from clean-text positions to display-text positions
        # so term searches work even when invisible markers (·, →, °, ↵, \u200B) are present.
        MARKER_CHARS = frozenset('·→°↵\u200B')
        clean_chars = []
        clean_to_display = []  # clean_to_display[i] = display position of clean char i
        for di, ch in enumerate(display_text):
            if ch not in MARKER_CHARS:
                clean_to_display.append(di)
                clean_chars.append(ch)
        # Sentinel for end-of-text mapping
        clean_to_display.append(len(display_text))
        text = ''.join(clean_chars)
        text_lower = text.lower()
        
        # IMPORTANT: Always clear all previous formatting first to prevent inconsistent highlighting
        cursor = QTextCursor(doc)
        cursor.select(QTextCursor.SelectionType.Document)
        default_fmt = QTextCharFormat()
        cursor.setCharFormat(default_fmt)
        
        # If no matches, we're done (highlighting has been cleared)
        if not matches_dict:
            return
        
        # Get highlight style and hide_shorter setting from main window
        highlight_style = 'background'  # default
        dotted_color = '#808080'  # default medium gray (more visible)
        hide_shorter = False  # default: show all glossary matches
        parent = self.parent()
        while parent:
            if hasattr(parent, 'termbase_highlight_style'):
                highlight_style = getattr(parent, 'termbase_highlight_style', 'background')
                dotted_color = getattr(parent, 'termbase_dotted_color', '#808080')
                hide_shorter = getattr(parent, 'termbase_hide_shorter_matches', False)
                break
            elif hasattr(parent, 'load_general_settings'):
                settings = parent.load_general_settings()
                highlight_style = settings.get('termbase_highlight_style', 'semibold')
                dotted_color = settings.get('termbase_dotted_color', '#808080')
                hide_shorter = settings.get('termbase_hide_shorter_matches', False)
                break
            parent = parent.parent() if hasattr(parent, 'parent') else None

        # Sort matches by source term length (longest first) to avoid partial matches
        # Since dict keys are now term_ids, we need to extract source terms first
        term_entries = []
        for term_id, match_info in matches_dict.items():
            if isinstance(match_info, dict):
                source_term = match_info.get('source', '')
                if source_term:
                    term_entries.append((source_term, term_id, match_info))

        # Sort by source term length (longest first)
        term_entries.sort(key=lambda x: len(x[0]), reverse=True)
        
        # Track positions we've already highlighted to avoid overlaps
        highlighted_ranges = []
        found_count = 0
        
        for term, term_id, match_info in term_entries:
            # Get ranking, forbidden status, and termbase type
            ranking = match_info.get('ranking', None)
            forbidden = match_info.get('forbidden', False)
            is_project_termbase = match_info.get('is_project_termbase', False)
            translation = match_info.get('target', match_info.get('translation', ''))
            notes = match_info.get('notes', '')
            termbase_name = match_info.get('termbase_name', '')
            
            # IMPORTANT: Treat ranking #1 as project termbase (even if flag not set)
            is_effective_project = is_project_termbase or (ranking == 1)
            
            # Build tooltip text
            tooltip_text = ""
            if translation:
                tooltip_text = f"Termbase: {translation}"
                if termbase_name:
                    tooltip_text += f"\nFrom: {termbase_name}"
                if notes:
                    tooltip_text += f"\nNote: {notes}"
            
            # Find all occurrences of this term (case-insensitive)
            term_lower = term.lower()
            start = 0
            while True:
                idx = text_lower.find(term_lower, start)
                if idx == -1:
                    break
                
                end_idx = idx + len(term)
                
                # Decide whether to skip this occurrence based on overlap and the
                # "Hide shorter glossary matches" setting (General Settings checkbox).
                # hide_shorter OFF (default): shorter terms highlight even inside a longer phrase.
                # hide_shorter ON: any overlap with an already-highlighted range → skip.
                overlaps = any(
                    (idx < h_end and end_idx > h_start)
                    for h_start, h_end in highlighted_ranges
                )
                exact_duplicate = (idx, end_idx) in highlighted_ranges
                skip = exact_duplicate or (hide_shorter and overlaps)

                if not skip:
                    # Map clean-text positions back to display-text positions
                    display_start = clean_to_display[idx]
                    display_end = clean_to_display[end_idx]

                    # Create cursor for this position (using display positions)
                    cursor = QTextCursor(doc)
                    cursor.setPosition(display_start)
                    cursor.setPosition(display_end, QTextCursor.MoveMode.KeepAnchor)

                    # Create format based on style
                    fmt = QTextCharFormat()
                    
                    is_project_term = (ranking == 1)

                    if highlight_style == 'dotted':
                        # DOTTED UNDERLINE STYLE
                        fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.DotLine)
                        if forbidden:
                            fmt.setUnderlineColor(QColor(0, 0, 0))  # Black for forbidden
                        elif is_project_term:
                            fmt.setUnderlineColor(QColor('#CC0000'))  # Red for project glossary
                        else:
                            fmt.setUnderlineColor(QColor(dotted_color))  # User-configured color
                        if tooltip_text:
                            fmt.setToolTip(tooltip_text)

                    elif highlight_style == 'semibold':
                        # SEMIBOLD TEXT STYLE
                        fmt.setFontWeight(QFont.Weight.DemiBold)
                        if forbidden:
                            fmt.setForeground(QColor(180, 0, 0))  # Dark red for forbidden
                        elif is_project_term:
                            fmt.setForeground(QColor('#1B5E20'))  # Dark green for project glossary
                        else:
                            fmt.setForeground(QColor('#43A047'))  # Lighter green for background
                        if tooltip_text:
                            fmt.setToolTip(tooltip_text)

                    else:
                        # BACKGROUND COLOR STYLE (default)
                        if forbidden:
                            color = QColor(0, 0, 0)  # Black for forbidden terms
                            fmt.setForeground(QColor("white"))
                        else:
                            # Project glossary = medium green, Background = light green
                            color = QColor(165, 214, 167) if is_project_term else QColor(200, 230, 201)
                            fmt.setForeground(QColor("black"))
                        fmt.setBackground(color)
                        if tooltip_text:
                            fmt.setToolTip(tooltip_text)
                    
                    # Apply format
                    cursor.setCharFormat(fmt)
                    found_count += 1
                    
                    # Track this range as highlighted
                    highlighted_ranges.append((idx, end_idx))
                
                start = end_idx
    
    def highlight_non_translatables(self, nt_matches: list, highlighted_ranges: list = None):
        """
        Highlight non-translatable matches in the text using pastel yellow background.
        
        Args:
            nt_matches: List of dicts with 'text', 'start', 'end' keys from NT manager
            highlighted_ranges: Optional list of already-highlighted ranges to avoid overlap
        """
        from PyQt6.QtGui import QTextCursor, QTextCharFormat, QColor
        
        if not nt_matches:
            return
        
        doc = self.document()
        
        # Track ranges we've highlighted (to avoid overlap with termbase matches)
        if highlighted_ranges is None:
            highlighted_ranges = []
        
        # Pastel yellow for non-translatables
        nt_color = QColor(255, 253, 208)  # Pastel yellow (#FFFDD0)
        
        for match in nt_matches:
            start_pos = match.get('start', 0)
            end_pos = match.get('end', 0)
            
            if start_pos >= end_pos:
                continue
            
            # Check for overlap with existing highlights
            overlaps = any(
                (start_pos < h_end and end_pos > h_start)
                for h_start, h_end in highlighted_ranges
            )
            
            if overlaps:
                continue
            
            # Create cursor for this position
            cursor = QTextCursor(doc)
            cursor.setPosition(start_pos)
            cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
            
            # Create format with pastel yellow background
            fmt = QTextCharFormat()
            fmt.setBackground(nt_color)
            fmt.setForeground(QColor("#5D4E37"))  # Dark brown text for contrast
            
            cursor.setCharFormat(fmt)
            highlighted_ranges.append((start_pos, end_pos))
    
    def event(self, event):
        """Override event() to catch Tab and Ctrl+T keys before Qt's default handling"""
        # Catch Tab key at event level (before keyPressEvent)
        if event.type() == event.Type.KeyPress:
            key_event = event
            
            # Ctrl+Alt+T: Add selected terms to termbase (with dialog).
            # Matches the shortcut used by the Trados plugin so muscle
            # memory transfers between products.  Was Ctrl+E before
            # v1.9.475; the old binding is now free for Qt's stock "edit"
            # default if anyone wants it back.
            #
            # Modifier mask: strip KeypadModifier (macOS arrow-key quirk)
            # and GroupSwitchModifier (Windows AltGr) before comparing.
            # Without this, on Windows AltGr-tolerant keyboards the
            # Ctrl+Alt combo carries an extra GroupSwitchModifier bit,
            # the strict equality check fails, the event falls through
            # to the QTextEdit, and the letter T just gets typed into
            # the cell instead of opening the dialog (v1.9.477 fix).
            if key_event.key() == Qt.Key.Key_T:
                _mod_mask = (Qt.KeyboardModifier.ControlModifier
                             | Qt.KeyboardModifier.AltModifier
                             | Qt.KeyboardModifier.ShiftModifier
                             | Qt.KeyboardModifier.MetaModifier)
                _mods = key_event.modifiers() & _mod_mask
                if _mods == (Qt.KeyboardModifier.ControlModifier
                             | Qt.KeyboardModifier.AltModifier):
                    self._handle_add_to_termbase()
                    return True  # Event handled
            
            # Alt+Up: Quick add selected terms to Project glossary (no dialog)
            if key_event.key() == Qt.Key.Key_Up and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.AltModifier:
                self._handle_quick_add_to_glossary_priority(1)
                return True  # Event handled

            # Alt+Down: Quick add selected terms to Background glossary (no dialog)
            if key_event.key() == Qt.Key.Key_Down and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.AltModifier:
                self._handle_quick_add_to_glossary_priority(2)
                return True  # Event handled
            
            # Ctrl+Alt+N: Add selected text to non-translatables
            if key_event.key() == Qt.Key.Key_N and key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier):
                self._handle_add_to_nt()
                return True  # Event handled
            
            # Ctrl+Up: Navigate to previous segment
            if key_event.key() == Qt.Key.Key_Up and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.ControlModifier:
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'go_to_previous_segment'):
                    main_window.go_to_previous_segment()
                return True  # Event handled

            # Ctrl+Down: Navigate to next segment
            if key_event.key() == Qt.Key.Key_Down and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.ControlModifier:
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'go_to_next_segment'):
                    main_window.go_to_next_segment()
                return True  # Event handled

            # Ctrl+Home: Navigate to first segment (pass to main window)
            if key_event.key() == Qt.Key.Key_Home and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.ControlModifier:
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'go_to_first_segment'):
                    main_window.go_to_first_segment()
                return True  # Event handled

            # Ctrl+End: Navigate to last segment (pass to main window)
            if key_event.key() == Qt.Key.Key_End and _cleaned_modifiers(key_event) == Qt.KeyboardModifier.ControlModifier:
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'go_to_last_segment'):
                    main_window.go_to_last_segment()
                return True  # Event handled
            
            if key_event.key() == Qt.Key.Key_Tab:
                # Ctrl+Tab: Insert actual tab character (if editing is allowed)
                if key_event.modifiers() == Qt.KeyboardModifier.ControlModifier:
                    if self.allow_source_edit:
                        self.insertPlainText('\t')
                        return True  # Event handled
                
                # Tab alone: Cycle to target cell (column 3) in same row
                if key_event.modifiers() == Qt.KeyboardModifier.NoModifier:
                    if self.table_ref and self.row >= 0:
                        # Get target cell widget (column 3)
                        target_widget = self.table_ref.cellWidget(self.row, 3)
                        if target_widget:
                            target_widget.setFocus()
                            self.table_ref.setCurrentCell(self.row, 3)
                            return True  # Event handled, don't propagate
        
        # Let base class handle all other events
        return super().event(event)
    
    def keyPressEvent(self, event):
        """Override to handle other keys (Tab is handled in event())"""
        # All keys: Handle normally (Tab already handled in event())
        super().keyPressEvent(event)
    
    def sizeHint(self):
        """Return compact size based on content"""
        doc = self.document()
        ideal_width = self.width() if self.width() > 0 else 200
        doc.setTextWidth(ideal_width)
        height = int(doc.size().height())
        # Add minimal padding (2px total)
        height = max(height + 2, 1)
        current_width = self.width() if self.width() > 0 else 200
        return QSize(current_width, height)
    
    def _handle_add_to_termbase(self):
        """Handle Ctrl+E: Add selected source and target terms to glossary (with dialogue)"""
        if not self.table_ref or self.row < 0:
            return

        # Get source selection (from this widget) – strip invisible markers before saving
        source_text = strip_invisible_markers(self.textCursor().selectedText()).strip()

        # Get target cell widget and its selection – strip invisible markers before saving
        target_widget = self.table_ref.cellWidget(self.row, 3)
        target_text = ""
        if target_widget and hasattr(target_widget, 'textCursor'):
            target_text = strip_invisible_markers(target_widget.textCursor().selectedText()).strip()
        
        # Validate we have both selections
        if not source_text or not target_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                "Please select text in both Source and Target cells before adding to termbase.\\n\\n"
                "Workflow:\\n"
                "1. Select term in source cell\\n"
                "2. Press Tab to cycle to target cell\\n"
                "3. Select corresponding translation\\n"
                f"4. Press {format_shortcut_for_display('Ctrl+Alt+T')} (or right-click) to add to termbase"
            )
            return
        
        # Find main window and call add_to_termbase method
        main_window = self.table_ref.parent()
        while main_window and not hasattr(main_window, 'add_term_pair_to_termbase'):
            main_window = main_window.parent()
        
        if main_window and hasattr(main_window, 'add_term_pair_to_termbase'):
            main_window.add_term_pair_to_termbase(source_text, target_text)
    
    def _handle_quick_add_to_glossary_priority(self, priority: int):
        """Handle Alt+Up/Down: Quick add selected terms to the Project/Background glossary (no dialog)"""
        if not self.table_ref or self.row < 0:
            return

        # Strip invisible markers before saving to glossary
        source_text = strip_invisible_markers(self.textCursor().selectedText()).strip()

        target_widget = self.table_ref.cellWidget(self.row, 3)
        target_text = ""
        if target_widget and hasattr(target_widget, 'textCursor'):
            target_text = strip_invisible_markers(target_widget.textCursor().selectedText()).strip()

        if not source_text or not target_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                f"Please select text in both Source and Target cells before quick-adding to the termbase.\n\n"
                f"Tip: Use {format_shortcut_for_display('Ctrl+Alt+T')} to add with a dialogue where you can choose a termbase and add metadata."
            )
            return

        main_window = self.table_ref.parent()
        while main_window and not hasattr(main_window, '_quick_add_term_with_priority'):
            main_window = main_window.parent()

        if main_window and hasattr(main_window, '_quick_add_term_with_priority'):
            main_window._quick_add_term_with_priority(priority)
    
    def _handle_superlookup_search(self):
        """Handle right-click: Search selected text in Superlookup"""
        # Get selected text
        selected_text = self.textCursor().selectedText().strip()
        
        if not selected_text:
            return
        
        # Find main window
        main_window = self._get_main_window()
        if not main_window:
            return
        
        # Get project languages
        source_lang = None
        target_lang = None
        if hasattr(main_window, 'current_project') and main_window.current_project:
            source_lang = main_window.current_project.source_lang
            target_lang = main_window.current_project.target_lang
        
        main_window._go_to_superlookup(
            query=selected_text,
            source_lang=source_lang,
            target_lang=target_lang,
        )

    def mouseMoveEvent(self, event):
        """Show tooltip when hovering over highlighted termbase matches"""
        super().mouseMoveEvent(event)

        # Get cursor position
        cursor = self.cursorForPosition(event.pos())
        cursor_pos = cursor.position()

        # Anchored-comment highlight takes priority: if the cursor is over the
        # yellow range of an anchored comment, show the comment as the tooltip.
        comment_pairs = _anchored_comments_at(
            self._get_main_window(), getattr(self, 'row', -1), 'source', cursor_pos)
        if comment_pairs:
            self.setToolTip(_format_comment_tooltip(comment_pairs))
            return

        # Check if cursor is over a termbase match
        for term_id, match_info in self.termbase_matches.items():
            # Extract source term from match_info
            term = match_info.get('source', '') if isinstance(match_info, dict) else str(term_id)
            # Find term position in text
            text = self.toPlainText()
            text_lower = text.lower()
            term_lower = term.lower()
            
            start = 0
            while True:
                idx = text_lower.find(term_lower, start)
                if idx == -1:
                    break
                
                end_idx = idx + len(term)
                
                # Check if cursor is within this term's range
                if idx <= cursor_pos <= end_idx:
                    # Get translation and other info
                    if isinstance(match_info, dict):
                        translation = match_info.get('translation', '')
                        forbidden = match_info.get('forbidden', False)

                        # Build tooltip (show only target translation)
                        tooltip = f"<b>{translation}</b>"
                        if forbidden:
                            tooltip += "<br><span style='color: red;'>⚠️ FORBIDDEN TERM</span>"
                        tooltip += "<br><span style='color: #666;'><i>Double-click to insert</i></span>"
                    else:
                        tooltip = f"<b>{match_info}</b><br><span style='color: #666;'><i>Double-click to insert</i></span>"
                    
                    self.setToolTip(tooltip)
                    return
                
                start = end_idx
        
        # No match found at cursor position
        self.setToolTip("")
    
    def mouseDoubleClickEvent(self, event):
        """Insert termbase translation on double-click"""
        # Get cursor position
        cursor = self.cursorForPosition(event.pos())
        cursor_pos = cursor.position()
        
        # Check if double-clicked on a termbase match
        for term_id, match_info in self.termbase_matches.items():
            # Extract source term from match_info
            term = match_info.get('source', '') if isinstance(match_info, dict) else str(term_id)
            text = self.toPlainText()
            text_lower = text.lower()
            term_lower = term.lower()
            
            start = 0
            while True:
                idx = text_lower.find(term_lower, start)
                if idx == -1:
                    break
                
                end_idx = idx + len(term)
                
                # Check if cursor is within this term's range
                if idx <= cursor_pos <= end_idx:
                    # Get translation
                    if isinstance(match_info, dict):
                        translation = match_info.get('translation', '')
                    else:
                        translation = match_info
                    
                    # Insert translation into target cell at cursor position
                    if self.table_ref and self.row >= 0:
                        target_widget = self.table_ref.cellWidget(self.row, 3)
                        if target_widget and hasattr(target_widget, 'textCursor'):
                            # Insert at current cursor position in target
                            target_cursor = target_widget.textCursor()
                            target_cursor.insertText(translation)
                            target_widget.setFocus()
                    return
                
                start = end_idx
        
        # If not on a termbase match, allow normal double-click selection
        super().mouseDoubleClickEvent(event)
    
    def mousePressEvent(self, event):
        """Allow text selection on click and trigger row selection"""
        super().mousePressEvent(event)

        # Use stored table reference and row number
        if self.table_ref and self.row >= 0:
            try:
                # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
                modifiers = event.modifiers()
                is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
                is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier

                if is_shift or is_ctrl:
                    # For Shift+click (range) or Ctrl+click (toggle), just set current cell
                    # but don't call selectRow() which would clear the selection
                    self.table_ref.setCurrentCell(self.row, 2)
                else:
                    # Normal click - select just this row
                    self.table_ref.selectRow(self.row)
                    self.table_ref.setCurrentCell(self.row, 2)

                    # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
                    # Find the main window and call the method directly
                    main_window = self.table_ref.parent()
                    while main_window and not hasattr(main_window, 'on_cell_selected'):
                        main_window = main_window.parent()
                    if main_window and hasattr(main_window, 'on_cell_selected'):
                        main_window.on_cell_selected(self.row, 2, -1, -1)
            except Exception as e:
                print(f"Error triggering manual cell selection: {e}")

    def mouseReleaseEvent(self, event):
        """Smart word selection - expand partial selections to full words
        
        Works across multiple lines - if you select text spanning several lines,
        partial words at the START and END of your selection will be expanded.
        """
        super().mouseReleaseEvent(event)

        # Check if smart selection is enabled
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'enable_smart_word_selection'):
            if not main_window.enable_smart_word_selection:
                return  # Feature disabled

        # Get the current cursor
        cursor = self.textCursor()

        # Only expand if there's a selection
        if cursor.hasSelection():
            # Get selection boundaries
            start = cursor.selectionStart()
            end = cursor.selectionEnd()

            # Get the full text
            text = self.toPlainText()

            # Helper function to check if character is part of a word
            # Includes alphanumeric, underscore, hyphen, and apostrophe
            def is_word_char(char):
                return char.isalnum() or char in "_-'"

            # Track if we need to update the selection
            selection_changed = False

            # Expand START boundary if we're in the middle of a word
            # (i.e., the character before the selection is a word character)
            if start > 0 and is_word_char(text[start - 1]):
                # Also check that the first selected character is a word char
                # (to avoid expanding when selecting from whitespace)
                if start < len(text) and is_word_char(text[start]):
                    while start > 0 and is_word_char(text[start - 1]):
                        start -= 1
                    selection_changed = True

            # Expand END boundary if we're in the middle of a word
            # (i.e., the character after the selection is a word character)
            if end < len(text) and is_word_char(text[end]):
                # Also check that the last selected character is a word char
                # (to avoid expanding when selecting to whitespace)
                if end > 0 and is_word_char(text[end - 1]):
                    while end < len(text) and is_word_char(text[end]):
                        end += 1
                    selection_changed = True

            # Set the new selection if boundaries changed
            if selection_changed:
                cursor.setPosition(start)
                cursor.setPosition(end, cursor.MoveMode.KeepAnchor)
                self.setTextCursor(cursor)

    def _get_main_window(self):
        """Get the main application window by traversing the parent hierarchy"""
        if not self.parent():
            return None
        main_window = self.parent()
        while main_window and not hasattr(main_window, 'go_to_first_segment'):
            main_window = main_window.parent()
        return main_window
    
    def focusInEvent(self, event):
        """Select text when focused for easy copying and trigger row selection"""
        super().focusInEvent(event)
        # Don't auto-select - let user select manually

        # Use stored table reference and row number
        if self.table_ref and self.row >= 0:
            try:
                # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
                from PyQt6.QtWidgets import QApplication
                modifiers = QApplication.keyboardModifiers()
                is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
                is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier

                if is_shift or is_ctrl:
                    # For Shift+click (range) or Ctrl+click (toggle), just set current cell
                    # but don't call selectRow() which would clear the selection
                    self.table_ref.setCurrentCell(self.row, 2)
                else:
                    # Normal focus - select just this row
                    self.table_ref.selectRow(self.row)
                    self.table_ref.setCurrentCell(self.row, 2)

                    # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
                    # Find the main window and call the method directly
                    main_window = self.table_ref.parent()
                    while main_window and not hasattr(main_window, 'on_cell_selected'):
                        main_window = main_window.parent()
                    if main_window and hasattr(main_window, 'on_cell_selected'):
                        main_window.on_cell_selected(self.row, 2, -1, -1)
            except Exception as e:
                print(f"Error triggering manual cell selection: {e}")

    def contextMenuEvent(self, event):
        """Show context menu with Add to Glossary and Add to Non-Translatables options"""
        from PyQt6.QtWidgets import QMenu
        from PyQt6.QtGui import QAction

        menu = QMenu(self)

        # "Open comment(s)" – jump to the relevant comment in the Comments tab.
        # Right-clicking a yellow anchored range opens that specific comment;
        # right-clicking anywhere else on a segment that has comments opens the
        # segment's first comment (this is how segment-level comments — which
        # have no highlighted range — are reached from the grid).
        if _add_open_comment_actions(self, menu, 'source', event.pos()):
            menu.addSeparator()

        # Add standard actions
        if self.textCursor().hasSelection():
            copy_action = QAction("Copy", self)
            copy_action.triggered.connect(self.copy)
            menu.addAction(copy_action)
            menu.addSeparator()

        # Superlookup search action
        if self.textCursor().hasSelection():
            superlookup_action = QAction(f"🔍 Search in SuperLookup ({format_shortcut_for_display('Ctrl+K')})", self)
            superlookup_action.triggered.connect(self._handle_superlookup_search)
            menu.addAction(superlookup_action)

        # QuickLauncher (prompt-based actions + QuickTrans)
        try:
            main_window = self._get_main_window()
            quicklauncher_items = []
            if main_window and hasattr(main_window, 'prompt_manager_qt') and main_window.prompt_manager_qt:
                lib = getattr(main_window.prompt_manager_qt, 'library', None)
                if lib and hasattr(lib, 'get_quicklauncher_grid_prompts'):
                    quicklauncher_items = lib.get_quicklauncher_grid_prompts() or []

            qm_menu = menu.addMenu("⚡ QuickLauncher")

            # Bold heading at the top of the submenu
            from PyQt6.QtWidgets import QWidgetAction, QLabel
            _heading_lbl = QLabel("  Supervertaler QuickLauncher")
            _heading_lbl.setStyleSheet("font-weight: bold; padding: 4px 8px; color: #1565C0;")
            _heading_act = QWidgetAction(qm_menu)
            _heading_act.setDefaultWidget(_heading_lbl)
            qm_menu.addAction(_heading_act)
            qm_menu.addSeparator()

            # Capture selected text NOW before menu takes focus
            _sel_text = self.textCursor().selectedText() if self.textCursor().hasSelection() else None

            # QuickTrans at the top of the QuickLauncher
            # v1.10.61: was "(Ctrl+M)" — that was a stale display hint
            # from an earlier version; no in-app binding ever existed.
            # The actual hotkey is the GLOBAL pynput chord (Ctrl+Alt+Q
            # on Windows/Linux, Meta+Ctrl+Q on macOS) which works
            # system-wide, including inside Workbench.
            _qt_hotkey = 'Meta+Ctrl+Q' if IS_MACOS else 'Ctrl+Alt+Q'
            qt_action = QAction(f"⚡ QuickTrans ({format_shortcut_for_display(_qt_hotkey)})", self)
            qt_action.triggered.connect(self._handle_mt_quick_lookup)
            qm_menu.addAction(qt_action)

            # Chat – defer with QTimer so menu fully closes first.
            # Despite the legacy method name (`show_supervertaler_assistant`)
            # this navigates to the in-Workbench Chat tab; the standalone
            # Sidekick window was retired in v1.10.4.
            assistant_action = QAction("💬 Chat", self)
            assistant_action.triggered.connect(
                lambda: QTimer.singleShot(0, lambda: main_window.show_supervertaler_assistant(
                    initial_text=_sel_text
                ))
            )
            qm_menu.addAction(assistant_action)

            if quicklauncher_items:
                qm_menu.addSeparator()
                for rel_path, label in sorted(quicklauncher_items, key=lambda x: (x[1] or x[0]).lower()):
                    prompt_menu = qm_menu.addMenu(label or rel_path)

                    run_show = QAction("▶ Run (show response)…", self)
                    run_show.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="show")
                    )
                    prompt_menu.addAction(run_show)

                    run_replace = QAction("↺ Run and replace target selection", self)
                    run_replace.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="replace")
                    )
                    prompt_menu.addAction(run_replace)

                    copy_clip = QAction("📋 Copy to clipboard", self)
                    copy_clip.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="clipboard")
                    )
                    prompt_menu.addAction(copy_clip)

                menu.addSeparator()
        except Exception:
            # Never break the normal context menu due to QuickLauncher errors
            pass

        # Add to glossary action (with dialogue)
        add_to_tb_action = QAction(f"📖 Add to Termbase ({format_shortcut_for_display('Ctrl+Alt+T')})", self)
        add_to_tb_action.triggered.connect(self._handle_add_to_termbase)
        menu.addAction(add_to_tb_action)

        # Quick add to Project glossary (no dialog)
        quick_add_p1_action = QAction(f"⚡ Quick Add to Project Termbase ({format_shortcut_for_display('Alt+Up')})", self)
        quick_add_p1_action.triggered.connect(lambda: self._handle_quick_add_to_glossary_priority(1))
        menu.addAction(quick_add_p1_action)

        # Quick add to Background glossary (no dialog)
        quick_add_p2_action = QAction(f"⚡ Quick Add to Background Termbase ({format_shortcut_for_display('Alt+Down')})", self)
        quick_add_p2_action.triggered.connect(lambda: self._handle_quick_add_to_glossary_priority(2))
        menu.addAction(quick_add_p2_action)

        # Add to non-translatables action
        add_to_nt_action = QAction(f"🚫 Add to Non-Translatables ({format_shortcut_for_display('Ctrl+Alt+N')})", self)
        add_to_nt_action.triggered.connect(self._handle_add_to_nt)
        menu.addAction(add_to_nt_action)

        menu.exec(event.globalPos())

    def _handle_add_to_nt(self):
        """Handle Ctrl+Alt+N: Add selected text to active non-translatable list(s)"""
        # Get selected text
        selected_text = self.textCursor().selectedText().strip()

        if not selected_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                "Please select text in the Source cell before adding to non-translatables."
            )
            return

        # Find main window and call add_to_nt method
        table = self.table_ref if hasattr(self, 'table_ref') else self.parent()
        if table:
            main_window = table.parent()
            while main_window and not hasattr(main_window, 'add_text_to_non_translatables'):
                main_window = main_window.parent()

            if main_window and hasattr(main_window, 'add_text_to_non_translatables'):
                main_window.add_text_to_non_translatables(selected_text)
            else:
                from PyQt6.QtWidgets import QMessageBox
                QMessageBox.warning(
                    self,
                    "Feature Not Available",
                    "Non-translatables functionality not available."
                )

    def _handle_mt_quick_lookup(self):
        """Handle right-click: Open MT Quick Lookup popup for selected text or full source"""
        # Get selected text (if any)
        selected_text = self.textCursor().selectedText().strip() if self.textCursor().hasSelection() else None

        # Find main window and call show_mt_quick_popup
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'show_mt_quick_popup'):
            main_window.show_mt_quick_popup(text_override=selected_text)

    def set_file_boundary(self, is_boundary: bool):
        """Mark this row as a file boundary. Kept for API compatibility; visual handled by banner overlay."""
        self._file_boundary = is_boundary

    def set_background_color(self, color: str, text_color: str = None):
        """Set the background color (and optional forced text color) for this cell.

        text_color is passed for the selection tint so the editor's text stays
        readable on the pale-blue selection background — without it, dark themes
        render light text on light blue (unreadable). Alternating-row callers
        pass no text_color, leaving the theme's default editor colour in place.
        """
        self._grid_bg_color = color
        self._grid_text_color = text_color
        self._rebuild_grid_stylesheet()

    def apply_grid_font_size(self, size_pt: float):
        """Set the displayed font size for this grid cell.

        Sets the size in this widget's own stylesheet (not just setFont) so
        the value wins over ThemeManager's app-level `QTextEdit { font-size }`
        rule emitted at non-100% UI scale. Without this, grid zoom and global
        scale would silently lose the per-cell font-size battle to that rule.
        """
        self._grid_font_pt = size_pt
        self._rebuild_grid_stylesheet()

    def _rebuild_grid_stylesheet(self):
        """Compose this cell's stylesheet from current bg colour + font size."""
        bg_rule = f"background-color: {self._grid_bg_color};" if self._grid_bg_color else ""
        font_rule = f"font-size: {self._grid_font_pt:.1f}pt;" if self._grid_font_pt else ""
        _tc = getattr(self, '_grid_text_color', None)
        color_rule = f"color: {_tc};" if _tc else ""
        self.setStyleSheet(f"""
            QTextEdit {{
                border: none;
                padding: 0px 4px 0px 0px;
                {bg_rule}
                {font_rule}
                {color_rule}
            }}
            QTextEdit:focus {{
                border: 1px solid #2196F3;
            }}
            QTextEdit::selection {{
                background-color: #000000;
                color: #FFFFFF;
            }}
        """)


class TagHighlighter(QSyntaxHighlighter):
    """Syntax highlighter for HTML/XML tags, CAT tool tags, CafeTran pipe symbols, Markdown syntax, and spell checking in text editors"""

    # Class-level reference to spellcheck manager (shared across all instances)
    _spellcheck_manager = None
    _spellcheck_enabled = False
    _is_cafetran_project = False  # Only highlight pipe symbols for CafeTran projects
    _is_markdown_project = False  # Highlight Markdown syntax for Markdown imports

    def __init__(self, document, tag_color='#7f0001', invisible_char_color='#999999', enable_spellcheck=False):
        super().__init__(document)
        self.tag_color = tag_color
        self.invisible_char_color = invisible_char_color
        self._local_spellcheck_enabled = enable_spellcheck
        self.update_tag_format()

    @classmethod
    def set_spellcheck_manager(cls, manager):
        """Set the shared spellcheck manager instance"""
        cls._spellcheck_manager = manager

    @classmethod
    def set_spellcheck_enabled(cls, enabled: bool):
        """Enable or disable spellchecking globally"""
        cls._spellcheck_enabled = enabled

    def update_tag_format(self):
        """Update the tag format with current color"""
        from PyQt6.QtGui import QTextCharFormat, QColor
        self.tag_format = QTextCharFormat()
        self.tag_format.setForeground(QColor(self.tag_color))

        # CafeTran pipe symbols - red and bold like in CafeTran
        self.pipe_format = QTextCharFormat()
        self.pipe_format.setForeground(QColor('#FF0000'))  # Red
        self.pipe_format.setFontWeight(700)  # Bold

        # Invisible character symbols - use configured color
        self.invisible_format = QTextCharFormat()
        self.invisible_format.setForeground(QColor(self.invisible_char_color))

        # Spellcheck format - red wavy underline
        self.spellcheck_format = QTextCharFormat()
        self.spellcheck_format.setUnderlineColor(QColor('#FF0000'))
        self.spellcheck_format.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline)

        # Markdown syntax formats
        # Bold/Italic markers: ** __ * _
        self.md_bold_format = QTextCharFormat()
        self.md_bold_format.setForeground(QColor('#C71585'))  # Medium violet red
        self.md_bold_format.setFontWeight(700)  # Bold to make it stand out
        
        # Heading markers: # ## ### etc.
        self.md_heading_format = QTextCharFormat()
        self.md_heading_format.setForeground(QColor('#0066CC'))  # Blue
        self.md_heading_format.setFontWeight(700)
        
        # Code markers: ` ``` 
        self.md_code_format = QTextCharFormat()
        self.md_code_format.setForeground(QColor('#D2691E'))  # Chocolate/Orange
        self.md_code_format.setFontWeight(700)
        
        # Link/Image syntax: []() ![]()
        self.md_link_format = QTextCharFormat()
        self.md_link_format.setForeground(QColor('#6A5ACD'))  # Slate blue/Purple
        
        # Blockquote markers: >
        self.md_quote_format = QTextCharFormat()
        self.md_quote_format.setForeground(QColor('#228B22'))  # Forest green
        self.md_quote_format.setFontWeight(700)
        
        # List markers: - * + 1. 2.
        self.md_list_format = QTextCharFormat()
        self.md_list_format.setForeground(QColor('#FF6600'))  # Orange
        self.md_list_format.setFontWeight(700)

    def set_tag_color(self, color: str):
        """Update tag highlight color"""
        self.tag_color = color
        self.update_tag_format()
        self.rehighlight()

    def set_invisible_char_color(self, color: str):
        """Update invisible character color"""
        self.invisible_char_color = color
        self.update_tag_format()
        self.rehighlight()

    def set_local_spellcheck(self, enabled: bool):
        """Enable or disable spellcheck for this specific highlighter"""
        self._local_spellcheck_enabled = enabled
        self.rehighlight()
    
    def highlightBlock(self, text):
        """Highlight all tags, pipe symbols, invisible chars, and misspelled words in the text block"""
        import re
        # Combined pattern for ALL CAT tool tag types:
        # 1. HTML/XML: <tag>, </tag>, <tag/>, <tag attr="val">
        # 2. Trados numeric: <1>, </1>
        # 3. memoQ numeric bracket tags:
        #    - Opening: [1}, [2} etc.
        #    - Closing: {1], {2] etc.
        #    - Standalone: [1], [2] etc.
        # 4. memoQ content tags with text/attributes (from bilingual DOCX):
        #    - [uicontrol id="GUID-..."], [image cid="..." href="..."], etc.
        #    - {uicontrol}, {image}, etc. (closing tags)
        #    NOTE: Opening [tag] MUST have attributes (space+content) to avoid matching
        #          placeholders like [Company] or [Bedrijf]. Closing {tag} doesn't need attrs.
        tag_patterns = [
            r'</?[a-zA-Z][a-zA-Z0-9-]*/?(?:\s[^>]*)?>',  # HTML/XML tags
            r'</?\d+>',                                   # Trados numeric: <1>, </1>
            r'\{/?\d+/?\}',                               # Compact tag placeholders: {1}, {/1}, {1/}
            r'\[\d+[}\]]',                                # memoQ numeric: [1}, [1]
            r'\{\d+[}\]]',                                # memoQ numeric: {1}, {1]
            r'\[[^}\]]+\}',                               # memoQ mixed: [anything} (exclude } and ])
            r'\{[^\[\]]+\]',                              # memoQ mixed: {anything] (exclude [ and ])
            r'\[[a-zA-Z][^}\]]*\s[^}\]]*\]',              # memoQ content: [tag attr...] (exclude } and ])
            r'\{[a-zA-Z][a-zA-Z0-9_-]*\}',                # memoQ closing: {uicontrol}, {MQ}
            r'\{\d{5}\}',                                 # Déjà Vu tags: {00108}, {00109}, etc.
        ]
        combined_pattern = re.compile('|'.join(tag_patterns))

        matches_found = list(combined_pattern.finditer(text))

        for match in matches_found:
            start = match.start()
            length = match.end() - start
            self.setFormat(start, length, self.tag_format)

        # Match invisible character symbols (light blue)
        for i, char in enumerate(text):
            if char in '·→°↵':  # Invisible character replacement symbols
                self.setFormat(i, 1, self.invisible_format)
        
        # CafeTran pipe symbols (red and bold) - ONLY for CafeTran projects
        if TagHighlighter._is_cafetran_project:
            for i, char in enumerate(text):
                if char == '|':
                    self.setFormat(i, 1, self.pipe_format)

        # Markdown syntax highlighting - ONLY for Markdown projects
        if TagHighlighter._is_markdown_project:
            self._highlight_markdown_syntax(text)

        # Spell checking - only for target editors when enabled
        if self._local_spellcheck_enabled and TagHighlighter._spellcheck_enabled and TagHighlighter._spellcheck_manager:
            self._highlight_misspelled_words(text)

    def _highlight_misspelled_words(self, text):
        """Highlight misspelled words with red wavy underline"""
        # Safety check - if spellcheck manager is not available or disabled, skip
        if not TagHighlighter._spellcheck_manager:
            return
        
        # Check if spellcheck manager detected a crash - if so, skip entirely
        if hasattr(TagHighlighter._spellcheck_manager, '_crash_detected') and TagHighlighter._spellcheck_manager._crash_detected:
            return
        
        import re
        # Find all words (letters only, including accented characters)
        # Skip words inside tags or that look like technical content
        word_pattern = re.compile(r'\b([a-zA-ZÀ-ÿ\']+)\b', re.UNICODE)
        
        try:
            for match in word_pattern.finditer(text):
                word = match.group(1)
                start = match.start(1)
                length = len(word)
                
                # Skip very short words and words with apostrophes at start/end
                if len(word) < 2:
                    continue
                
                # Check if this word is inside ANY type of tag:
                # - HTML/XML: < ... >
                # - memoQ/DITA: [ ... } or [ ... ] or { ... ]
                before_text = text[:start]
                
                # Check for HTML tags
                last_html_open = before_text.rfind('<')
                last_html_close = before_text.rfind('>')
                if last_html_open > last_html_close:
                    continue  # Inside HTML tag
                
                # Check for bracket tags: [ ... } or [ ... ]
                last_square_open = before_text.rfind('[')
                last_square_close = max(before_text.rfind('}'), before_text.rfind(']'))
                if last_square_open > last_square_close:
                    continue  # Inside [tag...} or [tag...]
                
                # Check for curly tags: { ... ] or { ... }
                last_curly_open = before_text.rfind('{')
                last_curly_close = max(before_text.rfind(']'), before_text.rfind('}'))
                if last_curly_open > last_curly_close:
                    continue  # Inside {tag...] or {tag...}
                
                # Check spelling
                if not TagHighlighter._spellcheck_manager.check_word(word):
                    self.setFormat(start, length, self.spellcheck_format)
        except Exception as e:
            # If anything goes wrong during spellcheck highlighting, disable it
            print(f"Spellcheck highlighting error: {e}")
            if TagHighlighter._spellcheck_manager:
                TagHighlighter._spellcheck_manager._crash_detected = True
                TagHighlighter._spellcheck_manager.enabled = False

    def _highlight_markdown_syntax(self, text):
        """Highlight Markdown syntax elements to make them visually distinct"""
        import re
        
        # 1. Heading markers at start of line: # ## ### #### ##### ######
        #    Only match at start of line (or after only whitespace)
        heading_pattern = re.compile(r'^(#{1,6})\s', re.MULTILINE)
        for match in heading_pattern.finditer(text):
            self.setFormat(match.start(1), len(match.group(1)), self.md_heading_format)
        
        # 2. Bold markers: **text** or __text__
        #    Highlight just the markers, not the content
        bold_pattern = re.compile(r'(\*\*|__)(?=\S)(.+?)(?<=\S)\1')
        for match in bold_pattern.finditer(text):
            # Opening marker
            self.setFormat(match.start(), 2, self.md_bold_format)
            # Closing marker
            self.setFormat(match.end() - 2, 2, self.md_bold_format)
        
        # 3. Italic markers: *text* or _text_ (but not ** or __)
        #    Must not be preceded/followed by same character
        italic_pattern = re.compile(r'(?<!\*)\*(?!\*)(?=\S)(.+?)(?<=\S)\*(?!\*)|(?<!_)_(?!_)(?=\S)(.+?)(?<=\S)_(?!_)')
        for match in italic_pattern.finditer(text):
            # Opening marker (1 char)
            self.setFormat(match.start(), 1, self.md_bold_format)
            # Closing marker (1 char)
            self.setFormat(match.end() - 1, 1, self.md_bold_format)
        
        # 4. Inline code: `code`
        code_inline_pattern = re.compile(r'(`+)([^`]+)\1')
        for match in code_inline_pattern.finditer(text):
            # Highlight entire code span including backticks
            self.setFormat(match.start(), len(match.group(0)), self.md_code_format)
        
        # 5. Code fence markers: ``` or ~~~
        code_fence_pattern = re.compile(r'^(`{3,}|~{3,}).*$', re.MULTILINE)
        for match in code_fence_pattern.finditer(text):
            self.setFormat(match.start(), len(match.group(0)), self.md_code_format)
        
        # 6. Links: [text](url) - highlight brackets and parens, not the text
        link_pattern = re.compile(r'\[([^\]]+)\]\(([^\)]+)\)')
        for match in link_pattern.finditer(text):
            full_start = match.start()
            text_part = match.group(1)
            url_part = match.group(2)
            # [ bracket
            self.setFormat(full_start, 1, self.md_link_format)
            # ] bracket
            self.setFormat(full_start + 1 + len(text_part), 1, self.md_link_format)
            # ( and url and )
            url_start = full_start + 1 + len(text_part) + 1
            self.setFormat(url_start, len(url_part) + 2, self.md_link_format)
        
        # 7. Images: ![alt](url) - same as links but with !
        image_pattern = re.compile(r'!\[([^\]]*)\]\(([^\)]+)\)')
        for match in image_pattern.finditer(text):
            full_start = match.start()
            alt_part = match.group(1)
            url_part = match.group(2)
            # ! and [ 
            self.setFormat(full_start, 2, self.md_link_format)
            # ]
            self.setFormat(full_start + 2 + len(alt_part), 1, self.md_link_format)
            # ( and url and )
            url_start = full_start + 2 + len(alt_part) + 1
            self.setFormat(url_start, len(url_part) + 2, self.md_link_format)
        
        # 8. Blockquote markers: > at start of line
        quote_pattern = re.compile(r'^(>+)\s?', re.MULTILINE)
        for match in quote_pattern.finditer(text):
            self.setFormat(match.start(1), len(match.group(1)), self.md_quote_format)
        
        # 9. Unordered list markers: - * + at start of line (with space after)
        ul_pattern = re.compile(r'^(\s*)([-*+])\s', re.MULTILINE)
        for match in ul_pattern.finditer(text):
            marker_start = match.start(2)
            self.setFormat(marker_start, 1, self.md_list_format)
        
        # 10. Ordered list markers: 1. 2. 3. etc. at start of line
        ol_pattern = re.compile(r'^(\s*)(\d+\.)\s', re.MULTILINE)
        for match in ol_pattern.finditer(text):
            marker_start = match.start(2)
            marker_len = len(match.group(2))
            self.setFormat(marker_start, marker_len, self.md_list_format)
        
        # 11. Horizontal rules: --- or *** or ___ (3+ chars)
        hr_pattern = re.compile(r'^([-*_]{3,})\s*$', re.MULTILINE)
        for match in hr_pattern.finditer(text):
            self.setFormat(match.start(1), len(match.group(1)), self.md_heading_format)


class EditableGridTextEditor(QTextEdit):
    """Editable QTextEdit for target cells - allows text selection and editing"""
    
    # Class variable for tag highlight color (shared across all instances)
    tag_highlight_color = '#7f0001'  # Default memoQ dark red
    
    # Class variables for focus border customization
    focus_border_color = '#f1b79a'  # Default peach/salmon
    focus_border_thickness = 2  # Default 2px (slightly thicker than before)
    
    def __init__(self, text: str = "", parent=None, row: int = -1, table=None):
        super().__init__(parent)
        self.row = row  # Store row number for auto-selection
        self.table = table  # Store table reference
        self.setReadOnly(False)

        # CRITICAL: Track initial load state to ignore spurious textChanged events
        # Qt queues document change events during setPlainText() even with blockSignals(True).
        # When signals are unblocked later, Qt delivers these queued events, causing false
        # textChanged signals. This flag allows us to ignore the first event after unblocking.
        self._initial_load_complete = False

        # CRITICAL: Block ALL signals during initialization to prevent textChanged from firing
        # when setText is called. Signals will be unblocked AFTER signal handler is connected
        # in load_segments_to_grid. This prevents ANY textChanged events during grid loading.
        self.blockSignals(True)
        self.setPlainText(text)
        # DO NOT unblock signals here - they will be unblocked after handler connection

        # CRITICAL: Enable strong focus to receive Tab key events
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)

        # Make inactive selections stay visible with same color
        from PyQt6.QtGui import QPalette
        palette = self.palette()
        palette.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.Highlight, QColor("#000000"))
        palette.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
        palette.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Highlight, QColor("#000000"))
        palette.setColor(QPalette.ColorGroup.Inactive, QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
        self.setPalette(palette)

        # Add syntax highlighter for tags (with spellcheck enabled for target cells)
        # Get invisible char color and tag color from main window (theme-aware)
        main_window = self._get_main_window()
        invisible_char_color = main_window.invisible_char_color if main_window and hasattr(main_window, 'invisible_char_color') else '#999999'

        # Use theme-aware tag color (light pink in dark mode, dark red in light mode)
        is_dark = main_window and hasattr(main_window, 'theme_manager') and main_window.theme_manager and main_window.theme_manager.current_theme.name == "Dark"
        tag_color = '#FFB6C1' if is_dark else self.tag_highlight_color  # Light pink in dark mode

        self.highlighter = TagHighlighter(self.document(), tag_color, invisible_char_color, enable_spellcheck=True)

        # Style to look like a normal cell with subtle selection.
        # Background and text colors now managed by theme system.
        # Font size lives in this widget's own stylesheet (not just setFont) so
        # it wins over ThemeManager's app-level `QTextEdit { font-size }` rule
        # that gets emitted whenever the global UI scale != 100%.
        self._grid_font_pt: float | None = None
        self._grid_bg_color: str | None = None
        self._rebuild_grid_stylesheet()

        # Set document margins to 0 for compact display
        doc = self.document()
        doc.setDocumentMargin(0)

        # Configure text option for minimal line spacing
        # With zero-width spaces inserted after invisible character markers,
        # normal WordWrap will work correctly
        wrap_mode = QTextOption.WrapMode.WordWrap

        self.setWordWrapMode(wrap_mode)
        self.setAcceptRichText(False)
        # v1.10.96 — AlwaysOff instead of AsNeeded. Reported by a user
        # on a laptop screen: "no matter what I do with the resizing
        # of the text size, I always have a scroll bar in the target
        # box." Cause: QTextEdit's AsNeeded policy compares document
        # height (which includes sub-pixel line metrics + the
        # DocumentMargin we set to 0 + any zero-width-space padding
        # from the invisible-char machinery) against the viewport
        # height; a 1-px off-by-one between those two on certain font
        # rendering paths is enough to surface the scrollbar even when
        # the text visually fits. The row auto-resize machinery
        # (resizeRowToContents called after every confirm + on grid
        # load) keeps the cell tall enough for the content, so
        # AlwaysOff is safe — content never gets clipped in practice.
        # The actively-being-edited delegate editor (at the createEditor
        # site in the table delegate) keeps AsNeeded so very long
        # in-progress edits still get a usable scrollbar.
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

        text_option = QTextOption()
        text_option.setWrapMode(wrap_mode)
        doc.setDefaultTextOption(text_option)

        # Set minimum height to 0 - let content determine size
        self.setMinimumHeight(0)
        self.setMaximumHeight(16777215)  # Qt's max int

    def mouseDoubleClickEvent(self, event):
        """Handle double-click to select words properly when invisibles are shown.

        When any invisible-character substitutions are active the text contains
        marker characters (·, →, °, ↵) and zero-width spaces (\u200B).
        Qt's built-in double-click word selection treats \u200B as a word
        character, causing it to include the zero-width space in the selection
        and breaking copy-to-clipboard / add-to-glossary workflows.

        We override double-click to use only the *visible* marker characters
        (·, →, °, ↵, \n, \t) as word delimiters.  \u200B is intentionally
        excluded – it is a transparent word-wrap hint, not a delimiter.
        """
        from PyQt6.QtGui import QTextCursor

        main_window = self._get_main_window()
        any_invisibles = False
        if main_window and hasattr(main_window, 'invisible_display_settings'):
            any_invisibles = any(main_window.invisible_display_settings.values())

        if any_invisibles:
            # Word delimiters: regular space + visible invisible-character markers.
            # NOTE: \u200B (zero-width space) is deliberately NOT in this set –
            # it lives inside '·\u200B' pairs and must not split word selection.
            DELIMITERS = {' ', '·', '→', '°', '↵', '\n', '\t'}

            cursor = self.cursorForPosition(event.pos())
            pos = cursor.position()
            text = self.toPlainText()

            # Search backwards for a delimiter (or start of text)
            start = pos
            while start > 0 and text[start - 1] not in DELIMITERS:
                start -= 1

            # Search forwards for a delimiter (or end of text)
            end = pos
            while end < len(text) and text[end] not in DELIMITERS:
                end += 1

            # Trim any trailing \u200B from the selection (it's always at the
            # start of a '·\u200B' pair and would be the first char after 'end')
            # – nothing to do since we stop before delimiters.
            # But trim leading \u200B if the word starts right after a '·\u200B':
            while start < end and text[start] == '\u200B':
                start += 1

            cursor.setPosition(start)
            cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
            self.setTextCursor(cursor)
            return

        # Default Qt word-selection behaviour when no invisibles are shown
        super().mouseDoubleClickEvent(event)
    
    def _get_main_window(self):
        """Get the main application window by traversing the parent hierarchy"""
        if not self.table:
            return None
        main_window = self.table.parent()
        while main_window and not hasattr(main_window, 'go_to_first_segment'):
            main_window = main_window.parent()
        return main_window
    
    def _handle_add_to_termbase(self):
        """Handle Ctrl+E: Add selected source and target terms to glossary (with dialogue)"""
        if not self.table or self.row < 0:
            return
        
        # Get target selection (from this widget)
        target_text = self.textCursor().selectedText().strip()
        
        # Get source cell widget and its selection
        source_widget = self.table.cellWidget(self.row, 2)
        source_text = ""
        if source_widget and hasattr(source_widget, 'textCursor'):
            source_text = source_widget.textCursor().selectedText().strip()
        
        # Validate we have both selections
        if not source_text or not target_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                "Please select text in both Source and Target cells before adding to glossary.\\n\\n"
                "Workflow:\\n"
                "1. Select term in source/target cell\\n"
                "2. Press Tab to cycle to other cell\\n"
                "3. Select corresponding term\\n"
                f"4. Press {format_shortcut_for_display('Ctrl+Alt+T')} (or right-click) to add to glossary"
            )
            return
        
        # Find main window and call add_to_termbase method
        main_window = self.table.parent()
        while main_window and not hasattr(main_window, 'add_term_pair_to_termbase'):
            main_window = main_window.parent()
        
        if main_window and hasattr(main_window, 'add_term_pair_to_termbase'):
            main_window.add_term_pair_to_termbase(source_text, target_text)
    
    def _handle_quick_add_to_glossary_priority(self, priority: int):
        """Handle Alt+Up/Down: Quick add selected terms to the Project/Background glossary (no dialog)"""
        if not self.table or self.row < 0:
            return

        target_text = self.textCursor().selectedText().strip()

        source_widget = self.table.cellWidget(self.row, 2)
        source_text = ""
        if source_widget and hasattr(source_widget, 'textCursor'):
            source_text = source_widget.textCursor().selectedText().strip()

        if not source_text or not target_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                f"Please select text in both Source and Target cells before quick-adding to the termbase.\n\n"
                f"Tip: Use {format_shortcut_for_display('Ctrl+Alt+T')} to add with a dialogue where you can choose a termbase and add metadata."
            )
            return

        main_window = self.table.parent()
        while main_window and not hasattr(main_window, '_quick_add_term_with_priority'):
            main_window = main_window.parent()

        if main_window and hasattr(main_window, '_quick_add_term_with_priority'):
            main_window._quick_add_term_with_priority(priority)
    
    def _handle_superlookup_search(self):
        """Handle right-click: Search selected text in Superlookup"""
        # Get selected text
        selected_text = self.textCursor().selectedText().strip()
        
        if not selected_text:
            return
        
        # Find main window
        main_window = self.table.parent() if self.table else None
        while main_window and not hasattr(main_window, '_go_to_superlookup'):
            main_window = main_window.parent()
        
        if not main_window:
            return
        
        # Get project languages
        source_lang = None
        target_lang = None
        if hasattr(main_window, 'current_project') and main_window.current_project:
            source_lang = main_window.current_project.source_lang
            target_lang = main_window.current_project.target_lang
        
        main_window._go_to_superlookup(
            query=selected_text,
            source_lang=source_lang,
            target_lang=target_lang,
        )

    def mouseMoveEvent(self, event):
        """Show the anchored comment as a tooltip when hovering its yellow range."""
        super().mouseMoveEvent(event)
        try:
            cursor_pos = self.cursorForPosition(event.pos()).position()
            comment_pairs = _anchored_comments_at(
                self._get_main_window(), getattr(self, 'row', -1), 'target', cursor_pos)
            if comment_pairs:
                self.setToolTip(_format_comment_tooltip(comment_pairs))
            else:
                self.setToolTip("")
        except Exception:
            pass

    def contextMenuEvent(self, event):
        """Show context menu with Add to Glossary, Non-Translatables, and Spellcheck options"""
        from PyQt6.QtWidgets import QMenu
        from PyQt6.QtGui import QAction

        menu = QMenu(self)

        # "Open comment(s)" – anchored range opens that specific comment;
        # otherwise, a segment with comments offers its first (covers
        # segment-level comments, which have no highlighted range).
        if _add_open_comment_actions(self, menu, 'target', event.pos()):
            menu.addSeparator()

        # Check if cursor is on a misspelled word for spellcheck suggestions
        cursor_pos = self.cursorForPosition(event.pos())
        misspelled_word, word_start, word_end = self._get_misspelled_word_at_cursor(cursor_pos)
        
        if misspelled_word:
            # Get spellcheck suggestions
            if TagHighlighter._spellcheck_manager:
                suggestions = TagHighlighter._spellcheck_manager.get_suggestions(misspelled_word)
                
                if suggestions:
                    # Add suggestions at the top
                    for suggestion in suggestions[:5]:  # Limit to 5 suggestions
                        suggestion_action = QAction(f"✓ {suggestion}", self)
                        # Use default argument to capture current value
                        suggestion_action.triggered.connect(
                            lambda checked, s=suggestion, start=word_start, end=word_end: 
                            self._replace_word(start, end, s)
                        )
                        suggestion_action.setFont(menu.font())
                        menu.addAction(suggestion_action)
                    menu.addSeparator()
                
                # Add to dictionary action
                add_to_dict_action = QAction(
                    f"📖 Add '{misspelled_word}' to Dictionary ({format_shortcut_for_display('Alt+D')})",
                    self,
                )
                add_to_dict_action.triggered.connect(
                    lambda checked, w=misspelled_word: self._add_to_dictionary(w)
                )
                menu.addAction(add_to_dict_action)
                
                # Ignore this session action
                ignore_action = QAction(f"🔇 Ignore '{misspelled_word}' (this session)", self)
                ignore_action.triggered.connect(
                    lambda checked, w=misspelled_word: self._ignore_word(w)
                )
                menu.addAction(ignore_action)
                menu.addSeparator()
        
        # Add standard edit actions
        if self.textCursor().hasSelection():
            copy_action = QAction("Copy", self)
            copy_action.triggered.connect(self.copy)
            menu.addAction(copy_action)
            
            cut_action = QAction("Cut", self)
            cut_action.triggered.connect(self.cut)
            menu.addAction(cut_action)
            menu.addSeparator()
        
        paste_action = QAction("Paste", self)
        paste_action.triggered.connect(self.paste)
        menu.addAction(paste_action)
        menu.addSeparator()
        
        # Superlookup search action
        if self.textCursor().hasSelection():
            superlookup_action = QAction(f"🔍 Search in SuperLookup ({format_shortcut_for_display('Ctrl+K')})", self)
            superlookup_action.triggered.connect(self._handle_superlookup_search)
            menu.addAction(superlookup_action)

        # QuickLauncher (prompt-based actions + QuickTrans)
        try:
            main_window = self.table.parent() if self.table else None
            while main_window and not hasattr(main_window, 'run_grid_quicklauncher_prompt'):
                main_window = main_window.parent()

            quicklauncher_items = []
            if main_window and hasattr(main_window, 'prompt_manager_qt') and main_window.prompt_manager_qt:
                lib = getattr(main_window.prompt_manager_qt, 'library', None)
                if lib and hasattr(lib, 'get_quicklauncher_grid_prompts'):
                    quicklauncher_items = lib.get_quicklauncher_grid_prompts() or []

            qm_menu = menu.addMenu("⚡ QuickLauncher")

            # Bold heading at the top of the submenu
            from PyQt6.QtWidgets import QWidgetAction, QLabel
            _heading_lbl = QLabel("  Supervertaler QuickLauncher")
            _heading_lbl.setStyleSheet("font-weight: bold; padding: 4px 8px; color: #1565C0;")
            _heading_act = QWidgetAction(qm_menu)
            _heading_act.setDefaultWidget(_heading_lbl)
            qm_menu.addAction(_heading_act)
            qm_menu.addSeparator()

            # Capture selected text NOW before menu takes focus
            _sel_text = self.textCursor().selectedText() if self.textCursor().hasSelection() else None

            # QuickTrans at the top of the QuickLauncher
            # v1.10.61: was "(Ctrl+M)" — that was a stale display hint
            # from an earlier version; no in-app binding ever existed.
            # The actual hotkey is the GLOBAL pynput chord (Ctrl+Alt+Q
            # on Windows/Linux, Meta+Ctrl+Q on macOS) which works
            # system-wide, including inside Workbench.
            _qt_hotkey = 'Meta+Ctrl+Q' if IS_MACOS else 'Ctrl+Alt+Q'
            qt_action = QAction(f"⚡ QuickTrans ({format_shortcut_for_display(_qt_hotkey)})", self)
            qt_action.triggered.connect(self._handle_mt_quick_lookup)
            qm_menu.addAction(qt_action)

            # Chat – defer with QTimer so menu fully closes first.
            # Despite the legacy method name (`show_supervertaler_assistant`)
            # this navigates to the in-Workbench Chat tab; the standalone
            # Sidekick window was retired in v1.10.4.
            assistant_action = QAction("💬 Chat", self)
            assistant_action.triggered.connect(
                lambda: QTimer.singleShot(0, lambda: main_window.show_supervertaler_assistant(
                    initial_text=_sel_text
                ))
            )
            qm_menu.addAction(assistant_action)

            if quicklauncher_items:
                qm_menu.addSeparator()
                for rel_path, label in sorted(quicklauncher_items, key=lambda x: (x[1] or x[0]).lower()):
                    prompt_menu = qm_menu.addMenu(label or rel_path)

                    run_show = QAction("▶ Run (show response)…", self)
                    run_show.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="show")
                    )
                    prompt_menu.addAction(run_show)

                    run_replace = QAction("↺ Run and replace selection", self)
                    run_replace.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="replace")
                    )
                    prompt_menu.addAction(run_replace)

                    copy_clip = QAction("📋 Copy to clipboard", self)
                    copy_clip.triggered.connect(
                        lambda checked=False, p=rel_path: main_window.run_grid_quicklauncher_prompt(p, origin_widget=self, behavior="clipboard")
                    )
                    prompt_menu.addAction(copy_clip)

                menu.addSeparator()
        except Exception:
            # Never break the normal context menu due to QuickLauncher errors
            pass

        # Add to glossary action (with dialogue)
        add_to_tb_action = QAction(f"📖 Add to Termbase ({format_shortcut_for_display('Ctrl+Alt+T')})", self)
        add_to_tb_action.triggered.connect(self._handle_add_to_termbase)
        menu.addAction(add_to_tb_action)

        # Quick add to Project glossary (no dialog)
        quick_add_p1_action = QAction(f"⚡ Quick Add to Project Termbase ({format_shortcut_for_display('Alt+Up')})", self)
        quick_add_p1_action.triggered.connect(lambda: self._handle_quick_add_to_glossary_priority(1))
        menu.addAction(quick_add_p1_action)

        # Quick add to Background glossary (no dialog)
        quick_add_p2_action = QAction(f"⚡ Quick Add to Background Termbase ({format_shortcut_for_display('Alt+Down')})", self)
        quick_add_p2_action.triggered.connect(lambda: self._handle_quick_add_to_glossary_priority(2))
        menu.addAction(quick_add_p2_action)

        # Add to non-translatables action
        add_to_nt_action = QAction(f"🚫 Add to Non-Translatables ({format_shortcut_for_display('Ctrl+Alt+N')})", self)
        add_to_nt_action.triggered.connect(self._handle_add_to_nt)
        menu.addAction(add_to_nt_action)

        menu.exec(event.globalPos())

    def _get_misspelled_word_at_cursor(self, cursor):
        """Get the misspelled word at the cursor position, if any"""
        import re
        
        if not TagHighlighter._spellcheck_enabled or not TagHighlighter._spellcheck_manager:
            return None, 0, 0
        
        text = self.toPlainText()
        pos = cursor.position()
        
        # Find word boundaries around cursor position
        word_pattern = re.compile(r'\b([a-zA-ZÀ-ÿ\']+)\b', re.UNICODE)
        
        for match in word_pattern.finditer(text):
            start = match.start(1)
            end = match.end(1)
            
            if start <= pos <= end:
                word = match.group(1)
                # Check if this word is misspelled
                if not TagHighlighter._spellcheck_manager.check_word(word):
                    return word, start, end
                break
        
        return None, 0, 0

    def _replace_word(self, start: int, end: int, replacement: str):
        """Replace a word at the given position with the replacement"""
        cursor = self.textCursor()
        cursor.setPosition(start)
        cursor.setPosition(end, cursor.MoveMode.KeepAnchor)
        cursor.insertText(replacement)

    def _add_to_dictionary(self, word: str):
        """Add word to the custom dictionary"""
        if TagHighlighter._spellcheck_manager:
            TagHighlighter._spellcheck_manager.add_to_dictionary(word)
            
            # Show confirmation in main window log
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'log'):
                main_window.log(f"✓ Added '{word}' to custom dictionary")

            # Refresh all highlighters to update other occurrences
            if main_window and hasattr(main_window, '_refresh_all_highlighters'):
                main_window._refresh_all_highlighters()
            else:
                # Fallback to local rehighlight
                self.highlighter.rehighlight()

    def _ignore_word(self, word: str):
        """Ignore word for this session"""
        if TagHighlighter._spellcheck_manager:
            TagHighlighter._spellcheck_manager.ignore_word(word)
            
            # Show confirmation in main window log
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'log'):
                main_window.log(f"🔇 Ignoring '{word}' for this session")

            # Refresh all highlighters to update other occurrences
            if main_window and hasattr(main_window, '_refresh_all_highlighters'):
                main_window._refresh_all_highlighters()
            else:
                # Fallback to local rehighlight
                self.highlighter.rehighlight()
    
    def sizeHint(self):
        """Return compact size based on content"""
        doc = self.document()
        ideal_width = self.width() if self.width() > 0 else 200
        doc.setTextWidth(ideal_width)
        height = int(doc.size().height())
        # Add minimal padding (2px total)
        height = max(height + 2, 1)
        current_width = self.width() if self.width() > 0 else 200
        return QSize(current_width, height)
    
    def mousePressEvent(self, event):
        """Allow text selection on click and auto-select row"""
        super().mousePressEvent(event)
        # Auto-select the row when clicking in the target cell
        if self.table and self.row >= 0:
            # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
            modifiers = event.modifiers()
            is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
            is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier

            if is_shift or is_ctrl:
                # For Shift+click (range) or Ctrl+click (toggle), just set current cell
                # but don't call selectRow() which would clear the selection
                self.table.setCurrentCell(self.row, 3)  # Column 3 is Target
            else:
                # Normal click - select just this row
                self.table.selectRow(self.row)
                self.table.setCurrentCell(self.row, 3)  # Column 3 is Target

                # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
                # Find the main window and call the method directly
                try:
                    main_window = self.table.parent()
                    while main_window and not hasattr(main_window, 'on_cell_selected'):
                        main_window = main_window.parent()
                    if main_window and hasattr(main_window, 'on_cell_selected'):
                        main_window.on_cell_selected(self.row, 3, -1, -1)
                except Exception as e:
                    print(f"Error triggering manual cell selection: {e}")

    def mouseReleaseEvent(self, event):
        """Smart word selection - expand partial selections to full words
        
        Works across multiple lines - if you select text spanning several lines,
        partial words at the START and END of your selection will be expanded.
        """
        super().mouseReleaseEvent(event)

        # Check if smart selection is enabled
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'enable_smart_word_selection'):
            if not main_window.enable_smart_word_selection:
                return  # Feature disabled

        # Get the current cursor
        cursor = self.textCursor()

        # Only expand if there's a selection
        if cursor.hasSelection():
            # Get selection boundaries
            start = cursor.selectionStart()
            end = cursor.selectionEnd()

            # Get the full text
            text = self.toPlainText()

            # Helper function to check if character is part of a word
            # Includes alphanumeric, underscore, hyphen, and apostrophe
            def is_word_char(char):
                return char.isalnum() or char in "_-'"

            # Track if we need to update the selection
            selection_changed = False

            # Expand START boundary if we're in the middle of a word
            # (i.e., the character before the selection is a word character)
            if start > 0 and is_word_char(text[start - 1]):
                # Also check that the first selected character is a word char
                # (to avoid expanding when selecting from whitespace)
                if start < len(text) and is_word_char(text[start]):
                    while start > 0 and is_word_char(text[start - 1]):
                        start -= 1
                    selection_changed = True

            # Expand END boundary if we're in the middle of a word
            # (i.e., the character after the selection is a word character)
            if end < len(text) and is_word_char(text[end]):
                # Also check that the last selected character is a word char
                # (to avoid expanding when selecting to whitespace)
                if end > 0 and is_word_char(text[end - 1]):
                    while end < len(text) and is_word_char(text[end]):
                        end += 1
                    selection_changed = True

            # Set the new selection if boundaries changed
            if selection_changed:
                cursor.setPosition(start)
                cursor.setPosition(end, cursor.MoveMode.KeepAnchor)
                self.setTextCursor(cursor)

    def focusInEvent(self, event):
        """Ensure text remains visible when focused and auto-select row"""
        super().focusInEvent(event)
        # Ensure the widget is properly visible
        self.setVisible(True)
        self.show()
        # Auto-select the row when focusing the target cell
        if self.table and self.row >= 0:
            # Check for Shift or Ctrl modifier - let Qt handle native multi-selection
            from PyQt6.QtWidgets import QApplication
            modifiers = QApplication.keyboardModifiers()
            is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
            is_ctrl = modifiers & Qt.KeyboardModifier.ControlModifier

            if is_shift or is_ctrl:
                # For Shift+click (range) or Ctrl+click (toggle), just set current cell
                # but don't call selectRow() which would clear the selection
                self.table.setCurrentCell(self.row, 3)  # Column 3 is Target
            else:
                # Normal focus - select just this row
                self.table.selectRow(self.row)
                self.table.setCurrentCell(self.row, 3)  # Column 3 is Target

                # CRITICAL: Manually trigger on_cell_selected since signals aren't firing
                # Find the main window and call the method directly
                try:
                    main_window = self.table.parent()
                    while main_window and not hasattr(main_window, 'on_cell_selected'):
                        main_window = main_window.parent()
                    if main_window and hasattr(main_window, 'on_cell_selected'):
                        main_window.on_cell_selected(self.row, 3, -1, -1)
                except Exception as e:
                    print(f"Error triggering manual cell selection: {e}")
    
    def keyPressEvent(self, event):
        """Handle Tab and Ctrl+E keys to cycle between source and target cells"""
        from PyQt6.QtWidgets import QApplication
        from PyQt6.QtGui import QTextCursor
        
        # Ctrl+C: Fix clipboard when copying with invisible characters shown
        if event.key() == Qt.Key.Key_C and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            cursor = self.textCursor()
            if cursor.hasSelection():
                selected_text = cursor.selectedText()
                # Reverse invisible character replacements before copying
                main_window = self._get_main_window()
                if main_window and hasattr(main_window, 'reverse_invisible_replacements'):
                    clean_text = main_window.reverse_invisible_replacements(selected_text)
                    # Also replace paragraph separator with newline (Qt uses U+2029)
                    clean_text = clean_text.replace('\u2029', '\n')
                    # Set clipboard with clean text
                    clipboard = QApplication.clipboard()
                    clipboard.setText(clean_text)
                    event.accept()
                    return
        
        # Handle Ctrl+Arrow word navigation when invisibles are shown
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'invisible_display_settings'):
            if main_window.invisible_display_settings.get('spaces', False):
                ctrl_only = event.modifiers() == Qt.KeyboardModifier.ControlModifier
                ctrl_shift = event.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)
                
                if (ctrl_only or ctrl_shift) and event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right):
                    cursor = self.textCursor()
                    text = self.toPlainText()
                    pos = cursor.position()
                    word_chars = ('·', '\u200B', '\n', '\t', '→', '°', '↵', ' ')
                    
                    if event.key() == Qt.Key.Key_Right:
                        # Move to end of current word, then skip delimiters to start of next word
                        # First skip any delimiters we're on
                        while pos < len(text) and text[pos] in word_chars:
                            pos += 1
                        # Then skip to end of word (next delimiter)
                        while pos < len(text) and text[pos] not in word_chars:
                            pos += 1
                    else:  # Key_Left
                        # Move backwards: skip delimiters, then find start of word
                        if pos > 0:
                            pos -= 1
                        # Skip any delimiters
                        while pos > 0 and text[pos] in word_chars:
                            pos -= 1
                        # Find start of word
                        while pos > 0 and text[pos - 1] not in word_chars:
                            pos -= 1
                    
                    # Apply cursor movement
                    if ctrl_shift:
                        cursor.setPosition(pos, QTextCursor.MoveMode.KeepAnchor)
                    else:
                        cursor.setPosition(pos)
                    self.setTextCursor(cursor)
                    event.accept()
                    return
        
        # Ctrl+Alt+T: Add selected terms to termbase (with dialog).
        # Was Ctrl+E in this widget before v1.9.475 (a leftover binding I
        # missed when migrating the source-side editor), and went through a
        # strict-equality modifier check that broke on Windows AltGr; fix
        # is the same as in ReadOnlyGridTextEditor – mask away
        # KeypadModifier / GroupSwitchModifier before comparing.
        if event.key() == Qt.Key.Key_T:
            _mod_mask = (Qt.KeyboardModifier.ControlModifier
                         | Qt.KeyboardModifier.AltModifier
                         | Qt.KeyboardModifier.ShiftModifier
                         | Qt.KeyboardModifier.MetaModifier)
            _mods = event.modifiers() & _mod_mask
            if _mods == (Qt.KeyboardModifier.ControlModifier
                         | Qt.KeyboardModifier.AltModifier):
                self._handle_add_to_termbase()
                event.accept()
                return
        
        # Ctrl+Up: Navigate to previous segment
        if event.key() == Qt.Key.Key_Up and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_previous_segment'):
                main_window.go_to_previous_segment()
            event.accept()
            return

        # Ctrl+Down: Navigate to next segment
        if event.key() == Qt.Key.Key_Down and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_next_segment'):
                main_window.go_to_next_segment()
            event.accept()
            return

        # Alt+Up: Quick add selected terms to Project glossary (no dialog)
        if event.key() == Qt.Key.Key_Up and _cleaned_modifiers(event) == Qt.KeyboardModifier.AltModifier:
            self._handle_quick_add_to_glossary_priority(1)
            event.accept()
            return

        # Alt+Down: Quick add selected terms to Background glossary (no dialog)
        if event.key() == Qt.Key.Key_Down and _cleaned_modifiers(event) == Qt.KeyboardModifier.AltModifier:
            self._handle_quick_add_to_glossary_priority(2)
            event.accept()
            return

        # Ctrl+Alt+N: Add selected text to non-translatables
        if event.key() == Qt.Key.Key_N and event.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier):
            self._handle_add_to_nt()
            event.accept()
            return
        
        # Ctrl+Home: Navigate to first segment (pass to main window)
        if event.key() == Qt.Key.Key_Home and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_first_segment'):
                main_window.go_to_first_segment()
            event.accept()
            return
        
        # Ctrl+End: Navigate to last segment (pass to main window)
        if event.key() == Qt.Key.Key_End and _cleaned_modifiers(event) == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'go_to_last_segment'):
                main_window.go_to_last_segment()
            event.accept()
            return
        
        # Ctrl+, (comma): Insert next memoQ tag or wrap selection with tag pair
        if event.key() == Qt.Key.Key_Comma and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self._insert_next_tag_or_wrap_selection()
            event.accept()
            return
        
        # Ctrl+Shift+S: Copy source text to target
        if event.key() == Qt.Key.Key_S and event.modifiers() == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
            self._copy_source_to_target()
            event.accept()
            return
        
        # Ctrl+B: Apply bold formatting to selection
        if event.key() == Qt.Key.Key_B and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self._apply_formatting_tag('b')
            event.accept()
            return
        
        # Ctrl+I: Apply italic formatting to selection
        if event.key() == Qt.Key.Key_I and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self._apply_formatting_tag('i')
            event.accept()
            return
        
        # Ctrl+U: Apply underline formatting to selection
        if event.key() == Qt.Key.Key_U and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self._apply_formatting_tag('u')
            event.accept()
            return
        
        # Ctrl+Enter: Confirm & Next (call main window method directly)
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            main_window = self._get_main_window()
            if main_window and hasattr(main_window, 'confirm_selected_or_next'):
                main_window.confirm_selected_or_next()
            event.accept()
            return
        
        # Shift+Enter: Insert line break (for multi-line content)
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
            super().keyPressEvent(event)
            event.accept()
            return
        
        # Plain Enter: Don't insert newline, just accept
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and event.modifiers() == Qt.KeyboardModifier.NoModifier:
            event.accept()
            return
        
        # Ctrl+Tab: Insert actual tab character
        if event.key() == Qt.Key.Key_Tab and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
            self.insertPlainText('\t')
            event.accept()
            return
        
        # Tab alone: Cycle to source cell (column 2) in same row
        if event.key() == Qt.Key.Key_Tab and event.modifiers() == Qt.KeyboardModifier.NoModifier:
            if self.table and self.row >= 0:
                # Get source cell widget (column 2)
                source_widget = self.table.cellWidget(self.row, 2)
                if source_widget:
                    source_widget.setFocus()
                    self.table.setCurrentCell(self.row, 2)
                    event.accept()
                    return
        
        # Arrow Up/Down: memoQ-style segment navigation at cell boundaries
        # When cursor is at top VISUAL line and Up is pressed, go to previous segment
        # When cursor is at bottom VISUAL line and Down is pressed, go to next segment
        if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and _cleaned_modifiers(event) == Qt.KeyboardModifier.NoModifier:
            cursor = self.textCursor()
            current_block = cursor.block()
            doc = self.document()
            first_block = doc.firstBlock()
            last_block = doc.lastBlock()
            
            # Get the visual line number within the current block
            layout = current_block.layout()
            if layout:
                pos_in_block = cursor.positionInBlock()
                current_visual_line = layout.lineForTextPosition(pos_in_block)
                current_line_num = current_visual_line.lineNumber() if current_visual_line.isValid() else 0
                total_lines_in_block = layout.lineCount()
            else:
                current_line_num = 0
                total_lines_in_block = 1
            
            if event.key() == Qt.Key.Key_Up:
                # Only navigate to previous segment if we're on the FIRST visual line of the FIRST block
                is_first_visual_line = (current_block == first_block and current_line_num == 0)
                if is_first_visual_line:
                    main_window = self._get_main_window()
                    if main_window and hasattr(main_window, 'go_to_previous_segment'):
                        # Get cursor column within current visual line
                        if layout and current_visual_line.isValid():
                            col_in_line = pos_in_block - int(current_visual_line.textStart())
                        else:
                            col_in_line = cursor.positionInBlock()
                        # Signal rapid navigation for performance optimization
                        main_window._arrow_key_navigation = True
                        main_window.go_to_previous_segment(target_column=col_in_line, to_last_line=True)
                        event.accept()
                        return
            
            elif event.key() == Qt.Key.Key_Down:
                # Only navigate to next segment if we're on the LAST visual line of the LAST block
                is_last_visual_line = (current_block == last_block and current_line_num >= total_lines_in_block - 1)
                if is_last_visual_line:
                    main_window = self._get_main_window()
                    if main_window and hasattr(main_window, 'go_to_next_segment'):
                        # Get cursor column within current visual line
                        if layout and current_visual_line.isValid():
                            col_in_line = pos_in_block - int(current_visual_line.textStart())
                        else:
                            col_in_line = cursor.positionInBlock()
                        # Signal rapid navigation for performance optimization
                        main_window._arrow_key_navigation = True
                        main_window.go_to_next_segment(target_column=col_in_line, to_first_line=True)
                        event.accept()
                        return
        
        # All other keys: Handle normally
        super().keyPressEvent(event)
    
    def _handle_add_to_nt(self):
        """Handle Ctrl+Alt+N: Add selected text to active non-translatable list(s)"""
        # Get selected text from source cell (for NT, we typically add from source)
        # But if this is target and source is available, use source
        selected_text = self.textCursor().selectedText().strip()
        
        # If no selection in target, try getting from source
        if not selected_text and self.table and self.row >= 0:
            source_widget = self.table.cellWidget(self.row, 2)
            if source_widget and hasattr(source_widget, 'textCursor'):
                selected_text = source_widget.textCursor().selectedText().strip()
        
        if not selected_text:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                self,
                "Selection Required",
                "Please select text before adding to non-translatables."
            )
            return
        
        # Find main window and call add_to_nt method
        if self.table:
            main_window = self.table.parent()
            while main_window and not hasattr(main_window, 'add_text_to_non_translatables'):
                main_window = main_window.parent()
            
            if main_window and hasattr(main_window, 'add_text_to_non_translatables'):
                main_window.add_text_to_non_translatables(selected_text)
            else:
                from PyQt6.QtWidgets import QMessageBox
                QMessageBox.warning(
                    self,
                    "Feature Not Available",
                    "Non-translatables functionality not available."
                )

    def _handle_mt_quick_lookup(self):
        """Handle right-click: Open MT Quick Lookup popup for selected text or full source"""
        # Get selected text (if any) - prefer from target, then try source
        selected_text = self.textCursor().selectedText().strip() if self.textCursor().hasSelection() else None

        if not selected_text and self.table and self.row >= 0:
            # Try getting selected text from source cell
            source_widget = self.table.cellWidget(self.row, 2)
            if source_widget and hasattr(source_widget, 'textCursor'):
                cursor = source_widget.textCursor()
                if cursor.hasSelection():
                    selected_text = cursor.selectedText().strip()

        # Find main window and call show_mt_quick_popup
        main_window = self._get_main_window()
        if main_window and hasattr(main_window, 'show_mt_quick_popup'):
            main_window.show_mt_quick_popup(text_override=selected_text)

    def _insert_next_tag_or_wrap_selection(self):
        """
        Insert the next memoQ tag, HTML tag, or CafeTran pipe symbol from source, or wrap selection.
        
        Behavior:
        - If text is selected: Wrap it with the next available tag pair [N}selection{N] or |selection|
        - If no selection: Insert the next unused tag/pipe from source at cursor position
        
        Supports:
        - memoQ tags: [1}, {1], [2}, {2], etc.
        - HTML/XML tags: <li>, </li>, <b>, </b>, <i>, </i>, etc.
        - CafeTran pipe symbols: |
        
        Shortcut: Ctrl+, (comma)
        """
        if not self.table or self.row < 0:
            return
        
        # Navigate up to find main window
        main_window = self.table.parent()
        while main_window and not hasattr(main_window, 'current_project'):
            main_window = main_window.parent()
        
        if not main_window or not hasattr(main_window, 'current_project'):
            return
        
        if not main_window.current_project or self.row >= len(main_window.current_project.segments):
            return
        
        segment = main_window.current_project.segments[self.row]
        source_text = segment.source
        current_target = self.toPlainText()
        
        # Check what type of tags are in the source
        has_memoq_tags = bool(extract_memoq_tags(source_text))
        has_html_tags = bool(extract_html_tags(source_text))
        has_any_tags = has_memoq_tags or has_html_tags
        has_pipe_symbols = '|' in source_text
        
        # Check if there's a selection
        cursor = self.textCursor()
        if cursor.hasSelection():
            selected_text = cursor.selectedText()

            # Try memoQ tag pair first
            if has_memoq_tags:
                opening_tag, closing_tag = get_wrapping_tag_pair(source_text, current_target)
                if opening_tag and closing_tag:
                    wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
                    return

            # Try HTML tag pairs (e.g., <b>...</b>, <i>...</i>)
            if has_html_tags:
                opening_tag, closing_tag = get_html_wrapping_tag_pair(source_text, current_target)
                if opening_tag and closing_tag:
                    wrapped_text = f"{opening_tag}{selected_text}{closing_tag}"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with {opening_tag}...{closing_tag}")
                    return

            # Try CafeTran pipe symbols
            if has_pipe_symbols:
                pipes_needed = get_next_pipe_count_needed(source_text, current_target)
                if pipes_needed >= 2:
                    # Wrap with pipes
                    wrapped_text = f"|{selected_text}|"
                    cursor.insertText(wrapped_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Wrapped selection with |...|")
                    return

            if hasattr(main_window, 'log'):
                main_window.log("⚠️ No tag pairs available from source")
        else:
            # No selection - insert next unused tag or pipe at cursor

            # Try memoQ tags and HTML tags (find_next_unused_tag handles both)
            if has_any_tags:
                # In compact mode, expand target placeholders before comparison
                compact_map = getattr(self, '_compact_tag_map', None)
                compare_target = expand_compact_tags(current_target, compact_map) if compact_map else current_target
                next_tag = find_next_unused_tag(source_text, compare_target)
                if next_tag:
                    # In compact mode, insert the compact placeholder instead of full tag
                    insert_text = next_tag
                    if compact_map:
                        for placeholder, full_tag in compact_map.items():
                            if full_tag == next_tag:
                                insert_text = placeholder
                                break
                    cursor.insertText(insert_text)
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Inserted tag: {insert_text}")
                    return

            # Try CafeTran pipe symbols
            if has_pipe_symbols:
                pipes_needed = get_next_pipe_count_needed(source_text, current_target)
                if pipes_needed > 0:
                    cursor.insertText('|')
                    if hasattr(main_window, 'log'):
                        main_window.log(f"🏷️ Inserted pipe symbol (|)")
                    return

            if hasattr(main_window, 'log'):
                main_window.log("✓ All tags from source already in target")

    def _copy_source_to_target(self):
        """
        Copy source text to target cell(s).

        Shortcut: Ctrl+Shift+S (macOS: Cmd+Shift+S)

        Smart behavior:
        - Multiple segments selected → copy source to target for all selected
        - Single segment → copy source to target for current segment only
        """
        if not self.table or self.row < 0:
            return

        # Navigate up to find main window
        main_window = self._get_main_window()

        if not main_window or not hasattr(main_window, 'current_project'):
            return

        if not main_window.current_project or self.row >= len(main_window.current_project.segments):
            return

        # Check if multiple segments are selected – if so, delegate to bulk
        if hasattr(main_window, 'get_selected_segments_from_grid'):
            selected = main_window.get_selected_segments_from_grid()
            if len(selected) > 1 and hasattr(main_window, '_copy_source_to_target_selected'):
                main_window._copy_source_to_target_selected(selected)
                return

        # Single segment: copy source to target for current cell
        segment = main_window.current_project.segments[self.row]
        source_text = segment.source

        # Set the target text
        self.setPlainText(source_text)

        if hasattr(main_window, 'log'):
            main_window.log(f"📋 Copied source to target (segment {self.row + 1})")

    def _apply_formatting_tag(self, tag: str):
        """
        Apply or toggle a formatting tag on the selected text.
        
        Args:
            tag: The tag to apply ('b', 'i', or 'u')
        """
        cursor = self.textCursor()
        if not cursor.hasSelection():
            return
        
        selected_text = cursor.selectedText()
        
        # Check if the text is already wrapped with this tag
        open_tag = f"<{tag}>"
        close_tag = f"</{tag}>"
        
        # Get the full text and selection position
        full_text = self.toPlainText()
        start = cursor.selectionStart()
        end = cursor.selectionEnd()
        
        # Check for existing tags just before/after selection
        prefix_has_tag = start >= len(open_tag) and full_text[start - len(open_tag):start] == open_tag
        suffix_has_tag = end + len(close_tag) <= len(full_text) and full_text[end:end + len(close_tag)] == close_tag
        
        if prefix_has_tag and suffix_has_tag:
            # Remove the tags (toggle off)
            new_text = full_text[:start - len(open_tag)] + selected_text + full_text[end + len(close_tag):]
            cursor.setPosition(start - len(open_tag))
            cursor.setPosition(end + len(close_tag), cursor.MoveMode.KeepAnchor)
            cursor.insertText(selected_text)
        else:
            # Add the tags (toggle on)
            wrapped_text = f"{open_tag}{selected_text}{close_tag}"
            cursor.insertText(wrapped_text)
            
            # Re-select the wrapped text (including tags)
            cursor.setPosition(start)
            cursor.setPosition(start + len(wrapped_text), cursor.MoveMode.KeepAnchor)
            self.setTextCursor(cursor)

    def update_display_mode(self, text: str, show_tags: bool):
        """
        Update the display based on tag view mode.
        
        Args:
            text: The raw text (with HTML tags like <b>bold</b>)
            show_tags: If True, show raw tags. If False, show formatted WYSIWYG.
        """
        # Store raw text as property for later retrieval
        self._raw_text = text
        
        self.blockSignals(True)
        if show_tags:
            # Tag view: Show plain text with visible tags
            # The TagHighlighter will colorize the tags
            self.setPlainText(text)
        else:
            # WYSIWYG view: Apply formatting
            if has_formatting_tags(text):
                html = get_formatted_html_display(text)
                self.setHtml(html)
            else:
                # No tags, just plain text
                self.setPlainText(text)
        self.blockSignals(False)
    
    def get_raw_text(self) -> str:
        """Get the raw text with tags, regardless of display mode."""
        return getattr(self, '_raw_text', self.toPlainText())

    def set_file_boundary(self, is_boundary: bool):
        """Mark this row as a file boundary (shows a top separator line)."""
        self._file_boundary = is_boundary

    def set_background_color(self, color: str, text_color: str = None):
        """Set the background color (and optional forced text color) for this cell.

        text_color is passed for the selection tint so the editor's text stays
        readable on the pale-blue selection background — without it, dark themes
        render light text on light blue (unreadable). Alternating-row callers
        pass no text_color, leaving the theme's default editor colour in place.
        """
        self._grid_bg_color = color
        self._grid_text_color = text_color
        self._rebuild_grid_stylesheet()

    def apply_grid_font_size(self, size_pt: float):
        """Set the displayed font size for this grid cell.

        Sets the size in this widget's own stylesheet (not just setFont) so
        the value wins over ThemeManager's app-level `QTextEdit { font-size }`
        rule emitted at non-100% UI scale. Without this, grid zoom and global
        scale would silently lose the per-cell font-size battle to that rule.
        """
        self._grid_font_pt = size_pt
        self._rebuild_grid_stylesheet()

    def _rebuild_grid_stylesheet(self):
        """Compose this cell's stylesheet from current bg colour + font size."""
        border_color = EditableGridTextEditor.focus_border_color
        border_thickness = EditableGridTextEditor.focus_border_thickness
        bg_rule = f"background-color: {self._grid_bg_color};" if self._grid_bg_color else ""
        font_rule = f"font-size: {self._grid_font_pt:.1f}pt;" if self._grid_font_pt else ""
        _tc = getattr(self, '_grid_text_color', None)
        color_rule = f"color: {_tc};" if _tc else ""
        self.setStyleSheet(f"""
            QTextEdit {{
                border: none;
                padding: 0px 4px 0px 0px;
                {bg_rule}
                {font_rule}
                {color_rule}
            }}
            QTextEdit:focus {{
                border: {border_thickness}px solid {border_color};
            }}
            QTextEdit::selection {{
                background-color: #000000;
                color: #FFFFFF;
            }}
        """)


class SearchHighlightDelegate(QStyledItemDelegate):
    """
    Custom delegate that highlights search terms in cells while preserving full editability.
    Uses custom painting to draw highlights underneath the text.
    """

    def __init__(self, parent=None):
        super().__init__(parent)
        self.search_terms = {}  # Dict of {(row, col): search_term}
        self.highlight_color = QColor(255, 255, 0, 180)  # Yellow with some transparency

    def set_highlight(self, row: int, col: int, search_term: str):
        """Set a search term to highlight for a specific cell"""
        self.search_terms[(row, col)] = search_term

    def clear_highlight(self, row: int, col: int):
        """Clear highlight for a specific cell"""
        if (row, col) in self.search_terms:
            del self.search_terms[(row, col)]

    def clear_all_highlights(self):
        """Clear all highlights"""
        self.search_terms.clear()

    def paint(self, painter, option, index):
        """Custom paint method that highlights search terms"""
        row = index.row()
        col = index.column()

        # Check if this cell has a search term to highlight
        if (row, col) in self.search_terms:
            search_term = self.search_terms[(row, col)]
            text = index.data(Qt.ItemDataRole.DisplayRole) or ""

            if text and search_term:
                # Save painter state
                painter.save()

                # Draw the background first (handles selection, etc.)
                option_copy = QStyleOptionViewItem(option)
                self.initStyleOption(option_copy, index)

                # Get the style
                style = option.widget.style() if option.widget else QApplication.style()

                # Draw background
                style.drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, option_copy, painter, option.widget)

                # Calculate text rect
                text_rect = style.subElementRect(QStyle.SubElement.SE_ItemViewItemText, option_copy, option.widget)

                # Get font metrics
                fm = painter.fontMetrics()

                # Find and highlight all occurrences of the search term
                text_lower = text.lower()
                search_lower = search_term.lower()
                pos = 0

                while True:
                    found = text_lower.find(search_lower, pos)
                    if found == -1:
                        break

                    # Calculate the position of this occurrence
                    prefix = text[:found]
                    prefix_width = fm.horizontalAdvance(prefix)
                    term_width = fm.horizontalAdvance(text[found:found + len(search_term)])

                    # Draw highlight rectangle
                    highlight_rect = QRectF(
                        text_rect.left() + prefix_width,
                        text_rect.top(),
                        term_width,
                        text_rect.height()
                    )
                    painter.fillRect(highlight_rect, self.highlight_color)

                    pos = found + len(search_term)

                # Draw the text on top
                painter.setPen(option_copy.palette.color(QPalette.ColorRole.Text))
                painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text)

                painter.restore()
                return

        # Default painting for non-highlighted cells
        super().paint(painter, option, index)


class ClickableHighlightLabel(QLabel):
    """
    Custom QLabel for highlighted search results that allows double-click to edit.
    When double-clicked, it removes itself from the table cell and triggers editing.

    Mouse events are transparent by default - the table's viewport handles them.
    We use installEventFilter on the table's viewport to catch double-clicks.
    """

    def __init__(self, table, row: int, col: int, plain_text: str, parent=None):
        super().__init__(parent)
        self.table = table
        self.row = row
        self.col = col
        self.plain_text = plain_text  # Store the original plain text for restoration
        # Make mouse events pass through to the table underneath
        self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)


class TermbaseHighlightWidget(QLabel):
    """Custom label widget that displays source text with termbase highlights and tooltips"""
    
    # Signal for term double-click
    term_double_clicked = None
    
    def __init__(self, source_text: str, termbase_matches: Optional[Dict[str, str]] = None, parent=None):
        """
        Args:
            source_text: The full source text to display
            termbase_matches: Dict of {term: translation} for highlighted terms
            parent: Parent widget
        """
        super().__init__(parent)
        self.source_text = source_text
        self.termbase_matches = termbase_matches if termbase_matches is not None else {}
        self.term_ranges = {}  # Store {term: (start_pos, end_pos)}
        self.setWordWrap(True)
        self.setup_display()
        self.setMouseTracking(True)
    
    def setup_display(self):
        """Create rich text display with highlighted terms"""
        html = self._create_highlighted_html()
        self.setText(html)
        self.setTextFormat(Qt.TextFormat.RichText)
    
    def _create_highlighted_html(self) -> str:
        """Create HTML with highlighted termbase matches using priority colors"""
        text = self.source_text
        
        # Sort matches by source term length (longest first) to avoid overlaps
        # Since dict keys are now term_ids, extract source terms first
        term_entries = []
        for term_id, match_info in self.termbase_matches.items():
            if isinstance(match_info, dict):
                source_term = match_info.get('source', '')
                if source_term:
                    term_entries.append((source_term, match_info))
        
        term_entries.sort(key=lambda x: len(x[0]), reverse=True)
        
        # Build HTML with highlights
        html = text
        offset = 0
        
        for term, match_info in term_entries:
            # Case-insensitive search
            search_term = term.lower()
            search_html = html.lower()
            
            # Determine color based on project/background glossary
            if isinstance(match_info, dict):
                translation = match_info.get('translation', '')
                ranking = match_info.get('ranking', 0)
                is_project = (ranking == 1) or match_info.get('is_project_termbase', False)
            else:
                translation = match_info if isinstance(match_info, str) else ''
                is_project = False
            # Project glossary = medium green, Background = light green
            color = "#A5D6A7" if is_project else "#C8E6C9"
            
            # Find all occurrences
            start = 0
            while True:
                idx = search_html.find(search_term, start)
                if idx == -1:
                    break
                
                # Extract original casing
                original_term = html[idx:idx + len(term)]
                
                # Replace with highlighted version
                tb_label = "Project" if is_project else "Background"
                highlighted = f'<span style="background-color: {color}; color: black; padding: 1px 3px; border-radius: 2px; font-weight: bold; cursor: pointer;" class="termbase-match" data-term="{term}" title="{translation} ({tb_label})">{original_term}</span>'
                
                html = html[:idx] + highlighted + html[idx + len(term):]
                search_html = html.lower()
                
                start = idx + len(highlighted)
        
        return html
    
    def mouseMoveEvent(self, ev):
        """Show tooltip when hovering over highlighted terms"""
        # Find if cursor is over a highlighted term using text format
        # This is a simplified approach - shows generic tooltip
        super().mouseMoveEvent(ev)
    
    def mouseDoubleClickEvent(self, ev):
        """Handle double-click on highlighted terms"""
        # Find which term was clicked by getting the word at the position
        # Since QLabel doesn't provide cursor position easily, we look at the click
        # and try to extract the clicked word from the text
        
        # Get position in text (approximate)
        pos = ev.pos()
        
        # Try to find which term is near the click point
        # This is a simplified approach - in production would need better handling
        for term_id, match_info in self.termbase_matches.items():
            term = match_info.get('source', '') if isinstance(match_info, dict) else str(term_id)
            translation = match_info.get('translation', match_info) if isinstance(match_info, dict) else match_info
            # Check if this term contains the click area (very approximate)
            if self.term_double_clicked is not None and callable(self.term_double_clicked):
                # For now, if we get a double-click and there are matches,
                # this is a signal to insert the first/only match
                # Better implementation would need proper hit detection
                pass
        
        super().mouseDoubleClickEvent(ev)


class WordWrapDelegate(QStyledItemDelegate):
    """Custom delegate to enable word wrap when editing cells, with search term highlighting"""

    def __init__(self, assistance_panel=None, table_widget=None, allow_source_edit=False):
        super().__init__()
        self.assistance_panel = assistance_panel
        self.table_widget = table_widget
        self.allow_source_edit = allow_source_edit  # Controls whether source can be edited
        self.search_terms = {}  # Dict of {(row, col): search_term} for highlighting
        self.highlight_color = QColor(255, 255, 0, 180)  # Yellow with some transparency

    def set_highlight(self, row: int, col: int, search_term: str):
        """Set a search term to highlight for a specific cell"""
        self.search_terms[(row, col)] = search_term

    def clear_highlight(self, row: int, col: int):
        """Clear highlight for a specific cell"""
        if (row, col) in self.search_terms:
            del self.search_terms[(row, col)]

    def clear_all_highlights(self):
        """Clear all highlights"""
        self.search_terms.clear()

    def paint(self, painter, option, index):
        """Custom paint method that highlights search terms while keeping cells editable.

        Uses QTextDocument with HTML for reliable cross-line highlighting.
        Instead of storing per-cell terms, we store a global search term and check
        if the cell's text contains it.
        """
        row = index.row()
        col = index.column()

        # Check if this cell has a search term to highlight
        # Use global search term if set, otherwise check per-cell dict
        search_term = None
        if col == 3 and hasattr(self, 'global_search_term') and self.global_search_term:
            # For target column, use global search term
            search_term = self.global_search_term
        elif col == 2 and hasattr(self, 'global_source_search_term') and self.global_source_search_term:
            # For source column, use global source search term
            search_term = self.global_source_search_term
        elif (row, col) in self.search_terms:
            search_term = self.search_terms[(row, col)]

        # Debug: Log once per column 3 cell when global_search_term is set
        if col == 3 and hasattr(self, 'global_search_term') and self.global_search_term and row == 0:
            print(f"[PAINT DEBUG] Row 0, Col 3: global_search_term='{self.global_search_term}', search_term='{search_term}'")

        if search_term:
            text = index.data(Qt.ItemDataRole.DisplayRole) or ""

            # Only highlight if the search term is actually in the text
            if text and search_term.lower() in text.lower():
                from PyQt6.QtGui import QColor, QTextDocument, QAbstractTextDocumentLayout, QPalette
                from PyQt6.QtCore import QRectF, QSizeF
                import html as html_module

                # Get style and text rect
                option_copy = QStyleOptionViewItem(option)
                self.initStyleOption(option_copy, index)
                style = option.widget.style() if option.widget else QApplication.style()

                # Draw background first (handles selection state etc.)
                style.drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, option_copy, painter, option.widget)

                # Get the text rectangle
                text_rect = style.subElementRect(QStyle.SubElement.SE_ItemViewItemText, option_copy, option.widget)

                # Add small padding like default delegate
                text_rect = text_rect.adjusted(3, 0, -3, 0)

                # Create HTML with highlighted search terms
                # Escape HTML special characters first
                escaped_text = html_module.escape(text)
                escaped_term = html_module.escape(search_term)

                # Case-insensitive replacement with highlighting
                import re
                pattern = re.compile(re.escape(escaped_term), re.IGNORECASE)

                def replace_with_highlight(match):
                    return f'<span style="background-color: #FFFF00;">{match.group(0)}</span>'

                html_text = pattern.sub(replace_with_highlight, escaped_text)

                # Create QTextDocument with the highlighted HTML
                doc = QTextDocument()
                doc.setDefaultFont(option.font)
                doc.setTextWidth(text_rect.width())
                doc.setHtml(html_text)

                painter.save()
                painter.translate(text_rect.topLeft())

                # Clip to text rect
                painter.setClipRect(QRectF(0, 0, text_rect.width(), text_rect.height()))

                # Draw the document
                doc.drawContents(painter)

                painter.restore()
                return

        # Default painting for non-highlighted cells
        super().paint(painter, option, index)
    
    def createEditor(self, parent, option, index):
        """Create a QTextEdit for multi-line editing with word wrap"""
        # Target column (column 3) - always editable
        if index.column() == 3:
            editor = GridTextEditor(parent)
            editor.assistance_panel = self.assistance_panel
            editor.table_widget = self.table_widget
            editor.current_row = index.row()
            editor.setWordWrapMode(QTextOption.WrapMode.WordWrap)
            editor.setAcceptRichText(False)  # Plain text only
            editor.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
            editor.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
            
            # Ensure the row is tall enough for editing
            table = parent.parent()
            if hasattr(table, 'resizeRowToContents'):
                # Schedule row resize after editor is shown
                from PyQt6.QtCore import QTimer
                QTimer.singleShot(0, lambda: table.resizeRowToContents(index.row()))
            
            return editor
        
        # Source column (column 2) - editable for selection/dual selection, optionally editable for content
        elif index.column() == 2:
            editor = ReadOnlyGridTextEditor("", parent)
            editor.table_widget = self.table_widget
            editor.current_row = index.row()
            editor.allow_source_edit = self.allow_source_edit
            
            # If allow_source_edit is True, make it actually editable (with warning color)
            if self.allow_source_edit:
                editor.setReadOnly(False)
                editor.setStyleSheet("""
                    QTextEdit {
                        border: none;
                        background-color: #FFF9E6;
                        padding: 0px;
                        color: black;
                    }
                    QTextEdit:focus {
                        border: 1px solid #FF9800;
                        background-color: #FFFACD;
                        color: black;
                    }
                    QTextEdit::selection {
                        background-color: #000000;
                        color: #FFFFFF;
                    }
                """)
            
            # Ensure the row is tall enough for editing
            table = parent.parent()
            if hasattr(table, 'resizeRowToContents'):
                from PyQt6.QtCore import QTimer
                QTimer.singleShot(0, lambda: table.resizeRowToContents(index.row()))
            
            return editor
        else:
            return super().createEditor(parent, option, index)
    
    def setEditorData(self, editor, index):
        """Load data into the editor"""
        if isinstance(editor, (QTextEdit, ReadOnlyGridTextEditor)):
            text = index.model().data(index, Qt.ItemDataRole.EditRole)
            editor.setPlainText(text or "")
            # Ensure cursor is at start
            cursor = editor.textCursor()
            cursor.movePosition(cursor.MoveOperation.Start)
            editor.setTextCursor(cursor)
        else:
            super().setEditorData(editor, index)
    
    def setModelData(self, editor, model, index):
        """Save data from editor back to model"""
        if isinstance(editor, ReadOnlyGridTextEditor):
            # Only save if editing is allowed
            if editor.allow_source_edit:
                text = editor.toPlainText()
                model.setData(index, text, Qt.ItemDataRole.EditRole)
            # Otherwise, just close without saving (was for selection only)
        elif isinstance(editor, QTextEdit):
            text = editor.toPlainText()
            model.setData(index, text, Qt.ItemDataRole.EditRole)
        else:
            super().setModelData(editor, model, index)
    
    def updateEditorGeometry(self, editor, option, index):
        """Set the editor geometry to match the cell size"""
        if isinstance(editor, (QTextEdit, ReadOnlyGridTextEditor)):
            # Make the editor fill the cell properly with some padding
            rect = option.rect
            editor.setGeometry(rect)
        else:
            super().updateEditorGeometry(editor, option, index)


# ============================================================================
# THEME EDITOR DIALOG
# ============================================================================

class ThemeEditorDialog(QDialog):
    """Dialog for editing and managing themes"""
    
    def __init__(self, parent, theme_manager):
        super().__init__(parent)
        self.theme_manager = theme_manager
        self.setWindowTitle("Theme Editor")
        self.setModal(True)
        self.resize(700, 600)
        
        self.setup_ui()
        self.load_themes()
    
    def setup_ui(self):
        """Create the UI"""
        layout = QVBoxLayout(self)
        
        # Theme selection
        theme_group = QGroupBox("Select Theme")
        theme_layout = QHBoxLayout()
        
        self.theme_combo = QComboBox()
        self.theme_combo.currentTextChanged.connect(self.on_theme_selected)
        theme_layout.addWidget(QLabel("Theme:"))
        theme_layout.addWidget(self.theme_combo, 1)
        
        self.apply_btn = QPushButton("✓ Apply")
        self.apply_btn.clicked.connect(self.apply_theme)
        theme_layout.addWidget(self.apply_btn)
        
        theme_group.setLayout(theme_layout)
        layout.addWidget(theme_group)
        
        # Color customization
        colors_group = QGroupBox("Customize Colors")
        colors_layout = QGridLayout()
        
        # Create color pickers for main colors
        self.color_buttons = {}
        color_configs = [
            ("window_bg", "Window Background", 0, 0),
            ("base", "Input Fields", 0, 1),
            ("button", "Buttons", 0, 2),
            ("text", "Text", 1, 0),
            ("highlight", "Highlight", 1, 1),
            ("border", "Borders", 1, 2),
            ("grid_header", "Table Headers", 2, 0),
            ("alternate_bg", "Alternate Rows", 2, 1),
            ("tm_exact", "100% TM Match", 3, 0),
            ("tm_high", "95-99% TM Match", 3, 1),
        ]
        
        for attr, label, row, col in color_configs:
            lbl = QLabel(label + ":")
            btn = QPushButton()
            btn.setMinimumHeight(30)
            btn.setProperty("color_attr", attr)
            btn.clicked.connect(lambda checked, a=attr: self.pick_color(a))
            self.color_buttons[attr] = btn
            
            colors_layout.addWidget(lbl, row * 2, col)
            colors_layout.addWidget(btn, row * 2 + 1, col)
        
        colors_group.setLayout(colors_layout)
        layout.addWidget(colors_group)
        
        # Custom theme actions
        custom_group = QGroupBox("Custom Themes")
        custom_layout = QHBoxLayout()
        
        save_btn = QPushButton("💾 Save as Custom Theme")
        save_btn.clicked.connect(self.save_custom_theme)
        custom_layout.addWidget(save_btn)
        
        delete_btn = QPushButton("🗑️ Delete Custom Theme")
        delete_btn.clicked.connect(self.delete_custom_theme)
        custom_layout.addWidget(delete_btn)
        
        custom_group.setLayout(custom_layout)
        layout.addWidget(custom_group)
        
        # Dialog buttons
        button_box = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | 
            QDialogButtonBox.StandardButton.Cancel
        )
        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)
        layout.addWidget(button_box)
    
    def load_themes(self):
        """Load available themes into combo box"""
        self.theme_combo.clear()
        themes = self.theme_manager.get_all_themes()
        for theme_name in themes.keys():
            self.theme_combo.addItem(theme_name)
        
        # Select current theme
        current_idx = self.theme_combo.findText(self.theme_manager.current_theme.name)
        if current_idx >= 0:
            self.theme_combo.setCurrentIndex(current_idx)
    
    def on_theme_selected(self, theme_name):
        """When theme is selected from combo box"""
        if not theme_name:
            return
        
        theme = self.theme_manager.get_theme(theme_name)
        if theme:
            self.update_color_buttons(theme)
    
    def update_color_buttons(self, theme):
        """Update color button backgrounds"""
        for attr, btn in self.color_buttons.items():
            color = getattr(theme, attr, "#FFFFFF")
            btn.setStyleSheet(f"background-color: {color}; border: 1px solid #999;")
            btn.setText(color)
    
    def pick_color(self, attr):
        """Open color picker for an attribute"""
        from PyQt6.QtWidgets import QColorDialog
        
        theme_name = self.theme_combo.currentText()
        theme = self.theme_manager.get_theme(theme_name)
        
        if not theme:
            return
        
        current_color = QColor(getattr(theme, attr))
        color = QColorDialog.getColor(current_color, self, f"Pick {attr}")
        
        if color.isValid():
            # Update button
            hex_color = color.name()
            self.color_buttons[attr].setStyleSheet(
                f"background-color: {hex_color}; border: 1px solid #999;"
            )
            self.color_buttons[attr].setText(hex_color)
            
            # Update theme
            setattr(theme, attr, hex_color)
    
    def apply_theme(self):
        """Apply the selected theme"""
        theme_name = self.theme_combo.currentText()
        if self.theme_manager.set_theme(theme_name):
            self.theme_manager.apply_theme(QApplication.instance())

            # Call the main app's refresh method to update all UI elements
            if hasattr(self.parent(), 'refresh_theme_colors'):
                self.parent().refresh_theme_colors()

            QMessageBox.information(self, "Theme Applied",
                                   f"Theme '{theme_name}' has been applied.")
    
    def save_custom_theme(self):
        """Save current settings as a custom theme"""
        from PyQt6.QtWidgets import QInputDialog
        
        name, ok = QInputDialog.getText(self, "Save Theme", "Enter theme name:")
        if ok and name:
            # Create new theme from current settings
            theme_name = self.theme_combo.currentText()
            base_theme = self.theme_manager.get_theme(theme_name)
            
            if base_theme:
                # Create copy with new name
                from modules.theme_manager import Theme
                new_theme = Theme(
                    name=name,
                    **{k: v for k, v in base_theme.to_dict().items() if k != 'name'}
                )
                
                # Update with any modified colors
                for attr, btn in self.color_buttons.items():
                    color = btn.text()
                    if color.startswith('#'):
                        setattr(new_theme, attr, color)
                
                self.theme_manager.save_custom_theme(new_theme)
                self.load_themes()
                
                # Select the new theme
                idx = self.theme_combo.findText(name)
                if idx >= 0:
                    self.theme_combo.setCurrentIndex(idx)
                
                QMessageBox.information(self, "Theme Saved", 
                                       f"Custom theme '{name}' has been saved.")
    
    def delete_custom_theme(self):
        """Delete the selected custom theme"""
        theme_name = self.theme_combo.currentText()
        
        # Can't delete predefined themes
        from modules.theme_manager import ThemeManager
        if theme_name in ThemeManager.PREDEFINED_THEMES:
            QMessageBox.warning(self, "Cannot Delete", 
                               "Cannot delete predefined themes.")
            return
        
        reply = QMessageBox.question(self, "Delete Theme",
                                    f"Delete custom theme '{theme_name}'?",
                                    QMessageBox.StandardButton.Yes | 
                                    QMessageBox.StandardButton.No)
        
        if reply == QMessageBox.StandardButton.Yes:
            if self.theme_manager.delete_custom_theme(theme_name):
                self.load_themes()
                QMessageBox.information(self, "Theme Deleted", 
                                       f"Theme '{theme_name}' has been deleted.")


# ============================================================================
# DETACHED LOG WINDOW
# ============================================================================

class DetachedLogWindow(QWidget):
    """Detached log window that can be moved to another screen"""

    def __init__(self, parent):
        super().__init__()
        self.parent = parent
        self.setWindowTitle("Supervertaler Workbench - Session Log")
        self.setWindowIcon(self.parent.windowIcon())
        self.resize(800, 600)

        # Create layout
        layout = QVBoxLayout(self)
        layout.setContentsMargins(10, 10, 10, 10)

        # Top toolbar
        toolbar = QWidget()
        toolbar_layout = QHBoxLayout(toolbar)
        toolbar_layout.setContentsMargins(0, 0, 0, 0)

        # Info label
        info_label = QLabel("📋 This is a detached log window. It will update in real-time.")
        info_label.setStyleSheet("color: #666; font-style: italic;")
        toolbar_layout.addWidget(info_label)

        toolbar_layout.addStretch()

        # Re-attach button
        reattach_btn = QPushButton("↩️ Close")
        reattach_btn.setToolTip("Close this detached window")
        reattach_btn.clicked.connect(self.close)
        toolbar_layout.addWidget(reattach_btn)

        layout.addWidget(toolbar)

        # Log display
        self.log_display = QPlainTextEdit()
        self.log_display.setReadOnly(True)
        self.log_display.setStyleSheet("""
            QPlainTextEdit {
                font-family: 'Consolas', 'Courier New', monospace;
                font-size: 10px;
                border: 1px solid #ccc;
            }
        """)
        layout.addWidget(self.log_display)

        # Copy existing log content. Prefer the always-built session_log_text
        # buffer; fall back to the Settings ▸ Log widget if that path ran first.
        source_widget = None
        if hasattr(parent, 'session_log_text') and parent.session_log_text:
            source_widget = parent.session_log_text
        elif hasattr(parent, 'session_log') and parent.session_log:
            source_widget = parent.session_log
        if source_widget is not None:
            self.log_display.setPlainText(source_widget.toPlainText())
            # Scroll to bottom
            scrollbar = self.log_display.verticalScrollBar()
            if scrollbar:
                scrollbar.setValue(scrollbar.maximum())

    def closeEvent(self, event):
        """Handle window close"""
        # Remove from parent's list
        try:
            if self in self.parent.detached_log_windows:
                self.parent.detached_log_windows.remove(self)
        except:
            pass
        event.accept()


# ============================================================================
# TERM METADATA DIALOG (legacy alias)
# ============================================================================
#
# The historical ``TermMetadataDialog`` class has been merged into the unified
# :class:`TermbaseEntryEditor` (``modules/termbase_entry_editor.py``), which now
# handles both the add-term path (this alias) and the edit-term path used
# elsewhere in the app. The alias below keeps the old name working for any
# stale references, internal or external.
from modules.termbase_entry_editor import TermbaseEntryEditor as _TermbaseEntryEditor
TermMetadataDialog = _TermbaseEntryEditor


class AdvancedFiltersDialog(QDialog):
    """Dialog for advanced filtering options"""
    
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setup_ui()
    
    def setup_ui(self):
        self.setWindowTitle("Advanced Filters")
        self.setMinimumWidth(600)
        self.setMinimumHeight(500)
        layout = QVBoxLayout(self)
        
        # Create scroll area for filters
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        
        content = QWidget()
        content_layout = QVBoxLayout(content)
        content_layout.setSpacing(15)
        
        # Match Rate Filter
        match_group = QGroupBox("Match Rate (%)")
        match_layout = QHBoxLayout()
        
        self.match_rate_check = CheckmarkCheckBox("Enable")
        match_layout.addWidget(self.match_rate_check)
        
        match_layout.addWidget(QLabel("From:"))
        self.match_min_spin = QSpinBox()
        self.match_min_spin.setRange(0, 102)
        self.match_min_spin.setValue(0)
        self.match_min_spin.setSuffix("%")
        match_layout.addWidget(self.match_min_spin)
        
        match_layout.addWidget(QLabel("To:"))
        self.match_max_spin = QSpinBox()
        self.match_max_spin.setRange(0, 102)
        self.match_max_spin.setValue(102)
        self.match_max_spin.setSuffix("%")
        match_layout.addWidget(self.match_max_spin)
        match_layout.addStretch()
        
        match_group.setLayout(match_layout)
        content_layout.addWidget(match_group)
        
        # Row Status Filter
        status_group = QGroupBox("Row Status")
        status_layout = QVBoxLayout()
        
        self.status_not_started = CheckmarkCheckBox("Not started")
        self.status_edited = CheckmarkCheckBox("Edited")
        self.status_pretranslated = CheckmarkCheckBox("Pre-translated")
        self.status_translated = CheckmarkCheckBox("Translated")
        self.status_confirmed = CheckmarkCheckBox("Confirmed")
        self.status_draft = CheckmarkCheckBox("Draft")

        status_layout.addWidget(self.status_not_started)
        status_layout.addWidget(self.status_edited)
        status_layout.addWidget(self.status_pretranslated)
        status_layout.addWidget(self.status_translated)
        status_layout.addWidget(self.status_confirmed)
        status_layout.addWidget(self.status_draft)

        # Match Origin statuses
        match_origin_label = QLabel("Match Origin")
        match_origin_label.setStyleSheet("font-weight: bold; color: #666; margin-top: 8px; margin-bottom: 2px;")
        status_layout.addWidget(match_origin_label)

        self.status_pm = CheckmarkCheckBox("PM (102%)")
        self.status_cm = CheckmarkCheckBox("CM (101%)")
        self.status_tm_100 = CheckmarkCheckBox("TM 100%")
        self.status_tm_fuzzy = CheckmarkCheckBox("TM Fuzzy")
        self.status_repetition = CheckmarkCheckBox("Repetition")
        self.status_mt = CheckmarkCheckBox("MT")

        status_layout.addWidget(self.status_pm)
        status_layout.addWidget(self.status_cm)
        status_layout.addWidget(self.status_tm_100)
        status_layout.addWidget(self.status_tm_fuzzy)
        status_layout.addWidget(self.status_repetition)
        status_layout.addWidget(self.status_mt)

        status_group.setLayout(status_layout)
        content_layout.addWidget(status_group)
        
        # Locked/Unlocked Filter
        locked_group = QGroupBox("Locked Status")
        locked_layout = QVBoxLayout()
        
        self.locked_both = CheckmarkRadioButton("Both locked and unlocked rows")
        self.locked_only = CheckmarkRadioButton("Only locked rows")
        self.locked_unlocked_only = CheckmarkRadioButton("Only unlocked rows")
        self.locked_both.setChecked(True)
        
        locked_layout.addWidget(self.locked_both)
        locked_layout.addWidget(self.locked_only)
        locked_layout.addWidget(self.locked_unlocked_only)
        
        locked_group.setLayout(locked_layout)
        content_layout.addWidget(locked_group)
        
        # Other Properties
        other_group = QGroupBox("Other Properties")
        other_layout = QVBoxLayout()
        
        self.has_comments_check = CheckmarkCheckBox("Has comments/notes")
        self.has_proofreading_check = CheckmarkCheckBox("Has proofreading issues")
        self.repetitions_check = CheckmarkCheckBox("Repetitions only")
        self.auto_propagated_check = CheckmarkCheckBox("Auto-propagated")
        
        other_layout.addWidget(self.has_comments_check)
        other_layout.addWidget(self.has_proofreading_check)
        other_layout.addWidget(self.repetitions_check)
        other_layout.addWidget(self.auto_propagated_check)
        
        other_group.setLayout(other_layout)
        content_layout.addWidget(other_group)
        
        content_layout.addStretch()
        scroll.setWidget(content)
        layout.addWidget(scroll)
        
        # Buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        reset_btn = QPushButton("Reset All")
        reset_btn.clicked.connect(self.reset_filters)
        button_layout.addWidget(reset_btn)
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(self.reject)
        button_layout.addWidget(cancel_btn)
        
        apply_btn = QPushButton("Apply Filters")
        apply_btn.setStyleSheet("background-color: #2196F3; color: white; font-weight: bold; padding: 5px 15px; border: none; outline: none;")
        apply_btn.clicked.connect(self.accept)
        apply_btn.setDefault(True)
        button_layout.addWidget(apply_btn)
        
        layout.addLayout(button_layout)
    
    def reset_filters(self):
        """Reset all filters to default"""
        self.match_rate_check.setChecked(False)
        self.match_min_spin.setValue(0)
        self.match_max_spin.setValue(102)

        self.status_not_started.setChecked(False)
        self.status_edited.setChecked(False)
        self.status_pretranslated.setChecked(False)
        self.status_translated.setChecked(False)
        self.status_confirmed.setChecked(False)
        self.status_draft.setChecked(False)
        self.status_pm.setChecked(False)
        self.status_cm.setChecked(False)
        self.status_tm_100.setChecked(False)
        self.status_tm_fuzzy.setChecked(False)
        self.status_repetition.setChecked(False)
        self.status_mt.setChecked(False)
        
        self.locked_both.setChecked(True)
        
        self.has_comments_check.setChecked(False)
        self.has_proofreading_check.setChecked(False)
        self.repetitions_check.setChecked(False)
        self.auto_propagated_check.setChecked(False)
    
    def get_filters(self):
        """Return dictionary of filter settings"""
        filters = {}
        
        # Match rate
        filters['match_rate_enabled'] = self.match_rate_check.isChecked()
        filters['match_rate_min'] = self.match_min_spin.value()
        filters['match_rate_max'] = self.match_max_spin.value()
        
        # Row status
        row_status = []
        if self.status_not_started.isChecked():
            row_status.append('not_started')
        if self.status_edited.isChecked():
            row_status.append('edited')
        if self.status_pretranslated.isChecked():
            row_status.append('pretranslated')
        if self.status_translated.isChecked():
            row_status.append('draft')
        if self.status_confirmed.isChecked():
            row_status.append('confirmed')
        if self.status_draft.isChecked():
            row_status.append('draft')
        if self.status_pm.isChecked():
            row_status.append('pm')
        if self.status_cm.isChecked():
            row_status.append('cm')
        if self.status_tm_100.isChecked():
            row_status.append('tm_100')
        if self.status_tm_fuzzy.isChecked():
            row_status.append('tm_fuzzy')
        if self.status_repetition.isChecked():
            row_status.append('repetition')
        if self.status_mt.isChecked():
            row_status.append('machine_translated')
        filters['row_status'] = row_status
        
        # Locked filter
        if self.locked_only.isChecked():
            filters['locked_filter'] = 'locked'
        elif self.locked_unlocked_only.isChecked():
            filters['locked_filter'] = 'unlocked'
        else:
            filters['locked_filter'] = None
        
        # Other properties
        filters['has_comments'] = self.has_comments_check.isChecked()
        filters['has_proofreading'] = self.has_proofreading_check.isChecked()
        filters['repetitions_only'] = self.repetitions_check.isChecked()
        filters['auto_propagated'] = self.auto_propagated_check.isChecked()
        
        return filters


class ScratchpadDialog(QDialog):
    """Dialog for private translator notes (scratchpad).
    
    Notes are stored only in the .svproj file and never exported to CAT tools.
    """
    
    def __init__(self, parent=None, notes_text: str = ""):
        super().__init__(parent)
        self.notes_text = notes_text
        self.setup_ui()
    
    def setup_ui(self):
        self.setWindowTitle("📝 Scratchpad - Private Notes")
        self.setMinimumWidth(500)
        self.setMinimumHeight(400)
        self.resize(600, 450)
        
        layout = QVBoxLayout(self)
        layout.setSpacing(10)
        
        # Info label
        info_label = QLabel(
            "📝 <b>Private notes for this project</b><br>"
            "<small>These notes are saved with your project (.svproj) file and are <b>never</b> "
            "exported to CAT tools or shared with clients.</small>"
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; padding: 5px; background: #f9f9f9; border-radius: 4px;")
        layout.addWidget(info_label)
        
        # Notes text area
        self.notes_edit = QPlainTextEdit()
        self.notes_edit.setPlainText(self.notes_text)
        self.notes_edit.setPlaceholderText(
            "Use this scratchpad for your private notes during translation:\n\n"
            "• Terminology decisions\n"
            "• Client preferences\n"
            "• Research findings\n"
            "• Questions to ask\n"
            "• Reminders for yourself"
        )
        # Nice monospace-ish font for notes
        font = self.notes_edit.font()
        font.setFamily("Consolas, Courier New, monospace")
        font.setPointSize(10)
        self.notes_edit.setFont(font)
        layout.addWidget(self.notes_edit, 1)  # Stretch to fill
        
        # Button row
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(self.reject)
        button_layout.addWidget(cancel_btn)
        
        save_btn = QPushButton("💾 Save Notes")
        save_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 6px 16px; border: none;")
        save_btn.clicked.connect(self.accept)
        save_btn.setDefault(True)
        button_layout.addWidget(save_btn)
        
        layout.addLayout(button_layout)
    
    def get_notes(self) -> str:
        """Return the current notes text."""
        return self.notes_edit.toPlainText()


# ============================================================================
# BATCH TRANSLATION WORKER & PROGRESS DIALOG
# ============================================================================

class TMSearchWorker(QThread):
    """Background worker thread for TM fuzzy search.

    Runs expensive SequenceMatcher-based fuzzy matching off the main thread
    to prevent UI freezes, especially with large TMs (1M+ entries).
    Uses its own SQLite connection since connections can't be shared across threads.
    """

    # Signal: segment_id, list of match dicts
    results_ready = pyqtSignal(int, list)

    def __init__(self, db_path: str, source_text: str, segment_id: int,
                 tm_ids: list = None, source_lang: str = None, target_lang: str = None,
                 fuzzy_threshold: float = 0.75, max_matches: int = 10,
                 tm_metadata: dict = None):
        super().__init__()
        self.db_path = db_path
        self.source_text = source_text
        self.segment_id = segment_id
        self.tm_ids = tm_ids
        self.source_lang = source_lang
        self.target_lang = target_lang
        self.fuzzy_threshold = fuzzy_threshold
        self.max_matches = max_matches
        self.tm_metadata = tm_metadata or {}
        self._cancelled = False

    def cancel(self):
        self._cancelled = True

    def run(self):
        """Run TM search in background thread with thread-local SQLite connection."""
        import sqlite3
        try:
            conn = sqlite3.connect(self.db_path, timeout=15)
            conn.row_factory = sqlite3.Row
            conn.execute("PRAGMA journal_mode=WAL")

            from modules.database_manager import DatabaseManager

            # Create a lightweight DatabaseManager that uses our thread-local connection
            db = DatabaseManager.__new__(DatabaseManager)
            db.connection = conn
            db.cursor = conn.cursor()
            db.log = lambda msg: None  # Suppress logging in worker thread
            db.db_path = self.db_path

            if self._cancelled:
                conn.close()
                return

            # First try exact match (fast, hash-based)
            exact_match = db.get_exact_match(
                source=self.source_text,
                tm_ids=self.tm_ids,
                source_lang=self.source_lang,
                target_lang=self.target_lang
            )

            if exact_match:
                results = [{
                    'source': exact_match['source_text'],
                    'target': exact_match['target_text'],
                    'similarity': 1.0,
                    'match_pct': 100,
                    'tm_name': self.tm_metadata.get(exact_match['tm_id'], {}).get('name', exact_match['tm_id']),
                    'tm_id': exact_match['tm_id'],
                    # v1.10.51: preserve reverse-match flag through the worker
                    # thread so _on_tm_search_results can put it on the
                    # TranslationMatch's metadata for the renderer to read.
                    'reverse_match': exact_match.get('reverse_match', False),
                }]
                if not self._cancelled:
                    self.results_ready.emit(self.segment_id, results)
                conn.close()
                return

            if self._cancelled:
                conn.close()
                return

            # Fuzzy matches (expensive - SequenceMatcher on many candidates)
            fuzzy_matches = db.search_fuzzy_matches(
                source=self.source_text,
                tm_ids=self.tm_ids,
                threshold=self.fuzzy_threshold,
                max_results=self.max_matches,
                source_lang=self.source_lang,
                target_lang=self.target_lang
            )

            results = []
            for match in fuzzy_matches:
                results.append({
                    'source': match['source_text'],
                    'target': match['target_text'],
                    'similarity': match.get('similarity', 0.85),
                    'match_pct': match.get('match_pct', 85),
                    'tm_name': self.tm_metadata.get(match['tm_id'], {}).get('name', match['tm_id']),
                    'tm_id': match['tm_id'],
                    'reverse_match': match.get('reverse_match', False),
                })

            if not self._cancelled:
                self.results_ready.emit(self.segment_id, results)

            conn.close()

        except Exception as e:
            try:
                conn.close()
            except:
                pass
            if not self._cancelled:
                self.results_ready.emit(self.segment_id, [])


class PreTranslationWorker(QThread):
    """Background worker thread for batch translation."""
    
    # Signals
    progress_update = pyqtSignal(int, int, str, bool, float)  # current, total, message, success, elapsed_time
    translation_complete = pyqtSignal(int, int)  # success_count, error_count
    translation_error = pyqtSignal(str)  # error_message
    retry_needed = pyqtSignal(list)  # empty_segments list of (row_index, segment)
    
    def __init__(self, parent_app, segments, provider_type, provider_name, model, tm_exact_only=False, prompt_manager=None, retry_enabled=False, retry_pass=0, tm_ids=None, glossary_terms=None, base_url=None, custom_api_key=None, http_proxy=None):
        super().__init__()
        self.parent_app = parent_app
        self.segments = segments
        self.provider_type = provider_type
        self.provider_name = provider_name
        self.model = model
        self.base_url = base_url
        self.custom_api_key = custom_api_key  # Profile API key (takes priority over api_keys.txt)
        self.http_proxy = http_proxy
        self.tm_exact_only = tm_exact_only
        self.prompt_manager = prompt_manager
        self.retry_enabled = retry_enabled
        self.retry_pass = retry_pass
        self.max_retries = 5
        self._cancelled = False
        self.tm_ids = tm_ids  # Activated TM IDs for TM pre-translation
        self.glossary_terms = glossary_terms or []  # Pre-fetched from main thread (SQLite is not thread-safe)
        self.success_count = 0
        self.error_count = 0
    
    def run(self):
        """Main translation loop - runs in background thread."""
        import time
        import re
        
        # For TM provider, create thread-local database connection
        # SQLite connections can't be shared across threads
        self._thread_local_db = None
        if self.provider_type == 'TM' and hasattr(self.parent_app, 'tm_database') and self.parent_app.tm_database:
            try:
                import sqlite3
                db_path = self.parent_app.tm_database.db_path
                self._thread_local_db = sqlite3.connect(db_path, timeout=15)
                self._thread_local_db.row_factory = sqlite3.Row
                self._thread_local_db.execute("PRAGMA journal_mode=WAL")
                print(f"✓ Created thread-local database connection for TM pre-translation")
            except Exception as e:
                print(f"❌ Failed to create thread-local DB connection: {e}")
                self._thread_local_db = None
        
        try:
            # For TM and MT, process segments individually
            if self.provider_type in ['TM', 'MT']:
                for idx, (row_index, segment) in enumerate(self.segments):
                    if self._cancelled:
                        break
                    
                    try:
                        start_time = time.time()
                        
                        # Get translation
                        if self.provider_type == 'TM':
                            translation = self._translate_from_tm(segment)
                        else:  # MT
                            translation = self._translate_with_mt(segment)
                        
                        elapsed = time.time() - start_time
                        
                        if translation:
                            segment.target = translation
                            segment.status = "draft"
                            
                            preview = segment.source[:50] + ("..." if len(segment.source) > 50 else "")
                            message = f"[{idx+1}/{len(self.segments)}] ✓ {preview} ({elapsed:.1f}s)"
                            self.progress_update.emit(idx + 1, len(self.segments), message, True, elapsed)
                            self.success_count += 1
                        else:
                            preview = segment.source[:50] + ("..." if len(self.segments) > 50 else "")
                            message = f"[{idx+1}/{len(self.segments)}] ⊘ No match found: {preview}"
                            self.progress_update.emit(idx + 1, len(self.segments), message, False, elapsed)
                            self.error_count += 1
                    
                    except Exception as e:
                        preview = segment.source[:50] + ("..." if len(segment.source) > 50 else "")
                        message = f"[{idx+1}/{len(self.segments)}] ✗ ERROR: {preview} - {str(e)}"
                        self.progress_update.emit(idx + 1, len(self.segments), message, False, 0)
                        self.error_count += 1
        
            # For LLM, process in batches
            else:
                # Get batch size from settings
                general_prefs = self.parent_app.load_general_settings()
                batch_size = general_prefs.get('batch_size', 20)
                
                # Split segments into batches
                total_batches = (len(self.segments) + batch_size - 1) // batch_size
                
                for batch_num in range(total_batches):
                    if self._cancelled:
                        break
                    
                    start_idx = batch_num * batch_size
                    end_idx = min((batch_num + 1) * batch_size, len(self.segments))
                    batch_segments = self.segments[start_idx:end_idx]
                    
                    try:
                        start_time = time.time()
                    
                        # Translate entire batch
                        batch_translations = self._translate_batch_with_llm(batch_segments)
                    
                        elapsed = time.time() - start_time
                    
                        # Process results
                        for (row_index, segment), translation in zip(batch_segments, batch_translations):
                            absolute_idx = start_idx + batch_segments.index((row_index, segment))
                            
                            if translation:
                                segment.target = translation
                                segment.status = "draft"
                                
                                preview = segment.source[:50] + ("..." if len(segment.source) > 50 else "")
                                message = f"[{absolute_idx+1}/{len(self.segments)}] ✓ {preview}"
                                self.progress_update.emit(absolute_idx + 1, len(self.segments), message, True, elapsed / len(batch_segments))
                                self.success_count += 1
                            else:
                                preview = segment.source[:50] + ("..." if len(segment.source) > 50 else "")
                                message = f"[{absolute_idx+1}/{len(self.segments)}] ⊘ No translation: {preview}"
                                self.progress_update.emit(absolute_idx + 1, len(self.segments), message, False, elapsed / len(batch_segments))
                                self.error_count += 1
                
                    except Exception as e:
                        import traceback
                        error_details = traceback.format_exc()
                        print(f"❌ Batch processing error: {e}")
                        print(f"❌ Full traceback:\n{error_details}")
                        # Mark entire batch as failed
                        for row_index, segment in batch_segments:
                            absolute_idx = start_idx + batch_segments.index((row_index, segment))
                            preview = segment.source[:50] + ("..." if len(segment.source) > 50 else "")
                            message = f"[{absolute_idx+1}/{len(self.segments)}] ✗ BATCH ERROR: {str(e)}"
                            self.progress_update.emit(absolute_idx + 1, len(self.segments), message, False, 0)
                            self.error_count += 1
        
            # Check if retry is needed (for LLM mode with retry option enabled)
            if self.retry_enabled and self.provider_type == 'LLM' and self.retry_pass < self.max_retries:
                # Count segments that are still empty after this pass
                empty_segments_after = []
                for row_index, seg in self.segments:
                    if not seg.target or not seg.target.strip():
                        empty_segments_after.append((row_index, seg))
                
                if empty_segments_after:
                    # Emit retry_needed signal with list of empty segments
                    self.retry_needed.emit(empty_segments_after)
                    # Still emit completion for UI updates
                    self.translation_complete.emit(self.success_count, self.error_count)
                    return
        
            # Emit completion
            self.translation_complete.emit(self.success_count, self.error_count)
        
        finally:
            # Clean up thread-local database connection
            if self._thread_local_db:
                try:
                    self._thread_local_db.close()
                    print(f"✓ Closed thread-local database connection")
                except:
                    pass
    
    def cancel(self):
        """Request cancellation of translation job."""
        self._cancelled = True
    
    def _translate_from_tm(self, segment):
        """Translate a single segment using TM.
        
        Uses thread-local database connection to avoid SQLite threading errors.
        """
        try:
            print(f"🔍 TM PRE-TRANSLATE: Searching for: '{segment.source[:50]}...'")
            print(f"🔍 TM PRE-TRANSLATE: Using tm_ids: {self.tm_ids}")
            print(f"🔍 TM PRE-TRANSLATE: Exact only: {self.tm_exact_only}")
            
            # Use thread-local database connection
            if not self._thread_local_db:
                print(f"❌ TM PRE-TRANSLATE: No thread-local database connection!")
                return None
            
            # Create a thread-local DatabaseManager for TM operations
            # This avoids SQLite threading errors
            from modules.database_manager import DatabaseManager
            
            # Get db_path from parent_app's tm_database
            db_path = self.parent_app.tm_database.db_path
            
            # Create a thread-local DatabaseManager
            thread_local_tm = DatabaseManager(db_path=db_path, log_callback=print)
            thread_local_tm.connection = self._thread_local_db
            thread_local_tm.cursor = self._thread_local_db.cursor()
            
            if self.tm_exact_only:
                # Exact matches only - use hash lookup
                match = thread_local_tm.get_exact_match(segment.source, tm_ids=self.tm_ids)
                print(f"🔍 TM PRE-TRANSLATE: Exact match result: {match}")
                if match:
                    return match.get('target_text', '')
                return None
            else:
                # Fuzzy matching enabled - use activated TM IDs
                matches = thread_local_tm.search_all(
                    segment.source, 
                    tm_ids=self.tm_ids, 
                    enabled_only=False, 
                    max_matches=1
                )
                print(f"🔍 TM PRE-TRANSLATE: Fuzzy matches: {matches}")
                if matches and len(matches) > 0:
                    match = matches[0]
                    match_pct = match.get('match_pct', 0)
                    print(f"🔍 TM PRE-TRANSLATE: Best match pct: {match_pct}")
                    if match_pct >= 75:  # Accept matches 75% and above
                        return match.get('target', '')
                return None
        except Exception as e:
            print(f"TM translation error: {e}")
            import traceback
            traceback.print_exc()
            return None
    
    def _translate_with_mt(self, segment):
        """Translate a single segment using MT."""
        # This would call parent_app's MT methods
        # Implementation depends on which MT provider is selected
        try:
            # Get source/target languages
            source_lang = getattr(self.parent_app.current_project, 'source_lang', 'en')
            target_lang = getattr(self.parent_app.current_project, 'target_lang', 'nl')
            
            # Load API keys
            api_keys = self.parent_app.load_api_keys()
            
            # Call appropriate MT service
            if self.provider_name == 'Google Translate':
                return self.parent_app.call_google_translate(segment.source, source_lang, target_lang, api_keys.get('google_translate'))
            elif self.provider_name == 'DeepL':
                return self.parent_app.call_deepl(segment.source, source_lang, target_lang, api_keys.get('deepl'))
            # Add other MT providers as needed
            return None
        except Exception as e:
            print(f"MT translation error: {e}")
            return None
    
    def _translate_with_llm(self, segment):
        """Translate a single segment using LLM."""
        try:
            from modules.llm_clients import LLMClient

            # Get source/target languages
            source_lang = getattr(self.parent_app.current_project, 'source_lang', 'en')
            target_lang = getattr(self.parent_app.current_project, 'target_lang', 'nl')

            # Load API keys
            api_keys = self.parent_app.load_api_keys()
            api_key = api_keys.get(self.provider_name) or (api_keys.get('google') if self.provider_name == 'gemini' else None)
            if self.provider_name == 'custom_openai':
                # Profile API key takes priority over api_keys.txt
                if self.custom_api_key:
                    api_key = self.custom_api_key
                elif not api_key:
                    api_key = api_keys.get('custom_openai', '') or 'not-needed'

            # Build custom prompt from prompt library
            custom_prompt = None
            if self.prompt_manager:
                try:
                    # Use pre-fetched glossary terms (SQLite is not thread-safe)
                    full_prompt = self.prompt_manager.build_final_prompt(
                        source_text=segment.source,
                        source_lang=source_lang,
                        target_lang=target_lang,
                        mode="single",
                        glossary_terms=self.glossary_terms,
                        target_text=segment.target or ""
                    )
                    # Extract just the instruction part (without the source text section)
                    if "**SOURCE TEXT:**" in full_prompt:
                        custom_prompt = full_prompt.split("**SOURCE TEXT:**")[0].strip()
                    elif "Translate the following" in full_prompt:
                        custom_prompt = full_prompt.split("Translate the following")[0].strip()
                    else:
                        custom_prompt = full_prompt
                except Exception as e:
                    print(f"Warning: Could not build custom prompt: {e}")
                    custom_prompt = None

            # Create client
            client = LLMClient(
                api_key=api_key,
                provider=self.provider_name,
                model=self.model,
                base_url=self.base_url,
                http_proxy=self.http_proxy
            )

            # Translate with custom prompt
            result = client.translate(
                text=segment.source,
                source_lang=source_lang,
                target_lang=target_lang,
                custom_prompt=custom_prompt
            )
            
            return result
        except Exception as e:
            print(f"LLM translation error: {e}")
            return None
    
    def _translate_batch_with_llm(self, batch_segments):
        """Translate multiple segments in one LLM call (batch mode)."""
        try:
            from modules.llm_clients import LLMClient
            import re
            import traceback
            
            print(f"🚀 _translate_batch_with_llm: Starting batch of {len(batch_segments)} segments")

            # Get source/target languages
            source_lang = getattr(self.parent_app.current_project, 'source_lang', 'en')
            target_lang = getattr(self.parent_app.current_project, 'target_lang', 'nl')
            print(f"🚀 Languages: {source_lang} → {target_lang}")

            # Load API keys
            api_keys = self.parent_app.load_api_keys()
            api_key = api_keys.get(self.provider_name) or (api_keys.get('google') if self.provider_name == 'gemini' else None)
            if self.provider_name == 'custom_openai':
                # Profile API key takes priority over api_keys.txt
                if self.custom_api_key:
                    api_key = self.custom_api_key
                elif not api_key:
                    api_key = api_keys.get('custom_openai', '') or 'not-needed'

            # Build batch prompt – split into a static cacheable system prompt
            # (base instructions, glossary, project context) and a per-batch
            # user prompt (segment list). Anthropic prompt caching marks the
            # system prompt as ephemeral so batches 2..N hit the 0.1× cache-read
            # rate instead of the full input rate; OpenRouter→Anthropic gets
            # the same treatment via OpenAI's content-array form. Other
            # providers ignore the marker.

            # Get base prompt from prompt library – becomes the system prompt
            # so that it's byte-stable across batches in this run and cacheable.
            base_prompt = None
            if self.prompt_manager and batch_segments:
                try:
                    first_segment = batch_segments[0][1]
                    # Use pre-fetched glossary terms (SQLite is not thread-safe)
                    full_prompt = self.prompt_manager.build_final_prompt(
                        source_text=first_segment.source,
                        source_lang=source_lang,
                        target_lang=target_lang,
                        mode="single",
                        glossary_terms=self.glossary_terms,
                        target_text=first_segment.target or ""
                    )
                    # Extract just the instruction part
                    if "**SOURCE TEXT:**" in full_prompt:
                        base_prompt = full_prompt.split("**SOURCE TEXT:**")[0].strip()
                    elif "Translate the following" in full_prompt:
                        base_prompt = full_prompt.split("Translate the following")[0].strip()
                    else:
                        base_prompt = full_prompt
                except Exception:
                    base_prompt = None

            # User-message portion: critical instructions + the actual segments.
            # This is per-batch and intentionally NOT cached.
            batch_prompt_parts = []
            if not base_prompt:
                # Fallback when no prompt manager is available – include the
                # minimal language pair instruction in the user prompt itself.
                batch_prompt_parts.append(f"Translate the following text segments from {source_lang} to {target_lang}.")

            batch_prompt_parts.append(f"**SEGMENTS TO TRANSLATE ({len(batch_segments)} segments):**")
            batch_prompt_parts.append("\n⚠️ CRITICAL INSTRUCTIONS:")
            batch_prompt_parts.append(f"1. You must provide EXACTLY one translation per segment")
            batch_prompt_parts.append(f"2. You MUST translate ALL {len(batch_segments)} segments")
            batch_prompt_parts.append("3. Format: Each translation MUST start with its segment number, a period, then the translation")
            batch_prompt_parts.append("4. Line breaks: If the source segment contains line breaks, preserve them in your translation.")
            batch_prompt_parts.append("   The number label (e.g. '40.') appears only ONCE at the start; continuation lines have no number.")
            batch_prompt_parts.append("5. NO explanations, NO commentary, ONLY the numbered translations\n")

            batch_prompt_parts.append("**SEGMENTS TO TRANSLATE:**\n")

            # Add all segments
            for row_index, seg in batch_segments:
                batch_prompt_parts.append(f"{seg.id}. {seg.source}")

            batch_prompt_parts.append("\n**YOUR TRANSLATIONS (numbered list):**")
            batch_prompt_parts.append("Begin your translations now:")

            batch_prompt = "\n".join(batch_prompt_parts)

            # Create client
            client = LLMClient(
                api_key=api_key,
                provider=self.provider_name,
                model=self.model,
                base_url=self.base_url,
                http_proxy=self.http_proxy
            )

            # Call LLM. enable_prompt_caching: the system prompt
            # (base_prompt) is byte-stable across every batch in this run, so
            # caching pays off from batch 2 onwards. Anthropic native +
            # OpenRouter→Anthropic add explicit cache_control markers;
            # OpenAI / DeepSeek / Gemini 2.5+ benefit from automatic implicit
            # caching at the provider layer; other providers ignore the flag.
            result = client.translate(
                text=batch_prompt,
                source_lang=source_lang,
                target_lang=target_lang,
                # custom_prompt=batch_prompt prevents translate() from wrapping
                # the user message with its own "Translate the following…" prefix.
                custom_prompt=batch_prompt,
                system_prompt=base_prompt,
                enable_prompt_caching=True,
            )
            
            # Parse the numbered response
            translations = []
            if result:
                lines = result.split('\n')
                translation_map = {}
                current_id = None

                for line in lines:
                    # Match "123. Translation text"
                    match = re.match(r'^(\d+)\.\s*(.*)', line)
                    if match:
                        current_id = int(match.group(1))
                        translation = match.group(2)
                        translation_map[current_id] = translation
                    elif current_id is not None:
                        # Continuation line for a multi-line translation
                        translation_map[current_id] += '\n' + line
                
                # Extract translations in order
                for row_index, seg in batch_segments:
                    translation = translation_map.get(seg.id, None)
                    translations.append(translation)
            else:
                # No result - all segments failed
                translations = [None] * len(batch_segments)
            
            return translations
            
        except Exception as e:
            import traceback
            error_details = traceback.format_exc()
            print(f"❌ Batch LLM translation error: {e}")
            print(f"❌ Full traceback:\n{error_details}")
            # Return list of None for all segments
            return [None] * len(batch_segments)


class ProofreadWorker(QThread):
    """Background worker thread for proofreading translations."""

    # Signals
    progress_update = pyqtSignal(int, str)  # checked_count, status_message
    stats_update = pyqtSignal(int, int, int)  # checked, issues, ok
    batch_error = pyqtSignal(int, int, str)  # batch_start, batch_end, error_message
    segment_issue = pyqtSignal(int, str, str)  # row_idx, issue_text, model_name
    finished_proofreading = pyqtSignal(int, int, int)  # checked, issues, ok

    def __init__(self, segments_to_check, provider, model, custom_prompt, api_keys, source_lang, target_lang, base_url=None, custom_api_key=None, http_proxy=None):
        super().__init__()
        self.segments_to_check = segments_to_check
        self.provider = provider
        self.model = model
        self.custom_prompt = custom_prompt
        self.api_keys = api_keys
        self.source_lang = source_lang
        self.target_lang = target_lang
        self.base_url = base_url
        self.custom_api_key = custom_api_key
        self.http_proxy = http_proxy
        self._cancelled = False

    def cancel(self):
        self._cancelled = True

    def run(self):
        """Main proofreading loop - runs in background thread."""
        import re

        # Build prompt if not custom
        if not self.custom_prompt:
            lang_specific = ""
            if self.target_lang.lower() in ['dutch', 'nl', 'nl-nl', 'nl-be', 'nederlands']:
                lang_specific = """
5. Dutch Compound Words – Verify correct spelling of compound words (e.g., "persoonsgegevens" NOT "persoongegevens", "bedrijfsnaam" NOT "bedrijfnaam"). Pay special attention to connecting letters like 's', 'e', 'en'.
6. Dutch Spelling – Check for common Dutch spelling errors including de/het articles, dt-errors, and capitalization."""
            elif self.target_lang.lower() in ['german', 'de', 'de-de', 'deutsch']:
                lang_specific = """
5. German Compound Words – Verify correct compound noun formation and capitalization of all nouns.
6. German Cases – Check correct use of cases (Nominativ, Akkusativ, Dativ, Genitiv)."""
            elif self.target_lang.lower() in ['french', 'fr', 'fr-fr', 'français']:
                lang_specific = """
5. French Accents – Verify correct use of accents (é, è, ê, à, ù, ô, etc.).
6. French Agreement – Check gender/number agreement between nouns, adjectives, and articles."""

            self.custom_prompt = f"""You are a translation proofreader. Your task is to analyze translations for errors.

DO NOT translate anything. DO NOT provide corrected translations unless specifically requested.
ONLY identify errors using the exact format specified below.

Task: Proofread this translation from {self.source_lang} to {self.target_lang}.

For each segment, verify:
1. Accuracy – Does the translation correctly convey the source meaning?
2. Completeness – Is anything missing or added?
3. Terminology – Are technical terms translated correctly and consistently?
4. Grammar & Style – Is the text natural and error-free?{lang_specific}

CRITICAL OUTPUT FORMAT (FOLLOW EXACTLY):
- If segment is OK: [SEGMENT XXXX] ✓
- If segment has issues: [SEGMENT XXXX] ⚠
  Issue: <brief description>
  Suggestion: <recommended fix>

OUTPUT ONLY THE SEGMENT MARKERS. DO NOT ADD EXPLANATIONS BEFORE OR AFTER."""

        # Initialize LLM client
        try:
            from modules.llm_clients import LLMClient

            if self.provider == 'openai':
                api_key = self.api_keys.get('openai', '')
            elif self.provider == 'claude':
                api_key = self.api_keys.get('claude', '')
            elif self.provider == 'gemini':
                api_key = self.api_keys.get('gemini', '')
            elif self.provider == 'mistral':
                api_key = self.api_keys.get('mistral', '')
            elif self.provider == 'deepseek':
                api_key = self.api_keys.get('deepseek', '')
            elif self.provider == 'ollama':
                api_key = self.api_keys.get('ollama', '') or 'not-needed'
            elif self.provider == 'custom_openai':
                api_key = self.custom_api_key or self.api_keys.get('custom_openai', '') or 'not-needed'
            else:
                api_key = ''

            llm_client = LLMClient(api_key=api_key, provider=self.provider, model=self.model, base_url=self.base_url, http_proxy=self.http_proxy)
        except Exception as e:
            self.batch_error.emit(0, 0, f"Failed to initialize LLM client:\n{str(e)}")
            self.finished_proofreading.emit(0, 0, 0)
            return

        # Process in batches of 20 segments
        batch_size = 20
        checked_count = 0
        issues_count = 0
        ok_count = 0

        for batch_start in range(0, len(self.segments_to_check), batch_size):
            if self._cancelled:
                break

            batch_end = min(batch_start + batch_size, len(self.segments_to_check))
            batch = self.segments_to_check[batch_start:batch_end]

            # Format batch for API
            batch_text = ""
            for row_idx, segment in batch:
                segment_num = f"{row_idx + 1:04d}"
                batch_text += f"[SEGMENT {segment_num}]\n{self.source_lang}: {segment.source}\n{self.target_lang}: {segment.target}\n\n"

            self.progress_update.emit(checked_count, f"Proofreading segments {batch_start + 1}-{batch_end}...")

            # Send to LLM
            try:
                full_prompt = f"{self.custom_prompt}\n\n{batch_text}"

                response = llm_client.translate(
                    text="",
                    source_lang=self.source_lang,
                    target_lang=self.target_lang,
                    custom_prompt=full_prompt
                )

                if self._cancelled:
                    break

                # Parse response
                for row_idx, segment in batch:
                    if self._cancelled:
                        break

                    segment_num = f"{row_idx + 1:04d}"

                    pattern_ok = f"\\[SEGMENT {segment_num}\\]\\s*✓"
                    pattern_issue = f"\\[SEGMENT {segment_num}\\]\\s*⚠"

                    if re.search(pattern_ok, response, re.IGNORECASE):
                        ok_count += 1
                    elif re.search(pattern_issue, response, re.IGNORECASE):
                        issue_pattern = f"\\[SEGMENT {segment_num}\\]\\s*⚠\\s*\\n(.+?)(?=\\[SEGMENT|$)"
                        issue_match = re.search(issue_pattern, response, re.DOTALL)

                        if issue_match:
                            issue_text = issue_match.group(1).strip()
                            self.segment_issue.emit(row_idx, issue_text, self.model)
                            issues_count += 1

                    checked_count += 1
                    self.stats_update.emit(checked_count, issues_count, ok_count)

            except Exception as e:
                self.batch_error.emit(batch_start, batch_end, str(e))

        self.finished_proofreading.emit(checked_count, issues_count, ok_count)


class LiveProgressDialog(QDialog):
    """Progress dialog with live console output."""
    
    def __init__(self, parent, total_segments, provider_info):
        super().__init__(parent)
        self.total_segments = total_segments
        self.provider_info = provider_info
        self.start_time = time.time()
        self.segment_times = []  # Track processing times for estimation
        
        self.setWindowTitle("Batch Translation Progress")
        self.setMinimumSize(800, 600)
        
        # Main layout
        layout = QVBoxLayout(self)
        
        # Header
        header_label = QLabel(f"<h3>🚀 Translating {total_segments} segment{'s' if total_segments != 1 else ''}</h3>")
        layout.addWidget(header_label)
        
        # Provider info
        provider_label = QLabel(provider_info)
        provider_label.setStyleSheet("color: #666; padding: 5px 0;")
        layout.addWidget(provider_label)
        
        # Progress info section
        info_layout = QHBoxLayout()
        self.progress_label = QLabel("0/0 (0%)")
        self.time_label = QLabel("Elapsed: 0:00 | Remaining: --:--")
        self.speed_label = QLabel("Speed: -- seg/min")
        info_layout.addWidget(self.progress_label)
        info_layout.addStretch()
        info_layout.addWidget(self.time_label)
        info_layout.addWidget(self.speed_label)
        layout.addLayout(info_layout)
        
        # Progress bar
        self.progress_bar = QProgressBar()
        self.progress_bar.setMaximum(total_segments)
        layout.addWidget(self.progress_bar)
        
        # Console output
        console_label = QLabel("<b>Console Output:</b>")
        layout.addWidget(console_label)
        
        self.console = QTextEdit()
        self.console.setReadOnly(True)
        self.console.setFont(QFont("Consolas", 9))
        self.console.setStyleSheet("""
            QTextEdit {
                background-color: #1e1e1e;
                color: #d4d4d4;
                border: 1px solid #3c3c3c;
            }
        """)
        layout.addWidget(self.console)
        
        # Statistics
        self.stats_label = QLabel("✓ Success: 0  |  ✗ Errors: 0")
        self.stats_label.setStyleSheet("padding: 5px 0; font-weight: bold;")
        layout.addWidget(self.stats_label)
        
        # Button section
        button_layout = QHBoxLayout()
        self.cancel_btn = QPushButton("Cancel")
        self.cancel_btn.clicked.connect(self.reject)
        button_layout.addStretch()
        button_layout.addWidget(self.cancel_btn)
        layout.addLayout(button_layout)
        
        # Timer for elapsed time updates
        self.timer = QTimer()
        self.timer.timeout.connect(self._update_time_display)
        self.timer.start(1000)  # Update every second
        
        # Initial console message
        self.add_console_line(f"Starting batch translation: {total_segments} segments", True)
        self.add_console_line(provider_info, True)
        self.add_console_line("-" * 80, True)
    
    def add_console_line(self, message, is_success=True):
        """Add a line to the console with color coding."""
        cursor = self.console.textCursor()
        cursor.movePosition(QTextCursor.MoveOperation.End)
        
        # Set text color
        fmt = cursor.charFormat()
        if is_success:
            fmt.setForeground(QColor("#4ec9b0"))  # Greenish (VS Code style)
        else:
            fmt.setForeground(QColor("#f48771"))  # Reddish (VS Code style)
        
        cursor.setCharFormat(fmt)
        cursor.insertText(message + "\n")
        
        # Auto-scroll to bottom
        self.console.setTextCursor(cursor)
        self.console.ensureCursorVisible()
    
    def update_progress(self, current, total, elapsed_seconds, success_count, error_count):
        """Update progress bar and labels."""
        self.progress_bar.setValue(current)
        
        # Update progress label
        percent = int((current / total) * 100) if total > 0 else 0
        self.progress_label.setText(f"{current}/{total} ({percent}%)")
        
        # Update statistics
        self.stats_label.setText(f"✓ Success: {success_count}  |  ✗ Errors: {error_count}")
        
        # Track timing
        if elapsed_seconds > 0:
            self.segment_times.append(elapsed_seconds)
        
        # Calculate speed and estimate remaining time
        if len(self.segment_times) > 0:
            avg_time = sum(self.segment_times) / len(self.segment_times)
            speed = 60 / avg_time if avg_time > 0 else 0
            remaining_segments = total - current
            remaining_seconds = remaining_segments * avg_time
            
            # Update labels
            self.speed_label.setText(f"Speed: {speed:.1f} seg/min")
            
            remaining_str = self._format_time(remaining_seconds)
            elapsed_str = self._format_time(time.time() - self.start_time)
            self.time_label.setText(f"Elapsed: {elapsed_str} | Remaining: {remaining_str}")
    
    def _update_time_display(self):
        """Update elapsed time display (called every second)."""
        if self.progress_bar.value() < self.total_segments:
            elapsed = time.time() - self.start_time
            current_text = self.time_label.text()
            # Update only elapsed portion
            if " | " in current_text:
                remaining_part = current_text.split(" | ")[1]
                self.time_label.setText(f"Elapsed: {self._format_time(elapsed)} | {remaining_part}")
    
    @staticmethod
    def _format_time(seconds):
        """Format seconds as MM:SS."""
        minutes = int(seconds // 60)
        secs = int(seconds % 60)
        return f"{minutes}:{secs:02d}"
    
    def show_completion_message(self, success_count, error_count):
        """Show completion summary in console."""
        self.add_console_line("", True)  # Blank line
        self.add_console_line("=" * 80, True)
        self.add_console_line(f"Translation Complete!", True)
        self.add_console_line(f"✓ Success: {success_count}", True)
        if error_count > 0:
            self.add_console_line(f"✗ Errors: {error_count}", False)
        total_time = time.time() - self.start_time
        self.add_console_line(f"Total time: {self._format_time(total_time)}", True)
        self.add_console_line("=" * 80, True)
        
        # Change button to "Close"
        self.cancel_btn.setText("Close")


# ============================================================================
# MAIN WINDOW
# ============================================================================

class SupervertalerQt(QMainWindow):
    """Main application window"""
    
    MAX_RECENT_PROJECTS = 10  # Maximum number of recent projects to track
    
    # Signal for thread-safe logging (background threads emit, main thread handles)
    _log_signal = pyqtSignal(str)
    
    # Signal for proactive highlighting - prefetch worker emits, main thread applies highlighting
    # Args: segment_id (int), termbase_matches (dict as JSON string for thread safety)
    _proactive_highlight_signal = pyqtSignal(int, str)
    
    def __init__(self):
        super().__init__()
        
        # Connect thread-safe log signal (must be done first for logging to work from threads)
        self._log_signal.connect(self._log_to_ui)
        
        # Connect proactive highlighting signal (prefetch worker emits, main thread highlights)
        self._proactive_highlight_signal.connect(self._apply_proactive_highlighting)
        
        # Application state
        self.current_project: Optional[Project] = None
        self.project_file_path: Optional[str] = None
        self.project_modified = False
        
        # memoQ bilingual DOCX import tracking
        self.memoq_source_file = None
        
        # UI Configuration
        self.default_font_family = "Calibri"
        self.default_font_size = 11
        
        # Application settings
        self.allow_replace_in_source = False  # Safety: don't allow replace in source by default
        self.auto_propagate_exact_matches = True  # Auto-fill 100% TM matches for empty segments
        self.auto_insert_100_percent_matches = True  # Auto-insert 100% TM matches when segment selected
        self.auto_confirm_100_percent_matches = False  # Auto-confirm 100% matches when navigating with Ctrl+Enter
        self.auto_confirm_overwrite_existing = False  # Allow auto-confirm to overwrite existing target content
        self.tm_save_mode = 'latest'  # 'all' = keep all translations with timestamps, 'latest' = only keep most recent (DEFAULT)
        
        # Tab position setting
        self.tabs_above_grid = False  # Whether to show TermLens/Session Log tabs above grid
        
        # Right panel visibility settings
        self.show_translation_results_pane = False  # Show Translation Results tab (hidden by default for new users)
        # Note: Match Panel is always visible (no toggle needed)
        
        # TM and Termbase matching toggle (default: enabled)
        self.enable_tm_matching = True
        self.enable_termbase_matching = True
        self.enable_mt_matching = True  # Machine Translation enabled
        self.enable_llm_matching = False  # LLM Translation enabled (DISABLED by default - too slow for navigation!)
        self.enable_termbase_grid_highlighting = True  # Highlight termbase matches in source cells

        # Termbase display settings
        self.termbase_hide_shorter_matches = False  # Hide shorter terms included in longer ones

        # Invisible character display settings
        self.showing_invisible_spaces = False  # Track whether spaces are shown as middle dots
        self.invisible_char_color = '#999999'  # Light gray color for invisible characters
        self.invisible_display_settings = {
            'spaces': False,
            'tabs': True,
            'nbsp': True,
            'linebreaks': True
        }

        # Grid row color settings (memoQ-style alternating row colors)
        self.enable_alternating_row_colors = True  # Enable alternating row colors by default
        self.even_row_color = '#FFFFFF'  # White for even rows
        self.odd_row_color = '#F0F0F0'  # Light gray for odd rows

        # Hide outer wrapping tags in grid display (e.g. <li-o>...</li-o>)
        self.hide_outer_wrapping_tags = False  # Disabled by default

        # Termbase highlight style settings
        self.termbase_highlight_style = 'semibold'  # 'background', 'dotted', or 'semibold'
        self.termbase_dotted_color = '#808080'  # Medium gray for dotted underline (more visible)
        
        # Focus border settings for target cells
        self.focus_border_color = '#f1b79a'  # Peach/salmon
        self.focus_border_thickness = 2  # 2px
        
        # Sound effects settings
        self.enable_sound_effects = False  # Sound effects disabled by default

        # Debug mode settings (for troubleshooting performance issues)
        self.debug_mode_enabled = False  # Enables verbose debug logging
        self.debug_auto_export = False  # Auto-export debug logs to file
        self.debug_log_buffer = []  # Buffer for debug logs (for export)
        
        # Precision scroll settings (for fine-tuned grid navigation)
        self.precision_scroll_divisor = 3  # Divide row height by this (higher = finer increments)
        self.auto_center_active_segment = True  # Auto-scroll to keep active segment centered (default ON)
        
        # Translation service availability flags (would be set from config/API keys)
        self.google_translate_enabled = True  # For demo purposes
        self.deepl_enabled = False  # Not implemented yet
        self.openai_enabled = True  # For demo purposes
        self.claude_enabled = True  # For demo purposes
        
        # Timer for delayed lookup (cancel if user moves to another segment)
        self.lookup_timer = None
        self.current_lookup_segment_id = None
        
        # Termbase cache for performance optimization
        # Maps segment ID → {term: translation} dictionary
        self.termbase_cache = {}
        self.termbase_cache_lock = threading.Lock()  # Thread-safe cache access
        self.termbase_batch_worker_thread = None  # Background worker thread
        self.termbase_batch_stop_event = threading.Event()  # Signal to stop background worker

        # In-memory termbase index for instant lookups (v1.9.182)
        # Loaded once on project load, contains ALL terms from activated termbases
        # Structure: list of term dicts with pre-compiled regex patterns
        self.termbase_index = []
        self.termbase_index_lock = threading.Lock()
        
        # TM/MT/LLM prefetch cache for instant segment switching (like memoQ)
        # Maps segment ID → {"TM": [...], "MT": [...], "LLM": [...]}
        self.translation_matches_cache = {}
        self.translation_matches_cache_lock = threading.Lock()
        self.prefetch_worker_thread = None
        self.prefetch_stop_event = threading.Event()
        self.prefetch_queue = []  # List of segment IDs to prefetch
        
        # Idle prefetch: prefetch next segments while user is thinking/typing
        self.idle_prefetch_timer = None  # QTimer for triggering prefetch after typing pause
        self.idle_prefetch_delay_ms = 1500  # Start prefetch 1.5s after user stops typing
        
        # Cache kill switch for performance testing
        # When True, all caches are bypassed - direct lookups every time
        self.disable_all_caches = False  # v1.9.183: Default to False (caches ENABLED)
        
        # Undo/Redo stack for grid edits
        self.undo_stack = []  # List of (segment_id, old_target, new_target, old_status, new_status)
        self.redo_stack = []  # List of undone actions that can be redone
        self.max_undo_levels = 100  # Maximum number of undo levels to keep
        
        # Global language settings (defaults)
        self.source_language = "English"
        self.target_language = "Dutch"

        # Voice model download tracking
        self.is_loading_model = False
        self.loading_model_name = None

        # Voice push-to-talk via a single low-level keyboard listener
        # (replaces the old Windows RegisterHotKey path so we get release
        # events for hold-to-talk). Created lazily on first use; see
        # _get_voice_hotkey_listener. The 200 ms debounce timestamp guards
        # against the in-editor QShortcut and the global listener both
        # calling start_voice_dictation on the same press.
        self._voice_hotkey_listener = None
        self._voice_dictate_last_press_ms = 0
        
        # Target editor signal suppression (prevents load-time churn)
        self._suppress_target_change_handlers = False
        self.warning_banners: Dict[str, QWidget] = {}
        
        # Superlookup detached window
        self.lookup_detached_window = None
        
        # ============================================================================
        # USER DATA PATH INITIALIZATION
        # ============================================================================
        # ALL users (EXE, pip, dev) now use a unified system:
        # - Default location: ~/Supervertaler/ (visible in home folder)
        # - User can choose custom location on first run
        # - Config pointer stores their choice at standard config location
        # - Auto-recovery if pointer is deleted but data exists at default location
        from modules.database_manager import DatabaseManager
        
        # Check if this is first run BEFORE getting the path
        # (so we know whether to show the data location dialog later)
        self._needs_data_location_dialog = needs_first_run_data_dialog()

        # Resolve the user data path via the unified pointer system for ALL
        # users (dev, pip, EXE). The dev branch used to hardcode this to
        # ~/Supervertaler and skip the pointer entirely, which made
        # Settings -> Change... silently revert on restart in dev mode
        # (the change saved to the pointer but startup never read it).
        # The unified path also auto-recovers if the default location has
        # content but no pointer exists, so a fresh dev install still does
        # the right thing without a special case.
        self.user_data_path = get_user_data_path()
        
        # Ensure user_data directory exists (creates empty folder if missing)
        # BUT only if we're not going to show the dialog (which will create the chosen folder)
        if not self._needs_data_location_dialog:
            self.user_data_path.mkdir(parents=True, exist_ok=True)
        
        print(f"[Data Paths] User data: {self.user_data_path}")

        # Migrate old settings files to unified settings/settings.json (one-time)
        if not self._needs_data_location_dialog:
            self._migrate_settings_to_unified()
            self._migrate_to_workbench_layout()

        # Load the saved source/target language pair NOW, before any UI is
        # built. The Language Pair combo boxes read self.source_language /
        # self.target_language when they're created; if the load happens
        # later, the combos show the hard-coded defaults and the next
        # dropdown change will save the stale UI values back to disk.
        # Spellcheck initialization stays in load_language_settings(),
        # which runs later after spellcheck_manager and log are ready.
        if not self._needs_data_location_dialog:
            self._load_language_pair_from_disk()

        # Database Manager for Termbases
        self.db_manager = DatabaseManager(
            db_path=str(self.user_data_path / "resources" / "supervertaler.db"),
            log_callback=self.log
        )
        # Only connect if we're not showing the dialog (which will create the folder)
        # If dialog is needed, we'll connect after user chooses location
        if not self._needs_data_location_dialog:
            self.db_manager.connect()
        
        # TM Database - Initialize early so Superlookup works without a project loaded
        from modules.translation_memory import TMDatabase
        self.tm_database = TMDatabase(
            source_lang=None,  # Will be set when project is loaded
            target_lang=None,  # Will be set when project is loaded
            db_path=str(self.user_data_path / "resources" / "supervertaler.db"),
            log_callback=self.log
        )
        
        # TM Metadata Manager - needed for TM list in Superlookup
        from modules.tm_metadata_manager import TMMetadataManager
        self.tm_metadata_mgr = TMMetadataManager(self.db_manager, self.log)

        # External TM live-sync timer. Attached Trados .sdltm files (and any
        # future external TM sources) are kept in step with their source by
        # checking the file mtime every few seconds and pulling any new or
        # modified TUs via incremental delta sync. The mtime check itself
        # is essentially free; the SQL work only runs on actual changes.
        from PyQt6.QtCore import QTimer
        self._external_tm_sync_timer = QTimer(self)
        self._external_tm_sync_timer.setInterval(5000)  # 5s — feels live without being spammy
        self._external_tm_sync_timer.timeout.connect(self._sync_external_tms)
        self._external_tm_sync_timer.start()

        # Termbase Manager - needed for glossary AI injection
        from modules.termbase_manager import TermbaseManager
        self.termbase_mgr = TermbaseManager(self.db_manager, self.log)

        # v1.10.29 one-shot reset: v1.10.28 introduced the
        # ``voice_dictation_enabled`` column with DEFAULT 1, so users
        # upgrading from v1.10.28 (or fresh installs that picked up
        # the v1.10.28 migration before today) ended up with every
        # termbase contributing to dictation bias by default. A user
        # with dozens of termbases flagged that as wrong – defaults
        # should be opt-in. v1.10.29 flips the DB DEFAULT to 0 for
        # new rows (database_manager.py) and one-shot resets all
        # existing rows here. Sentinel in settings JSON prevents the
        # reset from running twice or stomping on a user's own
        # post-v1.10.29 selections.
        self._migrate_voice_dictation_default_off()

        # Spellcheck Manager for target language spell checking
        self.spellcheck_manager = get_spellcheck_manager(str(self.user_data_path))
        # Note: spellcheck_enabled will be loaded from preferences later in _load_spellcheck_settings()
        # For now set to False, it gets updated when UI is created
        self.spellcheck_enabled = False
        # Set up the shared spellcheck manager for TagHighlighter instances
        TagHighlighter.set_spellcheck_manager(self.spellcheck_manager)
        TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)
        
        # Find & Replace History Manager
        self.fr_history = FindReplaceHistory(str(self.user_data_path))
        
        # Shortcut Manager for keyboard shortcuts (including enable/disable)
        self.shortcut_manager = ShortcutManager(Path(self.user_data_path) / "workbench" / "settings" / "shortcuts.json")
        
        # Voice Command Manager for Talon-style voice commands
        self.voice_command_manager = VoiceCommandManager(self.user_data_path, main_window=self)
        
        # Continuous Voice Listener (always-on mode) - initialized on demand
        self.voice_listener = None  # Will be ContinuousVoiceListener when enabled
        
        # Figure Context Manager for multimodal AI translation
        from modules.figure_context_manager import FigureContextManager
        self.figure_context = FigureContextManager(self)
        
        # Theme Manager
        from modules.theme_manager import ThemeManager
        self.theme_manager = None  # Will be initialized after UI setup
        
        self.recent_projects_file = self.user_data_path / "workbench" / "settings" / "recent_projects.json"
        
        # Initialize UI
        self.init_ui()
        
        # Initialize theme manager and apply theme
        self.theme_manager = ThemeManager(self.user_data_path)
        # Apply saved global UI font scale
        saved_font_scale = self._get_global_ui_font_scale()
        self.theme_manager.font_scale = saved_font_scale
        self.theme_manager.apply_theme(QApplication.instance())
        
        # Update widgets that were created before theme_manager existed
        if hasattr(self, 'termlens_widget') and self.termlens_widget:
            self.termlens_widget.theme_manager = self.theme_manager
            if hasattr(self.termlens_widget, 'apply_theme'):
                self.termlens_widget.apply_theme()

        # Also update the Match Panel TermLens (right panel)
        if hasattr(self, 'termlens_widget_match') and self.termlens_widget_match:
            self.termlens_widget_match.theme_manager = self.theme_manager
            if hasattr(self.termlens_widget_match, 'apply_theme'):
                self.termlens_widget_match.apply_theme()

        if hasattr(self, 'translation_results_panel') and self.translation_results_panel:
            self.translation_results_panel.theme_manager = self.theme_manager
            # Also update class-level theme_manager for CompactMatchItem
            from modules.translation_results_panel import CompactMatchItem
            CompactMatchItem.theme_manager = self.theme_manager
            if hasattr(self.translation_results_panel, 'apply_theme'):
                self.translation_results_panel.apply_theme()
        
        # Schedule theme refresh after UI is fully initialized
        # This ensures all widgets are properly themed at startup
        from PyQt6.QtCore import QTimer
        QTimer.singleShot(100, self.refresh_theme_colors)
        
        self.log(f"Welcome to Supervertaler Workbench v{__version__}")
        self.log("Supervertaler: The Ultimate Translation Workbench.")

        # Bring up the Voice tray icon now (in its inactive/grey
        # state) so the system tray slot is allocated up-front. Allocating
        # it on first Always-On activation would cause a one-time bounce
        # of neighbouring tray icons; doing it at startup folds that into
        # normal app launch instead.
        self._ensure_alwayson_tray_icon()
        # Note: the Workbench tray icon itself is set up later by
        # _setup_tray_icon(), called from main() after construction.

        # Load general settings (including auto-propagation)
        self.load_general_settings()
        
        # Load language settings
        self.load_language_settings()
        
        # Restore last project if enabled in settings
        self.restore_last_project_if_enabled()
        
        # Auto-open log window if enabled in settings
        general_settings = self.load_general_settings()
        if general_settings.get('auto_open_log', False):
            # Use QTimer to open log window after UI fully initializes
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(500, self.detach_log_window)  # 500ms delay to ensure UI is ready

        # Usage statistics – opt-in dialog (once) and anonymous ping
        self._init_usage_statistics()

        # Initialize auto backup timer
        self.auto_backup_timer = None
        self.last_backup_time = None
        self.restart_auto_backup_timer()

        # Apply proxy env vars for Gemini before any API calls are made
        self._apply_gemini_proxy()

        # Initialize Okapi sidecar (local file-processing service)
        self.okapi_sidecar = None
        self._init_okapi_sidecar_deferred()

        # Load font sizes from preferences (after UI is fully initialized)
        QApplication.instance().processEvents()  # Allow UI to finish initializing
        self.load_font_sizes_from_preferences()
        
        # Restore under-grid dock (TermLens / QuickTrans) visibility, position
        # and which tab was active.
        if hasattr(self, 'bottom_tabs'):
            self._restoring_dock = True
            try:
                termlens_visible = general_settings.get('termlens_under_grid_visible', False)
                above = general_settings.get('tabs_above_grid', False)
                self.tabs_above_grid = above
                if termlens_visible:
                    self.set_termlens_position('above' if above else 'below', save=False)
                else:
                    self.set_termlens_position(None, save=False)
                # Restore the active tab choice.
                active_tab = general_settings.get('bottom_dock_active_tab', 'termlens')
                if active_tab == 'quicktrans' and getattr(self, 'quicktrans_panel', None) is not None:
                    self.bottom_tabs.setCurrentWidget(self.quicktrans_panel)
                self._sync_bottom_dock_menu()
                # Restore the Match Panel top tab (TermLens / QuickTrans).
                match_tab = general_settings.get('match_top_active_tab', 'termlens')
                if (match_tab == 'quicktrans' and hasattr(self, 'match_top_tabs')
                        and getattr(self, 'quicktrans_panel_match', None) is not None):
                    self.match_top_tabs.setCurrentWidget(self.quicktrans_panel_match)
            finally:
                self._restoring_dock = False

        # First-run check - show unified setup wizard
        if self._needs_data_location_dialog or not general_settings.get('first_run_completed', False):
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(300, lambda: self._show_setup_wizard(is_first_run=True))
    
    def _show_data_location_dialog(self):
        """Show dialog to let user choose their data folder location on first run."""
        try:
            from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
                                         QPushButton, QLineEdit, QFileDialog, QDialogButtonBox)
            
            dialog = QDialog(self)
            dialog.setWindowTitle("Choose Data Folder Location")
            dialog.setMinimumWidth(550)
            dialog.setModal(True)
            
            layout = QVBoxLayout(dialog)
            layout.setSpacing(15)
            
            # Title
            title_label = QLabel("<h2>📁 Choose Your Data Folder</h2>")
            layout.addWidget(title_label)
            
            # Explanation
            msg_label = QLabel(
                "Supervertaler stores your data in a folder of your choice, which contains things like:<br><br>"
                "• API keys<br>"
                "• Translation memories<br>"
                "• Termbases<br>"
                "• Prompts<br>"
                "• Settings<br><br>"
                "Choose a location that's easy to find and backup.<br>"
                "You can change this later in Settings → General."
            )
            msg_label.setWordWrap(True)
            layout.addWidget(msg_label)
            
            # Path input with browse button
            path_layout = QHBoxLayout()
            
            path_edit = QLineEdit()
            default_path = get_default_user_data_path()
            path_edit.setText(str(default_path))
            path_edit.setMinimumWidth(350)
            path_layout.addWidget(path_edit)
            
            browse_btn = QPushButton("Browse...")
            def browse_folder():
                folder = QFileDialog.getExistingDirectory(
                    dialog, 
                    "Choose Data Folder",
                    str(Path.home())
                )
                if folder:
                    # Append "Supervertaler" if user didn't include it
                    folder_path = Path(folder)
                    if folder_path.name != "Supervertaler":
                        folder_path = folder_path / "Supervertaler"
                    path_edit.setText(str(folder_path))
            
            browse_btn.clicked.connect(browse_folder)
            path_layout.addWidget(browse_btn)
            
            layout.addLayout(path_layout)
            
            # Tip
            tip_label = QLabel(
                "💡 <b>Tip:</b> The default location is in your home folder, "
                "making it easy to find and backup."
            )
            tip_label.setWordWrap(True)
            tip_label.setStyleSheet("color: #666;")
            layout.addWidget(tip_label)
            
            # Buttons
            button_layout = QHBoxLayout()
            
            default_btn = QPushButton("Use Default")
            default_btn.clicked.connect(lambda: path_edit.setText(str(default_path)))
            button_layout.addWidget(default_btn)
            
            button_layout.addStretch()
            
            ok_btn = QPushButton("OK")
            ok_btn.setDefault(True)
            ok_btn.clicked.connect(dialog.accept)
            button_layout.addWidget(ok_btn)
            
            layout.addLayout(button_layout)
            
            # Show dialog
            if dialog.exec() == QDialog.DialogCode.Accepted:
                chosen_path = Path(path_edit.text())
                
                # Create the folder
                chosen_path.mkdir(parents=True, exist_ok=True)
                
                # Save the choice to config pointer
                save_user_data_path(chosen_path)
                
                # Update our path if different from what we initialized with
                if chosen_path != self.user_data_path:
                    self.user_data_path = chosen_path
                    # Re-initialize managers with new path
                    self._reinitialize_with_new_data_path()
                else:
                    # Same path, but we still need to connect database (it was deferred)
                    if hasattr(self, 'db_manager') and self.db_manager and not self.db_manager.connection:
                        self.db_manager.connect()
                
                self.log(f"📁 Data folder set to: {chosen_path}")
                
                # Now show the features welcome dialog
                from PyQt6.QtCore import QTimer
                QTimer.singleShot(300, self._show_first_run_welcome)
            else:
                # User cancelled - use default anyway
                default_path.mkdir(parents=True, exist_ok=True)
                save_user_data_path(default_path)
                self.log(f"📁 Using default data folder: {default_path}")
                
        except Exception as e:
            self.log(f"⚠️ Data location dialog error: {e}")
            import traceback
            traceback.print_exc()
    
    def _reinitialize_with_new_data_path(self):
        """Re-initialize managers after user changes data path."""
        try:
            # Migrate settings if needed for the new data path
            self._migrate_settings_to_unified()
            self._migrate_to_workbench_layout()

            # Close existing database connection
            if hasattr(self, 'db_manager') and self.db_manager:
                self.db_manager.close()
            
            # Re-initialize database manager
            from modules.database_manager import DatabaseManager
            self.db_manager = DatabaseManager(
                db_path=str(self.user_data_path / "resources" / "supervertaler.db"),
                log_callback=self.log
            )
            self.db_manager.connect()
            
            # Re-initialize TM database
            from modules.translation_memory import TMDatabase
            self.tm_database = TMDatabase(
                source_lang=None,
                target_lang=None,
                db_path=str(self.user_data_path / "resources" / "supervertaler.db"),
                log_callback=self.log
            )
            
            # Re-initialize TM metadata manager
            from modules.tm_metadata_manager import TMMetadataManager
            self.tm_metadata_mgr = TMMetadataManager(self.db_manager, self.log)
            
            # Update other managers
            self.spellcheck_manager = get_spellcheck_manager(str(self.user_data_path))
            self.fr_history = FindReplaceHistory(str(self.user_data_path))
            self.shortcut_manager = ShortcutManager(Path(self.user_data_path) / "workbench" / "settings" / "shortcuts.json")
            self.voice_command_manager = VoiceCommandManager(self.user_data_path, main_window=self)
            
            # Update theme manager
            from modules.theme_manager import ThemeManager
            self.theme_manager = ThemeManager(self.user_data_path)
            # Apply saved global UI font scale
            saved_font_scale = self._get_global_ui_font_scale()
            self.theme_manager.font_scale = saved_font_scale
            self.theme_manager.apply_theme(QApplication.instance())
            
            # Update recent projects file path
            self.recent_projects_file = self.user_data_path / "workbench" / "settings" / "recent_projects.json"
            
            self.log(f"✅ Re-initialized all managers with new data path")
            
        except Exception as e:
            self.log(f"⚠️ Error re-initializing managers: {e}")
            import traceback
            traceback.print_exc()
    
    def _show_first_run_welcome(self):
        """No-op since v1.9.474.

        This used to pop up an intro dialog about Supervertaler's modular
        feature architecture and route the user to Settings → Features.
        Both the modular-extras system and the Features tab were retired
        when every feature module became a core dependency. The setup
        wizard handles the data-folder confirmation; nothing else needs
        to nag the user on first run. Kept as a stub so any pending
        QTimer.singleShot() callers don't blow up.
        """
        try:
            settings = self.load_general_settings()
            settings['first_run_completed'] = True
            self.save_general_settings(settings)
        except Exception:
            pass

    def _show_setup_wizard(self, is_first_run: bool = False):
        """
        Show unified setup wizard that combines data folder selection and features intro.

        Args:
            is_first_run: If True, this is an automatic first-run trigger. If False, user
                          manually invoked from menu (skip data folder if already configured).
        """
        try:
            from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
                                         QPushButton, QLineEdit, QFileDialog, QStackedWidget,
                                         QWidget, QFrame, QCheckBox)
            from PyQt6.QtCore import Qt

            dialog = QDialog(self)
            dialog.setWindowTitle("Supervertaler Workbench Setup Wizard")
            dialog.setMinimumWidth(600)
            dialog.setMinimumHeight(450)
            dialog.setModal(True)

            main_layout = QVBoxLayout(dialog)
            main_layout.setSpacing(15)
            main_layout.setContentsMargins(20, 20, 20, 20)

            # Stacked widget for wizard pages
            stacked = QStackedWidget()

            # Determine if we need to show data folder page
            show_data_folder_page = is_first_run and self._needs_data_location_dialog

            # ==================== PAGE 1: Data Folder Selection ====================
            page1 = QWidget()
            page1_layout = QVBoxLayout(page1)
            page1_layout.setSpacing(15)

            # Step indicator
            step1_indicator = QLabel("<span style='color: #888;'>Step 1 of 2</span>")
            page1_layout.addWidget(step1_indicator)

            # Title
            page1_title = QLabel("<h2>📁 Choose Your Data Folder</h2>")
            page1_layout.addWidget(page1_title)

            # Explanation
            page1_msg = QLabel(
                "Supervertaler stores your data in a folder of your choice:<br><br>"
                "• <b>API keys</b> – Your LLM provider credentials<br>"
                "• <b>Translation memories</b> – Reusable translation pairs<br>"
                "• <b>Termbases</b> – Terminology databases<br>"
                "• <b>Prompts</b> – Custom AI prompts<br>"
                "• <b>Settings</b> – Application configuration<br><br>"
                "Choose a location that's easy to find and backup."
            )
            page1_msg.setWordWrap(True)
            page1_layout.addWidget(page1_msg)

            # Path input with browse button
            path_layout = QHBoxLayout()
            path_edit = QLineEdit()
            default_path = get_default_user_data_path()
            path_edit.setText(str(default_path))
            path_edit.setMinimumWidth(350)
            path_layout.addWidget(path_edit)

            browse_btn = QPushButton("Browse...")
            def browse_folder():
                folder = QFileDialog.getExistingDirectory(
                    dialog,
                    "Choose Data Folder",
                    str(Path.home())
                )
                if folder:
                    folder_path = Path(folder)
                    if folder_path.name != "Supervertaler":
                        folder_path = folder_path / "Supervertaler"
                    path_edit.setText(str(folder_path))

            browse_btn.clicked.connect(browse_folder)
            path_layout.addWidget(browse_btn)
            page1_layout.addLayout(path_layout)

            # Tip
            page1_tip = QLabel(
                "💡 <b>Tip:</b> The default location is in your home folder, "
                "making it easy to find and backup."
            )
            page1_tip.setWordWrap(True)
            page1_tip.setStyleSheet("color: #666;")
            page1_layout.addWidget(page1_tip)

            page1_layout.addStretch()
            stacked.addWidget(page1)

            # ==================== PAGE 2: Features Introduction ====================
            page2 = QWidget()
            page2_layout = QVBoxLayout(page2)
            page2_layout.setSpacing(15)

            # Step indicator
            step2_label = "Step 2 of 2" if show_data_folder_page else "Setup"
            step2_indicator = QLabel(f"<span style='color: #888;'>{step2_label}</span>")
            page2_layout.addWidget(step2_indicator)

            # Data folder info (shown when skipping page 1)
            if not show_data_folder_page:
                from PyQt6.QtGui import QDesktopServices
                from PyQt6.QtCore import QUrl

                data_folder_path = str(self.user_data_path)
                data_folder_info = QLabel(
                    f"<b>📁 Data Folder:</b> <a href='file:///{data_folder_path}' "
                    f"style='color: #3b82f6;'>{data_folder_path}</a><br>"
                    "<span style='color: #666; font-size: 0.9em;'>"
                    "Your settings, TMs, termbases and prompts are stored here. "
                    "Change in Settings → General.</span>"
                )
                data_folder_info.setWordWrap(True)
                data_folder_info.setTextFormat(Qt.TextFormat.RichText)
                data_folder_info.setOpenExternalLinks(False)  # Handle clicks ourselves
                data_folder_info.linkActivated.connect(
                    lambda url: QDesktopServices.openUrl(QUrl.fromLocalFile(data_folder_path))
                )
                data_folder_info.setStyleSheet(
                    "background: #f0f4ff; padding: 12px; border-radius: 6px; "
                    "border-left: 4px solid #3b82f6; margin-bottom: 10px;"
                )
                page2_layout.addWidget(data_folder_info)

            # Title
            page2_title = QLabel("<h2>✨ You're all set!</h2>")
            page2_layout.addWidget(page2_title)

            # Message – kept deliberately short. Earlier versions of this
            # page explained Supervertaler's modular-features architecture
            # and pointed users at Settings → Features. Both are gone in
            # v1.9.474: every feature module that used to be optional is
            # now a core dependency, so there is nothing for the user to
            # install or toggle.
            page2_msg = QLabel(
                "Your data folder is ready and Supervertaler is set up.<br><br>"
                "Have a look around the <b>Editor</b> tab to start translating, "
                "or visit <b>Settings</b> to configure API keys, prompts, theme, "
                "and more."
            )
            page2_msg.setWordWrap(True)
            page2_msg.setTextFormat(Qt.TextFormat.RichText)
            page2_layout.addWidget(page2_msg)

            # Checkbox
            dont_show_checkbox = CheckmarkCheckBox("Don't show this wizard on startup")
            dont_show_checkbox.setChecked(True)
            page2_layout.addWidget(dont_show_checkbox)

            page2_layout.addStretch()
            stacked.addWidget(page2)

            main_layout.addWidget(stacked)

            # ==================== Navigation Buttons ====================
            nav_layout = QHBoxLayout()

            back_btn = QPushButton("← Back")
            back_btn.setVisible(False)  # Hidden on first page

            next_btn = QPushButton("Next →")
            finish_btn = QPushButton("Finish")
            finish_btn.setVisible(False)
            finish_btn.setDefault(True)

            # Use Default button (only on page 1)
            default_btn = QPushButton("Use Default")
            default_btn.clicked.connect(lambda: path_edit.setText(str(default_path)))

            nav_layout.addWidget(default_btn)
            nav_layout.addStretch()
            nav_layout.addWidget(back_btn)
            nav_layout.addWidget(next_btn)
            nav_layout.addWidget(finish_btn)

            main_layout.addLayout(nav_layout)

            # Track chosen path for later
            chosen_path_holder = [None]

            def go_to_page(page_index):
                stacked.setCurrentIndex(page_index)
                if page_index == 0:
                    back_btn.setVisible(False)
                    next_btn.setVisible(True)
                    finish_btn.setVisible(False)
                    default_btn.setVisible(True)
                else:
                    back_btn.setVisible(show_data_folder_page)
                    next_btn.setVisible(False)
                    finish_btn.setVisible(True)
                    default_btn.setVisible(False)

            def on_next():
                # Save the data folder choice
                chosen_path = Path(path_edit.text())
                chosen_path_holder[0] = chosen_path

                # Create the folder and save config
                chosen_path.mkdir(parents=True, exist_ok=True)
                save_user_data_path(chosen_path)

                # Update our path if different
                if chosen_path != self.user_data_path:
                    self.user_data_path = chosen_path
                    self._reinitialize_with_new_data_path()
                else:
                    if hasattr(self, 'db_manager') and self.db_manager and not self.db_manager.connection:
                        self.db_manager.connect()

                self.log(f"📁 Data folder set to: {chosen_path}")
                go_to_page(1)

            def on_back():
                go_to_page(0)

            def on_finish():
                # Save first_run preference
                if dont_show_checkbox.isChecked():
                    settings = self.load_general_settings()
                    settings['first_run_completed'] = True
                    self.save_general_settings(settings)
                    self.log("✅ Setup wizard completed (won't show again on startup)")
                else:
                    self.log("✅ Setup wizard shown (will show again next time)")

                dialog.accept()

            back_btn.clicked.connect(on_back)
            next_btn.clicked.connect(on_next)
            finish_btn.clicked.connect(on_finish)

            # Start on appropriate page
            if show_data_folder_page:
                go_to_page(0)
            else:
                # Skip to features page if data folder already configured
                go_to_page(1)
                step2_indicator.setText("<span style='color: #888;'>Supervertaler Setup</span>")

            dialog.exec()

        except Exception as e:
            self.log(f"⚠️ Setup wizard error: {e}")
            import traceback
            traceback.print_exc()

    def init_ui(self):
        """Initialize the user interface"""
        # Build window title with dev mode indicator
        title = f"Supervertaler Workbench v{__version__}"
        if ENABLE_PRIVATE_FEATURES:
            title += " [🛠️ DEV MODE]"
        self.setWindowTitle(title)
        self.setGeometry(100, 100, 1400, 800)

        # Set application icon
        from PyQt6.QtGui import QIcon
        icon_path = get_resource_path("assets/icon.ico")
        if icon_path.exists():
            self.setWindowIcon(QIcon(str(icon_path)))

        # Ensure window can be resized (no minimum size constraint)
        self.setMinimumSize(400, 300)  # Very small minimum to allow resizing
        
        # Create menu bar (ribbon removed - using traditional menus)
        self.create_menus()
        
        # Ribbon removed - all functionality moved to menu bar
        # self.create_ribbon()
        
        # Create main layout
        self.create_main_layout()
        
        # Create status bar with progress indicators
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self.status_bar.showMessage("Ready")
        
        # Add permanent progress widgets to status bar (right side)
        self._setup_progress_indicators()

        # Setup global shortcuts
        self.setup_global_shortcuts()

        # Install context-sensitive help (F1 opens relevant GitBook page)
        install_help_system(self)

    def setup_global_shortcuts(self):
        """Setup application-wide keyboard shortcuts"""
        from PyQt6.QtGui import QShortcut
        
        # Store all shortcuts with their IDs and default key sequences for enable/disable management
        self.global_shortcuts = {}  # shortcut_id -> QShortcut
        self.global_shortcut_keys = {}  # shortcut_id -> current key sequence string (from manager)
        
        def create_shortcut(shortcut_id: str, default_key: str, handler):
            """Create a shortcut, using custom key from manager if set, respecting enabled state"""
            # Get the actual key sequence (custom or default) from the manager
            key_sequence = self.shortcut_manager.get_shortcut(shortcut_id)
            # Important: an empty string is a valid "unassigned" default.
            # Only fall back to the passed default_key if the shortcut ID is unknown to the manager.
            if key_sequence == "":
                default_ids = getattr(self.shortcut_manager, 'DEFAULT_SHORTCUTS', {})
                custom_ids = getattr(self.shortcut_manager, 'custom_shortcuts', {})
                if shortcut_id not in default_ids and shortcut_id not in custom_ids:
                    key_sequence = default_key
            
            shortcut = QShortcut(QKeySequence(key_sequence), self)
            shortcut.activated.connect(handler)
            # Store the current key sequence for re-enabling later
            self.global_shortcut_keys[shortcut_id] = key_sequence
            # If disabled, clear the key sequence to fully release the key combination
            if not self.shortcut_manager.is_enabled(shortcut_id):
                shortcut.setKey(QKeySequence())  # Clear key to release combination
            self.global_shortcuts[shortcut_id] = shortcut
            return shortcut

        # Voice dictation (default Ctrl+Shift+Space; configurable in
        # Settings → Keyboard Shortcuts). The fallback below only applies if
        # voice_dictate is somehow absent from the registry — keep it in sync
        # with the registry default rather than the long-dead "F9".
        # Disable autorepeat: holding the chord (hold-to-talk mode) must NOT
        # re-fire start_voice_dictation, otherwise toggle semantics keep
        # stopping and restarting the recording while the key is held –
        # producing fragmented audio that Whisper hallucinates over.
        _voice_dictate_sc = create_shortcut("voice_dictate", "Ctrl+Shift+Space", self.start_voice_dictation)
        if _voice_dictate_sc is not None:
            try:
                _voice_dictate_sc.setAutoRepeat(False)
            except Exception:
                pass
        
        # Ctrl+Up/Down - Segment navigation is handled by _GridArrowKeyEventFilter
        # (QTableWidget intercepts vertical arrow keys before QShortcuts can fire)
        # Legacy match cycling (Translation Results panel) has been removed.
        
        # Ctrl+1 through Ctrl+9 - Insert match by number
        self.match_shortcuts = []
        for i in range(1, 10):
            shortcut_id = f"match_insert_{i}"
            default_key = f"Ctrl+{i}"
            key_sequence = self.shortcut_manager.get_shortcut(shortcut_id)
            # Respect intentionally blank defaults (unassigned shortcuts)
            if key_sequence == "":
                default_ids = getattr(self.shortcut_manager, 'DEFAULT_SHORTCUTS', {})
                custom_ids = getattr(self.shortcut_manager, 'custom_shortcuts', {})
                if shortcut_id not in default_ids and shortcut_id not in custom_ids:
                    key_sequence = default_key
            shortcut = QShortcut(QKeySequence(key_sequence), self)
            shortcut.activated.connect(lambda num=i: self.insert_match_by_number(num))
            self.global_shortcut_keys[shortcut_id] = key_sequence
            if not self.shortcut_manager.is_enabled(shortcut_id):
                shortcut.setKey(QKeySequence())  # Clear key to release combination
            self.global_shortcuts[shortcut_id] = shortcut
            self.match_shortcuts.append(shortcut)

        # Compare Panel insertion shortcut
        # Alt+0 inserts MT; double-tap Alt+0,0 inserts TM Target.
        self._compare_panel_last_key = None
        self._compare_panel_last_time = 0
        create_shortcut("compare_insert_alt0", "Alt+0", self._handle_compare_panel_alt0_shortcut)

        # Compare Panel navigation shortcuts
        # MT prev/next: Ctrl+Alt+Left / Ctrl+Alt+Right
        # TM prev/next: Ctrl+Alt+Up / Ctrl+Alt+Down
        create_shortcut(
            "compare_nav_mt_prev",
            "Ctrl+Alt+Left",
            lambda: self._compare_panel_nav_mt(-1) if self._get_active_match_shortcut_mode() == 'compare' else None,
        )
        create_shortcut(
            "compare_nav_mt_next",
            "Ctrl+Alt+Right",
            lambda: self._compare_panel_nav_mt(1) if self._get_active_match_shortcut_mode() == 'compare' else None,
        )
        create_shortcut(
            "compare_nav_tm_prev",
            "Ctrl+Alt+Up",
            lambda: self._compare_panel_nav_tm(-1) if self._get_active_match_shortcut_mode() == 'compare' else None,
        )
        create_shortcut(
            "compare_nav_tm_next",
            "Ctrl+Alt+Down",
            lambda: self._compare_panel_nav_tm(1) if self._get_active_match_shortcut_mode() == 'compare' else None,
        )
        
        # Alt+0 through Alt+9 - Insert term from TermLens by number
        # Supports double-tap for terms 11-20 (00, 11, 22, ..., 99)
        self.termlens_shortcuts = []
        self._termlens_last_key = None  # Track last key for double-tap detection
        self._termlens_last_time = 0    # Track timing for double-tap
        
        # Double-tap Shift detection for context menu
        self._shift_last_press_time = 0
        self._shift_double_tap_threshold = 0.35  # 350ms window
        
        for i in range(0, 10):  # 0-9
            shortcut_id = f"termlens_insert_{i}"
            default_key = f"Alt+{i}"
            key_sequence = self.shortcut_manager.get_shortcut(shortcut_id)
            # Respect intentionally blank defaults (unassigned shortcuts)
            if key_sequence == "":
                default_ids = getattr(self.shortcut_manager, 'DEFAULT_SHORTCUTS', {})
                custom_ids = getattr(self.shortcut_manager, 'custom_shortcuts', {})
                if shortcut_id not in default_ids and shortcut_id not in custom_ids:
                    key_sequence = default_key
            shortcut = QShortcut(QKeySequence(key_sequence), self)
            shortcut.activated.connect(lambda num=i: self._handle_termlens_shortcut(num))
            self.global_shortcut_keys[shortcut_id] = key_sequence
            if not self.shortcut_manager.is_enabled(shortcut_id):
                shortcut.setKey(QKeySequence())  # Clear key to release combination
            self.global_shortcuts[shortcut_id] = shortcut
            self.termlens_shortcuts.append(shortcut)
        
        # Ctrl+Space - Insert currently selected match
        create_shortcut("match_insert_selected_ctrl", "Ctrl+Space", self.insert_selected_match)
        
        # Ctrl+Up/Down - Navigate to previous/next segment
        # Handled by _GridArrowKeyEventFilter (QTableWidget eats vertical arrow keys
        # before QShortcuts can fire, so QShortcut-based registration doesn't work)
        
        # Ctrl+Home/End - Navigate to first/last segment
        create_shortcut("segment_go_to_top", "Ctrl+Home", self.go_to_first_segment)
        create_shortcut("segment_go_to_bottom", "Ctrl+End", self.go_to_last_segment)
        
        # Ctrl+Enter - Confirm segment(s) and go to next unconfirmed
        # If multiple segments selected: confirm all selected
        # If single segment: confirm and go to next unconfirmed
        ctrl_enter_shortcut = create_shortcut("editor_save_and_next", "Ctrl+Return", self.confirm_selected_or_next)
        # CRITICAL: Use ApplicationShortcut context so it works even when focus is in QTextEdit widgets
        ctrl_enter_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut)

        # Workaround: ensure Ctrl+Return (main keyboard) is caught even if swallowed before QShortcut
        # (Observed: Ctrl+Enter on numpad works, Ctrl+Return does not when focus is in source cell.)
        if not hasattr(self, '_ctrl_return_event_filter'):
            from PyQt6.QtWidgets import QApplication
            self._ctrl_return_event_filter = _CtrlReturnEventFilter(self)
            QApplication.instance().installEventFilter(self._ctrl_return_event_filter)
        
        # Alt+Up/Down (glossary) and Ctrl+Up/Down (segment nav) in grid cells.
        # QAbstractItemView intercepts vertical arrow keys at the viewport level,
        # preventing them from reaching the cell widget's keyPressEvent/event().
        # This app-level filter sits above the viewport and dispatches them directly.
        if not hasattr(self, '_grid_arrow_key_event_filter'):
            from PyQt6.QtWidgets import QApplication
            self._grid_arrow_key_event_filter = _GridArrowKeyEventFilter(self)
            QApplication.instance().installEventFilter(self._grid_arrow_key_event_filter)

        # Double-tap Shift for context menu (cross-platform, replaces AHK detection)
        if not hasattr(self, '_double_shift_event_filter'):
            from PyQt6.QtWidgets import QApplication
            self._double_shift_event_filter = _DoubleTapShiftEventFilter(self)
            QApplication.instance().installEventFilter(self._double_shift_event_filter)

        # Hold-to-talk release detection is now handled by the global
        # GlobalHotkeyListener (single low-level keyboard hook delivers
        # both press and release for the user's voice_dictate chord, in or
        # out of the editor). The legacy _F9HoldReleaseFilter only worked
        # for the F9 key inside the Workbench window and only when the
        # user's shortcut happened to be F9 – it was effectively dead code
        # for the Ctrl+Shift+Space default. Removed in v1.9.492.


        # Ctrl+Shift+Enter - Always confirm all selected segments
        create_shortcut("editor_confirm_selected", "Ctrl+Shift+Return", self.confirm_selected_segments)
        
        # Note: Ctrl+Shift+S (Copy source to target) is handled in EditableGridTextEditor.keyPressEvent
        
        # Ctrl+K - Concordance Search
        create_shortcut("tools_concordance_search", "Ctrl+K", self.show_concordance_search)
        
        # Ctrl+Shift+F - Filter on selected text / Clear filter (toggle)
        create_shortcut("filter_selected_text", "Ctrl+Shift+F", self.filter_on_selected_text)
        
        # Ctrl+Shift+H – Toggle Tag View. (Was Ctrl+Alt+T before v1.9.476;
        # moved to free that combo for "Add term to glossary" which matches
        # the Trados plugin's shortcut. Ctrl+Shift+H is Trados Studio's own
        # default for the equivalent tag-display toggle.)
        create_shortcut("view_toggle_tags", "Ctrl+Shift+H", self._toggle_tag_view_via_shortcut)
        
        # Page Up/Down - Navigate pagination pages
        create_shortcut("page_prev", "PgUp", self.go_to_prev_page)
        create_shortcut("page_next", "PgDown", self.go_to_next_page)
        
        # Shift+Page Up/Down - Select range of segments
        create_shortcut("select_range_up", "Shift+PgUp", self.select_range_page_up)
        create_shortcut("select_range_down", "Shift+PgDown", self.select_range_page_down)
        
        # Ctrl+G - Go to segment
        create_shortcut("edit_goto", "Ctrl+G", self.show_goto_dialog)
        
        # F5 - Force refresh matches (clear all caches and re-search)
        create_shortcut("tools_force_refresh", "F5", self.force_refresh_matches)
        
        # Ctrl+Shift+1 - Quick add term to Project Glossary
        create_shortcut("editor_quick_add_priority_1", "Ctrl+Shift+1", lambda: self._quick_add_term_with_priority("project"))

        # Ctrl+Shift+2 - Quick add term to Background Glossary
        create_shortcut("editor_quick_add_priority_2", "Ctrl+Shift+2", lambda: self._quick_add_term_with_priority("background"))

        # Alt+Up/Down - Quick add term pair to Project/Background glossary
        # Handled by _GridArrowKeyEventFilter (QTableWidget eats vertical arrow keys
        # before QShortcuts can fire, so QShortcut-based registration doesn't work)
        
        # Alt+D - Add word at cursor to dictionary
        create_shortcut("editor_add_to_dictionary", "Alt+D", self.add_word_to_dictionary_shortcut)

        # Ctrl+M — Create a comment anchored to the currently-selected
        # text in the source or target cell. If there's no selection,
        # the comment is segment-level. Mirrors the Trados/memoQ
        # workflow exactly.
        # v1.10.61 history: originally Ctrl+Shift+M because the
        # QuickTrans menu items had "(Ctrl+M)" labels (stale display
        # text from an earlier version where Ctrl+M *was* bound). The
        # labels were misleading — no setShortcut() was ever wired, and
        # QuickTrans is in fact triggered via the global Ctrl+Alt+Q
        # pynput hotkey. v1.10.61 reclaimed Ctrl+M for comments.
        create_shortcut("editor_add_comment", "Ctrl+M", self.add_comment_from_selection)
        
        # Ctrl+Q - Open QuickLauncher directly
        create_shortcut("editor_open_quicklauncher", "Ctrl+Q", self.open_quicklauncher)

        # Ctrl+Shift+C - Open Sidekick directly to Clipboard tab
        create_shortcut("sidekick_open_clipboard", "Ctrl+Shift+C", self.open_clipboard_tab)

        # The next three actions used to have only an OS-level global hotkey
        # entry. After merging local + global into one shortcut per action,
        # they need an in-app QShortcut binding too so the same key works
        # whether or not Supervertaler is frontmost. The global side is
        # registered separately by SuperlookupTab.register_global_hotkey.
        create_shortcut(
            "tools_universal_lookup", "Ctrl+Alt+L",
            lambda: self.lookup_tab._handle_superlookup_hotkey()
                if hasattr(self, 'lookup_tab') else None,
        )
        create_shortcut("sidekick_open", "Alt+K", self.open_quicklauncher)
        create_shortcut(
            "voice_alwayson_toggle", "Ctrl+Alt+A",
            self._toggle_alwayson_listening,
        )

        # Lone Ctrl tap – Term Insert Popup (memoQ-style glossary + NT insert list).
        # Implemented as an app-level event filter rather than a QShortcut because
        # QShortcut cannot bind to a bare modifier key.
        if not hasattr(self, '_lone_ctrl_event_filter'):
            self._lone_ctrl_event_filter = _LoneCtrlEventFilter(self)
            QApplication.instance().installEventFilter(self._lone_ctrl_event_filter)

        # v1.10.97 — Ctrl+Shift tap event filter removed. Originally
        # added in v1.10.90 to give the Term Picker an ergonomic
        # modifier-chord trigger; turned out to clash with AltGr (which
        # emits Ctrl+Alt key events on Windows for users on Dutch /
        # German / other layouts) and to flood the session log with
        # diagnostic chatter for every chord attempt. User feedback:
        # "can we remove the Ctrl+Shift shortcut and just use
        # Ctrl+Shift+B?" The QShortcut below remains as the canonical
        # Term Picker trigger; the lone-Ctrl tap above remains the
        # canonical TermLens-popup trigger.

        # Ctrl+Shift+Q - MT Quick Lookup (GT4T-style popup)
        mt_quick_shortcut = create_shortcut("mt_quick_lookup", "Ctrl+Shift+Q", self.show_mt_quick_popup)
        # Use ApplicationShortcut context so it works even when focus is in QTextEdit widgets
        mt_quick_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut)

        # Ctrl+Shift+B - TermLens Term Picker (v1.10.87 — Trados parity).
        # Modal tabular dialog listing all matches for the current segment
        # with synonym expand/collapse + digit quick-pick. Complements the
        # lone-Ctrl TermLens-mirror popup: same data, different ergonomics.
        # v1.10.89: switched from Ctrl+Shift+P to Ctrl+Shift+B because the
        # Scratchpad menu action already binds Ctrl+Shift+P; the conflict
        # silently dropped the term_picker shortcut. B = termBase picker.
        term_picker_shortcut = create_shortcut("term_picker", "Ctrl+Shift+B", self.show_term_picker_dialog)
        term_picker_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut)

    def add_comment_from_selection(self):
        """Create a new Comment anchored to the text selected in the source
        or target cell of the focused row (Ctrl+M).

        Phase B of the v1.10.57+ range-anchored comments work.
        (Originally bound to Ctrl+Shift+M; reclaimed Ctrl+M in v1.10.61
        once the misleading "(Ctrl+M)" labels on the QuickTrans menu
        items were corrected — Ctrl+M was never actually bound in-app,
        QuickTrans uses the global Ctrl+Alt+Q hotkey.)

        Behaviour:
         - If the focused widget is a source-cell or target-cell editor
           AND the user has selected text in it, the new Comment is
           anchored to that character range (Python-slice semantics)
           on that field. Example: select "schroef" in the source cell
           → Ctrl+M → enter "translated as 'screw' based on
           context" → Comment is stored with anchor_field='source',
           anchor_start/end matching the selection.
         - If there's no selection (just a cursor position), the
           Comment is segment-level (no anchor). Useful when you want
           to attach a general note to the whole segment without
           clicking into the legacy bottom-of-tab editor.
         - If the focused widget isn't a source/target cell, shows a
           hint in the status bar and bails. The user can still use
           the legacy bottom-of-tab editor for segment-level notes.

        Visible end-user payoff: Phases C (highlight in editor) and E
        (Word comment anchored to the exact characters in DOCX export)
        light up after this Comment is created. In Phase B alone, the
        Comment exists in the data and shows up in the all-comments
        list (as part of the segment's combined comment text — Phase D
        rebuilds the list to show one entry per Comment with proper
        anchor display).
        """
        from PyQt6.QtWidgets import (QDialog as _QDialog, QDialogButtonBox as _QDialogButtonBox,
                                     QPlainTextEdit as _QPlainTextEdit)

        if not self.current_project or not self.current_project.segments:
            return
        if not hasattr(self, 'table') or not self.table:
            return

        # Identify the focused source/target cell editor (if any).
        focused = QApplication.focusWidget()
        if not isinstance(focused, QTextEdit):
            if hasattr(self, 'status_bar'):
                self.status_bar.showMessage(
                    "Click into a source or target cell first, "
                    "then select text and press Ctrl+M to add a comment.",
                    5000,
                )
            return

        # Walk up the parent chain looking for the row, since the focused
        # widget might be a child of the cell editor (e.g. completion popup).
        row = getattr(focused, 'row', None)
        if row is None or row < 0:
            return

        source_widget = self.table.cellWidget(row, 2)
        target_widget = self.table.cellWidget(row, 3)
        anchor_field = ''
        if focused is source_widget:
            anchor_field = 'source'
        elif focused is target_widget:
            anchor_field = 'target'
        else:
            # Not in a recognised source/target cell — bail rather than
            # create an unanchored comment in a surprising context.
            return

        # Capture selection (or empty range = unanchored).
        cursor = focused.textCursor()
        sel_start = cursor.selectionStart()
        sel_end = cursor.selectionEnd()
        has_selection = sel_end > sel_start
        selected_text = (
            focused.toPlainText()[sel_start:sel_end] if has_selection else ''
        )
        if not has_selection:
            anchor_field = ''  # No anchor without selection
            sel_start = 0
            sel_end = 0

        # Resolve the target Segment via the row's id cell.
        id_item = self.table.item(row, 0)
        if not id_item:
            return
        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            return
        segment = next(
            (s for s in self.current_project.segments if s.id == segment_id), None
        )
        if not segment:
            return

        # ── Capture dialog ──────────────────────────────────────────
        dialog = _QDialog(self)
        dialog.setWindowTitle("Add comment")
        dialog.setMinimumWidth(480)
        layout = QVBoxLayout(dialog)

        anchor_desc = (
            f"<b>Segment #{segment.id}</b> &mdash; "
            f"{'anchored to ' + anchor_field if anchor_field else 'segment-level (no anchor)'}"
        )
        info = QLabel(anchor_desc)
        info.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info)

        if selected_text:
            # Trim a long selection so the dialog doesn't blow out.
            snippet = selected_text[:140] + ('…' if len(selected_text) > 140 else '')
            # Escape for HTML so user text doesn't break the label.
            try:
                import html as _html
                snippet_escaped = _html.escape(snippet)
            except Exception:
                snippet_escaped = snippet
            snippet_label = QLabel(f'Re: <i>"{snippet_escaped}"</i>')
            snippet_label.setWordWrap(True)
            snippet_label.setTextFormat(Qt.TextFormat.RichText)
            snippet_label.setStyleSheet(
                "color: #555; padding: 6px 8px; "
                "background-color: rgba(37, 99, 235, 0.08); "
                "border-radius: 4px; margin: 4px 0;"
            )
            layout.addWidget(snippet_label)

        layout.addWidget(QLabel("Comment:"))
        text_edit = _QPlainTextEdit()
        text_edit.setMinimumHeight(90)
        text_edit.setPlaceholderText("Your comment…")
        layout.addWidget(text_edit)

        buttons = _QDialogButtonBox(
            _QDialogButtonBox.StandardButton.Ok
            | _QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)

        text_edit.setFocus()

        if dialog.exec() != _QDialog.DialogCode.Accepted:
            return

        comment_text = text_edit.toPlainText().strip()
        if not comment_text:
            return  # No empty comments

        author = (
            self.get_translator_name()
            if hasattr(self, 'get_translator_name') else ''
        )
        segment.add_comment(
            text=comment_text,
            author=author,
            anchor_field=anchor_field,
            anchor_start=sel_start,
            anchor_end=sel_end,
        )
        self.project_modified = True
        try:
            self.update_window_title()
        except Exception:
            pass

        # Refresh the all-comments list + status cell + anchor highlight.
        try:
            self._refresh_segment_comments_list()
        except Exception as e:
            self.log(f"⚠ Comments list refresh failed: {e}")
        try:
            self._refresh_segment_status(segment)
        except Exception:
            pass
        # Re-paint anchor highlights on the affected row's cells.
        try:
            self._apply_comment_anchors_to_cell(row, segment)
        except Exception as e:
            self.log(f"⚠ Anchor highlight refresh failed: {e}")

        if hasattr(self, 'status_bar'):
            anchor_note = (
                f" (anchored to {anchor_field})" if anchor_field else ""
            )
            self.status_bar.showMessage(
                f"💬 Comment added to segment #{segment.id}{anchor_note}",
                3000,
            )

    def focus_segment_notes(self):
        """Switch to the Comments tab → Segment sub-tab and focus the editor.

        v1.10.56 collapsed the previous separate Segment-note and
        Proofreading-note tabs into a single "💬 Comments" parent tab
        with two sub-tabs. This handler now navigates that two-level
        structure: parent first, then sub-tab, then focus the editor.
        """
        if not hasattr(self, 'right_tabs'):
            return

        # Find the parent Comments tab — by stored index first (set in
        # the tab-construction code), with a fallback label scan in case
        # the tab order shifts later.
        target_index = getattr(self, '_comments_tab_index', None)
        if target_index is None or target_index >= self.right_tabs.count():
            for i in range(self.right_tabs.count()):
                if "Comments" in self.right_tabs.tabText(i):
                    target_index = i
                    break
        if target_index is not None:
            self.right_tabs.setCurrentIndex(target_index)

        # Switch the inner tab widget to "📝 Segment" (sub-index 0).
        if hasattr(self, 'comments_sub_tabs'):
            self.comments_sub_tabs.setCurrentIndex(0)

        # Focus the editor so the user can start typing immediately.
        if hasattr(self, 'bottom_notes_edit'):
            self.bottom_notes_edit.setFocus()
    
    def open_clipboard_tab(self):
        """In-app Ctrl+Shift+C: jump to Workbench's Clipboard top tab.

        Pre-v1.10.4 this opened the (now-retired) Sidekick window
        to its Clipboard pane. From v1.10.10 it routes to the
        in-Workbench Clipboard top tab introduced in v1.10.0.
        """
        try:
            if hasattr(self, 'open_workbench_to_clipboard'):
                self.open_workbench_to_clipboard()
            else:
                self.log("⚠ Clipboard tab not available")
        except Exception as e:
            self.log(f"❌ Error opening Clipboard tab: {e}")

    def open_quicklauncher(self):
        """Ctrl+Q / Alt+K: pop up the QuickLauncher prompt menu for the
        current segment, the same menu available from the grid right-click.

        Each configured QuickLauncher prompt is listed as a submenu offering
        Run (show response), Run and replace selection, and Copy to clipboard.
        The menu acts on the focused source/target editor, falling back to the
        current row's target editor.
        """
        from PyQt6.QtWidgets import QMenu, QMessageBox
        from PyQt6.QtGui import QCursor

        try:
            # Decide which editor the prompt should act on
            origin = QApplication.focusWidget()
            if not isinstance(origin, QTextEdit):
                origin = None
            if origin is None and hasattr(self, 'table') and self.table:
                row = self.table.currentRow()
                if row is not None and row >= 0:
                    cell = self.table.cellWidget(row, 3)  # target column
                    if isinstance(cell, QTextEdit):
                        origin = cell
            if not isinstance(origin, QTextEdit):
                QMessageBox.information(self, "QuickLauncher", "Select a segment in the grid first.")
                return

            # Gather configured QuickLauncher prompts
            quicklauncher_items = []
            if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                lib = getattr(self.prompt_manager_qt, 'library', None)
                if lib and hasattr(lib, 'get_quicklauncher_grid_prompts'):
                    quicklauncher_items = lib.get_quicklauncher_grid_prompts() or []

            menu = QMenu(self)
            if not quicklauncher_items:
                act = menu.addAction("No QuickLauncher prompts configured")
                act.setEnabled(False)
            else:
                for rel_path, label in sorted(quicklauncher_items, key=lambda x: (x[1] or x[0]).lower()):
                    prompt_menu = menu.addMenu(label or rel_path)
                    run_show = prompt_menu.addAction("▶ Run (show response)…")
                    run_show.triggered.connect(
                        lambda checked=False, p=rel_path: self.run_grid_quicklauncher_prompt(p, origin_widget=origin, behavior="show")
                    )
                    run_replace = prompt_menu.addAction("↺ Run and replace target selection")
                    run_replace.triggered.connect(
                        lambda checked=False, p=rel_path: self.run_grid_quicklauncher_prompt(p, origin_widget=origin, behavior="replace")
                    )
                    copy_clip = prompt_menu.addAction("📋 Copy to clipboard")
                    copy_clip.triggered.connect(
                        lambda checked=False, p=rel_path: self.run_grid_quicklauncher_prompt(p, origin_widget=origin, behavior="clipboard")
                    )

            menu.exec(QCursor.pos())

        except Exception as e:
            self.log(f"❌ Error opening QuickLauncher: {e}")

    def show_supervertaler_assistant(self, initial_text=None, external_mode=False):
        """Navigate to the Supervertaler Sidekick (AI Assistant tab) and optionally insert text.

        Instead of a separate floating window, this reuses the existing AI Assistant
        inside the AI tab. For external mode, it also brings Supervertaler
        to the foreground first.
        """
        try:
            # If called from external app, bring Supervertaler to front
            if external_mode:
                self.show()
                self.raise_()
                self.activateWindow()

            # Switch to Grid tab (index 0) so the right panel is visible
            if hasattr(self, 'main_tabs'):
                self.main_tabs.setCurrentIndex(0)

            # Switch the right panel to the AI Assistant tab
            if hasattr(self, 'right_tabs') and hasattr(self, '_assistant_tab_index'):
                self.right_tabs.setCurrentIndex(self._assistant_tab_index)

            # Insert text into the assistant chat input
            if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                self.prompt_manager_qt.receive_text_for_assistant(
                    initial_text or "", from_external=external_mode
                )

            print(f"[Assistant] Navigated to Supervertaler Sidekick (external={external_mode})")

        except Exception as e:
            print(f"[Assistant] Error navigating to assistant: {e}")
            import traceback
            traceback.print_exc()

    def _on_bridge_prompt_request(self, expanded: str, display_prompt: str, prompt_name: str):
        """Handle a QuickLauncher prompt forwarded by the Trados plugin.

        Connected via Qt::QueuedConnection (cross-thread emit from the
        bridge's HTTP handler thread) so this always runs on the GUI
        thread. We:

          1. Bring Workbench forward (full hammer chain – Trados is
             the source of these requests and aggressive about
             foreground retention).
          2. Switch to the ✨ AI top tab and select its 💬 Chat sub-
             tab. There are two Chat surfaces in Workbench – one in
             the right panel next to the editor (translation
             workflow), and one as a full-width sub-tab inside the
             AI tab (general AI conversation). QuickLauncher prompts
             from Trados are explicitly the *general* kind, so they
             land on the latter where the user has more screen real
             estate to read the response.
          3. Echo the *display* version of the prompt as a "user"
             message (the Trados plugin builds a redacted display
             version – e.g. "[source document – N segments]" instead
             of the full project text – so the chat doesn't get
             spammed with kilobytes of context the LLM needs but the
             user has already seen).
          4. Send the fully-expanded prompt to the LLM directly via
             ``ChatBackend.send_ai_request``. We bypass the chat
             view's ``_context_aware_send`` override because the
             Trados side has already done all the context
             substitution; running it again would prepend Workbench's
             idea of the active context, which would be wrong for a
             Trados-originated prompt.

        Pre-v1.10.4 this lived on FloatingAssistant. v1.10.10 moved
        it to the main window targeting the right-panel Chat;
        v1.10.24 retargeted at the AI tab's Chat sub-tab per user
        request – the right panel is for translating, the AI tab's
        Chat is for general conversation.

        Both Chat surfaces share the same ChatBackend, so the user
        message + LLM response appear in *both* places automatically
        – we just need to make the AI tab's Chat the *visible* one
        when the prompt arrives.
        """
        try:
            self._bring_workbench_forward()
        except Exception:
            pass

        try:
            # Switch the main top tabs to ✨ AI. The index isn't
            # stored on self, so look it up by tab text – cheap, and
            # robust if future builds shift indices.
            if hasattr(self, 'main_tabs'):
                ai_tab_index = -1
                for i in range(self.main_tabs.count()):
                    if "AI" in self.main_tabs.tabText(i) and "✨" in self.main_tabs.tabText(i):
                        ai_tab_index = i
                        break
                if ai_tab_index >= 0:
                    self.main_tabs.setCurrentIndex(ai_tab_index)

            # Switch the AI tab's inner sub-tabs to 💬 Chat. The
            # ai_subtabs property is set up in create_main_layout
            # and points at prompt_manager_qt.sub_tabs.
            ai_subtabs = getattr(self, 'ai_subtabs', None)
            if ai_subtabs is not None:
                for i in range(ai_subtabs.count()):
                    if "Chat" in ai_subtabs.tabText(i):
                        ai_subtabs.setCurrentIndex(i)
                        break
        except Exception:
            pass

        backend = getattr(
            getattr(self, 'prompt_manager_qt', None), 'chat_backend', None
        )
        if backend is None:
            self.log("⚠ Trados bridge: chat backend unavailable")
            return

        label = f"[{prompt_name}] " if prompt_name else ""
        try:
            backend.add_message("user", label + (display_prompt or expanded))
            system_prompt = "You are an AI assistant. Follow the instructions precisely."
            response, metadata = backend.send_ai_request(expanded, system_prompt)
            if response and response.strip():
                backend.add_message("assistant", response, metadata=metadata)
            else:
                backend.add_message("system", "⚠ No response received.")
        except Exception as e:
            try:
                backend.add_message("system", f"⚠ Error: {e}")
            except Exception:
                print(f"[Trados bridge] Error handling prompt: {e}")

    def show_term_insert_popup(self):
        """Show the TermLens-mirror popup for the current segment.

        Triggered by a lone Ctrl tap (press and release Ctrl with no other key).
        v1.10.87 replaced the previous memoQ-style numbered-list popup with a
        floating mirror of the docked TermLens panel — same chip rendering,
        same colours, same metadata indicators — adding keyboard cycling and
        per-chip Edit / Info shortcuts so the popup matches the Trados
        plugin's TermLensPopupForm behaviour.

        Keyboard inside the popup:
          - Right / Down / Tab  → next chip (wraps)
          - Left / Up           → previous chip
          - Enter               → insert current chip
          - 1–9                 → insert chip N directly
          - E                   → open editor for current chip
          - I                   → toggle metadata sticky popup for current chip
          - Esc                 → close

        Auto-closes on mouse movement (>4 px), focus loss, or any other key
        that isn't a pure modifier. Single-NT shortcut behaviour preserved:
        a single NT with no termbase matches inserts directly without a popup.

        v1.10.90: toggles. A second Ctrl tap while the popup is already
        on-screen closes it instead of opening a duplicate. Cleaner UX
        than the v1.10.87 behaviour, which silently dropped the second
        tap because the still-active popup intercepted the keystroke.
        """
        # ── Toggle if already visible ────────────────────────────────
        existing = getattr(self, '_termlens_popup_instance', None)
        if existing is not None:
            try:
                # The popup uses WA_DeleteOnClose — a live reference
                # means it's actually on-screen. The closeEvent clears
                # this attribute via the on_closed callback below.
                if existing.isVisible():
                    existing.close()
                    return
            except RuntimeError:
                # Stale reference (the C++ object has already been
                # destroyed); fall through and open a fresh popup.
                self._termlens_popup_instance = None

        if not self.current_project:
            return
        current_row = self.table.currentRow()
        if current_row < 0 or current_row >= len(self.current_project.segments):
            self.statusBar().showMessage("No segment selected", 2000)
            return

        segment = self.current_project.segments[current_row]
        segment_id = segment.id

        # ── Gather termbase matches from cache ────────────────────────
        with self.termbase_cache_lock:
            raw_tb = dict(self.termbase_cache.get(segment_id, {}))

        # v1.10.98 — cache-staleness fallback. After certain operations
        # (e.g. adding a synonym to a term that matches the current
        # segment) the termbase_cache entry for this segment can be
        # missing or stale even though the in-memory termbase_index
        # already reflects the new state. Falling back to a fresh
        # ``_search_termbase_in_memory`` call when the cache is empty
        # ensures the popup never opens with a misleadingly empty
        # chip set — and removes the previous "Ctrl tap auto-inserts
        # an NT because termbase_matches is empty so the smart-NT
        # path fires" footgun that prompted this fix. Reported by a
        # user: "After adding a synonym, when I press Ctrl, TermLens
        # popup is no longer shown, but a term is inserted at the
        # cursor."
        if not raw_tb:
            try:
                raw_tb = self._search_termbase_in_memory(segment.source) or {}
            except Exception:
                raw_tb = {}

        termbase_matches = []
        for match in raw_tb.values():
            if not isinstance(match, dict):
                continue
            # Pass through ALL fields the chip renderer cares about
            # (synonyms, abbreviations, metadata, forbidden/NT flags)
            # — pre-v1.10.87 we projected to a thin 6-field dict and
            # the popup chips silently lost all the metadata indicators
            # the docked panel showed. The TermLensWidget normalises
            # the field aliases (source vs source_term, translation
            # vs target_term), so we can hand it the raw dict.
            forward = dict(match)
            forward.setdefault('source_term', match.get('source', ''))
            forward.setdefault('target_term', match.get('translation', ''))
            termbase_matches.append(forward)
        # Sort: project termbase (ranking=1) first, then alphabetically
        # — same ordering the docked panel uses so the popup chips
        # appear in identical order.
        termbase_matches.sort(
            key=lambda x: (-(x.get('ranking') or 0), (x.get('source_term') or '').lower())
        )

        # ── Gather NT matches ────────────────────────────────────────
        nt_matches = self.find_nt_matches_in_source(segment.source)

        if not termbase_matches and not nt_matches:
            self.statusBar().showMessage("No terms or NTs found for this segment", 2000)
            return

        # v1.10.98 — REMOVED: the "smart single-NT" shortcut that used
        # to auto-insert the NT text when there were no termbase matches
        # and exactly one NT match. With the new TermLens-mirror popup
        # (v1.10.87+), the popup itself IS the affordance — auto-inserting
        # bypasses the user's chance to see what's on offer. Also, when
        # combined with the v1.10.x termbase cache going briefly stale
        # after a synonym add, the smart-NT path was firing for segments
        # the user actually had termbase matches on, inserting an
        # unrelated NT at the cursor. Always show the popup now;
        # single-item segments just show a popup with one chip.

        # ── Show TermLens-mirror popup ───────────────────────────────
        from modules.termlens_popup import TermLensPopup
        # Pull the user's TermLens font settings so the popup chips
        # render at the same size the docked panel does. Read from the
        # general-settings JSON via the helper that the General-tab
        # config UI also uses, so the popup automatically stays in sync
        # with whatever the user has set there.
        font_family = 'Segoe UI'
        font_size = 10
        font_bold = False
        try:
            gen = self.load_general_settings() if hasattr(self, 'load_general_settings') else {}
            if isinstance(gen, dict):
                font_family = gen.get('termlens_font_family', font_family)
                font_size = int(gen.get('termlens_font_size', font_size))
                font_bold = bool(gen.get('termlens_font_bold', font_bold))
        except Exception:
            pass
        popup = TermLensPopup(
            source_text=segment.source,
            termbase_matches=termbase_matches,
            nt_matches=nt_matches,
            host_widget=self,
            theme_manager=getattr(self, 'theme_manager', None),
            db_manager=getattr(self, 'db_manager', None),
            log_callback=self.log if hasattr(self, 'log') else None,
            font_family=font_family,
            font_size=font_size,
            font_bold=font_bold,
        )
        popup.term_inserted.connect(self.insert_termlens_text)
        # Edit (E key) closes the popup itself first, then asks the
        # main window to open the existing TermLens editor dialog.
        popup.edit_requested.connect(self._on_termlens_edit_entry)
        # Track the live popup so a second Ctrl tap can toggle it
        # closed instead of stacking. Clear the reference on close
        # so the next Ctrl tap opens a fresh one.
        self._termlens_popup_instance = popup
        def _on_popup_closed(*_):
            if getattr(self, '_termlens_popup_instance', None) is popup:
                self._termlens_popup_instance = None
        popup.destroyed.connect(_on_popup_closed)
        popup.show()
        popup.setFocus()

    def show_term_picker_dialog(self):
        """Show the TermLens Term Picker modal dialog (Ctrl+Shift+B).

        v1.10.87 — parity with the Trados TermPickerDialog. A tabular
        grid of all matches for the current segment with synonym
        expand/collapse and number-key quick-pick. Use cases:
          - Comparing many alternatives at once when several termbases
            return overlapping hits and the chip grid feels crowded.
          - Keyboard-only insertion via 0-9 when ≤9 matches (auto-
            inserts on digit press); otherwise digit selects + Enter
            inserts.
        """
        # v1.10.97 — diagnostic [Term Picker] logs removed now that the
        # Ctrl+Shift tap chord (which they were added to triage) is gone.
        if not self.current_project:
            return
        current_row = self.table.currentRow()
        if current_row < 0 or current_row >= len(self.current_project.segments):
            self.statusBar().showMessage("No segment selected", 2000)
            return

        segment = self.current_project.segments[current_row]
        segment_id = segment.id

        with self.termbase_cache_lock:
            raw_tb = dict(self.termbase_cache.get(segment_id, {}))

        # v1.10.98 — same cache-staleness fallback as show_term_insert_popup.
        if not raw_tb:
            try:
                raw_tb = self._search_termbase_in_memory(segment.source) or {}
            except Exception:
                raw_tb = {}

        termbase_matches = []
        for match in raw_tb.values():
            if not isinstance(match, dict):
                continue
            forward = dict(match)
            forward.setdefault('source_term', match.get('source', ''))
            forward.setdefault('target_term', match.get('translation', ''))
            termbase_matches.append(forward)
        termbase_matches.sort(
            key=lambda x: (-(x.get('ranking') or 0), (x.get('source_term') or '').lower())
        )
        nt_matches = self.find_nt_matches_in_source(segment.source)

        if not termbase_matches and not nt_matches:
            self.statusBar().showMessage("No terms or NTs found for this segment", 2000)
            return

        from modules.term_picker_dialog import TermPickerDialog, build_picker_matches
        # Persist size + column widths via the main settings dict if
        # we have one. Falls back to an in-process dict that resets
        # on app restart.
        if not hasattr(self, '_term_picker_settings'):
            self._term_picker_settings = {}

        rows = build_picker_matches(termbase_matches, nt_matches)
        dlg = TermPickerDialog(
            matches=rows,
            settings=self._term_picker_settings,
            parent=self,
            source_lang_label=(
                getattr(self.current_project, 'source_language', None) or 'Source'
            ),
            target_lang_label=(
                getattr(self.current_project, 'target_language', None) or 'Target'
            ),
        )
        if dlg.exec() == dlg.DialogCode.Accepted and dlg.selected_target_term:
            self.insert_termlens_text(dlg.selected_target_term)

    def show_mt_quick_popup(self, text_override: str = None):
        """Show GT4T-style MT Quick Lookup popup with translations from all enabled MT engines.

        Triggered by Ctrl+Shift+Q or right-click menu. Shows machine translation
        suggestions from all configured and enabled MT providers in a popup window.

        Args:
            text_override: Optional text to translate. If None, uses selected text
                          in the current cell, or falls back to the full source text.

        Features:
        - Displays source text at top
        - Shows numbered list of MT suggestions from each provider
        - Press 1-9 to quickly insert a translation
        - Arrow keys to navigate, Enter to insert selected
        - Escape to dismiss
        """
        try:
            # Get current segment
            current_row = self.table.currentRow()
            if current_row < 0:
                self.log("⚠️ No segment selected")
                return

            # Determine what text to translate
            text_to_translate = text_override

            if not text_to_translate:
                # Check for selected text in the currently focused widget
                focus_widget = QApplication.focusWidget()
                if focus_widget and hasattr(focus_widget, 'textCursor'):
                    cursor = focus_widget.textCursor()
                    if cursor.hasSelection():
                        text_to_translate = cursor.selectedText().strip()

            if not text_to_translate:
                # Fall back to full source text – use clean segment.source to avoid
                # invisible markers (·, →, °, ↵) being sent to MT providers
                if current_row >= 0 and current_row < len(self.current_project.segments):
                    text_to_translate = self.current_project.segments[current_row].source.strip()
                else:
                    source_widget = self.table.cellWidget(current_row, 2)
                    if not source_widget or not hasattr(source_widget, 'toPlainText'):
                        self.log("⚠️ Could not get source text")
                        return
                    text_to_translate = source_widget.toPlainText().strip()

            # v1.9.306: Strip any invisible character markers from text before translation
            if hasattr(self, 'reverse_invisible_replacements'):
                text_to_translate = self.reverse_invisible_replacements(text_to_translate)
                text_to_translate = text_to_translate.strip()

            if not text_to_translate:
                self.log("⚠️ No text to translate")
                return

            # Import and create the popup
            from modules.quicktrans import MTQuickPopup

            # Get project languages - prefer project settings over app defaults
            if self.current_project:
                source_lang = self.current_project.source_lang or 'en'
                target_lang = self.current_project.target_lang or 'nl'
            else:
                source_lang = getattr(self, 'source_language', 'en')
                target_lang = getattr(self, 'target_language', 'nl')

            # Create popup
            popup = MTQuickPopup(
                parent_app=self,
                source_text=text_to_translate,
                source_lang=source_lang,
                target_lang=target_lang,
                parent=self
            )

            # Connect signal to insert translation into target cell
            def insert_translation(translation: str):
                # Get the target widget
                target_widget = self.table.cellWidget(current_row, 3)
                if not target_widget or not hasattr(target_widget, 'toPlainText'):
                    return

                # Check if there was a selection in the target - replace just that
                focus_widget = QApplication.focusWidget()
                if focus_widget == target_widget and hasattr(focus_widget, 'textCursor'):
                    cursor = focus_widget.textCursor()
                    if cursor.hasSelection():
                        # Replace selection only
                        cursor.insertText(translation)
                        self.log(f"✅ Replaced selection with MT translation")
                    else:
                        # No selection in target, replace entire target
                        target_widget.setPlainText(translation)
                        self.log(f"✅ Inserted MT translation")
                else:
                    # Focus was elsewhere, replace entire target
                    target_widget.setPlainText(translation)
                    self.log(f"✅ Inserted MT translation")

                # Mark segment as modified
                if hasattr(self, 'segments') and current_row < len(self.segments):
                    new_target = target_widget.toPlainText()
                    # v1.9.306: Strip invisible markers before saving to segment data
                    if hasattr(self, 'reverse_invisible_replacements'):
                        new_target = self.reverse_invisible_replacements(new_target)
                    self.segments[current_row].target = new_target
                    self.mark_segment_modified(current_row)

            popup.translation_selected.connect(insert_translation)

            # Show the popup (it positions itself near cursor)
            popup.show()

        except ImportError as e:
            self.log(f"❌ MT Quick Popup module not found: {e}")
        except Exception as e:
            self.log(f"❌ Error showing MT Quick Popup: {e}")

    def refresh_shortcut_enabled_states(self):
        """Refresh enabled/disabled states and key bindings of all global shortcuts from shortcut manager.
        
        When disabled: clears the key sequence to fully release the key combination.
        When enabled: restores the key sequence from the manager (custom or default).
        """
        if not hasattr(self, 'global_shortcuts'):
            return
        for shortcut_id, shortcut in self.global_shortcuts.items():
            is_enabled = self.shortcut_manager.is_enabled(shortcut_id)
            if is_enabled:
                # Get the current key sequence from manager (may have been customized)
                key_sequence = self.shortcut_manager.get_shortcut(shortcut_id)
                if key_sequence:
                    self.global_shortcut_keys[shortcut_id] = key_sequence
                    shortcut.setKey(QKeySequence(key_sequence))
            else:
                # Clear the key to release the combination for other uses
                shortcut.setKey(QKeySequence())

    def _setup_progress_indicators(self):
        """Setup permanent progress indicator widgets in the status bar"""
        from PyQt6.QtWidgets import QLabel, QFrame

        # Create a container frame for all progress info
        progress_frame = QFrame()
        progress_layout = QHBoxLayout(progress_frame)
        progress_layout.setContentsMargins(0, 0, 10, 0)
        progress_layout.setSpacing(20)

        # LLM Provider/Model indicator (leftmost in the permanent area)
        self.llm_indicator_label = QLabel("")
        self.llm_indicator_label.setStyleSheet("color: #888; font-size: 11px;")
        self.llm_indicator_label.setToolTip("Current LLM provider and model")
        progress_layout.addWidget(self.llm_indicator_label)
        
        # Update LLM indicator on startup
        self._update_llm_indicator()

        # Words translated label
        self.progress_words_label = QLabel("Words: --")
        self.progress_words_label.setStyleSheet("color: #555; font-size: 11px;")
        self.progress_words_label.setToolTip("Words with translation / Total words (percentage)")
        progress_layout.addWidget(self.progress_words_label)

        # Segments confirmed label
        self.progress_confirmed_label = QLabel("Confirmed: --")
        self.progress_confirmed_label.setStyleSheet("color: #555; font-size: 11px;")
        self.progress_confirmed_label.setToolTip("Confirmed segments / Total segments (percentage)")
        progress_layout.addWidget(self.progress_confirmed_label)

        # Remaining segments label
        self.progress_remaining_label = QLabel("Remaining: --")
        self.progress_remaining_label.setStyleSheet("color: #555; font-size: 11px;")
        self.progress_remaining_label.setToolTip("Segments still requiring work")
        progress_layout.addWidget(self.progress_remaining_label)
        
        # Files indicator (for multi-file projects)
        self.progress_files_label = QLabel("")
        self.progress_files_label.setStyleSheet("color: #2196F3; font-size: 11px;")
        self.progress_files_label.setToolTip("Files in multi-file project (click for details)")
        self.progress_files_label.setCursor(Qt.CursorShape.PointingHandCursor)
        self.progress_files_label.mousePressEvent = lambda e: self.show_project_info_dialog(initial_tab=1)
        self.progress_files_label.hide()  # Hidden by default, shown for multi-file projects
        progress_layout.addWidget(self.progress_files_label)
        
        # Always-on voice indicator
        self.alwayson_indicator_label = QLabel("")
        self.alwayson_indicator_label.setStyleSheet("font-size: 11px; font-weight: bold;")
        self.alwayson_indicator_label.setToolTip("Always-on voice listening status\nClick to toggle")
        self.alwayson_indicator_label.setCursor(Qt.CursorShape.PointingHandCursor)
        self.alwayson_indicator_label.mousePressEvent = lambda e: self._toggle_alwayson_from_statusbar()
        self.alwayson_indicator_label.hide()  # Hidden until enabled
        progress_layout.addWidget(self.alwayson_indicator_label)

        # Add as permanent widget (stays on right side)
        self.status_bar.addPermanentWidget(progress_frame)
        
        # Initialize Ollama keep-warm timer
        self.ollama_keepwarm_timer = None
        self._setup_ollama_keepwarm()
    
    def _resolve_provider_model(self, settings, provider, default='gpt-5.5'):
        """Resolve the model name for a provider.

        For ``custom_openai`` the active profile's model is the single source
        of truth (it drives the API call's base_url too). The flat
        ``custom_openai_model`` field is legacy and goes stale as soon as a
        user keeps several named profiles or switches the active one without
        re-saving AI Settings – which is exactly what produced the
        "status bar says X, QuickTrans says Y, and the model can't be
        changed" inconsistency. Always prefer the active profile's model so
        every surface (status bar, QuickTrans panel, the actual request)
        agrees.
        """
        model = settings.get(f'{provider}_model', default)
        if provider == 'custom_openai':
            profile = self._get_active_custom_profile(settings)
            if profile and (profile.get('model') or '').strip():
                model = profile.get('model').strip()
        return model

    def _update_llm_indicator(self):
        """Update the LLM provider/model indicator in the status bar"""
        try:
            settings = self.load_llm_settings()
            provider = settings.get('provider', 'openai')
            model = self._resolve_provider_model(settings, provider, 'gpt-5.5')
            
            # Format nicely
            if provider == 'ollama':
                icon = "🖥️"
                display = f"{icon} Local: {model}"
            elif provider == 'openai':
                icon = "🤖"
                display = f"{icon} {model}"
            elif provider == 'claude':
                icon = "🟣"
                display = f"{icon} {model}"
            elif provider == 'gemini':
                icon = "💎"
                display = f"{icon} {model}"
            elif provider == 'mistral':
                icon = "🌀"
                display = f"{icon} {model}"
            elif provider == 'deepseek':
                icon = "🐋"
                display = f"{icon} {model}"
            elif provider == 'openrouter':
                icon = "🌐"
                display = f"{icon} {model}"
            elif provider == 'custom_openai':
                icon = "🔌"
                profile = self._get_active_custom_profile(settings)
                profile_name = profile.get('name', '') if profile else ''
                if profile_name:
                    display = f"{icon} {profile_name}: {model}"
                else:
                    display = f"{icon} Custom: {model}"
            else:
                display = f"{provider}: {model}"
            
            if hasattr(self, 'llm_indicator_label'):
                self.llm_indicator_label.setText(display)
                self.llm_indicator_label.setToolTip(f"LLM Provider: {provider.title()}\nModel: {model}")
        except Exception as e:
            if hasattr(self, 'llm_indicator_label'):
                self.llm_indicator_label.setText("")
    
    def _setup_ollama_keepwarm(self):
        """Setup timer to keep Ollama model warm (loaded in memory)"""
        from PyQt6.QtCore import QTimer
        
        # Check settings to see if keep-warm is enabled
        general_settings = self.load_general_settings()
        keepwarm_enabled = general_settings.get('ollama_keepwarm', False)
        
        if keepwarm_enabled:
            self._start_ollama_keepwarm_timer()
    
    def _start_ollama_keepwarm_timer(self):
        """Start the Ollama keep-warm timer (pings every 4 minutes)"""
        from PyQt6.QtCore import QTimer
        
        if self.ollama_keepwarm_timer is None:
            self.ollama_keepwarm_timer = QTimer(self)
            self.ollama_keepwarm_timer.timeout.connect(self._ping_ollama_keepwarm)
        
        # Ping every 4 minutes (Ollama unloads after 5 min of inactivity)
        self.ollama_keepwarm_timer.start(4 * 60 * 1000)  # 4 minutes in ms
        self.log("🔥 Ollama keep-warm enabled (pinging every 4 minutes)")
    
    def _stop_ollama_keepwarm_timer(self):
        """Stop the Ollama keep-warm timer"""
        if self.ollama_keepwarm_timer:
            self.ollama_keepwarm_timer.stop()
            self.log("❄️ Ollama keep-warm disabled")
    
    def _ping_ollama_keepwarm(self):
        """Send a minimal request to Ollama to keep the model loaded"""
        import threading
        
        def ping():
            try:
                import requests
                settings = self.load_llm_settings()
                if settings.get('provider') != 'ollama':
                    return
                
                model = settings.get('ollama_model', 'translategemma:12b')
                endpoint = os.environ.get('OLLAMA_ENDPOINT', 'http://localhost:11434').rstrip('/')
                for _sfx in ('/api', '/v1'):
                    if endpoint.endswith(_sfx):
                        endpoint = endpoint[:-len(_sfx)]

                # Send minimal request to keep model warm
                response = requests.post(
                    f"{endpoint}/api/generate",
                    json={
                        "model": model,
                        "prompt": "hi",
                        "options": {"num_predict": 1}  # Generate just 1 token
                    },
                    timeout=30
                )
                if response.status_code == 200:
                    print(f"🔥 Ollama keep-warm ping successful ({model})")
            except Exception as e:
                print(f"⚠️ Ollama keep-warm ping failed: {e}")
        
        # Run in background thread to not block UI
        threading.Thread(target=ping, daemon=True).start()

    def update_progress_stats(self):
        """Update the progress indicator labels in the status bar"""
        try:
            if not self.current_project or not self.current_project.segments:
                self.progress_words_label.setText("Words: --")
                self.progress_confirmed_label.setText("Confirmed: --")
                self.progress_remaining_label.setText("Remaining: --")
                if hasattr(self, 'progress_files_label'):
                    self.progress_files_label.hide()
                return

            segments = self.current_project.segments
            total_segments = len(segments)

            # Count words in source text for each segment
            total_words = 0
            translated_words = 0
            confirmed_count = 0
            remaining_count = 0

            # Statuses that indicate "done" (confirmed or higher)
            confirmed_statuses = {'confirmed', 'proofread', 'approved'}
            # Statuses that need work
            unfinished_statuses = {'not_started', 'pretranslated', 'rejected', 'tm_100', 'tm_fuzzy'}

            for segment in segments:
                # Count words in source (simple split on whitespace)
                source_words = len(segment.source.split()) if segment.source else 0
                total_words += source_words

                # If segment has a non-empty translation, count those words as translated
                if segment.target and segment.target.strip():
                    translated_words += source_words

                # Count confirmed (or higher status)
                if segment.status in confirmed_statuses:
                    confirmed_count += 1

                # Count remaining (not_started, pretranslated, or rejected)
                if segment.status in unfinished_statuses:
                    remaining_count += 1

            # Calculate percentages
            word_percent = (translated_words / total_words * 100) if total_words > 0 else 0
            confirmed_percent = (confirmed_count / total_segments * 100) if total_segments > 0 else 0

            # Update labels with color-coding based on progress
            word_color = self._get_progress_color(word_percent)
            confirmed_color = self._get_progress_color(confirmed_percent)
            remaining_color = "#c00" if remaining_count > 0 else "#080"

            self.progress_words_label.setText(f"Words: {translated_words}/{total_words} ({word_percent:.0f}%)")
            self.progress_words_label.setStyleSheet(f"color: {word_color}; font-size: 11px;")

            self.progress_confirmed_label.setText(f"Confirmed: {confirmed_count}/{total_segments} ({confirmed_percent:.0f}%)")
            self.progress_confirmed_label.setStyleSheet(f"color: {confirmed_color}; font-size: 11px;")

            self.progress_remaining_label.setText(f"Remaining: {remaining_count}")
            self.progress_remaining_label.setStyleSheet(f"color: {remaining_color}; font-size: 11px;")
            
            # Multi-file project indicator
            if hasattr(self, 'progress_files_label'):
                is_multifile = getattr(self.current_project, 'is_multifile', False)
                files = getattr(self.current_project, 'files', [])
                
                if is_multifile and files:
                    # Count completed files
                    completed_files = 0
                    for file_info in files:
                        file_id = file_info['id']
                        file_segs = [s for s in segments if getattr(s, 'file_id', None) == file_id]
                        if file_segs:
                            conf_count = sum(1 for s in file_segs if s.status in confirmed_statuses)
                            if conf_count == len(file_segs):
                                completed_files += 1
                    
                    self.progress_files_label.setText(f"📁 Files: {completed_files}/{len(files)}")
                    self.progress_files_label.setToolTip(
                        f"Multi-file project: {len(files)} files\n"
                        f"Completed: {completed_files} files\n\n"
                        "Click for detailed file progress"
                    )
                    self.progress_files_label.show()
                else:
                    self.progress_files_label.hide()

        except Exception as e:
            self.log(f"⚠️ Error updating progress stats: {e}")

    def _get_progress_color(self, percent: float) -> str:
        """Get color for progress percentage: red < 50%, orange < 80%, green >= 80%"""
        if percent < 50:
            return "#c00"  # Red
        elif percent < 80:
            return "#c60"  # Orange
        else:
            return "#080"  # Green

    def create_menus(self):
        """Create application menus"""
        menubar = self.menuBar()
        
        # File Menu
        file_menu = menubar.addMenu("&File")
        
        new_action = QAction("&New Project", self)
        new_action.setShortcut(QKeySequence.StandardKey.New)
        new_action.triggered.connect(self.new_project)
        file_menu.addAction(new_action)
        
        open_action = QAction("&Open Project...", self)
        open_action.setShortcut(QKeySequence.StandardKey.Open)
        open_action.triggered.connect(self.open_project)
        file_menu.addAction(open_action)
        
        # Recent projects submenu
        self.recent_menu = file_menu.addMenu("Open &Recent")
        self.update_recent_menu()
        
        file_menu.addSeparator()
        
        save_action = QAction("&Save", self)
        save_action.setShortcut(QKeySequence.StandardKey.Save)
        save_action.triggered.connect(self.save_project)
        file_menu.addAction(save_action)
        
        save_as_action = QAction("Save &As...", self)
        # No keyboard shortcut - Ctrl+Shift+S is used for Copy Source to Target in editor
        save_as_action.triggered.connect(self.save_project_as)
        file_menu.addAction(save_as_action)
        
        file_menu.addSeparator()
        
        close_action = QAction("&Close Project", self)
        close_action.triggered.connect(self.close_project)
        file_menu.addAction(close_action)
        
        file_menu.addSeparator()
        
        # Import/Export submenu
        import_menu = file_menu.addMenu("&Import")
        
        # v1.10.53: collapsed the previous two-item split (DOCX vs "Other
        # format via Okapi") into one. Every supported format goes through
        # the Okapi sidecar regardless – the split was a stale UX leftover
        # from when there used to be two engines. The file picker accepts
        # DOCX, IDML, HTML, XLIFF, PO, XLSX, and PPTX with an "All
        # supported formats" default filter.
        import_document_action = QAction("&Import Document...", self)
        import_document_action.setToolTip(
            "Import a Word document (DOCX), Adobe InDesign markup (IDML), "
            "HTML, XLIFF, PO, Excel (XLSX), or PowerPoint (PPTX). Extracted "
            "via the Okapi sidecar; round-trips back to the original format "
            "on export."
        )
        import_document_action.triggered.connect(self.import_document)
        import_document_action.setShortcut("Ctrl+O")
        import_menu.addAction(import_document_action)

        import_txt_action = QAction("&Text / Markdown File (TXT, MD)...", self)
        import_txt_action.triggered.connect(self.import_simple_txt)
        import_menu.addAction(import_txt_action)
        
        import_menu.addSeparator()
        
        # Multi-file folder import
        import_folder_action = QAction("📁 &Folder (Multiple Files)...", self)
        import_folder_action.triggered.connect(self.import_folder_multifile)
        import_menu.addAction(import_folder_action)
        
        import_menu.addSeparator()  # Separate monolingual from bilingual tools
        
        # memoQ submenu - group all memoQ imports together
        memoq_submenu = import_menu.addMenu("&memoQ")

        import_memoq_action = QAction("Bilingual Table (&DOCX)...", self)
        import_memoq_action.triggered.connect(self.import_memoq_bilingual)
        memoq_submenu.addAction(import_memoq_action)

        import_memoq_rtf_action = QAction("Bilingual Table (&RTF)...", self)
        import_memoq_rtf_action.triggered.connect(self.import_memoq_rtf)
        memoq_submenu.addAction(import_memoq_rtf_action)

        import_memoq_xliff_action = QAction("&XLIFF (.mqxliff)...", self)
        import_memoq_xliff_action.triggered.connect(self.import_memoq_xliff)
        memoq_submenu.addAction(import_memoq_xliff_action)
        
        import_cafetran_action = QAction("&CafeTran Bilingual Table (DOCX)...", self)
        import_cafetran_action.triggered.connect(self.import_cafetran_bilingual)
        import_menu.addAction(import_cafetran_action)
        
        # Trados submenu - group all Trados imports together
        trados_submenu = import_menu.addMenu("&Trados Studio")

        import_trados_bilingual_action = QAction("Bilingual &Review (DOCX)...", self)
        import_trados_bilingual_action.triggered.connect(self.import_trados_bilingual)
        trados_submenu.addAction(import_trados_bilingual_action)

        import_sdlxliff_action = QAction("Bilingual &XLIFF (.sdlxliff)...", self)
        import_sdlxliff_action.triggered.connect(self.import_standalone_sdlxliff)
        trados_submenu.addAction(import_sdlxliff_action)

        import_sdlxliff_folder_action = QAction("Bilingual XLIFF &Folder (.sdlxliff)...", self)
        import_sdlxliff_folder_action.triggered.connect(self.import_sdlxliff_folder)
        trados_submenu.addAction(import_sdlxliff_folder_action)

        import_sdlppx_action = QAction("&Package (SDLPPX)...", self)
        import_sdlppx_action.triggered.connect(self.import_sdlppx_package)
        trados_submenu.addAction(import_sdlppx_action)

        # Phrase (Memsource) import
        import_phrase_bilingual_action = QAction("&Phrase (Memsource) Bilingual (DOCX)...", self)
        import_phrase_bilingual_action.triggered.connect(self.import_phrase_bilingual)
        import_menu.addAction(import_phrase_bilingual_action)

        # Déjà Vu X3 import
        import_dejavu_action = QAction("&Déjà Vu X3 Bilingual (RTF)...", self)
        import_dejavu_action.triggered.connect(self.import_dejavu_bilingual)
        import_menu.addAction(import_dejavu_action)

        import_menu.addSeparator()
        
        import_review_table_action = QAction("&Bilingual Table (DOCX) - Update Project...", self)
        import_review_table_action.triggered.connect(self.import_review_table)
        import_menu.addAction(import_review_table_action)

        # Help link at the foot of the Import submenu. Imports happen straight
        # from a menu (no dialog), so the standard set_help_topic "?" badge
        # has nowhere to live – this entry is the closest substitute.
        import_menu.addSeparator()
        import_help_action = QAction("❓ Supported file formats (online help)...", self)
        import_help_action.setToolTip("Open the Supported File Formats reference in your browser")
        import_help_action.triggered.connect(lambda: open_help(HelpTopics.IMPORT_FORMATS))
        import_menu.addAction(import_help_action)

        export_menu = file_menu.addMenu("&Export")

        # v1.10.53: collapsed the previous two-item split ("Target Only
        # (DOCX)" vs "Original format via Okapi") into one. The dispatcher
        # picks the right back-end based on the project's import format –
        # DOCX/unknown-origin projects go through the DOCX exporter (which
        # is itself Okapi-aware with a python-docx fallback); non-DOCX
        # Okapi-origin projects (IDML, HTML, …) round-trip via the Okapi
        # merge endpoint. Output file extension defaults to the original's.
        export_document_action = QAction("&Export Translated Document...", self)
        export_document_action.setToolTip(
            "Export the translation back to a document. Output format is "
            "the same as the original source (DOCX → DOCX, IDML → IDML, "
            "etc.). Uses the Okapi sidecar to preserve formatting."
        )
        export_document_action.triggered.connect(self.export_document)
        export_menu.addAction(export_document_action)

        export_txt_action = QAction("Simple &Text File - Translated (TXT)...", self)
        export_txt_action.triggered.connect(self.export_simple_txt)
        export_menu.addAction(export_txt_action)

        export_ai_action = QAction("📄 &AI-Readable Markdown (.md)...", self)
        export_ai_action.triggered.connect(self.export_bilingual_table_markdown)
        export_ai_action.setToolTip("Export segments in [SEGMENT] format for AI translation/review")
        export_menu.addAction(export_ai_action)

        export_menu.addSeparator()

        # memoQ submenu - group all memoQ exports together
        memoq_export_submenu = export_menu.addMenu("&memoQ")

        export_memoq_action = QAction("Bilingual Table - Translated (&DOCX)...", self)
        export_memoq_action.triggered.connect(self.export_memoq_bilingual)
        memoq_export_submenu.addAction(export_memoq_action)

        export_memoq_rtf_action = QAction("Bilingual Table - Translated (&RTF)...", self)
        export_memoq_rtf_action.triggered.connect(self.export_memoq_rtf)
        memoq_export_submenu.addAction(export_memoq_rtf_action)

        export_memoq_xliff_action = QAction("&XLIFF - Translated (.mqxliff)...", self)
        export_memoq_xliff_action.triggered.connect(self.export_memoq_xliff)
        memoq_export_submenu.addAction(export_memoq_xliff_action)

        export_cafetran_action = QAction("&CafeTran Bilingual Table - Translated (DOCX)...", self)
        export_cafetran_action.triggered.connect(self.export_cafetran_bilingual)
        export_menu.addAction(export_cafetran_action)

        # Trados submenu - group all Trados exports together
        trados_export_submenu = export_menu.addMenu("&Trados Studio")

        export_trados_bilingual_action = QAction("Bilingual &Review - Translated (DOCX)...", self)
        export_trados_bilingual_action.triggered.connect(self.export_trados_bilingual)
        trados_export_submenu.addAction(export_trados_bilingual_action)

        export_sdlxliff_action = QAction("Bilingual &XLIFF - Translated (.sdlxliff)...", self)
        export_sdlxliff_action.triggered.connect(self.export_standalone_sdlxliff)
        trados_export_submenu.addAction(export_sdlxliff_action)

        export_sdlrpx_action = QAction("Return &Package (SDLRPX)...", self)
        export_sdlrpx_action.triggered.connect(self.export_sdlrpx_package)
        trados_export_submenu.addAction(export_sdlrpx_action)

        # Phrase (Memsource) export
        export_phrase_bilingual_action = QAction("&Phrase (Memsource) Bilingual - Translated (DOCX)...", self)
        export_phrase_bilingual_action.triggered.connect(self.export_phrase_bilingual)
        export_menu.addAction(export_phrase_bilingual_action)

        # Déjà Vu X3 export
        export_dejavu_action = QAction("&Déjà Vu X3 Bilingual - Translated (RTF)...", self)
        export_dejavu_action.triggered.connect(self.export_dejavu_bilingual)
        export_menu.addAction(export_dejavu_action)

        export_menu.addSeparator()
        
        # Multi-file folder export
        export_folder_action = QAction("📁 &Folder (Multiple Files)...", self)
        export_folder_action.triggered.connect(self.export_folder_multifile)
        export_folder_action.setToolTip("Export multi-file project to folder with separate files")
        export_menu.addAction(export_folder_action)
        
        export_menu.addSeparator()
        
        # Relocate source folder for multi-file projects
        relocate_source_action = QAction("🔗 &Relocate Source Folder...", self)
        relocate_source_action.triggered.connect(self.relocate_source_folder)
        relocate_source_action.setToolTip("Repoint to moved/renamed source folder for multi-file project")
        export_menu.addAction(relocate_source_action)
        
        export_menu.addSeparator()
        
        # Supervertaler Bilingual Table exports
        export_review_table_action = QAction("Supervertaler Bilingual Table - With &Tags (DOCX)...", self)
        export_review_table_action.triggered.connect(self.export_review_table_with_tags)
        export_menu.addAction(export_review_table_action)
        
        export_review_table_formatted_action = QAction("Supervertaler Bilingual Table - &Formatted (DOCX)...", self)
        export_review_table_formatted_action.triggered.connect(self.export_review_table_formatted)
        export_menu.addAction(export_review_table_formatted_action)
        
        export_menu.addSeparator()
        
        export_grid_action = QAction("TMX from &Grid (all segments)...", self)
        export_grid_action.triggered.connect(self.export_tmx_from_grid)
        export_menu.addAction(export_grid_action)
        
        export_selected_action = QAction("TMX from &Selected Segments...", self)
        export_selected_action.triggered.connect(self.export_tmx_from_selected)
        export_menu.addAction(export_selected_action)
        
        export_tm_action = QAction("TMX from &TM(s) for Current Project...", self)
        export_tm_action.triggered.connect(self.export_tmx_from_tm_database)
        export_menu.addAction(export_tm_action)

        # Help link at the foot of the Export submenu (mirrors Import).
        export_menu.addSeparator()
        export_help_action = QAction("❓ Supported file formats (online help)...", self)
        export_help_action.setToolTip("Open the Supported File Formats reference in your browser")
        export_help_action.triggered.connect(lambda: open_help(HelpTopics.IMPORT_FORMATS))
        export_menu.addAction(export_help_action)

        file_menu.addSeparator()
        
        # Project Info
        project_info_action = QAction("📋 Project &Info...", self)
        project_info_action.triggered.connect(self.show_project_info_dialog)
        file_menu.addAction(project_info_action)
        
        file_menu.addSeparator()
        
        exit_action = QAction("E&xit", self)
        exit_action.setShortcut(QKeySequence.StandardKey.Quit)
        exit_action.triggered.connect(self.close)
        file_menu.addAction(exit_action)
        
        # Edit Menu
        edit_menu = menubar.addMenu("&Edit")
        
        self.undo_action = QAction("&Undo", self)
        self.undo_action.setShortcut(QKeySequence.StandardKey.Undo)
        self.undo_action.triggered.connect(self.undo_action_handler)
        self.undo_action.setEnabled(False)
        edit_menu.addAction(self.undo_action)
        
        self.redo_action = QAction("&Redo", self)
        self.redo_action.setShortcut(QKeySequence.StandardKey.Redo)
        self.redo_action.triggered.connect(self.redo_action_handler)
        self.redo_action.setEnabled(False)
        edit_menu.addAction(self.redo_action)
        
        edit_menu.addSeparator()
        
        find_action = QAction("&Find...", self)
        find_action.setShortcut(QKeySequence.StandardKey.Find)
        find_action.triggered.connect(self.show_find_replace_dialog)
        edit_menu.addAction(find_action)
        
        replace_action = QAction("&Replace...", self)
        replace_action.setShortcut(QKeySequence.StandardKey.Replace)
        replace_action.triggered.connect(self.show_find_replace_dialog)
        edit_menu.addAction(replace_action)
        
        edit_menu.addSeparator()
        
        goto_action = QAction(f"&Go to Segment...\t{format_shortcut_for_display('Ctrl+G')}", self)
        goto_action.triggered.connect(self.show_goto_dialog)
        edit_menu.addAction(goto_action)
        
        edit_menu.addSeparator()
        
        translate_action = QAction("&Translate Segment", self)
        translate_action.setShortcut("Ctrl+T")
        translate_action.triggered.connect(self.translate_current_segment)
        edit_menu.addAction(translate_action)

        translate_menu = edit_menu.addMenu("Batch &Translate")

        translate_selected_not_started_action = QAction("Translate selected not-started segments", self)
        translate_selected_not_started_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("selected_not_started")
        )
        translate_menu.addAction(translate_selected_not_started_action)

        translate_all_not_started_action = QAction("Translate all not-started segments", self)
        translate_all_not_started_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_not_started")
        )
        translate_menu.addAction(translate_all_not_started_action)

        translate_all_pretranslated_action = QAction("Translate all pre-translated segments", self)
        translate_all_pretranslated_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_pretranslated")
        )
        translate_menu.addAction(translate_all_pretranslated_action)

        translate_pending_action = QAction("Translate all not-started & pre-translated", self)
        translate_pending_action.setShortcut("Ctrl+Shift+T")
        translate_pending_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_not_started_pretranslated")
        )
        translate_menu.addAction(translate_pending_action)

        translate_translatable_action = QAction("Translate all translatable segments", self)
        translate_translatable_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_translatable")
        )
        translate_menu.addAction(translate_translatable_action)

        translate_all_segments_action = QAction("Translate all segments (all statuses)", self)
        translate_all_segments_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_segments")
        )
        translate_menu.addAction(translate_all_segments_action)
        
        translate_menu.addSeparator()
        
        # NEW: Translate only empty segments (for re-running failed batches)
        translate_empty_action = QAction("Translate all empty segments", self)
        translate_empty_action.setToolTip("Translate segments with empty target (useful after partial batch translation)")
        translate_empty_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("all_empty")
        )
        translate_menu.addAction(translate_empty_action)
        
        translate_filtered_action = QAction("Translate all filtered segments", self)
        translate_filtered_action.setToolTip("Translate only segments currently visible after filtering")
        translate_filtered_action.triggered.connect(
            lambda checked=False: self.translate_multiple_segments("filtered_segments")
        )
        translate_menu.addAction(translate_filtered_action)
        
        edit_menu.addSeparator()
        
        # Bulk Operations submenu
        self.bulk_menu = edit_menu.addMenu("Bulk &Operations")
        edit_menu.aboutToShow.connect(self._update_bulk_menu_label)
        bulk_menu = self.bulk_menu

        confirm_selected_action = QAction("✅ &Confirm Selected Segments", self)
        confirm_selected_action.setShortcut("Ctrl+Shift+Return")
        confirm_selected_action.setToolTip(
            f"Confirm all selected segments ({format_shortcut_for_display('Ctrl+Shift+Enter')})"
        )
        confirm_selected_action.triggered.connect(self.confirm_selected_segments_from_menu)
        bulk_menu.addAction(confirm_selected_action)

        # Change Status submenu
        status_submenu = bulk_menu.addMenu("🏷️ Change &Status")
        user_statuses = [
            ("not_started", "❌ &Not started"),
            ("pretranslated", "🤖 &Pre-translated"),
            ("draft", "✏️ &Draft"),
            ("confirmed", "✔ &Confirmed"),
            ("proofread", "🟪 Proo&fread"),
            ("approved", "⭐ &Approved"),
            ("rejected", "🚫 Re&jected"),
        ]
        for status_key, label in user_statuses:
            action = QAction(label, self)
            action.triggered.connect(lambda checked, s=status_key: self.change_status_selected(s, from_menu=True))
            status_submenu.addAction(action)

        clear_translations_action = QAction("🗑️ &Clear Translations", self)
        clear_translations_action.setToolTip("Clear translations for selected segments")
        clear_translations_action.triggered.connect(self.clear_selected_translations_from_menu)
        bulk_menu.addAction(clear_translations_action)
        
        copy_source_to_target_action = QAction("📋 Copy &Source to Target", self)
        copy_source_to_target_action.setToolTip("Copy source text to target for selected/filtered segments")
        copy_source_to_target_action.triggered.connect(self.copy_source_to_target_bulk)
        bulk_menu.addAction(copy_source_to_target_action)

        copy_nontrans_action = QAction("🔢 Copy Source to Target (&No Letters)", self)
        copy_nontrans_action.setToolTip("Copy source to target for segments containing no letters (numbers, codes, punctuation only) and mark as Translated")
        copy_nontrans_action.triggered.connect(self.copy_source_to_target_non_translatable_bulk)
        bulk_menu.addAction(copy_nontrans_action)

        send_to_tm_action = QAction("💾 &Send Segments to TM...", self)
        send_to_tm_action.setToolTip("Send confirmed segments to a writable Translation Memory")
        send_to_tm_action.triggered.connect(self.send_segments_to_tm_dialog)
        bulk_menu.addAction(send_to_tm_action)
        
        bulk_menu.addSeparator()
        
        clean_tags_action = QAction("🧹 Clean &Tags...", self)
        clean_tags_action.setToolTip("Remove formatting tags from selected segments")
        clean_tags_action.triggered.connect(self.show_clean_tags_dialog)
        bulk_menu.addAction(clean_tags_action)
        
        proofread_action = QAction("✅ &Proofread Translation...", self)
        proofread_action.setToolTip("Use AI to proofread and verify translation quality")
        proofread_action.triggered.connect(self.show_proofread_dialog)
        bulk_menu.addAction(proofread_action)

        edit_menu.addSeparator()
        
        # Superlookup
        superlookup_action = QAction("🔍 &SuperLookup...", self)
        if IS_MACOS:
            superlookup_action.setShortcut("Meta+Ctrl+L")  # Ctrl+Cmd+L on Mac
        else:
            superlookup_action.setShortcut("Ctrl+Alt+L")
        # Tab indices: Grid=0, Project resources=1, Tools=2, Settings=3
        superlookup_action.triggered.connect(lambda: self._go_to_superlookup() if hasattr(self, 'main_tabs') else None)  # Navigate to Superlookup
        edit_menu.addAction(superlookup_action)
        
        # View Menu
        view_menu = menubar.addMenu("&View")
        
        # Navigation submenu
        nav_menu = view_menu.addMenu("📑 &Navigate To")
        
        go_editor_action = QAction("📝 &Grid", self)
        go_editor_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(0) if hasattr(self, 'main_tabs') else None)
        nav_menu.addAction(go_editor_action)

        go_tms_action = QAction("💾 &TMs", self)
        go_tms_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(1) if hasattr(self, 'main_tabs') else None)
        nav_menu.addAction(go_tms_action)

        go_termbases_action = QAction("🏷️ Term&bases", self)
        go_termbases_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(2) if hasattr(self, 'main_tabs') else None)
        nav_menu.addAction(go_termbases_action)

        go_prompt_manager_action = QAction("⚡ &QuickLauncher", self)
        go_prompt_manager_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(3) if hasattr(self, 'main_tabs') else None)
        nav_menu.addAction(go_prompt_manager_action)

        # Tools-tab navigation entry retired in v1.9.467: PDF Rescue and TMX
        # Editor now open in their own windows from the Tools menu instead.

        go_settings_action = QAction("⚙️ &Settings", self)
        go_settings_action.triggered.connect(lambda: self.main_tabs.setCurrentIndex(4) if hasattr(self, 'main_tabs') else None)
        nav_menu.addAction(go_settings_action)
        
        view_menu.addSeparator()
        
        # Grid Text section
        grid_zoom_menu = view_menu.addMenu("📊 &Grid Text Zoom")
        
        grid_zoom_in = QAction("Grid Zoom &In", self)
        grid_zoom_in.setShortcut("Ctrl+=")
        grid_zoom_in.triggered.connect(self.zoom_in)
        grid_zoom_menu.addAction(grid_zoom_in)
        
        grid_zoom_out = QAction("Grid Zoom &Out", self)
        grid_zoom_out.setShortcut(QKeySequence.StandardKey.ZoomOut)
        grid_zoom_out.triggered.connect(self.zoom_out)
        grid_zoom_menu.addAction(grid_zoom_out)

        # Note: Do not add separate Ctrl++/Ctrl+- actions here.
        # QKeySequence.StandardKey.ZoomIn/ZoomOut already maps to these keys on many layouts,
        # and duplicates cause: "QAction::event: Ambiguous shortcut overload".
        grid_zoom_menu.addSeparator()
        
        grid_font_family_menu = grid_zoom_menu.addMenu("Grid Font &Family")
        font_families = ["Calibri", "Segoe UI", "Arial", "Consolas", "Verdana", 
                        "Times New Roman", "Georgia", "Courier New"]
        for font_name in font_families:
            font_action = QAction(font_name, self)
            font_action.triggered.connect(lambda checked, f=font_name: self.set_font_family(f))
            grid_font_family_menu.addAction(font_action)
        
        view_menu.addSeparator()

        # Match Panel zoom section
        match_panel_zoom_menu = view_menu.addMenu("🔍 &Match Panel")
        
        match_panel_zoom_in_action = QAction("Match Panel Zoom &In", self)
        match_panel_zoom_in_action.setShortcut("Ctrl+Alt+=")
        match_panel_zoom_in_action.triggered.connect(self.match_panel_zoom_in)
        match_panel_zoom_menu.addAction(match_panel_zoom_in_action)
        
        match_panel_zoom_out_action = QAction("Match Panel Zoom &Out", self)
        match_panel_zoom_out_action.setShortcut("Ctrl+Alt+-")
        match_panel_zoom_out_action.triggered.connect(self.match_panel_zoom_out)
        match_panel_zoom_menu.addAction(match_panel_zoom_out_action)
        
        match_panel_zoom_reset_action = QAction("Match Panel Zoom &Reset", self)
        match_panel_zoom_reset_action.triggered.connect(self.match_panel_zoom_reset)
        match_panel_zoom_menu.addAction(match_panel_zoom_reset_action)

        match_panel_zoom_menu.addSeparator()

        # v1.9.307: TM pane layout toggle (horizontal ↔ vertical)
        self.match_panel_vertical_action = QAction("Stack TM Panes &Vertically", self)
        self.match_panel_vertical_action.setCheckable(True)
        self.match_panel_vertical_action.setChecked(getattr(self, 'match_panel_tm_vertical', True))
        self.match_panel_vertical_action.triggered.connect(lambda _checked: self._toggle_match_panel_tm_layout())
        match_panel_zoom_menu.addAction(self.match_panel_vertical_action)

        view_menu.addSeparator()
        
        # Under-grid panel placement. The dock holds the TermLens and
        # QuickTrans tabs; this submenu controls only WHERE the dock sits
        # (the tab bar in the dock chooses which of the two is shown).
        from PyQt6.QtGui import QActionGroup
        underdock_menu = view_menu.addMenu("📑 TermLens / QuickTrans panel")
        self.underdock_action_group = QActionGroup(self)
        self.underdock_action_group.setExclusive(True)

        self.underdock_hide_action = QAction("Hide", self)
        self.underdock_hide_action.setCheckable(True)
        self.underdock_hide_action.setToolTip("Hide the under-grid panel")
        self.underdock_hide_action.triggered.connect(lambda: self.set_termlens_position(None))

        self.underdock_above_action = QAction("Show above grid", self)
        self.underdock_above_action.setCheckable(True)
        self.underdock_above_action.setToolTip("Dock the panel above the translation grid")
        self.underdock_above_action.triggered.connect(lambda: self.set_termlens_position('above'))

        self.underdock_below_action = QAction("Show below grid", self)
        self.underdock_below_action.setCheckable(True)
        self.underdock_below_action.setToolTip("Dock the panel below the translation grid")
        self.underdock_below_action.triggered.connect(lambda: self.set_termlens_position('below'))

        for _a in (self.underdock_hide_action, self.underdock_above_action, self.underdock_below_action):
            self.underdock_action_group.addAction(_a)
            underdock_menu.addAction(_a)
        self.underdock_hide_action.setChecked(True)

        view_menu.addSeparator()

        auto_resize_action = QAction("📐 &Auto-Resize Rows", self)
        auto_resize_action.triggered.connect(self.auto_resize_rows)
        auto_resize_action.setToolTip("Automatically resize all rows to fit content")
        view_menu.addAction(auto_resize_action)

        view_menu.addSeparator()

        # Proofreading results
        proofread_results_action = QAction("✅ &Proofreading Results...", self)
        proofread_results_action.triggered.connect(self.show_proofreading_results_dialog)
        proofread_results_action.setToolTip("View and manage proofreading issues")
        view_menu.addAction(proofread_results_action)

        view_menu.addSeparator()

        theme_action = QAction("🎨 &Theme Editor...", self)
        theme_action.triggered.connect(self.show_theme_editor)
        view_menu.addAction(theme_action)

        # Tools Menu
        tools_menu = menubar.addMenu("&Tools")
        
        # Tools (open in their own windows; no embedded Tools tab anymore)
        pdf_rescue_action = QAction("📄 &PDF Rescue...", self)
        pdf_rescue_action.triggered.connect(self.open_pdf_rescue_window)
        tools_menu.addAction(pdf_rescue_action)

        superlookup_action = QAction(f"🔍 Super&lookup ({format_shortcut_for_display('Ctrl+K')})...", self)
        # Note: Actual Ctrl+K shortcut handled by QShortcut in setup_global_shortcuts()
        # which calls show_concordance_search() for proper selection capture
        superlookup_action.triggered.connect(self.show_concordance_search)
        tools_menu.addAction(superlookup_action)

        tmx_editor_action = QAction("✏️ T&MX Editor...", self)
        tmx_editor_action.triggered.connect(self.open_tmx_editor_window)
        tools_menu.addAction(tmx_editor_action)
        
        tools_menu.addSeparator()
        
        image_extractor_action = QAction("🖼️ &Image Extractor (Superimage)...", self)
        image_extractor_action.triggered.connect(self.show_image_extractor_from_tools)
        image_extractor_action.setToolTip("Extract images from DOCX files")
        tools_menu.addAction(image_extractor_action)
        
        scratchpad_action = QAction("📝 Scratch&pad...", self)
        scratchpad_action.setShortcut("Ctrl+Shift+P")
        scratchpad_action.triggered.connect(self.show_scratchpad)
        scratchpad_action.setToolTip("Private notes for this project (never exported to CAT tools)")
        tools_menu.addAction(scratchpad_action)

        log_window_action = QAction("📋 &Log Window...", self)
        log_window_action.triggered.connect(self.detach_log_window)
        log_window_action.setToolTip("Open the session log in a separate window that can be moved to another screen")
        tools_menu.addAction(log_window_action)

        tools_menu.addSeparator()

        settings_action = QAction("&Settings...", self)
        settings_action.triggered.connect(lambda: self._go_to_settings_tab())
        tools_menu.addAction(settings_action)
        
        # Help Menu
        help_menu = menubar.addMenu("&Help")

        # Documentation links (GitHub URLs for universal access)
        # Removed internal manual link – documentation migrated to GitBook

        # Context-sensitive help (F1)
        context_help_action = QAction("Context Help", self)
        context_help_action.setShortcut("F1")
        context_help_action.setToolTip("Open help for the focused panel (F1)")
        context_help_action.triggered.connect(lambda: open_help())
        help_menu.addAction(context_help_action)

        # Place Supervertaler Help at the top of the Help menu
        superdocs_action = QAction("Supervertaler Workbench Help", self)
        superdocs_action.setToolTip("Open the Workbench documentation in your browser")
        superdocs_action.triggered.connect(lambda: self._open_url("https://help.supervertaler.com/workbench/"))
        help_menu.addAction(superdocs_action)

        setup_wizard_action = QAction("🚀 Setup Wizard...", self)
        setup_wizard_action.setToolTip("Run the initial setup wizard (data folder location, features overview)")
        setup_wizard_action.triggered.connect(lambda: self._show_setup_wizard(is_first_run=False))
        help_menu.addAction(setup_wizard_action)

        help_menu.addSeparator()

        shortcuts_action = QAction("⌨️ Keyboard Shortcuts", self)
        shortcuts_action.triggered.connect(lambda: open_help(HelpTopics.KEYBOARD_SHORTCUTS))
        help_menu.addAction(shortcuts_action)

        changelog_action = QAction("📝 Changelog", self)
        changelog_action.triggered.connect(lambda: self._open_url("https://github.com/Supervertaler/Supervertaler-Workbench/blob/main/CHANGELOG.md"))
        help_menu.addAction(changelog_action)

        update_check_action = QAction("🔄 Check for Updates...", self)
        update_check_action.setToolTip("Check whether you are running the latest Supervertaler release")
        update_check_action.triggered.connect(self.check_for_updates)
        help_menu.addAction(update_check_action)

        copy_version_info_action = QAction("📋 Copy Version Info", self)
        copy_version_info_action.setToolTip("Copy version and system info to clipboard (useful for support)")
        copy_version_info_action.triggered.connect(self.copy_version_info_to_clipboard)
        help_menu.addAction(copy_version_info_action)

        # Diagnostic log – so users can send us error output even when they
        # launched Supervertaler without a terminal window (silent / .exe /
        # gui-scripts entry point).
        open_log_action = QAction("📄 Open Diagnostic Log", self)
        open_log_action.setToolTip(
            "Open the log file containing startup output and errors – useful when reporting issues"
        )
        open_log_action.triggered.connect(self.open_diagnostic_log)
        help_menu.addAction(open_log_action)

        open_log_folder_action = QAction("📁 Open Log Folder", self)
        open_log_folder_action.setToolTip("Reveal the folder that contains the diagnostic log files")
        open_log_folder_action.triggered.connect(self.open_diagnostic_log_folder)
        help_menu.addAction(open_log_folder_action)

        help_menu.addSeparator()

        github_action = QAction("🔗 GitHub Repository", self)
        github_action.triggered.connect(lambda: self._open_url("https://github.com/Supervertaler/Supervertaler-Workbench"))
        help_menu.addAction(github_action)

        help_menu.addSeparator()
        
        # AutoHotkey setup (Windows only)
        if os.name == 'nt':
            ahk_setup_action = QAction("⌨️ Setup AutoHotkey (Global Hotkey)", self)
            ahk_setup_action.setToolTip(
                f"Configure AutoHotkey for SuperLookup global hotkey ({format_shortcut_for_display('Ctrl+Alt+L')})"
            )
            ahk_setup_action.triggered.connect(self._show_ahk_setup_from_menu)
            help_menu.addAction(ahk_setup_action)
            help_menu.addSeparator()

        about_action = QAction("ℹ️ About", self)
        about_action.triggered.connect(self.show_about)
        help_menu.addAction(about_action)
    
    def record_undo_state(self, segment_id, old_target, new_target, old_status, new_status):
        """Record an undo state when grid cells are edited"""
        # Don't record if nothing actually changed
        if old_target == new_target and old_status == new_status:
            return
        
        # Add to undo stack
        undo_entry = {
            "segment_id": segment_id,
            "old_target": old_target,
            "new_target": new_target,
            "old_status": old_status,
            "new_status": new_status
        }
        self.undo_stack.append(undo_entry)
        
        # Trim undo stack to max levels
        if len(self.undo_stack) > self.max_undo_levels:
            self.undo_stack.pop(0)
        
        # Clear redo stack (can't redo after new edit)
        self.redo_stack.clear()
        
        # Update menu actions
        self.update_undo_redo_actions()
    
    def undo_action_handler(self):
        """Handle Undo (Ctrl+Z) action"""
        if not self.undo_stack:
            return
        
        # Pop last action from undo stack
        action = self.undo_stack.pop()
        
        # Find the segment
        segment_id = action["segment_id"]
        segment = None
        for seg in self.current_project.segments:
            if seg.segment_id == segment_id:
                segment = seg
                break
        
        if not segment:
            return
        
        # Revert to old values
        segment.target_text = action["old_target"]
        segment.status = action["old_status"]
        
        # Update grid display
        row = self.find_grid_row_by_segment_id(segment_id)
        if row is not None:
            # Update target text cell
            target_item = self.grid.item(row, 2)
            if target_item:
                target_item.setText(action["old_target"])
            
            # Update status cell
            status_item = self.grid.item(row, 3)
            if status_item:
                status_item.setText(action["old_status"])
        
        # Move action to redo stack
        self.redo_stack.append(action)
        
        # Update menu actions
        self.update_undo_redo_actions()
    
    def redo_action_handler(self):
        """Handle Redo (Ctrl+Shift+Z / Ctrl+Y) action"""
        if not self.redo_stack:
            return
        
        # Pop last action from redo stack
        action = self.redo_stack.pop()
        
        # Find the segment
        segment_id = action["segment_id"]
        segment = None
        for seg in self.current_project.segments:
            if seg.segment_id == segment_id:
                segment = seg
                break
        
        if not segment:
            return
        
        # Reapply new values
        segment.target_text = action["new_target"]
        segment.status = action["new_status"]
        
        # Update grid display
        row = self.find_grid_row_by_segment_id(segment_id)
        if row is not None:
            # Update target text cell
            target_item = self.grid.item(row, 2)
            if target_item:
                target_item.setText(action["new_target"])
            
            # Update status cell
            status_item = self.grid.item(row, 3)
            if status_item:
                status_item.setText(action["new_status"])
        
        # Move action back to undo stack
        self.undo_stack.append(action)
        
        # Update menu actions
        self.update_undo_redo_actions()
    
    def update_undo_redo_actions(self):
        """Update enabled/disabled state of undo/redo menu actions"""
        self.undo_action.setEnabled(len(self.undo_stack) > 0)
        self.redo_action.setEnabled(len(self.redo_stack) > 0)
    
    def find_grid_row_by_segment_id(self, segment_id):
        """Find the grid row index for a given segment ID"""
        for row in range(self.grid.rowCount()):
            id_item = self.grid.item(row, 0)
            if id_item and id_item.text() == str(segment_id):
                return row
        return None
    
    def create_quick_access_toolbar(self):
        """Create Quick Access Toolbar above ribbon"""
        from PyQt6.QtWidgets import QToolBar, QWidget, QHBoxLayout
        from PyQt6.QtCore import QSize
        
        qat = QToolBar("Quick Access")
        qat.setMovable(False)
        qat.setFloatable(False)
        qat.setIconSize(QSize(20, 20))
        qat.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
        qat.setMaximumHeight(28)
        qat.setMinimumWidth(170)  # Fixed width to align ribbon tabs
        qat.setMaximumWidth(170)
        
        # Styling for compact appearance
        qat.setStyleSheet("""
            QToolBar {
                background: transparent;
                border: none;
                spacing: 2px;
                padding: 2px;
            }
            QToolButton {
                background: transparent;
                border: 1px solid transparent;
                border-radius: 3px;
                padding: 2px;
                margin: 0px;
                font-size: 14pt;
            }
            QToolButton:hover {
                background: rgba(255, 255, 255, 0.1);
                border: 1px solid rgba(255, 255, 255, 0.2);
            }
            QToolButton:pressed {
                background: rgba(0, 0, 0, 0.1);
            }
        """)
        
        # Empty toolbar - all buttons removed, minimize moved to ribbon
        # Add empty separator just to maintain spacing
        qat.addSeparator()
        
        return qat
    
    def create_ribbon(self):
        """
        Create modern ribbon interface - DISABLED
        Ribbon functionality has been moved to traditional menu bar.
        This code is kept for potential future use.
        """
        # Ribbon removed - all actions are now in the menu bar
        # Keeping this method for potential future re-enablement
        return
        
        # from modules.ribbon_widget import RibbonWidget, RibbonTab, RibbonGroup, RibbonButton
        # 
        # # Create ribbon widget
        # self.ribbon = RibbonWidget(self)
        # 
        # # Connect ribbon actions to methods
        # self.ribbon.action_triggered.connect(self.handle_ribbon_action)
        
        # Ribbon code commented out - all functionality moved to menu bar
        # HOME TAB - Project management and navigation
        # home_tab = RibbonTab()
        # project_group = RibbonGroup("Project")
        # project_group.add_button(self.ribbon.create_button("New", "📄", "new", "New project (Ctrl+N)"))
        # ... (all ribbon tab creation code commented out)
        # All actions are now available in the traditional menu bar
    
    def create_ribbon_toolbar(self):
        """Create a toolbar to hold the ribbon - DISABLED"""
        # Ribbon removed - using traditional menu bar instead
        # This method kept for potential future use
        return None
    
    def toggle_ribbon_minimized(self, minimized: bool):
        """Toggle ribbon between full and minimized (tabs-only) mode - DISABLED"""
        # Ribbon removed - no action needed
        self.ribbon_minimized = minimized  # Store state for backwards compatibility
        pass
    
    def show_ribbon_temporarily(self, index: int):
        """Show ribbon temporarily when tab clicked in minimized mode - DISABLED"""
        # Ribbon removed - no action needed
        pass
    
    def on_main_tab_changed(self, index: int):
        """Handle main tab change"""
        # Ribbon removed - menu bar remains constant across all tabs
        # No context switching needed
        pass
    
    def create_main_layout(self):
        """Create main application layout with all-tab interface"""
        # Central widget
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        
        # Main layout
        main_layout = QVBoxLayout(central_widget)
        main_layout.setContentsMargins(5, 0, 5, 5)  # Reduced top margin to minimize space below menu bar
        
        # ===== SIMPLIFIED TAB-BASED UI =====
        # Single tab widget with all functionality
        from modules.unified_prompt_manager_qt import UnifiedPromptManagerQt
        
        # Create main tab widget
        self.main_tabs = QTabWidget()
        self.main_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.main_tabs.tabBar().setDrawBase(False)
        self.main_tabs.tabBar().setExpanding(False)
        self.main_tabs.setStyleSheet("""
            QTabBar::tab { padding: 8px 15px; outline: 0; }
            QTabBar::tab:focus { outline: none; }
            QTabBar::tab:selected { 
                border-bottom: 1px solid #2196F3; 
                background-color: rgba(33, 150, 243, 0.08);
            }
        """)
        
        # ===== 1. GRID TAB =====
        # Contains the translation grid
        grid_widget = self.create_grid_view_widget_for_home()
        self.main_tabs.addTab(grid_widget, "📝 Editor")
        
        # ===== 2. TMs TAB =====
        # Promoted to a top-level tab in v1.9.422 (was previously nested
        # under "Resources" together with Termbases). Exposing TMs and
        # Termbases as siblings of Editor / AI / Tools / Settings makes
        # them reachable in one click instead of two and gives each one
        # a permanent slot on the tab bar.
        tm_tab_top = self.create_translation_memories_tab()
        set_help_topic(tm_tab_top, HelpTopics.TM_BASICS)
        self.main_tabs.addTab(tm_tab_top, "💾 TMs")

        # ===== 3. TERMBASES TAB =====
        # Promoted to a top-level tab in v1.9.422 (see TMs comment above).
        termbase_tab_top = self.create_termbases_tab()
        set_help_topic(termbase_tab_top, HelpTopics.GLOSSARY_BASICS)
        self.main_tabs.addTab(termbase_tab_top, "🏷️ Termbases")

        # Backward-compat: a few helpers (Sidekick "Go to glossary",
        # external nav code) still expect self.resources_tabs to exist.
        # The old wrapper QTabWidget is gone, so point this attribute at
        # main_tabs itself; callers that did
        # resources_tabs.setCurrentIndex(<index of Termbases inside the
        # old wrapper>) need updating to navigate via main_tabs directly.
        self.resources_tabs = self.main_tabs
        
        # ===== 3. AI TAB =====
        # Houses everything that exists purely for the AI translation flow:
        # the Prompt Manager, Variables reference, Assistant chat view, and
        # Image Context (figures the AI sees with each segment). Image
        # Context lived under Resources before v1.9.395 – moved here in
        # v1.9.395 because it isn't a translation aid like TMs/termbases;
        # it's an AI input and only the AI flow ever reads it.
        # UnifiedPromptManagerQt builds its own inner QTabWidget with
        # Prompt Manager / Variables / Assistant; rather than wrapping that
        # in another outer tab strip (which produced an awkward
        # double-"Prompt Manager" label in v1.9.395), we add the Image
        # Context pane as a fourth peer tab on the inner strip directly.
        from modules.unified_prompt_manager_qt import UnifiedPromptManagerQt
        prompt_widget = QWidget()
        self.prompt_manager_qt = UnifiedPromptManagerQt(self, standalone=False)
        self.prompt_manager_qt.create_tab(prompt_widget)
        set_help_topic(prompt_widget, HelpTopics.AI_PROMPT_MANAGER)

        # Add Image Context as a peer to the existing inner sub-tabs.
        # Exposed as self.ai_subtabs so navigation helpers can target it.
        if hasattr(self.prompt_manager_qt, 'sub_tabs'):
            self.ai_subtabs = self.prompt_manager_qt.sub_tabs
            image_context_widget = self.create_reference_images_tab()
            self.ai_subtabs.addTab(image_context_widget, "🎯 Image Context")
        else:
            self.ai_subtabs = None

        self.main_tabs.addTab(prompt_widget, "✨ AI")

        # Add AI Assistant to the right panel at position 1 (right after Match Panel)
        if hasattr(self, 'right_tabs') and hasattr(self.prompt_manager_qt, 'assistant_tab'):
            self.right_tabs.insertTab(1, self.prompt_manager_qt.assistant_tab, "💬 Chat")
            self._assistant_tab_index = 1
            # Bump stored indices that were shifted by the insert
            if hasattr(self, '_preview_tab_index'):
                self._preview_tab_index += 1

        # Sidekick (Floating Assistant) was retired in v1.10.4 and its
        # four tabs now live in Workbench itself:
        #   Chat        → right_tabs "💬 Chat"
        #   Clipboard   → main_tabs "📋 Clipboard Manager"
        #   Voice       → main_tabs "🎤 Voice"
        #   SuperLookup → main_tabs "🔍 SuperLookup"
        # v1.10.10 finished the cleanup: floating_assistant.py is
        # deleted and no code path references _floating_assistant
        # any more.

        # Sidekick Bridge server – inverse of the Trados-side bridge.
        # Lets the Trados plugin POST a QuickLauncher prompt here for
        # the Workbench Chat panel to run. The class / file / wire
        # paths keep the "Sidekick" name because Core/SidekickBridge.cs
        # on the Trados side looks up the handshake by that name; only
        # the consumer (here) changed from FloatingAssistant to the
        # in-Workbench Chat tab when Sidekick was retired. See
        # modules/sidekick_bridge_server.py for the wire format.
        self._bridge_server = None
        try:
            from modules.sidekick_bridge_server import SidekickBridgeServer
            self._bridge_server = SidekickBridgeServer(self)
            self._bridge_server.run_prompt_requested.connect(
                self._on_bridge_prompt_request
            )
            self._bridge_server.start()
            QApplication.instance().aboutToQuit.connect(self._bridge_server.stop)
        except Exception as e:
            self.log(f"⚠ Trados bridge server failed to start: {e}")

        # Keep backward compatibility reference
        self.document_views_widget = self.main_tabs
        
        # The "Tools" main tab was retired in v1.9.467: PDF Rescue and TMX Editor
        # now open in their own windows from the Tools menu, so the embedded
        # tab no longer pulls its weight. SuperlookupTab still needs to be
        # constructed though – its __init__ registers the global Ctrl+Alt+L /
        # Ctrl+Alt+Q / Alt+K hotkeys – so do that here as a side-effect.
        self._setup_superlookup_hotkeys()

        # ============================================================
        # NEW TOP-LEVEL TABS (Phase 1 of the Sidekick → Workbench
        # migration, issue #199). SuperLookup, Clipboard and Voice
        # are added here as Workbench top tabs so the user has a
        # single-window app instead of toggling between Workbench and
        # the always-on-top Sidekick.
        #
        # Lazy construction: each tab starts as an empty placeholder
        # QWidget. The first time the user activates the tab,
        # _on_main_tab_changed calls _ensure_*_top_tab which builds
        # the real widget into the placeholder. This keeps cold start
        # fast – SuperLookup in particular brings up TM / termbase /
        # MT / web-resource state and is too heavy to construct
        # eagerly on every launch.
        #
        # During Phases 1-3 Sidekick continues to exist with its own
        # copies of these widgets. Both surfaces work in parallel.
        # Phase 4 retires Sidekick and removes the duplication. The
        # hotkey-side-effect SuperLookup instance from
        # _setup_superlookup_hotkeys above stays during this phase –
        # it's what registers the global hotkeys; the visible top tab
        # is a separate instance.
        # ============================================================
        self._superlookup_top_widget = None
        self._clipboard_top_widget = None
        self._voice_top_widget = None

        from PyQt6.QtWidgets import QWidget as _QW
        superlookup_placeholder = _QW()
        self.main_tabs.addTab(superlookup_placeholder, "🔍 SuperLookup")
        self.superlookup_tab_index = self.main_tabs.count() - 1

        clipboard_placeholder = _QW()
        self.main_tabs.addTab(clipboard_placeholder, "📋 Clipboard Manager")
        self.clipboard_tab_index = self.main_tabs.count() - 1

        voice_placeholder = _QW()
        self.main_tabs.addTab(voice_placeholder, "🎤 Voice")
        self.voice_tab_index = self.main_tabs.count() - 1

        # 4. SETTINGS
        settings_tab = self.create_settings_tab()
        self.main_tabs.addTab(settings_tab, "⚙️ Settings")
        self.settings_tab_index = self.main_tabs.count() - 1
        
        # Set startup tab to Grid (index 0)
        self.main_tabs.setCurrentIndex(0)

        main_layout.addWidget(self.main_tabs)

        # v1.10.17: Esc-to-tray on the "quick lookup" top tabs.
        # When the user is on SuperLookup / Clipboard / Voice –
        # which they typically reach via global hotkeys and treat as
        # popup utilities rather than full-screen work surfaces –
        # pressing Esc hides Workbench to the system tray, matching
        # the dismissal flow that retired Sidekick used to have.
        # On Editor / TMs / Termbases / AI / Settings the binding
        # is a no-op so Esc retains its natural editor / dialog /
        # combo-box behaviour. Implementation is a single
        # window-level QShortcut with a current-tab check at fire
        # time, rather than per-tab WidgetShortcut bindings, so the
        # binding survives lazy tab construction (the tabs are
        # placeholder widgets at this point – the real widgets are
        # built on first activation by _ensure_*_top_tab).
        try:
            from PyQt6.QtGui import QShortcut, QKeySequence
            self._esc_to_tray_shortcut = QShortcut(
                QKeySequence(Qt.Key.Key_Escape), self
            )
            self._esc_to_tray_shortcut.activated.connect(
                self._on_esc_quick_lookup_dismiss
            )
        except Exception as e:
            print(f"[Workbench] Esc-to-tray binding failed: {e}")

        # Connect tab changes to handle view refreshes
        self.main_tabs.currentChanged.connect(self._on_main_tab_changed)

        # Warm up the new top tabs ~2.5 s after launch (Phase 1 + 2 of
        # issue #199). They're lazily constructed on first activation,
        # but the first-press race – global hotkey fires → lazy build
        # takes ~1 s → Workbench foreground-grab arrives too late to
        # win against the source app – means the *very* first
        # Ctrl+Alt+L / Ctrl+Alt+C after startup can silently fall on
        # the floor. Pre-warming a few seconds after the main window
        # paints sidesteps this without paying the cost at startup.
        # 2 500 ms gives the window time to paint, the Okapi sidecar
        # to hand over, and the database to settle – same value
        # Sidekick uses for its own lazy-tab warm-up.
        try:
            QTimer.singleShot(2500, self._warm_up_top_tabs)
        except Exception:
            pass

    
    def _create_placeholder_tab(self, title: str, description: str) -> QWidget:
        """Create a simple placeholder tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        
        header = QLabel(title)
        header.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 10px;")
        layout.addWidget(header)
        
        placeholder = QLabel(description)
        placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
        placeholder.setStyleSheet("color: #888; font-size: 12px;")
        layout.addWidget(placeholder, stretch=1)
        
        return tab
    
    def create_prompt_manager_tab(self) -> QWidget:
        """Create the AI tab (Prompt Manager, Supervertaler Sidekick, Variables)"""
        from modules.unified_prompt_manager_qt import UnifiedPromptManagerQt
        
        # Create Unified Prompt Manager widget (embedded mode, not standalone)
        prompt_widget = QWidget()
        self.prompt_manager_qt = UnifiedPromptManagerQt(self, standalone=False)
        self.prompt_manager_qt.create_tab(prompt_widget)
        set_help_topic(prompt_widget, HelpTopics.AI_PROMPT_MANAGER)

        return prompt_widget
    
    
    def open_tmx_editor_window(self):
        """Open the TMX Editor in a standalone window.

        Reuses any existing window if one is already open instead of stacking
        multiple instances on every menu click.
        """
        from PyQt6.QtCore import Qt
        from modules.tmx_editor_qt import TmxEditorUIQt

        existing = getattr(self, '_tmx_editor_window', None)
        if existing is not None:
            try:
                existing.show()
                existing.raise_()
                existing.activateWindow()
                return
            except RuntimeError:
                # Window was destroyed without our destroyed-signal callback
                # firing (rare). Fall through and create a new one.
                self._tmx_editor_window = None

        window = QWidget()
        window.setWindowFlags(Qt.WindowType.Window)
        window.setWindowTitle("📝 TMX Editor – Supervertaler")
        window.resize(1200, 800)

        layout = QVBoxLayout(window)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(5)

        header = QLabel("📝 TMX Editor")
        header.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1976D2;")
        layout.addWidget(header, 0)

        description = QLabel(
            "Edit translation memory files directly - inspired by Heartsome TMX Editor.\n"
            "Open, edit, filter, and manage your TMX translation memories."
        )
        description.setWordWrap(True)
        description.setStyleSheet(
            "color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;"
        )
        layout.addWidget(description, 0)

        # Pass the shared database manager so the window can access TMs.
        tmx_editor = TmxEditorUIQt(parent=window, standalone=False, db_manager=self.db_manager)
        tmx_editor.translator_name = self.get_translator_name()
        layout.addWidget(tmx_editor, 1)
        self.tmx_editor_embedded = tmx_editor

        self._tmx_editor_window = window
        # Drop the reference when the window closes so a subsequent click on
        # Tools → TMX Editor opens a fresh instance instead of trying to
        # reuse a deleted Qt object.
        window.destroyed.connect(lambda *a: setattr(self, '_tmx_editor_window', None))
        window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        window.show()
    
    def create_reference_images_tab(self) -> QWidget:
        """Create the Image Context tab - Load images as visual context for AI translation"""
        from modules.image_extractor import ImageExtractor
        
        tab = QWidget()
        main_layout = QVBoxLayout(tab)
        main_layout.setContentsMargins(10, 10, 10, 10)
        main_layout.setSpacing(5)
        
        # === IMAGE CONTEXT SECTION (TOP) ===
        context_group = QGroupBox("🎯 Image Context - Load Images for AI Translation")
        context_layout = QVBoxLayout()
        
        # Description
        context_desc = QLabel(
            "Load figure images to automatically include with AI translations when text references them (e.g., 'Figure 1', 'see fig 2A').\n"
            "The AI will 'see' the images and better translate technical descriptions and part references."
        )
        context_desc.setWordWrap(True)
        context_desc.setStyleSheet("font-size: 10px; padding: 5px; border-radius: 3px;")
        context_layout.addWidget(context_desc)
        
        # Context controls row
        context_controls = QHBoxLayout()
        
        load_context_btn = QPushButton("📁 Load Images Folder")
        load_context_btn.setStyleSheet("""
            QPushButton {
                background-color: #4CAF50;
                color: white;
                font-weight: bold;
                padding: 8px 16px;
                border-radius: 3px;
                border: none;
                outline: none;
            }
            QPushButton:hover {
                background-color: #45a049;
            }
            QPushButton:focus {
                outline: none;
                border: none;
            }
        """)
        load_context_btn.clicked.connect(self._on_load_image_context_folder)
        context_controls.addWidget(load_context_btn)
        
        clear_context_btn = QPushButton("🗑️ Clear")
        clear_context_btn.clicked.connect(self._on_clear_image_context)
        clear_context_btn.setMaximumWidth(80)
        context_controls.addWidget(clear_context_btn)
        
        # Status label
        self.image_context_status_label = QLabel("No images loaded")
        self.image_context_status_label.setStyleSheet("color: #999; font-size: 11px; padding: 5px;")
        context_controls.addWidget(self.image_context_status_label, 1)
        
        context_layout.addLayout(context_controls)
        context_group.setLayout(context_layout)
        main_layout.addWidget(context_group)
        
        # === IMAGE EXTRACTOR SECTION (BOTTOM) ===
        extractor_group = QGroupBox("🛠️ Image Extractor - Extract Images from DOCX Files")
        extractor_layout = QVBoxLayout()
        
        # Compact header with title and extract button in one row
        header_layout = QHBoxLayout()
        
        title = QLabel("Extract images to save them for later use as context")
        title.setStyleSheet("font-size: 14px; font-weight: bold; color: #2c3e50;")
        header_layout.addWidget(title)
        
        header_layout.addStretch()
        
        # Extract button (moved to top)
        extract_btn = QPushButton("🖼️ Extract Images")
        extract_btn.setStyleSheet("""
            QPushButton {
                background-color: #3498db;
                color: white;
                font-weight: bold;
                font-size: 12px;
                padding: 6px 12px;
                border-radius: 3px;
            }
            QPushButton:hover {
                background-color: #2980b9;
            }
            QPushButton:focus {
                outline: none;
            }
        """)
        extract_btn.clicked.connect(self._on_extract_images)
        header_layout.addWidget(extract_btn)
        
        main_layout.addLayout(header_layout)
        
        # Compact controls in a single row
        controls_layout = QHBoxLayout()
        
        # Input files
        add_file_btn = QPushButton("📄 Add File")
        add_file_btn.clicked.connect(self._on_add_docx_file_for_extraction)
        add_file_btn.setMaximumWidth(100)
        controls_layout.addWidget(add_file_btn)
        
        add_folder_btn = QPushButton("📁 Folder")
        add_folder_btn.clicked.connect(self._on_add_docx_folder_for_extraction)
        add_folder_btn.setMaximumWidth(80)
        controls_layout.addWidget(add_folder_btn)
        
        clear_list_btn = QPushButton("🗑️")
        clear_list_btn.clicked.connect(lambda: self.image_extractor_file_list.clear())
        clear_list_btn.setMaximumWidth(40)
        clear_list_btn.setToolTip("Clear file list")
        controls_layout.addWidget(clear_list_btn)
        
        # File list (compact, inline)
        self.image_extractor_file_list = QListWidget()
        self.image_extractor_file_list.setMaximumHeight(60)
        self.image_extractor_file_list.setStyleSheet("font-size: 9px;")
        controls_layout.addWidget(self.image_extractor_file_list, 1)
        
        # Auto-folder checkbox
        self.image_extractor_auto_folder = CheckmarkCheckBox("📁 Auto-folder")
        self.image_extractor_auto_folder.setChecked(False)
        self.image_extractor_auto_folder.setToolTip("Create 'Images' folder next to each DOCX file")
        self.image_extractor_auto_folder.toggled.connect(self._on_auto_folder_toggled)
        controls_layout.addWidget(self.image_extractor_auto_folder)
        
        # Output directory
        self.image_extractor_output_dir = QLineEdit()
        self.image_extractor_output_dir.setPlaceholderText("Output directory...")
        self.image_extractor_output_dir.setMaximumWidth(200)
        controls_layout.addWidget(self.image_extractor_output_dir)
        
        browse_btn = QPushButton("Browse...")
        browse_btn.clicked.connect(self._on_browse_output_dir_for_extraction)
        browse_btn.setMaximumWidth(80)
        controls_layout.addWidget(browse_btn)
        
        # Filename prefix
        controls_layout.addWidget(QLabel("Prefix:"))
        self.image_extractor_prefix = QLineEdit("Fig.")
        self.image_extractor_prefix.setMaximumWidth(60)
        controls_layout.addWidget(self.image_extractor_prefix)
        
        main_layout.addLayout(controls_layout)
        
        # Main horizontal splitter (left: operations, right: preview)
        results_splitter = QSplitter(Qt.Orientation.Horizontal)
        
        # Left panel: Status and file list
        left_widget = QWidget()
        left_layout = QVBoxLayout(left_widget)
        left_layout.setContentsMargins(0, 0, 0, 0)
        
        status_label = QLabel("📋 Results")
        status_label.setStyleSheet("font-weight: bold; font-size: 9px; color: #666;")
        left_layout.addWidget(status_label)
        
        # Status text area (resizable)
        self.image_extractor_status = QTextEdit()
        self.image_extractor_status.setReadOnly(True)
        self.image_extractor_status.setMinimumHeight(50)
        self.image_extractor_status.setStyleSheet("font-size: 9px;")
        self.image_extractor_status.setPlaceholderText("Extraction status...")
        left_layout.addWidget(self.image_extractor_status)
        
        # Extracted files list
        files_label = QLabel("📂 Extracted Files (click to preview)")
        files_label.setStyleSheet("font-weight: bold; font-size: 9px; color: #666; margin-top: 3px;")
        left_layout.addWidget(files_label)
        
        self.image_extractor_files_list = QListWidget()
        self.image_extractor_files_list.itemClicked.connect(self._on_file_list_item_clicked)
        self.image_extractor_files_list.setStyleSheet("""
            QListWidget {
                border: 1px solid #ccc;
                font-size: 9px;
            }
            QListWidget::item {
                padding: 3px;
                border-bottom: 1px solid #eee;
            }
            QListWidget::item:selected {
                background-color: #e3f2fd;
                color: black;
            }
        """)
        left_layout.addWidget(self.image_extractor_files_list)
        
        results_splitter.addWidget(left_widget)
        
        # Right panel: Full-height image preview
        preview_widget = QWidget()
        preview_layout = QVBoxLayout(preview_widget)
        preview_layout.setContentsMargins(0, 0, 0, 0)
        
        preview_label = QLabel("🖼️ Image Preview")
        preview_label.setStyleSheet("font-weight: bold; font-size: 9px; color: #666;")
        preview_layout.addWidget(preview_label)
        
        # Large image display area with scroll
        preview_scroll = QScrollArea()
        preview_scroll.setWidgetResizable(True)
        preview_scroll.setStyleSheet("QScrollArea { border: 1px solid #ccc; }")
        preview_scroll.setMinimumWidth(300)
        
        self.image_extractor_preview = QLabel()
        self.image_extractor_preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.image_extractor_preview.setStyleSheet("padding: 10px;")
        self.image_extractor_preview.setText("No image selected\n\nClick on a file in the list to preview\nor\nExtract images to see them here")
        self.image_extractor_preview.setWordWrap(True)
        
        preview_scroll.setWidget(self.image_extractor_preview)
        preview_layout.addWidget(preview_scroll)
        
        # Preview navigation buttons at bottom
        nav_layout = QHBoxLayout()
        
        self.preview_prev_btn = QPushButton("◀ Previous")
        self.preview_prev_btn.clicked.connect(self._on_preview_prev)
        self.preview_prev_btn.setEnabled(False)
        nav_layout.addWidget(self.preview_prev_btn)
        
        self.preview_image_label = QLabel("No images")
        self.preview_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.preview_image_label.setStyleSheet("font-size: 8px; color: #666;")
        nav_layout.addWidget(self.preview_image_label)
        
        self.preview_next_btn = QPushButton("Next ▶")
        self.preview_next_btn.clicked.connect(self._on_preview_next)
        self.preview_next_btn.setEnabled(False)
        nav_layout.addWidget(self.preview_next_btn)
        
        preview_layout.addLayout(nav_layout)
        
        results_splitter.addWidget(preview_widget)
        
        # Set initial splitter sizes (40% left, 60% right)
        results_splitter.setSizes([400, 600])
        
        main_layout.addWidget(results_splitter)
        
        # Initialize extractor and preview state
        self.image_extractor = ImageExtractor()
        self.extracted_image_files = []
        self.current_preview_index = 0
        
        return tab
    
    def open_pdf_rescue_window(self):
        """Open PDF Rescue in a standalone window. Reuses an existing window
        if already open."""
        from PyQt6.QtCore import Qt
        from modules.pdf_rescue_Qt import PDFRescueQt

        existing = getattr(self, '_pdf_rescue_window', None)
        if existing is not None:
            try:
                existing.show()
                existing.raise_()
                existing.activateWindow()
                return
            except RuntimeError:
                self._pdf_rescue_window = None

        window = QWidget()
        window.setWindowFlags(Qt.WindowType.Window)
        window.setWindowTitle("🔍 PDF Rescue – Supervertaler")
        window.resize(1100, 800)

        # PDFRescueQt builds its own header / layout into the supplied widget.
        self.pdf_rescue_qt = PDFRescueQt(self, standalone=False)
        self.pdf_rescue_qt.create_tab(window)

        self._pdf_rescue_window = window
        window.destroyed.connect(lambda *a: setattr(self, '_pdf_rescue_window', None))
        window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        window.show()
    
    # ═══════════════════════════════════════════════════════════════════════════
    # Image Extractor Helper Methods
    # ═══════════════════════════════════════════════════════════════════════════
    
    def _on_add_docx_file_for_extraction(self):
        """Add a single DOCX file to the extraction list"""
        file_path, _ = fdh.get_open_file_name(
            self,
            "Select DOCX File",
            "Word Documents (*.docx)"
        )
        
        if file_path:
            # Avoid duplicates
            items = [self.image_extractor_file_list.item(i).text() 
                    for i in range(self.image_extractor_file_list.count())]
            if file_path not in items:
                self.image_extractor_file_list.addItem(file_path)
    
    def _on_add_docx_folder_for_extraction(self):
        """Add all DOCX files from a folder to the extraction list"""
        folder_path = fdh.get_existing_directory(
            self,
            "Select Folder Containing DOCX Files"
        )
        
        if folder_path:
            import glob
            docx_files = glob.glob(os.path.join(folder_path, "*.docx"))
            
            # Get existing items to avoid duplicates
            items = [self.image_extractor_file_list.item(i).text() 
                    for i in range(self.image_extractor_file_list.count())]
            
            added = 0
            for docx_file in docx_files:
                if docx_file not in items:
                    self.image_extractor_file_list.addItem(docx_file)
                    added += 1
            
            if added > 0:
                self.image_extractor_status.append(f"✅ Added {added} DOCX file(s) from folder")
    
    def _on_browse_output_dir_for_extraction(self):
        """Browse for output directory"""
        folder_path = fdh.get_existing_directory(
            self,
            "Select Output Directory for Extracted Images"
        )
        
        if folder_path:
            self.image_extractor_output_dir.setText(folder_path)
    
    def _on_auto_folder_toggled(self, checked):
        """Handle auto-folder checkbox toggle"""
        # Disable/enable output directory selection based on auto-folder
        self.image_extractor_output_dir.setEnabled(not checked)
        
        if checked:
            self.image_extractor_output_dir.setPlaceholderText("Auto: 'Images' folder next to each DOCX")
        else:
            self.image_extractor_output_dir.setPlaceholderText("Choose output directory...")
    
    def _on_file_list_item_clicked(self, item):
        """Handle click on extracted file list item"""
        if not item:
            return
        
        # Get the file path stored in the item
        file_path = item.data(Qt.ItemDataRole.UserRole)
        
        if file_path and file_path in self.extracted_image_files:
            # Update current index and preview
            self.current_preview_index = self.extracted_image_files.index(file_path)
            self._update_preview()
    
    def _on_extract_images(self):
        """Extract images from all DOCX files in the list"""
        # Validate inputs
        if self.image_extractor_file_list.count() == 0:
            QMessageBox.warning(
                self,
                "No Files",
                "Please add at least one DOCX file to extract images from."
            )
            return
        
        # Check if using auto-folder mode
        use_auto_folder = self.image_extractor_auto_folder.isChecked()
        
        if not use_auto_folder:
            output_dir = self.image_extractor_output_dir.text().strip()
            if not output_dir:
                QMessageBox.warning(
                    self,
                    "No Output Directory",
                    "Please select an output directory for the extracted images."
                )
                return
        
        prefix = self.image_extractor_prefix.text().strip()
        if not prefix:
            prefix = "Fig."
        
        # Get list of files
        docx_files = [self.image_extractor_file_list.item(i).text() 
                     for i in range(self.image_extractor_file_list.count())]
        
        # Clear status, file list, and preview
        self.image_extractor_status.clear()
        self.image_extractor_status.append("🔄 Starting image extraction...\n")
        self.image_extractor_files_list.clear()
        self.extracted_image_files = []
        self.current_preview_index = 0
        self.image_extractor_preview.setText("No image selected\n\nClick on a file in the list to preview\nor\nExtract images to see them here")
        QApplication.processEvents()
        
        try:
            total_count = 0
            all_extracted_files = []
            
            if use_auto_folder:
                # Extract to "Images" folder next to each DOCX
                self.image_extractor_status.append("📁 Mode: Auto-folder (Images subfolder per DOCX)\n")
                
                for docx_file in docx_files:
                    docx_dir = os.path.dirname(docx_file)
                    docx_name = os.path.splitext(os.path.basename(docx_file))[0]
                    auto_output_dir = os.path.join(docx_dir, "Images")
                    
                    self.image_extractor_status.append(f"📄 Processing: {os.path.basename(docx_file)}")
                    
                    count, files = self.image_extractor.extract_images_from_docx(
                        docx_file,
                        auto_output_dir,
                        prefix
                    )
                    
                    total_count += count
                    all_extracted_files.extend(files)
                    
                    if count > 0:
                        self.image_extractor_status.append(f"   ✅ {count} image(s) → {auto_output_dir}")
                    else:
                        self.image_extractor_status.append(f"   ⚠️  No images found")
                    
                    QApplication.processEvents()
                
                output_msg = f"Images saved in 'Images' subfolders next to each DOCX file"
            else:
                # Extract all to single directory
                output_dir = self.image_extractor_output_dir.text().strip()
                self.image_extractor_status.append(f"📁 Output directory: {output_dir}\n")
                
                total_count, all_extracted_files = self.image_extractor.extract_from_multiple_docx(
                    docx_files, 
                    output_dir, 
                    prefix
                )
                
                output_msg = output_dir
            
            # Show results
            self.image_extractor_status.append(f"\n✅ Successfully extracted {total_count} images!")
            
            if all_extracted_files:
                # Store files for preview
                self.extracted_image_files = all_extracted_files
                self.current_preview_index = 0
                
                # Populate file list
                self.image_extractor_files_list.clear()
                for i, file_path in enumerate(all_extracted_files, 1):
                    item = QListWidgetItem(f"{i}. {os.path.basename(file_path)}")
                    item.setData(Qt.ItemDataRole.UserRole, file_path)  # Store full path
                    item.setToolTip(file_path)  # Show full path on hover
                    self.image_extractor_files_list.addItem(item)
                
                # Enable preview buttons
                if len(self.extracted_image_files) > 0:
                    self.preview_prev_btn.setEnabled(len(self.extracted_image_files) > 1)
                    self.preview_next_btn.setEnabled(len(self.extracted_image_files) > 1)
                    
                    # Select and show first image
                    self.image_extractor_files_list.setCurrentRow(0)
                    self._update_preview()
            
            # Success message
            QMessageBox.information(
                self,
                "Extraction Complete",
                f"Successfully extracted {total_count} images!\n\n{output_msg}"
            )
            
        except Exception as e:
            self.image_extractor_status.append(f"\n❌ Error: {str(e)}")
            QMessageBox.critical(
                self,
                "Extraction Error",
                f"An error occurred during extraction:\n{str(e)}"
            )
    
    def _update_preview(self):
        """Update the image preview with the current image"""
        if not self.extracted_image_files or self.current_preview_index >= len(self.extracted_image_files):
            self.image_extractor_preview.setText("No image selected\n\nClick on a file in the list to preview\nor\nExtract images to see them here")
            self.preview_image_label.setText("No images")
            return
        
        image_path = self.extracted_image_files[self.current_preview_index]
        
        # Update list selection to match current preview
        if hasattr(self, 'image_extractor_files_list'):
            self.image_extractor_files_list.setCurrentRow(self.current_preview_index)
        
        try:
            from PyQt6.QtGui import QPixmap
            
            pixmap = QPixmap(image_path)
            
            if not pixmap.isNull():
                # Get the scroll area size for better scaling
                scroll_area = self.image_extractor_preview.parent()
                if scroll_area and hasattr(scroll_area, 'viewport'):
                    viewport_size = scroll_area.viewport().size()
                    max_width = viewport_size.width() - 40  # Account for padding
                    max_height = viewport_size.height() - 40
                else:
                    # Fallback to larger default size
                    max_width = 800
                    max_height = 600
                
                # Scale image to fit preview area while maintaining aspect ratio
                scaled_pixmap = pixmap.scaled(
                    max_width, max_height,
                    Qt.AspectRatioMode.KeepAspectRatio,
                    Qt.TransformationMode.SmoothTransformation
                )
                
                self.image_extractor_preview.setPixmap(scaled_pixmap)
                
                # Update label with size info
                self.preview_image_label.setText(
                    f"Image {self.current_preview_index + 1} of {len(self.extracted_image_files)}: "
                    f"{os.path.basename(image_path)} ({pixmap.width()}×{pixmap.height()}px)"
                )
            else:
                self.image_extractor_preview.setText(f"Failed to load image:\n{os.path.basename(image_path)}")
                
        except Exception as e:
            self.image_extractor_preview.setText(f"Error loading image:\n{str(e)}")
    
    def _on_preview_prev(self):
        """Show previous image in preview"""
        if self.extracted_image_files and self.current_preview_index > 0:
            self.current_preview_index -= 1
            self._update_preview()
    
    def _on_preview_next(self):
        """Show next image in preview"""
        if self.extracted_image_files and self.current_preview_index < len(self.extracted_image_files) - 1:
            self.current_preview_index += 1
            self._update_preview()

    # === IMAGE CONTEXT METHODS ===
    
    def _on_load_image_context_folder(self):
        """Load a folder of figure images for AI translation context"""
        from PyQt6.QtWidgets import QFileDialog
        
        folder = QFileDialog.getExistingDirectory(
            self,
            "Select Folder with Figure Images",
            "",
            QFileDialog.Option.ShowDirsOnly
        )
        
        if folder:
            try:
                count = self.figure_context.load_from_folder(folder)
                
                if count > 0:
                    self.image_context_status_label.setText(
                        f"✅ {count} image{'s' if count != 1 else ''} loaded from: {os.path.basename(folder)}"
                    )
                    self.image_context_status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 11px; padding: 5px;")
                    self.log(f"[Image Context] Loaded {count} images from {folder}")
                    
                    # Update Prompt Manager display
                    if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                        self.prompt_manager_qt.update_image_context_display()
                    
                    QMessageBox.information(
                        self,
                        "Images Loaded",
                        f"Successfully loaded {count} figure image{'s' if count != 1 else ''}.\n\n"
                        f"These images will automatically be included with AI translations when the text "
                        f"references figures (e.g., 'Figure 1', 'see fig 2A')."
                    )
                else:
                    self.image_context_status_label.setText("⚠️ No valid images found in folder")
                    self.image_context_status_label.setStyleSheet("color: #FF9800; font-size: 11px; padding: 5px;")
                    QMessageBox.warning(
                        self,
                        "No Images Found",
                        "The selected folder does not contain any valid image files.\n\n"
                        "Supported formats: .png, .jpg, .jpeg, .gif, .bmp, .tiff\n"
                        "Filename examples: 'Figure 1.png', 'fig2a.jpg', 'Fig. 3-B.png'"
                    )
            except Exception as e:
                self.image_context_status_label.setText(f"❌ Error loading images")
                self.image_context_status_label.setStyleSheet("color: #F44336; font-size: 11px; padding: 5px;")
                self.log(f"[Image Context] Error loading images: {e}")
                QMessageBox.critical(
                    self,
                    "Error Loading Images",
                    f"Failed to load images from folder:\n\n{str(e)}"
                )
    
    def _on_clear_image_context(self):
        """Clear all loaded image context"""
        if self.figure_context.has_images():
            reply = QMessageBox.question(
                self,
                "Clear Image Context",
                f"Clear {self.figure_context.get_image_count()} loaded image{'s' if self.figure_context.get_image_count() != 1 else ''}?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                self.figure_context.clear()
                self.image_context_status_label.setText("No images loaded")
                self.image_context_status_label.setStyleSheet("color: #999; font-size: 11px; padding: 5px;")
                self.log("[Image Context] Cleared all images")
                
                # Update Prompt Manager display
                if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                    self.prompt_manager_qt.update_image_context_display()
        else:
            QMessageBox.information(
                self,
                "No Images Loaded",
                "There are no images currently loaded."
            )

    def create_superdocs_tab(self) -> QWidget:
        """Create the Help tab - Online Documentation Viewer"""
        # The embedded docs viewer was removed in favor of online documentation.
        placeholder = QWidget()
        layout = QVBoxLayout(placeholder)
        label = QLabel("📚 Supervertaler Help is now available online.\n\nVisit https://help.supervertaler.com/workbench/ to view the documentation.")
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        label.setStyleSheet("color: #888; font-size: 12px;")
        layout.addWidget(label)
        return placeholder

    def _get_api_keys(self) -> dict:
        """Get API keys from settings"""
        from modules.llm_clients import load_api_keys
        return load_api_keys()
    
    def show_concordance_search(self, initial_query: str = None):
        """
        Show concordance search (Ctrl+K) - now uses Superlookup instead of separate dialog.
        
        Superlookup provides concordance search plus:
        - Termbase search
        - Machine Translation
        - Web Resources
        
        The view can be toggled between Horizontal (table) and Vertical (list) modes.
        """
        try:
            # Get selected text if available and no initial query
            if not initial_query:
                # First check the currently focused widget (most reliable)
                focus_widget = QApplication.focusWidget()
                if focus_widget and hasattr(focus_widget, 'textCursor'):
                    cursor = focus_widget.textCursor()
                    if cursor.hasSelection():
                        initial_query = cursor.selectedText()
                        self.log(f"[Concordance] Got selection from focus widget: '{initial_query[:30]}...'")
                
                # Fallback: check source/target cells directly
                if not initial_query and hasattr(self, 'table') and self.table:
                    current_row = self.table.currentRow()
                    if current_row >= 0:
                        # Try source column first (column 2)
                        source_widget = self.table.cellWidget(current_row, 2)
                        if source_widget and hasattr(source_widget, 'textCursor'):
                            cursor = source_widget.textCursor()
                            if cursor.hasSelection():
                                initial_query = cursor.selectedText()
                        
                        # If no selection in source, try target column (column 3)
                        if not initial_query:
                            target_widget = self.table.cellWidget(current_row, 3)
                            if target_widget and hasattr(target_widget, 'textCursor'):
                                cursor = target_widget.textCursor()
                                if cursor.hasSelection():
                                    initial_query = cursor.selectedText()
            
            # Get project language pair
            source_lang = getattr(self, 'source_language', None)
            target_lang = getattr(self, 'target_language', None)
            
            # Log for debugging
            if initial_query:
                self.log(f"[Concordance] Opening Superlookup with query: '{initial_query[:50]}...' (lang: {source_lang} → {target_lang})")
            else:
                self.log(f"[Concordance] Opening Superlookup (no selection)")
            
            # Open Superlookup in Sidekick, with the query + language pair if any
            self._go_to_superlookup(
                query=initial_query,
                source_lang=source_lang,
                target_lang=target_lang,
            )

            # No-query path: focus the input, reset language dropdowns to "Any",
            # and switch to vertical view. Targets the in-Workbench
            # SuperLookup top tab (v1.10.10 cleanup; pre-v1.10.4 this
            # walked through Sidekick's _superlookup_widget).
            if not initial_query:
                widget = getattr(self, '_superlookup_top_widget', None)
                if widget is not None:
                    if hasattr(widget, 'source_text'):
                        widget.source_text.setFocus()
                    if hasattr(widget, 'lang_from_combo'):
                        widget.lang_from_combo.setCurrentIndex(0)
                    if hasattr(widget, 'lang_to_combo'):
                        widget.lang_to_combo.setCurrentIndex(0)
                    if hasattr(widget, 'tm_view_vertical_radio'):
                        widget.tm_view_vertical_radio.setChecked(True)
                        
        except Exception as e:
            self.log(f"Error opening concordance search: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.critical(self, "Error", f"Failed to open concordance search:\n{str(e)}")
    
    def show_tm_manager_tab(self, tab_index: int = 0, initial_concordance_query: str = None):
        """Show TM Manager dialog opened to specific tab.

        If initial_concordance_query is provided, the Concordance tab is
        selected and the query is auto-run – used by Superlookup's right-click
        'Open in TM browser' to jump straight to the matching segment.
        """
        from modules.tm_manager_qt import TMManagerDialog

        try:
            dialog = TMManagerDialog(self, self.db_manager, self.log,
                                     initial_concordance_query=initial_concordance_query)
            if not initial_concordance_query:
                dialog.tabs.setCurrentIndex(tab_index)
            dialog.exec()
        except Exception as e:
            self.log(f"Error opening TM Manager: {e}")
            QMessageBox.critical(self, "Error", f"Failed to open TM Manager:\n{str(e)}")

    def _show_tm_maintenance_dialog(self):
        """Open a focused TM Maintenance dialog.

        Reuses TMManagerDialog and hides every tab except Maintenance, so the
        user lands on the cleanup actions with no surrounding noise.
        """
        from modules.tm_manager_qt import TMManagerDialog
        try:
            dialog = TMManagerDialog(self, self.db_manager, self.log)
            maintenance_index = -1
            for i in range(dialog.tabs.count()):
                if "Maintenance" in dialog.tabs.tabText(i):
                    maintenance_index = i
                else:
                    dialog.tabs.setTabVisible(i, False)
            if maintenance_index >= 0:
                dialog.tabs.setCurrentIndex(maintenance_index)
            dialog.setWindowTitle("TM Maintenance")
            dialog.exec()
        except Exception as e:
            self.log(f"Error opening TM Maintenance: {e}")
            QMessageBox.critical(self, "Error", f"Failed to open TM Maintenance:\n{str(e)}")

    def _copy_tm_stats_to_clipboard(self, tm_metadata_mgr):
        """Build a markdown TM statistics report and copy it to the clipboard."""
        try:
            from datetime import datetime
            from modules.tmx_generator import normalize_lang_variant

            tms = tm_metadata_mgr.get_all_tms()
            total_entries = sum((tm.get('entry_count') or 0) for tm in tms)
            tm_count = len(tms)

            cursor = self.db_manager.cursor
            cursor.execute(
                "SELECT AVG(LENGTH(source_text)), AVG(LENGTH(target_text)) "
                "FROM translation_units"
            )
            row = cursor.fetchone()
            avg_source = row[0] if row and row[0] is not None else 0
            avg_target = row[1] if row and row[1] is not None else 0

            now = datetime.now().strftime("%Y-%m-%d %H:%M")
            lines = [
                "# Translation Memory Statistics",
                "",
                f"_Generated {now}_",
                "",
                f"- **Total translation units:** {total_entries:,}",
                f"- **Number of TMs:** {tm_count}",
                f"- **Average source length:** {avg_source:.1f} characters",
                f"- **Average target length:** {avg_target:.1f} characters",
                "",
                "## Breakdown by TM",
                "",
                "| TM | Languages | Entries | Share | Last modified |",
                "| --- | --- | ---: | ---: | --- |",
            ]
            sorted_tms = sorted(
                tms, key=lambda t: (t.get('entry_count') or 0), reverse=True
            )
            for tm in sorted_tms:
                entries = tm.get('entry_count') or 0
                pct = (entries / total_entries * 100) if total_entries > 0 else 0
                src = normalize_lang_variant(tm.get('source_lang')) if tm.get('source_lang') else '?'
                tgt = normalize_lang_variant(tm.get('target_lang')) if tm.get('target_lang') else '?'
                modified = tm.get('modified_date') or ''
                if isinstance(modified, str) and len(modified) > 16:
                    modified = modified[:16].replace('T', ' ')
                name = (tm.get('name') or tm.get('tm_id') or '?').replace('|', '\\|')
                lines.append(
                    f"| {name} | {src} → {tgt} | {entries:,} | {pct:.1f}% | {modified} |"
                )
            markdown = "\n".join(lines) + "\n"

            QApplication.clipboard().setText(markdown)
            self.log(
                f"📊 TM statistics copied to clipboard "
                f"({total_entries:,} TUs across {tm_count} TMs)"
            )
            QMessageBox.information(
                self,
                "Stats copied",
                f"Translation memory statistics copied to clipboard "
                f"({total_entries:,} TUs across {tm_count} TMs).",
            )
        except Exception as e:
            self.log(f"Error copying TM stats: {e}")
            QMessageBox.critical(self, "Error", f"Failed to copy TM stats:\n{str(e)}")

    def create_log_tab(self) -> QWidget:
        """Create the Log tab - Session Log"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(10)

        # Top toolbar with detach button
        toolbar = QWidget()
        toolbar_layout = QHBoxLayout(toolbar)
        toolbar_layout.setContentsMargins(0, 0, 0, 0)

        detach_btn = QPushButton("🪟 Detach Log Window")
        detach_btn.setToolTip("Open log in a separate window that can be moved to another screen")
        detach_btn.clicked.connect(self.detach_log_window)
        toolbar_layout.addWidget(detach_btn)

        clear_btn = QPushButton("🗑️ Clear Log")
        clear_btn.setToolTip("Clear all log messages")
        clear_btn.clicked.connect(self.clear_log)
        toolbar_layout.addWidget(clear_btn)

        toolbar_layout.addStretch()
        layout.addWidget(toolbar)

        # Log display area
        self.session_log = QPlainTextEdit()
        self.session_log.setReadOnly(True)
        self.session_log.setStyleSheet("""
            QPlainTextEdit {
                font-family: 'Consolas', 'Courier New', monospace;
                font-size: 9px;
                border: 1px solid #ccc;
            }
        """)
        layout.addWidget(self.session_log)

        # Initialize with welcome message
        self.session_log.setPlainText("Session Log - Ready\n" + "="*50 + "\n")

        # List to track detached log windows
        self.detached_log_windows = []

        return tab

    def detach_log_window(self):
        """Create a detached log window"""
        # The list is normally created in create_log_tab() (Settings ▸ Log),
        # but this entry point can fire before Settings has been built, so
        # initialise on demand.
        if not hasattr(self, 'detached_log_windows'):
            self.detached_log_windows = []
        detached_window = DetachedLogWindow(self)
        detached_window.show()
        detached_window.raise_()
        detached_window.activateWindow()
        self.detached_log_windows.append(detached_window)
        self.log("🪟 Log window detached")

    def clear_log(self):
        """Clear the log"""
        reply = QMessageBox.question(
            self,
            "Clear Log",
            "Are you sure you want to clear the log?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        if reply == QMessageBox.StandardButton.Yes:
            self.session_log.setPlainText("Session Log - Ready\n" + "="*50 + "\n")
            # Also clear detached windows
            for window in self.detached_log_windows:
                if window and not window.isHidden():
                    window.log_display.setPlainText("Session Log - Ready\n" + "="*50 + "\n")

    def _on_main_tab_changed(self, index: int):
        """Handle main tab changes (Grid/Project resources/Tools/Settings)"""
        try:
            # Lazy-construct the v1.10.0 SuperLookup / Clipboard / Voice
            # top tabs on first activation. Each helper is idempotent –
            # safe to call again on subsequent activations (it short-
            # circuits if already built). Keeps cold start fast by
            # deferring the heavy SuperLookup / Voice widget construction
            # until the user actually visits the tab.
            if hasattr(self, 'superlookup_tab_index') and index == self.superlookup_tab_index:
                self._ensure_superlookup_top_tab()
            elif hasattr(self, 'clipboard_tab_index') and index == self.clipboard_tab_index:
                self._ensure_clipboard_top_tab()
            elif hasattr(self, 'voice_tab_index') and index == self.voice_tab_index:
                self._ensure_voice_top_tab()

            if index == 0:  # Grid
                # Grid refreshes automatically when segments change
                pass
            elif index == 1:  # Project resources
                # Refresh AI Assistant LLM client when switching to Project resources tab
                # Prompts tab is inside Project resources, so refresh LLM settings when going there
                if hasattr(self, 'prompt_manager_qt'):
                    llm_settings = self.load_llm_settings()
                    self.current_provider = llm_settings.get('provider', 'openai')
                    provider_key = f"{self.current_provider}_model"
                    self.current_model = llm_settings.get(provider_key)
                    # Reinitialize AI Assistant's LLM client
                    self.prompt_manager_qt._init_llm_client()
        except Exception as e:
            self.log(f"⚠️ Error switching main tabs: {e}")
            import traceback
            traceback.print_exc()

    # ------------------------------------------------------------------
    # Phase 1 (issue #199): lazy constructors for the new Workbench
    # top tabs. Sidekick continues to embed its own copies of these
    # widgets during Phases 1-3; duplicate construction is accepted
    # until Phase 4 retires Sidekick.
    # ------------------------------------------------------------------

    def _warm_up_top_tabs(self):
        """Build the new SuperLookup / Clipboard / Voice top tabs in the
        background a few seconds after launch.

        Each ``_ensure_*_top_tab`` is idempotent – calling it here just
        forces eager construction before the user's first hotkey press
        instead of paying the cost on that press. Wrapped in
        per-helper try/except so a failure in one doesn't skip the
        others.

        Also kicks off an AHK pre-warm so the very first paste-and-
        return from the Clipboard tab doesn't lose its keystroke to
        AHK's cold-spawn latency (Windows + AV scan can stretch the
        first ``AutoHotkey64.exe`` launch to 1–2 s; subsequent
        launches hit a hot disk cache and run in ~150 ms).
        """
        for helper in ('_ensure_superlookup_top_tab',
                       '_ensure_clipboard_top_tab',
                       '_ensure_voice_top_tab'):
            try:
                fn = getattr(self, helper, None)
                if callable(fn):
                    fn()
            except Exception as e:
                self.log(f"⚠ Warm-up of {helper} failed (lazy path still works): {e}")
        try:
            self._prewarm_ahk()
        except Exception as e:
            self.log(f"⚠ AHK pre-warm failed (first paste may be slow): {e}")

    def _prewarm_ahk(self):
        """Spawn AHK once with a no-op script so the binary is in disk
        cache and the first real Ctrl+V / Ctrl+C from
        :class:`CrossPlatformKeySender` doesn't take an extra second
        on cold disk. No-op when AHK isn't installed or we're not on
        Windows. Runs in a background thread so it doesn't block the
        warm-up timer.
        """
        import sys as _sys
        if _sys.platform != 'win32':
            return
        import threading

        def _do_prewarm():
            try:
                from modules.platform_helpers import CrossPlatformKeySender
                ahk = CrossPlatformKeySender()._find_ahk()
                if not ahk:
                    return
                import subprocess as _sp
                import tempfile as _tf
                # Empty script body; just exits. AHK's spin-up cost
                # is paid here so the first user-triggered paste is fast.
                with _tf.NamedTemporaryFile(
                        mode='w', suffix='.ahk', delete=False,
                        encoding='utf-8') as f:
                    f.write('ExitApp\n')
                    tmp = f.name
                try:
                    _sp.run([ahk, '/ErrorStdOut', tmp],
                            timeout=10,
                            creationflags=getattr(_sp, 'CREATE_NO_WINDOW', 0))
                finally:
                    import os as _os
                    try:
                        _os.unlink(tmp)
                    except OSError:
                        pass
            except Exception as e:
                print(f"[AHK pre-warm] {e}")

        t = threading.Thread(target=_do_prewarm, daemon=True,
                             name="AHK-prewarm")
        t.start()

    def _ensure_superlookup_top_tab(self):
        """Build the SuperLookup top tab the first time the user opens it.

        Heavy: SuperlookupTab.__init__ brings up TM / termbase / MT and
        web-resource state. Deferring until first activation keeps cold
        start fast. The hotkey-side-effect SuperLookup instance from
        _setup_superlookup_hotkeys handled the Ctrl+Alt+L registration
        at launch time; this is a separate instance that powers the
        visible top tab. (RegisterHotKey calls from the second instance
        fail silently because the first one owns the registrations –
        expected and harmless.)
        """
        if self._superlookup_top_widget is not None:
            return
        try:
            widget = SuperlookupTab(
                self,
                user_data_path=getattr(self, 'user_data_path', None),
            )
            self._superlookup_top_widget = widget
            placeholder = self.main_tabs.widget(self.superlookup_tab_index)
            layout = QVBoxLayout(placeholder)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(widget)
        except Exception as e:
            self.log(f"⚠ Could not build SuperLookup top tab: {e}")
            import traceback
            traceback.print_exc()

    def _ensure_clipboard_top_tab(self):
        """Build the Clipboard top tab on first activation.

        Click-to-paste copies the chosen item to the system clipboard,
        so the user can paste with Ctrl+V wherever they're focused
        (typically the grid editor, or another app entirely). Phase 3
        of issue #199 turns this single column into a 3-column
        layout with snippets / characters / conversions on the right.
        """
        if self._clipboard_top_widget is not None:
            return
        try:
            from modules.clipboard_manager_widget import ClipboardManagerWidget
            from PyQt6.QtWidgets import QApplication

            def _paste_text(text: str):
                QApplication.clipboard().setText(text)

            def _paste_image(pixmap):
                QApplication.clipboard().setPixmap(pixmap)

            widget = ClipboardManagerWidget(
                self,
                paste_text_callback=_paste_text,
                paste_image_callback=_paste_image,
            )
            try:
                widget.ensure_db_loaded()
            except Exception:
                pass
            self._clipboard_top_widget = widget
            placeholder = self.main_tabs.widget(self.clipboard_tab_index)
            layout = QVBoxLayout(placeholder)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(widget)
        except Exception as e:
            self.log(f"⚠ Could not build Clipboard top tab: {e}")
            import traceback
            traceback.print_exc()

    def _ensure_voice_top_tab(self):
        """Build the Voice top tab on first activation.

        Same widget Sidekick uses (modules.voice_tab.VoiceTab); it reads
        parent_app properties (shortcut_manager, voice_listener,
        load_dictation_settings) so the existing wiring just works.
        """
        if self._voice_top_widget is not None:
            return
        try:
            from modules.voice_tab import VoiceTab
            widget = VoiceTab(self)
            self._voice_top_widget = widget
            placeholder = self.main_tabs.widget(self.voice_tab_index)
            layout = QVBoxLayout(placeholder)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(widget)
        except Exception as e:
            self.log(f"⚠ Could not build Voice top tab: {e}")
            import traceback
            traceback.print_exc()
    
    def detach_superlookup(self):
        """Detach Superlookup into a separate window for second screen use"""
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QPushButton
        
        try:
            # If already detached, just show the window
            if hasattr(self, 'lookup_detached_window') and self.lookup_detached_window and self.lookup_detached_window.isVisible():
                self.lookup_detached_window.raise_()
                self.lookup_detached_window.activateWindow()
                return
            
            # Create detached window
            self.lookup_detached_window = QDialog(self)
            self.lookup_detached_window.setWindowTitle("🔍 SuperLookup - Supervertaler Workbench")
            self.lookup_detached_window.setMinimumSize(600, 700)
            self.lookup_detached_window.resize(700, 800)
            
            # Set window flags to ensure it appears as a proper window
            self.lookup_detached_window.setWindowFlags(
                Qt.WindowType.Window | 
                Qt.WindowType.WindowCloseButtonHint | 
                Qt.WindowType.WindowMinimizeButtonHint |
                Qt.WindowType.WindowMaximizeButtonHint
            )
            
            # Position window to the right of main window, or center on same screen if no space
            main_geometry = self.geometry()
            
            # Get the screen that contains the main window (handles multi-monitor setups)
            app = QApplication.instance()
            screen = app.screenAt(main_geometry.center())
            if not screen:
                # Fallback to primary screen
                screen = app.primaryScreen()
            
            screen_geometry = screen.geometry()
            
            # Try to place to the right of main window
            new_x = main_geometry.right() + 20
            new_y = main_geometry.top()
            
            # If window would go off-screen to the right, center it on the main window's screen
            if new_x + 700 > screen_geometry.right():
                # Center horizontally on screen, place above main window
                new_x = screen_geometry.left() + (screen_geometry.width() - 700) // 2
                new_y = max(screen_geometry.top() + 50, main_geometry.top() - 100)
                
            # Ensure window stays on screen
            new_x = max(screen_geometry.left() + 10, min(new_x, screen_geometry.right() - 710))
            new_y = max(screen_geometry.top() + 10, min(new_y, screen_geometry.bottom() - 810))
            
            self.lookup_detached_window.move(int(new_x), int(new_y))
            
            # Make sure window is raised and activated - use QTimer to ensure it's after window is shown
            QTimer.singleShot(100, lambda: (
                self.lookup_detached_window.raise_(),
                self.lookup_detached_window.activateWindow(),
                self.lookup_detached_window.setFocus()
            ))
            
            layout = QVBoxLayout(self.lookup_detached_window)
            layout.setContentsMargins(10, 10, 10, 10)
            layout.setSpacing(5)
            
            # Header with reattach button
            header_layout = QVBoxLayout()
            
            header_title = QLabel("🔍 SuperLookup")
            header_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #333;")
            header_layout.addWidget(header_title)
            
            button_layout = QVBoxLayout()
            reattach_btn = QPushButton("📥 Attach to Main Window")
            reattach_btn.setToolTip("Re-attach SuperLookup to the Home tab")
            reattach_btn.setStyleSheet("font-size: 9pt; padding: 4px 12px; max-width: 200px;")
            reattach_btn.clicked.connect(self.reattach_superlookup)
            button_layout.addWidget(reattach_btn, alignment=Qt.AlignmentFlag.AlignRight)
            header_layout.addLayout(button_layout)
            
            layout.addLayout(header_layout)
            
            # Create new Superlookup instance for detached window
            # Or move the existing one - better to create new to avoid widget parenting issues
            detached_lookup = SuperlookupTab(self.lookup_detached_window, user_data_path=self.user_data_path)
            
            # Explicitly copy theme_manager reference
            detached_lookup.theme_manager = self.theme_manager
            
            # Copy TM database reference if available
            if hasattr(self, 'tm_database') and self.tm_database:
                detached_lookup.tm_database = self.tm_database
                if detached_lookup.engine:
                    detached_lookup.engine.set_tm_database(self.tm_database)
            
            # Copy home lookup state if it exists
            if hasattr(self, 'home_lookup_widget') and self.home_lookup_widget:
                # Copy source text
                source_text = self.home_lookup_widget.source_text.toPlainText()
                detached_lookup.source_text.setPlainText(source_text)
                
                # Copy TM database reference
                if hasattr(self.home_lookup_widget, 'tm_database'):
                    detached_lookup.tm_database = self.home_lookup_widget.tm_database
            
            layout.addWidget(detached_lookup, stretch=1)
            
            # Store reference for cleanup
            self.lookup_detached_widget = detached_lookup
            
            # Handle window close
            def on_close():
                self.lookup_detached_window = None
                self.lookup_detached_widget = None
            
            self.lookup_detached_window.finished.connect(on_close)
            
            # Show window (non-modal)
            self.lookup_detached_window.setWindowModality(Qt.WindowModality.NonModal)
            self.lookup_detached_window.show()
            self.log("Superlookup detached to separate window")
        except Exception as e:
            import traceback
            error_msg = f"Error detaching Superlookup: {str(e)}\n{traceback.format_exc()}"
            self.log(error_msg)
            QMessageBox.warning(self, "Error", f"Could not detach Superlookup:\n{str(e)}")
    
    def reattach_superlookup(self):
        """Re-attach Superlookup to the Home tab"""
        if not self.lookup_detached_window:
            return
        
        # Copy state back to home widget if it exists
        if (hasattr(self, 'home_lookup_widget') and self.home_lookup_widget and 
            hasattr(self, 'lookup_detached_widget') and self.lookup_detached_widget):
            # Copy source text
            source_text = self.lookup_detached_widget.source_text.toPlainText()
            self.home_lookup_widget.source_text.setPlainText(source_text)
        
        # Close detached window
        self.lookup_detached_window.close()
        self.lookup_detached_window = None
        if hasattr(self, 'lookup_detached_widget'):
            self.lookup_detached_widget = None
        
        self.log("Superlookup re-attached to Home tab")
    
    # create_resources_tab() was removed in v1.9.422.
    # TMs and Termbases used to share a wrapper "Resources" tab on the
    # main_tabs strip with their own inner QTabWidget (the resources_tabs
    # widget). Both have been promoted to peers of Editor / AI / Tools /
    # Settings on main_tabs directly – construction now calls
    # create_translation_memories_tab() and create_termbases_tab()
    # directly. The wrapper widget and its method are gone.

    def _setup_superlookup_hotkeys(self):
        """Construct the (hidden) SuperlookupTab so its __init__ registers
        the global hotkeys (Ctrl+Alt+L, Ctrl+Alt+Q, Alt+K).

        Pre-v1.9.467 this lived inside create_specialised_tools_tab(),
        which also built the embedded Tools-tab UI. The Tools tab has
        been retired; the SuperlookupTab construction stays because the
        global hotkey registration happens as a side-effect of __init__.

        Voice (commands / dictation) is registered separately
        via Sidekick (modules/voice_tab.py).
        """
        lookup_tab = SuperlookupTab(self, user_data_path=self.user_data_path)
        lookup_tab.hide()  # Never shown – purely for hotkey side effects
        self.lookup_tab = lookup_tab

        if hasattr(lookup_tab, 'hotkey_registered') and lookup_tab.hotkey_registered:
            failed = getattr(getattr(lookup_tab, '_hotkey_manager', None), 'failed_hotkeys', [])
            if failed:
                self.log(f"⚠ Global hotkeys: some failed to register: {', '.join(failed)}")
            else:
                self.log("⌨ Global hotkeys registered (Ctrl+Alt+L, Ctrl+Alt+Q, Alt+K)")
        else:
            self.log("⚠ Global hotkeys NOT registered – check console for errors")
    
    def create_translation_memories_tab(self):
        """Create the Translation Memories tab with nested sub-tabs"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        
        # Check if database is available
        if not (hasattr(self, 'db_manager') and self.db_manager):
            placeholder = QLabel("Translation Memories Manager\n\nDatabase not initialized.")
            placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
            placeholder.setStyleSheet("color: #888; font-size: 12px;")
            layout.addWidget(placeholder, stretch=1)
            return tab
        
        # Import TM metadata manager
        from modules.tm_metadata_manager import TMMetadataManager
        tm_metadata_mgr = TMMetadataManager(self.db_manager, self.log)
        self.tm_metadata_mgr = tm_metadata_mgr  # Store for later use
        
        # Create nested tab widget for TM functionality
        tm_tabs = QTabWidget()
        tm_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        tm_tabs.tabBar().setDrawBase(False)
        tm_tabs.tabBar().setExpanding(False)
        tm_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
        
        # Tab 1: TM List (Management) - manage multiple TMs
        tm_list_tab = self._create_tm_list_tab(tm_metadata_mgr)
        tm_tabs.addTab(tm_list_tab, "📋 TM List")
        
        # Tab 2: Browse All - browse ALL active TMs together
        from modules.tm_manager_qt import TMManagerDialog
        temp_manager = TMManagerDialog(self, self.db_manager, self.log)
        tm_tabs.addTab(temp_manager.browser_tab, "📖 Browse All")

        # Statistics (now a "Copy stats" button on the TM List tab – copies a
        # markdown report to the clipboard) and Maintenance (now a button that
        # opens a focused cleanup dialog) used to be sibling sub-tabs here.
        # Folded into TM List to reduce sub-tab noise.

        # Store reference to prevent garbage collection
        tab._tm_manager = temp_manager
        
        layout.addWidget(tm_tabs)
        
        return tab
    
    def _create_tm_list_tab(self, tm_metadata_mgr):
        """Create the TM List sub-tab with table and management buttons"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        
        # Header
        header = QLabel("💾 TMs")
        header.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 10px;")
        layout.addWidget(header)
        
        # Description
        desc = QLabel("Manage TMs. Activate/deactivate TMs for current project. Import client TMX files as named TMs.")
        desc.setStyleSheet("color: #666; font-size: 11px; margin-bottom: 10px;")
        layout.addWidget(desc)

        # Live summary line – updated by refresh_tm_list() below.
        summary_label = QLabel("")
        summary_label.setStyleSheet("color: #555; font-size: 11px; margin-bottom: 6px;")
        layout.addWidget(summary_label)

        # Search bar
        search_layout = QHBoxLayout()
        search_box = QLineEdit()
        search_box.setPlaceholderText("Search TMs...")
        search_box.setMaximumWidth(300)
        search_layout.addWidget(search_box)
        search_layout.addStretch()
        layout.addLayout(search_layout)
        
        # Help message for first-time users
        help_msg = QLabel(
            "💡 <b>Translation Memories</b><br>"
            "• <b>Read</b> (green ✓): TM is used for matching segments<br>"
            "• <b>Write</b> (blue ✓): TM is updated with new translations<br>"
            "• <b>Typical Setup</b>: Main TM (Read + Write) + Reference TMs (Read only)"
        )
        help_msg.setWordWrap(True)
        help_msg.setStyleSheet("background-color: #e3f2fd; padding: 8px; border-radius: 4px; color: #1976d2;")
        layout.addWidget(help_msg)
        
        # Bulk action controls
        bulk_layout = QHBoxLayout()
        bulk_layout.addWidget(QLabel("Quick Actions:"))
        
        read_header_checkbox = CheckmarkCheckBox("Select All Read")
        bulk_layout.addWidget(read_header_checkbox)
        
        write_header_checkbox = BlueCheckmarkCheckBox("Select All Write")
        bulk_layout.addWidget(write_header_checkbox)
        
        bulk_layout.addStretch()
        layout.addLayout(bulk_layout)
        
        # TM list with table
        tm_table = QTableWidget()
        tm_table.setColumnCount(7)
        tm_table.setHorizontalHeaderLabels(["TM Name", "Languages", "Entries", "Read", "Write", "Last Modified", "Description"])
        tm_table.horizontalHeader().setStretchLastSection(True)
        tm_table.setColumnWidth(0, 250)
        tm_table.setColumnWidth(1, 120)
        tm_table.setColumnWidth(2, 80)
        tm_table.setColumnWidth(3, 60)
        tm_table.setColumnWidth(4, 60)
        tm_table.setColumnWidth(5, 150)
        
        # Get current project (for lambda closures below)
        current_project = self.current_project if hasattr(self, 'current_project') else None
        project_id = current_project.id if (current_project and hasattr(current_project, 'id')) else None
        
        # Connect header checkboxes to toggle all
        def toggle_all_read(checked):
            for row in range(tm_table.rowCount()):
                checkbox = tm_table.cellWidget(row, 3)
                if checkbox and isinstance(checkbox, CheckmarkCheckBox):
                    checkbox.setChecked(checked)
        
        def toggle_all_write(checked):
            for row in range(tm_table.rowCount()):
                checkbox = tm_table.cellWidget(row, 4)
                if checkbox and isinstance(checkbox, BlueCheckmarkCheckBox):
                    checkbox.setChecked(checked)
        
        read_header_checkbox.toggled.connect(toggle_all_read)
        write_header_checkbox.toggled.connect(toggle_all_write)
        
        # Populate TM list
        def refresh_tm_list():
            # Get current project dynamically (not captured in closure!)
            # Use GLOBAL_PROJECT_ID (0) when no project is loaded for Superlookup support
            current_proj = self.current_project if hasattr(self, 'current_project') else None
            refresh_project_id = current_proj.id if (current_proj and hasattr(current_proj, 'id')) else 0  # 0 = global
            
            tms = tm_metadata_mgr.get_all_tms()
            tm_table.setRowCount(len(tms))
            
            for row, tm in enumerate(tms):
                # When no project is loaded, nothing should be selected (user requirement)
                if current_proj is None:
                    is_readable = False
                    is_writable = False
                else:
                    # Check if active (Read mode) for current project or global (0)
                    # Note: is_tm_active now supports project_id=0 for global activations
                    is_readable = tm_metadata_mgr.is_tm_active(tm['id'], refresh_project_id)
                    # Default: read-only (Write unchecked) - read_only=True means not writable
                    # If read_only is not set in database, treat as read-only by default
                    is_writable = not tm.get('read_only', True)  # Default to True (read-only) if not set
                
                # TM Name (bold if readable)
                name_item = QTableWidgetItem(tm['name'])
                if is_readable:
                    font = name_item.font()
                    font.setBold(True)
                    name_item.setFont(font)
                tm_table.setItem(row, 0, name_item)
                
                # Languages (normalized format: nl-NL, en-US, etc.)
                from modules.tmx_generator import normalize_lang_variant
                src_lang = normalize_lang_variant(tm['source_lang']) if tm['source_lang'] else '?'
                tgt_lang = normalize_lang_variant(tm['target_lang']) if tm['target_lang'] else '?'
                langs = f"{src_lang} → {tgt_lang}"
                tm_table.setItem(row, 1, QTableWidgetItem(langs))
                
                # Entry count
                tm_table.setItem(row, 2, QTableWidgetItem(str(tm['entry_count'])))
                
                # Read checkbox (green checkmark)
                read_checkbox = CheckmarkCheckBox()
                read_checkbox.setChecked(is_readable)
                read_checkbox.setToolTip("Read: TM is used for matching segments")
                
                def on_read_toggle(checked, tm_id=tm['id'], row_idx=row):
                    # Get current project ID dynamically
                    # Use 0 (global) when no project is loaded - allows Superlookup to work
                    curr_proj = self.current_project if hasattr(self, 'current_project') else None
                    curr_proj_id = curr_proj.id if (curr_proj and hasattr(curr_proj, 'id')) else 0  # 0 = global
                    
                    # Activate/deactivate for reading
                    if checked:
                        success = tm_metadata_mgr.activate_tm(tm_id, curr_proj_id)
                    else:
                        success = tm_metadata_mgr.deactivate_tm(tm_id, curr_proj_id)
                    
                    if success:
                        status = "readable" if checked else "not readable"
                        self.log(f"✅ TM {tm_id} set to {status}")
                        # Update name to be bold if readable
                        name_item = tm_table.item(row_idx, 0)
                        if name_item:
                            font = name_item.font()
                            font.setBold(checked)
                            name_item.setFont(font)
                        
                        # Invalidate translation cache so matches are refreshed with new TM settings
                        self.invalidate_translation_cache(smart_invalidation=False)
                    else:
                        self.log(f"❌ Failed to toggle TM {tm_id}")
                        # Revert checkbox on failure
                        sender_checkbox = tm_table.cellWidget(row_idx, 3)
                        if sender_checkbox:
                            sender_checkbox.blockSignals(True)
                            sender_checkbox.setChecked(not checked)
                            sender_checkbox.blockSignals(False)
                
                read_checkbox.toggled.connect(on_read_toggle)
                tm_table.setCellWidget(row, 3, read_checkbox)
                
                # Write checkbox (blue checkmark)
                write_checkbox = BlueCheckmarkCheckBox()
                write_checkbox.setChecked(is_writable)
                write_checkbox.setToolTip("Write: TM is updated with new translations")
                
                def on_write_toggle(checked, tm_id=tm['id'], row_idx=row):
                    # Invert logic: checked = writable, so set read_only to NOT checked
                    success = tm_metadata_mgr.set_read_only(tm_id, not checked)
                    if success:
                        status = "writable" if checked else "read-only"
                        self.log(f"✅ TM {tm_id} set to {status}")
                    else:
                        # Revert on failure
                        sender = tm_table.cellWidget(row_idx, 4)
                        if sender:
                            sender.blockSignals(True)
                            sender.setChecked(not checked)
                            sender.blockSignals(False)
                
                write_checkbox.toggled.connect(on_write_toggle)
                tm_table.setCellWidget(row, 4, write_checkbox)
                
                # Last modified
                modified = tm['modified_date'] or tm['created_date'] or ''
                if modified:
                    # Format datetime nicely
                    try:
                        from datetime import datetime
                        dt = datetime.fromisoformat(modified)
                        modified = dt.strftime("%Y-%m-%d %H:%M")
                    except:
                        pass
                tm_table.setItem(row, 5, QTableWidgetItem(modified))
                
                # Description
                desc_text = tm['description'] or ''
                tm_table.setItem(row, 6, QTableWidgetItem(desc_text))
            
            # Update header checkbox states based on current selection
            read_header_checkbox.blockSignals(True)
            write_header_checkbox.blockSignals(True)
            all_read_checked = all(tm_table.cellWidget(r, 3).isChecked() if tm_table.cellWidget(r, 3) else False for r in range(tm_table.rowCount())) if tm_table.rowCount() > 0 else False
            all_write_checked = all(tm_table.cellWidget(r, 4).isChecked() if tm_table.cellWidget(r, 4) else False for r in range(tm_table.rowCount())) if tm_table.rowCount() > 0 else False
            read_header_checkbox.setChecked(all_read_checked)
            write_header_checkbox.setChecked(all_write_checked)
            read_header_checkbox.blockSignals(False)
            write_header_checkbox.blockSignals(False)

            # Summary line: total TUs across N TMs
            total_tus = sum((tm.get('entry_count') or 0) for tm in tms)
            summary_label.setText(
                f"📊 {total_tus:,} translation units across {len(tms)} TM"
                f"{'s' if len(tms) != 1 else ''}"
            )
        
        # Store callback as instance attribute so load_project can refresh UI after restoration
        self.tm_tab_refresh_callback = refresh_tm_list
        
        refresh_tm_list()
        layout.addWidget(tm_table, stretch=1)
        
        # Button bar
        button_layout = QHBoxLayout()
        
        create_btn = QPushButton("+ Create New TM")
        # Get project_id dynamically - use 0 (global) when no project is loaded
        create_btn.clicked.connect(lambda: self._show_create_tm_dialog(
            tm_metadata_mgr, refresh_tm_list,
            self.current_project.id if (hasattr(self, 'current_project') and self.current_project and hasattr(self.current_project, 'id')) else 0
        ))
        button_layout.addWidget(create_btn)
        
        import_btn = QPushButton("📥 Import TMX")
        import_btn.setToolTip("Import TMX file as a new TM or add to existing TM")
        import_btn.clicked.connect(lambda: self._import_tmx_as_tm(tm_metadata_mgr, tm_table, refresh_tm_list))
        button_layout.addWidget(import_btn)

        attach_sdltm_btn = QPushButton("🔗 Attach Trados TM")
        attach_sdltm_btn.setToolTip(
            "Mirror a Trados Studio .sdltm file as a read-only TM. "
            "Inline tags are preserved as Supervertaler <N> markers."
        )
        attach_sdltm_btn.clicked.connect(lambda: self._attach_sdltm_as_tm(tm_metadata_mgr, refresh_tm_list))
        set_help_topic(attach_sdltm_btn, HelpTopics.TM_TRADOS_SDLTM)
        button_layout.addWidget(attach_sdltm_btn)
        
        export_btn = QPushButton("📤 Export TM")
        export_btn.setToolTip("Export selected TM to TMX file")
        export_btn.clicked.connect(lambda: self._export_tm_to_tmx(tm_metadata_mgr, tm_table))
        button_layout.addWidget(export_btn)
        
        edit_btn = QPushButton("✏️ Edit/Maintain TM")
        edit_btn.setToolTip("Open editor for selected TM (Browse, Import/Export, Maintenance)")
        edit_btn.clicked.connect(lambda: self._show_tm_editor_dialog(tm_metadata_mgr, tm_table))
        button_layout.addWidget(edit_btn)
        
        delete_btn = QPushButton("🗑️ Delete TM")
        delete_btn.clicked.connect(lambda: self._delete_tm(tm_metadata_mgr, tm_table, refresh_tm_list))
        button_layout.addWidget(delete_btn)

        button_layout.addStretch()

        maintenance_btn = QPushButton("🧹 Maintenance…")
        maintenance_btn.setToolTip(
            "Open TM maintenance dialog: remove identical source/target pairs, "
            "remove duplicate sources (keep newest)."
        )
        maintenance_btn.clicked.connect(self._show_tm_maintenance_dialog)
        button_layout.addWidget(maintenance_btn)

        copy_stats_btn = QPushButton("📊 Copy stats")
        copy_stats_btn.setToolTip(
            "Copy a markdown TM statistics report to the clipboard "
            "(total TUs, per-TM breakdown, averages)."
        )
        copy_stats_btn.clicked.connect(
            lambda: self._copy_tm_stats_to_clipboard(tm_metadata_mgr)
        )
        button_layout.addWidget(copy_stats_btn)

        layout.addLayout(button_layout)

        return tab
    
    def _show_tm_context_menu(self, tm_table, tm_metadata_mgr, refresh_callback, pos):
        """Show context menu for TM table"""
        current_row = tm_table.rowAt(pos.y())
        if current_row < 0:
            return
        
        # Get TM info from the row (column 0 = TM Name)
        name_item = tm_table.item(current_row, 0)
        if not name_item:
            return
        
        tm_name = name_item.text()
        
        # Find the TM in the list
        all_tms = tm_metadata_mgr.get_all_tms()
        tm = None
        for t in all_tms:
            if t['name'] == tm_name:
                tm = t
                break
        
        if not tm:
            return
        
        menu = QMenu(tm_table)
        
        # Check if this TM is already the project TM
        is_project_tm = tm.get('is_project_tm', False)
        
        # Get current project
        current_proj = self.current_project if hasattr(self, 'current_project') else None
        project_id = current_proj.id if (current_proj and hasattr(current_proj, 'id')) else None
        
        if project_id:
            if not is_project_tm:
                set_project_action = menu.addAction("📌 Set as Project TM")
                set_project_action.triggered.connect(
                    lambda: self._set_tm_as_project(tm['id'], project_id, tm_metadata_mgr, refresh_callback)
                )
            else:
                unset_project_action = menu.addAction("📌 Unset as Project TM")
                unset_project_action.triggered.connect(
                    lambda: self._unset_tm_as_project(tm['id'], tm_metadata_mgr, refresh_callback)
                )
        else:
            no_project_action = menu.addAction("⚠️ Load a project first")
            no_project_action.setEnabled(False)
        
        menu.addSeparator()
        
        # Read-only toggle
        is_readonly = tm.get('read_only', False)
        readonly_text = "✏️ Set as Writable" if is_readonly else "🔒 Set as Read-Only"
        readonly_action = menu.addAction(readonly_text)
        readonly_action.triggered.connect(
            lambda: self._toggle_tm_readonly(tm['id'], not is_readonly, tm_metadata_mgr, refresh_callback)
        )
        
        menu.exec(tm_table.viewport().mapToGlobal(pos))
    
    def _set_tm_as_project(self, tm_id, project_id, tm_metadata_mgr, refresh_callback):
        """Set a TM as the project TM"""
        success = tm_metadata_mgr.set_as_project_tm(tm_id, project_id)
        if success:
            self.log(f"✅ Set TM as Project TM")
            refresh_callback()
        else:
            QMessageBox.warning(self, "Error", "Failed to set TM as Project TM")
    
    def _unset_tm_as_project(self, tm_id, tm_metadata_mgr, refresh_callback):
        """Unset a TM as project TM"""
        success = tm_metadata_mgr.unset_project_tm(tm_id)
        if success:
            self.log(f"✅ Unset Project TM")
            refresh_callback()
        else:
            QMessageBox.warning(self, "Error", "Failed to unset Project TM")
    
    def _toggle_tm_readonly(self, tm_id, readonly, tm_metadata_mgr, refresh_callback):
        """Toggle read-only status of a TM"""
        success = tm_metadata_mgr.set_read_only(tm_id, readonly)
        if success:
            status = "read-only" if readonly else "writable"
            self.log(f"✅ Set TM as {status}")
            refresh_callback()
        else:
            QMessageBox.warning(self, "Error", f"Failed to set TM as {'read-only' if readonly else 'writable'}")
    
    def import_tmx_file(self):
        """Import TMX file into translation memory with language variant handling"""
        try:
            file_path, _ = fdh.get_open_file_name(
                self, 
                "Import TMX File", 
                "TMX Files (*.tmx);;All Files (*.*)"
            )
            
            if file_path and self.tm_database:
                # Get target languages
                source_lang = getattr(self.current_project, 'source_lang', 'en') if self.current_project else 'en'
                target_lang = getattr(self.current_project, 'target_lang', 'nl') if self.current_project else 'nl'
                
                # Detect TMX languages
                tmx_langs = self.tm_database.detect_tmx_languages(file_path)
                self.log(f"TMX languages detected: {tmx_langs}")
                
                # Check compatibility
                compat = self.tm_database.check_language_compatibility(tmx_langs, source_lang, target_lang)
                
                if not compat['compatible']:
                    QMessageBox.warning(
                        self, "Language Mismatch",
                        f"TMX file languages ({', '.join(tmx_langs)}) don't match project languages ({source_lang}, {target_lang}).\n\n"
                        "Cannot import this TMX file."
                    )
                    return
                
                # Handle variant mismatch
                if compat.get('variant_match'):
                    choice = self._show_language_variant_dialog(compat)
                    
                    if choice == 'cancel':
                        return
                    
                    # Show progress dialog
                    from PyQt6.QtWidgets import QProgressDialog
                    from PyQt6.QtCore import Qt
                    import time
                    
                    # Create progress dialog with cancel button
                    progress = QProgressDialog("Counting translation units...", "Cancel", 0, 100, self)
                    progress.setWindowTitle("TMX Import")
                    progress.setWindowModality(Qt.WindowModality.WindowModal)
                    progress.setMinimumDuration(0)
                    progress.setAutoClose(False)
                    progress.setAutoReset(False)
                    progress.setValue(0)
                    progress.show()
                    QApplication.processEvents()
                    
                    # Track if user cancelled
                    cancelled = False
                    start_time = time.time()
                    
                    def update_progress(current, total, message):
                        nonlocal cancelled
                        if progress.wasCanceled():
                            cancelled = True
                            return
                        
                        if total > 0:
                            percentage = int((current / total) * 100)
                            progress.setValue(percentage)
                            
                            # Calculate time stats
                            elapsed = time.time() - start_time
                            if current > 0:
                                rate = current / elapsed
                                remaining = (total - current) / rate if rate > 0 else 0
                                
                                # Format message with stats
                                time_info = f" (~{int(remaining/60)}m {int(remaining%60)}s remaining)" if remaining > 10 else ""
                                progress.setLabelText(f"{message}\nRate: {int(rate):,} entries/sec{time_info}")
                            else:
                                progress.setLabelText(message)
                        else:
                            progress.setLabelText(message)
                        
                        QApplication.processEvents()
                    
                    if choice == 'import_strip':
                        # Import with variant stripping
                        tm_id, count = self.tm_database.load_tmx_file(
                            file_path, source_lang, target_lang, 
                            tm_name=None, read_only=False, progress_callback=update_progress
                        )
                        progress.close()
                        
                        if not cancelled:
                            self.log(f"Imported {count} entries (variants stripped: {compat['tmx_source']}, {compat['tmx_target']} → {source_lang}, {target_lang})")
                            QMessageBox.information(
                                self, "Import Complete",
                                f"TMX imported successfully!\n\nEntries: {count:,}\n"
                                f"Mapped: {compat['tmx_source']}, {compat['tmx_target']} → {source_lang}, {target_lang}"
                            )
                        else:
                            self.log("TMX import cancelled by user")
                            QMessageBox.information(self, "Import Cancelled", f"Import stopped.\n\nPartial entries may have been imported: {count:,}")
                    elif choice == 'create_new':
                        # Create new TM with variant languages
                        tm_id, count = self.tm_database.load_tmx_file(
                            file_path, compat['tmx_source'], compat['tmx_target'],
                            tm_name=None, read_only=False, progress_callback=update_progress
                        )
                        progress.close()
                        
                        if not cancelled:
                            self.log(f"Created new TM with variants: {compat['tmx_source']}, {compat['tmx_target']}")
                            QMessageBox.information(
                                self, "Import Complete", 
                                f"New TM created!\n\nTM ID: {tm_id}\nEntries: {count:,}\n"
                                f"Languages: {compat['tmx_source']}, {compat['tmx_target']}"
                            )
                        else:
                            self.log("TMX import cancelled by user")
                            QMessageBox.information(self, "Import Cancelled", f"Import stopped.\n\nPartial entries may have been imported: {count:,}")
                else:
                    # Exact match - proceed normally with real-time progress
                    from PyQt6.QtWidgets import QProgressDialog
                    from PyQt6.QtCore import Qt
                    import time
                    
                    progress = QProgressDialog("Counting translation units...", "Cancel", 0, 100, self)
                    progress.setWindowTitle("TMX Import")
                    progress.setWindowModality(Qt.WindowModality.WindowModal)
                    progress.setMinimumDuration(0)
                    progress.setAutoClose(False)
                    progress.setAutoReset(False)
                    progress.setValue(0)
                    progress.show()
                    QApplication.processEvents()
                    
                    cancelled = False
                    start_time = time.time()
                    
                    def update_progress(current, total, message):
                        nonlocal cancelled
                        if progress.wasCanceled():
                            cancelled = True
                            return
                        
                        if total > 0:
                            percentage = int((current / total) * 100)
                            progress.setValue(percentage)
                            
                            # Calculate time stats
                            elapsed = time.time() - start_time
                            if current > 0:
                                rate = current / elapsed
                                remaining = (total - current) / rate if rate > 0 else 0
                                
                                # Format message with stats
                                time_info = f" (~{int(remaining/60)}m {int(remaining%60)}s remaining)" if remaining > 10 else ""
                                progress.setLabelText(f"{message}\nRate: {int(rate):,} entries/sec{time_info}")
                            else:
                                progress.setLabelText(message)
                        else:
                            progress.setLabelText(message)
                        
                        QApplication.processEvents()
                    
                    tm_id, count = self.tm_database.load_tmx_file(file_path, source_lang, target_lang, progress_callback=update_progress)
                    progress.close()
                    
                    if not cancelled:
                        self.log(f"Successfully imported {count} entries from TMX file")
                        QMessageBox.information(self, "Import Complete", f"TMX imported!\n\nEntries: {count:,}\nTM ID: {tm_id}")
                    else:
                        self.log("TMX import cancelled by user")
                        QMessageBox.information(self, "Import Cancelled", f"Import stopped.\n\nPartial entries may have been imported: {count:,}")
            
        except Exception as e:
            self.log(f"Error importing TMX file: {e}")
            QMessageBox.critical(self, "Import Error", f"Failed to import TMX:\n\n{e}")
    
    def _show_language_variant_dialog(self, compat_info: dict) -> str:
        """Show dialog for handling language variant mismatch. Returns 'import_strip', 'create_new', or 'cancel'"""
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QRadioButton, QButtonGroup
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Language Variant Detected")
        dialog.setMinimumWidth(500)
        
        layout = QVBoxLayout(dialog)
        
        # Explanation
        info_text = (
            f"<h3>⚠️ Language Variant Mismatch</h3>"
            f"<p>The TMX file uses language variants that don't exactly match your TM:</p>"
            f"<table style='margin: 10px 0;'>"
            f"<tr><td><b>TMX languages:</b></td><td>{compat_info['tmx_source']} → {compat_info['tmx_target']}</td></tr>"
            f"<tr><td><b>Your TM languages:</b></td><td>{compat_info['target_source']} → {compat_info['target_target']}</td></tr>"
            f"</table>"
            f"<p>How would you like to proceed?</p>"
        )
        
        info_label = QLabel(info_text)
        info_label.setWordWrap(True)
        info_label.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info_label)
        
        # Radio button group
        button_group = QButtonGroup(dialog)
        
        option1 = CheckmarkRadioButton(f"Import into existing TM (strip variants: {compat_info['tmx_source']},{compat_info['tmx_target']} → {compat_info['target_source']},{compat_info['target_target']})")
        option1.setChecked(True)
        option1.setToolTip("Import translations by matching base languages, ignoring regional variants")
        button_group.addButton(option1, 1)
        layout.addWidget(option1)
        
        option2 = CheckmarkRadioButton(f"Create new TM with variant languages ({compat_info['tmx_source']}, {compat_info['tmx_target']})")
        option2.setToolTip("Create a separate TM preserving the exact language variants from the TMX")
        button_group.addButton(option2, 2)
        layout.addWidget(option2)
        
        layout.addSpacing(20)
        
        # Buttons
        from PyQt6.QtWidgets import QHBoxLayout
        btn_layout = QHBoxLayout()
        btn_layout.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        btn_layout.addWidget(cancel_btn)
        
        ok_btn = QPushButton("Continue")
        ok_btn.setDefault(True)
        ok_btn.clicked.connect(dialog.accept)
        btn_layout.addWidget(ok_btn)
        
        layout.addLayout(btn_layout)
        
        if dialog.exec() == QDialog.DialogCode.Accepted:
            if option1.isChecked():
                return 'import_strip'
            elif option2.isChecked():
                return 'create_new'
        
        return 'cancel'
    
    def export_document(self):
        """Export the translated project back to a document.

        Replaces the older ``export_target_only_docx`` and
        ``export_okapi_merge`` menu handlers, which were two
        cosmetically-different entry points for what is effectively the
        same engine: Okapi merge primary, with a python-docx fallback
        for DOCX projects in case the merge ever fails.

        Routing:
         - **DOCX-origin projects** (and projects with no known origin
           file – e.g. older project formats from before Okapi-only
           import landed) go through ``export_target_only_docx``, which
           already tries Okapi merge first and falls back to python-docx
           on failure.
         - **Non-DOCX Okapi-origin projects** (IDML, HTML, XLIFF, PO,
           XLSX, PPTX) go through ``export_okapi_merge``, which insists
           on the Okapi round-trip and outputs in the original format.

        The user only ever sees one menu item; this dispatcher picks
        the right back-end.
        """
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(
                self, "Nothing to Export", "Open a project first."
            )
            return

        # Look up the original source file (if any) to decide the route.
        original_path = (
            getattr(self, 'original_docx', None)
            or getattr(self, 'current_document_path', None)
            or getattr(self.current_project, 'original_docx_path', None)
        )
        ext = Path(original_path).suffix.lower() if original_path else ''
        is_okapi = (
            getattr(self.current_project, 'import_engine', '') == 'okapi'
        )

        # Non-DOCX Okapi-origin projects (IDML, HTML, XLIFF, …) want the
        # original-format round-trip. DOCX (Okapi-origin or otherwise)
        # and unknown-origin projects go through the DOCX exporter, which
        # is itself Okapi-aware when the project has the right metadata.
        if is_okapi and ext and ext != '.docx':
            self.export_okapi_merge()
        else:
            self.export_target_only_docx()

    def export_target_only_docx(self):
        """Export target text only as a monolingual DOCX document, preserving original formatting"""
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project with segments first")
                return
            
            # Check if there are any translations
            segments = list(self.current_project.segments)
            translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
            
            if translated_count == 0:
                QMessageBox.warning(
                    self, "No Translations", 
                    "No translated segments found.\n\nPlease translate some segments before exporting."
                )
                return
            
            # Warn if not all segments are translated
            total_count = len(segments)
            if translated_count < total_count:
                reply = QMessageBox.question(
                    self, "Incomplete Translation",
                    f"Only {translated_count} of {total_count} segments are translated.\n\n"
                    f"Untranslated segments will use source text.\n\n"
                    f"Continue with export?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if reply != QMessageBox.StandardButton.Yes:
                    return
            
            # Get save path
            default_name = ""
            if self.current_project.name:
                default_name = self.current_project.name.replace(" ", "_") + "_translated.docx"
            
            file_path, _ = QFileDialog.getSaveFileName(
                self,
                "Export Target Only DOCX",
                default_name,
                "Word Documents (*.docx);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            # Ensure .docx extension
            if not file_path.lower().endswith('.docx'):
                file_path += '.docx'
            
            from docx import Document
            import shutil
            
            # Check if we have the original document to use as template
            original_path = getattr(self, 'original_docx', None) or getattr(self, 'current_document_path', None)

            # ── Okapi merge path ──────────────────────────────────────
            # If this project was imported via Okapi, try to use the sidecar
            # merge endpoint for a faithful round-trip preserving all formatting.
            if (getattr(self.current_project, 'import_engine', '') == 'okapi'
                    and original_path and os.path.exists(original_path)):
                okapi_merged = self._try_okapi_merge_export(
                    segments, original_path, file_path)
                if okapi_merged:
                    return
                # Fall through to standard export if merge failed

            if original_path and os.path.exists(original_path):
                # Copy original document and replace text - preserves all formatting
                self.log(f"Using original document as template: {os.path.basename(original_path)}")
                shutil.copy2(original_path, file_path)

                doc = Document(file_path)

                # Okapi-imported segments wrap text in placeholder tags
                # (<hyperlink1>, <tags2/>, <g1>, <bpt3>, etc.). When the
                # Okapi merge endpoint isn't available we have to strip
                # those too, otherwise nothing matches against python-docx
                # paragraph text and the export comes out fully untranslated.
                _is_okapi_project = (
                    getattr(self.current_project, 'import_engine', '') == 'okapi'
                )

                # Helper function to strip all formatting tags (for matching only)
                import re
                # Match Okapi placeholder tags like <hyperlink1>, <tags2/>, <g3>,
                # <bpt2>, <ept2>, <ph1/> – any tag name EXCEPT the formatting
                # tags that apply_formatted_text_to_paragraph knows how to render.
                _OKAPI_TAG_RE = re.compile(
                    r'</?(?!(?:b|i|u|bi|sub|sup|li|li-b|li-o)\b)'
                    r'[A-Za-z][A-Za-z0-9_-]*\s*/?>'
                )

                def strip_all_tags(text):
                    """Remove all formatting and list tags from text"""
                    text = re.sub(r'</?li-[bo]>', '', text)  # <li-b>, </li-b>, <li-o>, </li-o>
                    text = re.sub(r'</?li>', '', text)       # <li>, </li>
                    text = re.sub(r'</?[biu]>', '', text)    # <b>, </b>, <i>, </i>, <u>, </u>
                    text = re.sub(r'</?bi>', '', text)       # <bi>, </bi>
                    text = re.sub(r'</?sub>', '', text)      # <sub>, </sub>
                    text = re.sub(r'</?sup>', '', text)      # <sup>, </sup>
                    if _is_okapi_project:
                        # Generic strip for Okapi placeholder tags
                        text = _OKAPI_TAG_RE.sub('', text)
                    return text.strip()

                def clean_special_chars(text):
                    """Remove problematic Unicode characters like object replacement char"""
                    # Remove Unicode Object Replacement Character (U+FFFC) and similar
                    text = text.replace('\ufffc', '')  # Object Replacement Character
                    text = text.replace('\ufffe', '')  # Noncharacter
                    text = text.replace('\uffff', '')  # Noncharacter
                    text = text.replace('\u0000', '')  # Null character
                    return text
                
                def apply_formatted_text_to_paragraph(para, tagged_text):
                    """
                    Replace paragraph text with tagged text, applying bold/italic/underline formatting.
                    Parses tags like <b>, <i>, <u>, <bi> and creates appropriate runs.
                    Preserves original font name and size from the first run.
                    """
                    # Clean special characters first
                    text = clean_special_chars(tagged_text)

                    # Strip list tags - they don't affect formatting
                    text = re.sub(r'</?li-[bo]>', '', text)
                    text = re.sub(r'</?li>', '', text)

                    # Capture original font properties BEFORE clearing runs
                    original_font_name = None
                    original_font_size = None
                    original_all_caps = None
                    if para.runs:
                        first_run = para.runs[0]
                        if first_run.font:
                            original_font_name = first_run.font.name
                            original_font_size = first_run.font.size
                            original_all_caps = first_run.font.all_caps

                    # Clear existing runs
                    for run in para.runs:
                        run.clear()
                    # Remove the cleared runs
                    for run in list(para.runs):
                        run._element.getparent().remove(run._element)

                    # Parse tags and create runs with formatting
                    # Pattern matches tags or text between tags
                    tag_pattern = re.compile(r'(</?(?:b|i|u|bi|sub|sup)>)')
                    parts = tag_pattern.split(text)

                    is_bold = False
                    is_italic = False
                    is_underline = False
                    is_subscript = False
                    is_superscript = False

                    for part in parts:
                        if not part:
                            continue

                        # Check if this is a tag
                        if part == '<b>':
                            is_bold = True
                        elif part == '</b>':
                            is_bold = False
                        elif part == '<i>':
                            is_italic = True
                        elif part == '</i>':
                            is_italic = False
                        elif part == '<u>':
                            is_underline = True
                        elif part == '</u>':
                            is_underline = False
                        elif part == '<bi>':
                            is_bold = True
                            is_italic = True
                        elif part == '</bi>':
                            is_bold = False
                            is_italic = False
                        elif part == '<sub>':
                            is_subscript = True
                        elif part == '</sub>':
                            is_subscript = False
                        elif part == '<sup>':
                            is_superscript = True
                        elif part == '</sup>':
                            is_superscript = False
                        else:
                            # This is text content - create a run with current formatting
                            if part.strip() or part:  # Include whitespace
                                run = para.add_run(part)
                                run.bold = is_bold
                                run.italic = is_italic
                                run.underline = is_underline
                                if is_subscript:
                                    run.font.subscript = True
                                if is_superscript:
                                    run.font.superscript = True
                                # Restore original font properties
                                if original_font_name:
                                    run.font.name = original_font_name
                                if original_font_size:
                                    run.font.size = original_font_size
                                if original_all_caps:
                                    run.font.all_caps = original_all_caps
                
                # Build a mapping of source text (without tags) to raw target text (with tags)
                text_map = {}
                for seg in segments:
                    # Strip tags from source for matching against original DOCX
                    source_clean = strip_all_tags(seg.source) if seg.source else ""
                    source_clean = clean_special_chars(source_clean)
                    # Keep raw target text WITH formatting tags (b/i/u/sub/sup)
                    # but strip Okapi placeholder tags – apply_formatted_text_to_paragraph
                    # would otherwise insert <hyperlink1> etc. as literal text.
                    target_raw = seg.target.strip() if seg.target and seg.target.strip() else seg.source
                    if _is_okapi_project and target_raw:
                        target_raw = _OKAPI_TAG_RE.sub('', target_raw)
                    if source_clean and target_raw:
                        text_map[source_clean] = target_raw
                
                def replace_segments_in_text_with_tags(original_text, text_map):
                    """Replace all matching segments in text, preserving formatting tags."""
                    result = original_text
                    # Sort by length (longest first) to avoid partial replacement issues
                    for source_clean, target_raw in sorted(text_map.items(), key=lambda x: len(x[0]), reverse=True):
                        if source_clean in result:
                            # Keep target WITH tags for formatting preservation
                            result = result.replace(source_clean, target_raw)
                    return result
                
                replaced_count = 0
                
                # Replace text in paragraphs (outside tables)
                for para in doc.paragraphs:
                    para_text = para.text.strip()
                    
                    # First try exact match (single segment = whole paragraph)
                    if para_text in text_map:
                        target_text = text_map[para_text]
                        apply_formatted_text_to_paragraph(para, target_text)
                        replaced_count += 1
                    else:
                        # Try partial replacement (paragraph contains multiple segments)
                        new_text = replace_segments_in_text_with_tags(para_text, text_map)
                        if new_text != para_text:
                            # Text was changed - use apply_formatted_text_to_paragraph
                            # to preserve inline formatting tags (bold, italic, sub, sup, etc.)
                            apply_formatted_text_to_paragraph(para, new_text)
                            replaced_count += 1

                # Replace text in tables
                for table in doc.tables:
                    for row in table.rows:
                        for cell in row.cells:
                            for para in cell.paragraphs:
                                para_text = para.text.strip()
                                
                                # First try exact match
                                if para_text in text_map:
                                    target_text = text_map[para_text]
                                    apply_formatted_text_to_paragraph(para, target_text)
                                    replaced_count += 1
                                else:
                                    # Try partial replacement
                                    new_text = replace_segments_in_text_with_tags(para_text, text_map)
                                    if new_text != para_text:
                                        # Use apply_formatted_text_to_paragraph to preserve formatting
                                        apply_formatted_text_to_paragraph(para, new_text)
                                        replaced_count += 1

                # Set document language to target language
                if hasattr(self.current_project, 'target_lang') and self.current_project.target_lang:
                    target_lang_code = get_docx_language_code(self.current_project.target_lang)
                    set_docx_language(doc, target_lang_code)
                    self.log(f"✓ Set document language to: {target_lang_code}")

                doc.save(file_path)
                self.log(f"✓ Replaced {replaced_count} text segments in original document structure")

            else:
                # No original document - create simple paragraph-based export
                self.log("No original document found - creating new document (formatting may differ)")
                
                # Helper function to strip all formatting tags (if not already defined)
                import re
                def strip_all_tags(text):
                    """Remove all formatting and list tags from text"""
                    text = re.sub(r'</?li-[bo]>', '', text)  # <li-b>, </li-b>, <li-o>, </li-o>
                    text = re.sub(r'</?li>', '', text)       # <li>, </li>
                    text = re.sub(r'</?[biu]>', '', text)    # <b>, </b>, <i>, </i>, <u>, </u>
                    text = re.sub(r'</?bi>', '', text)       # <bi>, </bi>
                    text = re.sub(r'</?sub>', '', text)      # <sub>, </sub>
                    text = re.sub(r'</?sup>', '', text)      # <sup>, </sup>
                    return text.strip()

                def clean_special_chars(text):
                    """Remove problematic Unicode characters"""
                    text = text.replace('\ufffc', '')  # Object Replacement Character
                    text = text.replace('\ufffe', '')  # Noncharacter
                    text = text.replace('\uffff', '')  # Noncharacter
                    text = text.replace('\u0000', '')  # Null character
                    return text
                
                def add_formatted_text_to_paragraph(para, tagged_text):
                    """Add text with formatting tags to a paragraph."""
                    # Clean special characters first
                    text = clean_special_chars(tagged_text)
                    
                    # Strip list tags
                    text = re.sub(r'</?li-[bo]>', '', text)
                    text = re.sub(r'</?li>', '', text)
                    
                    # Parse and apply formatting
                    tag_pattern = re.compile(r'(</?(?:b|i|u|bi|sub|sup)>)')
                    parts = tag_pattern.split(text)

                    is_bold = False
                    is_italic = False
                    is_underline = False
                    is_subscript = False
                    is_superscript = False

                    for part in parts:
                        if not part:
                            continue
                        if part == '<b>':
                            is_bold = True
                        elif part == '</b>':
                            is_bold = False
                        elif part == '<i>':
                            is_italic = True
                        elif part == '</i>':
                            is_italic = False
                        elif part == '<u>':
                            is_underline = True
                        elif part == '</u>':
                            is_underline = False
                        elif part == '<bi>':
                            is_bold = True
                            is_italic = True
                        elif part == '</bi>':
                            is_bold = False
                            is_italic = False
                        elif part == '<sub>':
                            is_subscript = True
                        elif part == '</sub>':
                            is_subscript = False
                        elif part == '<sup>':
                            is_superscript = True
                        elif part == '</sup>':
                            is_superscript = False
                        else:
                            if part:
                                run = para.add_run(part)
                                run.bold = is_bold
                                run.italic = is_italic
                                run.underline = is_underline
                                if is_subscript:
                                    run.font.subscript = True
                                if is_superscript:
                                    run.font.superscript = True

                doc = Document()
                
                for seg in segments:
                    raw_text = seg.target.strip() if seg.target and seg.target.strip() else seg.source
                    para = doc.add_paragraph()
                    
                    # Try to apply heading style if segment has heading style
                    if hasattr(seg, 'style') and seg.style:
                        style_lower = seg.style.lower()
                        if 'heading 1' in style_lower:
                            para.style = 'Heading 1'
                        elif 'heading 2' in style_lower:
                            para.style = 'Heading 2'
                        elif 'heading 3' in style_lower:
                            para.style = 'Heading 3'
                        elif 'title' in style_lower:
                            para.style = 'Title'
                    
                    # Add text with formatting
                    if raw_text:
                        add_formatted_text_to_paragraph(para, raw_text)

                # Set document language to target language
                if hasattr(self.current_project, 'target_lang') and self.current_project.target_lang:
                    target_lang_code = get_docx_language_code(self.current_project.target_lang)
                    set_docx_language(doc, target_lang_code)
                    self.log(f"✓ Set document language to: {target_lang_code}")

                doc.save(file_path)

            # Attach segment notes as Word comments (post-process). Runs
            # for both the "copy original as template" branch above and
            # the "no original document" simple-paragraph branch — either
            # way the file at file_path is a saved DOCX by this point.
            self._attach_segment_notes_as_docx_comments(file_path, segments)

            self.log(f"✓ Exported {len(segments)} segments to: {os.path.basename(file_path)}")
            
            QMessageBox.information(
                self, "Export Complete",
                f"Successfully exported {translated_count} translated segments to:\n\n{os.path.basename(file_path)}"
            )
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for DOCX export.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            self.log(f"✗ Export failed: {str(e)}")
            QMessageBox.critical(self, "Export Error", f"Failed to export DOCX:\n\n{str(e)}")
            import traceback
            traceback.print_exc()

    def _split_docx_run_at_offset(self, run, offset):
        """Split a python-docx Run at the given character offset within its text.

        v1.10.60 Phase E: enables Word-comment range anchoring. To anchor
        a comment to only "schroef" within a longer paragraph, we need
        Word's commentRangeStart / commentRangeEnd to bracket exactly
        the runs covering "schroef" — and runs are immutable text
        fragments with formatting, so cutting mid-run requires creating
        a new run.

        Returns (left, right). The original ``run`` becomes ``left``
        (its text truncated to ``text[:offset]``). A new run is
        inserted into the paragraph immediately after the original,
        with text ``text[offset:]`` and the same formatting (rPr
        element deep-copied so bold/italic/font/colour all survive).

        Edge cases:
         - offset <= 0: no split, returns (None, run).
         - offset >= len(text): no split, returns (run, None).
        """
        from copy import deepcopy
        from docx.oxml.ns import qn
        from docx.text.run import Run as _DocxRun

        text = run.text or ''
        if offset <= 0:
            return (None, run)
        if offset >= len(text):
            return (run, None)

        left_text = text[:offset]
        right_text = text[offset:]

        # Deep-copy the run's XML element so the new run inherits all
        # of its formatting (rPr child with bold/italic/font/colour/etc.).
        new_element = deepcopy(run._element)

        # Replace the copied w:t element(s) with a single one containing
        # right_text. xml:space="preserve" prevents Word from collapsing
        # leading/trailing whitespace.
        for t_elem in list(new_element.findall(qn('w:t'))):
            new_element.remove(t_elem)
        new_t = run._element.makeelement(qn('w:t'), {qn('xml:space'): 'preserve'})
        new_t.text = right_text
        new_element.append(new_t)

        # Truncate the original run.
        run.text = left_text

        # Insert the new run element immediately after the original.
        run._element.addnext(new_element)

        new_run = _DocxRun(new_element, run._parent)
        return (run, new_run)

    def _find_or_split_runs_for_range(self, para, start, end):
        """Return the runs that exactly cover ``para.text[start:end]``.

        Splits runs at the start and/or end of the range when those
        boundaries fall mid-run. The returned runs, concatenated,
        produce exactly ``para.text[start:end]``.

        After splitting, ``para.runs`` itself is also restructured —
        the new sub-runs are inserted in the paragraph's XML so that
        subsequent paragraph operations see them.

        Returns an empty list if the range is empty or out of bounds.
        """
        if start >= end:
            return []

        # Walk runs accumulating absolute offsets. We can't trust
        # para.runs across mutations, so re-snapshot after each split.
        result: list = []

        def _walk_once():
            runs = list(para.runs)
            offset = 0
            for run in runs:
                run_text = run.text or ''
                run_len = len(run_text)
                run_start = offset
                run_end = offset + run_len
                offset = run_end

                # No overlap with [start, end)?
                if run_end <= start or run_start >= end:
                    continue

                # The run intersects the range. Two split points to consider:
                # local_start = where the range begins within this run
                # local_end   = where the range ends within this run
                local_start = max(0, start - run_start)
                local_end = min(run_len, end - run_start)

                if local_start > 0:
                    # Range starts mid-run — split off the prefix.
                    _left, _right = self._split_docx_run_at_offset(run, local_start)
                    return True  # mutation happened; re-walk from scratch

                if local_end < run_len:
                    # Range ends mid-run — split off the suffix.
                    _left, _right = self._split_docx_run_at_offset(run, local_end)
                    return True  # mutation happened; re-walk

                # Run fits entirely within the range — collect it.
                result.append(run)
            return False  # walk completed without mutation

        # Iterate until no more splits are needed. Bounded by 2*N splits
        # max (one each for start and end), so loop count is small.
        for _ in range(64):
            result = []
            if not _walk_once():
                break

        return result

    def _comment_author_and_initials(self):
        """Resolve the comment author + initials used in DOCX export.

        Pulled out of the inline body in ``_attach_segment_comments_as_docx_comments``
        so it can be reused (e.g. when range-anchoring decides to put a
        snippet-prefixed comment on the whole paragraph for source-anchored
        comments).
        """
        author = self.get_translator_name()
        parts = author.split()
        if len(parts) >= 2:
            initials = ''.join(p[0] for p in parts if p)[:4].upper()
        elif parts:
            initials = parts[0][:2].upper()
        else:
            initials = 'X'
        return author, initials

    def _attach_segment_notes_as_docx_comments(self, docx_path, segments):
        """Post-process an exported DOCX to attach segment comments as Word comments.

        v1.10.60 Phase E: now iterates ``segment.comments`` (the new
        structured list) instead of the legacy single-string
        ``segment.notes``. Behaviour per Comment:

         - **Unanchored (segment-level)**: same as v1.10.55 behaviour —
           Word comment anchors to the full paragraph (all its runs).
         - **Target-anchored**: finds the paragraph that contains the
           segment's target text, then finds the character range within
           that paragraph corresponding to the comment's
           ``anchor_start/end``. Runs are split if the boundaries cut
           mid-run, and the comment is anchored to exactly the resulting
           sub-runs. End result in Word: a yellow comment bubble whose
           range highlight covers exactly the anchored words.
         - **Source-anchored**: the source text isn't in the exported
           DOCX (the export is target-only), so we can't highlight a
           source range. The comment is attached to the whole paragraph
           with the source-snippet prefixed in the body so the reviewer
           knows what it's about: e.g. ``[Re: "schroef" (source)] use
           "screw" not "bolt"``.

        Silently no-ops on the usual prerequisites: no segments with
        comments, missing python-docx ``add_comment`` API, DOCX path not
        reopenable. Failures attaching individual comments are logged
        but never abort the export.

        Function name and signature kept for backward-compat with the
        existing call-sites in ``_try_okapi_merge_export`` and
        ``export_target_only_docx``.
        """
        # Collect all (segment, comment) pairs that have something to attach.
        candidates: list = []  # [(seg, comment), ...] in segment+document order
        for seg in segments:
            comments_list = getattr(seg, 'comments', None) or []
            # Backward-compat: if a segment somehow only has the legacy
            # notes string (older in-memory object that wasn't migrated),
            # treat it as a single unanchored Comment-shaped tuple.
            if not comments_list:
                legacy_note = (getattr(seg, 'notes', '') or '').strip()
                if legacy_note:
                    # Build a temporary Comment-like object so the loop
                    # below doesn't need to special-case.
                    legacy_comment = Comment(
                        text=legacy_note, author='', anchor_field='',
                    )
                    candidates.append((seg, legacy_comment))
                continue
            target_text = (getattr(seg, 'target', '') or '').strip()
            if not target_text and not any(
                c.anchor_field == 'source' for c in comments_list
            ):
                # No target text and no source-anchored comments means
                # nothing locatable in the exported DOCX. Skip.
                continue
            for c in comments_list:
                if c.text and c.text.strip():
                    candidates.append((seg, c))

        if not candidates:
            return

        try:
            from docx import Document
            doc = Document(docx_path)
        except Exception as e:
            self.log(f"  ⚠ Comment-attach: could not reopen exported DOCX ({e})")
            return

        if not hasattr(doc, 'add_comment'):
            self.log(
                f"  ⚠ Comment-attach: python-docx version doesn't expose "
                f"Document.add_comment; install python-docx >= 1.0 to enable "
                f"segment-comment export as Word comments. ({len(candidates)} comment(s) skipped.)"
            )
            return

        author, initials = self._comment_author_and_initials()

        # Build a segment_id → matched-paragraph cache. Multiple
        # comments on the same segment all reuse the same paragraph
        # (the one containing the segment's target text). First
        # encounter populates the cache; subsequent encounters reuse.
        segment_to_para: dict = {}
        # Track which paragraphs are already "claimed" by a segment so a
        # collision (two segments with identical target text) doesn't
        # repeatedly map to the same paragraph.
        claimed_paragraph_ids: set = set()

        def _find_paragraph_for_segment(seg):
            seg_target = (getattr(seg, 'target', '') or '').strip()
            if not seg_target:
                return None
            if seg.id in segment_to_para:
                return segment_to_para[seg.id]
            for para in doc.paragraphs:
                if id(para) in claimed_paragraph_ids:
                    continue
                para_text = para.text or ''
                if seg_target in para_text and para.runs:
                    segment_to_para[seg.id] = para
                    claimed_paragraph_ids.add(id(para))
                    return para
            return None

        attached_total = 0
        attached_anchored = 0
        unmatched_no_para = 0
        skipped_no_runs = 0

        for seg, comment in candidates:
            para = _find_paragraph_for_segment(seg)
            if para is None:
                unmatched_no_para += 1
                continue
            if not para.runs:
                skipped_no_runs += 1
                continue

            body = comment.text
            runs_to_anchor = None

            try:
                if comment.anchor_field == 'target' and comment.is_anchored:
                    # Target-side range anchoring: split runs as needed.
                    # The anchor offsets are character offsets within
                    # the SEGMENT'S target text, but we're anchoring
                    # inside the PARAGRAPH text. Map by finding the
                    # segment's target as a substring of the paragraph
                    # and offsetting.
                    seg_target = getattr(seg, 'target', '') or ''
                    para_text = para.text or ''
                    para_offset = para_text.find(seg_target)
                    if para_offset < 0:
                        # Defensive: shouldn't happen because we found
                        # the paragraph by containment.
                        runs_to_anchor = list(para.runs)
                    else:
                        text_len = len(seg_target)
                        anchor_start_para = para_offset + max(0, min(comment.anchor_start, text_len))
                        anchor_end_para = para_offset + max(0, min(comment.anchor_end, text_len))
                        ranged_runs = self._find_or_split_runs_for_range(
                            para, anchor_start_para, anchor_end_para
                        )
                        if ranged_runs:
                            runs_to_anchor = ranged_runs
                            attached_anchored += 1
                        else:
                            runs_to_anchor = list(para.runs)

                elif comment.anchor_field == 'source' and comment.is_anchored:
                    # Source-anchored: prefix the comment body with the
                    # source snippet so the reviewer knows what it's about,
                    # then anchor to the whole paragraph (since source text
                    # isn't present in the exported DOCX).
                    seg_source = getattr(seg, 'source', '') or ''
                    slen = len(seg_source)
                    sstart = max(0, min(comment.anchor_start, slen))
                    send = max(sstart, min(comment.anchor_end, slen))
                    snippet = seg_source[sstart:send]
                    snippet_short = snippet[:120] + ('…' if len(snippet) > 120 else '')
                    body = f'[Re: "{snippet_short}" (source)] {body}'
                    runs_to_anchor = list(para.runs)

                else:
                    # Unanchored / segment-level — anchor to whole paragraph.
                    runs_to_anchor = list(para.runs)

                if not runs_to_anchor:
                    skipped_no_runs += 1
                    continue

                doc.add_comment(
                    runs=runs_to_anchor,
                    text=body,
                    author=author,
                    initials=initials,
                )
                attached_total += 1
            except Exception as e:
                self.log(
                    f"  ⚠ Comment-attach: failed for segment "
                    f"#{getattr(seg, 'id', '?')} (anchor={comment.anchor_field or 'none'}): {e}"
                )

        # Save back only if we actually added anything.
        if attached_total > 0:
            try:
                doc.save(docx_path)
                msg = (
                    f"  ✓ Attached {attached_total} segment comment(s) "
                    f"as Word comments"
                )
                if attached_anchored:
                    msg += f" ({attached_anchored} range-anchored)"
                if unmatched_no_para:
                    msg += f" ({unmatched_no_para} unmatched — target text not found in any paragraph)"
                if skipped_no_runs:
                    msg += f" ({skipped_no_runs} skipped — empty paragraph)"
                self.log(msg)
            except Exception as e:
                self.log(f"  ⚠ Comment-attach: failed to save DOCX with comments: {e}")
        else:
            if unmatched_no_para:
                self.log(
                    f"  ⚠ Comment-attach: {unmatched_no_para} segment "
                    f"comment(s) had no matching paragraph in the exported DOCX"
                )

    def _try_okapi_merge_export(self, segments, original_path, output_path):
        """Attempt to export via Okapi sidecar merge. Returns True on success, False to fall back."""
        try:
            import re as _re

            # Check sidecar availability
            if not getattr(self, 'okapi_sidecar', None) or not self.okapi_sidecar.is_running():
                self.log("Okapi sidecar not running -- falling back to standard export")
                QMessageBox.warning(
                    self, "Okapi Sidecar Unavailable",
                    "This project was imported with the Okapi engine, but the sidecar "
                    "is not currently running.\n\n"
                    "Falling back to standard export. Some formatting may differ from "
                    "the original document."
                )
                return False

            # Build translations list, ONE entry per Okapi text-unit ID.
            #
            # Why one-per-TU and not one-per-segment: the sidecar applies
            # SRX sentence segmentation only inside its extract helper
            # (segmentTextContentWithTags) – it doesn't actually re-segment
            # the underlying ITextUnit. So when /merge re-opens the original
            # file and reads the same TU, srcSegments.count() == 1. The
            # merge loop then drops any MergeSegment with segmentIndex >= 1,
            # silently losing the 2nd, 3rd, … sentences of every paragraph
            # we sent (verified end-to-end against an IDML round-trip in
            # v1.9.470 testing).
            #
            # Workaround: group all our per-sentence segments back by
            # okapi_tu_id, sort by okapi_segment_index, concatenate the
            # translations with a single space, and send one merge entry
            # per TU at segmentIndex=0. The whole TU's text gets written
            # into the single source segment that actually exists in the
            # filter's view of the document. This costs us a possible
            # extra space at SRX sentence boundaries (the LLM strips
            # leading/trailing whitespace from each segment translation),
            # which is a rounding error in laid-out text but matters less
            # than the alternative of silently losing sentences.
            from collections import defaultdict as _defaultdict
            tu_groups: dict = _defaultdict(list)  # tu_id → [(seg_idx, translation)]
            skipped = 0
            for seg in segments:
                tu_id = getattr(seg, 'okapi_tu_id', '')
                seg_idx = getattr(seg, 'okapi_segment_index', -1)

                if not tu_id or seg_idx < 0:
                    skipped += 1
                    continue

                # Use target if translated, otherwise use source
                translation = seg.target.strip() if seg.target and seg.target.strip() else seg.source

                # Keep inline formatting tags (<b>, <i>, etc.) -- the Java
                # merge reconstructs Okapi inline codes from them.  Only
                # strip Supervertaler-specific tags that Okapi wouldn't know.
                translation = _re.sub(r'</?(?:bi|li-[bo]|li)>', '', translation)

                tu_groups[tu_id].append((seg_idx, translation))

            translations = []
            for tu_id, sub_segs in tu_groups.items():
                sub_segs.sort(key=lambda pair: pair[0])
                combined = ' '.join(t for _, t in sub_segs)

                # Strip Okapi-internal run tags (<run1>, </run2>, …) that
                # occasionally leak through from the source-to-cf
                # conversion when SRX segments split inside an inline run.
                # Our format uses <cf>/<b>/<i> etc.; <run\d+> tags should
                # never appear in user-facing translations. If they reach
                # /merge they unbalance Okapi's tag stack and crash the
                # OpenXML writer with NoSuchElementException — see the
                # BlockTextUnitWriter.writeCode → ArrayDeque.pop crash
                # reported by a Mac user on v1.9.484 with a docx whose
                # paragraphs had inline run boundaries mid-sentence.
                combined = _re.sub(r'</?run\d+>', '', combined)

                translations.append({
                    'id': tu_id,
                    'segmentIndex': 0,
                    'translation': combined,
                })

            if not translations:
                self.log("No segments with Okapi metadata found -- falling back to standard export")
                return False

            multi_tu_count = sum(1 for tu_id, sub in tu_groups.items() if len(sub) > 1)
            if multi_tu_count:
                self.log(f"Combining sub-segments for merge: {multi_tu_count} text-unit(s) "
                         f"had multiple sentences (concatenated for /merge)")
            if skipped > 0:
                self.log(f"{skipped} segments without Okapi metadata will use source text in merge")

            self.log(f"Merging via Okapi sidecar ({len(translations)} segments)...")

            result_path = self.okapi_sidecar.merge(
                original_path=original_path,
                translations=translations,
                source_lang=self.current_project.source_lang,
                target_lang=self.current_project.target_lang,
                output_path=output_path,
            )

            self.log(f"Okapi merge complete: {os.path.basename(result_path)}")

            # Post-process: attach any segment notes as Word comments. Only
            # makes sense for .docx output – Okapi can produce IDML/HTML/
            # XLIFF/etc. too, and python-docx can't open those.
            if result_path.lower().endswith('.docx'):
                self._attach_segment_notes_as_docx_comments(result_path, segments)

            translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
            QMessageBox.information(
                self, "Export Complete (Okapi Merge)",
                f"Successfully exported {translated_count} translated segments to:\n\n"
                f"{os.path.basename(output_path)}\n\n"
                f"Used Okapi Framework merge for faithful format preservation."
            )
            return True

        except FileNotFoundError:
            self.log(f"Original file not found for Okapi merge: {original_path}")
            QMessageBox.warning(
                self, "Original File Not Found",
                f"The original DOCX file needed for Okapi merge was not found:\n\n"
                f"{original_path}\n\n"
                "Falling back to standard export."
            )
            return False

        except Exception as e:
            self.log(f"Okapi merge failed, falling back to standard: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.warning(
                self, "Okapi Merge Failed",
                f"Okapi merge encountered an error:\n\n{str(e)}\n\n"
                "Falling back to standard export."
            )
            return False

    def export_review_table_with_tags(self):
        """Export Supervertaler Bilingual Table with formatting tags visible.
        
        This format is intended for proofreaders who will edit and return the file
        for re-import into Supervertaler.
        """
        self._export_review_table(apply_formatting=False)
    
    def export_review_table_formatted(self):
        """Export Supervertaler Bilingual Table with formatting applied (bold, italic, underline).
        
        This format is intended for end clients who want to see the actual formatting
        rather than the tags.
        """
        # Warn user that this format cannot be re-imported with tags
        reply = QMessageBox.warning(
            self, "Formatted Export - Tags Will Be Applied",
            "This export applies formatting (bold, italic, underline) to the text.\n\n"
            "⚠️ Formatting tags will be converted to actual Word formatting.\n"
            "This version CANNOT be re-imported to restore tagged formatting.\n\n"
            "Use 'Bilingual Table - With Tags' if you need to re-import after review.\n\n"
            "Continue with formatted export?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        
        if reply != QMessageBox.StandardButton.Yes:
            return
        
        self._export_review_table(apply_formatting=True)
    
    def _add_hyperlink_to_paragraph(self, paragraph, url, text):
        """Add a hyperlink to a paragraph in a Word document.
        
        Args:
            paragraph: The paragraph to add the hyperlink to
            url: The URL for the hyperlink
            text: The display text for the hyperlink
            
        Returns:
            The run containing the hyperlink text (for further formatting)
        """
        from docx.oxml.ns import qn
        from docx.oxml import OxmlElement
        
        # Get the document part
        part = paragraph.part
        
        # Create the relationship
        r_id = part.relate_to(url, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', is_external=True)
        
        # Create the hyperlink element
        hyperlink = OxmlElement('w:hyperlink')
        hyperlink.set(qn('r:id'), r_id)
        
        # Create a new run for the hyperlink text
        new_run = OxmlElement('w:r')
        rPr = OxmlElement('w:rPr')
        
        # Add the text
        text_elem = OxmlElement('w:t')
        text_elem.text = text
        new_run.append(rPr)
        new_run.append(text_elem)
        hyperlink.append(new_run)
        
        # Add hyperlink to paragraph
        paragraph._p.append(hyperlink)
        
        # Return a reference to the run for further formatting
        # We need to return a proper Run object
        from docx.text.run import Run
        return Run(new_run, paragraph)
    
    def _export_review_table(self, apply_formatting=False):
        """Internal method to export Supervertaler Bilingual Table.
        
        Args:
            apply_formatting: If True, apply bold/italic/underline formatting.
                            If False, show raw Supervertaler tags.
        """
        import re
        
        # Check if we have segments
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return
        
        segments = list(self.current_project.segments)
        
        if not segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return
        
        # Determine default filename
        project_name = getattr(self.current_project, 'name', 'project')
        if hasattr(self, 'current_project_path') and self.current_project_path:
            project_name = Path(self.current_project_path).stem
        
        format_suffix = "_bilingual_formatted" if apply_formatting else "_bilingual"
        default_name = f"{project_name}{format_suffix}.docx"
        
        # Get save path
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Bilingual Table" if not apply_formatting else "Export Bilingual Table (Formatted)",
            default_name,
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        try:
            from docx import Document
            from docx.shared import Inches, Pt, RGBColor
            from docx.enum.table import WD_TABLE_ALIGNMENT
            from docx.enum.text import WD_ALIGN_PARAGRAPH
            from docx.oxml.ns import qn
            from docx.oxml import OxmlElement
            
            doc = Document()

            # Set up document margins and landscape orientation
            from docx.enum.section import WD_ORIENT
            sections = doc.sections
            for section in sections:
                # Set landscape orientation for better visualization of long segments
                section.orientation = WD_ORIENT.LANDSCAPE
                # Swap page dimensions for landscape
                section.page_width, section.page_height = section.page_height, section.page_width
                # Set margins
                section.left_margin = Inches(0.5)
                section.right_margin = Inches(0.5)
                section.top_margin = Inches(0.5)
                section.bottom_margin = Inches(0.5)
            
            # Language code to display name mapping
            LANG_DISPLAY_NAMES = {
                'en': 'English', 'en-US': 'English (US)', 'en-GB': 'English (UK)', 'en-AU': 'English (AU)',
                'nl': 'Dutch', 'nl-NL': 'Dutch (NL)', 'nl-BE': 'Dutch (Belgium)',
                'de': 'German', 'de-DE': 'German (DE)', 'de-AT': 'German (AT)', 'de-CH': 'German (CH)',
                'fr': 'French', 'fr-FR': 'French (FR)', 'fr-CA': 'French (CA)', 'fr-BE': 'French (BE)',
                'es': 'Spanish', 'es-ES': 'Spanish (ES)', 'es-MX': 'Spanish (MX)', 'es-AR': 'Spanish (AR)',
                'it': 'Italian', 'it-IT': 'Italian (IT)',
                'pt': 'Portuguese', 'pt-PT': 'Portuguese (PT)', 'pt-BR': 'Portuguese (BR)',
                'ru': 'Russian', 'zh': 'Chinese', 'zh-CN': 'Chinese (Simplified)', 'zh-TW': 'Chinese (Traditional)',
                'ja': 'Japanese', 'ko': 'Korean', 'ar': 'Arabic', 'pl': 'Polish', 'sv': 'Swedish',
                'da': 'Danish', 'no': 'Norwegian', 'fi': 'Finnish', 'cs': 'Czech', 'hu': 'Hungarian',
                'tr': 'Turkish', 'el': 'Greek', 'he': 'Hebrew', 'th': 'Thai', 'vi': 'Vietnamese',
            }
            
            def get_language_display_name(lang_value):
                """Convert language code or name to display name."""
                if not lang_value:
                    return "Unknown"
                # If it's already a nice display name (contains space or parentheses), use it
                if ' ' in lang_value or '(' in lang_value:
                    return lang_value
                # Look up in our mapping
                if lang_value in LANG_DISPLAY_NAMES:
                    return LANG_DISPLAY_NAMES[lang_value]
                # Try lowercase
                if lang_value.lower() in LANG_DISPLAY_NAMES:
                    return LANG_DISPLAY_NAMES[lang_value.lower()]
                # Capitalize first letter as fallback
                return lang_value.capitalize() if lang_value else "Unknown"
            
            # Get language names for column headers
            source_lang = "Source"
            target_lang = "Target"
            if self.current_project:
                source_lang = get_language_display_name(self.current_project.source_lang) or "Source"
                target_lang = get_language_display_name(self.current_project.target_lang) or "Target"
            
            # === HEADER SECTION ===
            # Add decorative line above title
            header_line = doc.add_paragraph()
            header_line.alignment = WD_ALIGN_PARAGRAPH.CENTER
            line_run = header_line.add_run("━" * 50)
            line_run.font.color.rgb = RGBColor(0, 102, 204)
            line_run.font.size = Pt(10)
            header_line.paragraph_format.space_after = Pt(6)
            
            # Add title with link to Supervertaler website
            title = doc.add_paragraph()
            title.alignment = WD_ALIGN_PARAGRAPH.CENTER
            title.paragraph_format.space_before = Pt(0)
            title.paragraph_format.space_after = Pt(6)
            
            # Add globe emoji and "Supervertaler Bilingual Table" in blue (no link on title)
            globe_run = title.add_run("🌐 ")
            globe_run.font.size = Pt(18)
            
            title_run = title.add_run("Supervertaler Bilingual Table")
            title_run.font.size = Pt(18)
            title_run.font.bold = True
            title_run.font.color.rgb = RGBColor(0, 102, 204)
            
            # Add subtitle with clickable website URL
            subtitle = doc.add_paragraph()
            subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
            subtitle.paragraph_format.space_before = Pt(0)
            subtitle.paragraph_format.space_after = Pt(6)
            
            # Make Supervertaler.com a clickable link
            url_link = self._add_hyperlink_to_paragraph(subtitle, "https://supervertaler.com/", "Supervertaler.com")
            url_link.font.size = Pt(10)
            url_link.font.color.rgb = RGBColor(0, 102, 204)
            url_link.font.underline = True
            
            # Add decorative line below title
            footer_line = doc.add_paragraph()
            footer_line.alignment = WD_ALIGN_PARAGRAPH.CENTER
            line_run2 = footer_line.add_run("━" * 50)
            line_run2.font.color.rgb = RGBColor(0, 102, 204)
            line_run2.font.size = Pt(10)
            footer_line.paragraph_format.space_after = Pt(12)
            
            # Add project info in a nice format
            info = doc.add_paragraph()
            info.add_run(f"Project: ").bold = True
            info.add_run(f"{project_name}\n")
            info.add_run(f"Languages: ").bold = True
            info.add_run(f"{source_lang} → {target_lang}\n")
            info.add_run(f"Segments: ").bold = True
            info.add_run(f"{len(segments)}\n")
            info.add_run(f"Exported: ").bold = True
            info.add_run(f"{datetime.now().strftime('%Y-%m-%d %H:%M')}\n")
            
            # Add important notice
            notice = doc.add_paragraph()
            notice.paragraph_format.space_before = Pt(6)
            if not apply_formatting:
                warning_run = notice.add_run("⚠️ Important: ")
                warning_run.bold = True
                warning_run.font.color.rgb = RGBColor(180, 100, 0)
                notice.add_run("Do not change segment numbers (#) or source text. ").italic = True
                notice.add_run("This file can be re-imported into Supervertaler after proofreading.").italic = True
            else:
                notice.add_run("Note: This version shows applied formatting and is for client delivery or archiving. It cannot be re-imported.").italic = True
            
            # Create table with 5 columns: #, Source Language, Target Language, Status, Notes
            table = doc.add_table(rows=1, cols=5)
            table.style = 'Table Grid'
            table.alignment = WD_TABLE_ALIGNMENT.CENTER
            
            # Set column widths using fixed layout and explicit grid
            # (mirrors memoQ's proportions: 4.3% / 38.8% / 38.8% / 7.7% / 10.5%)
            tbl = table._tbl
            tbl_pr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')

            # Fixed layout – Word must use our exact widths
            tbl_layout = OxmlElement('w:tblLayout')
            tbl_layout.set(qn('w:type'), 'fixed')
            tbl_pr.append(tbl_layout)

            # Calculate usable width from page setup (twips = 1/1440 inch)
            section = doc.sections[0]
            usable_twips = int((section.page_width - section.left_margin - section.right_margin) / 914400 * 1440)

            # Proportional column widths (matching memoQ ratios)
            ratios = [0.043, 0.388, 0.388, 0.077, 0.105]
            col_twips = [int(r * usable_twips) for r in ratios]
            # Ensure total matches exactly
            col_twips[-1] = usable_twips - sum(col_twips[:-1])

            # Set explicit table width
            tbl_w = OxmlElement('w:tblW')
            tbl_w.set(qn('w:w'), str(usable_twips))
            tbl_w.set(qn('w:type'), 'dxa')
            tbl_pr.append(tbl_w)

            # Replace default grid with our explicit column widths
            old_grid = tbl.find(qn('w:tblGrid'))
            if old_grid is not None:
                tbl.remove(old_grid)
            new_grid = OxmlElement('w:tblGrid')
            for tw in col_twips:
                gc = OxmlElement('w:gridCol')
                gc.set(qn('w:w'), str(tw))
                new_grid.append(gc)
            # Insert grid after tblPr
            tbl.insert(tbl.index(tbl_pr) + 1, new_grid)

            # Also set cell widths for each row (belt and suspenders)
            from docx.shared import Emu
            for row in table.rows:
                for i, tw in enumerate(col_twips):
                    tc = row.cells[i]._tc
                    tcPr = tc.tcPr
                    if tcPr is None:
                        tcPr = OxmlElement('w:tcPr')
                        tc.insert(0, tcPr)
                    tcW = tcPr.find(qn('w:tcW'))
                    if tcW is None:
                        tcW = OxmlElement('w:tcW')
                        tcPr.insert(0, tcW)
                    tcW.set(qn('w:w'), str(tw))
                    tcW.set(qn('w:type'), 'dxa')
            
            # Add header row with actual language names
            header_cells = table.rows[0].cells
            headers = ['#', source_lang, target_lang, 'Status', 'Notes']
            for i, header in enumerate(headers):
                header_cells[i].text = header
                # Make header bold and shaded
                for para in header_cells[i].paragraphs:
                    for run in para.runs:
                        run.bold = True
                        run.font.size = Pt(10)
                # Add shading to header
                shading = OxmlElement('w:shd')
                shading.set(qn('w:fill'), 'DDDDDD')
                header_cells[i]._tc.get_or_add_tcPr().append(shading)
            
            # Helper function to add formatted text to a cell
            def add_formatted_text_to_cell(cell, text, apply_fmt=False):
                """Add text to cell, optionally applying formatting."""
                from docx.shared import RGBColor
                
                # Clear existing content
                for para in cell.paragraphs:
                    para.clear()
                
                if not text:
                    return
                
                para = cell.paragraphs[0]
                para.paragraph_format.space_before = Pt(2)
                para.paragraph_format.space_after = Pt(2)
                
                if not apply_fmt:
                    # Show raw text with tags in pink text (like in Supervertaler)
                    # Parse to find tags and color them - includes Supervertaler tags AND CAT tool tags
                    # Supervertaler tags: <b>, <i>, <u>, <bi>, <li>, <li-o>, <li-b>
                    # memoQ tags: {1}, [2}, {3], [uicontrol id="..."], {tagname}
                    # Trados tags: <1>, </1>
                    tag_pattern = re.compile(
                        r'('
                        r'</?(?:b|i|u|bi|li|li-[bo])>'  # Supervertaler tags
                        r'|</?[0-9]+>'  # Trados numeric tags: <1>, </1>
                        r'|\[[^}\]]+\}'  # memoQ mixed: [anything}
                        r'|\{[^\[\]]+\]'  # memoQ mixed: {anything]
                        r'|\[[a-zA-Z][^}\]]*\s[^}\]]*\]'  # memoQ content: [tag attr...]
                        r'|\{[a-zA-Z][a-zA-Z0-9_-]*\}'  # memoQ closing: {tagname}
                        r'|\[[0-9]+\]'  # memoQ numeric: [1]
                        r'|\{[0-9]+\}'  # memoQ/Phrase numeric: {1}
                        r')'
                    )
                    parts = tag_pattern.split(text)
                    
                    for part in parts:
                        if not part:
                            continue
                        run = para.add_run(part)
                        run.font.size = Pt(9)
                        
                        # Check if this is a tag - make text memoQ dark red
                        if tag_pattern.match(part):
                            run.font.color.rgb = RGBColor(127, 0, 1)  # memoQ dark red (#7f0001)
                else:
                    # Parse tags and apply formatting
                    # First handle list tags - convert to visible markers
                    text = re.sub(r'<li-b>\s*', '• ', text)
                    text = re.sub(r'<li-o>\s*', '◦ ', text)  # Open circle for nested
                    text = re.sub(r'<li>\s*', '– ', text)
                    text = re.sub(r'</li-[bo]>', '', text)
                    text = re.sub(r'</li>', '', text)
                    
                    # Parse formatting tags
                    tag_pattern = re.compile(r'(</?(?:b|i|u|bi)>)')
                    parts = tag_pattern.split(text)
                    
                    is_bold = False
                    is_italic = False
                    is_underline = False
                    
                    for part in parts:
                        if not part:
                            continue
                        if part == '<b>':
                            is_bold = True
                        elif part == '</b>':
                            is_bold = False
                        elif part == '<i>':
                            is_italic = True
                        elif part == '</i>':
                            is_italic = False
                        elif part == '<u>':
                            is_underline = True
                        elif part == '</u>':
                            is_underline = False
                        elif part == '<bi>':
                            is_bold = True
                            is_italic = True
                        elif part == '</bi>':
                            is_bold = False
                            is_italic = False
                        else:
                            # Regular text
                            run = para.add_run(part)
                            run.font.size = Pt(9)
                            run.bold = is_bold
                            run.italic = is_italic
                            run.underline = is_underline
            
            # Helper to get status display text
            def get_status_display(status):
                """Convert status to user-friendly display text."""
                status_map = {
                    'draft': 'Draft',
                    'confirmed': 'Confirmed',
                    'not_translated': 'Not Translated',
                    'proofread': 'Proofread',
                    'approved': 'Approved',
                    'rejected': 'Rejected',
                    'needs_review': 'Needs Review',
                    'edited': 'Edited',
                    'mt': 'MT',
                    'tm_match': 'TM Match',
                    'fuzzy_match': 'Fuzzy'
                }
                return status_map.get(status, status.replace('_', ' ').title() if status else '')
            
            # Add segment rows
            for i, seg in enumerate(segments):
                row = table.add_row()
                cells = row.cells
                
                # Segment number
                cells[0].text = str(i + 1)
                for para in cells[0].paragraphs:
                    para.alignment = WD_ALIGN_PARAGRAPH.CENTER
                    for run in para.runs:
                        run.font.size = Pt(9)
                
                # Source text
                source_text = seg.source if hasattr(seg, 'source') else ''
                add_formatted_text_to_cell(cells[1], source_text, apply_formatting)
                
                # Target text
                target_text = seg.target if hasattr(seg, 'target') else ''
                add_formatted_text_to_cell(cells[2], target_text, apply_formatting)
                
                # Status
                status = seg.status if hasattr(seg, 'status') else ''
                cells[3].text = get_status_display(status)
                for para in cells[3].paragraphs:
                    para.alignment = WD_ALIGN_PARAGRAPH.CENTER
                    for run in para.runs:
                        run.font.size = Pt(8)
                        # Color-code status
                        if status in ('confirmed', 'proofread', 'approved'):
                            run.font.color.rgb = RGBColor(0, 128, 0)  # Green
                        elif status in ('not_translated', 'rejected'):
                            run.font.color.rgb = RGBColor(200, 0, 0)  # Red
                        elif status in ('draft', 'needs_review'):
                            run.font.color.rgb = RGBColor(200, 100, 0)  # Orange
                
                # Notes column - populate with segment notes if available
                notes_text = seg.notes if hasattr(seg, 'notes') else ''
                cells[4].text = notes_text
                for para in cells[4].paragraphs:
                    for run in para.runs:
                        run.font.size = Pt(8)
            
            # Add footer with branding
            footer_para = doc.add_paragraph()
            footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
            footer_para.paragraph_format.space_before = Pt(12)
            
            # Add decorative line
            footer_line_run = footer_para.add_run("\n" + "━" * 50 + "\n")
            footer_line_run.font.size = Pt(10)
            footer_line_run.font.color.rgb = RGBColor(0, 102, 204)
            
            # Add footer text: "Supervertaler Bilingual Table | Supervertaler.com"
            footer_text = doc.add_paragraph()
            footer_text.alignment = WD_ALIGN_PARAGRAPH.CENTER
            
            brand_run = footer_text.add_run("Supervertaler Bilingual Table")
            brand_run.font.size = Pt(9)
            brand_run.font.color.rgb = RGBColor(100, 100, 100)
            
            separator_run = footer_text.add_run(" | ")
            separator_run.font.size = Pt(9)
            separator_run.font.color.rgb = RGBColor(150, 150, 150)
            
            # Add clickable website link in footer
            footer_link = self._add_hyperlink_to_paragraph(footer_text, "https://supervertaler.com/", "Supervertaler.com")
            footer_link.font.size = Pt(9)
            footer_link.font.color.rgb = RGBColor(0, 102, 204)
            footer_link.font.underline = True
            
            # Save the document
            doc.save(file_path)
            
            format_type = "formatted" if apply_formatting else "with tags"
            self.log(f"✓ Exported bilingual table ({format_type}) with {len(segments)} segments to: {Path(file_path).name}")
            
            QMessageBox.information(
                self, "Export Complete",
                f"Successfully exported {len(segments)} segments to bilingual table:\n\n{os.path.basename(file_path)}\n\n"
                + ("This formatted version is suitable for client review." if apply_formatting 
                   else "This version with tags can be re-imported after proofreading.")
            )
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for DOCX export.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            self.log(f"✗ Export failed: {str(e)}")
            QMessageBox.critical(self, "Export Error", f"Failed to export bilingual table:\n\n{str(e)}")
            import traceback
            traceback.print_exc()
    
    def export_tmx_from_grid(self):
        """Export all segments from current project grid as TMX file"""
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project first")
                return
            
            file_path, _ = fdh.get_save_file_name(
                self, 
                "Export Grid as TMX", 
                "TMX Files (*.tmx);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            source_segments = []
            target_segments = []
            entry_count = 0
            
            # Export all segments from grid (including empty translations)
            self.log("Exporting all segments from grid...")
            for segment in self.current_project.segments:
                source_text = segment.source
                target_text = segment.target if segment.target else ""

                # Strip outer wrapping tags if setting is enabled (for cleaner TM matching)
                if self.hide_outer_wrapping_tags:
                    source_text, _ = strip_outer_wrapping_tags(source_text)
                    target_text, _ = strip_outer_wrapping_tags(target_text) if target_text else (target_text, None)

                if source_text:
                    source_segments.append(source_text)
                    target_segments.append(target_text)
                    entry_count += 1
            
            self.log(f"Found {entry_count} segments in grid")
            
            if entry_count == 0:
                QMessageBox.warning(self, "No Data", "No segments found in grid")
                return
            
            # Generate and save TMX
            from modules.tmx_generator import TMXGenerator
            tmx_generator = TMXGenerator(log_callback=self.log)
            
            source_lang = self.current_project.source_lang or "en"
            target_lang = self.current_project.target_lang or "nl"
            
            tmx_tree = tmx_generator.generate_tmx(
                source_segments=source_segments,
                target_segments=target_segments,
                source_lang=source_lang,
                target_lang=target_lang
            )
            
            if tmx_generator.save_tmx(tmx_tree, file_path):
                self.log(f"✓ Exported TMX from grid: {file_path} ({entry_count} segments)")
                QMessageBox.information(
                    self, 
                    "Export Complete", 
                    f"Grid exported to TMX successfully!\n\n"
                    f"File: {file_path}\n"
                    f"Segments: {entry_count}\n"
                    f"Language pair: {source_lang} → {target_lang}"
                )
            else:
                QMessageBox.warning(self, "Export Error", "Failed to save TMX file")
            
        except Exception as e:
            self.log(f"✗ Error exporting grid as TMX: {e}")
            import traceback
            self.log(traceback.format_exc())
            QMessageBox.critical(self, "Export Error", f"Failed to export:\n\n{str(e)}")
    
    def export_tmx_from_selected(self):
        """Export only selected segments from grid as TMX file"""
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project first")
                return
            
            # Get selected rows from table
            selected_rows = set()
            for index in self.table.selectedIndexes():
                selected_rows.add(index.row())
            
            if not selected_rows:
                QMessageBox.warning(
                    self, 
                    "No Selection", 
                    "Please select one or more rows in the grid first.\n\n"
                    f"Click on row numbers or use {format_shortcut_for_display('Ctrl+Click')} to select multiple rows."
                )
                return
            
            file_path, _ = QFileDialog.getSaveFileName(
                self, 
                "Export Selected Segments as TMX", 
                "supervertaler_selected.tmx", 
                "TMX Files (*.tmx);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            source_segments = []
            target_segments = []
            entry_count = 0
            
            # Export only selected segments
            self.log(f"Exporting {len(selected_rows)} selected segments...")
            for row in sorted(selected_rows):
                if row < len(self.current_project.segments):
                    segment = self.current_project.segments[row]
                    source_text = segment.source
                    target_text = segment.target if segment.target else ""

                    # Strip outer wrapping tags if setting is enabled (for cleaner TM matching)
                    if self.hide_outer_wrapping_tags:
                        source_text, _ = strip_outer_wrapping_tags(source_text)
                        target_text, _ = strip_outer_wrapping_tags(target_text) if target_text else (target_text, None)

                    if source_text:
                        source_segments.append(source_text)
                        target_segments.append(target_text)
                        entry_count += 1
            
            self.log(f"Selected {entry_count} segments to export")
            
            if entry_count == 0:
                QMessageBox.warning(self, "No Data", "No valid segments selected")
                return
            
            # Generate and save TMX
            from modules.tmx_generator import TMXGenerator
            tmx_generator = TMXGenerator(log_callback=self.log)
            
            source_lang = self.current_project.source_lang or "en"
            target_lang = self.current_project.target_lang or "nl"
            
            tmx_tree = tmx_generator.generate_tmx(
                source_segments=source_segments,
                target_segments=target_segments,
                source_lang=source_lang,
                target_lang=target_lang
            )
            
            if tmx_generator.save_tmx(tmx_tree, file_path):
                self.log(f"✓ Exported TMX from selected: {file_path} ({entry_count} segments)")
                QMessageBox.information(
                    self, 
                    "Export Complete", 
                    f"Selected segments exported to TMX successfully!\n\n"
                    f"File: {file_path}\n"
                    f"Segments: {entry_count}\n"
                    f"Language pair: {source_lang} → {target_lang}"
                )
            else:
                QMessageBox.warning(self, "Export Error", "Failed to save TMX file")
            
        except Exception as e:
            self.log(f"✗ Error exporting selected segments as TMX: {e}")
            import traceback
            self.log(traceback.format_exc())
            QMessageBox.critical(self, "Export Error", f"Failed to export:\n\n{str(e)}")
    
    def export_tmx_from_tm_database(self):
        """Export translation memory entries as TMX file"""
        try:
            if not self.tm_database:
                QMessageBox.warning(self, "Error", "Translation memory database not available")
                return
            
            # Get TM entries from database
            tm_entries = self.tm_database.get_tm_entries(tm_id=None, limit=None)
            if not tm_entries:
                QMessageBox.warning(
                    self, 
                    "No Data", 
                    "No translation units in Translation Memory database.\n\n"
                    "Please import a TM or save translations to the TM first."
                )
                return
            
            file_path, _ = QFileDialog.getSaveFileName(
                self, 
                "Export TM Database as TMX", 
                "supervertaler_tm_database.tmx", 
                "TMX Files (*.tmx);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            source_segments = []
            target_segments = []
            
            # Extract segments from TM entries
            self.log(f"Exporting {len(tm_entries)} entries from TM database...")
            for entry in tm_entries:
                source_text = entry.get('source_text', entry.get('source', ''))
                target_text = entry.get('target_text', entry.get('target', ''))
                # Strip outer wrapping tags if setting is enabled
                if self.hide_outer_wrapping_tags:
                    source_text, _ = strip_outer_wrapping_tags(source_text)
                    target_text, _ = strip_outer_wrapping_tags(target_text)
                source_segments.append(source_text)
                target_segments.append(target_text)
            
            # Generate and save TMX
            from modules.tmx_generator import TMXGenerator
            tmx_generator = TMXGenerator(log_callback=self.log)
            
            # Use project languages or defaults
            source_lang = "en"
            target_lang = "nl"
            if self.current_project:
                source_lang = self.current_project.source_lang or "en"
                target_lang = self.current_project.target_lang or "nl"
            
            tmx_tree = tmx_generator.generate_tmx(
                source_segments=source_segments,
                target_segments=target_segments,
                source_lang=source_lang,
                target_lang=target_lang
            )
            
            if tmx_generator.save_tmx(tmx_tree, file_path):
                self.log(f"✓ Exported TMX from TM database: {file_path} ({len(tm_entries)} entries)")
                QMessageBox.information(
                    self, 
                    "Export Complete", 
                    f"Translation Memory exported to TMX successfully!\n\n"
                    f"File: {file_path}\n"
                    f"Entries: {len(tm_entries)}\n"
                    f"Language pair: {source_lang} → {target_lang}"
                )
            else:
                QMessageBox.warning(self, "Export Error", "Failed to save TMX file")
            
        except Exception as e:
            self.log(f"✗ Error exporting TM database as TMX: {e}")
            import traceback
            self.log(traceback.format_exc())
            QMessageBox.critical(self, "Export Error", f"Failed to export:\n\n{str(e)}")
    
    def export_tm_as_tmx(self):
        """Legacy function - exports grid segments (for backward compatibility)"""
        self.export_tmx_from_grid()
    
    
    
    
    def clear_tm_entries(self):
        """Clear all translation memory entries (with confirmation)"""
        try:
            reply = QMessageBox.question(
                self, 
                "Clear Translation Memory", 
                "⚠️ WARNING: This will permanently delete ALL translation memory entries!\n\nThis action cannot be undone. Are you sure?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                # Double confirmation
                reply2 = QMessageBox.question(
                    self, 
                    "Final Confirmation", 
                    "Last chance! Are you absolutely sure you want to delete all TM entries?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No
                )
                
                if reply2 == QMessageBox.StandardButton.Yes and self.tm_database:
                    # Clear TM entries
                    import sqlite3
                    db_path = self.user_data_path / "resources" / "supervertaler.db"
                    conn = sqlite3.connect(str(db_path), timeout=15)
                    conn.execute("PRAGMA journal_mode=WAL")
                    cursor = conn.cursor()
                    cursor.execute("DELETE FROM translation_units")
                    conn.commit()
                    conn.close()
                    
                    self.log("All translation memory entries cleared")
                    QMessageBox.information(self, "TM Cleared", "All translation memory entries have been deleted.")
            
        except Exception as e:
            self.log(f"Error clearing TM entries: {e}")
            QMessageBox.critical(self, "Clear Error", f"Failed to clear TM entries:\n\n{e}")
    
    def create_segmentation_rules_tab(self):
        """Create Segmentation Rules management tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        
        # Header
        header = QLabel("📏 Segmentation Rules")
        header.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 10px;")
        layout.addWidget(header)
        
        # Description
        desc = QLabel("Manage language-specific segmentation rules for accurate sentence/segment boundaries.")
        desc.setStyleSheet("color: #666; font-size: 11px; margin-bottom: 10px;")
        layout.addWidget(desc)
        
        # Language Selection
        lang_group = QGroupBox("Select Language")
        lang_layout = QHBoxLayout()
        
        lang_combo = QComboBox()
        languages = ["English (en)", "Dutch (nl)", "German (de)", "French (fr)", "Spanish (es)", "Italian (it)", "Portuguese (pt)", "Chinese (zh)", "Japanese (ja)", "Arabic (ar)"]
        lang_combo.addItems(languages)
        lang_layout.addWidget(QLabel("Language:"))
        lang_layout.addWidget(lang_combo, 1)
        
        lang_group.setLayout(lang_layout)
        layout.addWidget(lang_group)
        
        # Segmentation Rules
        rules_group = QGroupBox("Segmentation Rules")
        rules_layout = QVBoxLayout()
        
        # Current implementation info
        current_info = QLabel(
            "Current Implementation: SimpleSegmenter (language-agnostic)\n\n"
            "• Segments on: . ! ? (followed by space/newline)\n"
            "• Handles basic abbreviations: Mr. Dr. etc.\n"
            "• Preserves paragraph breaks\n"
            "• Treats each table cell as separate segment"
        )
        current_info.setStyleSheet("padding: 10px; border-radius: 4px; font-family: monospace;")
        rules_layout.addWidget(current_info)
        
        # Future implementation section
        future_label = QLabel("🚧 Language-Specific Rules (Planned):")
        future_label.setStyleSheet("font-weight: bold; margin-top: 15px;")
        rules_layout.addWidget(future_label)
        
        future_info = QLabel(
            "• German: Handle compound sentences, different abbreviations\n"
            "• Chinese/Japanese: Word boundary detection, different punctuation\n"
            "• Arabic: Right-to-left text handling\n"
            "• French: Quotation mark handling, spacing rules\n"
            "• Custom: User-defined regex patterns and exceptions"
        )
        future_info.setStyleSheet("color: #666; margin-left: 20px;")
        rules_layout.addWidget(future_info)
        
        rules_group.setLayout(rules_layout)
        layout.addWidget(rules_group)
        
        # Management buttons (for future implementation)
        buttons_group = QGroupBox("Rule Management")
        buttons_layout = QVBoxLayout()
        
        # Test segmentation button
        test_btn = QPushButton("🧪 Test Segmentation")
        test_btn.setToolTip("Test current segmentation rules on sample text")
        test_btn.clicked.connect(self.test_segmentation_rules)
        buttons_layout.addWidget(test_btn)
        
        # Import/Export buttons (disabled for now)
        import_btn = QPushButton("📥 Import Rules")
        import_btn.setToolTip("Import segmentation rules from file (Coming Soon)")
        import_btn.setEnabled(False)
        buttons_layout.addWidget(import_btn)
        
        export_btn = QPushButton("📤 Export Rules")
        export_btn.setToolTip("Export current segmentation rules (Coming Soon)")
        export_btn.setEnabled(False)
        buttons_layout.addWidget(export_btn)
        
        buttons_group.setLayout(buttons_layout)
        layout.addWidget(buttons_group)
        
        layout.addStretch()
        
        return tab
    
    def test_segmentation_rules(self):
        """Test current segmentation rules with sample text"""
        from PyQt6.QtWidgets import QInputDialog
        
        sample_text = (
            "Hello world. This is a test! How are you? "
            "Mr. Smith went to Dr. Jones. "
            "The U.S.A. is great. "
            "What about this... and that? "
            "End of test."
        )
        
        text, ok = QInputDialog.getMultiLineText(
            self, 
            "Test Segmentation", 
            "Enter text to test segmentation:\n(Default sample text provided)",
            sample_text
        )
        
        if ok and text:
            try:
                # Test with current segmenter
                from modules.simple_segmenter import SimpleSegmenter
                segmenter = SimpleSegmenter()
                
                # Create fake paragraph list for testing
                paragraphs = [(0, text)]
                segments = segmenter.segment_paragraphs(paragraphs)
                
                # Show results
                result_text = f"Input text:\n{text}\n\n"
                result_text += f"Segmentation results ({len(segments)} segments):\n"
                result_text += "=" * 50 + "\n"
                
                for i, (para_id, segment_text) in enumerate(segments, 1):
                    result_text += f"{i}. {segment_text}\n"
                
                QMessageBox.information(
                    self,
                    "Segmentation Test Results",
                    result_text
                )
                
            except Exception as e:
                QMessageBox.critical(
                    self,
                    "Segmentation Test Error",
                    f"Error testing segmentation:\n\n{e}"
                )
    
    def _update_both_termlens(self, source_text, termbase_list, nt_matches, status_hint=None):
        """Update all three TermLens instances with the same data.

        TermLens locations:
        1. Under grid (collapsible via View menu)
        2. Match Panel tab (top section)

        Args:
            source_text: The source text for the current segment
            termbase_list: List of termbase match dictionaries
            nt_matches: List of NT (Never Translate) matches
            status_hint: Optional hint for display when no matches:
                         'no_termbases_activated' - no glossaries activated for project
                         'wrong_language' - activated glossaries don't match project language
        """
        # v1.9.306: Defensive guard – strip invisible character markers from source text
        # so the TermLens tokenizer sees clean text (e.g. "hinge load" not "hinge·\u200Bload")
        if hasattr(self, 'reverse_invisible_replacements'):
            source_text = self.reverse_invisible_replacements(source_text)

        # Update left TermLens (under grid)
        if hasattr(self, 'termlens_widget') and self.termlens_widget:
            try:
                self.termlens_widget.update_with_matches(source_text, termbase_list, nt_matches, status_hint)
            except Exception as e:
                self.log(f"Error updating left termlens: {e}")

        # Update Match Panel TermLens
        if hasattr(self, 'termlens_widget_match') and self.termlens_widget_match:
            try:
                self.termlens_widget_match.update_with_matches(source_text, termbase_list, nt_matches, status_hint)
            except Exception as e:
                self.log(f"Error updating Match Panel termlens: {e}")

        # Keep the QuickTrans panels (under-grid and Match Panel) pointed at
        # the current segment. Each self-guards: it only actually fetches when
        # it is the visible tab, and auto-fetches the cheap MT engines only
        # (LLMs stay on-demand), so this is cheap to call on every segment.
        try:
            if self.current_project:
                src = self.current_project.source_lang or 'en'
                tgt = self.current_project.target_lang or 'nl'
            else:
                src = getattr(self, 'source_language', 'en')
                tgt = getattr(self, 'target_language', 'nl')
            for _panel in (getattr(self, 'quicktrans_panel', None),
                           getattr(self, 'quicktrans_panel_match', None)):
                if _panel is not None:
                    _panel.request_update(source_text, src, tgt)
        except Exception as e:
            self.log(f"Error updating QuickTrans panel(s): {e}")

    def _update_termlens_for_segment(self, segment):
        """Explicitly update termlens for a segment (v1.9.182).

        This is called directly from Ctrl+Enter navigation to ensure
        the termlens updates immediately, bypassing the deferred timer approach.
        """
        if not segment or ((not (hasattr(self, 'termlens_widget') and self.termlens_widget)) and (not (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match))):
            return

        try:
            # Use in-memory index for fast lookup
            stored_matches = self.find_termbase_matches_in_source(segment.source)

            # Convert dict format to list format for termlens
            termbase_matches = [
                {
                    'source_term': match_data.get('source', ''),
                    'target_term': match_data.get('translation', ''),
                    'termbase_name': match_data.get('termbase_name', ''),
                    'ranking': match_data.get('ranking', 0),
                    'is_project_termbase': match_data.get('is_project_termbase', False),
                    'term_id': match_data.get('term_id'),
                    'termbase_id': match_data.get('termbase_id'),
                    'notes': match_data.get('notes', '')
                }
                for match_data in stored_matches.values()
            ] if stored_matches else []

            # Get NT matches
            nt_matches = self.find_nt_matches_in_source(segment.source)

            # Get status hint
            status_hint = self._get_termbase_status_hint()

            # Update both TermLens widgets
            self._update_both_termlens(segment.source, termbase_matches, nt_matches, status_hint)

        except Exception as e:
            self.log(f"Error in _update_termlens_for_segment: {e}")

    def _get_termbase_status_hint(self) -> str:
        """Check termbase activation status and return appropriate hint.

        Returns:
            'no_termbases_activated' - if no glossaries are activated for this project
            'wrong_language' - if activated glossaries don't match project language pair
            None - if everything is correctly configured
        """
        if not self.current_project:
            return None

        project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
        if not project_id:
            return None

        # Check if termbase manager is available
        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            return None

        try:
            # Get active termbase IDs for this project
            active_tb_ids = self.termbase_mgr.get_active_termbase_ids(project_id)

            # Check if no termbases are activated
            if not active_tb_ids or len(active_tb_ids) == 0:
                return 'no_termbases_activated'

            # Check if any activated termbases match the project's language pair.
            # Normalise both sides through _convert_language_to_code so that
            # full names ("Dutch") and ISO codes ("nl") compare equal – the
            # project stores its languages as full English names by default
            # (chosen from the Settings dropdown) while imported termbases
            # tend to carry ISO codes. Without normalisation, "dutch" == "nl"
            # is false and the UI shows "Glossaries don't match language pair"
            # even when the langs are in fact the same.
            project_source = self._convert_language_to_code(self.current_project.source_lang or '') or ''
            project_target = self._convert_language_to_code(self.current_project.target_lang or '') or ''

            # Get all termbases and check language pairs
            all_termbases = self.termbase_mgr.get_all_termbases()
            has_matching_language = False

            for tb in all_termbases:
                if tb['id'] in active_tb_ids:
                    tb_source = self._convert_language_to_code(tb.get('source_lang') or '') or ''
                    tb_target = self._convert_language_to_code(tb.get('target_lang') or '') or ''
                    # Match if: no language set, or languages match (bidirectional)
                    if (not tb_source and not tb_target) or \
                       (tb_source == project_source and tb_target == project_target) or \
                       (tb_source == project_target and tb_target == project_source):
                        has_matching_language = True
                        break

            if not has_matching_language:
                return 'wrong_language'

            return None  # All good

        except Exception as e:
            self.log(f"Error checking termbase status: {e}")
            return None

    def _refresh_termbase_display_for_current_segment(self):
        """Refresh only termbase/glossary display for the current segment.
        
        This is a targeted refresh that does NOT trigger a TM search.
        Use this after adding a term to a glossary to update the display
        without the overhead of re-searching translation memories.
        """
        current_row = self.table.currentRow()
        if current_row < 0 or not self.current_project:
            return
        
        # Get the segment from the grid (use ID from cell, not row index)
        id_item = self.table.item(current_row, 0)
        if not id_item:
            return
        
        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            return
        
        segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
        if not segment:
            return
        
        # Clear only the termbase cache for this segment (NOT the TM cache)
        with self.termbase_cache_lock:
            if segment.id in self.termbase_cache:
                del self.termbase_cache[segment.id]
                self.log(f"🗑️ Cleared termbase cache for segment {segment.id}")
        
        # Search for fresh termbase matches
        termbase_matches = self.find_termbase_matches_in_source(segment.source)
        
        # Update termbase cache
        with self.termbase_cache_lock:
            self.termbase_cache[segment.id] = termbase_matches
        
        # Update TermLens widget
        if (hasattr(self, 'termlens_widget') and self.termlens_widget) or (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match):
            try:
                # Convert termbase matches to list format for termlens.
                # Note: find_termbase_matches_in_source returns dict with
                # 'source' and 'translation' keys (not 'source_term' /
                # 'target_term') — translation_list normalises to the
                # *_term shape TermLens expects.
                #
                # v1.10.73 (Tier 1 + Tier 2): forward the new metadata
                # fields (``definition``, ``url``, ``source_abbreviation``,
                # ``target_abbreviation``) and synonym lists
                # (``source_synonyms``, ``target_synonyms``) that
                # ``_search_termbase_in_memory`` now populates on every
                # match dict. The TermBlock tooltip's _meta_lines helper
                # immediately picks up the metadata fields, and the
                # update_with_matches loop appends target synonyms as
                # additional clickable translations.
                termbase_list = [
                    {
                        'source_term': match.get('source', ''),
                        'target_term': match.get('translation', ''),
                        'termbase_name': match.get('termbase_name', ''),
                        'ranking': match.get('ranking', 0),
                        'is_project_termbase': match.get('is_project_termbase', False),
                        'term_id': match.get('term_id'),
                        'termbase_id': match.get('termbase_id'),
                        'notes': match.get('notes', ''),
                        'definition': match.get('definition', ''),
                        'domain': match.get('domain', ''),
                        'url': match.get('url', ''),
                        'source_abbreviation': match.get('source_abbreviation', ''),
                        'target_abbreviation': match.get('target_abbreviation', ''),
                        'source_synonyms': match.get('source_synonyms', []),
                        'target_synonyms': match.get('target_synonyms', []),
                        # v1.10.75 (Tier 3): forbidden + NT flags for
                        # the new background-colour treatment in TermBlock.
                        'forbidden': match.get('forbidden', False),
                        'is_nontranslatable': match.get('is_nontranslatable', False),
                        # v1.10.75 (Tier 3c) abbreviation marker
                        'matched_via_abbreviation': match.get('matched_via_abbreviation', False),
                    }
                    for match in termbase_matches.values() if isinstance(match, dict)
                ] if isinstance(termbase_matches, dict) else []
                
                # Get NT matches
                nt_matches = self.find_nt_matches_in_source(segment.source)

                # Get status hint for termbase activation
                status_hint = self._get_termbase_status_hint()

                # Update both TermLens widgets (left and right)
                self._update_both_termlens(segment.source, termbase_list, nt_matches, status_hint)
            except Exception as e:
                self.log(f"Error updating termlens: {e}")
        
        # Update Translation Results panel termbase section (without touching TM)
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                try:
                    # Get the existing matches from the panel and update only Termbases
                    if hasattr(panel, 'current_matches') and panel.current_matches:
                        # Convert termbase matches to TranslationMatch format
                        # Note: find_termbase_matches_in_source returns 'source' and 'translation' keys
                        from modules.translation_results_panel import TranslationMatch
                        tb_matches = []
                        for match_data in termbase_matches.values():
                            if isinstance(match_data, dict):
                                tb_match = TranslationMatch(
                                    source=match_data.get('source', ''),  # 'source' not 'source_term'
                                    target=match_data.get('translation', ''),  # 'translation' not 'target_term'
                                    relevance=100.0,
                                    match_type='Termbase',
                                    provider=match_data.get('termbase_name', 'Termbase'),
                                    metadata={
                                        'termbase_name': match_data.get('termbase_name', ''),
                                        'ranking': match_data.get('ranking', 0),
                                        'is_project_termbase': match_data.get('is_project_termbase', False),
                                        'term_id': match_data.get('term_id'),
                                        'termbase_id': match_data.get('termbase_id'),
                                        'notes': match_data.get('notes', '')
                                    }
                                )
                                tb_matches.append(tb_match)
                        
                        # Update just the Termbases section
                        panel.current_matches['Termbases'] = tb_matches
                        panel.set_matches(panel.current_matches)
                except Exception as e:
                    self.log(f"Error updating results panel termbases: {e}")
        
        # Re-highlight termbase matches in the source cell.
        # We call highlight_source_with_termbase() directly (NOT rehighlight()),
        # because rehighlight() is expensive (spellcheck + tag detection + termbase).
        # This only applies the lightweight termbase + NT formatting pass.
        self.highlight_source_with_termbase(current_row, segment.source, termbase_matches)

        self.log(f"🔄 Refreshed termbase display for segment {segment.id} (TM untouched)")
    
    def _orient_term_for_termbase(self, source_text: str, target_text: str, termbase: dict):
        """Return (source_term, target_term, source_lang_code, target_lang_code)
        oriented to match the **termbase's declared direction**, not the
        project's.

        v1.10.62 termbase-direction bug fix. Mirrors the Trados v4.19.22
        fix (commit ``cf8d70b`` — "termbase direction overhaul"). The
        write side of the codebase previously trusted the project's
        direction blindly: it took ``project.source_lang`` as the
        term's ``source_lang`` and ``source_text`` as the term's
        ``source_term``, even when the termbase declared the opposite
        direction. Result for users running e.g. NL→EN projects with
        EN→NL termbases: the Dutch term landed in the column the
        termbase declares as English (and vice versa), and per-row
        lang codes pointed the wrong way. The bidirectional read-side
        indexer (``_build_termbase_index``) then swapped them again
        on lookup, so TermLens searched the Dutch segment for the
        English text and found nothing.

        This helper sits between the call sites (Ctrl+Q quick-add,
        Ctrl+Alt+T dialog add, Alt+Up project-termbase add) and
        ``TermbaseManager.add_term`` to do a per-termbase orientation
        fix: if the termbase declares the reverse of the project's
        direction, swap source/target text and lang codes so the
        INSERT lands them in the columns the termbase actually
        expects.

        Returns the inputs unchanged when:
         - there is no current project, or
         - the termbase declares no languages (legacy), or
         - the termbase's direction matches the project's, or
         - the termbase's direction is unrelated to the project's
           (different language pair entirely).

        In the matching case (termbase aligned with project) we still
        normalise the lang codes to ISO via
        ``_convert_language_to_code`` so the per-row ``source_lang`` /
        ``target_lang`` columns always carry codes, never full names.
        """
        # No project context → can't compare; trust the caller.
        if not self.current_project:
            return source_text, target_text, '', ''

        project_source = self._convert_language_to_code(
            self.current_project.source_lang or '') or ''
        project_target = self._convert_language_to_code(
            self.current_project.target_lang or '') or ''
        tb_source = self._convert_language_to_code(
            termbase.get('source_lang') or '') or ''
        tb_target = self._convert_language_to_code(
            termbase.get('target_lang') or '') or ''

        # Termbase has no declared direction → write project-direction
        # codes; harmless and at least populates the columns for any
        # future indexer that needs them.
        if not tb_source or not tb_target:
            return source_text, target_text, project_source, project_target

        # Reverse case: termbase's source matches project's target AND
        # termbase's target matches project's source. Swap so the
        # storage layer's source_term column always contains a term in
        # the termbase's declared source language.
        if (project_source and project_target
                and tb_source == project_target
                and tb_target == project_source):
            tb_name = termbase.get('name', '?')
            self.log(
                f"⚠️ Termbase '{tb_name}' direction ({tb_source}→{tb_target}) "
                f"is reverse of project ({project_source}→{project_target}); "
                f"swapping source/target on save so the term lands in the "
                f"columns the termbase expects."
            )
            return target_text, source_text, tb_source, tb_target

        # Default: termbase direction matches project direction (or the
        # two are unrelated entirely). Write project-direction lang
        # codes; the storage row's source_term/target_term already
        # match the termbase's declared direction by definition.
        return source_text, target_text, project_source, project_target

    def _detect_and_merge_synonyms(self, source_text: str, target_text: str,
                                   target_termbases: list,
                                   source_synonyms: list = None,
                                   target_synonyms: list = None):
        """Trados-parity "Similar Term Found" check across the given termbases.

        Detects entries that share the new term's source (but a different
        target) or its target (but a different source) and, if any are found,
        prompts the user to fold the new term into the existing entry as a
        synonym rather than create a near-duplicate.

        Returns ``(decision, merged_tb_ids)``:
          * ``'cancel'``     – user aborted; caller must add nothing.
          * ``'keep_both'``  – no conflict, or user chose to keep both; caller
                               inserts normally into every termbase.
          * ``'merged'``     – the new term (and any supplied synonyms) were
                               added as synonyms to the matched entries in
                               ``merged_tb_ids``; caller must skip those
                               termbases and insert only into the rest.

        When there are no conflicts the method returns ``('keep_both', set())``
        WITHOUT showing any dialog, so the quick-add paths stay silent and fast
        in the common case.
        """
        if not target_termbases or not getattr(self, 'termbase_mgr', None):
            return ('keep_both', set())

        # Gather matches across all destination termbases, orienting each
        # term to its termbase's storage direction first (same as add_term).
        all_matches = []
        for tb in target_termbases:
            src_term, tgt_term, _sl, _tl = self._orient_term_for_termbase(
                source_text, target_text, tb)
            tb_was_swapped = (src_term != source_text)
            for row in self.termbase_mgr.find_merge_matches(
                    tb['id'], src_term, tgt_term):
                all_matches.append({
                    'term_id': row['term_id'],
                    'source_term': row['source_term'],
                    'target_term': row['target_term'],
                    'match_type': row['match_type'],
                    'termbase_id': tb['id'],
                    'termbase_name': tb.get('name', ''),
                    'tb_was_swapped': tb_was_swapped,
                    'oriented_src': src_term,
                    'oriented_tgt': tgt_term,
                })

        if not all_matches:
            return ('keep_both', set())

        from modules.merge_prompt_dialog import MergePromptDialog
        dlg = MergePromptDialog(self, all_matches, source_text, target_text)
        dlg.exec()
        choice = dlg.choice

        if choice == MergePromptDialog.CANCEL:
            return ('cancel', set())
        if choice == MergePromptDialog.KEEP_BOTH:
            return ('keep_both', set())

        # SYNONYM or EDIT: fold the new term into each matched entry. The
        # language tag passed to add_synonym is relative to termbase storage,
        # so when the termbase is reverse of the project the project-source
        # text belongs in the target column and vice versa.
        merged_ids = set()
        for m in all_matches:
            try:
                if m['match_type'] == 'source':
                    ok = self.termbase_mgr.add_synonym(
                        m['term_id'], m['oriented_tgt'], language='target')
                else:
                    ok = self.termbase_mgr.add_synonym(
                        m['term_id'], m['oriented_src'], language='source')
                if ok:
                    self.log(
                        f"  ✓ Merged as synonym into '{m['termbase_name']}' "
                        f"(entry #{m['term_id']})")
                # Also fold in any synonyms the user typed in the add dialog.
                syn_source_lang = 'target' if m['tb_was_swapped'] else 'source'
                syn_target_lang = 'source' if m['tb_was_swapped'] else 'target'
                for syn in (source_synonyms or []):
                    self.termbase_mgr.add_synonym(
                        m['term_id'], syn['text'], language=syn_source_lang,
                        display_order=syn.get('order', 0),
                        forbidden=syn.get('forbidden', False))
                for syn in (target_synonyms or []):
                    self.termbase_mgr.add_synonym(
                        m['term_id'], syn['text'], language=syn_target_lang,
                        display_order=syn.get('order', 0),
                        forbidden=syn.get('forbidden', False))
                merged_ids.add(m['termbase_id'])
            except Exception as e:
                self.log(
                    f"  ✗ Failed to merge synonym into "
                    f"'{m['termbase_name']}': {e}")

        # "Add & Edit" – open the term entry editor on the first matched entry.
        if choice == MergePromptDialog.EDIT and all_matches:
            first = all_matches[0]
            try:
                from modules.termbase_entry_editor import TermbaseEntryEditor
                editor = TermbaseEntryEditor(
                    self,
                    db_manager=self.db_manager,
                    termbase_id=first['termbase_id'],
                    term_id=first['term_id'],
                )
                editor.exec()
            except Exception as e:
                self.log(f"✗ Could not open term entry editor for review: {e}")

        return ('merged', merged_ids)

    def add_term_pair_to_termbase(self, source_text: str, target_text: str):
        """Add a term pair to active termbase(s) with metadata dialog"""
        # Check if we have a current project
        if not hasattr(self, 'current_project') or not self.current_project:
            QMessageBox.warning(self, "No Active Project", "Please open or create a project before adding terms to termbase.")
            return
        
        # Get active termbases for current project
        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            QMessageBox.critical(self, "Error", "Termbase manager not initialized")
            return
        
        # Resolve project ID (prefer persistent project.id from loaded project)
        project_id = getattr(self.current_project, 'id', None)
        if not project_id:
            # Fallback for legacy projects without id
            import hashlib
            if hasattr(self, 'project_file_path') and self.project_file_path:
                project_id = int(hashlib.md5(self.project_file_path.encode()).hexdigest()[:8], 16)
            else:
                project_id = int(hashlib.md5(self.current_project.name.encode()).hexdigest()[:8], 16)

        # Get all termbases for dialog visibility, but preselect only active ones for this project
        active_termbases = self.termbase_mgr.get_all_termbases()
        active_termbase_ids = set(self.termbase_mgr.get_active_termbase_ids(project_id)) if project_id else set()
        
        if not active_termbases:
            QMessageBox.warning(self, "No Termbase", "Please create or activate at least one termbase in Resources → Termbases tab.")
            return
        
        # Show metadata dialog with termbase selection
        from modules.termbase_entry_editor import TermbaseEntryEditor
        dialog = TermbaseEntryEditor(
            self,
            source_term=source_text,
            target_term=target_text,
            active_termbases=active_termbases,
            user_data_path=self.user_data_path,
            default_selected_termbase_ids=active_termbase_ids,
        )
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return  # User cancelled

        metadata = dialog.get_metadata()
        source_synonyms = dialog.get_source_synonyms()
        target_synonyms = dialog.get_target_synonyms()

        # The user can correct the source/target text in the dialog now;
        # use those values if they changed.
        if hasattr(dialog, 'get_source_term'):
            edited_source = dialog.get_source_term()
            edited_target = dialog.get_target_term()
            if edited_source:
                source_text = edited_source
            if edited_target:
                target_text = edited_target

        # Determine destination termbases automatically. The picker that
        # used to live in the dialog is gone; the term is written to every
        # termbase that is BOTH active for this project AND not marked
        # read-only in the Termbases tab. If nothing matches, surface a
        # clear error so the user knows they need to flip a Write toggle.
        selected_termbase_ids = [
            tb['id'] for tb in active_termbases
            if tb['id'] in active_termbase_ids
            and not tb.get('read_only', False)
        ]
        if not selected_termbase_ids:
            QMessageBox.warning(
                self, "No Writable Termbase",
                "There are no termbases marked <b>Write</b> for the current "
                "project.<br><br>"
                "Open <b>Termbases → TM List</b> and tick the <b>Write</b> "
                "checkbox on at least one active termbase, then try again."
            )
            return

        # v1.10.62: orient + write is now per-termbase, since each
        # destination termbase may declare a different direction.
        # Synonym lists ride along unchanged — orientation only
        # decides which COLUMN the primary source/target text lands
        # in, not which list a synonym belongs to.
        self.log(f"📝 Adding term: {source_text} → {target_text}")
        if source_synonyms:
            self.log(f"   With {len(source_synonyms)} source synonym(s): {', '.join([s['text'] for s in source_synonyms])}")
        if target_synonyms:
            self.log(f"   With {len(target_synonyms)} target synonym(s): {', '.join([s['text'] for s in target_synonyms])}")

        # "Similar Term Found" check (Trados parity). If the new pair partially
        # overlaps an existing entry, offer to merge as a synonym instead of
        # creating a near-duplicate. Merged termbases are skipped in the loop.
        target_tbs = [tb for tb in active_termbases
                      if tb['id'] in selected_termbase_ids]
        decision, merged_ids = self._detect_and_merge_synonyms(
            source_text, target_text, target_tbs,
            source_synonyms, target_synonyms)
        if decision == 'cancel':
            return

        # Add term to selected termbases only
        success_count = 0
        duplicate_count = 0
        error_count = 0
        for tb in active_termbases:
            if tb['id'] not in selected_termbase_ids:
                continue  # Skip unselected termbases
            if tb['id'] in merged_ids:
                continue  # Already folded into an existing entry as a synonym

            # v1.10.62 per-termbase orientation (see _orient_term_for_termbase).
            src_term, tgt_term, src_lang, tgt_lang = (
                self._orient_term_for_termbase(source_text, target_text, tb)
            )
            # Detect whether orient flipped source<->target. If yes, the
            # dialog's "source synonyms" panel (user-typed in project's
            # source language) actually belongs in the termbase's
            # TARGET column, and vice versa. Flip the language tag
            # passed to add_synonym accordingly.
            tb_was_swapped = (src_term != source_text)
            syn_source_lang = 'target' if tb_was_swapped else 'source'
            syn_target_lang = 'source' if tb_was_swapped else 'target'

            try:
                term_id = self.termbase_mgr.add_term(
                    termbase_id=tb['id'],
                    source_term=src_term,
                    target_term=tgt_term,
                    source_lang=src_lang,
                    target_lang=tgt_lang,
                    domain=metadata['domain'],
                    notes=metadata['notes'],
                    project=metadata['project'],
                    client=metadata['client'],
                    forbidden=metadata['forbidden'],
                    is_nontranslatable=metadata.get('is_nontranslatable', False),
                    definition=metadata.get('definition', ''),
                    url=metadata.get('url', ''),
                    source_abbreviation=metadata.get('source_abbreviation', ''),
                    target_abbreviation=metadata.get('target_abbreviation', ''),
                )
                
                if term_id:
                    success_count += 1
                    self.log(f"✓ Added term to termbase '{tb['name']}': {source_text} → {target_text}")
                    
                    # Add source synonyms if any. Language tag is
                    # ``syn_source_lang`` (= 'source' normally, or
                    # 'target' when the termbase is reverse-direction
                    # to the project — see the swap-detection above).
                    if source_synonyms:
                        for syn_data in source_synonyms:
                            if self.termbase_mgr.add_synonym(
                                term_id,
                                syn_data['text'],
                                language=syn_source_lang,
                                display_order=syn_data['order'],
                                forbidden=syn_data['forbidden']
                            ):
                                forbidden_marker = " (forbidden)" if syn_data['forbidden'] else ""
                                self.log(f"  ✓ Added source synonym: {syn_data['text']}{forbidden_marker}")
                            else:
                                self.log(f"  ✗ Failed to add source synonym: {syn_data['text']}")

                    # Add target synonyms if any (same flip logic).
                    if target_synonyms:
                        for syn_data in target_synonyms:
                            if self.termbase_mgr.add_synonym(
                                term_id,
                                syn_data['text'],
                                language=syn_target_lang,
                                display_order=syn_data['order'],
                                forbidden=syn_data['forbidden']
                            ):
                                forbidden_marker = " (forbidden)" if syn_data['forbidden'] else ""
                                self.log(f"  ✓ Added target synonym: {syn_data['text']}{forbidden_marker}")
                            else:
                                self.log(f"  ✗ Failed to add target synonym: {syn_data['text']}")

                else:
                    duplicate_count += 1
                    
            except Exception as e:
                error_count += 1
                self.log(f"✗ Error adding term to termbase '{tb['name']}': {e}")

        # Count synonym-merges (Trados parity) as successful adds so the
        # messaging below reports them and the index/display refresh runs.
        success_count += len(merged_ids)

        # Show result
        if success_count > 0:
            self._play_sound_effect('glossary_term_added')

            # Non-modal info bar message (status bar)
            try:
                if hasattr(self, 'statusBar') and self.statusBar():
                    if success_count == 1:
                        self.statusBar().showMessage(f"✓ Added termbase entry: {source_text} → {target_text} (to 1 termbase)", 3500)
                    else:
                        self.statusBar().showMessage(f"✓ Added termbase entry to {success_count} termbases: {source_text} → {target_text}", 3500)
            except Exception:
                pass

            QMessageBox.information(self, "Term Added", f"Successfully added term pair to {success_count} termbase(s):\\n\\nSource: {source_text}\\nTarget: {target_text}\\n\\nDomain: {metadata['domain'] or '(none)'}")
            
            # Keep in-memory termbase search/index in sync with DB so new term appears immediately
            try:
                with self.termbase_cache_lock:
                    self.termbase_cache.clear()
                self._build_termbase_index()
            except Exception as e:
                self.log(f"WARNING: Failed to rebuild termbase index after add: {e}")

# Refresh termbase display (NOT TM - that would be wasteful)
            self._refresh_termbase_display_for_current_segment()
            
            # IMPORTANT: Refresh the termbase list UI if it's currently open to update term counts
            # Find the termbase tab and call its refresh function
            if hasattr(self, 'termbase_tab_refresh_callback') and self.termbase_tab_refresh_callback:
                self.log("🔄 Refreshing termbase list to update term counts")
                self.termbase_tab_refresh_callback()
            else:
                self.log("⚠️ No termbase refresh callback found (tab not initialized yet)")
        else:
            if duplicate_count > 0 and error_count == 0:
                self._play_sound_effect('glossary_term_duplicate')
                try:
                    if hasattr(self, 'statusBar') and self.statusBar():
                        self.statusBar().showMessage("⚠ Duplicate termbase entry (not added)", 3500)
                except Exception:
                    pass
                QMessageBox.warning(
                    self,
                    "Duplicate Term",
                    "This term already exists in the selected termbase(s). Duplicate terms are not allowed."
                )
            else:
                self._play_sound_effect('glossary_term_error')
                try:
                    if hasattr(self, 'statusBar') and self.statusBar():
                        self.statusBar().showMessage("❌ Error adding termbase entry (see log)", 3500)
                except Exception:
                    pass
                QMessageBox.warning(self, "Error Adding Term", "Failed to add term to any termbase. Check the log for details.")
    
    # ------------------------------------------------------------------
    # Termbase DB file watcher (v1.10.69)
    # ------------------------------------------------------------------
    # Background:
    # Workbench's TermLens / source-cell highlighting is driven by an
    # in-memory ``termbase_index`` built once per project load (the
    # v1.9.182 perf optimisation that took 365s → <1s for 349
    # segments). When the same SQLite database is also open in the
    # Supervertaler for Trados plugin and the user deletes / adds
    # terms there, Workbench's index goes stale — TermLens keeps
    # showing deleted pills and misses newly-added ones until the
    # user presses the v1.10.68 🔄 refresh button.
    #
    # This block makes that refresh **automatic** via QFileSystemWatcher.
    # Design choices that matter for the "don't misfire" requirement
    # the user called out explicitly:
    #
    #  1. **Debounce**: any DB-file modification restarts a single-
    #     shot 2-second timer. The rebuild only runs once the file
    #     has been quiet for 2 s, so a batch of own-writes (e.g. an
    #     INSERT followed by SELECT-verify followed by activation-
    #     table update) collapses into one rebuild.
    #
    #  2. **Snapshot gating**: even after debounce fires, we don't
    #     blindly rebuild. We snapshot the termbases / termbase_terms
    #     state (row counts + MAX(id) + MAX(modified_date)) at the
    #     end of every ``_build_termbase_index`` call, and at fire
    #     time we re-snapshot and compare. If nothing changed since
    #     the last rebuild — e.g. the file mtime ticked because of a
    #     TM write or a project metadata save, not a termbase edit —
    #     we skip. So routine non-termbase DB writes don't cause a
    #     visible TermLens flicker.
    #
    #  3. **Own-write integration**: every code path that modifies
    #     termbases already calls ``_post_termbase_delete_refresh``
    #     (add / delete / quick-add etc.), which in turn calls
    #     ``_build_termbase_index`` which updates the snapshot. So
    #     own-writes update the snapshot synchronously; when the
    #     watcher debounce later fires for that same write, the
    #     snapshot comparison sees no change and skips. Zero
    #     spurious rebuilds for own-writes.
    #
    #  4. **Defensive**: setup is wrapped in try/except. If the
    #     QFileSystemWatcher fails (network drive, OneDrive, exotic
    #     filesystem), the failure is logged and the manual 🔄
    #     button still works.
    #
    # Wired in by ``_setup_termbase_db_watcher`` which is called once
    # the db_manager + current_project are available (from the
    # project-load chain at the bottom of ``_load_project_data``).

    def _snapshot_termbase_db_state(self):
        """Return a small tuple summarising the termbase tables' state.

        Used by the file watcher to decide whether anything actually
        changed in the termbase tables before triggering a rebuild.
        Cheap (4 aggregate queries, sub-millisecond). Returns ``None``
        on any error so the caller can fall back to "assume changed".
        """
        try:
            if not hasattr(self, 'db_manager') or self.db_manager is None:
                return None
            cur = self.db_manager.cursor
            cur.execute("SELECT COUNT(*), COALESCE(MAX(id), 0) FROM termbases")
            row_tb = cur.fetchone() or (0, 0)
            cur.execute("SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(modified_date), '') FROM termbase_terms")
            row_terms = cur.fetchone() or (0, 0, '')
            return (row_tb[0], row_tb[1], row_terms[0], row_terms[1], row_terms[2])
        except Exception:
            return None

    def _setup_termbase_db_watcher(self):
        """Install a QFileSystemWatcher on the active SQLite DB file
        and wire it to a debounced auto-refresh of the termbase index.

        Safe to call repeatedly — the previous watcher (if any) is
        torn down first, so this can be re-invoked on project switch
        without leaking watchers or duplicate-firing.
        """
        try:
            from PyQt6.QtCore import QFileSystemWatcher, QTimer
        except Exception as e:
            self.log(f"⚠️ Termbase DB watcher: PyQt6 missing QFileSystemWatcher? {e}")
            return

        # Tear down any previous watcher / timer cleanly.
        try:
            old_w = getattr(self, '_termbase_db_watcher', None)
            if old_w is not None:
                try:
                    paths = old_w.files()
                    if paths:
                        old_w.removePaths(paths)
                except Exception:
                    pass
                old_w.deleteLater()
            old_t = getattr(self, '_termbase_db_debounce_timer', None)
            if old_t is not None:
                old_t.stop()
                old_t.deleteLater()
        except Exception:
            pass
        self._termbase_db_watcher = None
        self._termbase_db_debounce_timer = None

        # Resolve the DB path. Bail quietly if no db_manager yet —
        # caller can retry after init.
        try:
            db_path = getattr(self.db_manager, 'db_path', None) if hasattr(self, 'db_manager') else None
            if not db_path:
                return
            import os as _os
            if not _os.path.isfile(db_path):
                self.log(f"⚠️ Termbase DB watcher: file does not exist ({db_path}); skipping auto-refresh")
                return
        except Exception as e:
            self.log(f"⚠️ Termbase DB watcher: path resolution failed ({e}); skipping auto-refresh")
            return

        # Snapshot the current termbase state so the first fire after
        # setup has a reference point to compare against.
        self._termbase_db_last_snapshot = self._snapshot_termbase_db_state()

        try:
            watcher = QFileSystemWatcher(self)
            ok = watcher.addPath(db_path)
            if not ok:
                self.log(f"⚠️ Termbase DB watcher: addPath returned False for {db_path}; skipping auto-refresh")
                watcher.deleteLater()
                return
        except Exception as e:
            self.log(f"⚠️ Termbase DB watcher: failed to install ({e}); manual 🔄 still works")
            return

        # Debounce timer — restarted on every fileChanged hit; only
        # actually fires the rebuild once changes have stopped.
        debounce = QTimer(self)
        debounce.setSingleShot(True)
        debounce.setInterval(2000)  # 2 s quiet window
        debounce.timeout.connect(self._on_termbase_db_debounce_fire)

        def _on_changed(_path):
            # Some platforms (Windows in particular) remove the file
            # from the watcher's tracking list when it's
            # rewritten / replaced (which happens with SQLite WAL
            # checkpoints). Re-add the path defensively if it
            # disappeared so subsequent edits still fire events.
            try:
                if db_path not in watcher.files():
                    watcher.addPath(db_path)
            except Exception:
                pass
            debounce.start()  # restart the quiet window

        watcher.fileChanged.connect(_on_changed)

        self._termbase_db_watcher = watcher
        self._termbase_db_debounce_timer = debounce
        self._termbase_db_path = db_path
        self.log(f"👁️  Termbase DB auto-refresh: watching {_os.path.basename(db_path)} (2s debounce)")

    def _on_termbase_db_debounce_fire(self):
        """Debounce timer fired — DB has been quiet for 2 s.

        v1.10.72: just defers to ``force_refresh_matches``, which
        does its own snapshot-gating (only rebuilds the index when
        the termbase tables actually changed) and then runs the full
        per-segment refresh chain. The pre-v1.10.72 version did its
        own snapshot check here and called a separate
        ``_post_termbase_delete_refresh``; merging both into the F5
        path collapses every refresh entry point (F5, 🔄 button,
        auto-refresh) into a single code path.
        """
        try:
            self.force_refresh_matches()
        except Exception as e:
            self.log(f"⚠️ Termbase DB auto-refresh failed: {e}")

    def _force_termlens_display_redraw(self):
        """Force a full TermLens redraw using the F5 path
        (``force_refresh_matches``) when the lightweight refresh isn't
        enough.

        v1.10.71 background: a user reported that after clicking the
        v1.10.68 ↻ refresh button, pills for some terms (specifically
        ones added moments earlier) disappeared from TermLens for the
        current segment — even though the cache and index both
        contained them (the cell-select log line on the next segment
        switch confirmed ``TB=13`` matches still cached, but the
        widget only rendered 12 pills). Pressing F5 (which calls
        ``force_refresh_matches``) immediately restored all pills.

        The two paths look near-identical on paper but differ in
        thoroughness: the light path
        (``_refresh_termbase_display_for_current_segment``) clears
        only the per-segment termbase cache; ``force_refresh_matches``
        additionally clears the translation-matches cache, re-runs
        the TM search, builds a richer ``tb_list`` that includes
        ``target_synonyms``, and updates the Translation Results
        panel — and somewhere in that broader chain the TermLens
        widget rebuilds cleanly.

        Rather than chase the exact divergence (race conditions in
        the FlowLayoutPanel re-layout when controls are added/cleared
        rapidly are notoriously hard to pin down without a
        reproducer), this helper just calls into the F5 path. It's
        slightly heavier work — including a TM re-search — but the
        TM search is cached past the first hit and the user feedback
        is unambiguous that F5 works where the lightweight refresh
        doesn't. Correctness > a few hundred milliseconds.

        Best-effort: wrapped in try/except so the helper can never
        tear down whatever caller triggered it.
        """
        try:
            if hasattr(self, 'force_refresh_matches'):
                self.force_refresh_matches()
            else:
                # Defensive fallback if the F5 handler is somehow gone —
                # call the light path instead so something still happens.
                self._refresh_termbase_display_for_current_segment()
        except Exception as e:
            self.log(f"⚠️ Forced TermLens redraw failed: {e}")

    def _post_termbase_delete_refresh(self):
        """Run the full refresh chain after a term is deleted from any
        termbase, from any UI surface.

        v1.10.64 bug fix. Five different code paths can delete a term
        (Termbases tab "Delete Term" button, the Termbase Editor's
        "Delete Selected Term", the Edit Termbase Entry dialog's red
        Delete button, the right-click "Delete Termbase Entry" on a
        translation-results match card, and the right-click "Delete
        Termbase Entry" on a TermLens block). All five used to do an
        ``UPDATE termbase_terms`` followed by *partial* refresh logic
        — typically clearing ``self.termbase_cache`` and re-running
        ``find_termbase_matches_in_source`` for the current segment.

        The bug: ``find_termbase_matches_in_source`` reads from the
        **in-memory** ``self.termbase_index`` (built by
        ``_build_termbase_index``). That index still contains the
        just-deleted entry until it's rebuilt. So the user deletes a
        term, looks at TermLens, and sees the "deleted" term still
        there because the search hit comes from the stale index.

        This helper consolidates the post-delete refresh into one
        place. It:
         1. Clears ``self.termbase_cache`` (per-segment match cache).
         2. Rebuilds ``self.termbase_index`` (the lookup index).
         3. Re-runs the search for the current segment so TermLens
            and the source-cell highlight reflect the change.
         4. Refreshes the Termbases tab UI if it's open, so the
            "term count" badge and the terms grid update.

        Safe to call when nothing's open / no current segment / no
        termbase tab — every step is wrapped in best-effort try/except.
        """
        # v1.10.72: collapsed into ``force_refresh_matches``. Every
        # entry point that used to call this helper (the five delete
        # paths from v1.10.64) now gets the same treatment users get
        # from F5 / the 🔄 button: smart index rebuild (snapshot
        # check decides whether the rebuild step actually runs),
        # then the full per-segment cache clear + re-search + redraw.
        #
        # After a delete, the snapshot will always differ (one fewer
        # row in termbase_terms), so the rebuild will always fire
        # here — which is correct: the deleted term must be evicted
        # from the in-memory index or the next TermLens search will
        # surface it again as a phantom match. The snapshot gating
        # makes no-op refreshes cheap for other callers, but it's
        # not used to *skip* the rebuild after a known delete.
        try:
            self.force_refresh_matches()
        except Exception as e:
            self.log(f"⚠️ post-delete refresh failed: {e}")
        try:
            if hasattr(self, 'termbase_tab_refresh_callback') and self.termbase_tab_refresh_callback:
                self.termbase_tab_refresh_callback()
        except Exception as e:
            self.log(f"⚠️ post-delete refresh: tab refresh failed: {e}")

    def _quick_add_term_with_priority(self, target: str = "project"):
        """Quick add selected term pair to the Project or first Background glossary.

        Args:
            target: "project" for the Project glossary (priority=1),
                    "background" for the first writable background glossary.
                    Legacy int values (1, 2) are auto-mapped for backward compat.
        """
        # Legacy compat: map old int calls
        if target == 1:
            target = "project"
        elif target == 2:
            target = "background"

        # Get current row
        current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
        if current_row < 0:
            self.statusBar().showMessage("No segment selected", 3000)
            return

        # Get source selection – strip invisible markers before saving
        source_widget = self.table.cellWidget(current_row, 2)
        source_text = ""
        if source_widget and hasattr(source_widget, 'textCursor'):
            source_text = self.reverse_invisible_replacements(source_widget.textCursor().selectedText()).strip()

        # Get target selection – strip invisible markers before saving
        target_widget = self.table.cellWidget(current_row, 3)
        target_text = ""
        if target_widget and hasattr(target_widget, 'textCursor'):
            target_text = self.reverse_invisible_replacements(target_widget.textCursor().selectedText()).strip()

        # Validate selections
        if not source_text or not target_text:
            self.statusBar().showMessage("Select text in both Source and Target cells first", 3000)
            return

        # Check prerequisites
        if not hasattr(self, 'current_project') or not self.current_project:
            self.statusBar().showMessage("No project open", 3000)
            return

        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            self.statusBar().showMessage("Termbase manager not initialized", 3000)
            return

        project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
        if not project_id:
            self.statusBar().showMessage("No project ID - save project first", 3000)
            return

        # Find target glossary based on project/background
        target_termbase = None
        glossary_rank = 1 if target == "project" else 0  # For cache entry
        all_termbases = self.termbase_mgr.get_all_termbases()

        for tb in all_termbases:
            tb_id = tb['id']
            if not self.termbase_mgr.is_termbase_active(tb_id, project_id):
                continue  # Not activated
            priority = self.termbase_mgr.get_termbase_priority(tb_id, project_id)
            is_writable = not tb.get('read_only', True)
            is_project_tb = (priority == 1)

            if target == "project" and is_project_tb:
                target_termbase = tb
                break
            elif target == "background" and not is_project_tb and is_writable:
                target_termbase = tb
                break

        label = "Project termbase" if target == "project" else "Background termbase"
        if not target_termbase:
            self.statusBar().showMessage(f"No {label} found – check Resources → Termbases", 3000)
            return
        
        self.log(f"⚡ Quick-adding term to {label} '{target_termbase['name']}': {source_text} → {target_text}")

        # "Similar Term Found" check (Trados parity). Only interrupts on a
        # same-source/same-target conflict; otherwise stays silent. When the
        # user merges, the new term is folded into the existing entry as a
        # synonym and we refresh and return without a fresh insert.
        decision, merged_ids = self._detect_and_merge_synonyms(
            source_text, target_text, [target_termbase])
        if decision == 'cancel':
            return
        if decision == 'merged':
            self._play_sound_effect('glossary_term_added')
            self.statusBar().showMessage(
                f"✓ Merged as synonym into '{target_termbase['name']}': "
                f"{source_text} → {target_text}", 3000)
            try:
                with self.termbase_cache_lock:
                    self.termbase_cache.clear()
                self._build_termbase_index()
            except Exception as e:
                self.log(f"WARNING: Failed to rebuild termbase index after merge: {e}")
            self._refresh_termbase_display_for_current_segment()
            if (hasattr(self, 'termbase_tab_refresh_callback')
                    and self.termbase_tab_refresh_callback):
                self.termbase_tab_refresh_callback()
            return

        # v1.10.62: orient for termbase direction. Project termbases
        # are usually aligned with the project, but background
        # termbases (also reachable here in some configurations) may
        # not be. _orient_term_for_termbase is the single source of
        # truth for the swap decision.
        src_term, tgt_term, src_lang, tgt_lang = (
            self._orient_term_for_termbase(source_text, target_text, target_termbase)
        )

        try:
            term_id = self.termbase_mgr.add_term(
                termbase_id=target_termbase['id'],
                source_term=src_term,
                target_term=tgt_term,
                source_lang=src_lang,
                target_lang=tgt_lang,
                priority=99,  # Default priority for the term itself
                notes="",
                domain="",
                project="",
                client="",
                forbidden=False
            )
            
            if term_id:
                self._play_sound_effect('glossary_term_added')
                self.log(f"✓ Added to '{target_termbase['name']}': {source_text} → {target_text}")
                self.statusBar().showMessage(f"✓ Added to '{target_termbase['name']}': {source_text} → {target_text}", 3000)
                
                # OPTIMIZATION: Directly add the new term to cache and TermLens instead of full search
                # This avoids the 5-6 second delay from searching all words in long patent segments
                current_row = self.table.currentRow()
                if current_row >= 0 and self.current_project:
                    id_item = self.table.item(current_row, 0)
                    if id_item:
                        try:
                            segment_id = int(id_item.text())
                            segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
                            
                            if segment:
                                # Create match entry for the new term
                                is_project = (glossary_rank == 1)
                                new_match = {
                                    'source': source_text,
                                    'translation': target_text,
                                    'ranking': glossary_rank,
                                    'forbidden': False,
                                    'is_project_termbase': is_project,
                                    'term_id': term_id,
                                    'termbase_id': target_termbase['id'],
                                    'termbase_name': target_termbase['name'],
                                    'domain': '',
                                    'notes': '',
                                    'project': '',
                                    'client': '',
                                    'target_synonyms': []
                                }

                                # Add to cache directly, replacing any lower-ranked duplicate
                                with self.termbase_cache_lock:
                                    if segment_id not in self.termbase_cache:
                                        self.termbase_cache[segment_id] = {}
                                    cached = self.termbase_cache[segment_id]

                                    # Remove existing entries for the same source→target pair
                                    # so the new (possibly higher-ranked) entry takes precedence
                                    src_lower = source_text.lower()
                                    tgt_lower = target_text.lower()
                                    dup_ids = [
                                        tid for tid, m in cached.items()
                                        if m.get('source', '').lower() == src_lower
                                        and m.get('translation', '').lower() == tgt_lower
                                    ]
                                    for tid in dup_ids:
                                        del cached[tid]

                                    cached[term_id] = new_match
                                    self.log(f"⚡ Added term directly to cache (instant update)")

                                # v1.9.182: Also add to in-memory termbase index for future lookups
                                import re
                                source_lower = source_text.lower().strip()
                                try:
                                    if any(c in source_lower for c in '.%,/-'):
                                        pattern = re.compile(r'(?<!\w)' + re.escape(source_lower) + r'(?!\w)')
                                    else:
                                        pattern = re.compile(r'\b' + re.escape(source_lower) + r'\b')
                                except re.error:
                                    pattern = None

                                # v1.10.73: include the new metadata + synonym
                                # fields in the optimisation-path index entry
                                # so it has the same shape as a full-rebuild
                                # entry. Empty defaults for everything that
                                # only gets populated by the dialog later —
                                # quick-add never sets definition / URL /
                                # abbreviations / synonyms, so they stay empty
                                # until the user either edits the entry or the
                                # next full rebuild picks them up from the DB.
                                index_entry = {
                                    'term_id': term_id,
                                    'source_term': source_text,
                                    'source_term_lower': source_lower,
                                    'target_term': target_text,
                                    'termbase_id': target_termbase['id'],
                                    'domain': '',
                                    'notes': '',
                                    'project': '',
                                    'client': '',
                                    'forbidden': False,
                                    'is_project_termbase': is_project,
                                    'termbase_name': target_termbase['name'],
                                    'ranking': glossary_rank,
                                    'definition': '',
                                    'url': '',
                                    'source_abbreviation': '',
                                    'target_abbreviation': '',
                                    'source_synonyms': [],
                                    'target_synonyms': [],
                                    'pattern': pattern,
                                }
                                with self.termbase_index_lock:
                                    self.termbase_index.append(index_entry)
                                    # Re-sort by length (longest first) for proper phrase matching
                                    self.termbase_index.sort(key=lambda x: len(x['source_term_lower']), reverse=True)
                                
                                # Update TermLens widget with the new term
                                if (hasattr(self, 'termlens_widget') and self.termlens_widget) or (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match):
                                    # Get current matches from cache
                                    with self.termbase_cache_lock:
                                        cached_matches = self.termbase_cache.get(segment_id, {})
                                    
                                    # Convert to list format for TermLens.
                                    # v1.10.73 (Tier 1 + Tier 2): forward the
                                    # new metadata + synonym fields so the
                                    # tooltip and alternative-translation
                                    # rendering pick them up consistently
                                    # with the other call sites that build
                                    # termbase_list.
                                    termbase_list = [
                                        {
                                            'source_term': match.get('source', ''),
                                            'target_term': match.get('translation', ''),
                                            'termbase_name': match.get('termbase_name', ''),
                                            'ranking': match.get('ranking', 0),
                                            'is_project_termbase': match.get('is_project_termbase', False),
                                            'term_id': match.get('term_id'),
                                            'termbase_id': match.get('termbase_id'),
                                            'notes': match.get('notes', ''),
                                            'definition': match.get('definition', ''),
                                            'domain': match.get('domain', ''),
                                            'url': match.get('url', ''),
                                            'source_abbreviation': match.get('source_abbreviation', ''),
                                            'target_abbreviation': match.get('target_abbreviation', ''),
                                            'source_synonyms': match.get('source_synonyms', []),
                                            'target_synonyms': match.get('target_synonyms', []),
                                            # v1.10.75 (Tier 3) flags
                                            'forbidden': match.get('forbidden', False),
                                            'is_nontranslatable': match.get('is_nontranslatable', False),
                                            # v1.10.75 (Tier 3c) abbreviation marker
                                            'matched_via_abbreviation': match.get('matched_via_abbreviation', False),
                                        }
                                        for match in cached_matches.values() if isinstance(match, dict)
                                    ]
                                    
                                    # Get NT matches
                                    nt_matches = self.find_nt_matches_in_source(segment.source)

                                    # Get status hint (although after adding a term, it should be fine)
                                    status_hint = self._get_termbase_status_hint()

                                    # Update both TermLens widgets (left and right)
                                    self._update_both_termlens(segment.source, termbase_list, nt_matches, status_hint)
                                    self.log(f"✅ Both TermLens widgets updated instantly with new term")
                                
                                # Update source cell highlighting with updated cache
                                # Call the highlighting function directly with the new matches
                                self.highlight_source_with_termbase(current_row, segment.source, cached_matches)
                                self.log(f"✅ Source highlighting updated with new term")

                                # Invalidate stale cache entries for ALL OTHER segments.
                                # The batch worker pre-populated these before the new term existed,
                                # so they may be missing the new term. Clear them now so the next
                                # navigation triggers a fresh lookup (or the batch worker refills them).
                                with self.termbase_cache_lock:
                                    stale_tb = [k for k in self.termbase_cache if k != segment_id]
                                    for k in stale_tb:
                                        del self.termbase_cache[k]
                                with self.translation_matches_cache_lock:
                                    stale_tm = [k for k in self.translation_matches_cache if k != segment_id]
                                    for k in stale_tm:
                                        del self.translation_matches_cache[k]

                                # Re-start the batch worker to re-populate all segments with the new
                                # term included (fast: ~0.3s for 659 segments in background).
                                self._start_termbase_batch_worker()
                                self.log(f"🔄 Cache invalidated for other segments; batch worker re-started")

                        except Exception as e:
                            self.log(f"⚠️  Quick cache update failed, falling back to full search: {e}")
                            # Fallback to full refresh if something goes wrong
                            self._refresh_termbase_display_for_current_segment()
                
                # OPTIMIZATION: Skip refreshing the entire termbase table UI (slow)
                # We're just adding ONE term to ONE glossary - no need to rebuild the whole table
                # The term count will update naturally when user switches segments or tabs
                # if hasattr(self, 'termbase_tab_refresh_callback') and self.termbase_tab_refresh_callback:
                #     self.termbase_tab_refresh_callback()
            else:
                self._play_sound_effect('glossary_term_duplicate')
                self.statusBar().showMessage(f"Term already exists in '{target_termbase['name']}'", 3000)
        except Exception as e:
            self.log(f"✗ Error adding term: {e}")
            self._play_sound_effect('glossary_term_error')
            self.statusBar().showMessage(f"Error adding term: {e}", 3000)

    def add_word_to_dictionary_shortcut(self):
        """Add word at cursor position to custom dictionary (Alt+D shortcut)
        
        Finds the misspelled word at the current cursor position and adds it to the
        custom dictionary. Works when focus is in a grid target cell.
        """
        # Get currently focused widget
        focused_widget = QApplication.focusWidget()
        
        # Check if we're in an editable grid cell
        if not isinstance(focused_widget, EditableGridTextEditor):
            self.statusBar().showMessage(
                f"{format_shortcut_for_display('Alt+D')}: Place cursor on a misspelled word in the target cell first",
                3000,
            )
            return
        
        # Get cursor position
        cursor = focused_widget.textCursor()
        
        # Try to find misspelled word at cursor
        word_info = focused_widget._get_misspelled_word_at_cursor(cursor)
        
        if word_info[0] is None:
            # No misspelled word found at cursor
            self.statusBar().showMessage("No misspelled word at cursor position", 3000)
            return
        
        word, start_pos, end_pos = word_info
        
        # Add to dictionary
        focused_widget._add_to_dictionary(word)
        
        # Status message already shown by _add_to_dictionary

    def add_text_to_non_translatables(self, text: str):
        """Add the selected source-cell text as a non-translatable termbase entry.

        Stores the text on a writable activated termbase with
        ``is_nontranslatable = 1``, mirroring the Trados plugin's
        approach. Picks the project termbase if one is set; otherwise
        the first writable activated termbase. Falls back to a clear
        error message rather than silently doing nothing.
        """
        if not text or not text.strip():
            QMessageBox.warning(self, "No Text", "Please select text before adding to non-translatables.")
            return

        text = text.strip()

        if not hasattr(self, 'current_project') or not self.current_project:
            QMessageBox.warning(self, "No project", "Open a project first.")
            return

        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            QMessageBox.critical(self, "Error", "Termbase manager not initialised.")
            return

        project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
        if not project_id:
            QMessageBox.warning(self, "No project ID", "Save the project before adding non-translatables.")
            return

        # Pick a target termbase: project termbase first, otherwise first
        # writable activated termbase.
        target_termbase = None
        all_termbases = self.termbase_mgr.get_all_termbases()
        for tb in all_termbases:
            if not self.termbase_mgr.is_termbase_active(tb['id'], project_id):
                continue
            if tb.get('read_only', True):
                continue
            priority = self.termbase_mgr.get_termbase_priority(tb['id'], project_id)
            if priority == 1:
                target_termbase = tb
                break
        if target_termbase is None:
            for tb in all_termbases:
                if not self.termbase_mgr.is_termbase_active(tb['id'], project_id):
                    continue
                if not tb.get('read_only', True):
                    target_termbase = tb
                    break

        if target_termbase is None:
            QMessageBox.warning(
                self,
                "No writable termbase",
                "Activate at least one writable termbase for this project "
                "before marking text as non-translatable.\n\n"
                "Resources → Termbases → tick the Read and Write boxes."
            )
            return

        # Use language codes that match the project so the index can
        # orient correctly when the termbase is reverse-direction.
        source_lang = self.current_project.source_lang if self.current_project else None
        target_lang = self.current_project.target_lang if self.current_project else None
        source_lang_code = self._convert_language_to_code(source_lang) if source_lang else None
        target_lang_code = self._convert_language_to_code(target_lang) if target_lang else None

        try:
            term_id = self.termbase_mgr.add_term(
                termbase_id=target_termbase['id'],
                source_term=text,
                target_term=text,  # NT: target = source (Trados convention)
                source_lang=source_lang_code,
                target_lang=target_lang_code,
                notes="Added as non-translatable from grid",
                is_nontranslatable=True,
            )
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to add non-translatable: {e}")
            return

        if term_id is None:
            self.statusBar().showMessage(
                f"⚠ '{text}' is already in '{target_termbase['name']}'", 3000
            )
            return

        self._play_sound_effect('glossary_term_added') if hasattr(self, '_play_sound_effect') else None
        self.log(f"🚫 Added non-translatable to '{target_termbase['name']}': {text}")
        self.statusBar().showMessage(
            f"🚫 Added non-translatable: '{text}' → {target_termbase['name']}", 3000
        )

        # Rebuild the in-memory term index so the new NT highlights immediately
        # in the current segment and onward, rather than waiting for next reload.
        try:
            self._build_termbase_index()
        except Exception as e:
            self.log(f"⚠ Could not rebuild termbase index: {e}")

        # Refresh highlighting for the current segment.
        if hasattr(self, 'table') and self.table is not None:
            current_row = self.table.currentRow()
            if current_row >= 0 and self.current_project and current_row < len(self.current_project.segments):
                segment = self.current_project.segments[current_row]

                # Refresh the source cell highlighting
                source_widget = self.table.cellWidget(current_row, 2)
                if source_widget and hasattr(source_widget, 'highlight_termbase_matches'):
                    source_text = segment.source  # v1.9.306: use clean segment text, not display text
                    nt_matches = self.find_nt_matches_in_source(source_text)
                    if nt_matches and hasattr(source_widget, 'highlight_non_translatables'):
                        source_widget.highlight_non_translatables(nt_matches)
                
                # Trigger panel refresh
                self._last_selected_row = -1
                self.on_cell_selected(current_row, self.table.currentColumn(), -1, -1)

    def create_termbases_tab(self):
        """Create the Termbases tab - manage all termbases (global and project-specific)"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        
        # Header
        header = QLabel("📚 Termbases")
        header.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 10px;")
        layout.addWidget(header)

        # Description
        desc = QLabel("Manage termbases for terminology searching. Activate/deactivate for current project.")
        desc.setStyleSheet("color: #666; font-size: 11px; margin-bottom: 10px;")
        layout.addWidget(desc)

        # Check if database is available
        if not (hasattr(self, 'db_manager') and self.db_manager):
            placeholder = QLabel("Termbase Manager\n\nDatabase not initialized.")
            placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
            placeholder.setStyleSheet("color: #888; font-size: 12px;")
            layout.addWidget(placeholder, stretch=1)
            return tab
        
        # Import here to avoid issues if database not available
        from modules.termbase_manager import TermbaseManager
        termbase_mgr = TermbaseManager(self.db_manager, self.log)
        self.termbase_mgr = termbase_mgr  # Store for later use in get_termbase_code

        set_help_topic(tab, HelpTopics.GLOSSARY_BASICS)

        # ========== MAIN SPLITTER ==========
        main_splitter = QSplitter(Qt.Orientation.Horizontal)
        
        # ========== LEFT PANEL: Termbase List ==========
        left_panel = QWidget()
        left_layout = QVBoxLayout(left_panel)
        left_layout.setContentsMargins(0, 0, 5, 0)
        
        # Search bar
        search_layout = QHBoxLayout()
        search_box = QLineEdit()
        search_box.setPlaceholderText("Search termbases...")
        search_box.setMaximumWidth(300)
        search_layout.addWidget(search_box)
        search_layout.addStretch()
        left_layout.addLayout(search_layout)
        
        # Help message
        help_msg = QLabel(
            "💡 <b>Termbases</b><br>"
            "Manage termbases for terminology searching. Activate/deactivate for current project.<br>"
            "• <b>Read</b> (green ✓): Termbase is used for terminology matching<br>"
            "• <b>Write</b> (blue ✓): Termbase is updated with new terms<br>"
            "• <b>Project</b> (pink ✓): Set as Project Termbase (highest priority, one at a time)<br>"
            "• <b>AI</b> (orange ✓): Send termbase terms to LLM with every translation (increases prompt size)"
        )
        help_msg.setWordWrap(True)
        help_msg.setStyleSheet("background-color: #e3f2fd; padding: 8px; border-radius: 4px; color: #1976d2;")
        left_layout.addWidget(help_msg)
        
        # Bulk action controls
        tb_bulk_layout = QHBoxLayout()
        tb_bulk_layout.addWidget(QLabel("Quick Actions:"))
        
        tb_read_header_checkbox = CheckmarkCheckBox("Select All Read")
        tb_bulk_layout.addWidget(tb_read_header_checkbox)
        
        tb_write_header_checkbox = BlueCheckmarkCheckBox("Select All Write")
        tb_bulk_layout.addWidget(tb_write_header_checkbox)
        
        tb_bulk_layout.addStretch()
        left_layout.addLayout(tb_bulk_layout)
        
        # Termbase list with table
        termbase_table = QTableWidget()
        self.termbase_table = termbase_table  # Store for external access (Superlookup navigation)
        # v1.10.28: added 🎤 Voice column (per-termbase opt-in for
        # voice-dictation vocabulary biasing). New column count = 9.
        termbase_table.setColumnCount(9)
        termbase_table.setHorizontalHeaderLabels([
            "Type", "Name", "Languages", "Terms",
            "Read", "Write", "Project", "AI", "🎤 Voice",
        ])
        termbase_table.horizontalHeader().setStretchLastSection(False)
        termbase_table.setColumnWidth(0, 80)   # Type (Project/Background)
        termbase_table.setColumnWidth(1, 180)  # Name
        termbase_table.setColumnWidth(2, 100)  # Languages
        termbase_table.setColumnWidth(3, 50)   # Terms
        termbase_table.setColumnWidth(4, 50)   # Read checkbox
        termbase_table.setColumnWidth(5, 50)   # Write checkbox
        termbase_table.setColumnWidth(6, 60)   # Priority
        termbase_table.setColumnWidth(7, 40)   # AI checkbox
        termbase_table.setColumnWidth(8, 70)   # 🎤 Voice checkbox
        termbase_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        termbase_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
        termbase_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)  # Disable inline editing
        
        # Enable context menu for termbase table
        termbase_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        termbase_table.customContextMenuRequested.connect(
            lambda pos: self._show_termbase_context_menu(pos, termbase_table, termbase_mgr, refresh_termbase_list)
        )
        
        # Get current project
        current_project = self.current_project if hasattr(self, 'current_project') else None
        # current_project is a Project object, not a dict
        project_id = current_project.id if (current_project and hasattr(current_project, 'id')) else None
        
        # Filter function for search box
        def filter_termbase_table(search_text):
            """Filter termbase table rows by name"""
            search_text = search_text.lower().strip()
            for row in range(termbase_table.rowCount()):
                name_item = termbase_table.item(row, 1)
                if name_item:
                    name = name_item.text().lower()
                    # Show row if search text is in the name, or if search is empty
                    termbase_table.setRowHidden(row, search_text and search_text not in name)
        
        # Connect search box to filter function
        search_box.textChanged.connect(filter_termbase_table)
        
        # Connect header checkboxes to toggle all
        def toggle_all_tb_read(checked):
            for row in range(termbase_table.rowCount()):
                checkbox = termbase_table.cellWidget(row, 4)
                if checkbox and isinstance(checkbox, CheckmarkCheckBox):
                    checkbox.setChecked(checked)
        
        def toggle_all_tb_write(checked):
            for row in range(termbase_table.rowCount()):
                checkbox = termbase_table.cellWidget(row, 5)
                if checkbox and isinstance(checkbox, BlueCheckmarkCheckBox):
                    checkbox.setChecked(checked)
        
        tb_read_header_checkbox.toggled.connect(toggle_all_tb_read)
        tb_write_header_checkbox.toggled.connect(toggle_all_tb_write)
        
        # ========== RIGHT PANEL: Terms Editor ==========
        right_panel = QWidget()
        right_layout = QVBoxLayout(right_panel)
        right_layout.setContentsMargins(5, 0, 0, 0)
        
        # Terms header with termbase name
        terms_header_layout = QHBoxLayout()
        terms_header = QLabel("📝 Terms")
        terms_header.setStyleSheet("font-size: 13px; font-weight: bold;")
        terms_header_layout.addWidget(terms_header)
        
        selected_tb_label = QLabel("Select a termbase to view/edit terms")
        selected_tb_label.setStyleSheet("color: #666; font-style: italic;")
        terms_header_layout.addWidget(selected_tb_label)
        terms_header_layout.addStretch()
        right_layout.addLayout(terms_header_layout)
        
        # Search/filter for terms
        terms_search_layout = QHBoxLayout()
        terms_search_box = QLineEdit()
        terms_search_box.setPlaceholderText("Filter terms...")
        terms_search_box.setMaximumWidth(250)
        terms_search_layout.addWidget(terms_search_box)
        self.terms_search_box = terms_search_box  # Store for external access
        terms_search_layout.addStretch()
        right_layout.addLayout(terms_search_layout)
        
        # ========== PAGINATION CONTROLS ==========
        # State variables for pagination (using lists to allow modification in nested functions)
        terms_page_size = [100]  # Terms per page
        terms_current_page = [0]  # Current page (0-indexed)
        terms_total_count = [0]  # Total terms in current termbase
        terms_filter_text = ['']  # Current filter text
        # v1.10.65: configurable ORDER BY clause for the terms query so
        # users can flip the grid between "alphabetical" (default) and
        # "newest first" — the latter is essential for finding/fixing
        # a batch of terms you just added but realised were wrong.
        # Whitelisted strings only (we inline them into the SQL); see
        # _SORT_OPTIONS below.
        terms_sort_order = ['source_term COLLATE NOCASE ASC']

        pagination_layout = QHBoxLayout()
        pagination_layout.setContentsMargins(0, 5, 0, 5)

        # Page size selector
        page_size_label = QLabel("Show:")
        page_size_combo = QComboBox()
        page_size_combo.addItems(["50", "100", "250", "500", "All"])
        page_size_combo.setCurrentText("100")
        page_size_combo.setFixedWidth(70)
        pagination_layout.addWidget(page_size_label)
        pagination_layout.addWidget(page_size_combo)

        pagination_layout.addSpacing(15)

        # Sort selector (v1.10.65). Label → SQL ORDER BY clause.
        # The order-by strings are NEVER user-supplied; they're keyed
        # off a fixed dropdown, so inlining them into the SELECT is
        # safe. (Parameterising ORDER BY would require sqlite3
        # gymnastics for no real benefit here.)
        _SORT_OPTIONS = [
            ("Source term (A→Z)",      "source_term COLLATE NOCASE ASC"),
            ("Source term (Z→A)",      "source_term COLLATE NOCASE DESC"),
            ("Created (newest first)", "created_date DESC, id DESC"),
            ("Created (oldest first)", "created_date ASC, id ASC"),
            ("Modified (newest first)", "modified_date DESC, id DESC"),
            ("Modified (oldest first)", "modified_date ASC, id ASC"),
        ]
        sort_label = QLabel("Sort:")
        sort_combo = QComboBox()
        for label, _clause in _SORT_OPTIONS:
            sort_combo.addItem(label)
        sort_combo.setFixedWidth(180)
        sort_combo.setToolTip(
            "Choose how terms are ordered in the grid.\n"
            "Tip: 'Created (newest first)' is the quickest way to find\n"
            "(and fix or delete) a batch of terms you just added."
        )
        pagination_layout.addWidget(sort_label)
        pagination_layout.addWidget(sort_combo)

        def on_sort_changed(idx):
            if 0 <= idx < len(_SORT_OPTIONS):
                clause = _SORT_OPTIONS[idx][1]
                terms_sort_order[0] = clause
                terms_current_page[0] = 0  # Reset to first page so newest jumps into view
                # v1.10.66: mirror the dropdown choice on the column
                # header sort indicator so the two stay visually in sync.
                # Map: option index → (column index, ascending).
                # 0:Source A→Z (col 0 ASC), 1:Source Z→A (col 0 DESC),
                # 2:Created newest (col 7 DESC), 3:Created oldest (col 7 ASC),
                # 4/5:Modified (no column → leave indicator where it is).
                _DROPDOWN_TO_HEADER = {
                    0: (0, True),
                    1: (0, False),
                    2: (7, False),
                    3: (7, True),
                }
                hdr = _DROPDOWN_TO_HEADER.get(idx)
                if hdr is not None:
                    _set_header_indicator(hdr[0], hdr[1])
                if current_terms_tb_id[0]:
                    load_terms_page()
        sort_combo.currentIndexChanged.connect(on_sort_changed)

        pagination_layout.addSpacing(20)
        
        # Navigation buttons
        first_page_btn = QPushButton("⏮")
        first_page_btn.setFixedWidth(30)
        first_page_btn.setToolTip("First page")
        prev_page_btn = QPushButton("◀")
        prev_page_btn.setFixedWidth(30)
        prev_page_btn.setToolTip("Previous page")
        
        page_info_label = QLabel("Page 1 of 1")
        page_info_label.setStyleSheet("padding: 0 10px;")
        
        next_page_btn = QPushButton("▶")
        next_page_btn.setFixedWidth(30)
        next_page_btn.setToolTip("Next page")
        last_page_btn = QPushButton("⏭")
        last_page_btn.setFixedWidth(30)
        last_page_btn.setToolTip("Last page")
        
        pagination_layout.addWidget(first_page_btn)
        pagination_layout.addWidget(prev_page_btn)
        pagination_layout.addWidget(page_info_label)
        pagination_layout.addWidget(next_page_btn)
        pagination_layout.addWidget(last_page_btn)
        
        pagination_layout.addSpacing(20)
        
        # Total count label
        total_terms_label = QLabel("(0 terms total)")
        total_terms_label.setStyleSheet("color: #666;")
        pagination_layout.addWidget(total_terms_label)
        
        pagination_layout.addStretch()
        right_layout.addLayout(pagination_layout)
        
        # Terms table - columns: Source, Target, Domain, Notes, Project, Client, Forbidden, Created, Delete
        # v1.10.65: added the **Created** column (read-only timestamp,
        # rendered yyyy-mm-dd HH:MM) so users can flip the sort
        # dropdown to "Created (newest first)" and immediately see —
        # and triage — terms they just added but realised were wrong.
        # The Delete button moved from col 7 → col 8.
        terms_table = QTableWidget()
        terms_table.setColumnCount(9)
        terms_table.setHorizontalHeaderLabels(["Source Term", "Target Term", "Domain", "Notes", "Project", "Client", "Forbidden", "Created", ""])
        terms_table.horizontalHeader().setStretchLastSection(False)
        terms_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)      # Source – fills remaining space
        terms_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)      # Target – fills remaining space
        terms_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)  # Domain
        terms_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)  # Notes
        terms_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive)  # Project
        terms_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Interactive)  # Client
        terms_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Interactive)  # Forbidden
        terms_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Interactive)  # Created
        terms_table.horizontalHeader().setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed)        # Delete button
        terms_table.setColumnWidth(2, 100)  # Domain
        terms_table.setColumnWidth(3, 120)  # Notes
        terms_table.setColumnWidth(4, 100)  # Project
        terms_table.setColumnWidth(5, 100)  # Client
        terms_table.setColumnWidth(6, 70)   # Forbidden
        terms_table.setColumnWidth(7, 130)  # Created (fits "2026-05-17 14:32" with a little slack)
        terms_table.setColumnWidth(8, 30)   # Delete button
        terms_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        # v1.10.66: click-to-sort on every data column. We do NOT call
        # setSortingEnabled(True) — that would tell Qt to sort the
        # *visible page* in-place (12 rows of 50,000), which is the
        # wrong answer. Instead we just show the sort indicator and
        # re-issue the SQL with a new ORDER BY when a header is
        # clicked. Sort the entire termbase, not just the page that
        # happens to be loaded.
        terms_table.horizontalHeader().setSortIndicatorShown(True)
        terms_table.horizontalHeader().setSectionsClickable(True)
        # Column-index → (SQL fragment, dropdown index if any). Columns
        # 8 (Delete button) is not sortable — we filter clicks on it
        # below. The fragments use COLLATE NOCASE on text columns so
        # the alphabetical sort isn't case-sensitive (matches the
        # existing dropdown convention).
        _COL_SORT = {
            0: ('source_term COLLATE NOCASE',   0),  # → dropdown "Source term (A→Z)"
            1: ('target_term COLLATE NOCASE',   None),
            2: ('domain COLLATE NOCASE',        None),
            3: ('notes COLLATE NOCASE',         None),
            4: ('project COLLATE NOCASE',       None),
            5: ('client COLLATE NOCASE',        None),
            6: ('forbidden',                    None),  # 0/1 numeric
            7: ('created_date',                 2),     # → dropdown "Created (newest first)"
        }
        right_layout.addWidget(terms_table, stretch=1)

        # Store current termbase ID for the terms panel
        current_terms_tb_id = [None]  # Use list to allow modification in nested function

        def _set_header_indicator(col: int, ascending: bool):
            """Show the up/down arrow on the clicked header column.

            We disconnect the dropdown's signal first so changing the
            dropdown's index here doesn't recurse back into a reload.
            """
            from PyQt6.QtCore import Qt as _Qt
            order = _Qt.SortOrder.AscendingOrder if ascending else _Qt.SortOrder.DescendingOrder
            terms_table.horizontalHeader().setSortIndicator(col, order)

        def _sync_dropdown_to_sort(sort_clause: str):
            """Best-effort sync: if the current sort matches one of the
            dropdown options, surface that option as selected. Otherwise
            leave the dropdown where it is — it just reads as a 'hint
            of the last preset chosen' rather than the current sort.
            """
            for idx, (_label, clause) in enumerate(_SORT_OPTIONS):
                if clause.startswith(sort_clause):
                    sort_combo.blockSignals(True)
                    sort_combo.setCurrentIndex(idx)
                    sort_combo.blockSignals(False)
                    return
            # No exact match — clear visual selection by selecting
            # nothing (cosmetic only; sort still applies).
            pass

        def on_header_clicked(col: int):
            if col not in _COL_SORT:
                return  # Delete button column — ignore
            sql_col, _dropdown_idx = _COL_SORT[col]
            # Toggle direction if same column was clicked again.
            current = terms_sort_order[0]
            if current.startswith(sql_col):
                ascending = ' DESC' in current  # was DESC, flip to ASC
            else:
                # Sensible defaults: text/forbidden columns ascending,
                # date columns descending (newest first — that's what
                # users almost always want for "Created" / "Modified").
                ascending = sql_col not in ('created_date', 'modified_date')
            direction = 'ASC' if ascending else 'DESC'
            # Tiebreak by id so the sort is deterministic across pages
            # when many rows share the same value (esp. timestamps for
            # bulk-imported termbases).
            terms_sort_order[0] = f"{sql_col} {direction}, id {direction}"
            terms_current_page[0] = 0
            _set_header_indicator(col, ascending)
            _sync_dropdown_to_sort(f"{sql_col} {direction}")
            if current_terms_tb_id[0]:
                load_terms_page()

        terms_table.horizontalHeader().sectionClicked.connect(on_header_clicked)

        # Show the initial indicator on column 0 (matches the default
        # terms_sort_order = source_term ASC).
        _set_header_indicator(0, True)

        def filter_terms_table(search_text):
            """Filter terms table rows by source or target term"""
            search_text = search_text.lower().strip()
            terms_filter_text[0] = search_text
            # When filter changes, reset to page 0 and reload
            terms_current_page[0] = 0
            if current_terms_tb_id[0]:
                load_terms_page()
        
        terms_search_box.textChanged.connect(filter_terms_table)
        
        def update_pagination_ui():
            """Update pagination controls based on current state"""
            page_size = terms_page_size[0]
            total = terms_total_count[0]
            current = terms_current_page[0]
            
            if page_size == 0:  # "All" selected
                total_pages = 1
            else:
                total_pages = max(1, (total + page_size - 1) // page_size)
            
            page_info_label.setText(f"Page {current + 1} of {total_pages}")
            total_terms_label.setText(f"({total:,} terms total)")
            
            # Enable/disable navigation buttons
            first_page_btn.setEnabled(current > 0)
            prev_page_btn.setEnabled(current > 0)
            next_page_btn.setEnabled(current < total_pages - 1)
            last_page_btn.setEnabled(current < total_pages - 1)
        
        def load_terms_page():
            """Load current page of terms from the database"""
            if current_terms_tb_id[0] is None:
                return
            
            terms_table.setRowCount(0)
            terms_table.blockSignals(True)
            
            try:
                tb_id = current_terms_tb_id[0]
                filter_text = terms_filter_text[0]
                page_size = terms_page_size[0]
                offset = terms_current_page[0] * page_size if page_size > 0 else 0
                
                # v1.10.65: ORDER BY is driven by the Sort dropdown
                # (terms_sort_order). The clause is a whitelisted string
                # from a fixed list (_SORT_OPTIONS), never user-supplied,
                # so inlining it into the SQL is safe.
                order_clause = terms_sort_order[0]

                # Build query with optional filter. SELECT now also
                # returns created_date so the v1.10.65 Created column
                # can render the timestamp.
                if filter_text:
                    # Count filtered results
                    self.db_manager.cursor.execute(
                        """SELECT COUNT(*) FROM termbase_terms
                           WHERE termbase_id = CAST(? AS TEXT)
                           AND (LOWER(source_term) LIKE ? OR LOWER(target_term) LIKE ?)""",
                        (tb_id, f'%{filter_text}%', f'%{filter_text}%')
                    )
                    terms_total_count[0] = self.db_manager.cursor.fetchone()[0]

                    # Get filtered page
                    if page_size > 0:
                        self.db_manager.cursor.execute(
                            f"""SELECT id, source_term, target_term, domain, notes, project, client, forbidden, created_date
                               FROM termbase_terms
                               WHERE termbase_id = CAST(? AS TEXT)
                               AND (LOWER(source_term) LIKE ? OR LOWER(target_term) LIKE ?)
                               ORDER BY {order_clause} LIMIT ? OFFSET ?""",
                            (tb_id, f'%{filter_text}%', f'%{filter_text}%', page_size, offset)
                        )
                    else:  # All
                        self.db_manager.cursor.execute(
                            f"""SELECT id, source_term, target_term, domain, notes, project, client, forbidden, created_date
                               FROM termbase_terms
                               WHERE termbase_id = CAST(? AS TEXT)
                               AND (LOWER(source_term) LIKE ? OR LOWER(target_term) LIKE ?)
                               ORDER BY {order_clause}""",
                            (tb_id, f'%{filter_text}%', f'%{filter_text}%')
                        )
                else:
                    # Count all results
                    self.db_manager.cursor.execute(
                        "SELECT COUNT(*) FROM termbase_terms WHERE termbase_id = CAST(? AS TEXT)",
                        (tb_id,)
                    )
                    terms_total_count[0] = self.db_manager.cursor.fetchone()[0]

                    # Get page
                    if page_size > 0:
                        self.db_manager.cursor.execute(
                            f"""SELECT id, source_term, target_term, domain, notes, project, client, forbidden, created_date
                               FROM termbase_terms WHERE termbase_id = CAST(? AS TEXT)
                               ORDER BY {order_clause} LIMIT ? OFFSET ?""",
                            (tb_id, page_size, offset)
                        )
                    else:  # All
                        self.db_manager.cursor.execute(
                            f"""SELECT id, source_term, target_term, domain, notes, project, client, forbidden, created_date
                               FROM termbase_terms WHERE termbase_id = CAST(? AS TEXT) ORDER BY {order_clause}""",
                            (tb_id,)
                        )
                
                terms = self.db_manager.cursor.fetchall()
                terms_table.setRowCount(len(terms))
                
                for row, term in enumerate(terms):
                    term_id, source, target, domain, notes, project, client, forbidden, created_raw = term

                    # Source term (editable)
                    source_item = QTableWidgetItem(source or "")
                    source_item.setData(Qt.ItemDataRole.UserRole, term_id)
                    terms_table.setItem(row, 0, source_item)

                    # Target term (editable)
                    target_item = QTableWidgetItem(target or "")
                    terms_table.setItem(row, 1, target_item)

                    # Domain (editable)
                    domain_item = QTableWidgetItem(domain or "")
                    terms_table.setItem(row, 2, domain_item)

                    # Notes (editable)
                    notes_item = QTableWidgetItem(notes or "")
                    terms_table.setItem(row, 3, notes_item)

                    # Project (editable)
                    project_item = QTableWidgetItem(project or "")
                    terms_table.setItem(row, 4, project_item)

                    # Client (editable)
                    client_item = QTableWidgetItem(client or "")
                    terms_table.setItem(row, 5, client_item)

                    # Forbidden (checkbox)
                    forbidden_checkbox = CheckmarkCheckBox()
                    forbidden_checkbox.setChecked(bool(forbidden))
                    forbidden_checkbox.setToolTip("Mark term as forbidden (do not use)")
                    forbidden_checkbox.toggled.connect(lambda checked, tid=term_id: save_forbidden_state(tid, checked))
                    terms_table.setCellWidget(row, 6, forbidden_checkbox)

                    # Created timestamp (read-only). SQLite returns
                    # CURRENT_TIMESTAMP values as "YYYY-MM-DD HH:MM:SS"
                    # strings (UTC). Trim the seconds for compactness;
                    # the full value goes into the tooltip for anyone
                    # who needs the precise instant. Cell is marked
                    # non-editable so the save_term_edit handler can
                    # safely ignore changes here.
                    created_str = str(created_raw) if created_raw else ""
                    created_display = created_str[:16] if len(created_str) >= 16 else created_str
                    created_item = QTableWidgetItem(created_display)
                    if created_str:
                        created_item.setToolTip(f"Created: {created_str} (UTC)")
                    created_item.setFlags(created_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
                    terms_table.setItem(row, 7, created_item)

                    # Delete button (moved from col 7 → col 8 in v1.10.65)
                    delete_btn = QPushButton("🗑")
                    delete_btn.setFixedSize(24, 24)
                    delete_btn.setToolTip("Delete this term")
                    delete_btn.setStyleSheet("QPushButton { border: none; } QPushButton:hover { background-color: #ffcccc; }")
                    delete_btn.clicked.connect(lambda checked, tid=term_id: delete_term(tid))
                    terms_table.setCellWidget(row, 8, delete_btn)
                
                update_pagination_ui()
                
            except Exception as e:
                self.log(f"⚠️ Error loading terms: {e}")
            finally:
                terms_table.blockSignals(False)
        
        def load_terms_for_termbase(tb_id, tb_name):
            """Load terms from selected termbase into the terms table (resets to page 1)"""
            current_terms_tb_id[0] = tb_id
            terms_current_page[0] = 0  # Reset to first page
            terms_filter_text[0] = ''  # Clear filter
            terms_search_box.clear()  # Clear search box
            selected_tb_label.setText(f"<b>{tb_name}</b>")
            selected_tb_label.setStyleSheet("color: #1976d2; font-weight: bold;")

            # Update Source/Target column headers with the termbase's actual
            # base-language names (e.g. "Dutch" instead of "Source Term",
            # stripping region variants like "nl-NL" → "Dutch").
            try:
                tb_info = termbase_mgr.get_termbase(tb_id)
                if tb_info:
                    src_code = tb_info.get('source_lang') or ''
                    tgt_code = tb_info.get('target_lang') or ''
                    src_header = self._normalize_language_code(src_code) if src_code else "Source Term"
                    tgt_header = self._normalize_language_code(tgt_code) if tgt_code else "Target Term"
                    terms_table.setHorizontalHeaderLabels([
                        src_header, tgt_header, "Domain", "Notes",
                        "Project", "Client", "Forbidden", "Created", ""
                    ])
            except Exception:
                pass

            load_terms_page()
            self.log(f"📝 Loaded {terms_total_count[0]:,} terms from termbase '{tb_name}'")
        
        # Pagination button handlers
        def go_first_page():
            terms_current_page[0] = 0
            load_terms_page()
        
        def go_prev_page():
            if terms_current_page[0] > 0:
                terms_current_page[0] -= 1
                load_terms_page()
        
        def go_next_page():
            page_size = terms_page_size[0]
            if page_size > 0:
                total_pages = (terms_total_count[0] + page_size - 1) // page_size
                if terms_current_page[0] < total_pages - 1:
                    terms_current_page[0] += 1
                    load_terms_page()
        
        def go_last_page():
            page_size = terms_page_size[0]
            if page_size > 0:
                total_pages = max(1, (terms_total_count[0] + page_size - 1) // page_size)
                terms_current_page[0] = total_pages - 1
                load_terms_page()
        
        def on_page_size_changed(text):
            if text == "All":
                terms_page_size[0] = 0
            else:
                terms_page_size[0] = int(text)
            terms_current_page[0] = 0  # Reset to first page
            load_terms_page()
        
        first_page_btn.clicked.connect(go_first_page)
        prev_page_btn.clicked.connect(go_prev_page)
        next_page_btn.clicked.connect(go_next_page)
        last_page_btn.clicked.connect(go_last_page)
        page_size_combo.currentTextChanged.connect(on_page_size_changed)

        def save_forbidden_state(term_id, forbidden):
            """Save the forbidden state of a term"""
            try:
                # v1.10.74: bump modified_date so the snapshot-gated
                # auto-refresh in force_refresh_matches sees the
                # change. See modules/termbase_entry_editor.save_term
                # for the full rationale.
                self.db_manager.cursor.execute(
                    "UPDATE termbase_terms SET forbidden = ?, "
                    "modified_date = CURRENT_TIMESTAMP WHERE id = ?",
                    (1 if forbidden else 0, term_id)
                )
                self.db_manager.connection.commit()
                # Clear termbase cache
                with self.termbase_cache_lock:
                    self.termbase_cache.clear()
            except Exception as e:
                self.log(f"⚠️ Error saving forbidden state: {e}")
        
        def delete_term(term_id):
            """Delete a term from the termbase"""
            reply = QMessageBox.question(
                self, "Delete Term",
                "Are you sure you want to delete this term?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.Yes:
                try:
                    self.db_manager.cursor.execute("DELETE FROM termbase_terms WHERE id = ?", (term_id,))
                    self.db_manager.connection.commit()
                    self.log(f"🗑️ Deleted term {term_id}")
                    # Reload terms
                    if current_terms_tb_id[0]:
                        # Get current termbase name from table
                        for row in range(termbase_table.rowCount()):
                            name_item = termbase_table.item(row, 1)
                            if name_item and name_item.data(Qt.ItemDataRole.UserRole) == current_terms_tb_id[0]:
                                load_terms_for_termbase(current_terms_tb_id[0], name_item.text())
                                break
                    # Also refresh termbase list to update term count
                    refresh_termbase_list()
                    # v1.10.64: clear cache + rebuild index + refresh
                    # TermLens for current segment so the deleted term
                    # stops showing up as a match.
                    self._post_termbase_delete_refresh()
                except Exception as e:
                    QMessageBox.warning(self, "Error", f"Failed to delete term: {e}")
        
        def save_term_edit(row, column):
            """Save edited term to database"""
            if current_terms_tb_id[0] is None:
                return

            # Skip columns that have widgets or are read-only
            # (Forbidden=6 checkbox, Created=7 read-only timestamp,
            # Delete=8 button). v1.10.65: Created column inserted at 7;
            # Delete pushed to 8.
            if column in (6, 7, 8):
                return

            source_item = terms_table.item(row, 0)
            if not source_item:
                return

            term_id = source_item.data(Qt.ItemDataRole.UserRole)
            if not term_id:
                return

            source = terms_table.item(row, 0).text() if terms_table.item(row, 0) else ""
            target = terms_table.item(row, 1).text() if terms_table.item(row, 1) else ""
            domain = terms_table.item(row, 2).text() if terms_table.item(row, 2) else ""
            notes = terms_table.item(row, 3).text() if terms_table.item(row, 3) else ""
            project = terms_table.item(row, 4).text() if terms_table.item(row, 4) else ""
            client = terms_table.item(row, 5).text() if terms_table.item(row, 5) else ""

            try:
                # v1.10.74: bump modified_date so the snapshot-gated
                # auto-refresh sees the change. Required because
                # SQLite doesn't auto-update modified_date on UPDATE
                # — the column's DEFAULT CURRENT_TIMESTAMP only
                # fires on INSERT. Without this bump, inline grid
                # edits to terms (Termbases tab) wouldn't propagate
                # to the live TermLens display because the snapshot
                # would still match. See entry_editor.save_term for
                # the full back-story.
                self.db_manager.cursor.execute(
                    """UPDATE termbase_terms
                       SET source_term = ?, target_term = ?, domain = ?, notes = ?,
                           project = ?, client = ?,
                           modified_date = CURRENT_TIMESTAMP
                       WHERE id = ?""",
                    (source, target, domain, notes, project, client, term_id)
                )
                self.db_manager.connection.commit()
                # Clear termbase cache
                with self.termbase_cache_lock:
                    self.termbase_cache.clear()
            except Exception as e:
                self.log(f"⚠️ Error saving term: {e}")
        
        terms_table.cellChanged.connect(save_term_edit)
        
        # Add new term button
        terms_btn_layout = QHBoxLayout()
        add_term_btn = QPushButton("+ Add Term")
        add_term_btn.setEnabled(False)
        
        def add_new_term():
            """Add a new empty term to the current termbase"""
            if current_terms_tb_id[0] is None:
                return
            
            try:
                # Don't specify id - let SQLite auto-generate it (INTEGER PRIMARY KEY AUTOINCREMENT)
                self.db_manager.cursor.execute(
                    """INSERT INTO termbase_terms (termbase_id, source_term, target_term, priority, domain, notes, project, client, forbidden) 
                       VALUES (CAST(? AS TEXT), '', '', 50, '', '', '', '', 0)""",
                    (current_terms_tb_id[0],)
                )
                self.db_manager.connection.commit()
                
                # Reload terms and refresh termbase list
                for row in range(termbase_table.rowCount()):
                    name_item = termbase_table.item(row, 1)
                    if name_item and name_item.data(Qt.ItemDataRole.UserRole) == current_terms_tb_id[0]:
                        load_terms_for_termbase(current_terms_tb_id[0], name_item.text())
                        break
                refresh_termbase_list()
                
                # Select the new row for editing
                terms_table.setCurrentCell(terms_table.rowCount() - 1, 0)
                terms_table.editItem(terms_table.item(terms_table.rowCount() - 1, 0))
                
            except Exception as e:
                QMessageBox.warning(self, "Error", f"Failed to add term: {e}")
        
        add_term_btn.clicked.connect(add_new_term)
        terms_btn_layout.addWidget(add_term_btn)
        terms_btn_layout.addStretch()
        right_layout.addLayout(terms_btn_layout)
        
        # Connect termbase table selection to load terms
        def on_termbase_selected():
            """Handle termbase selection change"""
            selected_rows = termbase_table.selectionModel().selectedRows()
            if selected_rows:
                row = selected_rows[0].row()
                name_item = termbase_table.item(row, 1)
                if name_item:
                    tb_id = name_item.data(Qt.ItemDataRole.UserRole)
                    tb_name = name_item.text()
                    load_terms_for_termbase(tb_id, tb_name)
                    add_term_btn.setEnabled(True)
        
        self._on_termbase_selected = on_termbase_selected  # Store for external access
        termbase_table.itemSelectionChanged.connect(on_termbase_selected)
        
        # Populate termbase list
        def refresh_termbase_list():
            # CRITICAL FIX: Get project_id dynamically, not from closure
            # Use 0 (global) when no project is loaded for Superlookup support
            current_proj = self.current_project if hasattr(self, 'current_project') else None
            refresh_project_id = current_proj.id if (current_proj and hasattr(current_proj, 'id')) else 0  # 0 = global
            
            self.log(f"📋 Refreshing termbase list (project_id: {refresh_project_id})")
            termbases = termbase_mgr.get_all_termbases()
            self.log(f"  Found {len(termbases)} termbase(s) in database")
            termbase_table.setRowCount(len(termbases))
            
            for row, tb in enumerate(termbases):
                # When no project is loaded, nothing should be selected (user requirement)
                if current_proj is None:
                    is_readable = False
                    is_writable = False
                else:
                    # Check if readable (activated) for current project or global (0)
                    is_readable = termbase_mgr.is_termbase_active(tb['id'], refresh_project_id)
                    # Check if writable (not read-only)
                    is_writable = not tb.get('read_only', True)  # Default to True (read-only) if not set
                
                # Get project glossary flag from termbase_activation table
                priority = termbase_mgr.get_termbase_priority(tb['id'], refresh_project_id) if is_readable else None
                is_project_tb = (priority == 1)
                
                # Type (Project/Background) - auto-determined by priority
                if is_project_tb:
                    type_label = QLabel("📌 Project")
                    type_label.setStyleSheet("color: #FF69B4; font-weight: bold;")  # Pink
                else:
                    type_label = QLabel("Background")
                    type_label.setStyleSheet("color: #666;")
                termbase_table.setCellWidget(row, 0, type_label)
                
                # Name (bold if readable, pink if project termbase)
                name_item = QTableWidgetItem(tb['name'])
                name_item.setData(Qt.ItemDataRole.UserRole, tb['id'])
                if is_readable:
                    font = name_item.font()
                    font.setBold(True)
                    name_item.setFont(font)
                if is_project_tb:
                    name_item.setForeground(QColor("#FF69B4"))  # Pink for project termbase
                termbase_table.setItem(row, 1, name_item)
                
                # Languages
                langs = f"{tb['source_lang'] or '?'} → {tb['target_lang'] or '?'}"
                termbase_table.setItem(row, 2, QTableWidgetItem(langs))
                
                # Term count
                try:
                    self.db_manager.cursor.execute("SELECT COUNT(*) FROM termbase_terms WHERE termbase_id = CAST(? AS TEXT)", (tb['id'],))
                    live_count = self.db_manager.cursor.fetchone()[0]
                except Exception as e:
                    live_count = tb.get('term_count', 0)
                termbase_table.setItem(row, 3, QTableWidgetItem(str(live_count)))
                
                # Read checkbox (green)
                read_checkbox = CheckmarkCheckBox()
                read_checkbox.setChecked(is_readable)
                read_checkbox.setToolTip("Read: Termbase is used for terminology matching")
                
                def on_read_toggle(checked, tb_id=tb['id'], row_idx=row):
                    # Use 0 (global) when no project is loaded - allows Superlookup to work
                    curr_proj = self.current_project if hasattr(self, 'current_project') else None
                    curr_proj_id = curr_proj.id if (curr_proj and hasattr(curr_proj, 'id')) else 0  # 0 = global

                    if checked:
                        termbase_mgr.activate_termbase(tb_id, curr_proj_id)
                    else:
                        termbase_mgr.deactivate_termbase(tb_id, curr_proj_id)

                    # Clear cache and rebuild in-memory index (v1.9.182)
                    with self.termbase_cache_lock:
                        self.termbase_cache.clear()
                    self._build_termbase_index()  # Rebuild index with new activation state
                    refresh_termbase_list()
                
                read_checkbox.toggled.connect(on_read_toggle)
                termbase_table.setCellWidget(row, 4, read_checkbox)
                
                # Write checkbox (blue)
                write_checkbox = BlueCheckmarkCheckBox()
                write_checkbox.setChecked(is_writable)
                write_checkbox.setToolTip("Write: Termbase is updated with new terms")
                
                def on_write_toggle(checked, tb_id=tb['id'], row_idx=row):
                    # Invert logic: checked = writable, so set read_only to NOT checked
                    success = termbase_mgr.set_termbase_read_only(tb_id, not checked)
                    if success:
                        status = "writable" if checked else "read-only"
                        self.log(f"✅ Termbase {tb_id} set to {status}")
                    else:
                        # Revert on failure
                        sender = termbase_table.cellWidget(row_idx, 5)
                        if sender:
                            sender.blockSignals(True)
                            sender.setChecked(not checked)
                            sender.blockSignals(False)
                
                write_checkbox.toggled.connect(on_write_toggle)
                termbase_table.setCellWidget(row, 5, write_checkbox)
                
                # Project glossary checkbox (only for readable termbases)
                if is_readable and refresh_project_id:
                    is_project_glossary = (priority == 1)
                    project_checkbox = PinkCheckmarkCheckBox()
                    project_checkbox.setChecked(is_project_glossary)
                    project_checkbox.setToolTip("Set as Project Termbase (highest priority, pink)")

                    def on_project_toggle(checked, tb_id=tb['id'], row_idx=row):
                        curr_proj = self.current_project if hasattr(self, 'current_project') else None
                        curr_proj_id = curr_proj.id if (curr_proj and hasattr(curr_proj, 'id')) else None
                        if not curr_proj_id:
                            self.log(f"⚠️ No project loaded, cannot change project termbase")
                            return

                        new_priority = 1 if checked else None
                        success = termbase_mgr.set_termbase_priority(tb_id, curr_proj_id, new_priority)
                        if success:
                            label = "Project termbase" if checked else "Background"
                            self.log(f"✅ Set termbase {tb_id} as {label}")

                            # Update all rows: uncheck others if this was checked (exclusive)
                            for r in range(termbase_table.rowCount()):
                                type_widget = termbase_table.cellWidget(r, 0)
                                proj_widget = termbase_table.cellWidget(r, 6)
                                name_item = termbase_table.item(r, 1)
                                if not type_widget or not isinstance(proj_widget, PinkCheckmarkCheckBox):
                                    continue

                                proj_widget.blockSignals(True)
                                if r == row_idx:
                                    proj_widget.setChecked(checked)
                                elif checked:
                                    # Exclusive: uncheck all other rows
                                    proj_widget.setChecked(False)
                                is_proj = proj_widget.isChecked()
                                proj_widget.blockSignals(False)

                                # Update Type column styling
                                if is_proj:
                                    type_widget.setText("📌 Project")
                                    type_widget.setStyleSheet("color: #FF69B4; font-weight: bold;")
                                    if name_item:
                                        name_item.setForeground(QColor("#FF69B4"))
                                else:
                                    type_widget.setText("Background")
                                    type_widget.setStyleSheet("color: #666;")
                                    if name_item:
                                        name_item.setForeground(QColor("#000"))

                            # Clear cache for termbase matching
                            with self.termbase_cache_lock:
                                self.termbase_cache.clear()

                    project_checkbox.toggled.connect(on_project_toggle)
                    termbase_table.setCellWidget(row, 6, project_checkbox)
                else:
                    # Non-readable termbase: show dash
                    priority_item = QTableWidgetItem("–")
                    priority_item.setForeground(QColor("#999"))
                    priority_item.setToolTip("Not active – enable Read first")
                    priority_item.setFlags(priority_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
                    termbase_table.setItem(row, 6, priority_item)

                # AI checkbox (purple/orange) - whether to inject terms into LLM prompts
                ai_enabled = termbase_mgr.get_termbase_ai_inject(tb['id'])
                ai_checkbox = OrangeCheckmarkCheckBox()
                ai_checkbox.setChecked(ai_enabled)
                ai_checkbox.setToolTip("AI: Send termbase terms to LLM with translation prompts")

                def on_ai_toggle(checked, tb_id=tb['id'], tb_name=tb['name']):
                    if checked:
                        # Show warning when enabling
                        from PyQt6.QtWidgets import QMessageBox
                        msg = QMessageBox()
                        msg.setWindowTitle("Enable AI Injection")
                        msg.setText(f"Enable AI injection for '{tb_name}'?")
                        msg.setInformativeText(
                            "When enabled, ALL terms from this termbase will be sent to the LLM "
                            "with every translation request.\n\n"
                            "This helps the AI consistently use your preferred terminology "
                            "throughout the translation.\n\n"
                            "Recommended for small, curated termbases (< 500 terms)."
                        )
                        msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
                        msg.setDefaultButton(QMessageBox.StandardButton.Yes)
                        if msg.exec() != QMessageBox.StandardButton.Yes:
                            # User cancelled - revert checkbox
                            sender = termbase_table.cellWidget(termbase_table.currentRow(), 7)
                            if sender:
                                sender.blockSignals(True)
                                sender.setChecked(False)
                                sender.blockSignals(False)
                            return
                    termbase_mgr.set_termbase_ai_inject(tb_id, checked)
                    self.log(f"{'✅ Enabled' if checked else '❌ Disabled'} AI injection for termbase: {tb_name}")

                ai_checkbox.toggled.connect(on_ai_toggle)
                termbase_table.setCellWidget(row, 7, ai_checkbox)

                # 🎤 Voice checkbox (purple) – whether this termbase
                # contributes target-language terms to Whisper's
                # initial_prompt for voice dictation (v1.10.28).
                # Independent of project context, no Read/Write
                # interaction – purely a voice-dictation opt-in flag.
                voice_enabled = termbase_mgr.get_termbase_voice_enabled(tb['id'])
                voice_checkbox = PurpleCheckmarkCheckBox()
                voice_checkbox.setChecked(voice_enabled)
                voice_checkbox.setToolTip(
                    "🎤 Voice: include this termbase's target-language terms "
                    "in the dictation vocabulary that biases Whisper. Opt-in: "
                    "tick only the few termbases relevant to your current "
                    "work – pulling target terms from every termbase is too "
                    "noisy for users with dozens of glossaries. Requires "
                    "the global toggle in 🎤 Voice tab → Dictation vocabulary "
                    "→ \"Also bias from your termbases\" to be on."
                )

                def on_voice_toggle(checked, tb_id=tb['id'], tb_name=tb['name']):
                    termbase_mgr.set_termbase_voice_enabled(tb_id, checked)
                    self.log(
                        f"{'✅ Enabled' if checked else '❌ Disabled'} "
                        f"voice-dictation bias for termbase: {tb_name}"
                    )

                voice_checkbox.toggled.connect(on_voice_toggle)
                termbase_table.setCellWidget(row, 8, voice_checkbox)

            # Update header checkbox states based on current selection
            tb_read_header_checkbox.blockSignals(True)
            tb_write_header_checkbox.blockSignals(True)
            all_tb_read_checked = all(termbase_table.cellWidget(r, 4).isChecked() if termbase_table.cellWidget(r, 4) else False for r in range(termbase_table.rowCount())) if termbase_table.rowCount() > 0 else False
            all_tb_write_checked = all(termbase_table.cellWidget(r, 5).isChecked() if termbase_table.cellWidget(r, 5) else False for r in range(termbase_table.rowCount())) if termbase_table.rowCount() > 0 else False
            tb_read_header_checkbox.setChecked(all_tb_read_checked)
            tb_write_header_checkbox.setChecked(all_tb_write_checked)
            tb_read_header_checkbox.blockSignals(False)
            tb_write_header_checkbox.blockSignals(False)
        
        # Store callback as instance attribute so add_term_to_termbase can call it
        self.termbase_tab_refresh_callback = refresh_termbase_list
        
        refresh_termbase_list()
        left_layout.addWidget(termbase_table, stretch=1)
        
        # Button bar for left panel
        button_layout = QHBoxLayout()
        
        create_btn = QPushButton("+ Create New")
        create_btn.clicked.connect(lambda: self._show_create_termbase_dialog(termbase_mgr, refresh_termbase_list, project_id))
        button_layout.addWidget(create_btn)
        
        extract_btn = QPushButton("🔍 Extract Terms")
        extract_btn.setToolTip("Extract terminology from project segments to create project termbase")
        extract_btn.setEnabled(project_id is not None)  # Only enabled when project is loaded
        extract_btn.clicked.connect(lambda: self._show_term_extraction_dialog(termbase_mgr, refresh_termbase_list, project_id))
        button_layout.addWidget(extract_btn)
        
        import_btn = QPushButton("📥 Import")
        import_btn.clicked.connect(lambda: self._import_termbase(termbase_mgr, termbase_table))
        button_layout.addWidget(import_btn)
        
        export_btn = QPushButton("📤 Export")
        export_btn.clicked.connect(lambda: self._export_termbase(termbase_mgr, termbase_table))
        button_layout.addWidget(export_btn)
        
        delete_btn = QPushButton("🗑️ Delete")
        delete_btn.clicked.connect(lambda: self._delete_termbase(termbase_mgr, termbase_table, refresh_termbase_list))
        button_layout.addWidget(delete_btn)
        
        button_layout.addStretch()
        left_layout.addLayout(button_layout)
        
        # Add panels to splitter
        main_splitter.addWidget(left_panel)
        main_splitter.addWidget(right_panel)
        main_splitter.setSizes([500, 500])  # Equal initial split
        main_splitter.setStretchFactor(0, 1)
        main_splitter.setStretchFactor(1, 1)
        
        layout.addWidget(main_splitter, stretch=1)
        
        return tab
    
    def _show_create_termbase_dialog(self, termbase_mgr, refresh_callback, project_id):
        """Show dialog to create new glossary"""
        from modules.termbase_manager import TermbaseManager
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Create New Termbase")
        dialog.setMinimumWidth(400)
        
        layout = QFormLayout()
        
        # Name
        name_field = QLineEdit()
        layout.addRow("Termbase Name:", name_field)
        
        # Source language
        source_lang_field = QLineEdit()
        source_lang_field.setPlaceholderText("e.g., en, nl, de")
        layout.addRow("Source Language:", source_lang_field)
        
        # Target language
        target_lang_field = QLineEdit()
        target_lang_field.setPlaceholderText("e.g., en, nl, de")
        layout.addRow("Target Language:", target_lang_field)
        
        # Description
        desc_field = QTextEdit()
        desc_field.setMaximumHeight(80)
        layout.addRow("Description:", desc_field)
        
        # Scope - use styled checkboxes instead of radio buttons for better visibility
        scope_group = QButtonGroup()
        scope_group.setExclusive(True)  # Only one can be checked
        
        global_checkbox = BlueCheckmarkCheckBox("Global (all projects)")
        global_checkbox.setChecked(True)
        project_checkbox = PinkCheckmarkCheckBox("Project-specific")
        
        scope_group.addButton(global_checkbox, 0)
        scope_group.addButton(project_checkbox, 1)
        
        scope_layout = QHBoxLayout()
        scope_layout.addWidget(global_checkbox)
        scope_layout.addWidget(project_checkbox)
        layout.addRow("Scope:", scope_layout)
        
        # Buttons
        button_layout = QHBoxLayout()
        create_btn = QPushButton("Create")
        cancel_btn = QPushButton("Cancel")
        
        def create_termbase():
            name = name_field.text().strip()
            if not name:
                QMessageBox.warning(dialog, "Error", "Please enter a termbase name")
                return
            
            source_lang = source_lang_field.text().strip() or None
            target_lang = target_lang_field.text().strip() or None
            description = desc_field.toPlainText().strip()
            is_global = scope_group.checkedId() == 0
            
            tb_project_id = None if is_global else project_id
            
            tb_id = termbase_mgr.create_termbase(
                name=name,
                source_lang=source_lang,
                target_lang=target_lang,
                project_id=tb_project_id,
                description=description,
                is_global=is_global
            )
            
            if tb_id:
                self._play_sound_effect('glossary_created')
                QMessageBox.information(dialog, "Success", f"Termbase '{name}' created successfully!")
                refresh_callback()
                dialog.accept()
            else:
                QMessageBox.critical(dialog, "Error", "Failed to create termbase")
        
        create_btn.clicked.connect(create_termbase)
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(create_btn)
        button_layout.addWidget(cancel_btn)
        layout.addRow("", button_layout)
        
        dialog.setLayout(layout)
        dialog.exec()
    
    def _show_term_extraction_dialog(self, termbase_mgr, refresh_callback, project_id):
        """Show dialog to extract terms from project segments"""
        from modules.term_extractor import TermExtractor
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Extract Terms from Project")
        dialog.setMinimumWidth(800)
        dialog.setMinimumHeight(600)
        
        layout = QVBoxLayout()
        
        # Info label
        info_label = QLabel("Extract terminology from project source segments to create a project termbase.")
        info_label.setWordWrap(True)
        layout.addWidget(info_label)
        
        # Source text section
        source_group = QGroupBox("Source Text")
        source_layout = QVBoxLayout()
        
        # Text source options
        source_type_layout = QHBoxLayout()
        use_project_radio = CheckmarkRadioButton("Use project segments")
        use_project_radio.setChecked(True)
        use_manual_radio = CheckmarkRadioButton("Paste text manually")
        
        source_type_layout.addWidget(use_project_radio)
        source_type_layout.addWidget(use_manual_radio)
        source_type_layout.addStretch()
        source_layout.addLayout(source_type_layout)
        
        # Manual text input
        text_input = QTextEdit()
        text_input.setPlaceholderText("Paste source text here...")
        text_input.setMaximumHeight(150)
        text_input.setEnabled(False)
        source_layout.addWidget(text_input)
        
        def on_source_type_changed():
            text_input.setEnabled(use_manual_radio.isChecked())
        
        use_project_radio.toggled.connect(on_source_type_changed)
        use_manual_radio.toggled.connect(on_source_type_changed)
        
        source_group.setLayout(source_layout)
        layout.addWidget(source_group)
        
        # Extraction parameters
        params_group = QGroupBox("Extraction Parameters")
        params_layout = QFormLayout()
        
        # Source language
        lang_combo = QComboBox()
        lang_combo.addItems(["en", "nl", "de", "fr", "es"])
        params_layout.addRow("Source Language:", lang_combo)
        
        # Min frequency
        freq_spin = QSpinBox()
        freq_spin.setMinimum(1)
        freq_spin.setMaximum(20)
        freq_spin.setValue(2)
        params_layout.addRow("Min Frequency:", freq_spin)
        
        # Max n-gram
        ngram_spin = QSpinBox()
        ngram_spin.setMinimum(1)
        ngram_spin.setMaximum(5)
        ngram_spin.setValue(3)
        params_layout.addRow("Max N-gram:", ngram_spin)
        
        # Max terms
        max_terms_spin = QSpinBox()
        max_terms_spin.setMinimum(10)
        max_terms_spin.setMaximum(1000)
        max_terms_spin.setValue(100)
        params_layout.addRow("Max Terms:", max_terms_spin)
        
        params_group.setLayout(params_layout)
        layout.addWidget(params_group)
        
        # Extract button
        extract_btn = QPushButton("🔍 Extract Terms")
        extract_btn.setMaximumWidth(150)
        
        # Results table
        results_table = QTableWidget()
        results_table.setColumnCount(4)
        results_table.setHorizontalHeaderLabels(["Select", "Term", "Frequency", "Score"])
        results_table.horizontalHeader().setStretchLastSection(False)
        results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        results_table.setVisible(False)
        
        results_label = QLabel("Extracted terms will appear here")
        results_label.setVisible(False)
        
        layout.addWidget(extract_btn)
        layout.addWidget(results_label)
        layout.addWidget(results_table)
        
        extracted_terms = []
        
        def extract_terms():
            """Extract terms and populate results table"""
            # Get source text
            if use_project_radio.isChecked():
                # Get all source segments from current project
                if not self.current_project:
                    QMessageBox.warning(dialog, "Error", "No project is currently loaded")
                    return
                
                segments = []
                for i in range(self.grid_widget.grid.rowCount()):
                    source_widget = self.grid_widget.grid.cellWidget(i, self.grid_widget.source_col)
                    if source_widget:
                        text = source_widget.toPlainText().strip()
                        # v1.9.306: Strip invisible character markers before term extraction
                        if hasattr(self, 'reverse_invisible_replacements'):
                            text = self.reverse_invisible_replacements(text).strip()
                        if text:
                            segments.append(text)
                
                if not segments:
                    QMessageBox.warning(dialog, "Error", "No source segments found in project")
                    return
                
                source_text = "\n".join(segments)
            else:
                source_text = text_input.toPlainText().strip()
                if not source_text:
                    QMessageBox.warning(dialog, "Error", "Please enter some text to analyze")
                    return
            
            # Extract terms
            try:
                extractor = TermExtractor(
                    source_lang=lang_combo.currentText(),
                    min_frequency=freq_spin.value(),
                    max_ngram=ngram_spin.value()
                )
                
                terms = extractor.extract_terms(source_text)
                
                # Limit to max terms
                terms = terms[:max_terms_spin.value()]
                
                if not terms:
                    QMessageBox.information(dialog, "No Terms", "No terms were extracted with the current parameters. Try lowering the minimum frequency.")
                    return
                
                # Store for later use
                nonlocal extracted_terms
                extracted_terms = terms
                
                # Populate table
                results_table.setRowCount(len(terms))
                for i, term in enumerate(terms):
                    # Checkbox
                    check_item = QTableWidgetItem()
                    check_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
                    check_item.setCheckState(Qt.CheckState.Checked)
                    results_table.setItem(i, 0, check_item)
                    
                    # Term
                    results_table.setItem(i, 1, QTableWidgetItem(term['term']))
                    
                    # Frequency
                    freq_item = QTableWidgetItem(str(term['frequency']))
                    freq_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                    results_table.setItem(i, 2, freq_item)
                    
                    # Score
                    score_item = QTableWidgetItem(f"{term['score']:.2f}")
                    score_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                    results_table.setItem(i, 3, score_item)
                
                results_table.setVisible(True)
                results_label.setText(f"Extracted {len(terms)} terms (select terms to add):")
                results_label.setVisible(True)
                
                self.log(f"✓ Extracted {len(terms)} terms from project")
                
            except Exception as e:
                QMessageBox.critical(dialog, "Error", f"Failed to extract terms: {str(e)}")
                self.log(f"✗ Term extraction failed: {e}")
        
        extract_btn.clicked.connect(extract_terms)
        
        # Buttons
        button_layout = QHBoxLayout()
        
        create_btn = QPushButton("Create Project Termbase")
        create_btn.setToolTip("Create a new project termbase with selected terms")
        
        cancel_btn = QPushButton("Cancel")
        
        def create_project_termbase():
            """Create project termbase with selected terms"""
            if not extracted_terms:
                QMessageBox.warning(dialog, "Error", "Please extract terms first")
                return
            
            # Get selected terms
            selected = []
            for i in range(results_table.rowCount()):
                check_item = results_table.item(i, 0)
                if check_item and check_item.checkState() == Qt.CheckState.Checked:
                    selected.append(extracted_terms[i])
            
            if not selected:
                QMessageBox.warning(dialog, "Error", "Please select at least one term")
                return
            
            # Ask for termbase name
            name, ok = QInputDialog.getText(
                dialog,
                "Termbase Name",
                "Enter name for project termbase:",
                text=f"{self.current_project.get('name', 'Project')} Terminology"
            )
            
            if not ok or not name.strip():
                return
            
            # Create termbase
            source_lang = lang_combo.currentText()
            tb_id = termbase_mgr.create_termbase(
                name=name.strip(),
                source_lang=source_lang,
                target_lang=None,  # Source-only termbase
                project_id=project_id,
                description=f"Extracted terminology from project segments ({len(selected)} terms)",
                is_global=False,
                is_project_termbase=True
            )
            
            if not tb_id:
                QMessageBox.critical(dialog, "Error", "Failed to create termbase. There may already be a project termbase for this project.")
                return
            
            # Add terms (source only, target = empty string)
            added = 0
            for term in selected:
                success = termbase_mgr.add_term(
                    termbase_id=tb_id,
                    source_term=term['term'],
                    target_term="",  # Empty target for source-only
                    priority=50
                )
                if success:
                    added += 1
            
            self.log(f"✓ Created project termbase '{name}' with {added} terms")
            QMessageBox.information(
                dialog,
                "Success",
                f"Project termbase '{name}' created with {added} terms!"
            )
            
            # Clear cache and refresh
            with self.termbase_cache_lock:
                self.termbase_cache.clear()
            refresh_callback()
            dialog.accept()
        
        create_btn.clicked.connect(create_project_termbase)
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(create_btn)
        button_layout.addWidget(cancel_btn)
        layout.addLayout(button_layout)
        
        dialog.setLayout(layout)
        dialog.exec()
    
    def _show_termbase_context_menu(self, pos, termbase_table, termbase_mgr, refresh_callback):
        """Show context menu for termbase table with Rename and Delete options"""
        # Get the row under the cursor
        item = termbase_table.itemAt(pos)
        if not item:
            return
        
        row = item.row()
        name_item = termbase_table.item(row, 1)
        if not name_item:
            return
        
        tb_name = name_item.text()
        tb_id = name_item.data(Qt.ItemDataRole.UserRole)
        
        menu = QMenu(self)
        
        # Rename action
        rename_action = QAction("✏️ Rename Termbase", self)
        rename_action.triggered.connect(
            lambda: self._rename_termbase_dialog(tb_id, tb_name, termbase_mgr, refresh_callback)
        )
        menu.addAction(rename_action)
        
        menu.addSeparator()
        
        # Delete action
        delete_action = QAction("🗑️ Delete Termbase", self)
        delete_action.triggered.connect(
            lambda: self._delete_termbase(termbase_mgr, termbase_table, refresh_callback)
        )
        menu.addAction(delete_action)
        
        menu.exec(termbase_table.viewport().mapToGlobal(pos))
    
    def _rename_termbase_dialog(self, termbase_id, current_name, termbase_mgr, refresh_callback):
        """Show dialog to rename a termbase"""
        from PyQt6.QtWidgets import QInputDialog
        
        new_name, ok = QInputDialog.getText(
            self,
            "Rename Termbase",
            f"Enter new name for '{current_name}':",
            text=current_name
        )
        
        if ok and new_name and new_name.strip():
            new_name = new_name.strip()
            if new_name != current_name:
                if termbase_mgr.rename_termbase(termbase_id, new_name):
                    self.log(f"✓ Renamed termbase '{current_name}' to '{new_name}'")
                    QMessageBox.information(self, "Success", f"Termbase renamed to '{new_name}'")
                    # Clear cache and refresh
                    with self.termbase_cache_lock:
                        self.termbase_cache.clear()
                    refresh_callback()
                else:
                    QMessageBox.critical(self, "Error", "Failed to rename termbase")
    
    def _delete_termbase(self, termbase_mgr, termbase_table, refresh_callback):
        """Delete selected termbase"""
        selected_row = termbase_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a termbase to delete")
            return
        
        # Get termbase info from Name column (column 1 - has ID stored in UserRole)
        name_item = termbase_table.item(selected_row, 1)
        if not name_item:
            QMessageBox.warning(self, "Error", "Could not read termbase information")
            return
        
        tb_name = name_item.text()
        termbase_id = name_item.data(Qt.ItemDataRole.UserRole)
        
        if not termbase_id:
            QMessageBox.warning(self, "Error", "Could not find termbase ID")
            return
        
        # Confirm deletion
        reply = QMessageBox.question(
            self,
            "Confirm Deletion",
            f"Are you sure you want to delete termbase '{tb_name}'?\n\nThis will permanently delete all terms in this termbase.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        
        if reply == QMessageBox.StandardButton.Yes:
            # Use the termbase ID we got from row data
            if termbase_id:
                try:
                    # Delete from database
                    cursor = self.db_manager.cursor
                    cursor.execute("DELETE FROM termbase_terms WHERE termbase_id = ?", (termbase_id,))
                    cursor.execute("DELETE FROM termbase_activation WHERE termbase_id = ?", (termbase_id,))
                    cursor.execute("DELETE FROM termbases WHERE id = ?", (termbase_id,))
                    self.db_manager.connection.commit()
                    
                    self.log(f"✓ Deleted termbase: {tb_name}")
                    QMessageBox.information(self, "Success", f"Termbase '{tb_name}' has been deleted")
                    
                    # Clear cache and refresh
                    with self.termbase_cache_lock:
                        self.termbase_cache.clear()
                    refresh_callback()
                except Exception as e:
                    QMessageBox.critical(self, "Error", f"Failed to delete termbase: {str(e)}")
                    self.log(f"✗ Error deleting termbase: {e}")
    
    def _refresh_termbase_table(self, termbase_table, termbase_mgr):
        """Refresh termbase table to show updated term counts"""
        # Get current project ID
        current_proj = self.current_project if hasattr(self, 'current_project') else None
        project_id = current_proj.id if (current_proj and hasattr(current_proj, 'id')) else 0
        
        # Update term counts for each row
        for row in range(termbase_table.rowCount()):
            name_item = termbase_table.item(row, 1)
            if name_item:
                tb_id = name_item.data(Qt.ItemDataRole.UserRole)
                if tb_id:
                    try:
                        self.db_manager.cursor.execute(
                            "SELECT COUNT(*) FROM termbase_terms WHERE termbase_id = CAST(? AS TEXT)", 
                            (tb_id,)
                        )
                        term_count = self.db_manager.cursor.fetchone()[0]
                        count_item = termbase_table.item(row, 3)
                        if count_item:
                            count_item.setText(str(term_count))
                    except Exception as e:
                        self.log(f"⚠ Error updating term count: {e}")
    
    def _import_termbase(self, termbase_mgr, termbase_table):
        """Import terms into selected termbase from TSV file"""
        selected_row = termbase_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a termbase to import into")
            return
        
        # Get termbase info from Name column (column 1 - has ID stored in UserRole)
        name_item = termbase_table.item(selected_row, 1)
        if not name_item:
            QMessageBox.warning(self, "Error", "Could not read termbase information")
            return
        
        tb_name = name_item.text()
        termbase_id = name_item.data(Qt.ItemDataRole.UserRole)
        
        if not termbase_id:
            QMessageBox.warning(self, "Error", "Could not find termbase ID")
            return
        
        # File dialog
        from PyQt6.QtWidgets import QFileDialog
        filepath, _ = QFileDialog.getOpenFileName(
            self,
            "Import Termbase",
            "",
            "TSV Files (*.tsv *.txt);;All Files (*.*)"
        )
        
        if not filepath:
            return

        # Count rows and get language pair for the confirmation dialog
        try:
            with open(filepath, 'r', encoding='utf-8-sig') as f:
                row_count = max(sum(1 for _ in f) - 1, 0)  # subtract header
        except Exception:
            row_count = 0

        tb_info = termbase_mgr.get_termbase(termbase_id)
        src_lang = (tb_info or {}).get('source_lang', '?') or '?'
        tgt_lang = (tb_info or {}).get('target_lang', '?') or '?'
        lang_pair = f"{src_lang} \u2192 {tgt_lang}"
        file_name = os.path.basename(filepath)

        # Show options dialog
        options_dialog = QDialog(self)
        options_dialog.setWindowTitle("Import Options")
        options_dialog.setMinimumWidth(400)

        layout = QVBoxLayout(options_dialog)

        # Info with filename, row count, termbase name, and language pair
        row_text = f"{row_count:,} row{'s' if row_count != 1 else ''}" if row_count > 0 else "unknown rows"
        info_label = QLabel(
            f'Import <b>{row_text}</b> from "<b>{file_name}</b>"'
            f'<br>into termbase "<b>{tb_name}</b>" ({lang_pair})')
        info_label.setWordWrap(True)
        layout.addWidget(info_label)
        
        # Duplicate handling
        dup_group = QGroupBox("Duplicate Handling")
        dup_layout = QVBoxLayout()
        
        skip_radio = CheckmarkRadioButton("Skip duplicates (keep existing terms)")
        skip_radio.setChecked(True)
        update_radio = CheckmarkRadioButton("Update duplicates (overwrite existing terms)")
        
        dup_layout.addWidget(skip_radio)
        dup_layout.addWidget(update_radio)
        dup_group.setLayout(dup_layout)
        layout.addWidget(dup_group)
        
        # Buttons
        button_box = QHBoxLayout()
        button_box.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(options_dialog.reject)
        button_box.addWidget(cancel_btn)
        
        import_btn = QPushButton("Import")
        import_btn.setStyleSheet("font-weight: bold; background-color: #4CAF50; color: white; padding: 8px 20px; border: none; outline: none;")
        import_btn.clicked.connect(options_dialog.accept)
        button_box.addWidget(import_btn)
        
        layout.addLayout(button_box)
        
        if options_dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Create progress dialog with scrolling log
        progress_dialog = QDialog(self)
        progress_dialog.setWindowTitle("Importing Termbase")
        progress_dialog.setMinimumSize(600, 400)
        progress_dialog.setModal(True)
        
        progress_layout = QVBoxLayout(progress_dialog)
        
        # Header info
        header_label = QLabel(f"<b>📥 Importing into: {tb_name}</b>")
        header_label.setStyleSheet("font-size: 12pt; padding: 5px;")
        progress_layout.addWidget(header_label)
        
        # File info
        file_info = QLabel(f"File: {os.path.basename(filepath)}")
        file_info.setStyleSheet("color: #666; padding: 2px 5px;")
        progress_layout.addWidget(file_info)
        
        # Progress bar
        progress_bar = QProgressBar()
        progress_bar.setMinimum(0)
        progress_bar.setMaximum(100)
        progress_bar.setValue(0)
        progress_bar.setTextVisible(True)
        progress_layout.addWidget(progress_bar)
        
        # Stats labels row
        stats_layout = QHBoxLayout()
        imported_label = QLabel("✅ Imported: 0")
        imported_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
        skipped_label = QLabel("⏭️ Skipped: 0")
        skipped_label.setStyleSheet("color: #FF9800;")
        errors_label = QLabel("❌ Errors: 0")
        errors_label.setStyleSheet("color: #F44336;")
        stats_layout.addWidget(imported_label)
        stats_layout.addWidget(skipped_label)
        stats_layout.addWidget(errors_label)
        stats_layout.addStretch()
        progress_layout.addLayout(stats_layout)
        
        # Scrolling log area
        log_label = QLabel("Import Log:")
        log_label.setStyleSheet("font-weight: bold; padding-top: 10px;")
        progress_layout.addWidget(log_label)
        
        log_text = QTextEdit()
        log_text.setReadOnly(True)
        log_text.setFont(QFont("Consolas", 9))
        log_text.setStyleSheet("background-color: #1e1e1e; color: #d4d4d4; border: 1px solid #555;")
        progress_layout.addWidget(log_text)
        
        # Close button (initially disabled)
        close_btn = QPushButton("Close")
        close_btn.setEnabled(False)
        close_btn.clicked.connect(progress_dialog.accept)
        progress_layout.addWidget(close_btn)
        
        # Show dialog
        progress_dialog.show()
        QApplication.processEvents()
        
        # Progress callback function
        import_stats = {'imported': 0, 'skipped': 0, 'errors': 0}
        
        def progress_callback(current, total, message):
            """Update progress dialog with import progress"""
            if total > 0:
                percent = int((current / total) * 100)
                progress_bar.setValue(percent)
                progress_bar.setFormat(f"{current}/{total} ({percent}%)")
            
            # Update stats from message
            if message.startswith("✅"):
                import_stats['imported'] += 1
                imported_label.setText(f"✅ Imported: {import_stats['imported']}")
            elif message.startswith("⏭️"):
                import_stats['skipped'] += 1
                skipped_label.setText(f"⏭️ Skipped: {import_stats['skipped']}")
            elif message.startswith("❌"):
                import_stats['errors'] += 1
                errors_label.setText(f"❌ Errors: {import_stats['errors']}")
            
            # Add to log
            log_text.append(message)
            
            # Auto-scroll to bottom
            scrollbar = log_text.verticalScrollBar()
            scrollbar.setValue(scrollbar.maximum())
            
            # Process events to keep UI responsive
            QApplication.processEvents()
        
        # Perform import with progress callback
        from modules.termbase_import_export import TermbaseImporter
        importer = TermbaseImporter(self.db_manager, termbase_mgr)
        
        self.log(f"📥 Importing termbase from {filepath}...")
        
        result = importer.import_tsv(
            filepath=filepath,
            termbase_id=termbase_id,
            skip_duplicates=skip_radio.isChecked(),
            update_duplicates=update_radio.isChecked(),
            progress_callback=progress_callback
        )
        
        # Update final stats
        progress_bar.setValue(100)
        imported_label.setText(f"✅ Imported: {result.imported_count}")
        skipped_label.setText(f"⏭️ Skipped: {result.skipped_count}")
        errors_label.setText(f"❌ Errors: {result.error_count}")
        
        # Add summary to log
        log_text.append("")
        log_text.append("=" * 50)
        if result.success:
            log_text.append(f"✓ Import complete: {result.imported_count} terms imported")
            if result.skipped_count > 0:
                log_text.append(f"  Skipped {result.skipped_count} duplicates")
            if result.error_count > 0:
                log_text.append(f"  {result.error_count} errors occurred")
            header_label.setText(f"<b>✓ Import Complete: {tb_name}</b>")
            header_label.setStyleSheet("font-size: 12pt; padding: 5px; color: #4CAF50;")
        else:
            log_text.append(f"✗ Import failed: {result.message}")
            header_label.setText(f"<b>✗ Import Failed: {tb_name}</b>")
            header_label.setStyleSheet("font-size: 12pt; padding: 5px; color: #F44336;")
        
        # Enable close button
        close_btn.setEnabled(True)
        close_btn.setFocus()
        
        # Wait for user to close dialog
        progress_dialog.exec()
        
        # Log and clear cache
        if result.success:
            self.log(f"✓ {result.message}")
            with self.termbase_cache_lock:
                self.termbase_cache.clear()
            
            # Refresh the termbase table to show updated term counts
            self._refresh_termbase_table(termbase_table, termbase_mgr)
        else:
            self.log(f"✗ Import failed: {result.message}")
    
    def _export_termbase(self, termbase_mgr, termbase_table):
        """Export selected termbase to TSV file"""
        selected_row = termbase_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a termbase to export")
            return
        
        # Get termbase info from Name column (column 1 - has ID stored in UserRole)
        name_item = termbase_table.item(selected_row, 1)
        if not name_item:
            QMessageBox.warning(self, "Error", "Could not read termbase information")
            return
        
        tb_name = name_item.text()
        termbase_id = name_item.data(Qt.ItemDataRole.UserRole)
        
        if not termbase_id:
            QMessageBox.warning(self, "Error", "Could not find termbase ID")
            return
        
        # File dialog
        from PyQt6.QtWidgets import QFileDialog
        default_filename = f"{tb_name.replace(' ', '_')}.tsv"
        filepath, _ = QFileDialog.getSaveFileName(
            self,
            "Export Termbase",
            default_filename,
            "TSV Files (*.tsv);;Text Files (*.txt);;All Files (*.*)"
        )
        
        if not filepath:
            return
        
        # Show options dialog
        options_dialog = QDialog(self)
        options_dialog.setWindowTitle("Export Options")
        options_dialog.setMinimumWidth(400)
        
        layout = QVBoxLayout(options_dialog)
        
        # Info
        info_label = QLabel(f"Exporting termbase: <b>{tb_name}</b>")
        info_label.setWordWrap(True)
        layout.addWidget(info_label)
        
        # Metadata checkbox
        metadata_check = CheckmarkCheckBox("Include all metadata (project, client, forbidden)")
        metadata_check.setChecked(True)
        layout.addWidget(metadata_check)
        
        # Buttons
        button_box = QHBoxLayout()
        button_box.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(options_dialog.reject)
        button_box.addWidget(cancel_btn)
        
        export_btn = QPushButton("Export")
        export_btn.setStyleSheet("font-weight: bold; background-color: #2196F3; color: white; padding: 8px 20px; border: none; outline: none;")
        export_btn.clicked.connect(options_dialog.accept)
        button_box.addWidget(export_btn)
        
        layout.addLayout(button_box)
        
        if options_dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Perform export
        from modules.termbase_import_export import TermbaseExporter
        exporter = TermbaseExporter(self.db_manager, termbase_mgr)
        
        self.log(f"📤 Exporting termbase to {filepath}...")
        
        success, message = exporter.export_tsv(
            termbase_id=termbase_id,
            filepath=filepath,
            include_metadata=metadata_check.isChecked()
        )
        
        # Show result
        if success:
            QMessageBox.information(self, "Export Complete", message)
            self.log(f"✓ {message}")
        else:
            QMessageBox.critical(self, "Export Failed", message)
            self.log(f"✗ Export failed: {message}")
    
    def _update_term_forbidden(self, term_id: int, forbidden: bool):
        """Update forbidden flag for a term"""
        try:
            cursor = self.db_manager.cursor
            cursor.execute("UPDATE termbase_terms SET forbidden = ? WHERE id = ?", (1 if forbidden else 0, term_id))
            self.db_manager.connection.commit()
            self.log(f"✓ Updated term {term_id} forbidden status to {forbidden}")
        except Exception as e:
            self.log(f"✗ Error updating term forbidden status: {e}")
    
    def _show_edit_terms_dialog(self, termbase_mgr, termbase_table, refresh_callback):
        """Show dialog to edit terms in selected termbase"""
        selected_row = termbase_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a termbase first")
            return
        
        # Get termbase ID and name from table (column 1 is Name with ID stored in UserRole)
        name_item = termbase_table.item(selected_row, 1)
        if not name_item:
            QMessageBox.warning(self, "Error", "Could not find selected termbase")
            return
        
        termbase_id = name_item.data(Qt.ItemDataRole.UserRole)
        tb_name = name_item.text()
        
        if not termbase_id:
            QMessageBox.warning(self, "Error", "Could not find selected termbase")
            return
        
        # Create dialog
        dialog = QDialog(self)
        dialog.setWindowTitle(f"Edit Terms - {tb_name}")
        dialog.setMinimumSize(700, 500)
        
        layout = QVBoxLayout()
        
        # Terms table
        terms_table = QTableWidget()
        terms_table.setColumnCount(7)
        terms_table.setHorizontalHeaderLabels(["Source", "Target", "Domain", "Notes", "Project", "Client", "Forbidden"])
        terms_table.horizontalHeader().setStretchLastSection(False)
        terms_table.setColumnWidth(0, 120)
        terms_table.setColumnWidth(1, 120)
        terms_table.setColumnWidth(2, 100)
        terms_table.setColumnWidth(3, 150)
        terms_table.setColumnWidth(4, 100)
        terms_table.setColumnWidth(5, 100)
        terms_table.setColumnWidth(6, 80)
        # v1.10.62: extended selection so the Reverse Direction action
        # can repair multiple legacy reversed entries at once.
        terms_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
        terms_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        terms_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        
        # Load terms
        def refresh_terms_table():
            terms = termbase_mgr.get_terms(termbase_id)
            terms_table.setRowCount(len(terms))
            
            for row, term in enumerate(terms):
                # Store term ID in first column (hidden from user)
                source_item = QTableWidgetItem(term['source_term'])
                source_item.setData(Qt.ItemDataRole.UserRole, term['id'])  # Store term ID
                terms_table.setItem(row, 0, source_item)
                
                terms_table.setItem(row, 1, QTableWidgetItem(term['target_term']))
                terms_table.setItem(row, 2, QTableWidgetItem(term['domain'] or ""))

                # Notes column (truncated for display)
                notes_text = term.get('notes', '') or ""
                if len(notes_text) > 50:
                    notes_text = notes_text[:47] + "..."
                terms_table.setItem(row, 3, QTableWidgetItem(notes_text))

                # Project and Client columns
                terms_table.setItem(row, 4, QTableWidgetItem(term.get('project', '') or ""))
                terms_table.setItem(row, 5, QTableWidgetItem(term.get('client', '') or ""))

                # Forbidden checkbox
                forbidden_check = CheckmarkCheckBox()
                forbidden_check.setChecked(term['forbidden'])
                forbidden_check.toggled.connect(lambda checked, t_id=term['id']: self._update_term_forbidden(t_id, checked))
                terms_table.setCellWidget(row, 6, forbidden_check)
        
        refresh_terms_table()
        layout.addWidget(QLabel(f"Terms in '{tb_name}':"), 0)
        layout.addWidget(terms_table, 1)
        
        # Add term section
        add_layout = QHBoxLayout()
        
        source_field = QLineEdit()
        source_field.setPlaceholderText("Source term")
        add_layout.addWidget(source_field)
        
        target_field = QLineEdit()
        target_field.setPlaceholderText("Target term")
        add_layout.addWidget(target_field)
        
        add_btn = QPushButton("+ Add")
        def add_term():
            source = source_field.text().strip()
            target = target_field.text().strip()
            if not source or not target:
                QMessageBox.warning(dialog, "Error", "Both source and target terms are required")
                return

            termbase_mgr.add_term(
                termbase_id=termbase_id,
                source_term=source,
                target_term=target
            )
            
            source_field.clear()
            target_field.clear()
            refresh_terms_table()
        
        add_btn.clicked.connect(add_term)
        add_layout.addWidget(add_btn)
        layout.addLayout(add_layout)
        
        # v1.10.62: Reverse source/target on selected term(s).
        # Repair action for the pre-v1.10.62 termbase-direction bug:
        # entries written with project source/target swapped relative
        # to the termbase's declared direction can be fixed in place
        # here. Works on a single selected row or multiple rows at
        # once. Mirrors the Trados v4.19.22 "Reverse source/target"
        # right-click action that solved the same bug there.
        def reverse_direction_on_selected():
            from PyQt6.QtWidgets import QMessageBox as _QMessageBox
            # Collect distinct selected rows. Items() returns
            # QTableWidgetItem objects, possibly multiple per row;
            # dedupe via a set of row indices.
            selected_rows = sorted({
                item.row() for item in terms_table.selectedItems()
            })
            if not selected_rows:
                _QMessageBox.warning(
                    dialog, "No selection",
                    "Select one or more terms first, then use this action "
                    "to swap their source ↔ target text + language tags."
                )
                return

            # Collect the (term_id, source, target) tuples for confirmation.
            entries_to_reverse = []
            for row in selected_rows:
                id_item = terms_table.item(row, 0)
                if not id_item:
                    continue
                term_id = id_item.data(Qt.ItemDataRole.UserRole)
                if not term_id:
                    continue
                source = terms_table.item(row, 0).text() if terms_table.item(row, 0) else ''
                target = terms_table.item(row, 1).text() if terms_table.item(row, 1) else ''
                entries_to_reverse.append((term_id, source, target))

            if not entries_to_reverse:
                return

            # Confirmation dialog. Show the first few rows so the user
            # can sanity-check what's about to flip.
            preview_lines = []
            for tid, s, t in entries_to_reverse[:5]:
                preview_lines.append(f"  • '{s}' ↔ '{t}'")
            preview = "\n".join(preview_lines)
            extra = (f"\n  ... and {len(entries_to_reverse) - 5} more"
                     if len(entries_to_reverse) > 5 else "")

            reply = _QMessageBox.question(
                dialog, "Reverse source/target",
                f"Reverse source ↔ target on {len(entries_to_reverse)} "
                f"term(s)?\n\n{preview}{extra}\n\n"
                f"This swaps each entry's source_term, target_term, "
                f"source_lang, target_lang, source_abbreviation, and "
                f"target_abbreviation. It also flips each synonym's "
                f"language tag (source ↔ target). The change is committed "
                f"in a single transaction.\n\n"
                f"Use this to fix terms that were saved with text in the "
                f"wrong columns before the v1.10.62 termbase-direction fix.",
                _QMessageBox.StandardButton.Yes | _QMessageBox.StandardButton.No,
                _QMessageBox.StandardButton.No,
            )
            if reply != _QMessageBox.StandardButton.Yes:
                return

            cursor = self.db_manager.cursor
            try:
                cursor.execute("BEGIN")
                for tid, _s, _t in entries_to_reverse:
                    # Swap text + lang + abbreviation pairs in one row.
                    cursor.execute(
                        """
                        UPDATE termbase_terms
                        SET source_term = target_term,
                            target_term = source_term,
                            source_lang = target_lang,
                            target_lang = source_lang,
                            source_abbreviation = target_abbreviation,
                            target_abbreviation = source_abbreviation
                        WHERE id = ?
                        """,
                        (tid,),
                    )
                    # Flip every synonym's language: 'source' → 'target'
                    # and vice versa. Done in two passes via a temporary
                    # sentinel to avoid mid-update collisions.
                    cursor.execute(
                        "UPDATE termbase_synonyms SET language = '__tmp__' "
                        "WHERE term_id = ? AND language = 'source'",
                        (tid,),
                    )
                    cursor.execute(
                        "UPDATE termbase_synonyms SET language = 'source' "
                        "WHERE term_id = ? AND language = 'target'",
                        (tid,),
                    )
                    cursor.execute(
                        "UPDATE termbase_synonyms SET language = 'target' "
                        "WHERE term_id = ? AND language = '__tmp__'",
                        (tid,),
                    )
                self.db_manager.connection.commit()
                self.log(f"🔄 Reversed direction on {len(entries_to_reverse)} term(s) in '{tb_name}'")
            except Exception as e:
                self.db_manager.connection.rollback()
                _QMessageBox.critical(
                    dialog, "Reverse failed",
                    f"Could not reverse direction:\n\n{e}\n\n"
                    f"No changes were committed (transaction rolled back).",
                )
                return

            refresh_terms_table()
            # Rebuild the in-memory index so TermLens immediately
            # reflects the swap.
            try:
                with self.termbase_cache_lock:
                    self.termbase_cache.clear()
                self._build_termbase_index()
            except Exception as e:
                self.log(f"⚠️ Index rebuild after reverse failed: {e}")

        # Right-click context menu on terms_table — same action, more
        # discoverable for power users.
        def show_terms_context_menu(pos):
            from PyQt6.QtWidgets import QMenu as _QMenu
            from PyQt6.QtGui import QAction as _QAction
            menu = _QMenu(terms_table)
            reverse_action = _QAction("🔄 Reverse source / target on selected", menu)
            reverse_action.triggered.connect(reverse_direction_on_selected)
            menu.addAction(reverse_action)
            menu.exec(terms_table.viewport().mapToGlobal(pos))

        terms_table.customContextMenuRequested.connect(show_terms_context_menu)

        # Action buttons
        action_layout = QHBoxLayout()

        # Reverse direction button (v1.10.62 termbase-direction repair).
        reverse_dir_btn = QPushButton("🔄 Reverse Source/Target")
        reverse_dir_btn.setToolTip(
            "Swap source ↔ target on selected term(s). Use this to repair "
            "terms that were saved in the wrong direction before the "
            "v1.10.62 fix."
        )
        reverse_dir_btn.clicked.connect(reverse_direction_on_selected)
        action_layout.addWidget(reverse_dir_btn)

        # Edit selected term button
        edit_term_btn = QPushButton("✏️ Edit Selected Term")
        def edit_selected_term():
            selected_row = terms_table.currentRow()
            if selected_row < 0:
                QMessageBox.warning(dialog, "Error", "Please select a term to edit")
                return
            
            term_id = terms_table.item(selected_row, 0).data(Qt.ItemDataRole.UserRole)
            if term_id:
                from modules.termbase_entry_editor import TermbaseEntryEditor
                edit_dialog = TermbaseEntryEditor(
                    parent=self,
                    db_manager=self.db_manager,
                    termbase_id=termbase_id,
                    term_id=term_id
                )
                if edit_dialog.exec():
                    refresh_terms_table()
        
        edit_term_btn.clicked.connect(edit_selected_term)
        action_layout.addWidget(edit_term_btn)
        
        # Delete selected term button
        delete_term_btn = QPushButton("🗑️ Delete Selected Term")
        delete_term_btn.setStyleSheet("background-color: #f44336; color: white; font-weight: bold; padding: 3px 5px;")
        def delete_selected_term():
            selected_row = terms_table.currentRow()
            if selected_row < 0:
                QMessageBox.warning(dialog, "Error", "Please select a term to delete")
                return
            
            term_id = terms_table.item(selected_row, 0).data(Qt.ItemDataRole.UserRole)
            source_term = terms_table.item(selected_row, 0).text()
            target_term = terms_table.item(selected_row, 1).text()
            
            if term_id:
                reply = QMessageBox.question(
                    dialog,
                    "Confirm Deletion",
                    f"Delete this term?\n\nSource: {source_term}\nTarget: {target_term}\n\nThis action cannot be undone.",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No
                )
                
                if reply == QMessageBox.StandardButton.Yes:
                    try:
                        cursor = self.db_manager.cursor
                        cursor.execute("DELETE FROM termbase_terms WHERE id = ?", (term_id,))
                        self.db_manager.connection.commit()
                        self.log(f"✓ Deleted term: {source_term} → {target_term}")
                        refresh_terms_table()
                        # v1.10.64: full post-delete refresh chain
                        # (clears cache, rebuilds index, refreshes
                        # TermLens for current segment) so the just-
                        # deleted term stops appearing as a match.
                        self._post_termbase_delete_refresh()
                    except Exception as e:
                        QMessageBox.critical(dialog, "Error", f"Failed to delete term: {e}")
        
        delete_term_btn.clicked.connect(delete_selected_term)
        action_layout.addWidget(delete_term_btn)
        
        action_layout.addStretch()
        layout.addLayout(action_layout)
        
        # Close button
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        layout.addWidget(close_btn)
        
        dialog.setLayout(layout)
        dialog.exec()
    
    # ========================================================================
    # TRANSLATION MEMORY HELPER METHODS
    # ========================================================================
    
    def _show_create_tm_dialog(self, tm_metadata_mgr, refresh_callback, project_id):
        """Show dialog to create new TM"""
        dialog = QDialog(self)
        dialog.setWindowTitle("Create New Translation Memory")
        dialog.setMinimumWidth(450)
        
        layout = QFormLayout()
        
        # TM Name
        name_field = QLineEdit()
        name_field.setPlaceholderText("e.g., ClientX_Medical_2024")
        layout.addRow("TM Name:", name_field)
        
        # TM ID (auto-generated from name, but editable)
        tm_id_field = QLineEdit()
        tm_id_field.setPlaceholderText("e.g., clientx_medical_2024")
        layout.addRow("TM ID:", tm_id_field)
        
        # Auto-generate TM ID from name
        def update_tm_id():
            name = name_field.text().strip()
            if name and not tm_id_field.text():
                # Convert name to valid tm_id
                tm_id = name.lower().replace(' ', '_').replace('-', '_')
                # Remove special characters
                tm_id = ''.join(c for c in tm_id if c.isalnum() or c == '_')
                tm_id_field.setText(tm_id)
        
        name_field.textChanged.connect(update_tm_id)
        
        # Source language
        source_lang_field = QLineEdit()
        source_lang_field.setPlaceholderText("e.g., en, nl, de")
        layout.addRow("Source Language:", source_lang_field)
        
        # Target language
        target_lang_field = QLineEdit()
        target_lang_field.setPlaceholderText("e.g., en, nl, de")
        layout.addRow("Target Language:", target_lang_field)
        
        # Description
        desc_field = QTextEdit()
        desc_field.setMaximumHeight(80)
        desc_field.setPlaceholderText("Optional description...")
        layout.addRow("Description:", desc_field)
        
        # Buttons
        button_box = QHBoxLayout()
        button_box.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_box.addWidget(cancel_btn)
        
        create_btn = QPushButton("Create")
        create_btn.setStyleSheet("font-weight: bold;")
        create_btn.clicked.connect(dialog.accept)
        button_box.addWidget(create_btn)
        
        layout.addRow("", button_box)
        
        dialog.setLayout(layout)
        
        if dialog.exec() == QDialog.DialogCode.Accepted:
            name = name_field.text().strip()
            tm_id = tm_id_field.text().strip()
            source_lang = source_lang_field.text().strip() or None
            target_lang = target_lang_field.text().strip() or None
            description = desc_field.toPlainText().strip()
            
            if not name:
                QMessageBox.warning(self, "Error", "Please enter a TM name")
                return
            
            if not tm_id:
                QMessageBox.warning(self, "Error", "Please enter a TM ID")
                return
            
            # Create TM
            result = tm_metadata_mgr.create_tm(
                name=name,
                tm_id=tm_id,
                source_lang=source_lang,
                target_lang=target_lang,
                description=description
            )
            
            if result:
                # Auto-activate for current project (or global=0 if no project loaded)
                if project_id is not None:
                    tm_metadata_mgr.activate_tm(result, project_id)
                
                QMessageBox.information(self, "Success", f"Translation Memory '{name}' created successfully!")
                refresh_callback()
            else:
                QMessageBox.critical(self, "Error", "Failed to create TM. The TM name or ID may already exist.")
    
    def _import_tmx_as_tm(self, tm_metadata_mgr, tm_table, refresh_callback):
        """Import TMX file as a new TM or add to existing TM"""
        from PyQt6.QtWidgets import QFileDialog, QRadioButton, QButtonGroup
        
        # File dialog
        filepath, _ = QFileDialog.getOpenFileName(
            self,
            "Import TMX File",
            "",
            "TMX Files (*.tmx);;All Files (*.*)"
        )
        
        if not filepath:
            return
        
        # Ask user: create new TM or add to existing?
        choice_dialog = QDialog(self)
        choice_dialog.setWindowTitle("Import TMX")
        choice_dialog.setMinimumWidth(400)
        
        layout = QVBoxLayout(choice_dialog)
        
        layout.addWidget(QLabel(f"Importing: {filepath}\n"))
        layout.addWidget(QLabel("Choose import option:"))
        
        new_tm_radio = CheckmarkRadioButton("Create new TM from this TMX")
        new_tm_radio.setChecked(True)
        layout.addWidget(new_tm_radio)
        
        existing_tm_radio = CheckmarkRadioButton("Add to existing TM")
        layout.addWidget(existing_tm_radio)
        
        # TM selection combo (for existing TM option)
        tm_combo = QComboBox()
        tm_combo.setEnabled(False)
        tms = tm_metadata_mgr.get_all_tms()
        for tm in tms:
            tm_combo.addItem(tm['name'], tm['tm_id'])
        layout.addWidget(tm_combo)
        
        existing_tm_radio.toggled.connect(tm_combo.setEnabled)
        
        # Buttons
        button_box = QHBoxLayout()
        button_box.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(choice_dialog.reject)
        button_box.addWidget(cancel_btn)
        
        import_btn = QPushButton("Import")
        import_btn.setStyleSheet("font-weight: bold;")
        import_btn.clicked.connect(choice_dialog.accept)
        button_box.addWidget(import_btn)
        
        layout.addLayout(button_box)
        
        if choice_dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Determine target tm_id
        target_tm_id = None
        
        if new_tm_radio.isChecked():
            # Create new TM - ask for name
            from pathlib import Path
            default_name = Path(filepath).stem  # Use filename without extension
            
            name, ok = QInputDialog.getText(
                self,
                "New TM Name",
                "Enter name for the new Translation Memory:",
                text=default_name
            )
            
            if not ok or not name:
                return
            
            # Generate tm_id from name
            tm_id = name.lower().replace(' ', '_').replace('-', '_')
            tm_id = ''.join(c for c in tm_id if c.isalnum() or c == '_')
            
            # Detect TMX languages BEFORE creating TM
            if not self.tm_database:
                self.initialize_tm_database()
            
            if not self.tm_database:
                QMessageBox.critical(self, "Error", "TM database not available")
                return
            
            tmx_langs = self.tm_database.detect_tmx_languages(filepath)
            
            if len(tmx_langs) < 2:
                QMessageBox.warning(
                    self, "Invalid TMX",
                    f"TMX file must contain at least 2 languages.\nFound: {', '.join(tmx_langs) if tmx_langs else 'none'}"
                )
                return
            
            # Let user select source and target from detected languages
            lang_dialog = QDialog(self)
            lang_dialog.setWindowTitle("Select Language Pair")
            lang_dialog.setMinimumWidth(400)
            
            lang_layout = QVBoxLayout(lang_dialog)
            lang_layout.addWidget(QLabel(f"TMX file contains {len(tmx_langs)} languages:\n{', '.join(tmx_langs)}\n"))
            lang_layout.addWidget(QLabel("Select source and target languages:"))
            
            # Source language combo
            src_layout = QHBoxLayout()
            src_layout.addWidget(QLabel("Source:"))
            src_combo = QComboBox()
            for lang in tmx_langs:
                src_combo.addItem(lang)
            src_layout.addWidget(src_combo)
            lang_layout.addLayout(src_layout)
            
            # Target language combo
            tgt_layout = QHBoxLayout()
            tgt_layout.addWidget(QLabel("Target:"))
            tgt_combo = QComboBox()
            for lang in tmx_langs:
                tgt_combo.addItem(lang)
            # Default to second language if available
            if len(tmx_langs) > 1:
                tgt_combo.setCurrentIndex(1)
            tgt_layout.addWidget(tgt_combo)
            lang_layout.addLayout(tgt_layout)
            
            # Buttons
            lang_button_box = QHBoxLayout()
            lang_button_box.addStretch()
            
            lang_cancel_btn = QPushButton("Cancel")
            lang_cancel_btn.clicked.connect(lang_dialog.reject)
            lang_button_box.addWidget(lang_cancel_btn)
            
            lang_ok_btn = QPushButton("OK")
            lang_ok_btn.setStyleSheet("font-weight: bold;")
            lang_ok_btn.clicked.connect(lang_dialog.accept)
            lang_button_box.addWidget(lang_ok_btn)
            
            lang_layout.addLayout(lang_button_box)
            
            if lang_dialog.exec() != QDialog.DialogCode.Accepted:
                return
            
            detected_src = src_combo.currentText()
            detected_tgt = tgt_combo.currentText()
            
            # Validate that source != target
            if detected_src == detected_tgt:
                QMessageBox.warning(
                    self, "Invalid Selection",
                    "Source and target languages must be different."
                )
                return
            
            # Create TM metadata entry with detected languages
            db_id = tm_metadata_mgr.create_tm(
                name=name,
                tm_id=tm_id,
                source_lang=detected_src,
                target_lang=detected_tgt,
                description=f"Imported from {Path(filepath).name}"
            )
            
            if not db_id:
                QMessageBox.critical(self, "Error", "Failed to create TM metadata")
                return
            
            target_tm_id = tm_id
        else:
            # Use existing TM
            target_tm_id = tm_combo.currentData()
            if not target_tm_id:
                QMessageBox.warning(self, "Error", "No TM selected")
                return
        
        # Import TMX
        try:
            if not self.tm_database:
                self.initialize_tm_database()
            
            if self.tm_database:
                # Import using existing TM database import method
                # Get TM languages from metadata
                tm_info = tm_metadata_mgr.get_tm_by_tm_id(target_tm_id)
                if not tm_info:
                    QMessageBox.critical(self, "Error", f"TM '{target_tm_id}' not found")
                    return
                
                tm_src_lang = tm_info.get('source_lang')
                tm_tgt_lang = tm_info.get('target_lang')
                
                # If TM has NULL languages, detect from TMX and let user select
                if not tm_src_lang or not tm_tgt_lang:
                    tmx_langs = self.tm_database.detect_tmx_languages(filepath)
                    
                    if len(tmx_langs) < 2:
                        QMessageBox.warning(
                            self, "Invalid TMX",
                            f"TMX file must contain at least 2 languages.\nFound: {', '.join(tmx_langs) if tmx_langs else 'none'}"
                        )
                        return
                    
                    # Let user select source and target from detected languages
                    lang_dialog = QDialog(self)
                    lang_dialog.setWindowTitle("Select Language Pair")
                    lang_dialog.setMinimumWidth(400)
                    
                    lang_layout = QVBoxLayout(lang_dialog)
                    lang_layout.addWidget(QLabel(f"TMX file contains {len(tmx_langs)} languages:\n{', '.join(tmx_langs)}\n"))
                    lang_layout.addWidget(QLabel("Select source and target languages:"))
                    
                    # Source language combo
                    src_layout = QHBoxLayout()
                    src_layout.addWidget(QLabel("Source:"))
                    src_combo = QComboBox()
                    for lang in tmx_langs:
                        src_combo.addItem(lang)
                    src_layout.addWidget(src_combo)
                    lang_layout.addLayout(src_layout)
                    
                    # Target language combo
                    tgt_layout = QHBoxLayout()
                    tgt_layout.addWidget(QLabel("Target:"))
                    tgt_combo = QComboBox()
                    for lang in tmx_langs:
                        tgt_combo.addItem(lang)
                    # Default to second language if available
                    if len(tmx_langs) > 1:
                        tgt_combo.setCurrentIndex(1)
                    tgt_layout.addWidget(tgt_combo)
                    lang_layout.addLayout(tgt_layout)
                    
                    # Buttons
                    lang_button_box = QHBoxLayout()
                    lang_button_box.addStretch()
                    
                    lang_cancel_btn = QPushButton("Cancel")
                    lang_cancel_btn.clicked.connect(lang_dialog.reject)
                    lang_button_box.addWidget(lang_cancel_btn)
                    
                    lang_ok_btn = QPushButton("OK")
                    lang_ok_btn.setStyleSheet("font-weight: bold;")
                    lang_ok_btn.clicked.connect(lang_dialog.accept)
                    lang_button_box.addWidget(lang_ok_btn)
                    
                    lang_layout.addLayout(lang_button_box)
                    
                    if lang_dialog.exec() != QDialog.DialogCode.Accepted:
                        return
                    
                    tm_src_lang = src_combo.currentText()
                    tm_tgt_lang = tgt_combo.currentText()
                    
                    # Validate that source != target
                    if tm_src_lang == tm_tgt_lang:
                        QMessageBox.warning(
                            self, "Invalid Selection",
                            "Source and target languages must be different."
                        )
                        return
                    
                    # Update TM metadata with detected languages
                    tm_metadata_mgr.update_tm(
                        target_tm_id,
                        source_lang=tm_src_lang,
                        target_lang=tm_tgt_lang
                    )
                    self.log(f"✓ Updated TM '{target_tm_id}' with detected languages: {tm_src_lang} → {tm_tgt_lang}")
                
                # Detect TMX languages and check compatibility
                tmx_langs = self.tm_database.detect_tmx_languages(filepath)
                compat = self.tm_database.check_language_compatibility(tmx_langs, tm_src_lang, tm_tgt_lang)
                
                if not compat['compatible']:
                    QMessageBox.warning(
                        self, "Language Mismatch",
                        f"TMX languages ({', '.join(tmx_langs)}) don't match TM languages ({tm_src_lang}, {tm_tgt_lang})"
                    )
                    return
                
                # Determine if we should strip variants
                strip_variants = compat.get('variant_match', False)
                if strip_variants:
                    reply = QMessageBox.question(
                        self, "Language Variants Detected",
                        f"TMX has language variants:\n{compat['tmx_source']}, {compat['tmx_target']}\n\n"
                        f"TM expects:\n{tm_src_lang}, {tm_tgt_lang}\n\n"
                        "Import by matching base languages?",
                        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                    )
                    if reply != QMessageBox.StandardButton.Yes:
                        return
                
                # Import into existing TM with real-time progress dialog
                from PyQt6.QtWidgets import QProgressDialog
                from PyQt6.QtCore import Qt
                import time
                
                progress = QProgressDialog("Counting translation units...", "Cancel", 0, 100, self)
                progress.setWindowTitle("TMX Import")
                progress.setWindowModality(Qt.WindowModality.WindowModal)
                progress.setMinimumDuration(0)
                progress.setAutoClose(False)
                progress.setAutoReset(False)
                progress.setValue(0)
                progress.show()
                QApplication.processEvents()
                
                cancelled = False
                start_time = time.time()
                
                def update_progress(current, total, message):
                    nonlocal cancelled
                    if progress.wasCanceled():
                        cancelled = True
                        return
                    
                    if total > 0:
                        percentage = int((current / total) * 100)
                        progress.setValue(percentage)
                        
                        # Calculate time stats
                        elapsed = time.time() - start_time
                        if current > 0:
                            rate = current / elapsed
                            remaining = (total - current) / rate if rate > 0 else 0
                            
                            # Format message with stats
                            time_info = f" (~{int(remaining/60)}m {int(remaining%60)}s remaining)" if remaining > 10 else ""
                            progress.setLabelText(f"{message}\nRate: {int(rate):,} entries/sec{time_info}")
                        else:
                            progress.setLabelText(message)
                    else:
                        progress.setLabelText(message)
                    
                    QApplication.processEvents()
                
                count = self.tm_database._load_tmx_into_db(
                    filepath, tm_src_lang, tm_tgt_lang, target_tm_id, 
                    strip_variants=strip_variants, progress_callback=update_progress
                )
                
                progress.close()
                
                if not cancelled:
                    # Update entry count
                    tm_metadata_mgr.update_entry_count(target_tm_id)
                    self.log(f"Imported {count} entries into TM '{target_tm_id}'")
                    
                    QMessageBox.information(self, "Success", f"TMX imported successfully into TM '{target_tm_id}'!\n\nEntries imported: {count:,}")
                else:
                    self.log(f"TMX import cancelled by user. Partial import: {count} entries")
                    QMessageBox.information(self, "Import Cancelled", f"Import stopped.\n\nPartial entries imported: {count:,}")
                refresh_callback()
            else:
                QMessageBox.critical(self, "Error", "TM database not available")
        except Exception as e:
            self.log(f"✗ Error importing TMX: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.critical(self, "Import Error", f"Failed to import TMX:\n\n{str(e)}")

    def _attach_sdltm_as_tm(self, tm_metadata_mgr, refresh_callback):
        """Mirror a Trados Studio .sdltm into Supervertaler's DB as a read-only TM.

        The .sdltm file stays untouched – we open it in SQLite read-only mode
        so it's safe to attach while Trados Studio has the same TM open. Each
        translation unit's source/target XML is converted to Supervertaler's
        inline-tag form (`<N>...</N>` / `<N/>`), then bulk-inserted under a
        new tm_id.
        """
        from PyQt6.QtWidgets import QFileDialog, QProgressDialog
        from pathlib import Path
        from modules.sdltm_handler import SDLTMReader, SDLTMError

        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Attach Trados TM (.sdltm)",
            "",
            "Trados Translation Memory (*.sdltm);;All Files (*.*)"
        )
        if not file_path:
            return

        if not self.tm_database:
            self.initialize_tm_database()
        if not self.tm_database:
            QMessageBox.critical(self, "Error", "TM database not available")
            return

        try:
            reader = SDLTMReader(file_path, log_callback=self.log)
            md = reader.metadata()
        except SDLTMError as e:
            QMessageBox.critical(
                self, "Cannot Read .sdltm",
                f"Could not open the selected file as a Trados TM:\n\n{e}"
            )
            return

        # Confirmation dialog – show what we found and let the user name it.
        # Default to the full filename (including .sdltm) so the entry stands
        # out from regular TMX-imported TMs in the list.
        default_name = Path(file_path).name
        confirm_msg = (
            f"<b>{Path(file_path).name}</b><br><br>"
            f"Languages: <b>{md['source_lang']}</b> → <b>{md['target_lang']}</b><br>"
            f"Translation units: <b>{md['tu_count']:,}</b><br><br>"
            f"This will mirror the TM into Supervertaler as <i>read-only</i>. "
            f"The .sdltm file itself is not modified.<br><br>"
            f"Continue?"
        )
        reply = QMessageBox.question(
            self, "Attach Trados TM",
            confirm_msg,
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
            QMessageBox.StandardButton.Yes
        )
        if reply != QMessageBox.StandardButton.Yes:
            reader.close()
            return

        # Ask the user for the display name (default = full filename of the .sdltm).
        name, ok = QInputDialog.getText(
            self, "Trados TM name",
            "Display name for this TM in Supervertaler:",
            text=default_name
        )
        if not ok or not name.strip():
            reader.close()
            return
        name = name.strip()

        # tm_id derived from filename so re-attaching the same .sdltm
        # lands on the same TM (and acts as a "refresh"). If a TM with
        # this id already exists we ask whether to replace it.
        stem = Path(file_path).stem
        safe_stem = "".join(c if c.isalnum() else "_" for c in stem).strip("_")
        tm_id = f"trados_{safe_stem.lower()}"

        if tm_metadata_mgr.tm_id_exists(tm_id):
            existing = tm_metadata_mgr.get_tm_by_tm_id(tm_id)
            existing_count = existing.get('entry_count', 0) if existing else 0
            reply = QMessageBox.question(
                self, "Trados TM already attached",
                f"A TM with id <code>{tm_id}</code> is already attached "
                f"({existing_count:,} TUs).<br><br>"
                f"Replace it with a fresh import from the .sdltm? "
                f"This deletes the existing entries and re-imports.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
                QMessageBox.StandardButton.Cancel
            )
            if reply != QMessageBox.StandardButton.Yes:
                reader.close()
                return
            # Delete entries + metadata so we get a clean slate.
            if existing:
                tm_metadata_mgr.delete_tm(existing['id'], delete_entries=True)

        db_id = tm_metadata_mgr.create_tm(
            name=name,
            tm_id=tm_id,
            source_lang=md["source_lang"],
            target_lang=md["target_lang"],
            description=f"Mirrored from {file_path}",
            read_only=True,
            auto_unique_id=False,
        )
        if not db_id:
            QMessageBox.critical(self, "Error", "Failed to create TM metadata entry")
            reader.close()
            return
        actual_tm_id = tm_id

        # Bulk import with a progress dialog. We chunk the inserts so the
        # dialog updates and the user can cancel mid-flight on huge TMs.
        total = md["tu_count"]
        progress = QProgressDialog(
            f"Importing {total:,} TUs from {Path(file_path).name}...",
            "Cancel", 0, max(total, 1), self
        )
        progress.setWindowTitle("Attaching Trados TM")
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.setValue(0)
        QApplication.processEvents()

        CHUNK = 500
        buffer = []        # (src, tgt) pairs ready for batch insert
        inserted = 0
        cancelled = False
        import_error = None
        max_id_seen = 0
        max_date_seen = ""  # lexicographic compare on ISO strings
        try:
            for tu_id, src, tgt, change_date in reader.iter_tus():
                if progress.wasCanceled():
                    cancelled = True
                    break
                buffer.append((src, tgt))
                if tu_id > max_id_seen:
                    max_id_seen = tu_id
                if change_date and change_date > max_date_seen:
                    max_date_seen = change_date
                if len(buffer) >= CHUNK:
                    inserted += self.db_manager.add_translation_units_batch(
                        buffer, md["source_lang"], md["target_lang"], actual_tm_id
                    )
                    buffer = []
                    progress.setValue(min(inserted, total))
                    progress.setLabelText(
                        f"Imported {inserted:,} / {total:,} TUs from {Path(file_path).name}..."
                    )
                    QApplication.processEvents()
            # Flush remaining buffer.
            if buffer and not cancelled:
                inserted += self.db_manager.add_translation_units_batch(
                    buffer, md["source_lang"], md["target_lang"], actual_tm_id
                )
        except Exception as e:
            # Catch and surface – Qt slot callbacks would otherwise swallow
            # the traceback silently and leave the user with an empty TM.
            import_error = e
            self.log(f"✗ Trados TM import failed: {e}")
            import traceback
            traceback.print_exc()
        finally:
            progress.close()
            reader.close()

        # Persist external-source state so the periodic sync timer can
        # delta-update from this .sdltm without duplicating work.
        if import_error is None and inserted > 0:
            try:
                self.db_manager.cursor.execute(
                    "UPDATE translation_memories SET "
                    "external_source_path = ?, "
                    "external_last_sync_id = ?, "
                    "external_last_sync_date = ?, "
                    "external_last_mtime = ? "
                    "WHERE id = ?",
                    (
                        file_path,
                        max_id_seen,
                        max_date_seen or None,
                        md.get("mtime"),
                        db_id,
                    ),
                )
                self.db_manager.connection.commit()
            except Exception as e:
                self.log(f"⚠ Could not save external-source state: {e}")

        # Refresh the entry_count on the metadata row so the TM Manager
        # display shows the right number without a manual reload.
        if hasattr(tm_metadata_mgr, 'update_entry_count'):
            tm_metadata_mgr.update_entry_count(actual_tm_id)

        if refresh_callback:
            refresh_callback()

        if import_error is not None:
            QMessageBox.critical(
                self, "Import Failed",
                f"Failed while importing TUs from <b>{Path(file_path).name}</b>:<br><br>"
                f"{import_error}<br><br>"
                f"The partial TM has been kept ({inserted:,} TUs) so you can inspect it. "
                f"Delete it from the TM Manager and try again."
            )
        elif cancelled:
            self.log(f"⚠ Trados TM attach cancelled after {inserted:,} TUs (TM kept partial)")
            QMessageBox.warning(
                self, "Attach Cancelled",
                f"Cancelled after importing {inserted:,} of {total:,} TUs.\n\n"
                f"The partial TM '{name}' has been kept – you can delete it from the "
                f"TM Manager or re-attach the .sdltm to start over."
            )
        else:
            self.log(f"✓ Attached Trados TM '{name}' ({inserted:,} TUs, tm_id={actual_tm_id})")
            QMessageBox.information(
                self, "Trados TM Attached",
                f"Successfully imported <b>{inserted:,}</b> TUs from "
                f"<b>{Path(file_path).name}</b>.<br><br>"
                f"Languages: {md['source_lang']} → {md['target_lang']}<br>"
                f"TM ID: <code>{actual_tm_id}</code> (read-only)<br><br>"
                f"Activate it for the current project from the TM list to use it for lookups."
            )

    def _sync_external_tms(self):
        """Tick of the external-TM live-sync timer.

        For each TM that was attached from an external source (currently:
        Trados Studio .sdltm), check the file's mtime; if it's newer than
        when we last synced, pull any new or modified TUs in via a delta
        query. Cheap on every tick when nothing has changed (one stat()
        call per attached TM). Silent unless we actually pulled new data.
        """
        if not self.db_manager or not getattr(self.db_manager, 'connection', None):
            return
        try:
            cur = self.db_manager.cursor
            cur.execute(
                "SELECT id, name, tm_id, source_lang, target_lang, "
                "external_source_path, external_last_sync_id, "
                "external_last_sync_date, external_last_mtime "
                "FROM translation_memories "
                "WHERE external_source_path IS NOT NULL"
            )
            rows = cur.fetchall()
        except Exception:
            # Migration may not have run yet, or DB closed mid-shutdown.
            return

        for db_id, name, tm_id, src_lang, tgt_lang, path, last_id, last_date, last_mtime in rows:
            if not path or not os.path.exists(path):
                continue
            try:
                current_mtime = os.path.getmtime(path)
            except OSError:
                continue
            # Skip if nothing has changed on disk since the last sync. Use a
            # small epsilon to dodge filesystem timestamp granularity issues
            # (FAT-flavoured filesystems round to 2-second boundaries).
            if last_mtime is not None and current_mtime <= (last_mtime + 0.001):
                continue
            self._delta_sync_external_tm(
                db_id, name, tm_id, src_lang, tgt_lang, path,
                last_id or 0, last_date, current_mtime,
            )

    def _delta_sync_external_tm(
        self, db_id, name, tm_id, src_lang, tgt_lang, path,
        since_id, since_date, current_mtime,
    ):
        """Pull new/modified TUs from an external .sdltm into our DB.

        Always updates external_last_mtime so a no-op change (mtime bumped
        without any new TUs, e.g. Trados re-saving on close) doesn't keep
        re-triggering the SQL on every tick.
        """
        from modules.sdltm_handler import SDLTMReader
        try:
            with SDLTMReader(path, log_callback=self.log) as reader:
                new_tus = list(reader.iter_tus(
                    since_id=since_id,
                    since_change_date=since_date or None,
                ))
        except Exception as e:
            self.log(f"⚠ Live-sync failed for '{name}' ({path}): {e}")
            return

        max_id = since_id
        max_date = since_date or ""
        inserted = 0
        if new_tus:
            pairs = [(src, tgt) for (_tid, src, tgt, _cd) in new_tus]
            try:
                inserted = self.db_manager.add_translation_units_batch(
                    pairs, src_lang, tgt_lang, tm_id
                )
            except Exception as e:
                self.log(f"⚠ Could not insert delta TUs for '{name}': {e}")
                return
            for tid, _src, _tgt, cd in new_tus:
                if tid > max_id:
                    max_id = tid
                if cd and cd > max_date:
                    max_date = cd

        # Always persist mtime + max-marker state so we don't redo this work.
        try:
            self.db_manager.cursor.execute(
                "UPDATE translation_memories SET "
                "external_last_sync_id = ?, "
                "external_last_sync_date = ?, "
                "external_last_mtime = ?, "
                "entry_count = (SELECT COUNT(*) FROM translation_units WHERE tm_id = ?) "
                "WHERE id = ?",
                (max_id, max_date or None, current_mtime, tm_id, db_id),
            )
            self.db_manager.connection.commit()
        except Exception as e:
            self.log(f"⚠ Could not persist sync state for '{name}': {e}")
            return

        if inserted:
            self.log(f"🔄 Synced {inserted:,} new TU(s) from '{name}'")

    def _export_tm_to_tmx(self, tm_metadata_mgr, tm_table):
        """Export selected TM to TMX file"""
        selected_row = tm_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a TM to export")
            return
        
        # Get TM (column 0 = TM Name)
        tm_name = tm_table.item(selected_row, 0).text()
        tms = tm_metadata_mgr.get_all_tms()
        tm = next((t for t in tms if t['name'] == tm_name), None)
        if not tm:
            QMessageBox.warning(self, "Error", "Could not find selected TM")
            return
        
        # File dialog
        from PyQt6.QtWidgets import QFileDialog
        default_filename = f"{tm_name.replace(' ', '_')}.tmx"
        filepath, _ = QFileDialog.getSaveFileName(
            self,
            "Export TM to TMX",
            default_filename,
            "TMX Files (*.tmx);;All Files (*.*)"
        )
        
        if not filepath:
            return
        
        try:
            # Get all entries for this TM
            cursor = self.db_manager.cursor
            cursor.execute("""
                SELECT source_text, target_text 
                FROM translation_units 
                WHERE tm_id = ?
                ORDER BY id
            """, (tm['tm_id'],))
            
            entries = cursor.fetchall()
            
            if not entries:
                QMessageBox.warning(self, "Empty TM", "This TM has no entries to export")
                return
            
            # Generate TMX
            from modules.tmx_generator import TMXGenerator
            tmx_generator = TMXGenerator(log_callback=self.log)
            
            source_segments = [e[0] for e in entries]
            target_segments = [e[1] for e in entries]
            
            tmx_tree = tmx_generator.generate_tmx(
                source_segments=source_segments,
                target_segments=target_segments,
                source_lang=tm['source_lang'] or "en",
                target_lang=tm['target_lang'] or "nl"
            )
            
            if tmx_generator.save_tmx(tmx_tree, filepath):
                self.log(f"✓ Exported TM '{tm_name}' to {filepath} ({len(entries)} entries)")
                QMessageBox.information(
                    self,
                    "Export Complete",
                    f"TM exported successfully!\n\nFile: {filepath}\nEntries: {len(entries)}"
                )
            else:
                QMessageBox.warning(self, "Export Error", "Failed to save TMX file")
        
        except Exception as e:
            self.log(f"✗ Error exporting TM: {e}")
            import traceback
            traceback.print_exc()
            QMessageBox.critical(self, "Export Error", f"Failed to export TM:\n\n{str(e)}")
    
    def _delete_tm(self, tm_metadata_mgr, tm_table, refresh_callback):
        """Delete selected TM"""
        selected_row = tm_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a TM to delete")
            return
        
        # Get TM (column 0 = TM Name)
        tm_name = tm_table.item(selected_row, 0).text()
        tms = tm_metadata_mgr.get_all_tms()
        tm = next((t for t in tms if t['name'] == tm_name), None)
        if not tm:
            return
        
        # Confirm deletion
        reply = QMessageBox.question(
            self,
            "Delete TM",
            f"Delete TM '{tm_name}'?\n\nThis will also delete all {tm['entry_count']} translation units in this TM.\n\nThis action cannot be undone!",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        
        if reply == QMessageBox.StandardButton.Yes:
            if tm_metadata_mgr.delete_tm(tm['id'], delete_entries=True):
                QMessageBox.information(self, "Success", f"TM '{tm_name}' deleted successfully")
                refresh_callback()
            else:
                QMessageBox.critical(self, "Error", "Failed to delete TM")
    
    def _show_tm_editor_dialog(self, tm_metadata_mgr, tm_table):
        """Show TM Editor dialog for selected TM (combines old TM Manager functionality)"""
        selected_row = tm_table.currentRow()
        if selected_row < 0:
            QMessageBox.warning(self, "Error", "Please select a TM to edit")
            return
        
        # Get TM (column 0 = TM Name)
        tm_name = tm_table.item(selected_row, 0).text()
        tms = tm_metadata_mgr.get_all_tms()
        tm = next((t for t in tms if t['name'] == tm_name), None)
        if not tm:
            return
        
        # Open TM Editor with this specific TM
        from modules.tm_editor_dialog import TMEditorDialog
        dialog = TMEditorDialog(self, self.db_manager, self.log, tm_id=tm['tm_id'], tm_name=tm_name)
        dialog.exec()
    
    # ========================================================================
    # SETTINGS TAB
    # ========================================================================
    
    def create_settings_tab(self):
        """Create the Settings tab with vertical sidebar navigation"""
        from PyQt6.QtWidgets import QScrollArea
        from modules.settings_sidebar import SettingsSidebar

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        # Create sidebar navigation (replaces horizontal QTabWidget)
        settings_tabs = SettingsSidebar()
        self.settings_tabs = settings_tabs  # Store for reference
        set_help_topic(tab, HelpTopics.SETTINGS_GENERAL)

        # Scroll area wrapper for each tab (for long content)
        scroll_area_wrapper = lambda widget: self._wrap_in_scroll(widget)

        # ===== TAB 1: General Settings =====
        general_tab = self._create_general_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(general_tab), "⚙️ General")

        # ===== TAB 2: User Identity =====
        identity_tab = self._create_user_identity_tab()
        settings_tabs.addTab(scroll_area_wrapper(identity_tab), "👤 User Identity")

        # ===== TAB 3: AI Settings (LLM, Ollama) =====
        ai_tab = self._create_ai_settings_tab()
        ai_scroll = scroll_area_wrapper(ai_tab)
        settings_tabs.addTab(ai_scroll, "🤖 AI Settings")
        self.ai_settings_scroll = ai_scroll  # Store reference for scrolling to API keys

        # ===== TAB: Voice (commands & dictation, lives in Sidekick) =====
        voice_tab = self._create_voice_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(voice_tab), "🎤 Voice")

        # ===== TAB 3: Language Pair Settings =====
        lang_tab = self._create_language_pair_tab()
        settings_tabs.addTab(scroll_area_wrapper(lang_tab), "🌐 Language Pair")

        # ===== TAB 4: MT Settings =====
        mt_tab = self._create_mt_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(mt_tab), "🌐 MT Settings")

        # ===== TAB 5: QuickTrans Settings =====
        # Prior to v1.10.10 this was a stub pointing users to
        # Sidekick → SuperLookup → SuperLookup Settings → QuickTrans
        # (a sub-sub-tab Sidekick mounted via the is_sidekick=True
        # branch on SuperlookupTab). With Sidekick retired the real
        # QuickTrans settings widget now lives directly here, where
        # users would have looked for it in the first place.
        mt_quick_tab = self._create_mt_quick_lookup_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(mt_quick_tab), "⚡ QuickTrans")
        self.mt_quick_lookup_tab_index = settings_tabs.count() - 1  # Store index for opening

        # ===== TAB 6: View Settings =====
        view_tab = self._create_view_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(view_tab), "🔍 View Settings")

        # ===== TAB 7: System Prompts (Layer 1) =====
        system_prompts_tab = self._create_system_prompts_tab()
        settings_tabs.addTab(scroll_area_wrapper(system_prompts_tab), "📝 System Prompts")

        # ===== TAB 8: Debug Settings =====
        debug_tab = self._create_debug_settings_tab()
        settings_tabs.addTab(scroll_area_wrapper(debug_tab), "🐛 Debug")

        # The "📦 Features" tab was retired in v1.9.474. The Workbench used
        # to ship feature modules (voice, web, pdf, mt, hunspell, …) as
        # optional pip extras and exposed an install-status panel here. All
        # of those packages are now part of the core dependency set, so the
        # tab no longer had any toggles to offer – everything is always
        # installed and ready. The pyproject [project.optional-dependencies]
        # section is also gone; ``pip install supervertaler`` gives you the
        # full surface area in one step.

        # ===== TAB 9: Domain Detection Keywords =====
        domain_keywords_tab = self._create_domain_keywords_tab()
        settings_tabs.addTab(scroll_area_wrapper(domain_keywords_tab), "🎯 Domain Detection")

        # ===== TAB 11: Segmentation Rules =====
        seg_tab = self.create_segmentation_rules_tab()
        settings_tabs.addTab(scroll_area_wrapper(seg_tab), "📏 Segmentation Rules")

        # ===== TAB 12: Keyboard Shortcuts =====
        from modules.keyboard_shortcuts_widget import KeyboardShortcutsWidget
        shortcuts_tab = KeyboardShortcutsWidget(self)
        settings_tabs.addTab(shortcuts_tab, "⌨️ Keyboard Shortcuts")
        # Index stored so the Voice tab's "Change in Settings → Keyboard
        # Shortcuts" link can jump straight here.
        self.keyboard_shortcuts_tab_index = settings_tabs.count() - 1

        # ===== TAB 13: Log (moved from main tabs) =====
        log_tab = self.create_log_tab()
        settings_tabs.addTab(log_tab, "📋 Log")

        layout.addWidget(settings_tabs)

        # Apply saved UI font scale on startup
        saved_scale = self._get_global_ui_font_scale()
        if saved_scale != 100:
            # Defer application to ensure widgets are fully created
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(100, lambda: self._apply_global_ui_font_scale(saved_scale))

        # Apply saved Compact UI chrome preference on startup.
        # Defaults to compact (v1.9.386+); use 0ms timer so it lands in the same event
        # loop iteration the widgets were constructed in – no visible flash of
        # non-compact chrome before the restyle.
        if self._get_ui_chrome_compact():
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(0, lambda: self._apply_ui_chrome_compactness(True, persist=False))

        # Apply dark sidebar style if current theme is dark
        self._update_settings_sidebar_theme()

        return tab
    
    # Fixed content width for every settings tab. Content is left-anchored
    # against the sidebar; anything past this width sits as breathing room
    # on the right. 1000 px is roughly the content area of the Trados
    # Studio Options dialog and keeps label–control rows comfortable
    # without feeling oversized on wide monitors.
    SETTINGS_CONTENT_MAX_WIDTH = 1000

    def _wrap_in_scroll(self, widget):
        """Wrap a settings-tab widget in a scroll area with consistent layout.

        Three things in one place so every settings tab gets them for free:

        1. **Wheel guard** – prevents hovered sliders / spinboxes / combo
           boxes from silently absorbing mouse-wheel events meant for the
           scroll area. See ``_WheelGuard``.
        2. **Left-anchor + max-width cap** – wraps ``widget`` in a horizontal
           layout with the content on the left and a stretch on the right,
           capped at ``SETTINGS_CONTENT_MAX_WIDTH`` px. Stops settings rows
           from sprawling across ultra-wide monitors while avoiding the
           "centred in a sea of grey" look that bare centring produces.
        3. **Scroll area** – the obvious one.
        """
        from PyQt6.QtWidgets import QSlider, QAbstractSpinBox, QComboBox

        # Left-anchor + width cap. Skip if the caller already wrapped its
        # own content (legacy: AI Settings used to do this inline). We
        # detect that by checking whether `widget` itself already has a
        # QHBoxLayout with a stretch – a heuristic that's good enough for
        # the small number of legacy callers.
        host = widget
        if not getattr(widget, '_settings_left_anchored', False):
            host = QWidget()
            outer = QHBoxLayout(host)
            outer.setContentsMargins(0, 0, 0, 0)
            outer.setSpacing(0)
            # Pin both min and max to the same width so every tab claims
            # exactly the same column regardless of how wide its longest
            # internal row would naturally make it. Without the min, a tab
            # with short rows would sit narrower than its neighbours, and
            # clicking between tabs would feel jumpy.
            widget.setMinimumWidth(self.SETTINGS_CONTENT_MAX_WIDTH)
            widget.setMaximumWidth(self.SETTINGS_CONTENT_MAX_WIDTH)
            outer.addWidget(widget, 0)
            outer.addStretch(1)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setWidget(host)
        scroll.setStyleSheet("QScrollArea { border: none; }")

        # Wheel guard: one instance per scroll area, attached so it's not GC'd.
        if not hasattr(self, '_settings_wheel_guards'):
            self._settings_wheel_guards = []
        guard = _WheelGuard(scroll)
        self._settings_wheel_guards.append(guard)
        for cls in (QSlider, QAbstractSpinBox, QComboBox):
            for w in widget.findChildren(cls):
                w.installEventFilter(guard)
                # Demote WheelFocus → StrongFocus so wheel doesn't take focus.
                w.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        return scroll

    def _update_settings_sidebar_theme(self):
        """Update the settings sidebar styling based on the current theme."""
        if not hasattr(self, 'settings_tabs'):
            return
        from modules.settings_sidebar import SettingsSidebar
        if not isinstance(self.settings_tabs, SettingsSidebar):
            return
        is_dark = (hasattr(self, 'theme_manager') and self.theme_manager
                   and 'dark' in self.theme_manager.current_theme.name.lower())
        if is_dark:
            self.settings_tabs.apply_dark_style()
        else:
            self.settings_tabs._apply_style()

    def _update_tools_sidebar_theme(self):
        """Update the tools sidebar styling based on the current theme."""
        if not hasattr(self, 'modules_tabs'):
            return
        from modules.settings_sidebar import SettingsSidebar
        if not isinstance(self.modules_tabs, SettingsSidebar):
            return
        is_dark = (hasattr(self, 'theme_manager') and self.theme_manager
                   and 'dark' in self.theme_manager.current_theme.name.lower())
        if is_dark:
            self.modules_tabs.apply_dark_style()
        else:
            self.modules_tabs._apply_style()

    def _update_resources_sidebar_theme(self):
        """No-op: resources tab now uses standard QTabWidget (themed automatically)."""
        pass

    def _create_language_pair_tab(self):
        """Create Language Pair Settings tab content"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)
        
        # Language Pair Settings group
        lang_group = QGroupBox("Translation Language Pair")
        lang_layout = QVBoxLayout()
        
        info_label = QLabel(
            "Set your default source and target languages for translation projects."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("font-size: 9pt; padding: 8px; border-radius: 3px;")
        lang_layout.addWidget(info_label)
        
        # Language selection layout
        lang_select_layout = QHBoxLayout()
        
        # Source language
        source_label = QLabel("Source Language:")
        source_combo = QComboBox()
        
        # Available languages (from Tkinter version)
        available_languages = [
            "Afrikaans", "Albanian", "Arabic", "Armenian", "Basque", "Bengali",
            "Bulgarian", "Catalan", "Chinese (Simplified)", "Chinese (Traditional)",
            "Croatian", "Czech", "Danish", "Dutch", "English", "Estonian",
            "Finnish", "French", "Galician", "Georgian", "German", "Greek",
            "Hebrew", "Hindi", "Hungarian", "Icelandic", "Indonesian", "Irish",
            "Italian", "Japanese", "Korean", "Latvian", "Lithuanian", "Macedonian",
            "Malay", "Norwegian", "Persian", "Polish", "Portuguese", "Romanian",
            "Russian", "Serbian", "Slovak", "Slovenian", "Spanish", "Swahili",
            "Swedish", "Thai", "Turkish", "Ukrainian", "Urdu", "Vietnamese", "Welsh"
        ]
        source_combo.addItems(available_languages)
        source_combo.setCurrentText(self.source_language)
        
        lang_select_layout.addWidget(source_label)
        lang_select_layout.addWidget(source_combo, 1)
        
        # Swap button
        swap_btn = QPushButton("🔄 Swap")
        swap_btn.setToolTip("Swap source and target languages")
        lang_select_layout.addWidget(swap_btn)
        
        # Target language
        target_label = QLabel("Target Language:")
        target_combo = QComboBox()
        target_combo.addItems(available_languages)
        target_combo.setCurrentText(self.target_language)
        
        lang_select_layout.addWidget(target_label)
        lang_select_layout.addWidget(target_combo, 1)
        
        lang_layout.addLayout(lang_select_layout)
        
        # Swap functionality
        def on_swap():
            source_val = source_combo.currentText()
            target_val = target_combo.currentText()
            source_combo.setCurrentText(target_val)
            target_combo.setCurrentText(source_val)

        swap_btn.clicked.connect(on_swap)

        # Auto-save on any dropdown change so the chosen pair persists
        # across restarts without requiring the user to click Save.
        # Triggers for typed entries and swap clicks alike via currentTextChanged.
        #
        # IMPORTANT: only save if the value actually differs from what's
        # already in self.source_language / self.target_language. This
        # prevents a spurious startup-time currentTextChanged (from
        # setCurrentText, or from Qt laying out the combo) from clobbering
        # the loaded-from-disk values with whatever happens to be on the
        # widget at that moment.
        def _autosave_lang(_=None):
            new_src = source_combo.currentText()
            new_tgt = target_combo.currentText()
            if not new_src or not new_tgt:
                return  # combo not fully populated yet
            if new_src == self.source_language and new_tgt == self.target_language:
                return  # no real change
            self.source_language = new_src
            self.target_language = new_tgt
            self.save_language_settings(self.source_language, self.target_language)
            self.log(f"✓ Language settings auto-saved: {self.source_language} → {self.target_language}")

        source_combo.currentTextChanged.connect(_autosave_lang)
        target_combo.currentTextChanged.connect(_autosave_lang)

        lang_group.setLayout(lang_layout)
        layout.addWidget(lang_group)

        # Save button – kept for users who want an explicit confirmation
        # dialog. Auto-save above means it's no longer strictly required.
        save_btn = QPushButton("💾 Save Language Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_language_settings_from_ui(source_combo, target_combo))
        layout.addWidget(save_btn)
        
        layout.addStretch()
        
        return tab
    
    def _create_ai_settings_tab(self):
        """Create unified AI Settings tab - combines LLM, Ollama, and AI Assistant settings"""
        from PyQt6.QtWidgets import QCheckBox, QGroupBox, QPushButton
        
        # The width-cap + left-anchor layout is applied centrally by
        # _wrap_in_scroll so every settings tab gets it consistently.
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        # Load current settings
        settings = self.load_llm_settings()
        enabled_providers = self.load_provider_enabled_states()
        general_settings = self.load_general_settings()
        general_prefs = self.load_general_settings()

        # Page title (replaces the old "Free vs Paid API Access" info panel
        # that was repeating information already covered in the help docs and
        # added visual noise on every Settings visit).
        title = QLabel("AI Settings")
        title.setStyleSheet("font-size: 16pt; font-weight: bold; padding: 0 0 4px 0;")
        layout.addWidget(title)
        
        # ========== SECTION 1: LLM Provider Selection ==========
        provider_group = QGroupBox("🤖 LLM Provider Selection")
        provider_layout = QVBoxLayout()
        
        provider_label = QLabel("Select your preferred AI translation provider:")
        provider_layout.addWidget(provider_label)
        
        # Provider radio buttons (custom styled)
        provider_button_group = QButtonGroup(tab)
        
        openai_radio = CustomRadioButton("OpenAI")
        openai_radio.setChecked(settings.get('provider', 'openai') == 'openai')
        provider_button_group.addButton(openai_radio)
        provider_layout.addWidget(openai_radio)

        claude_radio = CustomRadioButton("Anthropic Claude")
        claude_radio.setChecked(settings.get('provider', 'openai') == 'claude')
        provider_button_group.addButton(claude_radio)
        provider_layout.addWidget(claude_radio)

        gemini_radio = CustomRadioButton("Google Gemini")
        gemini_radio.setChecked(settings.get('provider', 'openai') == 'gemini')
        provider_button_group.addButton(gemini_radio)
        provider_layout.addWidget(gemini_radio)

        mistral_radio = CustomRadioButton("Mistral AI")
        mistral_radio.setChecked(settings.get('provider', 'openai') == 'mistral')
        provider_button_group.addButton(mistral_radio)
        provider_layout.addWidget(mistral_radio)

        deepseek_radio = CustomRadioButton("DeepSeek")
        deepseek_radio.setChecked(settings.get('provider', 'openai') == 'deepseek')
        provider_button_group.addButton(deepseek_radio)
        provider_layout.addWidget(deepseek_radio)

        openrouter_radio = CustomRadioButton("🌐 OpenRouter (200+ models)")
        openrouter_radio.setChecked(settings.get('provider', 'openai') == 'openrouter')
        provider_button_group.addButton(openrouter_radio)
        provider_layout.addWidget(openrouter_radio)

        # Local LLM option (Ollama)
        ollama_radio = CustomRadioButton("🖥️ Local LLM (Ollama - runs on your computer)")
        ollama_radio.setChecked(settings.get('provider', 'openai') == 'ollama')
        provider_button_group.addButton(ollama_radio)
        provider_layout.addWidget(ollama_radio)

        # Custom OpenAI-compatible endpoint
        custom_radio = CustomRadioButton("🔌 Custom (OpenAI-Compatible API)")
        custom_radio.setChecked(settings.get('provider', 'openai') == 'custom_openai')
        provider_button_group.addButton(custom_radio)
        provider_layout.addWidget(custom_radio)

        provider_group.setLayout(provider_layout)
        layout.addWidget(provider_group)
        
        # ========== SECTION 2: Model Selection ==========
        model_group = QGroupBox("📦 Model Selection")
        model_layout = QVBoxLayout()
        
        model_label = QLabel("Choose the specific model to use:")
        model_layout.addWidget(model_label)
        
        # OpenAI models
        openai_model_label = QLabel("<b>OpenAI Models:</b>")
        model_layout.addWidget(openai_model_label)
        
        openai_combo = QComboBox()
        openai_combo.addItems([
            "gpt-5.5 (Recommended - Flagship)",
            "gpt-5.4-mini (Fast & Economical)"
        ])
        openai_combo.setToolTip(
            "GPT-5.5: OpenAI's flagship – advanced reasoning, excellent for complex translation.\n"
            "GPT-5.4 Mini: Faster and cheaper, great quality for routine, high-volume work."
        )
        current_openai_model = settings.get('openai_model', 'gpt-5.5')
        for i in range(openai_combo.count()):
            if current_openai_model in openai_combo.itemText(i).lower():
                openai_combo.setCurrentIndex(i)
                break
        openai_combo.setEnabled(openai_radio.isChecked())
        model_layout.addWidget(openai_combo)
        
        model_layout.addSpacing(10)
        
        # Claude models
        claude_model_label = QLabel("<b>Claude Models:</b>")
        model_layout.addWidget(claude_model_label)
        
        claude_combo = QComboBox()
        claude_combo.addItems([
            "claude-sonnet-4-6 (Recommended - Best Balance)",
            "claude-haiku-4-5-20251001 (Fast & Affordable)",
            "claude-opus-4-7 (Latest Premium - Most Capable)"
        ])
        claude_combo.setToolTip(
            "Claude Sonnet 4.6: Best balance of speed, quality, and cost.\n"
            "Claude Haiku 4.5: Fast and affordable for batch jobs.\n"
            "Claude Opus 4.7: Anthropic's most capable model (1M context, 128k max output)."
        )
        current_claude_model = settings.get('claude_model', 'claude-sonnet-4-6')
        for i in range(claude_combo.count()):
            if current_claude_model in claude_combo.itemText(i):
                claude_combo.setCurrentIndex(i)
                break
        claude_combo.setEnabled(claude_radio.isChecked())
        model_layout.addWidget(claude_combo)
        
        model_layout.addSpacing(10)
        
        # Gemini models
        gemini_model_label = QLabel("<b>Gemini Models:</b>")
        model_layout.addWidget(gemini_model_label)
        
        gemini_combo = QComboBox()
        gemini_combo.addItems([
            "gemini-3.1-flash-lite (Recommended - Fast & Economical)",
            "gemini-2.5-pro (Premium - Complex Reasoning)",
            "gemini-3.1-pro-preview (Latest - Smartest Gemini)",
            "gemma-4-26b-a4b-it (Open Model - Lightweight)"
        ])
        gemini_combo.setToolTip(
            "Gemini 3.1 Flash-Lite: Fast and economical – great for routine translation.\n"
            "Gemini 2.5 Pro: Premium for complex problems.\n"
            "Gemini 3.1 Pro Preview: Latest, smartest Gemini for complex tasks.\n"
            "Gemma 4 26B MoE: Google's open model, lightweight and capable."
        )
        current_gemini_model = settings.get('gemini_model', 'gemini-3.1-flash-lite')
        for i in range(gemini_combo.count()):
            if current_gemini_model in gemini_combo.itemText(i):
                gemini_combo.setCurrentIndex(i)
                break
        gemini_combo.setEnabled(gemini_radio.isChecked())
        model_layout.addWidget(gemini_combo)

        model_layout.addSpacing(10)

        # Mistral models
        mistral_model_label = QLabel("<b>Mistral AI Models:</b>")
        model_layout.addWidget(mistral_model_label)

        mistral_combo = QComboBox()
        mistral_combo.addItems([
            "mistral-large-latest (Recommended - Flagship)",
            "mistral-small-latest (Fast & Cost-Effective)"
        ])
        mistral_combo.setToolTip(
            "Mistral Large: Flagship model, top-tier reasoning and multilingual quality.\n"
            "Mistral Small: Fast and cost-effective, great for high-volume translation."
        )
        current_mistral_model = settings.get('mistral_model', 'mistral-large-latest')
        for i in range(mistral_combo.count()):
            if current_mistral_model in mistral_combo.itemText(i):
                mistral_combo.setCurrentIndex(i)
                break
        mistral_combo.setEnabled(mistral_radio.isChecked())
        model_layout.addWidget(mistral_combo)

        model_layout.addSpacing(10)

        # DeepSeek models
        deepseek_model_label = QLabel("<b>DeepSeek Models:</b>")
        model_layout.addWidget(deepseek_model_label)

        deepseek_combo = QComboBox()
        deepseek_combo.addItems([
            "deepseek-v4-pro (Recommended - Flagship)",
            "deepseek-v4-flash (Fast & Cost-Effective)"
        ])
        deepseek_combo.setToolTip(
            "DeepSeek V4 Pro: Flagship – top-tier reasoning and multilingual quality.\n"
            "DeepSeek V4 Flash: Fast and cost-effective, great for high-volume translation.\n\n"
            "Get an API key at platform.deepseek.com."
        )
        current_deepseek_model = settings.get('deepseek_model', 'deepseek-v4-pro')
        for i in range(deepseek_combo.count()):
            if current_deepseek_model in deepseek_combo.itemText(i):
                deepseek_combo.setCurrentIndex(i)
                break
        deepseek_combo.setEnabled(deepseek_radio.isChecked())
        model_layout.addWidget(deepseek_combo)

        model_layout.addSpacing(10)

        # OpenRouter models
        openrouter_model_label = QLabel("<b>🌐 OpenRouter Models:</b>")
        model_layout.addWidget(openrouter_model_label)

        openrouter_combo = QComboBox()
        openrouter_combo.setEditable(True)
        openrouter_combo.addItems([
            "anthropic/claude-sonnet-4.6 (Recommended)",
            "anthropic/claude-opus-4.7 (Latest Premium)",
            "anthropic/claude-opus-4.6 (Previous Premium)",
            "openai/gpt-5.4 (Advanced Reasoning)",
            "openai/gpt-5.4-mini (Fast & Economical)",
            "google/gemini-3.1-pro-preview (Latest Gemini)",
            "google/gemini-3-flash-preview (Fast Gemini)",
            "mistralai/mistral-small-2603 (European Languages)",
            "qwen/qwen3.6-plus:free (Free)"
        ])
        openrouter_combo.setToolTip(
            "OpenRouter gives you access to 200+ models with a single API key.\n"
            "Select a model from the dropdown or type any model ID from openrouter.ai/models.\n\n"
            "Pricing varies per model – see openrouter.ai for details."
        )
        current_openrouter_model = settings.get('openrouter_model', 'anthropic/claude-sonnet-4.6')
        # Try to select from dropdown, or set as typed text
        found = False
        for i in range(openrouter_combo.count()):
            if current_openrouter_model in openrouter_combo.itemText(i):
                openrouter_combo.setCurrentIndex(i)
                found = True
                break
        if not found:
            openrouter_combo.setEditText(current_openrouter_model)
        openrouter_combo.setEnabled(openrouter_radio.isChecked())
        model_layout.addWidget(openrouter_combo)

        model_layout.addSpacing(10)

        # Local LLM (Ollama) models
        ollama_model_label = QLabel("<b>🖥️ Local LLM Models (Ollama):</b>")
        model_layout.addWidget(ollama_model_label)
        
        # Import the LocalLLMStatusWidget for embedded status
        try:
            from modules.local_llm_setup import LocalLLMStatusWidget, check_ollama_status, RECOMMENDED_MODELS
            
            # Create embedded status widget
            ollama_status_widget = LocalLLMStatusWidget()
            ollama_status_widget.setEnabled(ollama_radio.isChecked())
            model_layout.addWidget(ollama_status_widget)
            
            # Store reference for saving settings
            tab.ollama_status_widget = ollama_status_widget
            
        except ImportError as e:
            # Fallback if module not available
            ollama_fallback = QLabel("⚠️ Local LLM module not available")
            ollama_fallback.setStyleSheet("color: orange;")
            model_layout.addWidget(ollama_fallback)
            ollama_status_widget = None
            tab.ollama_status_widget = None
        
        # Custom OpenAI-compatible endpoint settings (with profiles)
        model_layout.addSpacing(10)
        custom_model_label = QLabel("<b>🔌 Custom (OpenAI-Compatible) Settings:</b>")
        model_layout.addWidget(custom_model_label)

        # Profile selector row
        profile_row = QHBoxLayout()
        profile_label = QLabel("Profile:")
        profile_label.setStyleSheet("font-size: 9pt; margin-top: 2px;")
        profile_row.addWidget(profile_label)

        custom_profile_combo = QComboBox()
        custom_profile_combo.setMinimumWidth(200)
        profiles = settings.get('custom_openai_profiles', [])
        active_profile_name = settings.get('custom_openai_active_profile', '')
        for p in profiles:
            custom_profile_combo.addItem(p.get('name', 'Unnamed'))
        if active_profile_name:
            idx = custom_profile_combo.findText(active_profile_name)
            if idx >= 0:
                custom_profile_combo.setCurrentIndex(idx)
        custom_profile_combo.setEnabled(custom_radio.isChecked())
        profile_row.addWidget(custom_profile_combo)

        add_profile_btn = QPushButton("+")
        add_profile_btn.setFixedWidth(30)
        add_profile_btn.setToolTip("Add a new custom API profile")
        add_profile_btn.setEnabled(custom_radio.isChecked())
        profile_row.addWidget(add_profile_btn)

        remove_profile_btn = QPushButton("−")
        remove_profile_btn.setFixedWidth(30)
        remove_profile_btn.setToolTip("Remove the selected profile")
        remove_profile_btn.setEnabled(custom_radio.isChecked() and custom_profile_combo.count() > 0)
        profile_row.addWidget(remove_profile_btn)

        profile_row.addStretch()
        model_layout.addLayout(profile_row)

        custom_endpoint_label = QLabel("API Endpoint URL:")
        custom_endpoint_label.setStyleSheet("font-size: 9pt; margin-top: 2px;")
        model_layout.addWidget(custom_endpoint_label)

        custom_endpoint_input = QLineEdit()
        custom_endpoint_input.setPlaceholderText("https://api.example.com/v1")
        custom_endpoint_input.setEnabled(custom_radio.isChecked())
        model_layout.addWidget(custom_endpoint_input)

        custom_model_name_label = QLabel("Model Name or Endpoint ID:")
        custom_model_name_label.setStyleSheet("font-size: 9pt; margin-top: 2px;")
        model_layout.addWidget(custom_model_name_label)

        custom_model_input = QLineEdit()
        custom_model_input.setPlaceholderText("e.g. qwen-plus, ep-xxxxx, deepseek-chat")
        custom_model_input.setEnabled(custom_radio.isChecked())
        model_layout.addWidget(custom_model_input)

        custom_key_label = QLabel("API Key:")
        custom_key_label.setStyleSheet("font-size: 9pt; margin-top: 2px;")
        model_layout.addWidget(custom_key_label)

        custom_key_row = QHBoxLayout()
        custom_key_input = QLineEdit()
        custom_key_input.setPlaceholderText("API key for this endpoint (leave empty to use api_keys.txt)")
        custom_key_input.setEchoMode(QLineEdit.EchoMode.Password)
        custom_key_input.setEnabled(custom_radio.isChecked())
        custom_key_row.addWidget(custom_key_input)

        custom_key_toggle = QPushButton("👁")
        custom_key_toggle.setFixedWidth(30)
        custom_key_toggle.setCheckable(True)
        custom_key_toggle.setToolTip("Show/hide API key")
        custom_key_toggle.toggled.connect(
            lambda checked: custom_key_input.setEchoMode(
                QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password
            )
        )
        custom_key_row.addWidget(custom_key_toggle)
        model_layout.addLayout(custom_key_row)

        # Populate fields from active profile
        def _populate_profile_fields(index):
            """Populate endpoint/model/key fields from the selected profile."""
            _profiles = settings.get('custom_openai_profiles', [])
            if 0 <= index < len(_profiles):
                p = _profiles[index]
                custom_endpoint_input.setText(p.get('endpoint', ''))
                custom_model_input.setText(p.get('model', ''))
                custom_key_input.setText(p.get('api_key', ''))
            else:
                custom_endpoint_input.clear()
                custom_model_input.clear()
                custom_key_input.clear()

        # Initial population
        if custom_profile_combo.count() > 0:
            _populate_profile_fields(custom_profile_combo.currentIndex())

        custom_profile_combo.currentIndexChanged.connect(_populate_profile_fields)

        # Save current field values back into the profiles list when fields change
        def _sync_fields_to_profile():
            """Write current field values back into the profiles list."""
            idx = custom_profile_combo.currentIndex()
            _profiles = settings.get('custom_openai_profiles', [])
            if 0 <= idx < len(_profiles):
                _profiles[idx]['endpoint'] = custom_endpoint_input.text().strip()
                _profiles[idx]['model'] = custom_model_input.text().strip()
                _profiles[idx]['api_key'] = custom_key_input.text().strip()

        custom_endpoint_input.editingFinished.connect(_sync_fields_to_profile)
        custom_model_input.editingFinished.connect(_sync_fields_to_profile)
        custom_key_input.editingFinished.connect(_sync_fields_to_profile)

        # Add profile handler
        def _add_custom_profile():
            from PyQt6.QtWidgets import QInputDialog
            name, ok = QInputDialog.getText(
                self, "New Custom API Profile",
                "Profile name (e.g. 'Volcengine Doubao', 'DeepSeek'):"
            )
            if ok and name.strip():
                name = name.strip()
                _profiles = settings.get('custom_openai_profiles', [])
                # Check for duplicate name
                if any(p.get('name') == name for p in _profiles):
                    from PyQt6.QtWidgets import QMessageBox
                    QMessageBox.warning(self, "Duplicate Name", f"A profile named '{name}' already exists.")
                    return
                new_profile = {'name': name, 'endpoint': '', 'model': '', 'api_key': ''}
                _profiles.append(new_profile)
                settings['custom_openai_profiles'] = _profiles
                custom_profile_combo.addItem(name)
                custom_profile_combo.setCurrentIndex(custom_profile_combo.count() - 1)
                remove_profile_btn.setEnabled(True)

        add_profile_btn.clicked.connect(_add_custom_profile)

        # Remove profile handler
        def _remove_custom_profile():
            idx = custom_profile_combo.currentIndex()
            if idx < 0:
                return
            from PyQt6.QtWidgets import QMessageBox
            name = custom_profile_combo.currentText()
            reply = QMessageBox.question(
                self, "Remove Profile",
                f"Remove profile '{name}'?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.Yes:
                _profiles = settings.get('custom_openai_profiles', [])
                if idx < len(_profiles):
                    _profiles.pop(idx)
                    settings['custom_openai_profiles'] = _profiles
                custom_profile_combo.removeItem(idx)
                remove_profile_btn.setEnabled(custom_profile_combo.count() > 0)

        remove_profile_btn.clicked.connect(_remove_custom_profile)

        custom_info = QLabel(
            "💡 Works with any OpenAI-compatible API: Volcengine (Doubao), "
            "Alibaba Tongyi (Qwen), DeepSeek, Mistral, Groq, etc.\n"
            "Create multiple profiles to switch between different endpoints."
        )
        custom_info.setWordWrap(True)
        custom_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        model_layout.addWidget(custom_info)

        # Helper to extract friendly model name from combo text and update radio label
        def _friendly_model_name(combo_text: str) -> str:
            """Extract model ID from combo text like 'gpt-5.5 (Recommended)' → 'GPT-5.5'"""
            model_id = combo_text.split()[0] if combo_text else ""
            friendly = {
                "gpt-5.5": "GPT-5.5", "gpt-5.4-mini": "GPT-5.4 Mini",
                "claude-sonnet-4-6": "Claude Sonnet 4.6", "claude-opus-4-7": "Claude Opus 4.7",
                "claude-haiku-4-5-20251001": "Claude Haiku 4.5",
                "gemini-3.1-flash-lite": "Gemini 3.1 Flash-Lite", "gemini-2.5-pro": "Gemini 2.5 Pro",
                "gemini-3.1-pro-preview": "Gemini 3.1 Pro", "gemma-4-26b-a4b-it": "Gemma 4 26B MoE",
                "mistral-large-latest": "Mistral Large", "mistral-small-latest": "Mistral Small",
                "deepseek-v4-pro": "DeepSeek V4 Pro", "deepseek-v4-flash": "DeepSeek V4 Flash",
            }
            return friendly.get(model_id, model_id)

        def _update_provider_label(radio, base_name, combo):
            """Update radio button label to show selected model: 'Provider (Model)'"""
            model_name = _friendly_model_name(combo.currentText())
            radio.setText(f"{base_name} ({model_name})")

        # Connect combo changes to update radio button labels dynamically
        openai_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(openai_radio, "OpenAI", openai_combo))
        claude_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(claude_radio, "Anthropic Claude", claude_combo))
        gemini_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(gemini_radio, "Google Gemini", gemini_combo))
        mistral_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(mistral_radio, "Mistral AI", mistral_combo))
        deepseek_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(deepseek_radio, "DeepSeek", deepseek_combo))
        openrouter_combo.currentIndexChanged.connect(
            lambda: _update_provider_label(openrouter_radio, "🌐 OpenRouter (200+ models)", openrouter_combo))

        # Set initial labels based on current combo selections
        _update_provider_label(openai_radio, "OpenAI", openai_combo)
        _update_provider_label(claude_radio, "Anthropic Claude", claude_combo)
        _update_provider_label(gemini_radio, "Google Gemini", gemini_combo)
        _update_provider_label(mistral_radio, "Mistral AI", mistral_combo)
        _update_provider_label(deepseek_radio, "DeepSeek", deepseek_combo)
        _update_provider_label(openrouter_radio, "🌐 OpenRouter (200+ models)", openrouter_combo)

        # Connect radio buttons to enable/disable combos
        def update_combo_states():
            openai_combo.setEnabled(openai_radio.isChecked())
            claude_combo.setEnabled(claude_radio.isChecked())
            gemini_combo.setEnabled(gemini_radio.isChecked())
            mistral_combo.setEnabled(mistral_radio.isChecked())
            deepseek_combo.setEnabled(deepseek_radio.isChecked())
            openrouter_combo.setEnabled(openrouter_radio.isChecked())
            if ollama_status_widget:
                ollama_status_widget.setEnabled(ollama_radio.isChecked())
            _custom_enabled = custom_radio.isChecked()
            custom_profile_combo.setEnabled(_custom_enabled)
            add_profile_btn.setEnabled(_custom_enabled)
            remove_profile_btn.setEnabled(_custom_enabled and custom_profile_combo.count() > 0)
            custom_endpoint_input.setEnabled(_custom_enabled)
            custom_model_input.setEnabled(_custom_enabled)
            custom_key_input.setEnabled(_custom_enabled)
        
        # Handler to show setup dialog when Ollama is selected but not running
        def on_ollama_selected(checked):
            if checked:
                try:
                    from modules.local_llm_setup import check_ollama_status, LocalLLMSetupDialog
                    status = check_ollama_status()
                    
                    if not status.get('running', False):
                        from PyQt6.QtWidgets import QMessageBox
                        msg = QMessageBox(self)
                        msg.setWindowTitle("Local LLM Setup Required")
                        msg.setIcon(QMessageBox.Icon.Information)
                        msg.setText(
                            "<b>🖥️ Local LLM (Ollama) Selected</b><br><br>"
                            "Ollama is not currently running on your computer.<br><br>"
                            "Would you like to open the setup wizard?"
                        )
                        msg.setStandardButtons(
                            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                        )
                        msg.setDefaultButton(QMessageBox.StandardButton.Yes)
                        
                        if msg.exec() == QMessageBox.StandardButton.Yes:
                            setup_dialog = LocalLLMSetupDialog(self)
                            setup_dialog.exec()
                            if ollama_status_widget:
                                ollama_status_widget.refresh_status()
                except Exception as e:
                    self.log(f"Error checking Ollama status: {e}")
        
        openai_radio.toggled.connect(update_combo_states)
        claude_radio.toggled.connect(update_combo_states)
        gemini_radio.toggled.connect(update_combo_states)
        mistral_radio.toggled.connect(update_combo_states)
        deepseek_radio.toggled.connect(update_combo_states)
        openrouter_radio.toggled.connect(update_combo_states)
        ollama_radio.toggled.connect(update_combo_states)
        ollama_radio.toggled.connect(on_ollama_selected)
        custom_radio.toggled.connect(update_combo_states)
        
        model_group.setLayout(model_layout)
        layout.addWidget(model_group)

        # ========== SECTION 2c: API Keys ==========
        api_keys_group = QGroupBox("🔑 LLM API Keys")
        api_keys_form = QFormLayout()
        api_keys_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)

        current_keys = self.load_api_keys()
        if not hasattr(self, '_api_key_inputs'):
            self._api_key_inputs = {}

        llm_key_fields = [
            ("OpenAI:", "openai", "sk-proj-..."),
            ("Claude (Anthropic):", "claude", "sk-ant-api03-..."),
            ("Gemini (Google AI):", "gemini", "AIza..."),
            ("Mistral AI:", "mistral", "..."),
            ("DeepSeek:", "deepseek", "sk-..."),
            ("OpenRouter:", "openrouter", "sk-or-..."),
            ("Ollama Endpoint:", "ollama_endpoint", "http://localhost:11434"),
        ]

        for label, key_name, placeholder in llm_key_fields:
            row = QHBoxLayout()
            key_input = QLineEdit()
            key_input.setPlaceholderText(placeholder)
            key_input.setText(current_keys.get(key_name, ''))
            if key_name != 'ollama_endpoint':
                key_input.setEchoMode(QLineEdit.EchoMode.Password)
            row.addWidget(key_input)

            if key_name != 'ollama_endpoint':
                toggle_btn = QPushButton("Show")
                toggle_btn.setFixedWidth(50)
                toggle_btn.setCheckable(True)
                toggle_btn.clicked.connect(
                    lambda checked, inp=key_input, btn=toggle_btn:
                        (inp.setEchoMode(QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password),
                         btn.setText("Hide" if checked else "Show"))
                )
                row.addWidget(toggle_btn)

            container = QWidget()
            container.setLayout(row)
            api_keys_form.addRow(label, container)
            self._api_key_inputs[key_name] = key_input

        api_keys_group.setLayout(api_keys_form)
        layout.addWidget(api_keys_group)

        # ========== SECTION 3: Enable/Disable LLM Providers ==========
        provider_enable_group = QGroupBox("✅ Enable/Disable LLM Providers")
        provider_enable_layout = QVBoxLayout()
        
        provider_enable_info = QLabel(
            "Uncheck providers you don't want to use. Only enabled providers will be available."
        )
        provider_enable_info.setWordWrap(True)
        provider_enable_info.setStyleSheet("font-size: 9pt; color: #666; padding: 5px;")
        provider_enable_layout.addWidget(provider_enable_info)
        
        openai_enable_cb = CheckmarkCheckBox("Enable OpenAI")
        openai_enable_cb.setChecked(enabled_providers.get('llm_openai', True))
        provider_enable_layout.addWidget(openai_enable_cb)
        
        claude_enable_cb = CheckmarkCheckBox("Enable Claude")
        claude_enable_cb.setChecked(enabled_providers.get('llm_claude', True))
        provider_enable_layout.addWidget(claude_enable_cb)
        
        gemini_enable_cb = CheckmarkCheckBox("Enable Gemini")
        gemini_enable_cb.setChecked(enabled_providers.get('llm_gemini', True))
        provider_enable_layout.addWidget(gemini_enable_cb)

        mistral_enable_cb = CheckmarkCheckBox("Enable Mistral AI")
        mistral_enable_cb.setChecked(enabled_providers.get('llm_mistral', True))
        provider_enable_layout.addWidget(mistral_enable_cb)

        openrouter_enable_cb = CheckmarkCheckBox("Enable OpenRouter")
        openrouter_enable_cb.setChecked(enabled_providers.get('llm_openrouter', True))
        provider_enable_layout.addWidget(openrouter_enable_cb)

        ollama_enable_cb = CheckmarkCheckBox("Enable Local LLM (Ollama)")
        ollama_enable_cb.setChecked(enabled_providers.get('llm_ollama', True))
        provider_enable_layout.addWidget(ollama_enable_cb)

        custom_enable_cb = CheckmarkCheckBox("Enable Custom (OpenAI-Compatible)")
        custom_enable_cb.setChecked(enabled_providers.get('llm_custom_openai', True))
        provider_enable_layout.addWidget(custom_enable_cb)

        provider_enable_group.setLayout(provider_enable_layout)
        layout.addWidget(provider_enable_group)

        # ========== SECTION 3b: HTTP Proxy Settings ==========
        proxy_settings = self.load_proxy_settings()

        proxy_group = QGroupBox("🌐 HTTP Proxy Settings")
        proxy_layout = QVBoxLayout()
        proxy_layout.setSpacing(8)

        proxy_info = QLabel(
            "Route all AI and MT API requests through an HTTP proxy.\n"
            "Useful if direct access to AI/MT services is restricted in your region."
        )
        proxy_info.setWordWrap(True)
        proxy_info.setStyleSheet("font-size: 9pt; color: #666; padding: 5px;")
        proxy_layout.addWidget(proxy_info)

        self.proxy_enabled_cb = CheckmarkCheckBox("Enable HTTP proxy")
        self.proxy_enabled_cb.setChecked(proxy_settings.get('enabled', False))
        proxy_layout.addWidget(self.proxy_enabled_cb)

        # Host + port row
        proxy_addr_row = QHBoxLayout()
        proxy_addr_row.addWidget(QLabel("Host:"))
        self.proxy_host_input = QLineEdit()
        self.proxy_host_input.setPlaceholderText("e.g. 127.0.0.1")
        self.proxy_host_input.setText(proxy_settings.get('host', ''))
        self.proxy_host_input.setMinimumWidth(180)
        proxy_addr_row.addWidget(self.proxy_host_input)
        proxy_addr_row.addSpacing(12)
        proxy_addr_row.addWidget(QLabel("Port:"))
        self.proxy_port_spin = QSpinBox()
        self.proxy_port_spin.setMinimum(1)
        self.proxy_port_spin.setMaximum(65535)
        self.proxy_port_spin.setValue(proxy_settings.get('port', 8080))
        self.proxy_port_spin.setFixedWidth(80)
        proxy_addr_row.addWidget(self.proxy_port_spin)
        proxy_addr_row.addStretch()
        proxy_layout.addLayout(proxy_addr_row)

        # Optional credentials row
        proxy_cred_row = QHBoxLayout()
        proxy_cred_row.addWidget(QLabel("Username (optional):"))
        self.proxy_user_input = QLineEdit()
        self.proxy_user_input.setPlaceholderText("leave blank if not required")
        self.proxy_user_input.setText(proxy_settings.get('username', ''))
        self.proxy_user_input.setMinimumWidth(150)
        proxy_cred_row.addWidget(self.proxy_user_input)
        proxy_cred_row.addSpacing(12)
        proxy_cred_row.addWidget(QLabel("Password:"))
        self.proxy_pass_input = QLineEdit()
        self.proxy_pass_input.setEchoMode(QLineEdit.EchoMode.Password)
        self.proxy_pass_input.setPlaceholderText("leave blank if not required")
        self.proxy_pass_input.setText(proxy_settings.get('password', ''))
        self.proxy_pass_input.setMinimumWidth(130)
        proxy_cred_row.addWidget(self.proxy_pass_input)
        proxy_cred_row.addStretch()
        proxy_layout.addLayout(proxy_cred_row)

        proxy_note = QLabel(
            "ⓘ  Applies to: OpenAI, Claude, Gemini, Mistral, Ollama, Google Translate, DeepL,\n"
            "    Microsoft Translator, ModernMT, and MyMemory."
        )
        proxy_note.setStyleSheet("font-size: 9pt; color: #888; padding-left: 4px;")
        proxy_layout.addWidget(proxy_note)

        proxy_group.setLayout(proxy_layout)
        layout.addWidget(proxy_group)

        # ========== SECTION 4: Local LLM (Ollama) Advanced Settings ==========
        ollama_group = QGroupBox("🖥️ Local LLM (Ollama) Advanced Settings")
        ollama_layout = QVBoxLayout()
        
        ollama_keepwarm_cb = CheckmarkCheckBox("Keep model warm (prevent unloading)")
        ollama_keepwarm_cb.setChecked(general_settings.get('ollama_keepwarm', False))
        ollama_keepwarm_cb.setToolTip(
            "When enabled, sends a small ping to Ollama every 4 minutes\n"
            "to keep the model loaded in memory for faster translations.\n\n"
            "Drawback: Model keeps using RAM even when not translating."
        )
        ollama_layout.addWidget(ollama_keepwarm_cb)
        
        ollama_info = QLabel(
            "💡 <b>Tip:</b> Ollama normally unloads models after 5 minutes of inactivity.\n"
            "Enable 'Keep warm' for faster translations if you translate frequently."
        )
        ollama_info.setWordWrap(True)
        ollama_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        ollama_layout.addWidget(ollama_info)
        
        ollama_group.setLayout(ollama_layout)
        layout.addWidget(ollama_group)
        
        # ========== SECTION 5: AI Translation Preferences ==========
        prefs_group = QGroupBox("⚙️ AI Translation Preferences")
        prefs_layout = QVBoxLayout()
        prefs_layout.setSpacing(10)

        # Load current preferences
        batch_size = general_prefs.get('batch_size', 20)
        surrounding_segments = general_prefs.get('surrounding_segments', 5)
        use_full_context = general_prefs.get('use_full_context', True)
        check_tm_before_api = general_prefs.get('check_tm_before_api', True)
        auto_propagate_100 = general_prefs.get('auto_propagate_100', True)

        # --- Single-Segment Translation (Ctrl+T) ---
        single_header = QLabel(
            f"<b>Single-Segment Translation ({format_shortcut_for_display('Ctrl+T')}):</b>"
        )
        prefs_layout.addWidget(single_header)

        surrounding_layout = QHBoxLayout()
        surrounding_label = QLabel("Context segments:")
        surrounding_layout.addWidget(surrounding_label)
        surrounding_spin = QSpinBox()
        surrounding_spin.setMinimum(0)
        surrounding_spin.setMaximum(20)
        surrounding_spin.setValue(surrounding_segments)
        surrounding_layout.addWidget(surrounding_spin)
        surrounding_segments_label = QLabel("segments before/after")
        surrounding_layout.addWidget(surrounding_segments_label)
        surrounding_layout.addStretch()
        prefs_layout.addLayout(surrounding_layout)
        surrounding_info = QLabel("  ⓘ Surrounding segments sent to the AI for context. 0 = no context. Default: 5")
        surrounding_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
        prefs_layout.addWidget(surrounding_info)

        prefs_layout.addSpacing(10)

        # --- Batch Translation ---
        batch_header = QLabel("<b>Batch Translation:</b>")
        prefs_layout.addWidget(batch_header)

        batch_size_layout = QHBoxLayout()
        batch_size_label = QLabel("Batch size:")
        batch_size_layout.addWidget(batch_size_label)
        batch_size_spin = QSpinBox()
        batch_size_spin.setMinimum(1)
        batch_size_spin.setMaximum(500)
        batch_size_spin.setValue(batch_size)
        batch_size_spin.setToolTip("Larger batches = faster but higher API cost per call")
        batch_size_layout.addWidget(batch_size_spin)
        batch_size_layout.addWidget(QLabel("segments per API call"))
        batch_size_layout.addStretch()
        prefs_layout.addLayout(batch_size_layout)
        batch_size_info = QLabel("  ⓘ Larger batches = faster but higher API cost. Default: 20")
        batch_size_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
        prefs_layout.addWidget(batch_size_info)

        prefs_layout.addSpacing(5)

        full_context_cb = CheckmarkCheckBox("Include surrounding context in batch translation")
        full_context_cb.setChecked(use_full_context)
        prefs_layout.addWidget(full_context_cb)

        # Context window size slider
        context_window_size = general_prefs.get('context_window_size', 50)
        context_layout = QHBoxLayout()
        context_label = QLabel("  Context window:")
        context_layout.addWidget(context_label)
        context_slider = QSlider(Qt.Orientation.Horizontal)
        context_slider.setMinimum(0)
        context_slider.setMaximum(200)
        context_slider.setValue(context_window_size)
        context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        context_slider.setTickInterval(25)
        context_slider.setEnabled(use_full_context)
        context_layout.addWidget(context_slider)
        context_value_label = QLabel(f"{context_window_size} segments")
        context_value_label.setMinimumWidth(100)
        context_layout.addWidget(context_value_label)
        context_layout.addStretch()
        prefs_layout.addLayout(context_layout)

        # Update label when slider changes
        def update_context_label(value):
            if value == 0:
                context_value_label.setText("0 (disabled)")
            else:
                total_segs = len(self.current_project.segments) if self.current_project else 300
                context_segs = min(value * 2 + batch_size_spin.value(), total_segs)
                percentage = int((context_segs / total_segs) * 100) if total_segs > 0 else 0
                context_value_label.setText(f"{value} seg (~{percentage}% of doc)")

        context_slider.valueChanged.connect(update_context_label)
        full_context_cb.stateChanged.connect(lambda: context_slider.setEnabled(full_context_cb.isChecked()))
        update_context_label(context_window_size)

        prefs_layout.addSpacing(10)

        # --- Translation Memory ---
        tm_header = QLabel("<b>Translation Memory:</b>")
        prefs_layout.addWidget(tm_header)

        # Get current settings
        check_tm_exact_only = general_prefs.get('check_tm_exact_only', False)

        # TM check before AI - applies to both single and batch
        tm_check_label = QLabel("Check TM before calling AI (saves API costs):")
        prefs_layout.addWidget(tm_check_label)

        # Radio buttons for TM check mode
        tm_no_check_rb = CheckmarkRadioButton("Don't check TM - always call AI")
        tm_fuzzy_rb = CheckmarkRadioButton("Check TM first (including fuzzy matches)")
        tm_exact_rb = CheckmarkRadioButton("Check TM first (only 100% matches - faster)")

        # Set initial state
        if not check_tm_before_api:
            tm_no_check_rb.setChecked(True)
        elif check_tm_exact_only:
            tm_exact_rb.setChecked(True)
        else:
            tm_fuzzy_rb.setChecked(True)

        prefs_layout.addWidget(tm_no_check_rb)
        prefs_layout.addWidget(tm_fuzzy_rb)
        prefs_layout.addWidget(tm_exact_rb)

        prefs_layout.addSpacing(5)

        # Auto-propagate 100% TM matches
        auto_propagate_cb = CheckmarkCheckBox("Auto-propagate 100% TM matches")
        auto_propagate_cb.setChecked(auto_propagate_100)
        prefs_layout.addWidget(auto_propagate_cb)

        prefs_layout.addSpacing(5)

        # TM/Termbase lookup delay
        lookup_delay = general_prefs.get('lookup_delay', 1500)
        delay_layout = QHBoxLayout()
        delay_label = QLabel("TM/Termbase lookup delay:")
        delay_layout.addWidget(delay_label)
        delay_spin = QSpinBox()
        delay_spin.setMinimum(0)
        delay_spin.setMaximum(10000)
        delay_spin.setSingleStep(100)
        delay_spin.setValue(lookup_delay)
        delay_spin.setSuffix(" ms")
        delay_layout.addWidget(delay_spin)
        delay_layout.addStretch()
        prefs_layout.addLayout(delay_layout)
        delay_info = QLabel("  ⓘ Prevents searches while navigating quickly. Default: 1500ms")
        delay_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
        prefs_layout.addWidget(delay_info)

        prefs_layout.addSpacing(10)

        # --- Document Context (for AI Prompts) ---
        ql_context_label = QLabel("<b>Document Context (for QuickLauncher AI prompts):</b>")
        prefs_layout.addWidget(ql_context_label)

        ql_context_percent = general_prefs.get('quicklauncher_context_percent',
                                               general_prefs.get('quickmenu_context_percent', 50))
        ql_context_layout = QHBoxLayout()
        ql_context_layout.addWidget(QLabel("  Document context size:"))
        quicklauncher_context_slider = QSlider(Qt.Orientation.Horizontal)
        quicklauncher_context_slider.setMinimum(0)
        quicklauncher_context_slider.setMaximum(100)
        quicklauncher_context_slider.setValue(ql_context_percent)
        quicklauncher_context_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        quicklauncher_context_slider.setTickInterval(10)
        ql_context_layout.addWidget(quicklauncher_context_slider)
        ql_context_value_label = QLabel(f"{ql_context_percent}%")
        ql_context_value_label.setMinimumWidth(60)
        ql_context_layout.addWidget(ql_context_value_label)
        ql_context_layout.addStretch()
        prefs_layout.addLayout(ql_context_layout)

        ql_context_info = QLabel(
            "  ⓘ When using {{SOURCE+TARGET_CONTEXT}} or {{SOURCE_CONTEXT}} placeholders in QuickLauncher prompts.\n"
            "  0% = disabled, 50% = half the document (default), 100% = entire document.\n"
            "  Limit: maximum 100 segments for performance."
        )
        ql_context_info.setStyleSheet("font-size: 9pt; color: #666; padding-left: 20px;")
        ql_context_info.setWordWrap(True)
        prefs_layout.addWidget(ql_context_info)

        def update_ql_context_label(value):
            if value == 0:
                ql_context_value_label.setText("0% (disabled)")
            else:
                ql_context_value_label.setText(f"{value}%")

        quicklauncher_context_slider.valueChanged.connect(update_ql_context_label)

        prefs_group.setLayout(prefs_layout)
        layout.addWidget(prefs_group)
        
        # ========== SECTION 6: AI Behavior Settings ==========
        behavior_group = QGroupBox("🎯 AI Behavior Settings")
        behavior_layout = QVBoxLayout()
        
        # LLM matching toggle
        llm_matching_cb = CheckmarkCheckBox("Enable LLM (AI) matching on segment selection")
        llm_matching_cb.setChecked(self.enable_llm_matching)
        llm_matching_cb.setToolTip(
            "⚠️ WARNING: LLM translations take 10-20 seconds per segment!\n\n"
            "When enabled, AI translations are automatically generated when you select a segment.\n"
            "RECOMMENDED: Keep DISABLED. Use 'Translate with AI' button instead."
        )
        behavior_layout.addWidget(llm_matching_cb)
        self.llm_matching_checkbox = llm_matching_cb
        
        # Auto-generate markdown
        auto_markdown_cb = CheckmarkCheckBox("Auto-generate markdown for imported documents")
        auto_markdown_cb.setChecked(general_settings.get('auto_generate_markdown', False))
        auto_markdown_cb.setToolTip(
            "Automatically convert imported documents to markdown format\n"
            "for AI Assistant access in user_data_private/ai_assistant/current_document/"
        )
        behavior_layout.addWidget(auto_markdown_cb)
        
        # LLM match limits
        behavior_layout.addSpacing(10)
        llm_limits_label = QLabel("<b>LLM Match Limits:</b>")
        behavior_layout.addWidget(llm_limits_label)
        
        current_limits = general_settings.get('match_limits', {"LLM": 3, "MT": 3, "TM": 5, "Termbases": 10})
        
        llm_limit_layout = QHBoxLayout()
        llm_limit_layout.addWidget(QLabel("🧠 Maximum LLM matches to display:"))
        llm_spin = QSpinBox()
        llm_spin.setRange(1, 10)
        llm_spin.setValue(current_limits.get("LLM", 3))
        llm_spin.setSuffix(" matches")
        llm_limit_layout.addWidget(llm_spin)
        llm_limit_layout.addStretch()
        behavior_layout.addLayout(llm_limit_layout)
        
        behavior_group.setLayout(behavior_layout)
        layout.addWidget(behavior_group)

        # ========== SAVE BUTTON ==========
        save_btn = QPushButton("💾 Save AI Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px; outline: none;")
        save_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        save_btn.clicked.connect(lambda: self._save_ai_settings_from_ui(
            openai_radio, claude_radio, gemini_radio, ollama_radio,
            openai_combo, claude_combo, gemini_combo,
            tab.ollama_status_widget,
            openai_enable_cb, claude_enable_cb, gemini_enable_cb, ollama_enable_cb,
            batch_size_spin, surrounding_spin, full_context_cb, context_slider,
            tm_no_check_rb, tm_fuzzy_rb, tm_exact_rb, auto_propagate_cb, delay_spin,
            ollama_keepwarm_cb,
            llm_matching_cb, auto_markdown_cb, llm_spin,
            quicklauncher_context_slider,
            custom_radio=custom_radio, custom_endpoint_input=custom_endpoint_input,
            custom_model_input=custom_model_input, custom_enable_cb=custom_enable_cb,
            custom_profile_combo=custom_profile_combo, custom_key_input=custom_key_input,
            mistral_radio=mistral_radio, mistral_combo=mistral_combo,
            mistral_enable_cb=mistral_enable_cb,
            deepseek_radio=deepseek_radio, deepseek_combo=deepseek_combo,
            openrouter_radio=openrouter_radio, openrouter_combo=openrouter_combo,
            openrouter_enable_cb=openrouter_enable_cb
        ))
        layout.addWidget(save_btn)
        
        layout.addStretch()
        
        return tab
    
    def _create_mt_settings_tab(self):
        """Create MT Settings tab content"""
        from PyQt6.QtWidgets import QCheckBox, QGroupBox, QPushButton
        
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)
        
        enabled_providers = self.load_provider_enabled_states()
        
        # Enable/Disable MT Providers
        mt_provider_group = QGroupBox("Machine Translation Providers")
        mt_provider_layout = QVBoxLayout()
        
        mt_info = QLabel(
            "Enable or disable individual MT providers. Supervertaler will use the first available enabled provider in priority order:\n"
            "1. Google Translate, 2. DeepL, 3. Microsoft Translator, 4. Amazon Translate, 5. ModernMT, 6. MyMemory"
        )
        mt_info.setWordWrap(True)
        mt_info.setStyleSheet("font-size: 9pt; color: #666; padding: 5px;")
        mt_provider_layout.addWidget(mt_info)
        
        google_translate_enable_cb = CheckmarkCheckBox("Enable Google Translate")
        google_translate_enable_cb.setChecked(enabled_providers.get('mt_google_translate', True))
        mt_provider_layout.addWidget(google_translate_enable_cb)
        
        deepl_enable_cb = CheckmarkCheckBox("Enable DeepL")
        deepl_enable_cb.setChecked(enabled_providers.get('mt_deepl', True))
        mt_provider_layout.addWidget(deepl_enable_cb)
        
        microsoft_enable_cb = CheckmarkCheckBox("Enable Microsoft Translator")
        microsoft_enable_cb.setChecked(enabled_providers.get('mt_microsoft', True))
        mt_provider_layout.addWidget(microsoft_enable_cb)
        
        amazon_enable_cb = CheckmarkCheckBox("Enable Amazon Translate")
        amazon_enable_cb.setChecked(enabled_providers.get('mt_amazon', True))
        mt_provider_layout.addWidget(amazon_enable_cb)
        
        modernmt_enable_cb = CheckmarkCheckBox("Enable ModernMT")
        modernmt_enable_cb.setChecked(enabled_providers.get('mt_modernmt', True))
        mt_provider_layout.addWidget(modernmt_enable_cb)
        
        mymemory_enable_cb = CheckmarkCheckBox("Enable MyMemory (Free tier)")
        mymemory_enable_cb.setChecked(enabled_providers.get('mt_mymemory', True))
        mt_provider_layout.addWidget(mymemory_enable_cb)
        
        mt_provider_group.setLayout(mt_provider_layout)
        layout.addWidget(mt_provider_group)
        
        # Inline MT API Keys
        mt_api_keys_group = QGroupBox("🔑 MT API Keys")
        mt_keys_form = QFormLayout()
        mt_keys_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)

        current_keys = self.load_api_keys()
        if not hasattr(self, '_api_key_inputs'):
            self._api_key_inputs = {}

        mt_key_fields = [
            ("Google Translate:", "google_translate", "AIza...", True),
            ("DeepL:", "deepl", "your-deepl-key", True),
            ("Microsoft Translate:", "microsoft_translate", "your-azure-key", True),
            ("Microsoft Region:", "microsoft_translate_region", "global", False),
            ("Amazon Access Key:", "amazon_translate", "AKIA...", True),
            ("Amazon Secret Key:", "amazon_translate_secret", "your-secret", True),
            ("Amazon Region:", "amazon_translate_region", "us-east-1", False),
            ("ModernMT:", "modernmt", "your-modernmt-key", True),
            ("MyMemory:", "mymemory", "your-mymemory-key", True),
        ]

        for label, key_name, placeholder, is_secret in mt_key_fields:
            row = QHBoxLayout()
            key_input = QLineEdit()
            key_input.setPlaceholderText(placeholder)
            key_input.setText(current_keys.get(key_name, ''))
            if is_secret:
                key_input.setEchoMode(QLineEdit.EchoMode.Password)
            row.addWidget(key_input)

            if is_secret:
                toggle_btn = QPushButton("Show")
                toggle_btn.setFixedWidth(50)
                toggle_btn.setCheckable(True)
                toggle_btn.clicked.connect(
                    lambda checked, inp=key_input, btn=toggle_btn:
                        (inp.setEchoMode(QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password),
                         btn.setText("Hide" if checked else "Show"))
                )
                row.addWidget(toggle_btn)

            container = QWidget()
            container.setLayout(row)
            mt_keys_form.addRow(label, container)
            self._api_key_inputs[key_name] = key_input

        mt_api_keys_group.setLayout(mt_keys_form)
        layout.addWidget(mt_api_keys_group)
        
        # Save button
        save_btn = QPushButton("💾 Save MT Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_mt_settings_from_ui(
            google_translate_enable_cb, deepl_enable_cb, microsoft_enable_cb,
            amazon_enable_cb, modernmt_enable_cb, mymemory_enable_cb
        ))
        layout.addWidget(save_btn)
        
        layout.addStretch()

        return tab

    # _create_quicktrans_stub_tab and _open_quicktrans_in_sidekick
    # were removed in v1.10.10. Pre-retirement they advertised that
    # QuickTrans settings lived inside Sidekick and provided a button
    # to navigate there; with Sidekick gone the real settings widget
    # is now mounted directly in Workbench Settings → QuickTrans by
    # _create_workbench_settings_tab (TAB 5), via the canonical
    # _create_mt_quick_lookup_settings_tab() factory.

    def _create_mt_quick_lookup_settings_tab(self):
        """Create MT Quick Lookup settings tab content"""
        from PyQt6.QtWidgets import QCheckBox, QGroupBox, QPushButton, QComboBox

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        # Load current settings
        general_settings = self.load_general_settings()
        mt_quick_settings = general_settings.get('mt_quick_lookup', {})
        api_keys = self.load_api_keys()
        enabled_providers = self.load_provider_enabled_states()

        # Header info
        header_info = QLabel(
            f"⚡ <b>QuickTrans</b> - Configure which providers appear in the QuickTrans popup ({format_shortcut_for_display('Meta+Ctrl+Q') if IS_MACOS else format_shortcut_for_display('Ctrl+Alt+Q')} — system-wide hotkey, works inside Workbench too).<br>"
            "Enable MT engines and/or LLMs to get instant translation suggestions."
        )
        header_info.setTextFormat(Qt.TextFormat.RichText)
        header_info.setStyleSheet("font-size: 9pt; color: #444; padding: 10px; background-color: #E3F2FD; border-radius: 4px;")
        header_info.setWordWrap(True)
        layout.addWidget(header_info)

        # ===== MT Providers Group =====
        mt_group = QGroupBox("🌐 Machine Translation Providers")
        mt_layout = QVBoxLayout()

        mt_info = QLabel("Select which MT engines to query. Only enabled providers with valid API keys are shown.")
        mt_info.setWordWrap(True)
        mt_info.setStyleSheet("font-size: 8pt; color: #666; padding-bottom: 8px;")
        mt_layout.addWidget(mt_info)

        # MT provider checkboxes
        self._mtql_checkboxes = {}

        mt_providers = [
            ("gt", "Google Translate", "mt_google_translate", "google_translate"),
            ("dl", "DeepL", "mt_deepl", "deepl"),
            ("ms", "Microsoft Translator", "mt_microsoft", "microsoft_translate"),
            ("at", "Amazon Translate", "mt_amazon", "amazon_translate"),
            ("mmt", "ModernMT", "mt_modernmt", "modernmt"),
            ("mm", "MyMemory (Free)", "mt_mymemory", None),
        ]

        for code, name, enabled_key, api_key_name in mt_providers:
            # Check if provider is available (has API key or doesn't need one)
            has_key = api_key_name is None or bool(api_keys.get(api_key_name))
            is_enabled_globally = enabled_providers.get(enabled_key, True)

            checkbox = CheckmarkCheckBox(name)
            # Default: use global MT enabled state
            checkbox.setChecked(mt_quick_settings.get(f"mtql_{code}", is_enabled_globally and has_key))
            checkbox.setEnabled(has_key)

            if not has_key:
                checkbox.setToolTip(f"API key not configured for {name}")
                checkbox.setStyleSheet("color: #999;")
            else:
                checkbox.setToolTip(f"Include {name} in QuickTrans results")

            self._mtql_checkboxes[f"mtql_{code}"] = checkbox
            mt_layout.addWidget(checkbox)

        mt_group.setLayout(mt_layout)
        layout.addWidget(mt_group)

        # ===== LLM Providers Group =====
        llm_group = QGroupBox("🤖 AI/LLM Providers")
        llm_layout = QVBoxLayout()

        llm_info = QLabel(
            "Enable AI models for translation suggestions. LLMs may provide more context-aware translations but are slower.<br>"
            "<b>Note:</b> LLM calls cost more than MT APIs. Use sparingly for quick lookups."
        )
        llm_info.setTextFormat(Qt.TextFormat.RichText)
        llm_info.setWordWrap(True)
        llm_info.setStyleSheet("font-size: 8pt; color: #666; padding-bottom: 8px;")
        llm_layout.addWidget(llm_info)

        # LLM provider checkboxes with model selection
        self._mtql_llm_combos = {}

        llm_providers = [
            ("claude", "Claude", "claude", [
                ("claude-sonnet-4-6", "Claude Sonnet 4.6 (Recommended)"),
                ("claude-haiku-4-5-20251001", "Claude Haiku 4.5 (Fast)"),
                ("claude-opus-4-7", "Claude Opus 4.7 (Latest Premium)"),
            ]),
            ("openai", "OpenAI", "openai", [
                ("gpt-5.5", "GPT-5.5 (Recommended)"),
                ("gpt-5.4-mini", "GPT-5.4 Mini (Fast)"),
            ]),
            ("gemini", "Gemini (Google AI)", "gemini", [
                ("gemini-3.1-flash-lite", "Gemini 3.1 Flash-Lite (Recommended)"),
                ("gemini-2.5-pro", "Gemini 2.5 Pro (Premium)"),
                ("gemini-3.1-pro-preview", "Gemini 3.1 Pro (Latest)"),
                ("gemma-4-26b-a4b-it", "Gemma 4 26B MoE (Open)"),
            ]),
            ("mistral", "Mistral AI", "mistral", [
                ("mistral-large-latest", "Mistral Large (Recommended)"),
                ("mistral-small-latest", "Mistral Small (Fast)"),
            ]),
            ("deepseek", "DeepSeek", "deepseek", [
                ("deepseek-v4-pro", "DeepSeek V4 Pro (Recommended)"),
                ("deepseek-v4-flash", "DeepSeek V4 Flash (Fast)"),
            ]),
        ]

        for code, name, api_key_name, models in llm_providers:
            # Gemini key may be stored as either 'gemini' or 'google' (backward compat)
            if api_key_name == 'gemini':
                has_key = bool(api_keys.get('gemini') or api_keys.get('google'))
            else:
                has_key = bool(api_keys.get(api_key_name))

            # Container for checkbox and model combo
            llm_row = QHBoxLayout()

            checkbox = CheckmarkCheckBox(name)
            # Default: disabled (LLMs are opt-in)
            checkbox.setChecked(mt_quick_settings.get(f"mtql_{code}", False))
            checkbox.setEnabled(has_key)

            if not has_key:
                checkbox.setToolTip(f"API key not configured for {name}. Add it in AI Settings.")
                checkbox.setStyleSheet("color: #999;")
            else:
                checkbox.setToolTip(f"Include {name} translations in QuickTrans")

            self._mtql_checkboxes[f"mtql_{code}"] = checkbox
            llm_row.addWidget(checkbox)

            # Model selection combo
            model_combo = QComboBox()
            model_combo.setMinimumWidth(200)
            for model_id, model_name in models:
                model_combo.addItem(model_name, model_id)

            # Restore saved model selection
            saved_model = mt_quick_settings.get(f"mtql_{code}_model")
            if saved_model:
                idx = model_combo.findData(saved_model)
                if idx >= 0:
                    model_combo.setCurrentIndex(idx)

            model_combo.setEnabled(has_key)
            self._mtql_llm_combos[f"mtql_{code}_model"] = model_combo
            llm_row.addWidget(model_combo)

            llm_row.addStretch()
            llm_layout.addLayout(llm_row)

        # Custom OpenAI-compatible provider (shows active profile info)
        custom_row = QHBoxLayout()
        custom_cb = CheckmarkCheckBox("Custom (OpenAI-Compatible)")
        custom_cb.setChecked(mt_quick_settings.get("mtql_custom_openai", False))
        custom_cb.setToolTip("Use your custom OpenAI-compatible endpoint (configured in AI Settings)")
        self._mtql_checkboxes["mtql_custom_openai"] = custom_cb
        custom_row.addWidget(custom_cb)

        llm_settings = self.load_llm_settings()
        profile = self._get_active_custom_profile(llm_settings)
        profile_display = ""
        if profile:
            profile_display = f"{profile.get('name', 'Custom')} – {profile.get('model', '')}"
        else:
            profile_display = mt_quick_settings.get("mtql_custom_openai_model", llm_settings.get('custom_openai_model', ''))

        custom_model_edit = QLineEdit()
        custom_model_edit.setMinimumWidth(200)
        custom_model_edit.setPlaceholderText("Uses active profile from AI Settings")
        custom_model_edit.setText(profile_display)
        custom_model_edit.setReadOnly(True)
        custom_model_edit.setStyleSheet("color: #666; background: #f5f5f5;")
        self._mtql_llm_combos["mtql_custom_openai_model"] = custom_model_edit
        custom_row.addWidget(custom_model_edit)
        custom_row.addStretch()
        llm_layout.addLayout(custom_row)

        # Ollama (local LLM – always available, no API key needed)
        ollama_row = QHBoxLayout()
        ollama_cb = CheckmarkCheckBox("🖥️ Ollama (Local LLM)")
        ollama_cb.setChecked(mt_quick_settings.get("mtql_ollama", False))
        ollama_cb.setToolTip("Use a locally-running Ollama model for translation (no API key needed)")
        self._mtql_checkboxes["mtql_ollama"] = ollama_cb
        ollama_row.addWidget(ollama_cb)

        ollama_model_edit = QLineEdit()
        ollama_model_edit.setMinimumWidth(200)
        ollama_model_edit.setPlaceholderText("e.g. translategemma:12b")
        _ollama_model_saved = mt_quick_settings.get("mtql_ollama_model") or llm_settings.get('ollama_model', 'translategemma:12b')
        ollama_model_edit.setText(_ollama_model_saved)
        ollama_model_edit.setToolTip("Ollama model name – must be pulled locally (ollama pull <model>)")
        self._mtql_llm_combos["mtql_ollama_model"] = ollama_model_edit
        ollama_row.addWidget(ollama_model_edit)
        ollama_row.addStretch()
        llm_layout.addLayout(ollama_row)

        llm_group.setLayout(llm_layout)
        layout.addWidget(llm_group)

        # Save button
        save_btn = QPushButton("💾 Save QuickTrans Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(self._save_mt_quick_lookup_settings)
        layout.addWidget(save_btn)

        layout.addStretch()

        return tab

    def _save_mt_quick_lookup_settings(self):
        """Save MT Quick Lookup settings"""
        general_settings = self.load_general_settings()

        mt_quick_settings = {}

        # Save MT provider states
        for key, checkbox in self._mtql_checkboxes.items():
            mt_quick_settings[key] = checkbox.isChecked()

        # Save LLM model selections
        for key, widget in self._mtql_llm_combos.items():
            if isinstance(widget, QLineEdit):
                mt_quick_settings[key] = widget.text().strip()
            else:
                mt_quick_settings[key] = widget.currentData()

        general_settings['mt_quick_lookup'] = mt_quick_settings
        self.save_general_settings(general_settings)

        self.log("✓ QuickTrans settings saved")
        QMessageBox.information(self, "Settings Saved", "QuickTrans settings have been saved.")

    def open_mt_quick_lookup_settings(self):
        """Open Settings → ⚡ QuickTrans and bring Workbench forward.

        Called by the gear icon in the QuickTrans always-on-top popup.
        Pre-v1.10.11 this only navigated tabs, on the assumption that
        Workbench was already foreground – but the QuickTrans popup
        often fires from another app (Trados) which retains OS-level
        foreground while the always-on-top popup floats over it.
        Closing the popup and switching tabs in that state leaves
        Workbench updated but invisible behind the source app. v1.10.11
        routes through _bring_workbench_forward() so the same hammer
        chain that makes Ctrl+Alt+C / Ctrl+Alt+L reliable applies here
        too (synthetic Alt-key + AttachThreadInput + BringWindowToTop
        + SetForegroundWindow + SwitchToThisWindow).
        """
        try:
            self._bring_workbench_forward()
        except Exception:
            pass

        # Switch to Settings tab
        if hasattr(self, 'main_tabs'):
            # Find Settings tab index
            for i in range(self.main_tabs.count()):
                if "Settings" in self.main_tabs.tabText(i):
                    self.main_tabs.setCurrentIndex(i)
                    break

            # Navigate to MT Quick Lookup sub-tab
            if hasattr(self, 'settings_tabs') and hasattr(self, 'mt_quick_lookup_tab_index'):
                self.settings_tabs.setCurrentIndex(self.mt_quick_lookup_tab_index)

    def _find_autohotkey_for_settings(self):
        """Find AutoHotkey executable for settings display (doesn't modify state)"""
        # Standard installation paths
        username = os.environ.get('USERNAME', '')
        ahk_paths = [
            r"C:\Program Files\AutoHotkey\v2\AutoHotkey.exe",
            r"C:\Program Files\AutoHotkey\v2\AutoHotkey64.exe",
            r"C:\Program Files\AutoHotkey\AutoHotkey.exe",
            r"C:\Program Files (x86)\AutoHotkey\AutoHotkey.exe",
            fr"C:\Users\{username}\AppData\Local\Programs\AutoHotkey\AutoHotkey.exe",
        ]
        
        for path in ahk_paths:
            if os.path.exists(path):
                return path, 'detected'
        
        return None, None
    
    def _browse_autohotkey_for_settings(self, line_edit):
        """Browse for AutoHotkey executable and update line edit"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Locate AutoHotkey Executable",
            "C:\\Program Files",
            "Executable Files (*.exe);;All Files (*.*)"
        )
        
        if file_path:
            line_edit.setText(file_path)
    
    def _create_general_settings_tab(self):
        """Create General Settings tab content"""
        from PyQt6.QtWidgets import QCheckBox, QGroupBox, QPushButton, QLineEdit, QFileDialog
        
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)
        
        general_settings = self.load_general_settings()
        
        # Startup Settings group
        startup_group = QGroupBox("Startup Settings")
        startup_layout = QVBoxLayout()
        
        restore_last_project_cb = CheckmarkCheckBox("Restore last project on startup")
        restore_last_project_cb.setChecked(general_settings.get('restore_last_project', False))
        restore_last_project_cb.setToolTip(
            "When enabled, Supervertaler will automatically open the last project you were working on when the application starts."
        )
        startup_layout.addWidget(restore_last_project_cb)
        
        auto_open_log_cb = CheckmarkCheckBox("Auto-open log window on startup (detached)")
        auto_open_log_cb.setChecked(general_settings.get('auto_open_log', False))
        auto_open_log_cb.setToolTip(
            "When enabled, the log window will automatically open in a separate detached window when Supervertaler starts."
        )
        startup_layout.addWidget(auto_open_log_cb)
        
        startup_group.setLayout(startup_layout)
        layout.addWidget(startup_group)

        # Usage Statistics group
        stats_group = QGroupBox("Privacy")
        stats_layout = QVBoxLayout()

        # Load current setting from unified settings file
        from modules.usage_statistics import is_opted_in
        settings_path = self._get_unified_settings_path()

        usage_stats_cb = CheckmarkCheckBox("Share anonymous usage statistics (no personal data)")
        usage_stats_cb.setChecked(is_opted_in(settings_path))
        usage_stats_cb.setToolTip(
            "Sends a single anonymous ping on startup (app version, OS, Python version, locale).\n"
            "No personal data, translation content, or termbase info is ever collected."
        )

        def _on_usage_stats_toggled(checked):
            from modules.usage_statistics import set_opted_in
            set_opted_in(self._get_unified_settings_path(), checked)
            self.log(f"📊 Usage statistics {'enabled' if checked else 'disabled'}.")

        usage_stats_cb.toggled.connect(_on_usage_stats_toggled)
        stats_layout.addWidget(usage_stats_cb)

        stats_group.setLayout(stats_layout)
        layout.addWidget(stats_group)

        # Data Folder Location group
        data_folder_group = QGroupBox("📁 Data Folder Location")
        data_folder_layout = QVBoxLayout()
        
        data_folder_info = QLabel(
            "Your translation memories, termbases, prompts, and settings are stored here:"
        )
        data_folder_info.setWordWrap(True)
        data_folder_layout.addWidget(data_folder_info)
        
        # Path display with browse button
        path_row = QHBoxLayout()
        
        data_path_edit = QLineEdit()
        data_path_edit.setText(str(self.user_data_path))
        data_path_edit.setReadOnly(True)
        data_path_edit.setToolTip("Current data folder location")
        path_row.addWidget(data_path_edit)
        
        change_path_btn = QPushButton("Change...")
        change_path_btn.setToolTip("Choose a different location for your data folder")
        
        def change_data_path():
            """Let user change their data folder location."""
            from PyQt6.QtWidgets import QMessageBox
            
            folder = QFileDialog.getExistingDirectory(
                self, 
                "Choose New Data Folder Location",
                str(Path.home())
            )
            if folder:
                folder_path = Path(folder)
                # Append "Supervertaler" if user didn't include it
                if folder_path.name != "Supervertaler":
                    folder_path = folder_path / "Supervertaler"
                
                # Warn if switching to a folder with existing data
                if folder_path.exists() and folder_path != self.user_data_path:
                    has_content = any(folder_path.iterdir())
                    if has_content:
                        reply = QMessageBox.question(
                            self,
                            "Existing Data Found",
                            f"The folder already contains data:\n{folder_path}\n\n"
                            "Do you want to use this existing data?",
                            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
                        )
                        if reply != QMessageBox.StandardButton.Yes:
                            return
                
                # Create the folder
                folder_path.mkdir(parents=True, exist_ok=True)
                
                # Ask if they want to copy existing data
                if self.user_data_path.exists() and self.user_data_path != folder_path:
                    reply = QMessageBox.question(
                        self,
                        "Copy Existing Data?",
                        f"Would you like to copy your existing data to the new location?\n\n"
                        f"From: {self.user_data_path}\n"
                        f"To: {folder_path}\n\n"
                        "This will NOT delete the original data.",
                        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                    )
                    if reply == QMessageBox.StandardButton.Yes:
                        import shutil
                        try:
                            for item in self.user_data_path.iterdir():
                                dest = folder_path / item.name
                                if item.is_dir():
                                    shutil.copytree(item, dest, dirs_exist_ok=True)
                                else:
                                    shutil.copy2(item, dest)
                            self.log(f"✅ Copied data to new location")
                        except Exception as e:
                            QMessageBox.warning(self, "Copy Error", f"Failed to copy data: {e}")
                
                # Save the new path
                save_user_data_path(folder_path)
                data_path_edit.setText(str(folder_path))
                
                # Update and reinitialize
                self.user_data_path = folder_path
                self._reinitialize_with_new_data_path()
                
                QMessageBox.information(
                    self,
                    "Data Folder Changed",
                    f"Data folder changed to:\n{folder_path}\n\n"
                    "All managers have been reinitialized."
                )
        
        change_path_btn.clicked.connect(change_data_path)
        path_row.addWidget(change_path_btn)
        
        open_folder_btn = QPushButton("Open")
        open_folder_btn.setToolTip("Open this folder in your file manager")
        open_folder_btn.clicked.connect(lambda: open_file(str(self.user_data_path)))
        path_row.addWidget(open_folder_btn)
        
        data_folder_layout.addLayout(path_row)
        
        data_folder_tip = QLabel(
            "💡 This folder persists across updates. Back it up regularly!"
        )
        data_folder_tip.setStyleSheet("color: #666; font-size: 9pt;")
        data_folder_layout.addWidget(data_folder_tip)
        
        data_folder_group.setLayout(data_folder_layout)
        layout.addWidget(data_folder_group)

        # Sound Effects group
        sound_group = QGroupBox("🔊 Sound Effects")
        sound_layout = QVBoxLayout()

        sound_effects_cb = CheckmarkCheckBox("Enable minimalist sound effects")
        sound_effects_cb.setChecked(general_settings.get('enable_sound_effects', False))
        sound_effects_cb.setToolTip(
            "Plays a subtle system beep for certain operations (e.g. termbase creation, match insertion).\n"
            "Disable if you prefer a silent workflow."
        )
        sound_layout.addWidget(sound_effects_cb)

        # Per-event sound mapping (Windows system beeps)
        sound_effects_map = general_settings.get('sound_effects_map', {})
        sound_choices = [
            ("(None)", "none"),
            ("OK", "ok"),
            ("Asterisk", "asterisk"),
            ("Exclamation", "exclamation"),
            ("Hand", "hand"),
            ("Question", "question"),
            ("Windows Proximity Notification (.wav)", "windows_proximity_wav"),
            ("Windows Restore (.wav)", "windows_restore_wav"),
            ("Windows Navigation Start (.wav)", "windows_navigation_start_wav"),
            ("Speech Disambiguation (.wav)", "speech_disambiguation_wav"),
            ("Windows Startup (.wav)", "windows_startup_wav"),
        ]

        def make_sound_combo(default_key: str) -> QComboBox:
            combo = QComboBox()
            for label, key in sound_choices:
                combo.addItem(label, key)
            # Set initial
            idx = combo.findData(default_key)
            if idx >= 0:
                combo.setCurrentIndex(idx)
            return combo

        event_rows = QFormLayout()
        event_rows.setContentsMargins(0, 0, 0, 0)
        event_rows.setSpacing(6)

        # Store combos so save button can persist them
        sound_event_combos = {}

        # Glossary entry added (term pair)
        default_term_added = sound_effects_map.get('glossary_term_added', 'asterisk')
        sound_event_combos['glossary_term_added'] = make_sound_combo(default_term_added)
        event_rows.addRow("Termbase entry added:", sound_event_combos['glossary_term_added'])

        # Glossary created (collection)
        default_glossary_created = sound_effects_map.get('glossary_created', 'asterisk')
        sound_event_combos['glossary_created'] = make_sound_combo(default_glossary_created)
        event_rows.addRow("Termbase created:", sound_event_combos['glossary_created'])

        # Compare Panel match inserted/replaced
        default_match_inserted = sound_effects_map.get('match_inserted', 'ok')
        sound_event_combos['match_inserted'] = make_sound_combo(default_match_inserted)
        event_rows.addRow("Match inserted/replaced:", sound_event_combos['match_inserted'])

        # Glossary entry duplicate
        default_term_duplicate = sound_effects_map.get('glossary_term_duplicate', 'exclamation')
        sound_event_combos['glossary_term_duplicate'] = make_sound_combo(default_term_duplicate)
        event_rows.addRow("Termbase entry duplicate:", sound_event_combos['glossary_term_duplicate'])

        # Glossary add error
        default_term_error = sound_effects_map.get('glossary_term_error', 'hand')
        sound_event_combos['glossary_term_error'] = make_sound_combo(default_term_error)
        event_rows.addRow("Termbase add error:", sound_event_combos['glossary_term_error'])

        # Segment confirmed (Ctrl+Enter)
        default_segment_confirmed = sound_effects_map.get('segment_confirmed', 'none')
        sound_event_combos['segment_confirmed'] = make_sound_combo(default_segment_confirmed)
        event_rows.addRow("Segment confirmed:", sound_event_combos['segment_confirmed'])

        # 100% TM match found
        default_tm_100_match = sound_effects_map.get('tm_100_percent_match', 'none')
        sound_event_combos['tm_100_percent_match'] = make_sound_combo(default_tm_100_match)
        event_rows.addRow("100% TM match alert:", sound_event_combos['tm_100_percent_match'])

        # Fuzzy TM match found (< 100%)
        default_tm_fuzzy_match = sound_effects_map.get('tm_fuzzy_match', 'none')
        sound_event_combos['tm_fuzzy_match'] = make_sound_combo(default_tm_fuzzy_match)
        event_rows.addRow("Fuzzy TM match found:", sound_event_combos['tm_fuzzy_match'])

        sound_layout.addLayout(event_rows)

        sound_note = QLabel("💡 Uses Windows system beeps (no audio files).")
        sound_note.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        sound_layout.addWidget(sound_note)

        sound_group.setLayout(sound_layout)
        layout.addWidget(sound_group)

        # Auto Backup Settings group
        backup_group = QGroupBox("💾 Auto Backup Settings")
        backup_layout = QVBoxLayout()

        backup_info = QLabel(
            "Supervertaler can automatically back up your project at regular intervals\n"
            "to prevent data loss. Both a project.json and TMX file will be saved."
        )
        backup_info.setWordWrap(True)
        backup_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        backup_layout.addWidget(backup_info)

        # Enable auto backup checkbox
        enable_backup_cb = CheckmarkCheckBox("Enable automatic backups")
        enable_backup_cb.setChecked(general_settings.get('enable_auto_backup', True))
        enable_backup_cb.setToolTip(
            "When enabled, Supervertaler will automatically save your project and export\n"
            "a TMX file at the configured interval. This helps prevent data loss."
        )
        backup_layout.addWidget(enable_backup_cb)

        # Backup interval spinner
        interval_layout = QHBoxLayout()
        interval_label = QLabel("Backup interval:")
        interval_layout.addWidget(interval_label)

        backup_interval_spin = QSpinBox()
        backup_interval_spin.setRange(1, 60)
        backup_interval_spin.setValue(general_settings.get('backup_interval_minutes', 5))
        backup_interval_spin.setSuffix(" minutes")
        backup_interval_spin.setToolTip(
            "Set how often Supervertaler should automatically save your project.\n"
            "Default: 5 minutes. Range: 1-60 minutes."
        )
        interval_layout.addWidget(backup_interval_spin)
        interval_layout.addStretch()
        backup_layout.addLayout(interval_layout)

        backup_group.setLayout(backup_layout)
        layout.addWidget(backup_group)

        # Store references for saving
        self.enable_backup_cb = enable_backup_cb
        self.backup_interval_spin = backup_interval_spin

        # TM and glossary settings section
        tm_termbase_group = QGroupBox("TM and termbase settings")
        tm_termbase_layout = QVBoxLayout()
        
        # Auto-propagate exact TM matches
        auto_propagate_cb = CheckmarkCheckBox("Auto-propagate exact TM matches (100%)")
        auto_propagate_cb.setChecked(general_settings.get('auto_propagate_exact_matches', True))
        auto_propagate_cb.setToolTip(
            "Automatically fill target with 100% TM matches when a segment is selected and empty.\n"
            "This saves time by applying exact matches without manual confirmation."
        )
        tm_termbase_layout.addWidget(auto_propagate_cb)
        
        # Auto-insert 100% matches on segment selection
        auto_insert_100_cb = CheckmarkCheckBox("Auto-insert 100% TM matches when selecting segment")
        auto_insert_100_cb.setChecked(general_settings.get('auto_insert_100_percent_matches', True))
        auto_insert_100_cb.setToolTip(
            "When enabled, 100% TM matches are automatically inserted into the target field\n"
            "as soon as you select a segment (even if target is not empty).\n"
            "Like memoQ's auto-propagation feature for exact matches."
        )
        tm_termbase_layout.addWidget(auto_insert_100_cb)
        
        # Auto-confirm 100% matches when navigating with Ctrl+Enter
        auto_confirm_100_cb = CheckmarkCheckBox(
            f"Auto-confirm 100% TM matches when navigating ({format_shortcut_for_display('Ctrl+Enter')})"
        )
        auto_confirm_100_cb.setChecked(general_settings.get('auto_confirm_100_percent_matches', False))
        auto_confirm_100_cb.setToolTip(
            f"When enabled, {format_shortcut_for_display('Ctrl+Enter')} will automatically insert, confirm, and skip past\n"
            "segments that have 100% TM matches. This speeds up workflow by automatically\n"
            "handling perfect matches while navigating through unconfirmed segments."
        )
        tm_termbase_layout.addWidget(auto_confirm_100_cb)
        
        # Sub-option: Allow overwriting existing translations
        auto_confirm_overwrite_cb = CheckmarkCheckBox("    ↳ Also overwrite existing translations with 100% TM matches")
        auto_confirm_overwrite_cb.setChecked(general_settings.get('auto_confirm_overwrite_existing', False))
        auto_confirm_overwrite_cb.setToolTip(
            "When enabled, auto-confirm will also replace existing target content\n"
            "(including pre-translations or machine translations) with 100% TM matches.\n\n"
            "Useful when you've confirmed a segment higher up, and want identical segments\n"
            "lower down to automatically use your confirmed translation."
        )
        auto_confirm_overwrite_cb.setEnabled(auto_confirm_100_cb.isChecked())
        auto_confirm_100_cb.toggled.connect(auto_confirm_overwrite_cb.setEnabled)
        tm_termbase_layout.addWidget(auto_confirm_overwrite_cb)
        
        # TM Save Mode
        tm_save_label = QLabel("TM Save Mode:")
        tm_save_mode_combo = QComboBox()
        tm_save_mode_combo.addItem("Save all translations (with timestamps)", "all")
        tm_save_mode_combo.addItem("Save only latest translation (overwrite)", "latest")
        current_tm_mode = general_settings.get('tm_save_mode', 'latest')
        tm_save_mode_combo.setCurrentIndex(0 if current_tm_mode == 'all' else 1)
        tm_save_mode_combo.setToolTip(
            "All translations: Keeps all versions of translations for the same source segment with timestamps.\n"
            "The system will prefer the most recent translation when showing matches.\n\n"
            "Latest only: Overwrites old translations - only the most recent translation is kept.\n"
            "This prevents the TM from growing with old/obsolete translations."
        )
        tm_save_layout_h = QHBoxLayout()
        tm_save_layout_h.addWidget(tm_save_label)
        tm_save_layout_h.addWidget(tm_save_mode_combo)
        tm_save_layout_h.addStretch()
        tm_termbase_layout.addLayout(tm_save_layout_h)
        
        # TM/Glossary matching toggle
        tm_matching_cb = CheckmarkCheckBox("Enable TM && Termbase Matching")
        tm_matching_cb.setChecked(self.enable_tm_matching)  # Load current state
        tm_matching_cb.setToolTip(
            "When enabled, TM and termbase searches are performed automatically\n"
            "when you select a segment (after a 1.5 second delay). Disable to improve performance\n"
            "when navigating quickly through segments."
        )
        tm_matching_cb.toggled.connect(self.toggle_tm_termbase_matching)
        tm_termbase_layout.addWidget(tm_matching_cb)
        
        # Glossary grid highlighting toggle
        tb_highlight_cb = CheckmarkCheckBox("Highlight termbase matches in source cells")
        tb_highlight_cb.setChecked(general_settings.get('enable_termbase_grid_highlighting', True))
        tb_highlight_cb.setToolTip(
            "When enabled, termbase matches are highlighted with colored backgrounds in the source column.\n"
            "Higher priority terms are shown with darker blue, lower priority with lighter blue.\n"
            "This provides visual feedback similar to memoQ's termbase highlighting."
        )
        tm_termbase_layout.addWidget(tb_highlight_cb)

        # Hide shorter matches checkbox
        tb_hide_shorter_cb = CheckmarkCheckBox("Hide shorter termbase matches included in longer ones")
        tb_hide_shorter_cb.setChecked(general_settings.get('termbase_hide_shorter_matches', False))
        tb_hide_shorter_cb.setToolTip(
            "When enabled, shorter terms that are fully contained within longer matched terms are hidden.\n\n"
            "Example: If both 'cooling' and 'cooling system' match, only 'cooling system' is shown.\n"
            "This reduces clutter in the translation results panel."
        )
        tm_termbase_layout.addWidget(tb_hide_shorter_cb)

        self.tm_matching_checkbox = tm_matching_cb  # Store reference for updates
        self.auto_propagate_checkbox = auto_propagate_cb  # Store reference for updates
        self.auto_insert_100_checkbox = auto_insert_100_cb  # Store reference for updates
        self.tb_highlight_checkbox = tb_highlight_cb  # Store reference for updates

        tm_termbase_group.setLayout(tm_termbase_layout)
        layout.addWidget(tm_termbase_group)

        # Editor Settings group
        editor_group = QGroupBox("✍️ Editor Settings")
        editor_layout = QVBoxLayout()

        # Smart word selection toggle
        smart_selection_cb = CheckmarkCheckBox("Enable smart word selection")
        smart_selection_cb.setChecked(general_settings.get('enable_smart_word_selection', True))
        smart_selection_cb.setToolTip(
            "When enabled, selecting part of a word automatically expands to the full word.\n\n"
            "Example: Selecting 'ductiv' in 'productivity' will automatically select the full word 'productivity'.\n\n"
            "This makes word selection faster and less stressful during translation.\n"
            "Works with hyphens (self-contained) and apostrophes (don't, l'homme)."
        )
        editor_layout.addWidget(smart_selection_cb)
        self.smart_selection_checkbox = smart_selection_cb  # Store reference

        editor_group.setLayout(editor_layout)
        layout.addWidget(editor_group)

        # Global Hotkeys section has been moved to the Keyboard Shortcuts tab
        # (see modules/keyboard_shortcuts_widget.py)

        # Find & Replace settings group
        find_replace_group = QGroupBox("Find && Replace Settings")
        find_replace_layout = QVBoxLayout()
        
        allow_replace_cb = CheckmarkCheckBox("Allow Replace in Source Text")
        allow_replace_cb.setChecked(self.allow_replace_in_source)
        allow_replace_cb.setToolTip(
            "⚠️ WARNING: Enabling this allows replacing text in the source column.\n"
            "This can be dangerous as it modifies your original source text.\n"
            "Use with extreme caution!"
        )
        find_replace_layout.addWidget(allow_replace_cb)
        
        # Add warning label
        warning_label = QLabel(
            "⚠️ <b>Warning:</b> Replacing in source text modifies your original content.\n"
            "This feature is disabled by default for safety."
        )
        warning_label.setWordWrap(True)
        warning_label.setStyleSheet("color: #d97706; padding: 10px; border-radius: 3px;")
        find_replace_layout.addWidget(warning_label)
        
        find_replace_group.setLayout(find_replace_layout)
        layout.addWidget(find_replace_group)
        
        # Precision Scroll Settings group
        scroll_group = QGroupBox("⬆️⬇️ Precision Scroll Settings")
        scroll_layout = QVBoxLayout()
        
        scroll_info = QLabel(
            "Configure how much the precision scroll buttons (▲▼) move the grid.\n"
            "Higher values = finer increments (smaller movements)."
        )
        scroll_info.setWordWrap(True)
        scroll_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        scroll_layout.addWidget(scroll_info)
        
        # Scroll increment slider/spinbox
        scroll_control_layout = QHBoxLayout()
        scroll_label = QLabel("Scroll precision:")
        scroll_control_layout.addWidget(scroll_label)
        
        precision_spin = QSpinBox()
        precision_spin.setRange(1, 10)
        precision_spin.setValue(general_settings.get('precision_scroll_divisor', 3))
        precision_spin.setSuffix(" (divisor)")
        precision_spin.setToolTip(
            "Control how far each button click scrolls the grid.\n\n"
            "1 = 50 pixels (coarse)\n"
            "3 = 40 pixels (default)\n"
            "5 = 30 pixels (fine)\n"
            "10 = 5 pixels (very fine)\n\n"
            "Higher values = smaller increments = more precise control."
        )
        scroll_control_layout.addWidget(precision_spin)
        
        # Preview label
        preview_label = QLabel()
        def update_preview(value):
            if value == 1:
                preview_label.setText("↕️ Coarse (full row)")
            elif value <= 3:
                preview_label.setText("↕️ Medium (default)")
            elif value <= 6:
                preview_label.setText("↕️ Fine")
            else:
                preview_label.setText("↕️ Very fine")
        update_preview(precision_spin.value())
        precision_spin.valueChanged.connect(update_preview)
        
        scroll_control_layout.addWidget(preview_label)
        scroll_control_layout.addStretch()
        scroll_layout.addLayout(scroll_control_layout)
        
        # Auto-center active segment toggle
        auto_center_cb = CheckmarkCheckBox("Keep active segment centered (like memoQ/Trados)")
        auto_center_cb.setChecked(general_settings.get('auto_center_active_segment', False))
        auto_center_cb.setToolTip(
            "When enabled, the grid automatically scrolls to keep the currently selected segment\n"
            "centered in the viewport when you navigate between segments.\n"
            "This matches the behavior of memoQ, Trados, and other CAT tools."
        )
        scroll_layout.addWidget(auto_center_cb)
        
        scroll_group.setLayout(scroll_layout)
        layout.addWidget(scroll_group)
        
        # Store references for saving
        self.precision_spin = precision_spin
        self.auto_center_cb = auto_center_cb
        
        # 🧪 Experimental Performance group
        experimental_group = QGroupBox("🧪 Experimental Performance")
        experimental_layout = QVBoxLayout()
        
        exp_info = QLabel(
            "⚠️ These options are for testing and debugging performance.\n"
            "Use with caution - they may affect application behavior."
        )
        exp_info.setWordWrap(True)
        exp_info.setStyleSheet("color: #d97706; font-size: 9pt; padding: 5px;")
        experimental_layout.addWidget(exp_info)
        
        # Cache kill switch
        disable_cache_cb = CheckmarkCheckBox("Disable ALL caches (direct lookups every time)")
        disable_cache_cb.setChecked(general_settings.get('disable_all_caches', False))
        disable_cache_cb.setToolTip(
            "When enabled, ALL caching is bypassed:\n"
            "• Termbase cache\n"
            "• Translation matches cache\n"
            "• Prefetch system\n\n"
            "Every segment navigation will perform fresh database lookups.\n"
            "Use this to test if caching is causing issues or to measure\n"
            "baseline performance without any caching."
        )
        experimental_layout.addWidget(disable_cache_cb)
        self.disable_cache_checkbox = disable_cache_cb
        
        experimental_group.setLayout(experimental_layout)
        layout.addWidget(experimental_group)
        
        # Translation Results Match Limits group
        match_limits_group = QGroupBox("📊 Translation Results - Match Limits")
        match_limits_layout = QVBoxLayout()
        
        info_label = QLabel(
            "Configure the maximum number of matches to display for each match type.\n"
            "Reducing limits improves performance and reduces clutter."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        match_limits_layout.addWidget(info_label)
        
        # Create spinboxes for each match type
        limits_grid = QVBoxLayout()
        limits_grid.setSpacing(5)
        
        current_limits = general_settings.get('match_limits', {
            "LLM": 3,
            "MT": 3,
            "TM": 5,
            "Termbases": 10
        })
        
        # LLM limit
        llm_layout = QHBoxLayout()
        llm_layout.addWidget(QLabel("🧠 LLM (AI) matches:"))
        llm_spin = QSpinBox()
        llm_spin.setRange(1, 10)
        llm_spin.setValue(current_limits.get("LLM", 3))
        llm_spin.setSuffix(" matches")
        llm_layout.addWidget(llm_spin)
        llm_layout.addStretch()
        limits_grid.addLayout(llm_layout)
        
        # MT limit
        mt_layout = QHBoxLayout()
        mt_layout.addWidget(QLabel("🤖 MT (Machine Translation) matches:"))
        mt_spin = QSpinBox()
        mt_spin.setRange(1, 10)
        mt_spin.setValue(current_limits.get("MT", 3))
        mt_spin.setSuffix(" matches")
        mt_layout.addWidget(mt_spin)
        mt_layout.addStretch()
        limits_grid.addLayout(mt_layout)
        
        # TM limit
        tm_limit_layout = QHBoxLayout()
        tm_limit_layout.addWidget(QLabel("💾 TM (Translation Memory) matches:"))
        tm_limit_spin = QSpinBox()
        tm_limit_spin.setRange(1, 20)
        tm_limit_spin.setValue(current_limits.get("TM", 5))
        tm_limit_spin.setSuffix(" matches")
        tm_limit_layout.addWidget(tm_limit_spin)
        tm_limit_layout.addStretch()
        limits_grid.addLayout(tm_limit_layout)
        
        # Glossary limit
        tb_layout = QHBoxLayout()
        tb_layout.addWidget(QLabel("📚 Termbase matches:"))
        tb_spin = QSpinBox()
        tb_spin.setRange(1, 50)
        tb_spin.setValue(current_limits.get("Termbases", 10))
        tb_spin.setSuffix(" matches")
        tb_layout.addWidget(tb_spin)
        tb_layout.addStretch()
        limits_grid.addLayout(tb_layout)
        
        match_limits_layout.addLayout(limits_grid)
        match_limits_group.setLayout(match_limits_layout)
        layout.addWidget(match_limits_group)
        
        # Save button
        save_btn = QPushButton("💾 Save General Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_general_settings_from_ui(
            restore_last_project_cb, allow_replace_cb, auto_propagate_cb,
            llm_spin, mt_spin, tm_limit_spin, tb_spin,
            auto_open_log_cb, auto_insert_100_cb, tm_save_mode_combo, tb_highlight_cb,
            enable_backup_cb, backup_interval_spin,
            tb_hide_shorter_cb, smart_selection_cb,
            ahk_path_edit=getattr(self, 'ahk_path_edit', None),
            auto_center_cb=auto_center_cb,
            auto_confirm_100_cb=auto_confirm_100_cb,
            auto_confirm_overwrite_cb=auto_confirm_overwrite_cb,
            sound_effects_cb=sound_effects_cb,
            sound_event_combos=sound_event_combos,
            disable_cache_cb=disable_cache_cb
        ))
        layout.addWidget(save_btn)
        
        layout.addStretch()
        
        return tab
    
    def _create_view_settings_tab(self):
        """Create View Settings tab content"""
        from PyQt6.QtWidgets import QGroupBox, QPushButton, QButtonGroup, QColorDialog
        from PyQt6.QtGui import QColor
        
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)
        
        font_settings = self.load_general_settings()
        
        # Grid Text Font Settings section
        grid_group = QGroupBox("📊 Grid Text Font Settings")
        grid_layout = QVBoxLayout()
        
        grid_size_info = QLabel(
            "Set the default font family and size for the grid (source and target columns).\n"
            "You can also adjust size using View menu → Grid Text Zoom.\n"
            "If your favourite font is missing, contact the developer!"
        )
        grid_size_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        grid_size_info.setWordWrap(True)
        grid_layout.addWidget(grid_size_info)
        
        # Font family dropdown
        grid_font_family_layout = QHBoxLayout()
        grid_font_family_layout.addWidget(QLabel("Font Family:"))
        grid_font_family_combo = QComboBox()
        font_families = ["Calibri", "Segoe UI", "Arial", "Consolas", "Verdana", 
                        "Times New Roman", "Georgia", "Courier New", "Tahoma", "Trebuchet MS"]
        grid_font_family_combo.addItems(font_families)
        current_font_family = font_settings.get('grid_font_family', self.default_font_family)
        if current_font_family in font_families:
            grid_font_family_combo.setCurrentText(current_font_family)
        else:
            grid_font_family_combo.setCurrentText("Calibri")
        grid_font_family_combo.setToolTip("Select the font family for grid text")
        grid_font_family_layout.addWidget(grid_font_family_combo)
        grid_font_family_layout.addStretch()
        grid_layout.addLayout(grid_font_family_layout)
        
        # Font size spinbox
        grid_spin_layout = QHBoxLayout()
        grid_spin_layout.addWidget(QLabel("Font Size:"))
        grid_font_spin = QSpinBox()
        grid_font_spin.setMinimum(7)
        grid_font_spin.setMaximum(72)
        grid_font_spin.setValue(font_settings.get('grid_font_size', 11))
        grid_font_spin.setSuffix(" pt")
        grid_font_spin.setToolTip("Grid font size (7-72 pt)")
        grid_font_spin.setMinimumHeight(28)
        grid_font_spin.setMinimumWidth(80)
        # Fix spinbox arrow buttons - ensure both up and down work correctly
        grid_font_spin.setStyleSheet("""
            QSpinBox {
                padding-right: 20px;
            }
            QSpinBox::up-button {
                width: 20px;
                height: 14px;
            }
            QSpinBox::down-button {
                width: 20px;
                height: 14px;
            }
        """)
        grid_spin_layout.addWidget(grid_font_spin)
        grid_spin_layout.addStretch()
        grid_layout.addLayout(grid_spin_layout)
        
        # Live preview section
        preview_label = QLabel("Preview:")
        preview_label.setStyleSheet("font-weight: bold; margin-top: 8px;")
        grid_layout.addWidget(preview_label)
        
        # Preview container with border to simulate grid
        preview_container = QFrame()
        preview_container.setFrameStyle(QFrame.Shape.Box | QFrame.Shadow.Sunken)
        preview_container.setStyleSheet("border: 1px solid #ccc;")
        preview_container_layout = QVBoxLayout(preview_container)
        preview_container_layout.setContentsMargins(0, 0, 0, 0)
        preview_container_layout.setSpacing(0)
        
        # Source row (read-only style - light gray background)
        source_preview = QLabel()
        source_preview.setTextFormat(Qt.TextFormat.RichText)
        source_preview.setWordWrap(True)
        source_preview.setStyleSheet("padding: 6px; border-bottom: 1px solid #ddd;")
        source_preview.setToolTip("Source text preview")
        preview_container_layout.addWidget(source_preview)
        
        # Target row (editable style - white background)
        target_preview = QLabel()
        target_preview.setTextFormat(Qt.TextFormat.RichText)
        target_preview.setWordWrap(True)
        target_preview.setStyleSheet("padding: 6px;")
        target_preview.setToolTip("Target text preview")
        preview_container_layout.addWidget(target_preview)
        
        grid_layout.addWidget(preview_container)
        
        # Function to update preview
        def update_font_preview():
            font_family = grid_font_family_combo.currentText()
            font_size = grid_font_spin.value()
            tag_color = font_settings.get('tag_highlight_color', '#7f0001')
            
            # Sample text with a tag
            source_html = f'<span style="font-family: {font_family}; font-size: {font_size}pt;">The <span style="background-color: {tag_color};">&lt;b&gt;</span>quick<span style="background-color: {tag_color};">&lt;/b&gt;</span> brown fox jumps.</span>'
            target_html = f'<span style="font-family: {font_family}; font-size: {font_size}pt;">De <span style="background-color: {tag_color};">&lt;b&gt;</span>snelle<span style="background-color: {tag_color};">&lt;/b&gt;</span> bruine vos springt.</span>'
            
            source_preview.setText(source_html)
            target_preview.setText(target_html)
        
        # Connect signals to update preview
        grid_font_family_combo.currentTextChanged.connect(update_font_preview)
        grid_font_spin.valueChanged.connect(update_font_preview)
        
        # Initial preview update
        update_font_preview()
        
        grid_group.setLayout(grid_layout)
        layout.addWidget(grid_group)

        # Grid Display Options section
        grid_display_group = QGroupBox("📊 Grid Display Options")
        grid_display_layout = QVBoxLayout()

        grid_display_info = QLabel(
            "Configure how content is displayed in the translation grid."
        )
        grid_display_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        grid_display_info.setWordWrap(True)
        grid_display_layout.addWidget(grid_display_info)

        # Hide wrapping tags checkbox
        hide_wrapping_tags_layout = QHBoxLayout()
        hide_wrapping_tags_check = CheckmarkCheckBox("Hide outer wrapping tags in grid (e.g. <li-o>...</li-o>)")
        hide_wrapping_tags_check.setChecked(font_settings.get('hide_outer_wrapping_tags', False))
        hide_wrapping_tags_check.setToolTip(
            "When enabled, structural tags that wrap the entire segment (like <li-o>, <p>, <td>) are hidden.\n"
            "Tags are automatically restored when saving. List items show visual prefixes (1. 2. or •).\n"
            "The segment type is shown in the Type column, so outer tags are redundant.\n"
            "Inner formatting tags like <b>bold</b> are still shown."
        )
        hide_wrapping_tags_layout.addWidget(hide_wrapping_tags_check)
        hide_wrapping_tags_layout.addStretch()
        grid_display_layout.addLayout(hide_wrapping_tags_layout)

        # Status column position checkbox
        status_col_layout = QHBoxLayout()
        status_before_target_check = CheckmarkCheckBox("Show Status column between Source and Target (uncheck to move it to the far right)")
        status_before_target_check.setChecked(font_settings.get('status_column_before_target', True))
        status_before_target_check.setToolTip(
            "When enabled (default), the Status column sits between the Source and\n"
            "Target columns.\n"
            "When disabled, the Status column moves to the far right (after Target)."
        )
        status_col_layout.addWidget(status_before_target_check)
        status_col_layout.addStretch()
        grid_display_layout.addLayout(status_col_layout)

        grid_display_group.setLayout(grid_display_layout)
        layout.addWidget(grid_display_group)

        # Match Panel & Tag Colors section
        results_group = QGroupBox("📋 Match Panel && Tag Colors")
        results_layout = QVBoxLayout()
        
        results_size_info = QLabel(
            "Set font sizes for the Match Panel (TM/termbase matches) and tag colors."
        )
        results_size_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        results_size_info.setWordWrap(True)
        results_layout.addWidget(results_size_info)
        
        # Match list font size
        match_spin_layout = QHBoxLayout()
        match_spin_layout.addWidget(QLabel("Match List Font Size:"))
        match_font_spin = QSpinBox()
        match_font_spin.setMinimum(7)
        match_font_spin.setMaximum(16)
        match_font_spin.setValue(font_settings.get('results_match_font_size', 9))
        match_font_spin.setSuffix(" pt")
        match_font_spin.setToolTip("Match list font size (7-16 pt)")
        match_spin_layout.addWidget(match_font_spin)
        match_spin_layout.addStretch()
        results_layout.addLayout(match_spin_layout)
        
        # Compare boxes font size
        compare_spin_layout = QHBoxLayout()
        compare_spin_layout.addWidget(QLabel("Compare Boxes Font Size:"))
        compare_font_spin = QSpinBox()
        compare_font_spin.setMinimum(7)
        compare_font_spin.setMaximum(14)
        compare_font_spin.setValue(font_settings.get('results_compare_font_size', 9))
        compare_font_spin.setSuffix(" pt")
        compare_font_spin.setToolTip("Compare boxes font size (7-14 pt)")
        compare_spin_layout.addWidget(compare_font_spin)
        compare_spin_layout.addStretch()
        results_layout.addLayout(compare_spin_layout)
        
        # Show tags checkbox
        show_tags_layout = QHBoxLayout()
        show_tags_check = CheckmarkCheckBox("Show HTML/XML tags in match text")
        show_tags_check.setChecked(font_settings.get('results_show_tags', False))
        show_tags_check.setToolTip("When enabled, tags like <b>, <li>, etc. are displayed. When disabled, only the text content is shown.")
        show_tags_layout.addWidget(show_tags_check)
        show_tags_layout.addStretch()
        results_layout.addLayout(show_tags_layout)

        # Tag highlight color picker
        tag_color_layout = QHBoxLayout()
        tag_color_layout.addWidget(QLabel("Tag Highlight Color:"))
        
        from PyQt6.QtWidgets import QColorDialog
        from PyQt6.QtGui import QColor
        
        # Get current tag color or default to memoQ dark red
        current_color = font_settings.get('tag_highlight_color', '#7f0001')
        tag_color_btn = QPushButton()
        tag_color_btn.setFixedSize(80, 25)
        tag_color_btn.setStyleSheet(f"background-color: {current_color}; border: 1px solid #999;")
        tag_color_btn.setToolTip("Color for CAT tool tags (e.g. <b>, [uicontrol], {MQ}) in the grid and results pane")
        
        def choose_tag_color():
            # Set up preset colors in the color dialog
            preset_colors = [
                '#7f0001',  # memoQ dark red (default)
                '#996666',  # Dusty rose
                '#9AAFC7',  # Slate blue
                '#B8A9C9',  # Dusty lavender
                '#A05050',  # Muted red
                '#FFB6C1',  # Light pink (old default)
                '#FF69B4',  # Hot pink
                '#D4A5A5',  # Muted rose
            ]
            for i, preset in enumerate(preset_colors):
                QColorDialog.setCustomColor(i, QColor(preset))
            
            color = QColorDialog.getColor(QColor(current_color), self, "Choose Tag Highlight Color")
            if color.isValid():
                hex_color = color.name()
                tag_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                tag_color_btn.setProperty('selected_color', hex_color)
        
        tag_color_btn.clicked.connect(choose_tag_color)
        tag_color_btn.setProperty('selected_color', current_color)
        tag_color_layout.addWidget(tag_color_btn)
        
        # Reset tag color button
        reset_tag_color_btn = QPushButton("Reset")
        reset_tag_color_btn.setFixedSize(50, 25)
        reset_tag_color_btn.setToolTip("Reset to default memoQ red (#7f0001)")
        def reset_tag_color():
            default_color = '#7f0001'
            tag_color_btn.setStyleSheet(f"background-color: {default_color}; border: 1px solid #999;")
            tag_color_btn.setProperty('selected_color', default_color)
        reset_tag_color_btn.clicked.connect(reset_tag_color)
        tag_color_layout.addWidget(reset_tag_color_btn)
        
        tag_color_layout.addStretch()
        results_layout.addLayout(tag_color_layout)

        # Badge text color picker
        badge_text_color_layout = QHBoxLayout()
        badge_text_color_layout.addWidget(QLabel("Badge Text Color:"))
        
        # Get current badge text color or default to dark gray
        current_badge_color = font_settings.get('badge_text_color', '#333333')
        badge_text_color_btn = QPushButton()
        badge_text_color_btn.setFixedSize(80, 25)
        badge_text_color_btn.setStyleSheet(f"background-color: {current_badge_color}; border: 1px solid #999;")
        badge_text_color_btn.setToolTip("Color for match number badges in Translation Results panel")
        
        def choose_badge_text_color():
            # Set up preset colors in the color dialog
            preset_colors = [
                '#333333',  # Dark gray (default)
                '#212121',  # Material Design off-black
                '#2C2C2C',  # Charcoal
                '#37474F',  # Slate gray
                '#000000',  # Pure black
                '#444444',  # Medium gray
                '#1A1A1A',  # Near black
                '#505050',  # Lighter gray
            ]
            for i, preset in enumerate(preset_colors):
                QColorDialog.setCustomColor(i, QColor(preset))
            
            color = QColorDialog.getColor(QColor(current_badge_color), self, "Choose Badge Text Color")
            if color.isValid():
                hex_color = color.name()
                badge_text_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                badge_text_color_btn.setProperty('selected_color', hex_color)
        
        badge_text_color_btn.clicked.connect(choose_badge_text_color)
        badge_text_color_btn.setProperty('selected_color', current_badge_color)
        badge_text_color_layout.addWidget(badge_text_color_btn)
        
        # Reset badge text color button
        reset_badge_text_color_btn = QPushButton("Reset")
        reset_badge_text_color_btn.setFixedSize(50, 25)
        reset_badge_text_color_btn.setToolTip("Reset to default dark gray (#333333)")
        def reset_badge_text_color():
            default_color = '#333333'
            badge_text_color_btn.setStyleSheet(f"background-color: {default_color}; border: 1px solid #999;")
            badge_text_color_btn.setProperty('selected_color', default_color)
        reset_badge_text_color_btn.clicked.connect(reset_badge_text_color)
        badge_text_color_layout.addWidget(reset_badge_text_color_btn)
        
        badge_text_color_layout.addStretch()
        results_layout.addLayout(badge_text_color_layout)

        # Invisible character color picker
        invisible_char_color_layout = QHBoxLayout()
        invisible_char_color_layout.addWidget(QLabel("Invisible Char Color:"))

        # Get current invisible character color or default to light gray
        current_invisible_color = font_settings.get('invisible_char_color', '#999999')
        invisible_char_color_btn = QPushButton()
        invisible_char_color_btn.setFixedSize(80, 25)
        invisible_char_color_btn.setStyleSheet(f"background-color: {current_invisible_color}; border: 1px solid #999;")
        invisible_char_color_btn.setToolTip("Color for invisible character symbols (·→°↵)")

        def choose_invisible_char_color():
            color = QColorDialog.getColor(QColor(current_invisible_color), self, "Choose Invisible Character Color")
            if color.isValid():
                hex_color = color.name()
                invisible_char_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                invisible_char_color_btn.setProperty('selected_color', hex_color)

        invisible_char_color_btn.clicked.connect(choose_invisible_char_color)
        invisible_char_color_btn.setProperty('selected_color', current_invisible_color)
        invisible_char_color_layout.addWidget(invisible_char_color_btn)
        
        # Reset invisible char color button
        reset_invisible_color_btn = QPushButton("Reset")
        reset_invisible_color_btn.setFixedSize(50, 25)
        reset_invisible_color_btn.setToolTip("Reset to default gray (#999999)")
        def reset_invisible_color():
            default_color = '#999999'
            invisible_char_color_btn.setStyleSheet(f"background-color: {default_color}; border: 1px solid #999;")
            invisible_char_color_btn.setProperty('selected_color', default_color)
        reset_invisible_color_btn.clicked.connect(reset_invisible_color)
        invisible_char_color_layout.addWidget(reset_invisible_color_btn)
        
        invisible_char_color_layout.addStretch()
        results_layout.addLayout(invisible_char_color_layout)

        results_group.setLayout(results_layout)
        layout.addWidget(results_group)

        # Grid Row Colors section (memoQ-style alternating row colors)
        row_colors_group = QGroupBox("🎨 Grid Row Colors")
        row_colors_layout = QVBoxLayout()
        
        row_colors_info = QLabel(
            "Configure alternating row colors for the translation grid.\n"
            "This creates a visual distinction between rows (like memoQ)."
        )
        row_colors_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        row_colors_info.setWordWrap(True)
        row_colors_layout.addWidget(row_colors_info)
        
        # Enable alternating colors checkbox
        alt_colors_check = CheckmarkCheckBox("Enable alternating row colors")
        alt_colors_check.setChecked(font_settings.get('enable_alternating_row_colors', True))
        alt_colors_check.setToolTip("When enabled, even and odd rows have different background colors")
        row_colors_layout.addWidget(alt_colors_check)
        
        # Even row color picker (lighter/default)
        even_color_layout = QHBoxLayout()
        even_color_layout.addWidget(QLabel("Even Row Color:"))
        
        even_row_color = font_settings.get('even_row_color', '#FFFFFF')
        even_color_btn = QPushButton()
        even_color_btn.setFixedSize(80, 25)
        even_color_btn.setStyleSheet(f"background-color: {even_row_color}; border: 1px solid #999;")
        even_color_btn.setToolTip("Background color for even rows (0, 2, 4, ...)")
        
        def choose_even_color():
            color = QColorDialog.getColor(QColor(even_row_color), self, "Choose Even Row Color")
            if color.isValid():
                hex_color = color.name()
                even_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                even_color_btn.setProperty('selected_color', hex_color)
        
        even_color_btn.clicked.connect(choose_even_color)
        even_color_btn.setProperty('selected_color', even_row_color)
        even_color_layout.addWidget(even_color_btn)
        even_color_layout.addStretch()
        row_colors_layout.addLayout(even_color_layout)
        
        # Odd row color picker (darker/alternate)
        odd_color_layout = QHBoxLayout()
        odd_color_layout.addWidget(QLabel("Odd Row Color:"))
        
        odd_row_color = font_settings.get('odd_row_color', '#F0F0F0')
        odd_color_btn = QPushButton()
        odd_color_btn.setFixedSize(80, 25)
        odd_color_btn.setStyleSheet(f"background-color: {odd_row_color}; border: 1px solid #999;")
        odd_color_btn.setToolTip("Background color for odd rows (1, 3, 5, ...)")
        
        def choose_odd_color():
            color = QColorDialog.getColor(QColor(odd_row_color), self, "Choose Odd Row Color")
            if color.isValid():
                hex_color = color.name()
                odd_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                odd_color_btn.setProperty('selected_color', hex_color)
        
        odd_color_btn.clicked.connect(choose_odd_color)
        odd_color_btn.setProperty('selected_color', odd_row_color)
        odd_color_layout.addWidget(odd_color_btn)
        odd_color_layout.addStretch()
        row_colors_layout.addLayout(odd_color_layout)
        
        # Reset to defaults button
        reset_colors_btn = QPushButton("Reset to Default Colors")
        reset_colors_btn.setToolTip("Reset to white (#FFFFFF) and light gray (#F0F0F0)")
        def reset_row_colors():
            even_color_btn.setStyleSheet("background-color: #FFFFFF; border: 1px solid #999;")
            even_color_btn.setProperty('selected_color', '#FFFFFF')
            odd_color_btn.setStyleSheet("background-color: #F0F0F0; border: 1px solid #999;")
            odd_color_btn.setProperty('selected_color', '#F0F0F0')
        reset_colors_btn.clicked.connect(reset_row_colors)
        row_colors_layout.addWidget(reset_colors_btn)
        
        row_colors_group.setLayout(row_colors_layout)
        layout.addWidget(row_colors_group)
        
        # Focus Border Settings section
        focus_border_group = QGroupBox("🔵 Target Cell Focus Border")
        focus_border_layout = QVBoxLayout()
        
        focus_border_info = QLabel(
            "Customize the border that appears around the target cell when it has focus."
        )
        focus_border_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        focus_border_info.setWordWrap(True)
        focus_border_layout.addWidget(focus_border_info)
        
        # Border color picker
        border_color_layout = QHBoxLayout()
        border_color_layout.addWidget(QLabel("Border Color:"))
        
        focus_border_color = font_settings.get('focus_border_color', '#f1b79a')
        border_color_btn = QPushButton()
        border_color_btn.setFixedSize(80, 25)
        border_color_btn.setStyleSheet(f"background-color: {focus_border_color}; border: 1px solid #999;")
        border_color_btn.setToolTip("Color of the focus border around target cells")
        
        def choose_border_color():
            color = QColorDialog.getColor(QColor(focus_border_color), self, "Choose Focus Border Color")
            if color.isValid():
                hex_color = color.name()
                border_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                border_color_btn.setProperty('selected_color', hex_color)
        
        border_color_btn.clicked.connect(choose_border_color)
        border_color_btn.setProperty('selected_color', focus_border_color)
        border_color_layout.addWidget(border_color_btn)
        
        # Reset to default color button
        reset_border_color_btn = QPushButton("Reset")
        reset_border_color_btn.setFixedWidth(50)
        reset_border_color_btn.setToolTip("Reset to default blue (#2196F3)")
        def reset_border_color():
            border_color_btn.setStyleSheet("background-color: #2196F3; border: 1px solid #999;")
            border_color_btn.setProperty('selected_color', '#2196F3')
        reset_border_color_btn.clicked.connect(reset_border_color)
        border_color_layout.addWidget(reset_border_color_btn)
        border_color_layout.addStretch()
        focus_border_layout.addLayout(border_color_layout)
        
        # Border thickness spinbox
        thickness_layout = QHBoxLayout()
        thickness_layout.addWidget(QLabel("Border Thickness:"))
        border_thickness_spin = QSpinBox()
        border_thickness_spin.setMinimum(1)
        border_thickness_spin.setMaximum(10)
        border_thickness_spin.setValue(font_settings.get('focus_border_thickness', 2))
        border_thickness_spin.setSuffix(" px")
        border_thickness_spin.setMinimumWidth(90)
        border_thickness_spin.setMinimumHeight(28)
        border_thickness_spin.setButtonSymbols(QSpinBox.ButtonSymbols.UpDownArrows)
        border_thickness_spin.setToolTip("Thickness of the focus border (1-10 pixels)")
        # Fix spinbox arrow buttons - ensure both up and down work correctly
        border_thickness_spin.setStyleSheet("""
            QSpinBox {
                padding-right: 20px;
            }
            QSpinBox::up-button {
                width: 20px;
                height: 14px;
            }
            QSpinBox::down-button {
                width: 20px;
                height: 14px;
            }
        """)
        thickness_layout.addWidget(border_thickness_spin)
        thickness_layout.addStretch()
        focus_border_layout.addLayout(thickness_layout)
        
        focus_border_group.setLayout(focus_border_layout)
        layout.addWidget(focus_border_group)
        
        # TermLens placement is now controlled live from the View menu
        # ("Show TermLens above grid" / "Show TermLens below grid"), so there is
        # no longer a settings checkbox for it here.

        # Glossary Highlight Style section
        tb_highlight_group = QGroupBox("🏷️ Termbase Highlight Style")
        tb_highlight_layout = QVBoxLayout()
        
        tb_highlight_info = QLabel(
            "Choose how termbase matches are highlighted in the source text.\n"
            "Different styles offer varying levels of visual prominence."
        )
        tb_highlight_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        tb_highlight_info.setWordWrap(True)
        tb_highlight_layout.addWidget(tb_highlight_info)
        
        # Style selection radio buttons
        tb_style_layout = QVBoxLayout()
        
        # Get current setting
        current_tb_style = font_settings.get('termbase_highlight_style', 'semibold')
        
        # Background highlight (current default)
        tb_style_background = CheckmarkRadioButton("Background Color - Pastel green background (current default)")
        tb_style_background.setToolTip("Traditional highlight with pastel green background colors based on priority")
        tb_style_background.setChecked(current_tb_style == 'background')
        tb_style_layout.addWidget(tb_style_background)
        
        # Dotted underline (code editor style)
        tb_style_dotted = CheckmarkRadioButton("Dotted Underline - Subtle dotted line below text (IDE style)")
        tb_style_dotted.setToolTip("Unobtrusive dotted underline like code editors use for suggestions")
        tb_style_dotted.setChecked(current_tb_style == 'dotted')
        tb_style_layout.addWidget(tb_style_dotted)
        
        # Semibold text (typographic)
        tb_style_semibold = CheckmarkRadioButton("Semibold Text - Slightly bolder text with tinted color")
        tb_style_semibold.setToolTip("Typographic approach: text appears slightly heavier/darker without adding visual elements")
        tb_style_semibold.setChecked(current_tb_style == 'semibold')
        tb_style_layout.addWidget(tb_style_semibold)
        
        # Button group for mutual exclusion
        tb_style_group = QButtonGroup(self)
        tb_style_group.addButton(tb_style_background, 0)
        tb_style_group.addButton(tb_style_dotted, 1)
        tb_style_group.addButton(tb_style_semibold, 2)
        
        tb_highlight_layout.addLayout(tb_style_layout)
        
        # Dotted underline color picker (only shown when dotted style selected)
        dotted_color_layout = QHBoxLayout()
        dotted_color_label = QLabel("Underline Color:")
        dotted_color_layout.addWidget(dotted_color_label)
        
        dotted_underline_color = font_settings.get('termbase_dotted_color', '#808080')
        dotted_color_btn = QPushButton()
        dotted_color_btn.setFixedSize(80, 25)
        dotted_color_btn.setStyleSheet(f"background-color: {dotted_underline_color}; border: 1px solid #999;")
        dotted_color_btn.setToolTip("Color for dotted underline (soft blue-grey recommended)")
        
        def choose_dotted_color():
            color = QColorDialog.getColor(QColor(dotted_underline_color), self, "Choose Dotted Underline Color")
            if color.isValid():
                hex_color = color.name()
                dotted_color_btn.setStyleSheet(f"background-color: {hex_color}; border: 1px solid #999;")
                dotted_color_btn.setProperty('selected_color', hex_color)
        
        dotted_color_btn.clicked.connect(choose_dotted_color)
        dotted_color_btn.setProperty('selected_color', dotted_underline_color)
        dotted_color_layout.addWidget(dotted_color_btn)
        
        # Reset to default color button
        reset_dotted_btn = QPushButton("Reset")
        reset_dotted_btn.setFixedWidth(50)
        reset_dotted_btn.setToolTip("Reset to default color (#808080)")
        def reset_dotted_color():
            dotted_color_btn.setStyleSheet("background-color: #808080; border: 1px solid #999;")
            dotted_color_btn.setProperty('selected_color', '#808080')
        reset_dotted_btn.clicked.connect(reset_dotted_color)
        dotted_color_layout.addWidget(reset_dotted_btn)
        
        dotted_color_layout.addStretch()
        tb_highlight_layout.addLayout(dotted_color_layout)
        
        # Store references for save function
        self._tb_style_group = tb_style_group
        self._tb_dotted_color_btn = dotted_color_btn
        
        tb_highlight_group.setLayout(tb_highlight_layout)
        layout.addWidget(tb_highlight_group)
        
        # TermLens Font Settings section
        termlens_group = QGroupBox("🔍 TermLens Font Settings")
        termlens_layout = QVBoxLayout()
        
        termlens_info = QLabel(
            "Configure the font appearance for the TermLens inline terminology display."
        )
        termlens_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        termlens_info.setWordWrap(True)
        termlens_layout.addWidget(termlens_info)
        
        # Font family dropdown
        termlens_font_family_layout = QHBoxLayout()
        termlens_font_family_layout.addWidget(QLabel("Font Family:"))
        termlens_font_family_combo = QComboBox()
        termlens_font_family_combo.addItems(font_families)  # Same fonts as grid
        current_termlens_font_family = font_settings.get('termlens_font_family', 'Segoe UI')
        if current_termlens_font_family in font_families:
            termlens_font_family_combo.setCurrentText(current_termlens_font_family)
        else:
            termlens_font_family_combo.setCurrentText("Segoe UI")
        termlens_font_family_combo.setToolTip("Select the font family for TermLens text")
        termlens_font_family_layout.addWidget(termlens_font_family_combo)
        termlens_font_family_layout.addStretch()
        termlens_layout.addLayout(termlens_font_family_layout)
        
        # Font size spinbox
        termlens_size_layout = QHBoxLayout()
        termlens_size_layout.addWidget(QLabel("Font Size:"))
        termlens_font_spin = QSpinBox()
        termlens_font_spin.setMinimum(6)
        termlens_font_spin.setMaximum(16)
        termlens_font_spin.setValue(font_settings.get('termlens_font_size', 10))
        termlens_font_spin.setSuffix(" pt")
        termlens_font_spin.setToolTip("TermLens font size (6-16 pt)")
        termlens_font_spin.setMinimumHeight(28)
        termlens_font_spin.setMinimumWidth(80)
        # Fix spinbox arrow buttons - ensure both up and down work correctly
        termlens_font_spin.setStyleSheet("""
            QSpinBox {
                padding-right: 20px;
            }
            QSpinBox::up-button {
                width: 20px;
                height: 14px;
            }
            QSpinBox::down-button {
                width: 20px;
                height: 14px;
            }
        """)
        termlens_size_layout.addWidget(termlens_font_spin)
        termlens_size_layout.addStretch()
        termlens_layout.addLayout(termlens_size_layout)
        
        # Font weight checkbox
        termlens_bold_layout = QHBoxLayout()
        termlens_bold_check = CheckmarkCheckBox("Bold font")
        termlens_bold_check.setChecked(font_settings.get('termlens_font_bold', False))
        termlens_bold_check.setToolTip("Display TermLens text in bold")
        termlens_bold_layout.addWidget(termlens_bold_check)
        termlens_bold_layout.addStretch()
        termlens_layout.addLayout(termlens_bold_layout)
        
        termlens_group.setLayout(termlens_layout)
        layout.addWidget(termlens_group)

        # ===== Match Panel Font Settings =====
        mp_font_group = QGroupBox("📊 Match Panel Font Settings")
        mp_font_layout = QVBoxLayout()

        mp_font_info = QLabel(
            "Configure the font appearance for the TM Source and TM Target boxes in the Match Panel."
        )
        mp_font_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        mp_font_info.setWordWrap(True)
        mp_font_layout.addWidget(mp_font_info)

        # Font family dropdown
        mp_font_family_layout = QHBoxLayout()
        mp_font_family_layout.addWidget(QLabel("Font Family:"))
        mp_font_family_combo = QComboBox()
        mp_font_family_combo.addItems(font_families)
        current_mp_font_family = font_settings.get('match_panel_font_family', '')
        if current_mp_font_family and current_mp_font_family in font_families:
            mp_font_family_combo.setCurrentText(current_mp_font_family)
        else:
            # Show placeholder when using system default
            mp_font_family_combo.setCurrentIndex(0)
        mp_font_family_combo.setToolTip("Select the font family for TM Source/Target text in the Match Panel")
        mp_font_family_layout.addWidget(mp_font_family_combo)
        mp_font_family_layout.addStretch()
        mp_font_layout.addLayout(mp_font_family_layout)

        # Font size spinbox
        mp_size_layout = QHBoxLayout()
        mp_size_layout.addWidget(QLabel("Font Size:"))
        mp_font_spin = QSpinBox()
        mp_font_spin.setMinimum(6)
        mp_font_spin.setMaximum(18)
        mp_font_spin.setValue(font_settings.get('match_panel_font_size', 10))
        mp_font_spin.setSuffix(" pt")
        mp_font_spin.setToolTip("Match Panel TM font size (6-18 pt). Can also be adjusted with View → Match Panel zoom shortcuts.")
        mp_font_spin.setMinimumHeight(28)
        mp_font_spin.setMinimumWidth(80)
        mp_font_spin.setStyleSheet("""
            QSpinBox {
                padding-right: 20px;
            }
            QSpinBox::up-button {
                width: 20px;
                height: 14px;
            }
            QSpinBox::down-button {
                width: 20px;
                height: 14px;
            }
        """)
        mp_size_layout.addWidget(mp_font_spin)
        mp_size_layout.addStretch()
        mp_font_layout.addLayout(mp_size_layout)

        # Font weight checkbox
        mp_bold_layout = QHBoxLayout()
        mp_bold_check = CheckmarkCheckBox("Bold font")
        mp_bold_check.setChecked(font_settings.get('match_panel_font_bold', False))
        mp_bold_check.setToolTip("Display TM Source/Target text in bold")
        mp_bold_layout.addWidget(mp_bold_check)
        mp_bold_layout.addStretch()
        mp_font_layout.addLayout(mp_bold_layout)

        mp_font_group.setLayout(mp_font_layout)
        layout.addWidget(mp_font_group)

        # ===== Global UI Font Scale =====
        ui_scale_group = QGroupBox("🖥️ Global UI Font Scale")
        ui_scale_layout = QVBoxLayout()

        ui_scale_info = QLabel(
            "Adjust the font size for the entire application UI. Useful for Linux/macOS users where\n"
            "Qt applications may render with smaller fonts, or for high-DPI displays.\n"
            "Changes apply immediately. Default is 100%."
        )
        ui_scale_info.setWordWrap(True)
        ui_scale_layout.addWidget(ui_scale_info)

        ui_scale_row = QHBoxLayout()
        ui_scale_row.addWidget(QLabel("UI Font Scale:"))
        ui_scale_spin = QSpinBox()
        ui_scale_spin.setMinimum(50)
        ui_scale_spin.setMaximum(200)
        ui_scale_spin.setValue(font_settings.get('global_ui_font_scale', font_settings.get('settings_ui_font_scale', 100)))
        ui_scale_spin.setSuffix("%")
        ui_scale_spin.setSingleStep(10)
        ui_scale_spin.setToolTip("Scale entire application UI text (50%-200%)")
        ui_scale_spin.setMinimumHeight(28)
        ui_scale_spin.setMinimumWidth(90)
        ui_scale_spin.setStyleSheet("""
            QSpinBox {
                padding-right: 20px;
            }
            QSpinBox::up-button {
                width: 20px;
                height: 14px;
            }
            QSpinBox::down-button {
                width: 20px;
                height: 14px;
            }
        """)
        ui_scale_row.addWidget(ui_scale_spin)

        # Apply button for immediate feedback
        apply_scale_btn = QPushButton("Apply")
        apply_scale_btn.setToolTip("Apply font scale immediately")
        apply_scale_btn.clicked.connect(lambda: self._apply_global_ui_font_scale(ui_scale_spin.value()))
        ui_scale_row.addWidget(apply_scale_btn)
        
        ui_scale_row.addStretch()
        ui_scale_layout.addLayout(ui_scale_row)
        
        # Store reference for saving
        self._ui_scale_spin = ui_scale_spin
        
        ui_scale_group.setLayout(ui_scale_layout)
        layout.addWidget(ui_scale_group)

        # ===== Compact UI Chrome =====
        chrome_group = QGroupBox("📐 Compact UI Chrome")
        chrome_layout = QVBoxLayout()

        chrome_info = QLabel(
            "Reduce vertical padding on the menu bar, main tab strip, and right-panel tab "
            "strip to give the translation grid more room. Takes effect immediately."
        )
        chrome_info.setWordWrap(True)
        chrome_layout.addWidget(chrome_info)

        compact_chrome_check = CheckmarkCheckBox("Compact UI chrome (tighter menu / tab padding)")
        compact_chrome_check.setChecked(self._get_ui_chrome_compact())
        compact_chrome_check.setToolTip("Tighten padding on the menu bar and tab strips to make more room for the translation grid")
        compact_chrome_check.toggled.connect(self._apply_ui_chrome_compactness)
        chrome_layout.addWidget(compact_chrome_check)

        chrome_group.setLayout(chrome_layout)
        layout.addWidget(chrome_group)

        # Quick Reference section
        reference_group = QGroupBox("⌨️ Font Size Quick Reference")
        reference_layout = QVBoxLayout()
        
        reference_text = QLabel(
            "<b>All Zoom Controls:</b><br>"
            "View → Grid Text Zoom<br>"
            f"• {format_shortcut_for_display('Ctrl+=')} (or {format_shortcut_for_display('Ctrl+NumPad +')}) - Increase<br>"
            f"• {format_shortcut_for_display('Ctrl+-')} (or {format_shortcut_for_display('Ctrl+NumPad -')}) - Decrease<br><br>"
            "View → Translation Results Pane<br>"
            f"• {format_shortcut_for_display('Ctrl+Shift++')} ({format_shortcut_for_display('Ctrl+Shift+=')}) - Increase<br>"
            f"• {format_shortcut_for_display('Ctrl+Shift+-')} - Decrease<br>"
            "• Results Zoom Reset - Back to default (9pt)<br>"
        )
        reference_text.setTextFormat(Qt.TextFormat.RichText)
        reference_text.setWordWrap(True)
        reference_layout.addWidget(reference_text)
        
        reference_group.setLayout(reference_layout)
        layout.addWidget(reference_group)
        
        # Save button
        save_btn = QPushButton("💾 Save View Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        
        def save_view_settings_with_scale():
            # Save the UI scale setting first (only if scale actually changed)
            if hasattr(self, '_ui_scale_spin'):
                new_scale = self._ui_scale_spin.value()
                current_scale = self._get_global_ui_font_scale()
                if new_scale != current_scale:
                    self._apply_global_ui_font_scale(new_scale)
            # Then save other view settings
            self._save_view_settings_from_ui(
                grid_font_spin, match_font_spin, compare_font_spin, show_tags_check, tag_color_btn,
                alt_colors_check, even_color_btn, odd_color_btn, invisible_char_color_btn, grid_font_family_combo,
                termlens_font_family_combo, termlens_font_spin, termlens_bold_check,
                border_color_btn, border_thickness_spin, badge_text_color_btn, None,
                hide_wrapping_tags_check, status_before_target_check,
                mp_font_family_combo=mp_font_family_combo, mp_font_spin=mp_font_spin, mp_bold_check=mp_bold_check
            )

        save_btn.clicked.connect(save_view_settings_with_scale)
        layout.addWidget(save_btn)
        
        layout.addStretch()

        return tab

    def _populate_voice_commands_table(self):
        """Refresh the Voice command table in Sidekick (if open).

        Called whenever the underlying voice_command_manager has changed
        (commands added, edited, removed, or reset).
        """
        if not hasattr(self, 'voice_command_manager'):
            return
        # Refresh the in-Workbench Voice top tab's command table if
        # it's been built (it's lazy-constructed on first activation).
        # Pre-v1.10.4 this also refreshed Sidekick's voice tab; with
        # Sidekick retired only the top tab needs poking.
        voice_widget = getattr(self, '_voice_top_widget', None)
        if voice_widget is not None and hasattr(voice_widget, '_populate_table'):
            try:
                voice_widget._populate_table()
            except Exception:
                pass

    def _reset_voice_commands(self):
        """Reset voice commands to defaults"""
        reply = QMessageBox.question(
            self, "Reset Commands",
            "Reset all voice commands to defaults?\n\nThis will remove any custom commands you've added.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        
        if reply == QMessageBox.StandardButton.Yes:
            self.voice_command_manager.commands = self.voice_command_manager.DEFAULT_COMMANDS.copy()
            self.voice_command_manager.save_commands()
            self._populate_voice_commands_table()
            QMessageBox.information(self, "Reset Complete", "Voice commands have been reset to defaults.")

    def _check_ahk_installed(self) -> str:
        """Check if AutoHotkey is installed"""
        ahk_exe = self.voice_command_manager._find_ahk_executable() if hasattr(self, 'voice_command_manager') else None
        if ahk_exe:
            return "✅ AutoHotkey detected"
        else:
            return "⚠️ AutoHotkey not found - install from autohotkey.com for system commands"

    def _open_voice_scripts_folder(self):
        """Open the voice scripts folder"""
        import subprocess
        scripts_folder = self.user_data_path / "workbench" / "voice_scripts"
        scripts_folder.mkdir(parents=True, exist_ok=True)
        subprocess.Popen(['explorer', str(scripts_folder)])

    def _toggle_alwayson_listening(self):
        """Toggle always-on voice listening mode"""
        if self.voice_listener and self.voice_listener.is_listening:
            # Stop listening
            self.voice_listener.stop()
            self.voice_listener = None
            self._update_alwayson_ui("stopped")
            self.log("🔇 Always-on listening stopped")
            self.status_bar.showMessage("🔇 Always-on listening stopped", 3000)
        else:
            # Start listening
            try:
                dictation_settings = self.load_dictation_settings()
                model_name = dictation_settings.get('model', 'base')
                
                # Recognition engine setting. Legacy 'local' maps to
                # 'faster_whisper' for users upgrading from pre-Vosk versions.
                engine = dictation_settings.get('recognition_engine', 'vosk')
                if engine == 'local':
                    engine = 'faster_whisper'

                # If using API, check if we have an OpenAI key. If not,
                # fall back to Vosk (the new safe default) rather than
                # forcing the user into a dialog about installing extras.
                api_key = None
                if engine == 'api':
                    api_keys = self.load_api_keys()
                    api_key = api_keys.get('openai') or api_keys.get('openai_api_key')
                    if not api_key:
                        QMessageBox.warning(
                            self, "OpenAI API Key Required",
                            "To use OpenAI Whisper API, please set your OpenAI API key in:\n\n"
                            "Settings → AI Settings → OpenAI API Key\n\n"
                            "Falling back to Vosk (offline, free, commands-only)."
                        )
                        engine = 'vosk'
                
                # Get language setting
                lang_setting = dictation_settings.get('language', 'Auto (use project target language)')
                if lang_setting.startswith('Auto'):
                    target_lang = self.current_project.target_lang if self.current_project else 'en'
                    lang_map = {
                        'nl': 'nl', 'nl_NL': 'nl', 'nl_BE': 'nl',
                        'en': 'en', 'en_US': 'en', 'en_GB': 'en',
                        'de': 'de', 'de_DE': 'de',
                        'fr': 'fr', 'fr_FR': 'fr',
                        'es': 'es', 'es_ES': 'es',
                    }
                    lang_code = lang_map.get(target_lang, 'auto')
                else:
                    lang_code = lang_setting.lower()[:2]
                
                # Create listener. user_data_path is needed by the Vosk
                # engine to locate / download the Vosk model on demand.
                # initial_prompt + replacements bias the Whisper
                # decoder toward known brand / technical vocabulary so
                # "Supervertaler" stops being heard as "Supervertile"
                # etc. Built fresh from current settings + (optionally)
                # the active project's termbase – see
                # build_voice_initial_prompt() and
                # load_voice_vocabulary_settings(). Vosk ignores both
                # (it runs in grammar mode with a fixed phrase list).
                vocab_settings = self.load_voice_vocabulary_settings()
                self.voice_listener = ContinuousVoiceListener(
                    command_manager=self.voice_command_manager,
                    model_name=model_name,
                    language=lang_code,
                    engine=engine,
                    api_key=api_key,
                    user_data_path=str(self.user_data_path) if hasattr(self, 'user_data_path') else None,
                    mic_device=dictation_settings.get('mic_device'),
                    initial_prompt=self.build_voice_initial_prompt(),
                    replacements=vocab_settings.get('replacements', []),
                )
                
                # Set sensitivity from persisted settings (the Voice tab
                # writes this key directly via _set_dictation_keys).
                sensitivity = dictation_settings.get('alwayson_sensitivity', 'medium')
                if sensitivity in ('low', 'medium', 'high'):
                    self.voice_listener.set_sensitivity(sensitivity)
                
                # Connect signals
                self.voice_listener.speech_detected.connect(self._on_alwayson_speech)
                self.voice_listener.command_detected.connect(self._on_alwayson_command)
                self.voice_listener.text_for_dictation.connect(self._on_alwayson_dictation)
                self.voice_listener.status_update.connect(self._on_alwayson_status)
                self.voice_listener.error_occurred.connect(self._on_alwayson_error)
                self.voice_listener.vad_status_changed.connect(self._on_alwayson_vad_status)
                self.voice_listener.listening_started.connect(lambda: self._update_alwayson_ui("listening"))
                self.voice_listener.listening_stopped.connect(lambda: self._update_alwayson_ui("stopped"))
                
                # Log which engine we're using
                if engine == 'api':
                    self.log("🎧 Always-on listening started (OpenAI API – fast & accurate)")
                elif engine == 'vosk':
                    self.log("🎧 Always-on listening started (Vosk – offline, commands-only)")
                else:
                    self.log(f"🎧 Always-on listening started (faster-whisper '{model_name}')")
                
                # Start
                self.voice_listener.start()
                self.log("🎧 Always-on listening started")
                
            except Exception as e:
                import traceback
                self.log(f"❌ Failed to start always-on listening: {e}")
                self.log(traceback.format_exc())
                QMessageBox.critical(self, "Always-On Error", f"Failed to start always-on listening:\n\n{e}")

    def _update_alwayson_ui(self, status: str):
        """Update the always-on UI elements"""
        # Update settings panel UI (if visible)
        if hasattr(self, 'alwayson_status_label'):
            if status == "listening" or status == "waiting":
                self.alwayson_status_label.setText("🟢 Listening for speech...")
                self.alwayson_status_label.setStyleSheet("font-size: 9pt; padding: 5px; color: #2E7D32;")
                self.alwayson_toggle_btn.setText("⏹️ Stop Always-On Listening")
                self.alwayson_toggle_btn.setStyleSheet("padding: 8px 15px; background-color: #FFCDD2;")
            elif status == "recording":
                self.alwayson_status_label.setText("🔴 Recording...")
                self.alwayson_status_label.setStyleSheet("font-size: 9pt; padding: 5px; color: #C62828;")
            elif status == "processing":
                self.alwayson_status_label.setText("⏳ Processing...")
                self.alwayson_status_label.setStyleSheet("font-size: 9pt; padding: 5px; color: #F57C00;")
            else:  # stopped or other
                self.alwayson_status_label.setText("⚪ Not active")
                self.alwayson_status_label.setStyleSheet("font-size: 9pt; padding: 5px;")
                self.alwayson_toggle_btn.setText("▶️ Start Always-On Listening")
                self.alwayson_toggle_btn.setStyleSheet("padding: 8px 15px;")
        
        # Update status bar indicator (always visible when active)
        if hasattr(self, 'alwayson_indicator_label'):
            if status == "listening" or status == "waiting":
                self.alwayson_indicator_label.setText("🎤 ALWAYS-ON")
                self.alwayson_indicator_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #2E7D32; background-color: #C8E6C9; padding: 2px 6px; border-radius: 3px;")
                self.alwayson_indicator_label.setToolTip("Always-on voice listening ACTIVE\nClick to stop")
                self.alwayson_indicator_label.show()
            elif status == "recording":
                self.alwayson_indicator_label.setText("🔴 REC")
                self.alwayson_indicator_label.setStyleSheet("font-size: 11px; font-weight: bold; color: white; background-color: #C62828; padding: 2px 6px; border-radius: 3px;")
                self.alwayson_indicator_label.setToolTip("Recording speech...")
                self.alwayson_indicator_label.show()
            elif status == "processing":
                self.alwayson_indicator_label.setText("⏳ ...")
                self.alwayson_indicator_label.setStyleSheet("font-size: 11px; font-weight: bold; color: #F57C00; background-color: #FFF3E0; padding: 2px 6px; border-radius: 3px;")
                self.alwayson_indicator_label.setToolTip("Processing speech...")
                self.alwayson_indicator_label.show()
            else:  # stopped or other
                self.alwayson_indicator_label.hide()
        
        # Update grid toolbar button (if exists)
        if hasattr(self, 'grid_alwayson_btn'):
            if status == "listening" or status == "waiting":
                self.grid_alwayson_btn.setText("🎧 Always-On: ON")
                self.grid_alwayson_btn.setChecked(True)
                self.grid_alwayson_btn.setStyleSheet("""
                    QPushButton {
                        background-color: #2E7D32;
                        color: white;
                        font-weight: bold;
                        padding: 3px 5px;
                        border-radius: 3px;
                    }
                    QPushButton:checked {
                        background-color: #2E7D32;
                    }
                """)
            elif status == "recording":
                self.grid_alwayson_btn.setText("🔴 REC")
                self.grid_alwayson_btn.setStyleSheet("""
                    QPushButton {
                        background-color: #C62828;
                        color: white;
                        font-weight: bold;
                        padding: 3px 5px;
                        border-radius: 3px;
                    }
                    QPushButton:checked {
                        background-color: #C62828;
                    }
                """)
            elif status == "processing":
                self.grid_alwayson_btn.setText("⏳ ...")
                self.grid_alwayson_btn.setStyleSheet("""
                    QPushButton {
                        background-color: #F57C00;
                        color: white;
                        font-weight: bold;
                        padding: 3px 5px;
                        border-radius: 3px;
                    }
                    QPushButton:checked {
                        background-color: #F57C00;
                    }
                """)
            else:  # stopped or other
                self.grid_alwayson_btn.setText("🎧 Always-On: OFF")
                self.grid_alwayson_btn.setChecked(False)
                self.grid_alwayson_btn.setStyleSheet("""
                    QPushButton {
                        background-color: #757575;
                        color: white;
                        font-weight: bold;
                        padding: 3px 5px;
                        border-radius: 3px;
                    }
                    QPushButton:checked {
                        background-color: #2E7D32;
                    }
                """)

        # Update the in-Workbench Voice top tab's status indicator if
        # it has been built (lazy on first activation). Pre-v1.10.4
        # this also poked Sidekick's voice widget; Sidekick is gone.
        voice_widget = getattr(self, '_voice_top_widget', None)
        if voice_widget is not None and hasattr(voice_widget, 'set_alwayson_status'):
            try:
                voice_widget.set_alwayson_status(status)
            except Exception:
                pass

        # System tray icon – visible only while Always-On is active.
        # Gives the user a persistent "the mic is hot" signal even when
        # Workbench is hidden.
        self._update_alwayson_tray_icon(status)

    @staticmethod
    def _draw_mic_icon(color):
        """Draw a small microphone glyph as a QIcon, in the given QColor.

        Used by the Always-On tray indicator. Two visual states share the
        same shape but differ in colour: grey when Always-On is off, red
        when it's actively listening. Drawing programmatically avoids
        bundling an extra asset and keeps colour-swaps trivial.
        """
        from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush
        size = 32
        pix = QPixmap(size, size)
        pix.fill(QColor(0, 0, 0, 0))
        p = QPainter(pix)
        try:
            p.setRenderHint(QPainter.RenderHint.Antialiasing)
            qcol = QColor(color) if not isinstance(color, QColor) else color
            stroke = max(1.5, size * 0.06)

            # Mic body – vertical capsule centred horizontally
            body_w = size * 0.42
            body_h = size * 0.50
            body_x = (size - body_w) / 2
            body_y = size * 0.10
            p.setBrush(QBrush(qcol))
            p.setPen(Qt.PenStyle.NoPen)
            p.drawRoundedRect(
                int(body_x), int(body_y), int(body_w), int(body_h),
                int(body_w / 2), int(body_w / 2),
            )

            # U-shaped pickup arc cradling the body
            p.setBrush(Qt.BrushStyle.NoBrush)
            p.setPen(QPen(qcol, stroke, Qt.PenStyle.SolidLine,
                          Qt.PenCapStyle.RoundCap))
            arc_x = size * 0.16
            arc_y = size * 0.30
            arc_w = size * 0.68
            arc_h = size * 0.50
            # Qt uses 1/16ths of a degree; 0° is at 3 o'clock, going CCW.
            p.drawArc(int(arc_x), int(arc_y), int(arc_w), int(arc_h),
                      200 * 16, 140 * 16)

            # Stand (vertical) + base (horizontal)
            cx = size / 2
            p.drawLine(int(cx), int(size * 0.80), int(cx), int(size * 0.90))
            p.drawLine(int(cx - size * 0.18), int(size * 0.90),
                       int(cx + size * 0.18), int(size * 0.90))
        finally:
            p.end()
        return QIcon(pix)

    def _ensure_alwayson_tray_icon(self):
        """Lazily create the QSystemTrayIcon used as a global Always-On
        toggle and visual state indicator.

        A small microphone glyph drawn programmatically – grey when
        Always-On is off, red when actively listening. Convention follows
        OBS / Zoom / Discord (grey = ready/off, red = live).

        The icon is created once and stays visible for the lifetime of
        the Workbench window. Showing/hiding a tray slot causes Windows
        to reflow the tray (the "icons bounce" effect), so the slot
        stays allocated and only the pixmap, tooltip, and menu wording
        change as state changes.

        Single-click toggles Always-On. Right-click shows a small menu
        with the same toggle plus an "Open Voice in Sidekick"
        shortcut.
        """
        if hasattr(self, '_alwayson_tray_icon'):
            return
        from PyQt6.QtWidgets import QSystemTrayIcon, QMenu
        from PyQt6.QtGui import QColor

        if not QSystemTrayIcon.isSystemTrayAvailable():
            self._alwayson_tray_icon = None
            return

        # Material grey 600 (off) and red 800 (live).
        self._alwayson_tray_icon_normal = self._draw_mic_icon(QColor(0x75, 0x75, 0x75))
        self._alwayson_tray_icon_red = self._draw_mic_icon(QColor(0xC6, 0x28, 0x28))

        tray = QSystemTrayIcon(self._alwayson_tray_icon_normal, self)
        tray.setToolTip("Voice – Always-On is OFF. Click to start.")

        menu = QMenu(self)
        toggle_action = menu.addAction("▶ Start Always-On")
        toggle_action.triggered.connect(self._toggle_alwayson_listening)
        menu.addSeparator()
        open_action = menu.addAction("🎤 Open Voice")
        open_action.triggered.connect(self._open_voice_in_workbench)
        tray.setContextMenu(menu)
        self._alwayson_tray_toggle_action = toggle_action

        tray.activated.connect(self._on_alwayson_tray_activated)
        self._alwayson_tray_icon = tray
        tray.show()

    def _on_alwayson_tray_activated(self, reason):
        """Tray-icon click handler – single-click toggles Always-On."""
        from PyQt6.QtWidgets import QSystemTrayIcon
        if reason == QSystemTrayIcon.ActivationReason.Trigger:
            self._toggle_alwayson_listening()

    def _update_alwayson_tray_icon(self, status: str):
        """Switch the tray icon between inactive/active visuals based on
        the current listener status. The icon stays visible at all times
        – only the pixmap, tooltip, and menu wording change."""
        self._ensure_alwayson_tray_icon()
        tray = getattr(self, '_alwayson_tray_icon', None)
        if tray is None:
            return

        active = status in ("listening", "waiting", "recording", "processing")

        # Skip every tray API call when the active/inactive state hasn't changed.
        # setIcon() and setToolTip() both trigger a Windows notification-area
        # repaint that briefly hides the icon. The VAD cycle emits three status
        # changes per spoken command (recording → processing → listening), all of
        # which map to the same "active" state, so we can safely ignore them.
        if getattr(self, '_alwayson_tray_active', None) == active:
            return
        self._alwayson_tray_active = active

        tray.setIcon(
            self._alwayson_tray_icon_red if active
            else self._alwayson_tray_icon_normal
        )
        tray.setToolTip(
            "Voice – Always-On listening. Click to stop." if active
            else "Voice – Always-On is OFF. Click to start."
        )
        action = getattr(self, '_alwayson_tray_toggle_action', None)
        if action is not None:
            action.setText("⏹ Stop Always-On" if active else "▶ Start Always-On")

    def _on_alwayson_speech(self, text: str):
        """Handle raw speech from always-on listener"""
        self.log(f"🎤 Heard: {text}")

    def _on_alwayson_command(self, phrase: str, result: str):
        """Handle command execution from always-on listener"""
        self.log(f"🎤 Command: {phrase} → {result}")
        self.status_bar.showMessage(f"🎤 {result}", 3000)

    def _on_alwayson_dictation(self, text: str):
        """Handle dictation text from always-on listener (no command matched).

        Routes through the shared cross-app helper so Always-On, F9
        push-to-talk, and the Voice global hotkey all behave the same
        way regardless of which app has focus.

        Skipped entirely when the user has set Always-On to commands-only
        mode (Voice tab → "Listen for commands only"). In that mode
        unmatched speech is logged but not typed – dictation is reserved
        for the explicit push-to-talk path (hold the dictation hotkey).
        """
        try:
            settings = self.load_dictation_settings()
            if settings.get('alwayson_commands_only', False):
                self.log(f"💬 Heard (commands-only mode, not dictated): {text}")
                return
        except Exception:
            pass
        self._insert_dictated_text(text)

    def _on_alwayson_status(self, message: str):
        """Handle status updates from always-on listener"""
        self.log(message)
        if hasattr(self, 'alwayson_status_label'):
            # Keep status label updated but don't override state-based status
            pass

    def _on_alwayson_error(self, error: str):
        """Handle errors from always-on listener"""
        self.log(f"❌ Always-on error: {error}")
        QMessageBox.warning(self, "Always-On Error", f"Voice listener error:\n\n{error}")

    def _on_alwayson_vad_status(self, status: str):
        """Handle VAD status changes (waiting/recording/processing)"""
        self._update_alwayson_ui(status)

    def _toggle_alwayson_from_statusbar(self):
        """Toggle always-on listening when clicking the status bar indicator"""
        self._toggle_alwayson_listening()

    def _toggle_alwayson_from_grid_btn(self, checked: bool, btn: QPushButton):
        """Toggle always-on listening from the grid toolbar button"""
        self._toggle_alwayson_listening()
        # Button state will be updated by _update_alwayson_ui

    def _create_system_prompts_tab(self):
        """Create System Prompts (Layer 1) Settings tab content"""
        from PyQt6.QtWidgets import QGroupBox, QPushButton, QTextEdit, QComboBox

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        # Header info
        header_group = QGroupBox("📝 System Prompts (Layer 1)")
        header_layout = QVBoxLayout()

        info_label = QLabel(
            "<b>System Prompts are the foundation of Supervertaler's 2-Layer Prompt Architecture.</b><br><br>"
            "These prompts are <b>always applied</b> and contain critical infrastructure instructions:<br>"
            "• CAT tool tag preservation (memoQ, Trados, CafeTran)<br>"
            "• Formatting rules and output standards<br>"
            "• Language-specific conventions (numbers, dates, typography)<br>"
            "• Professional translation context<br><br>"
            "<i>Layer 2 (Custom Prompts) are managed in the Prompts tab and can be freely attached/detached.</i>"
        )
        info_label.setTextFormat(Qt.TextFormat.RichText)
        info_label.setWordWrap(True)
        info_label.setStyleSheet("font-size: 9pt; padding: 12px; border-radius: 3px;")
        header_layout.addWidget(info_label)

        header_group.setLayout(header_layout)
        layout.addWidget(header_group)

        # Mode selector
        mode_group = QGroupBox("🎯 Select System Prompt Mode")
        mode_layout = QVBoxLayout()

        mode_info = QLabel(
            "Supervertaler uses different system prompts for different translation modes:"
        )
        mode_info.setStyleSheet("font-size: 9pt; color: #666; padding: 8px;")
        mode_info.setWordWrap(True)
        mode_layout.addWidget(mode_info)

        mode_selector_layout = QHBoxLayout()
        mode_selector_layout.addWidget(QLabel("Mode:"))
        mode_combo = QComboBox()
        mode_combo.addItems([
            "Single Segment Translation",
            "Batch DOCX Translation",
            "Batch Bilingual Translation"
        ])
        mode_combo.setToolTip("Select which system prompt to view/edit")
        mode_selector_layout.addWidget(mode_combo)
        mode_selector_layout.addStretch()
        mode_layout.addLayout(mode_selector_layout)

        mode_group.setLayout(mode_layout)
        layout.addWidget(mode_group)

        # Editor
        editor_group = QGroupBox("✏️ Edit System Prompt")
        editor_layout = QVBoxLayout()

        editor_info = QLabel(
            "Edit the system prompt below. Use {{SOURCE_LANGUAGE}}, {{TARGET_LANGUAGE}}, and {{SOURCE_TEXT}} as placeholders."
        )
        editor_info.setStyleSheet("font-size: 8pt; padding: 8px; border-radius: 2px;")
        editor_info.setWordWrap(True)
        editor_layout.addWidget(editor_info)

        system_prompt_editor = QTextEdit()
        system_prompt_editor.setStyleSheet("font-family: 'Consolas', 'Courier New', monospace; font-size: 9pt;")
        system_prompt_editor.setMinimumHeight(500)  # Increased from 400 to 500
        system_prompt_editor.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)  # Enable word wrap
        system_prompt_editor.setAcceptRichText(False)  # Plain text only
        editor_layout.addWidget(system_prompt_editor, 1)  # Added stretch factor of 1 to allow expansion

        editor_group.setLayout(editor_layout)
        layout.addWidget(editor_group, 1)  # Added stretch factor of 1 to allow expansion

        # Buttons
        buttons_layout = QHBoxLayout()

        reset_btn = QPushButton("🔄 Reset to Default")
        reset_btn.setToolTip("Restore the default system prompt for this mode")
        reset_btn.clicked.connect(lambda: self._reset_system_prompt(mode_combo, system_prompt_editor))
        buttons_layout.addWidget(reset_btn)

        buttons_layout.addStretch()

        save_btn = QPushButton("💾 Save System Prompt")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_system_prompt_from_ui(mode_combo, system_prompt_editor))
        buttons_layout.addWidget(save_btn)

        layout.addLayout(buttons_layout)

        # Load initial prompt
        self._load_system_prompt_into_editor(mode_combo, system_prompt_editor)

        # Connect mode change to load new prompt
        mode_combo.currentIndexChanged.connect(lambda: self._load_system_prompt_into_editor(mode_combo, system_prompt_editor))

        return tab

    def _load_system_prompt_into_editor(self, mode_combo, editor):
        """Load the selected system prompt into the editor"""
        mode_map = {
            "Single Segment Translation": "single",
            "Batch DOCX Translation": "batch_docx",
            "Batch Bilingual Translation": "batch_bilingual"
        }

        selected_mode = mode_combo.currentText()
        mode_key = mode_map.get(selected_mode, "single")

        # Load from prompt_manager_qt if available
        if hasattr(self, 'prompt_manager_qt'):
            prompt_text = self.prompt_manager_qt.get_system_template(mode_key)
        else:
            # Fallback: load from JSON file
            system_prompts_file = self.user_data_path / "prompt_library" / "system_prompts_layer1.json"
            if system_prompts_file.exists():
                import json
                with open(system_prompts_file, 'r', encoding='utf-8') as f:
                    system_prompts = json.load(f)
                prompt_text = system_prompts.get(mode_key, "# SYSTEM PROMPT\n\nNo prompt defined.")
            else:
                prompt_text = "# SYSTEM PROMPT\n\nNo prompt defined."

        editor.setPlainText(prompt_text)

    def _save_system_prompt_from_ui(self, mode_combo, editor):
        """Save the edited system prompt"""
        mode_map = {
            "Single Segment Translation": "single",
            "Batch DOCX Translation": "batch_docx",
            "Batch Bilingual Translation": "batch_bilingual"
        }

        selected_mode = mode_combo.currentText()
        mode_key = mode_map.get(selected_mode, "single")
        prompt_text = editor.toPlainText()

        # Save to prompt_manager_qt if available
        if hasattr(self, 'prompt_manager_qt'):
            self.prompt_manager_qt.system_templates[mode_key] = prompt_text

        # Always save to JSON file
        import json
        system_prompts_file = self.user_data_path / "prompt_library" / "system_prompts_layer1.json"
        system_prompts_file.parent.mkdir(parents=True, exist_ok=True)

        # Load existing prompts
        if system_prompts_file.exists():
            with open(system_prompts_file, 'r', encoding='utf-8') as f:
                system_prompts = json.load(f)
        else:
            system_prompts = {}

        # Update and save
        system_prompts[mode_key] = prompt_text
        with open(system_prompts_file, 'w', encoding='utf-8') as f:
            json.dump(system_prompts, f, indent=2, ensure_ascii=False)

        self.log(f"✓ Saved system prompt: {selected_mode}")
        QMessageBox.information(
            self,
            "System Prompt Saved",
            f"System prompt for '{selected_mode}' has been saved successfully."
        )

    def _reset_system_prompt(self, mode_combo, editor):
        """Reset system prompt to default"""
        mode_map = {
            "Single Segment Translation": "single",
            "Batch DOCX Translation": "batch_docx",
            "Batch Bilingual Translation": "batch_bilingual"
        }

        selected_mode = mode_combo.currentText()
        mode_key = mode_map.get(selected_mode, "single")

        # Confirm reset
        reply = QMessageBox.question(
            self,
            "Reset System Prompt",
            f"Are you sure you want to reset the system prompt for '{selected_mode}' to its default value?\n\n"
            "This will discard any custom changes you've made.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )

        if reply == QMessageBox.StandardButton.Yes:
            # Get default from prompt_manager_qt
            if hasattr(self, 'prompt_manager_qt'):
                default_prompt = self.prompt_manager_qt._get_default_system_template(mode_key)
            else:
                default_prompt = "# SYSTEM PROMPT\n\nNo default prompt available."
            editor.setPlainText(default_prompt)
            self.log(f"✓ Reset system prompt to default: {selected_mode}")

    def _create_user_identity_tab(self):
        """Create User Identity settings tab – translator name used in file exports."""
        from PyQt6.QtWidgets import QGroupBox, QPushButton

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        settings = self.load_general_settings()

        # Header
        header = QLabel(
            "👤 <b>User Identity</b><br>"
            "<span style='color:#666;'>Configure the translator name that appears in exported files. "
            "This name is written to SDLXLIFF comments, Trados return packages, TMX files, and other output.</span>"
        )
        header.setTextFormat(Qt.TextFormat.RichText)
        header.setWordWrap(True)
        header.setStyleSheet("font-size: 9pt; padding: 10px; background-color: #E3F2FD; border-radius: 4px;")
        layout.addWidget(header)

        # Translator name group
        name_group = QGroupBox("Translator Name")
        name_layout = QVBoxLayout()

        name_info = QLabel(
            "Enter your name or alias as you want it to appear in exported files.\n"
            "If left empty, your system username will be used as a fallback."
        )
        name_info.setWordWrap(True)
        name_info.setStyleSheet("font-size: 9pt; color: #555;")
        name_layout.addWidget(name_info)

        name_layout.addSpacing(5)

        name_edit = QLineEdit()
        name_edit.setPlaceholderText("(uses system username if empty)")
        name_edit.setText(settings.get('translator_name', ''))
        name_edit.setMaxLength(100)
        name_layout.addWidget(name_edit)

        # Show current fallback
        import os as _os
        fallback = _os.environ.get('USERNAME', _os.environ.get('USER', ''))
        if fallback:
            fallback_label = QLabel(f"Current system username: <b>{fallback}</b>")
            fallback_label.setTextFormat(Qt.TextFormat.RichText)
            fallback_label.setStyleSheet("font-size: 8pt; color: #888; margin-top: 3px;")
            name_layout.addWidget(fallback_label)

        name_group.setLayout(name_layout)
        layout.addWidget(name_group)

        # Save button
        save_btn = QPushButton("💾 Save User Identity")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_user_identity_from_ui(name_edit))
        layout.addWidget(save_btn)

        layout.addStretch()
        return tab

    def _save_user_identity_from_ui(self, name_edit):
        """Save user identity settings."""
        general_settings = self.load_general_settings()
        general_settings['translator_name'] = name_edit.text().strip()
        self.save_general_settings(general_settings)

        saved_name = name_edit.text().strip()
        display = saved_name if saved_name else "(system username)"
        self.log(f"✓ User identity saved: translator name = {display}")
        QMessageBox.information(self, "Settings Saved", f"User identity saved.\nTranslator name: {display}")

    def _create_voice_settings_tab(self):
        """Create the Voice info/redirect tab.

        Voice (commands & dictation) lives in Sidekick. This
        Workbench Settings tab is just a signpost: a brief explanation, a
        quick-reference card for the hotkeys, and a button that opens
        Sidekick directly to the Voice tab.
        """
        from PyQt6.QtWidgets import QGroupBox, QPushButton

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        header = QLabel("🎤 <b>Voice</b> – commands and dictation")
        header.setTextFormat(Qt.TextFormat.RichText)
        header.setStyleSheet("font-size: 14pt; padding: 8px;")
        layout.addWidget(header)

        info = QLabel(
            "Voice is Supervertaler's command and dictation system. "
            "Its full settings panel lives in <b>Supervertaler Sidekick</b>, the "
            "floating companion window you can summon from anywhere on your "
            "computer.<br><br>"
            "<b>Why is it there and not here?</b><br>"
            "Sidekick stays accessible even when Workbench is hidden – and "
            "Voice's Always-On listening + global hotkeys are designed to "
            "work across every app on your computer (Word, Trados, memoQ, "
            "browsers, etc.), not just inside Workbench."
        )
        info.setTextFormat(Qt.TextFormat.RichText)
        info.setWordWrap(True)
        info.setStyleSheet(
            "font-size: 9pt; color: #444; padding: 12px;"
            " background-color: #E3F2FD; border-radius: 4px;"
        )
        layout.addWidget(info)

        open_btn = QPushButton("🎤  Open Voice")
        open_btn.setStyleSheet(
            "background-color: #4CAF50; color: white; font-weight: bold;"
            " padding: 12px; font-size: 11pt; border: none;"
        )
        open_btn.clicked.connect(self._open_voice_in_workbench)
        layout.addWidget(open_btn)

        quick_ref_group = QGroupBox("📖 Quick Reference")
        quick_ref_layout = QVBoxLayout()
        quick_ref = QLabel(
            "<b>Dictation hotkey</b> (default <b>Ctrl+Shift+Space</b>) – "
            "hold to dictate, release to transcribe. Works in the "
            "Workbench grid and in any other app on your computer.<br>"
            "<b>Always-On</b> – toggleable in Sidekick → Voice tab; "
            "listens continuously, hands-free.<br>"
            "<b>Voice commands</b> – say a phrase to execute keystrokes, "
            "AutoHotkey scripts, or built-in actions. Editable in Sidekick.<br><br>"
            "Rebind the dictation hotkey to any key you like "
            "(numpad+, a function key, anything) in "
            "<b>Settings → Keyboard Shortcuts → Special → Voice dictation</b>."
        )
        quick_ref.setTextFormat(Qt.TextFormat.RichText)
        quick_ref.setWordWrap(True)
        quick_ref.setStyleSheet("font-size: 9pt; color: #555; padding: 8px;")
        quick_ref_layout.addWidget(quick_ref)
        quick_ref_group.setLayout(quick_ref_layout)
        layout.addWidget(quick_ref_group)

        layout.addStretch()
        return tab

    def _open_voice_in_workbench(self):
        """Open Workbench's Voice top tab from a Settings link / tray menu.

        Method was renamed from _open_voice_in_sidekick in v1.10.10
        when Sidekick was retired. The two existing call sites
        (Voice settings link and Always-On tray menu) were updated
        to match.
        """
        try:
            if hasattr(self, '_ensure_voice_top_tab'):
                self._ensure_voice_top_tab()
            self._bring_workbench_forward()
            if hasattr(self, 'voice_tab_index') and hasattr(self, 'main_tabs'):
                self.main_tabs.setCurrentIndex(self.voice_tab_index)
        except Exception as e:
            QMessageBox.warning(self, "Could not open Voice", str(e))

    def _bring_workbench_forward(self):
        """Bring Workbench to the foreground.

        On Windows the bare show/raise/activateWindow trio isn't
        enough to win the foreground race against another app the
        user has been working in (Trados is a known-aggressive
        host). We escalate through the full documented
        foreground-grab chain: synthetic Alt-key press to satisfy
        SetForegroundWindow's "Alt pressed" exception, then
        AttachThreadInput dance, then BringWindowToTop +
        SetForegroundWindow + SwitchToThisWindow. See v1.10.9
        changelog for the full history of this fix.
        """
        if self.isMinimized():
            self.showNormal()
        self.show()
        self.raise_()
        self.activateWindow()

        import sys
        if sys.platform == 'win32':
            # v1.10.9: belt + braces + suspenders. Three earlier
            # versions (v1.10.6 deferred the button click, v1.10.7
            # reordered ensure vs. bring, v1.10.8 split work across
            # event-loop turns) still left the first Ctrl+Alt+C from
            # a heavy host like Trados painting Workbench *behind*
            # Trados despite the SetForegroundWindow call returning
            # success. That's classic Windows foreground-stealing
            # prevention: the OS accepts the call but quietly refuses
            # to actually bring our window forward, leaving it
            # flashing in the taskbar with no visible activation.
            #
            # The canonical fix is the "Alt key" trick. Windows
            # documents an exception in the foreground-allowed rules
            # (SetForegroundWindow MSDN page): if the Alt key is
            # currently pressed at the moment SetForegroundWindow is
            # called, the OS grants the request unconditionally.
            # By synthesising a brief Alt down + Alt up *immediately
            # before* the call, we satisfy that rule even when our
            # process has no other claim to foreground rights. The
            # Alt key is released within microseconds so the user
            # never sees side effects (no menu activation etc.).
            #
            # Plus three additional hammers, applied in order:
            #   - BringWindowToTop() forces Z-order to the top of
            #     the same-thread-group stack.
            #   - SetForegroundWindow() requests the actual
            #     foreground change. With Alt held + thread input
            #     attached, the OS now accepts it.
            #   - SwitchToThisWindow() is a deprecated-but-functional
            #     API that bypasses some foreground rules entirely;
            #     used as a last-resort hammer for stubborn cases
            #     (Trados being one).
            try:
                import ctypes
                user32 = ctypes.windll.user32
                kernel32 = ctypes.windll.kernel32
                hwnd = int(self.winId())

                # Step 1: Synthetic Alt + F24 chord to satisfy
                # SetForegroundWindow's "Alt key pressed" exception
                # without triggering Qt's QMenuBar menu-activation.
                #
                # v1.10.9 used a bare Alt down/up here. That works
                # for the foreground grab, but Windows + Qt also
                # interpret a *naked* Alt tap (Alt down → Alt up
                # with nothing in between) as "user wants to
                # activate the menu bar". Even though the synthetic
                # tap was meant for the originating foreground app,
                # the AttachThreadInput dance below shares input
                # queues, so the Alt event leaks into Workbench's
                # input stream just as we become foreground. Qt's
                # QMenuBar then activates File-menu navigation and
                # the user's arrow keys steer the menu instead of
                # the Clipboard list / SuperLookup results
                # (v1.10.14 tried to fix this post-hoc by calling
                # menuBar().setActiveAction(None) and setFocus()
                # on the target widget, but that ran *before* Qt
                # processed the queued Alt event, so it cleared
                # state that hadn't been set yet).
                #
                # v1.10.15 inserts a synthetic F24 between the Alt
                # down and Alt up. VK_F24 (0x87) is a defined VK
                # code that no app binds to – it exists in the
                # Windows VK enum mostly for keyboards with extended
                # function-key rows that nobody ships any more. Qt's
                # QMenuBar treats Alt+anything as a chord, not a
                # naked Alt tap, and skips menu activation. The
                # synthetic F24 itself reaches no listener (nothing
                # binds it). The Alt key still satisfies
                # SetForegroundWindow's exception because it's
                # pressed at the moment SetForegroundWindow is
                # called.
                VK_MENU = 0x12
                VK_F24 = 0x87
                KEYEVENTF_KEYUP = 0x0002
                user32.keybd_event(VK_MENU, 0, 0, 0)               # Alt down
                user32.keybd_event(VK_F24, 0, 0, 0)                # F24 down (chord)
                user32.keybd_event(VK_F24, 0, KEYEVENTF_KEYUP, 0)  # F24 up
                user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up

                # Step 2: AttachThreadInput dance.
                fg = user32.GetForegroundWindow()
                fg_thread = user32.GetWindowThreadProcessId(fg, None)
                our_thread = kernel32.GetCurrentThreadId()
                attached = False
                if fg and fg_thread and fg_thread != our_thread:
                    attached = bool(
                        user32.AttachThreadInput(fg_thread, our_thread, True)
                    )

                # Step 3: Force Z-order to top, then request
                # foreground, then the deprecated-but-effective
                # SwitchToThisWindow hammer.
                try:
                    user32.BringWindowToTop(hwnd)
                    user32.SetForegroundWindow(hwnd)
                    # SwitchToThisWindow(hwnd, fAltTab=True) – second
                    # arg makes the call behave like an Alt+Tab
                    # switch which is more reliable than the bare
                    # foreground-request path.
                    user32.SwitchToThisWindow(hwnd, True)
                finally:
                    if attached:
                        user32.AttachThreadInput(fg_thread, our_thread, False)
            except Exception as e:
                print(f"[Workbench] foreground escalation failed: {e}")

        # v1.10.14: the synthetic Alt-tap above gets the foreground
        # grant we need, but it's also what Windows interprets as
        # "user pressed Alt to activate the menu bar". Even though
        # we sent the Alt to the *originating* foreground (Trados),
        # AttachThreadInput shares input queues for the duration, so
        # the Alt event leaks into Workbench's input stream just as
        # we become foreground. Qt's QMenuBar then activates File-
        # menu navigation mode and the user's arrow keys steer the
        # menu instead of the Clipboard list / SuperLookup results.
        # Dismiss the leaked menu state explicitly so we land in the
        # expected "neutral" focus state.
        self._dismiss_menu_activation()

    def _dismiss_menu_activation(self):
        """Clear any leaked menu-bar activation after a foreground
        grab. Called from _bring_workbench_forward(); safe to call
        when no menu is active (no-op in that case). The continuation
        callbacks (`_continue_clipboard`, `_continue_superlookup`)
        also explicitly setFocus() on their target widget, which
        belt-and-braces clears the menu-activation indirectly – but
        calling setActiveAction(None) here makes the cleanup
        deterministic instead of relying on focus-change side
        effects."""
        try:
            mb = self.menuBar() if hasattr(self, 'menuBar') else None
            if mb is not None:
                mb.setActiveAction(None)
        except Exception:
            pass

    def open_workbench_to_superlookup(self, text: str = ""):
        """Bring Workbench forward, switch to the SuperLookup top tab,
        and optionally seed the search field with ``text``.

        Wired to Ctrl+Alt+L in v1.10.1 (Phase 2 of issue #199). Replaces
        the legacy ``Sidekick.show_superlookup(text)`` route for the
        global hotkey path; Sidekick's own copy still works for users
        who summon Sidekick via Ctrl+Alt+K.
        """
        try:
            # v1.10.7: bring Workbench forward *before* the lazy-tab
            # ensure.
            # v1.10.8: that wasn't enough on a cold first run. Even
            # with bring-forward called first, the SetForegroundWindow
            # call only *queues* WM_ACTIVATE / WM_PAINT for our
            # window – Workbench doesn't visibly become active until
            # Qt's event loop processes those messages. If we then
            # synchronously call _ensure_superlookup_top_tab() and
            # block the GUI thread for 1–2s building the widget, the
            # queued activation events sit waiting; meanwhile Windows
            # may decide to re-evaluate foreground or simply never
            # repaint Workbench as the active window because Qt hasn't
            # acknowledged the WM_ACTIVATE. The user keeps seeing
            # their source app the whole time.
            #
            # Fix: split the work across two event-loop turns.
            #   Turn 1 (now):   _bring_workbench_forward()
            #   Turn 2 (next):  ensure + setCurrentIndex + seed/click
            # QTimer.singleShot(0, …) schedules the continuation for
            # the next iteration of Qt's main loop, between which the
            # activation/paint events get processed and Workbench
            # actually appears on top before we start blocking the
            # GUI thread on widget construction.
            self._bring_workbench_forward()
            from PyQt6.QtCore import QTimer

            def _continue_superlookup():
                try:
                    if hasattr(self, '_ensure_superlookup_top_tab'):
                        self._ensure_superlookup_top_tab()
                    if hasattr(self, 'superlookup_tab_index') and hasattr(self, 'main_tabs'):
                        self.main_tabs.setCurrentIndex(self.superlookup_tab_index)
                    widget = getattr(self, '_superlookup_top_widget', None)
                    if widget is not None:
                        # v1.10.14: explicit focus on the search field
                        # (or the tab root if the search field isn't
                        # available) so keyboard navigation lands here
                        # instead of leaking into the menu bar after
                        # the foreground-grab Alt-tap.
                        try:
                            if hasattr(widget, 'source_text') and widget.source_text is not None:
                                widget.source_text.setFocus()
                            else:
                                widget.setFocus()
                        except Exception:
                            pass
                    if widget is not None and text:
                        try:
                            if hasattr(widget, 'source_text'):
                                widget.source_text.setEditText(text)
                            if hasattr(widget, 'search_btn'):
                                # Still defer the click by another
                                # 150ms past the ensure so the search
                                # doesn't peg the GUI thread before
                                # the tab finishes painting.
                                QTimer.singleShot(150, widget.search_btn.click)
                        except Exception as inner:
                            self.log(f"⚠ Could not seed SuperLookup search: {inner}")
                except Exception as inner:
                    self.log(f"⚠ Could not finish SuperLookup open: {inner}")

            QTimer.singleShot(0, _continue_superlookup)
        except Exception as e:
            self.log(f"⚠ Could not open SuperLookup top tab: {e}")
            import traceback
            traceback.print_exc()

    def open_workbench_to_clipboard(self, source_window=None):
        """Bring Workbench forward and switch to the Clipboard top tab.

        Wired to Ctrl+Alt+C in v1.10.1 (Phase 2 of issue #199). If
        ``source_window`` is supplied (captured by the hotkey handler
        BEFORE bringing Workbench forward), it's passed to the
        Clipboard widget so snippet / conversion activations
        paste-and-return to that window rather than just sitting on
        the clipboard. Manual navigation to the Clipboard tab passes
        no source; activations then just set the clipboard.
        """
        try:
            # v1.10.7 reordered to bring-then-ensure.
            # v1.10.8 additionally splits the work across two Qt event-
            # loop turns so the WM_ACTIVATE / WM_PAINT events queued by
            # _bring_workbench_forward() actually get processed before
            # the heavy lazy-tab ensure blocks the GUI thread. See
            # open_workbench_to_superlookup for the full diagnosis.
            self._bring_workbench_forward()
            from PyQt6.QtCore import QTimer

            def _continue_clipboard():
                try:
                    if hasattr(self, '_ensure_clipboard_top_tab'):
                        self._ensure_clipboard_top_tab()
                    if hasattr(self, 'clipboard_tab_index') and hasattr(self, 'main_tabs'):
                        self.main_tabs.setCurrentIndex(self.clipboard_tab_index)
                    widget = getattr(self, '_clipboard_top_widget', None)
                    if widget is not None and hasattr(widget, 'set_source_window'):
                        widget.set_source_window(source_window)
                    # v1.10.14: explicit focus on the clipboard text
                    # list so arrow keys navigate the history straight
                    # away. Without this, the foreground-grab Alt-tap
                    # leaks into Qt's QMenuBar and arrow keys steer
                    # the File menu instead of the list. Prefer the
                    # text list (most-used column); the widget's own
                    # _focus_list helper falls back to selecting row 0
                    # if nothing is currently selected.
                    if widget is not None:
                        try:
                            text_list = getattr(widget, '_text_list', None)
                            if text_list is not None and hasattr(widget, '_focus_list'):
                                widget._focus_list(text_list)
                            else:
                                widget.setFocus()
                        except Exception:
                            pass
                except Exception as inner:
                    self.log(f"⚠ Could not finish Clipboard open: {inner}")

            QTimer.singleShot(0, _continue_clipboard)
        except Exception as e:
            self.log(f"⚠ Could not open Clipboard top tab: {e}")
            import traceback
            traceback.print_exc()

    def open_settings_to_keyboard_shortcuts(self):
        """Jump to Settings → Keyboard Shortcuts.

        Called from the Voice tab's "Change in Settings → Keyboard
        Shortcuts" link. Switches the main window to the Settings page
        and selects the Keyboard Shortcuts sub-tab.
        """
        try:
            main_tabs = getattr(self, 'main_tabs', None)
            settings_tabs = getattr(self, 'settings_tabs', None)
            settings_idx = getattr(self, 'settings_tab_index', None)
            kb_idx = getattr(self, 'keyboard_shortcuts_tab_index', None)
            if main_tabs is not None and settings_idx is not None:
                main_tabs.setCurrentIndex(settings_idx)
            if settings_tabs is not None and kb_idx is not None:
                settings_tabs.setCurrentIndex(kb_idx)
            self._bring_workbench_forward()
        except Exception as e:
            QMessageBox.information(
                self, "Open Keyboard Shortcuts",
                f"Couldn't auto-navigate (\"{e}\"). Open the main "
                "Workbench window → Settings → Keyboard Shortcuts and "
                "find <b>Voice dictation / push-to-talk</b> under "
                "<b>Special</b>."
            )

    def reload_global_hotkeys(self):
        """Re-register the global hotkey set with current ShortcutManager values.

        Called after the user changes a ``global_*`` shortcut in
        Settings → Keyboard Shortcuts. Without this call, the OS-level
        hotkey listener keeps firing on the *old* key until the next
        application restart, because pynput / WinAPI registrations don't
        track ShortcutManager state. Idempotent – ``register_global_hotkey``
        stops any previously running manager before binding fresh keys.
        """
        lookup_tab = getattr(self, 'lookup_tab', None)
        if lookup_tab is None or not hasattr(lookup_tab, 'register_global_hotkey'):
            return
        try:
            lookup_tab.register_global_hotkey()
        except Exception as e:
            self.log(f"⚠ Could not reload global hotkeys: {e}")

    def _create_debug_settings_tab(self):
        """Create Debug Settings tab content"""
        from PyQt6.QtWidgets import QGroupBox, QPushButton, QTextEdit
        
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)
        
        debug_settings = self.load_general_settings()
        
        # Debug Mode section
        debug_group = QGroupBox("🐛 Debug Mode")
        debug_layout = QVBoxLayout()
        
        info_label = QLabel(
            "<b>Debug Mode</b> enables verbose logging to help diagnose performance issues.\n\n"
            "When enabled, detailed logs will show:\n"
            "• Every text change event in the grid\n"
            "• Database update operations (BEFORE/AFTER)\n"
            "• Cell selection events\n"
            "• Signal connections\n\n"
            "⚠️ <b>Warning:</b> Debug mode generates a LOT of log output and may slow down the application.\n"
            "Only enable when troubleshooting specific issues."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("font-size: 9pt; padding: 10px; border-radius: 4px;")
        debug_layout.addWidget(info_label)
        
        debug_layout.addSpacing(10)
        
        # Enable debug mode checkbox
        debug_mode_cb = CheckmarkCheckBox("Enable verbose debug logging")
        debug_mode_cb.setChecked(debug_settings.get('debug_mode_enabled', False))
        debug_mode_cb.setToolTip(
            "When enabled, shows detailed debug messages in the log.\n"
            "This helps diagnose performance issues but generates a lot of output."
        )
        debug_layout.addWidget(debug_mode_cb)
        
        # Auto-export debug logs checkbox
        debug_export_cb = CheckmarkCheckBox("Auto-export debug logs to file")
        debug_export_cb.setChecked(debug_settings.get('debug_auto_export', False))
        debug_export_cb.setToolTip(
            "When enabled, automatically saves debug logs to:\n"
            "supervertaler_debug_log_<timestamp>.txt in the program root.\n"
            "Logs are exported every 5 minutes or when you close the application."
        )
        debug_export_cb.setEnabled(debug_mode_cb.isChecked())  # Only enabled if debug mode is on
        debug_layout.addWidget(debug_export_cb)
        
        # Link the checkboxes
        debug_mode_cb.toggled.connect(lambda checked: debug_export_cb.setEnabled(checked))
        
        debug_layout.addSpacing(10)
        
        # Export log button
        export_btn = QPushButton("📁 Export Debug Log Now")
        export_btn.setToolTip("Export the current debug log buffer to a timestamped file")
        export_btn.clicked.connect(self.export_debug_log_now)
        debug_layout.addWidget(export_btn)
        
        # Clear log buffer button
        clear_btn = QPushButton("🗑️ Clear Debug Log Buffer")
        clear_btn.setToolTip("Clear the in-memory debug log buffer")
        clear_btn.clicked.connect(self.clear_debug_log_buffer)
        debug_layout.addWidget(clear_btn)
        
        debug_group.setLayout(debug_layout)
        layout.addWidget(debug_group)
        
        # Performance Settings section
        perf_group = QGroupBox("⚡ Performance Settings")
        perf_layout = QVBoxLayout()
        
        perf_info = QLabel(
            "<b>Performance Tuning</b> - These settings control UI responsiveness.\n\n"
            "Higher delays = more responsive typing but slower lookups.\n"
            "Lower delays = faster lookups but potential UI freezes during typing."
        )
        perf_info.setWordWrap(True)
        perf_info.setStyleSheet("font-size: 9pt; padding: 10px; border-radius: 4px;")
        perf_layout.addWidget(perf_info)
        
        perf_layout.addSpacing(10)
        
        # Target text debounce delay
        debounce_layout = QHBoxLayout()
        debounce_layout.addWidget(QLabel("Target text save delay:"))
        debounce_spin = QSpinBox()
        debounce_spin.setRange(100, 5000)
        debounce_spin.setValue(debug_settings.get('target_debounce_delay', 1000))
        debounce_spin.setSingleStep(100)
        debounce_spin.setSuffix(" ms")
        debounce_spin.setToolTip(
            "Delay before saving target text changes to database.\n"
            "1000ms (default) = good balance between responsiveness and data safety.\n"
            "Higher values = less database overhead but longer delay before saving."
        )
        debounce_layout.addWidget(debounce_spin)
        debounce_layout.addStretch()
        perf_layout.addLayout(debounce_layout)
        
        perf_group.setLayout(perf_layout)
        layout.addWidget(perf_group)
        
        # Save button
        save_btn = QPushButton("💾 Save Debug Settings")
        save_btn.setStyleSheet("font-weight: bold; padding: 8px;")
        save_btn.clicked.connect(lambda: self._save_debug_settings_from_ui(
            debug_mode_cb, debug_export_cb, debounce_spin
        ))
        layout.addWidget(save_btn)
        
        layout.addStretch()
        
        return tab

    def _save_debug_settings_from_ui(self, debug_mode_cb, debug_export_cb, debounce_spin):
        """Save debug settings from UI"""
        self.debug_mode_enabled = debug_mode_cb.isChecked()
        self.debug_auto_export = debug_export_cb.isChecked()
        
        general_settings = self.load_general_settings()
        general_settings['debug_mode_enabled'] = self.debug_mode_enabled
        general_settings['debug_auto_export'] = self.debug_auto_export
        general_settings['target_debounce_delay'] = debounce_spin.value()
        self.save_general_settings(general_settings)
        
        self.log(f"✓ Debug settings saved: Debug Mode={'ON' if self.debug_mode_enabled else 'OFF'}, Auto-export={'ON' if self.debug_auto_export else 'OFF'}")
        QMessageBox.information(self, "Settings Saved", "Debug settings have been saved successfully.")
    
    def export_debug_log_now(self):
        """Export debug log buffer to file immediately"""
        from datetime import datetime
        
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"supervertaler_debug_log_{timestamp}.txt"
        
        try:
            with open(filename, 'w', encoding='utf-8') as f:
                f.write(f"Supervertaler Workbench Debug Log\n")
                f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write("="*80 + "\n\n")
                
                if self.debug_log_buffer:
                    f.write("\n".join(self.debug_log_buffer))
                else:
                    f.write("(No debug logs in buffer)")
            
            self.log(f"✓ Debug log exported to: {filename}")
            QMessageBox.information(
                self,
                "Log Exported",
                f"Debug log exported successfully to:\n{filename}\n\n"
                f"Log entries: {len(self.debug_log_buffer)}"
            )
        except Exception as e:
            self.log(f"❌ Error exporting debug log: {e}")
            QMessageBox.warning(self, "Export Failed", f"Failed to export debug log:\n{e}")
    
    def clear_debug_log_buffer(self):
        """Clear the debug log buffer"""
        buffer_size = len(self.debug_log_buffer)
        self.debug_log_buffer.clear()
        self.log(f"✓ Debug log buffer cleared ({buffer_size} entries removed)")
        QMessageBox.information(self, "Buffer Cleared", f"Debug log buffer cleared.\n{buffer_size} entries removed.")

    def _create_domain_keywords_tab(self):
        """Create Domain Detection Keywords tab content"""
        from PyQt6.QtWidgets import QGroupBox, QPushButton, QTextEdit, QTabWidget

        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(15)

        # Introduction
        intro_label = QLabel(
            "<h3>🎯 Automated Domain Detection</h3>"
            "<p>Supervertaler automatically identifies document types (Legal, Medical, Technical, Patent) "
            "to generate domain-appropriate translation prompts.</p>"
            "<p><b>Keywords below</b> are used for detection. The system counts matches and selects the domain "
            "with the highest score (minimum 2 matches required).</p>"
            "<p>💡 <b>Tip:</b> Add domain-specific terminology to improve detection accuracy for your projects.</p>"
        )
        intro_label.setWordWrap(True)
        intro_label.setStyleSheet("font-size: 10pt; padding: 10px; border-radius: 4px;")
        layout.addWidget(intro_label)

        # Load current keywords from prompt_manager_qt.py default lists
        keywords = self._load_domain_keywords()

        # Create tab widget for each domain
        domain_tabs = QTabWidget()
        domain_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        domain_tabs.tabBar().setDrawBase(False)
        domain_tabs.tabBar().setExpanding(False)
        domain_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")

        # Legal domain
        legal_tab = QWidget()
        legal_layout = QVBoxLayout(legal_tab)
        legal_info = QLabel("<b>Legal/Notarial Documents:</b> Notarial deeds, contracts, court documents, Belgian law")
        legal_info.setWordWrap(True)
        legal_layout.addWidget(legal_info)
        self.legal_keywords_edit = QTextEdit()
        self.legal_keywords_edit.setPlainText("\n".join(keywords['legal']))
        self.legal_keywords_edit.setPlaceholderText("Enter keywords, one per line...")
        legal_layout.addWidget(self.legal_keywords_edit)
        domain_tabs.addTab(legal_tab, "⚖️ Legal")

        # Medical domain
        medical_tab = QWidget()
        medical_layout = QVBoxLayout(medical_tab)
        medical_info = QLabel("<b>Medical Documents:</b> Clinical reports, procedures, pharmaceutical documents")
        medical_info.setWordWrap(True)
        medical_layout.addWidget(medical_info)
        self.medical_keywords_edit = QTextEdit()
        self.medical_keywords_edit.setPlainText("\n".join(keywords['medical']))
        self.medical_keywords_edit.setPlaceholderText("Enter keywords, one per line...")
        medical_layout.addWidget(self.medical_keywords_edit)
        domain_tabs.addTab(medical_tab, "🏥 Medical")

        # Patent domain
        patent_tab = QWidget()
        patent_layout = QVBoxLayout(patent_tab)
        patent_info = QLabel("<b>Patent Documents:</b> Patent applications, claims, prior art references")
        patent_info.setWordWrap(True)
        patent_layout.addWidget(patent_info)
        self.patent_keywords_edit = QTextEdit()
        self.patent_keywords_edit.setPlainText("\n".join(keywords['patent']))
        self.patent_keywords_edit.setPlaceholderText("Enter keywords, one per line...")
        patent_layout.addWidget(self.patent_keywords_edit)
        domain_tabs.addTab(patent_tab, "🔬 Patent")

        # Technical domain
        technical_tab = QWidget()
        technical_layout = QVBoxLayout(technical_tab)
        technical_info = QLabel("<b>Technical Documents:</b> Manuals, specifications, installation guides")
        technical_info.setWordWrap(True)
        technical_layout.addWidget(technical_info)
        self.technical_keywords_edit = QTextEdit()
        self.technical_keywords_edit.setPlainText("\n".join(keywords['technical']))
        self.technical_keywords_edit.setPlaceholderText("Enter keywords, one per line...")
        technical_layout.addWidget(self.technical_keywords_edit)
        domain_tabs.addTab(technical_tab, "🔧 Technical")

        layout.addWidget(domain_tabs)

        # Save and Reset buttons
        buttons_layout = QHBoxLayout()

        reset_btn = QPushButton("🔄 Reset to Defaults")
        reset_btn.setToolTip("Reset all keyword lists to default values")
        reset_btn.clicked.connect(self._reset_domain_keywords_to_defaults)
        buttons_layout.addWidget(reset_btn)

        buttons_layout.addStretch()

        save_btn = QPushButton("💾 Save Domain Keywords")
        save_btn.setToolTip("Save custom domain detection keywords")
        save_btn.clicked.connect(self._save_domain_keywords)
        save_btn.setStyleSheet("font-weight: bold; padding: 8px 16px;")
        buttons_layout.addWidget(save_btn)

        layout.addLayout(buttons_layout)

        return tab

    def _load_domain_keywords(self):
        """Load domain keywords from settings or use defaults"""
        settings = self.load_general_settings()

        # Default keywords - MULTILINGUAL (matches prompt_manager_qt.py)
        # Includes English + Dutch + French + German keywords
        defaults = {
            'legal': [
                # English
                'notarial deed', 'notary', 'legal contract', 'deed of', 'legal document',
                'testament', 'mortgage', 'clause', 'hereby', 'whereas', 'contractual',
                'court', 'judgment', 'plaintiff', 'defendant', 'attorney', 'barrister',
                'articles of association', 'general meeting', 'extraordinary general meeting',
                # Dutch/Flemish
                'notariële akte', 'notaris', 'rechtbank', 'vonnis', 'statuten',
                'algemene vergadering', 'buitengewone algemene vergadering', 'meester',
                'advocaat', 'artikel', 'wetboek', 'rechtspersonenregister',
                # French
                'acte notarié', 'notaire', 'tribunal', 'jugement', 'statuts',
                'assemblée générale', 'maître',
                # German
                'notarielle urkunde', 'notar', 'gericht', 'urteil', 'satzung'
            ],
            'medical': [
                # English
                'patient', 'diagnosis', 'medical', 'clinical', 'procedure',
                'treatment', 'symptom', 'disease', 'medication', 'pharmaceutical',
                'surgery', 'anatomical', 'pathology', 'radiology', 'hospital',
                # Dutch
                'patiënt', 'diagnose', 'medisch', 'klinisch', 'behandeling',
                'symptoom', 'ziekte', 'medicijn', 'chirurgie', 'ziekenhuis',
                # French
                'médical', 'clinique', 'traitement', 'symptôme', 'maladie',
                'médicament', 'chirurgie', 'hôpital',
                # German
                'medizinisch', 'klinisch', 'behandlung', 'symptom', 'krankheit',
                'medikament', 'krankenhaus'
            ],
            'patent': [
                # English
                'patent', 'claim', 'invention', 'embodiment', 'prior art',
                'apparatus', 'method comprising', 'said', 'wherein', 'configured to',
                # Dutch
                'octrooi', 'conclusie', 'uitvinding', 'uitvoeringsvorm',
                # French
                'brevet', 'revendication', 'invention', 'mode de réalisation',
                # German
                'patentanspruch', 'erfindung', 'ausführungsform'
            ],
            'technical': [
                # English
                'specification', 'component', 'assembly', 'technical manual',
                'installation', 'maintenance', 'user guide', 'operating instructions',
                'safety warning', 'dimensions', 'tolerance',
                # Dutch
                'specificatie', 'onderdeel', 'montage', 'technische handleiding',
                'installatie', 'onderhoud', 'gebruikershandleiding', 'veiligheidswaarschuwing',
                # French
                'spécification', 'composant', 'assemblage', 'manuel technique',
                'entretien', 'avertissement de sécurité',
                # German
                'spezifikation', 'komponente', 'montage', 'technisches handbuch',
                'wartung', 'sicherheitswarnung'
            ]
        }

        # Load custom keywords if saved
        return {
            'legal': settings.get('domain_keywords_legal', defaults['legal']),
            'medical': settings.get('domain_keywords_medical', defaults['medical']),
            'patent': settings.get('domain_keywords_patent', defaults['patent']),
            'technical': settings.get('domain_keywords_technical', defaults['technical'])
        }

    def _save_domain_keywords(self):
        """Save domain detection keywords to settings"""
        keywords = {
            'domain_keywords_legal': [kw.strip() for kw in self.legal_keywords_edit.toPlainText().split('\n') if kw.strip()],
            'domain_keywords_medical': [kw.strip() for kw in self.medical_keywords_edit.toPlainText().split('\n') if kw.strip()],
            'domain_keywords_patent': [kw.strip() for kw in self.patent_keywords_edit.toPlainText().split('\n') if kw.strip()],
            'domain_keywords_technical': [kw.strip() for kw in self.technical_keywords_edit.toPlainText().split('\n') if kw.strip()]
        }

        # Save to general settings
        settings = self.load_general_settings()
        settings.update(keywords)
        self.save_general_settings(settings)

        self.log(f"✓ Domain detection keywords saved")
        QMessageBox.information(
            self,
            "Keywords Saved",
            "Domain detection keywords have been saved successfully.\n\n"
            "The updated keywords will be used for automatic domain detection "
            "when generating translation prompts."
        )

    def _reset_domain_keywords_to_defaults(self):
        """Reset all domain keywords to default values"""
        reply = QMessageBox.question(
            self,
            "Reset to Defaults",
            "Are you sure you want to reset all domain keywords to their default values?\n\n"
            "This will overwrite any custom keywords you've added.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )

        if reply == QMessageBox.StandardButton.Yes:
            defaults = self._load_domain_keywords()  # Gets defaults when no custom settings exist

            # Clear saved keywords from settings
            settings = self.load_general_settings()
            settings.pop('domain_keywords_legal', None)
            settings.pop('domain_keywords_medical', None)
            settings.pop('domain_keywords_patent', None)
            settings.pop('domain_keywords_technical', None)
            self.save_general_settings(settings)

            # Reload defaults and update UI
            defaults = self._load_domain_keywords()
            self.legal_keywords_edit.setPlainText("\n".join(defaults['legal']))
            self.medical_keywords_edit.setPlainText("\n".join(defaults['medical']))
            self.patent_keywords_edit.setPlainText("\n".join(defaults['patent']))
            self.technical_keywords_edit.setPlainText("\n".join(defaults['technical']))

            self.log("✓ Domain keywords reset to defaults")
            QMessageBox.information(self, "Reset Complete", "Domain keywords have been reset to default values.")

    def _save_llm_settings_from_ui(self, openai_radio, claude_radio, gemini_radio, ollama_radio,
                                   openai_combo, claude_combo, gemini_combo,
                                   ollama_status_widget,
                                   openai_enable_cb, claude_enable_cb, gemini_enable_cb, ollama_enable_cb,
                                   batch_size_spin, surrounding_spin, full_context_cb, context_slider,
                                   tm_no_check_rb, tm_fuzzy_rb, tm_exact_rb, auto_propagate_cb,
                                   delay_spin):
        """Save LLM settings from UI"""
        # Determine selected provider
        if openai_radio.isChecked():
            provider = 'openai'
        elif claude_radio.isChecked():
            provider = 'claude'
        elif gemini_radio.isChecked():
            provider = 'gemini'
        elif ollama_radio.isChecked():
            provider = 'ollama'
        else:
            provider = 'openai'  # Default fallback
        
        # Get Ollama model from widget if available
        ollama_model = 'translategemma:12b'  # Default
        if ollama_status_widget and hasattr(ollama_status_widget, 'get_selected_model'):
            selected = ollama_status_widget.get_selected_model()
            if selected:
                ollama_model = selected
        
        new_settings = {
            'provider': provider,
            'openai_model': openai_combo.currentText().split()[0],
            'claude_model': claude_combo.currentText().split()[0],
            'gemini_model': gemini_combo.currentText().split()[0],
            'ollama_model': ollama_model
        }
        self.save_llm_settings(new_settings)

        # Update current provider and model attributes for AI Assistant
        self.current_provider = new_settings['provider']
        provider_key = f"{self.current_provider}_model"
        self.current_model = new_settings.get(provider_key)

        enabled_states = {
            'llm_openai': openai_enable_cb.isChecked(),
            'llm_claude': claude_enable_cb.isChecked(),
            'llm_gemini': gemini_enable_cb.isChecked(),
            'llm_ollama': ollama_enable_cb.isChecked()
        }
        # Merge with existing MT settings
        existing = self.load_provider_enabled_states()
        enabled_states.update({k: v for k, v in existing.items() if k.startswith('mt_')})
        self.save_provider_enabled_states(enabled_states)
        
        # Save translation preferences
        general_prefs = self.load_general_settings()
        general_prefs['batch_size'] = batch_size_spin.value()
        general_prefs['surrounding_segments'] = surrounding_spin.value()
        general_prefs['use_full_context'] = full_context_cb.isChecked()
        general_prefs['context_window_size'] = context_slider.value()
        # Save TM check mode settings
        general_prefs['check_tm_before_api'] = not tm_no_check_rb.isChecked()
        general_prefs['check_tm_exact_only'] = tm_exact_rb.isChecked()
        general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
        general_prefs['lookup_delay'] = delay_spin.value()
        self.save_general_settings(general_prefs)
        
        # Update LLM indicator in status bar
        self._update_llm_indicator()
        
        self.log(f"✓ LLM settings saved: Provider={new_settings['provider']}, Batch Size={batch_size_spin.value()}")
        QMessageBox.information(self, "Settings Saved", "LLM settings have been saved successfully.")
    
    def _save_ai_settings_from_ui(self, openai_radio, claude_radio, gemini_radio, ollama_radio,
                                   openai_combo, claude_combo, gemini_combo,
                                   ollama_status_widget,
                                   openai_enable_cb, claude_enable_cb, gemini_enable_cb, ollama_enable_cb,
                                   batch_size_spin, surrounding_spin, full_context_cb, context_slider,
                                   tm_no_check_rb, tm_fuzzy_rb, tm_exact_rb, auto_propagate_cb, delay_spin,
                                   ollama_keepwarm_cb,
                                   llm_matching_cb, auto_markdown_cb, llm_spin,
                                   quicklauncher_context_slider,
                                   custom_radio=None, custom_endpoint_input=None,
                                   custom_model_input=None, custom_enable_cb=None,
                                   custom_profile_combo=None, custom_key_input=None,
                                   mistral_radio=None, mistral_combo=None,
                                   mistral_enable_cb=None,
                                   deepseek_radio=None, deepseek_combo=None,
                                   openrouter_radio=None, openrouter_combo=None,
                                   openrouter_enable_cb=None):
        """Save all AI settings from the unified AI Settings tab"""
        # Determine selected provider
        if openai_radio.isChecked():
            provider = 'openai'
        elif claude_radio.isChecked():
            provider = 'claude'
        elif gemini_radio.isChecked():
            provider = 'gemini'
        elif mistral_radio and mistral_radio.isChecked():
            provider = 'mistral'
        elif deepseek_radio and deepseek_radio.isChecked():
            provider = 'deepseek'
        elif openrouter_radio and openrouter_radio.isChecked():
            provider = 'openrouter'
        elif ollama_radio.isChecked():
            provider = 'ollama'
        elif custom_radio and custom_radio.isChecked():
            provider = 'custom_openai'
        else:
            provider = 'openai'  # Default fallback

        # Get Ollama model from widget if available
        ollama_model = 'translategemma:12b'  # Default
        if ollama_status_widget and hasattr(ollama_status_widget, 'get_selected_model'):
            selected = ollama_status_widget.get_selected_model()
            if selected:
                ollama_model = selected

        # Build profiles list from UI state
        existing_settings = self.load_llm_settings()
        profiles = existing_settings.get('custom_openai_profiles', [])
        active_profile_name = ''

        if custom_profile_combo and custom_profile_combo.count() > 0:
            # Sync current field values into the active profile
            idx = custom_profile_combo.currentIndex()
            if 0 <= idx < len(profiles):
                profiles[idx]['endpoint'] = custom_endpoint_input.text().strip() if custom_endpoint_input else ''
                profiles[idx]['model'] = custom_model_input.text().strip() if custom_model_input else ''
                profiles[idx]['api_key'] = custom_key_input.text().strip() if custom_key_input else ''
            active_profile_name = custom_profile_combo.currentText()

        # For legacy compat, also store flat fields from active profile
        active_endpoint = custom_endpoint_input.text().strip() if custom_endpoint_input else ''
        active_model = custom_model_input.text().strip() if custom_model_input else ''

        new_settings = {
            'provider': provider,
            'openai_model': openai_combo.currentText().split()[0],
            'claude_model': claude_combo.currentText().split()[0],
            'gemini_model': gemini_combo.currentText().split()[0],
            'mistral_model': mistral_combo.currentText().split()[0] if mistral_combo else 'mistral-large-latest',
            'deepseek_model': deepseek_combo.currentText().split()[0] if deepseek_combo else 'deepseek-v4-pro',
            'openrouter_model': openrouter_combo.currentText().split()[0] if openrouter_combo else 'anthropic/claude-sonnet-4.6',
            'ollama_model': ollama_model,
            'custom_openai_model': active_model,
            'custom_openai_endpoint': active_endpoint,
            'custom_openai_profiles': profiles,
            'custom_openai_active_profile': active_profile_name
        }
        self.save_llm_settings(new_settings)

        # Update current provider and model attributes for AI Assistant
        self.current_provider = new_settings['provider']
        provider_key = f"{self.current_provider}_model"
        self.current_model = new_settings.get(provider_key)

        # Save LLM provider enabled states
        enabled_states = {
            'llm_openai': openai_enable_cb.isChecked(),
            'llm_claude': claude_enable_cb.isChecked(),
            'llm_gemini': gemini_enable_cb.isChecked(),
            'llm_mistral': mistral_enable_cb.isChecked() if mistral_enable_cb else True,
            'llm_openrouter': openrouter_enable_cb.isChecked() if openrouter_enable_cb else True,
            'llm_ollama': ollama_enable_cb.isChecked(),
            'llm_custom_openai': custom_enable_cb.isChecked() if custom_enable_cb else True
        }
        # Merge with existing MT settings
        existing = self.load_provider_enabled_states()
        enabled_states.update({k: v for k, v in existing.items() if k.startswith('mt_')})
        self.save_provider_enabled_states(enabled_states)
        
        # Update LLM matching setting
        self.enable_llm_matching = llm_matching_cb.isChecked()
        
        # Update auto-markdown setting
        self.auto_generate_markdown = auto_markdown_cb.isChecked()
        
        # Save all general settings including AI-related ones
        general_prefs = self.load_general_settings()
        general_prefs['batch_size'] = batch_size_spin.value()
        general_prefs['surrounding_segments'] = surrounding_spin.value()
        general_prefs['use_full_context'] = full_context_cb.isChecked()
        general_prefs['context_window_size'] = context_slider.value()
        general_prefs['quicklauncher_context_percent'] = quicklauncher_context_slider.value()
        # Save TM check mode settings
        general_prefs['check_tm_before_api'] = not tm_no_check_rb.isChecked()
        general_prefs['check_tm_exact_only'] = tm_exact_rb.isChecked()
        general_prefs['auto_propagate_100'] = auto_propagate_cb.isChecked()
        general_prefs['lookup_delay'] = delay_spin.value()
        general_prefs['ollama_keepwarm'] = ollama_keepwarm_cb.isChecked()
        general_prefs['enable_llm_matching'] = llm_matching_cb.isChecked()
        general_prefs['auto_generate_markdown'] = auto_markdown_cb.isChecked()

        # Update LLM match limits
        if 'match_limits' not in general_prefs:
            general_prefs['match_limits'] = {}
        general_prefs['match_limits']['LLM'] = llm_spin.value()
        
        self.save_general_settings(general_prefs)
        
        # Handle Ollama keep-warm timer
        if ollama_keepwarm_cb.isChecked():
            self._start_ollama_keepwarm_timer()
        else:
            self._stop_ollama_keepwarm_timer()
        
        # Apply LLM match limits to results panels
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                if hasattr(panel, 'match_limits'):
                    panel.match_limits['LLM'] = llm_spin.value()
        
        # Update LLM indicator in status bar
        self._update_llm_indicator()
        
        # Save API keys from inline fields
        self._save_api_keys_from_ui()

        # Save proxy settings
        if hasattr(self, 'proxy_enabled_cb'):
            proxy_settings = {
                'enabled': self.proxy_enabled_cb.isChecked(),
                'host': self.proxy_host_input.text().strip(),
                'port': self.proxy_port_spin.value(),
                'username': self.proxy_user_input.text().strip(),
                'password': self.proxy_pass_input.text().strip(),
            }
            self.save_proxy_settings(proxy_settings)
            # Re-apply Gemini env vars immediately
            self._apply_gemini_proxy()

        self.log(f"✓ AI settings saved: Provider={provider}, Model={self.current_model}")
        QMessageBox.information(self, "Settings Saved", "AI settings have been saved successfully.")
    
    def _save_api_keys_from_ui(self):
        """Save API keys from inline input fields to unified settings"""
        if not hasattr(self, '_api_key_inputs') or not self._api_key_inputs:
            return
        api_keys = self._load_settings_section("api_keys")
        for key_name, input_widget in self._api_key_inputs.items():
            value = input_widget.text().strip()
            api_keys[key_name] = value
        # Migrate legacy 'google' key to canonical 'gemini' key
        if api_keys.get('google') and not api_keys.get('gemini'):
            api_keys['gemini'] = api_keys['google']
        self.save_api_keys(api_keys)

    def _save_mt_settings_from_ui(self, google_cb, deepl_cb, microsoft_cb, amazon_cb, modernmt_cb, mymemory_cb):
        """Save MT settings from UI"""
        # Save API keys from inline fields
        self._save_api_keys_from_ui()

        enabled_states = {
            'mt_google_translate': google_cb.isChecked(),
            'mt_deepl': deepl_cb.isChecked(),
            'mt_microsoft': microsoft_cb.isChecked(),
            'mt_amazon': amazon_cb.isChecked(),
            'mt_modernmt': modernmt_cb.isChecked(),
            'mt_mymemory': mymemory_cb.isChecked()
        }
        # Merge with existing LLM settings
        existing = self.load_provider_enabled_states()
        enabled_states.update({k: v for k, v in existing.items() if k.startswith('llm_')})
        self.save_provider_enabled_states(enabled_states)
        
        self.log("✓ MT settings saved")
        QMessageBox.information(self, "Settings Saved", "MT settings have been saved successfully.")
    
    def _save_general_settings_from_ui(self, restore_cb, allow_replace_cb, auto_propagate_cb,
                                       llm_spin=None, mt_spin=None, tm_limit_spin=None, tb_spin=None,
                                       auto_open_log_cb=None, auto_insert_100_cb=None, tm_save_mode_combo=None, tb_highlight_cb=None,
                                       enable_backup_cb=None, backup_interval_spin=None,
                                       tb_hide_shorter_cb=None, smart_selection_cb=None,
                                       ahk_path_edit=None, auto_center_cb=None, auto_confirm_100_cb=None,
                                       auto_confirm_overwrite_cb=None, sound_effects_cb=None, sound_event_combos=None,
                                       disable_cache_cb=None):
        """Save general settings from UI (non-AI settings only)"""
        self.allow_replace_in_source = allow_replace_cb.isChecked()
        self.update_warning_banner()

        # Update auto-propagate state
        self.auto_propagate_exact_matches = auto_propagate_cb.isChecked()
        
        # Update auto-insert setting
        if auto_insert_100_cb is not None:
            self.auto_insert_100_percent_matches = auto_insert_100_cb.isChecked()
        
        # Update auto-confirm 100% matches setting
        if auto_confirm_100_cb is not None:
            self.auto_confirm_100_percent_matches = auto_confirm_100_cb.isChecked()
        
        # Update auto-confirm overwrite existing setting
        if auto_confirm_overwrite_cb is not None:
            self.auto_confirm_overwrite_existing = auto_confirm_overwrite_cb.isChecked()
        
        # Update auto-center active segment setting
        if auto_center_cb is not None:
            self.auto_center_active_segment = auto_center_cb.isChecked()
            self.log(f"💾 Saving auto-center setting: {self.auto_center_active_segment}")
        
        # Update termbase grid highlighting setting
        if tb_highlight_cb is not None:
            self.enable_termbase_grid_highlighting = tb_highlight_cb.isChecked()

        # Update termbase display settings
        if tb_hide_shorter_cb is not None:
            self.termbase_hide_shorter_matches = tb_hide_shorter_cb.isChecked()

        # Load existing settings to preserve AI-related ones
        existing_settings = self.load_general_settings()
        
        general_settings = {
            'restore_last_project': restore_cb.isChecked(),
            'auto_open_log': auto_open_log_cb.isChecked() if auto_open_log_cb is not None else False,
            'auto_propagate_exact_matches': self.auto_propagate_checkbox.isChecked() if hasattr(self, 'auto_propagate_checkbox') else self.auto_propagate_exact_matches,
            'auto_insert_100_percent_matches': auto_insert_100_cb.isChecked() if auto_insert_100_cb is not None else (self.auto_insert_100_checkbox.isChecked() if hasattr(self, 'auto_insert_100_checkbox') else True),
            'auto_confirm_100_percent_matches': auto_confirm_100_cb.isChecked() if auto_confirm_100_cb is not None else False,
            'auto_confirm_overwrite_existing': auto_confirm_overwrite_cb.isChecked() if auto_confirm_overwrite_cb is not None else False,
            'tm_save_mode': tm_save_mode_combo.currentData() if tm_save_mode_combo is not None else 'latest',
            'auto_generate_markdown': existing_settings.get('auto_generate_markdown', False),  # Preserve AI setting
            'enable_termbase_grid_highlighting': tb_highlight_cb.isChecked() if tb_highlight_cb is not None else True,
            'enable_llm_matching': existing_settings.get('enable_llm_matching', False),  # Preserve AI setting
            'termbase_hide_shorter_matches': tb_hide_shorter_cb.isChecked() if tb_hide_shorter_cb is not None else False,
            'enable_smart_word_selection': smart_selection_cb.isChecked() if smart_selection_cb is not None else True,
            'precision_scroll_divisor': self.precision_spin.value() if hasattr(self, 'precision_spin') else 3,
            'auto_center_active_segment': self.auto_center_cb.isChecked() if hasattr(self, 'auto_center_cb') else False,
            'enable_auto_backup': enable_backup_cb.isChecked() if enable_backup_cb is not None else True,
            'backup_interval_minutes': backup_interval_spin.value() if backup_interval_spin is not None else 5,
            'ollama_keepwarm': existing_settings.get('ollama_keepwarm', False),  # Preserve AI setting
            'grid_font_size': self.default_font_size,
            'results_match_font_size': 9,
            'results_compare_font_size': 9,
            'autohotkey_path': ahk_path_edit.text().strip() if ahk_path_edit is not None else existing_settings.get('autohotkey_path', ''),
            'enable_sound_effects': sound_effects_cb.isChecked() if sound_effects_cb is not None else existing_settings.get('enable_sound_effects', False),
            'disable_all_caches': disable_cache_cb.isChecked() if disable_cache_cb is not None else existing_settings.get('disable_all_caches', False)
        }

        # Keep a fast-access instance value
        self.enable_sound_effects = general_settings.get('enable_sound_effects', False)
        
        # Update cache kill switch
        if disable_cache_cb is not None:
            self.disable_all_caches = disable_cache_cb.isChecked()
            if self.disable_all_caches:
                self.log("🧪 EXPERIMENTAL: All caches DISABLED - direct lookups enabled")
                # Stop any running background workers that use the database
                if hasattr(self, 'termbase_batch_stop_event'):
                    self.termbase_batch_stop_event.set()
                if hasattr(self, 'prefetch_stop_event'):
                    self.prefetch_stop_event.set()
            else:
                self.log("✓ Caches enabled (normal mode)")

        # Persist per-event sound mapping
        existing_map = existing_settings.get('sound_effects_map', {}) if isinstance(existing_settings, dict) else {}
        new_map = dict(existing_map) if isinstance(existing_map, dict) else {}
        if isinstance(sound_event_combos, dict):
            for event_key, combo in sound_event_combos.items():
                try:
                    new_map[event_key] = combo.currentData()
                except Exception:
                    pass
        # Ensure key exists even if UI wasn't present
        if 'glossary_term_added' not in new_map:
            new_map['glossary_term_added'] = existing_map.get('glossary_term_added', 'asterisk') if isinstance(existing_map, dict) else 'asterisk'
        if 'glossary_created' not in new_map:
            new_map['glossary_created'] = existing_map.get('glossary_created', 'asterisk') if isinstance(existing_map, dict) else 'asterisk'
        if 'match_inserted' not in new_map:
            new_map['match_inserted'] = existing_map.get('match_inserted', 'ok') if isinstance(existing_map, dict) else 'ok'
        if 'glossary_term_duplicate' not in new_map:
            new_map['glossary_term_duplicate'] = existing_map.get('glossary_term_duplicate', 'exclamation') if isinstance(existing_map, dict) else 'exclamation'
        if 'glossary_term_error' not in new_map:
            new_map['glossary_term_error'] = existing_map.get('glossary_term_error', 'hand') if isinstance(existing_map, dict) else 'hand'

        general_settings['sound_effects_map'] = new_map
        self.sound_effects_map = new_map
        
        # Add match limits if provided (MT, TM, Termbase - LLM is handled in AI Settings)
        if all([mt_spin, tm_limit_spin, tb_spin]):
            existing_limits = existing_settings.get('match_limits', {})
            general_settings['match_limits'] = {
                'LLM': existing_limits.get('LLM', 3),  # Preserve LLM limit from AI settings
                'MT': mt_spin.value(),
                'TM': tm_limit_spin.value(),
                'Termbases': tb_spin.value()
            }
            # Apply to all results panels immediately
            if hasattr(self, 'results_panels'):
                for panel in self.results_panels:
                    panel.match_limits = general_settings['match_limits']
        
        # Update instance variable from checkbox
        if auto_insert_100_cb is not None:
            self.auto_insert_100_percent_matches = auto_insert_100_cb.isChecked()
        elif hasattr(self, 'auto_insert_100_checkbox'):
            self.auto_insert_100_percent_matches = self.auto_insert_100_checkbox.isChecked()
        
        # Update TM save mode
        if tm_save_mode_combo is not None:
            self.tm_save_mode = tm_save_mode_combo.currentData()
        
        self.save_general_settings(general_settings)

        # If a project is active, also save settings to project file (project overrides)
        if self.current_project:
            self.current_project.general_settings_overrides = {
                'auto_center_active_segment': self.auto_center_active_segment,
                'auto_propagate_exact_matches': self.auto_propagate_exact_matches,
                'auto_insert_100_percent_matches': self.auto_insert_100_percent_matches,
                'auto_confirm_100_percent_matches': self.auto_confirm_100_percent_matches,
                'auto_confirm_overwrite_existing': self.auto_confirm_overwrite_existing,
                'enable_termbase_grid_highlighting': self.enable_termbase_grid_highlighting,
                'precision_scroll_divisor': self.precision_scroll_divisor,
                'termbase_hide_shorter_matches': self.termbase_hide_shorter_matches,
                'enable_smart_word_selection': self.enable_smart_word_selection,
                'enable_sound_effects': self.enable_sound_effects,
                'sound_effects_map': getattr(self, 'sound_effects_map', {}),
            }
            self.log("💾 Settings also saved to active project")
            self.project_modified = True  # Mark project as modified

        # Restart auto backup timer if settings changed
        if enable_backup_cb is not None or backup_interval_spin is not None:
            self.restart_auto_backup_timer()

        self.log("✓ General settings saved")
        QMessageBox.information(self, "Settings Saved", "General settings have been saved successfully.")
    
    def _save_view_settings_from_ui(self, grid_spin, match_spin, compare_spin, show_tags_check=None, tag_color_btn=None,
                                     alt_colors_check=None, even_color_btn=None, odd_color_btn=None, invisible_char_color_btn=None,
                                     grid_font_family_combo=None, termlens_font_family_combo=None, termlens_font_spin=None, termlens_bold_check=None,
                                     border_color_btn=None, border_thickness_spin=None, badge_text_color_btn=None, tabs_above_check=None,
                                     hide_wrapping_tags_check=None, status_before_target_check=None,
                                     mp_font_family_combo=None, mp_font_spin=None, mp_bold_check=None):
        """Save view settings from UI"""
        # CRITICAL: Suppress TM saves during view settings update
        # Grid operations (setStyleSheet, rehighlight, etc.) can trigger textChanged events
        # which would cause mass TM saves for all confirmed segments
        previous_suppression = getattr(self, '_suppress_target_change_handlers', False)
        self._suppress_target_change_handlers = True

        try:
            self._save_view_settings_from_ui_impl(
                grid_spin, match_spin, compare_spin, show_tags_check, tag_color_btn,
                alt_colors_check, even_color_btn, odd_color_btn, invisible_char_color_btn,
                grid_font_family_combo, termlens_font_family_combo, termlens_font_spin, termlens_bold_check,
                border_color_btn, border_thickness_spin, badge_text_color_btn, tabs_above_check,
                hide_wrapping_tags_check, status_before_target_check,
                mp_font_family_combo=mp_font_family_combo, mp_font_spin=mp_font_spin, mp_bold_check=mp_bold_check
            )
        finally:
            self._suppress_target_change_handlers = previous_suppression

    def _save_view_settings_from_ui_impl(self, grid_spin, match_spin, compare_spin, show_tags_check=None, tag_color_btn=None,
                                     alt_colors_check=None, even_color_btn=None, odd_color_btn=None, invisible_char_color_btn=None,
                                     grid_font_family_combo=None, termlens_font_family_combo=None, termlens_font_spin=None, termlens_bold_check=None,
                                     border_color_btn=None, border_thickness_spin=None, badge_text_color_btn=None, tabs_above_check=None,
                                     hide_wrapping_tags_check=None, status_before_target_check=None,
                                     mp_font_family_combo=None, mp_font_spin=None, mp_bold_check=None):
        """Implementation of save view settings (called with TM saves suppressed)"""
        # Load existing settings first to preserve all values, then update with new ones
        general_settings = self.load_general_settings()

        # Snapshot old values for change detection – only run expensive grid loops
        # when the relevant setting actually changed
        _old_tag_color = getattr(EditableGridTextEditor, 'tag_highlight_color', '#7f0001')
        _old_invisible_color = getattr(self, 'invisible_char_color', '#999999')
        _old_border_color = getattr(EditableGridTextEditor, 'focus_border_color', '')
        _old_border_thickness = getattr(EditableGridTextEditor, 'focus_border_thickness', 2)
        _old_alt_colors = getattr(self, 'enable_alternating_row_colors', True)
        _old_even_color = getattr(self, 'even_row_color', '#FFFFFF')
        _old_odd_color = getattr(self, 'odd_row_color', '#F0F0F0')
        _old_hide_tags = getattr(self, 'hide_outer_wrapping_tags', False)
        general_settings.update({
            'auto_propagate_exact_matches': self.auto_propagate_exact_matches,
            'grid_font_size': grid_spin.value(),
            'results_match_font_size': match_spin.value(),
            'results_compare_font_size': compare_spin.value(),
            'enable_tm_termbase_matching': self.enable_tm_matching
        })
        
        # Add tabs above grid setting if provided
        if tabs_above_check is not None:
            general_settings['tabs_above_grid'] = tabs_above_check.isChecked()
            self.tabs_above_grid = tabs_above_check.isChecked()
        
        # Add font family if provided
        if grid_font_family_combo is not None:
            general_settings['grid_font_family'] = grid_font_family_combo.currentText()
        
        # Add TermLens font settings if provided
        if termlens_font_family_combo is not None:
            general_settings['termlens_font_family'] = termlens_font_family_combo.currentText()
        if termlens_font_spin is not None:
            general_settings['termlens_font_size'] = termlens_font_spin.value()
        if termlens_bold_check is not None:
            general_settings['termlens_font_bold'] = termlens_bold_check.isChecked()

        # Add Match Panel font settings if provided
        if mp_font_family_combo is not None:
            general_settings['match_panel_font_family'] = mp_font_family_combo.currentText()
        if mp_font_spin is not None:
            general_settings['match_panel_font_size'] = mp_font_spin.value()
        if mp_bold_check is not None:
            general_settings['match_panel_font_bold'] = mp_bold_check.isChecked()

        # Add tag color if provided
        if tag_color_btn:
            tag_color = tag_color_btn.property('selected_color')
            if tag_color:
                general_settings['tag_highlight_color'] = tag_color
                EditableGridTextEditor.tag_highlight_color = tag_color

        # Add badge text color if provided
        if badge_text_color_btn:
            badge_text_color = badge_text_color_btn.property('selected_color')
            if badge_text_color:
                general_settings['badge_text_color'] = badge_text_color
                from modules.translation_results_panel import CompactMatchItem
                CompactMatchItem.badge_text_color = badge_text_color

        # Add invisible character color if provided
        if invisible_char_color_btn:
            invisible_char_color = invisible_char_color_btn.property('selected_color')
            if invisible_char_color:
                general_settings['invisible_char_color'] = invisible_char_color
                self.invisible_char_color = invisible_char_color

        # Add hide outer wrapping tags setting if provided
        if hide_wrapping_tags_check is not None:
            hide_tags_value = hide_wrapping_tags_check.isChecked()
            general_settings['hide_outer_wrapping_tags'] = hide_tags_value
            self.hide_outer_wrapping_tags = hide_tags_value

        # Add status column position setting if provided
        if status_before_target_check is not None:
            status_before = status_before_target_check.isChecked()
            general_settings['status_column_before_target'] = status_before
            self.status_column_before_target = status_before
            # Apply column reorder immediately
            if hasattr(self, 'table') and self.table is not None:
                header = self.table.horizontalHeader()
                if status_before:
                    # Move Status (logical 4) to visual position 3 (before Target)
                    header.moveSection(header.visualIndex(4), 3)
                else:
                    # Move Status (logical 4) back to visual position 4 (after Target)
                    header.moveSection(header.visualIndex(4), 4)

        # Add focus border settings if provided
        if border_color_btn is not None:
            border_color = border_color_btn.property('selected_color')
            # Fallback: if property is empty, try to parse from stylesheet
            if not border_color:
                style = border_color_btn.styleSheet()
                if 'background-color:' in style:
                    import re
                    match = re.search(r'background-color:\s*(#[0-9A-Fa-f]{6})', style)
                    if match:
                        border_color = match.group(1)
            if border_color:
                general_settings['focus_border_color'] = border_color
                EditableGridTextEditor.focus_border_color = border_color
        
        if border_thickness_spin is not None:
            thickness = border_thickness_spin.value()
            general_settings['focus_border_thickness'] = thickness
            EditableGridTextEditor.focus_border_thickness = thickness
        
        # Add alternating row color settings
        if alt_colors_check is not None:
            general_settings['enable_alternating_row_colors'] = alt_colors_check.isChecked()
            self.enable_alternating_row_colors = alt_colors_check.isChecked()
        
        if even_color_btn is not None:
            even_color = even_color_btn.property('selected_color')
            if even_color:
                general_settings['even_row_color'] = even_color
                self.even_row_color = even_color
        
        if odd_color_btn is not None:
            odd_color = odd_color_btn.property('selected_color')
            if odd_color:
                general_settings['odd_row_color'] = odd_color
                self.odd_row_color = odd_color
        
        # Save termbase highlight style settings
        if hasattr(self, '_tb_style_group') and self._tb_style_group is not None:
            checked_id = self._tb_style_group.checkedId()
            style_map = {0: 'background', 1: 'dotted', 2: 'semibold'}
            tb_style = style_map.get(checked_id, 'background')
            general_settings['termbase_highlight_style'] = tb_style
            self.termbase_highlight_style = tb_style
        
        if hasattr(self, '_tb_dotted_color_btn') and self._tb_dotted_color_btn is not None:
            dotted_color = self._tb_dotted_color_btn.property('selected_color')
            if dotted_color:
                general_settings['termbase_dotted_color'] = dotted_color
                self.termbase_dotted_color = dotted_color
        
        self.save_general_settings(general_settings)

        # Apply termlens font settings immediately to BOTH termlens widgets
        if hasattr(self, 'termlens_widget') and self.termlens_widget is not None:
            termlens_family = general_settings.get('termlens_font_family', 'Segoe UI')
            termlens_size = general_settings.get('termlens_font_size', 10)
            termlens_bold = general_settings.get('termlens_font_bold', False)
            self.termlens_widget.set_font_settings(termlens_family, termlens_size, termlens_bold)
        
        # Also apply to the Match Panel's TermLens widget
        if hasattr(self, 'termlens_widget_match') and self.termlens_widget_match is not None:
            termlens_family = general_settings.get('termlens_font_family', 'Segoe UI')
            termlens_size = general_settings.get('termlens_font_size', 10)
            termlens_bold = general_settings.get('termlens_font_bold', False)
            self.termlens_widget_match.set_font_settings(termlens_family, termlens_size, termlens_bold)

        # Apply Match Panel TM Source/Target font settings immediately
        if mp_font_spin is not None:
            new_mp_size = mp_font_spin.value()
            if 6 <= new_mp_size <= 18:
                SupervertalerQt.match_panel_font_size = new_mp_size
        if mp_font_family_combo is not None:
            SupervertalerQt.match_panel_font_family = mp_font_family_combo.currentText()
        if mp_bold_check is not None:
            SupervertalerQt.match_panel_font_bold = mp_bold_check.isChecked()
        if mp_font_spin is not None or mp_font_family_combo is not None or mp_bold_check is not None:
            self._apply_match_panel_font_size()


        # Apply font family and size immediately
        font_changed = False
        if grid_font_family_combo is not None and self.default_font_family != grid_font_family_combo.currentText():
            self.default_font_family = grid_font_family_combo.currentText()
            font_changed = True
        if self.default_font_size != grid_spin.value():
            self.default_font_size = grid_spin.value()
            font_changed = True
        if font_changed and hasattr(self, 'table') and self.table is not None:
            self.apply_font_to_grid()
            self.auto_resize_rows()
        

        # Apply results pane font sizes to all panels
        if hasattr(self, 'results_panels'):
            from modules.translation_results_panel import CompactMatchItem
            if CompactMatchItem.font_size_pt != match_spin.value():
                CompactMatchItem.set_font_size(match_spin.value())
                for panel in self.results_panels:
                    if hasattr(panel, 'set_font_size'):
                        panel.set_font_size(match_spin.value())
        
            from modules.translation_results_panel import TranslationResultsPanel
            if TranslationResultsPanel.compare_box_font_size != compare_spin.value():
                TranslationResultsPanel.compare_box_font_size = compare_spin.value()
                for panel in self.results_panels:
                    if hasattr(panel, 'set_compare_box_font_size'):
                        panel.set_compare_box_font_size(compare_spin.value())
            
            # Apply show_tags setting
            if show_tags_check:
                show_tags = show_tags_check.isChecked()
                CompactMatchItem.show_tags = show_tags
                for panel in self.results_panels:
                    if hasattr(panel, 'set_show_tags'):
                        panel.set_show_tags(show_tags)
            
            # Apply tag color setting
            if tag_color_btn:
                tag_color = tag_color_btn.property('selected_color')
                if tag_color:
                    for panel in self.results_panels:
                        if hasattr(panel, 'set_tag_color'):
                            panel.set_tag_color(tag_color)

        # Apply badge text color to all results panels
        if badge_text_color_btn and hasattr(self, 'results_panels'):
            badge_text_color = badge_text_color_btn.property('selected_color')
            if badge_text_color:
                from modules.translation_results_panel import CompactMatchItem
                CompactMatchItem.badge_text_color = badge_text_color


        # --- Expensive grid loops: only run when the relevant setting changed ---

        _has_table = hasattr(self, 'table') and self.table is not None

        # Apply invisible char color to grid cells (only if color changed)
        if invisible_char_color_btn and _has_table:
            invisible_char_color = invisible_char_color_btn.property('selected_color')
            if invisible_char_color and invisible_char_color != _old_invisible_color:
                for row in range(self.table.rowCount()):
                    if row % 50 == 0:
                        QApplication.processEvents()
                    for col in [2, 3]:  # Source and target columns
                        widget = self.table.cellWidget(row, col)
                        if widget and hasattr(widget, 'highlighter'):
                            widget.highlighter.set_invisible_char_color(invisible_char_color)

        # Apply focus border settings to all grid cells (only if border settings changed)
        if (border_color_btn is not None or border_thickness_spin is not None) and _has_table:
            border_color = EditableGridTextEditor.focus_border_color
            border_thickness = EditableGridTextEditor.focus_border_thickness
            if border_color != _old_border_color or border_thickness != _old_border_thickness:
                self.log(f"Applying focus border: color={border_color}, thickness={border_thickness}px")
                for row in range(self.table.rowCount()):
                    if row % 50 == 0:
                        QApplication.processEvents()
                    widget = self.table.cellWidget(row, 3)  # Target column
                    if widget and isinstance(widget, EditableGridTextEditor):
                        widget.setStyleSheet(f"""
                            QTextEdit {{
                                border: none;
                                padding: 0px 4px 0px 0px;
                            }}
                            QTextEdit:focus {{
                                border: {border_thickness}px solid {border_color};
                            }}
                            QTextEdit::selection {{
                                background-color: #D0E7FF;
                                color: black;
                            }}
                        """)

        # Refresh tag colors (only if tag color changed)
        if _has_table:
            new_tag_color = general_settings.get('tag_highlight_color', '#7f0001')
            if new_tag_color != _old_tag_color:
                self.refresh_grid_tag_colors()

        # Refresh alternating row colors (only if row color settings changed)
        if _has_table:
            new_alt = general_settings.get('enable_alternating_row_colors', True)
            new_even = general_settings.get('even_row_color', '')
            new_odd = general_settings.get('odd_row_color', '')
            if new_alt != _old_alt_colors or new_even != _old_even_color or new_odd != _old_odd_color:
                self.apply_alternating_row_colors()

        # Refresh source column if hide_outer_wrapping_tags setting changed
        if hide_wrapping_tags_check is not None and _has_table:
            if hide_wrapping_tags_check.isChecked() != _old_hide_tags:
                self._refresh_source_column_display()


        self.log("✓ View settings saved and applied")
        # Use explicit QMessageBox instance to ensure proper dialog closing
        msg = QMessageBox(self)
        msg.setIcon(QMessageBox.Icon.Information)
        msg.setWindowTitle("Settings Saved")
        msg.setText("View settings have been saved and applied successfully.")
        msg.setStandardButtons(QMessageBox.StandardButton.Ok)
        msg.exec()
    
    def _apply_global_ui_font_scale(self, scale_percent: int):
        """Apply font scale to the entire application UI"""
        general_settings = self.load_general_settings()
        general_settings['global_ui_font_scale'] = scale_percent
        # Remove old key if present (migration)
        if 'settings_ui_font_scale' in general_settings:
            del general_settings['settings_ui_font_scale']
        self.save_general_settings(general_settings)

        # Invalidate the modules.ui_scale cache so newly-constructed Sidekick /
        # clipboard widgets see the new value (already-open ones keep their
        # construction-time scale until reopened).
        try:
            from modules.ui_scale import refresh_ui_font_scale
            refresh_ui_font_scale()
        except Exception:
            pass

        # Update ThemeManager and reapply theme
        if hasattr(self, 'theme_manager') and self.theme_manager is not None:
            self.theme_manager.font_scale = scale_percent
            self.theme_manager.apply_theme(QApplication.instance())

        # Update status bar and main tabs fonts
        self._update_status_bar_fonts(scale_percent)
        self._update_main_tabs_fonts(scale_percent)
        self.log(f"✓ Global UI font scale set to {scale_percent}%")

    def _update_status_bar_fonts(self, scale_percent: int):
        """Update status bar label fonts based on scale percentage"""
        base_size = int(9 * scale_percent / 100)
        small_size = max(7, base_size)
        style = f"font-size: {small_size}pt;"

        # Update all status bar labels if they exist
        for attr_name in ['segment_count_label', 'file_label', 'tm_status_label',
                          'termbase_status_label', 'source_lang_label', 'target_lang_label']:
            if hasattr(self, attr_name):
                label = getattr(self, attr_name)
                if label is not None:
                    label.setStyleSheet(style)

    def _update_main_tabs_fonts(self, scale_percent: int):
        """Update main tab bar and settings sidebar fonts based on scale percentage"""
        base_size = int(10 * scale_percent / 100)
        if hasattr(self, 'main_tabs') and self.main_tabs is not None:
            self.main_tabs.tabBar().setStyleSheet(f"font-size: {base_size}pt;")
        # Also scale the sidebar list fonts (Settings + Tools)
        from modules.settings_sidebar import SettingsSidebar
        if hasattr(self, 'settings_tabs') and isinstance(self.settings_tabs, SettingsSidebar):
            self.settings_tabs.set_font_size(base_size)
        if hasattr(self, 'modules_tabs') and isinstance(self.modules_tabs, SettingsSidebar):
            self.modules_tabs.set_font_size(base_size)

    def _get_global_ui_font_scale(self) -> int:
        """Get the current global UI font scale percentage"""
        general_settings = self.load_general_settings()
        # Check new key first, fall back to old key for migration
        return general_settings.get('global_ui_font_scale',
                                    general_settings.get('settings_ui_font_scale', 100))

    def _get_ui_chrome_compact(self) -> bool:
        """True when Compact UI chrome is enabled (tighter menu bar and tab strip padding).

        Default changed from False → True in v1.9.386: compact is now the shipped look.
        Users who have explicitly set it False keep that setting; only users who have
        never touched it get the new default on next launch.
        """
        general_settings = self.load_general_settings()
        return bool(general_settings.get('compact_ui_chrome', True))

    def _apply_ui_chrome_compactness(self, compact: bool, persist: bool = True):
        """Apply or remove compact-chrome styling on menu bar, main tabs, and right tabs.

        Saves the new state to general settings by default; pass persist=False for
        non-interactive re-application on startup.
        """
        compact = bool(compact)

        if persist:
            general_settings = self.load_general_settings()
            general_settings['compact_ui_chrome'] = compact
            self.save_general_settings(general_settings)

        main_tab_padding = "3px 12px" if compact else "8px 15px"
        right_tab_padding_rule = (
            f"QTabBar::tab {{ padding: 3px 10px; outline: 0; }}"
            if compact
            else "QTabBar::tab { outline: 0; }"
        )
        menu_item_padding = "2px 8px" if compact else ""

        if hasattr(self, 'main_tabs') and self.main_tabs is not None:
            self.main_tabs.setStyleSheet(
                f"QTabBar::tab {{ padding: {main_tab_padding}; outline: 0; }}\n"
                "QTabBar::tab:focus { outline: none; }\n"
                "QTabBar::tab:selected {\n"
                "    border-bottom: 1px solid #2196F3;\n"
                "    background-color: rgba(33, 150, 243, 0.08);\n"
                "}"
            )

        if hasattr(self, 'right_tabs') and self.right_tabs is not None:
            self.right_tabs.setStyleSheet(
                f"{right_tab_padding_rule} "
                "QTabBar::tab:focus { outline: none; } "
                "QTabBar::tab:selected { border-bottom: 1px solid #2196F3; "
                "background-color: rgba(33, 150, 243, 0.08); }"
            )

        menu_bar = self.menuBar() if hasattr(self, 'menuBar') else None
        if menu_bar is not None:
            menu_bar.setStyleSheet(
                f"QMenuBar::item {{ padding: {menu_item_padding}; }}" if menu_item_padding else ""
            )

        try:
            self.log(f"✓ UI chrome: {'compact' if compact else 'default'}")
        except Exception:
            pass

    def create_grid_view_widget(self):
        """Create the Grid View widget (existing grid functionality)"""
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        
        # Splitter for grid and assistance panel
        self.editor_splitter = QSplitter(Qt.Orientation.Horizontal)
        
        # Left side: Grid container with filter boxes
        grid_container = QWidget()
        grid_layout = QVBoxLayout(grid_container)
        grid_layout.setContentsMargins(0, 0, 0, 0)
        grid_layout.setSpacing(5)
        
        # Warning banner for replace in source (hidden by default)
        editor_warning = self._get_warning_banner('editor_grid')
        grid_layout.addWidget(editor_warning)
        editor_warning.hide()  # Hidden by default
        
        # Filter panel (like memoQ)
        filter_panel = QWidget()
        filter_layout = QHBoxLayout(filter_panel)
        filter_layout.setContentsMargins(0, 0, 0, 0)
        filter_layout.setSpacing(10)
        
        # Source filter
        source_filter_label = QLabel("Source:")
        self.source_filter = self._ensure_shared_filter(
            'source_filter',
            "Type to filter source segments...",
            on_change=self.apply_filters,
            on_return=self.apply_filters,
        )
        
        # Target filter
        target_filter_label = QLabel("Target:")
        self.target_filter = self._ensure_shared_filter(
            'target_filter',
            "Type to filter target segments...",
            on_change=self.apply_filters,
            on_return=self.apply_filters,
        )
        
        # Clear filters button
        clear_filters_btn = QPushButton("Clear Filters")
        clear_filters_btn.clicked.connect(self.clear_filters)
        clear_filters_btn.setMaximumWidth(100)
        clear_filters_btn.setStyleSheet("padding: 3px 5px;")

        # Show Invisibles button with dropdown menu
        show_invisibles_btn = QPushButton("¶ Show Invisibles")
        show_invisibles_btn.setMaximumWidth(140)
        show_invisibles_btn.setStyleSheet("background-color: #607D8B; color: white; font-weight: bold; padding: 3px 5px;")
        show_invisibles_menu = QMenu(show_invisibles_btn)

        # Create checkable actions for each invisible character type
        self.show_spaces_action = show_invisibles_menu.addAction("Spaces (·)")
        self.show_spaces_action.setCheckable(True)
        self.show_spaces_action.setChecked(False)
        self.show_spaces_action.triggered.connect(lambda: self.toggle_invisible_display('spaces'))

        self.show_tabs_action = show_invisibles_menu.addAction("Tabs (→)")
        self.show_tabs_action.setCheckable(True)
        self.show_tabs_action.setChecked(True)
        self.show_tabs_action.triggered.connect(lambda: self.toggle_invisible_display('tabs'))

        self.show_nbsp_action = show_invisibles_menu.addAction("Non-breaking Spaces (°)")
        self.show_nbsp_action.setCheckable(True)
        self.show_nbsp_action.setChecked(True)
        self.show_nbsp_action.triggered.connect(lambda: self.toggle_invisible_display('nbsp'))

        self.show_linebreaks_action = show_invisibles_menu.addAction("Line Breaks (↵)")
        self.show_linebreaks_action.setCheckable(True)
        self.show_linebreaks_action.setChecked(True)
        self.show_linebreaks_action.triggered.connect(lambda: self.toggle_invisible_display('linebreaks'))

        show_invisibles_menu.addSeparator()

        # Toggle All option
        toggle_all_action = show_invisibles_menu.addAction("Toggle All")
        toggle_all_action.triggered.connect(self.toggle_all_invisibles)

        show_invisibles_btn.setMenu(show_invisibles_menu)

        # Spellcheck toggle button with dropdown menu
        # Load saved spellcheck state from preferences
        saved_enabled = self._load_spellcheck_settings()
        self.spellcheck_enabled = saved_enabled
        TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)
        
        self.spellcheck_btn = QPushButton("📝 Spellcheck")
        self.spellcheck_btn.setMaximumWidth(120)
        self.spellcheck_btn.setCheckable(True)
        self.spellcheck_btn.setChecked(self.spellcheck_enabled)
        self._update_spellcheck_button_style()
        
        # Create dropdown menu for spellcheck options
        spellcheck_menu = QMenu(self.spellcheck_btn)
        
        # Toggle spellcheck action
        self.spellcheck_toggle_action = spellcheck_menu.addAction("Enable Spellcheck")
        self.spellcheck_toggle_action.setCheckable(True)
        self.spellcheck_toggle_action.setChecked(self.spellcheck_enabled)
        self.spellcheck_toggle_action.triggered.connect(self._toggle_spellcheck)
        
        spellcheck_menu.addSeparator()
        
        # Open custom dictionary action
        open_dict_action = spellcheck_menu.addAction("📖 Manage Custom Dictionary...")
        open_dict_action.triggered.connect(self._open_custom_dictionary_dialog)
        
        # Show spellcheck info
        info_action = spellcheck_menu.addAction("ℹ️ Spellcheck Info")
        info_action.triggered.connect(self._show_spellcheck_info)
        
        self.spellcheck_btn.setMenu(spellcheck_menu)
        # Also allow clicking the button itself to toggle
        self.spellcheck_btn.clicked.connect(self._toggle_spellcheck_from_button)

        filter_layout.addWidget(source_filter_label)
        filter_layout.addWidget(self.source_filter, stretch=1)
        filter_layout.addWidget(target_filter_label)
        filter_layout.addWidget(self.target_filter, stretch=1)
        filter_layout.addWidget(clear_filters_btn)
        filter_layout.addWidget(show_invisibles_btn)
        filter_layout.addWidget(self.spellcheck_btn)
        
        grid_layout.addWidget(filter_panel)
        
        # Translation Grid (create it if it doesn't exist or has been deleted)
        if not hasattr(self, 'table') or not self._widget_is_alive(self.table):
            self.create_translation_grid()
        grid_layout.addWidget(self.table)
        
        # Add grid container to splitter
        self.editor_splitter.addWidget(grid_container)
        
        # Set splitter proportions - Qt displays RIGHT to LEFT
        # Position 0 (grid/RIGHT) gets 1000, Position 1 (assistance/LEFT) gets 400
        self.editor_splitter.setSizes([1000, 400])
        
        layout.addWidget(self.editor_splitter)
        
        self.update_warning_banner()

        return widget
    
    def create_grid_view_widget_for_home(self):
        """Create Grid View widget with translation results panel on the right (memoQ style)"""
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        
        # Main horizontal splitter: Grid+Segment Editor (left) and Translation Results (right)
        main_horizontal_splitter = QSplitter(Qt.Orientation.Horizontal)
        
        # Left side: Vertical splitter with Grid (top) and Segment Editor/Comments (bottom)
        left_vertical_splitter = QSplitter(Qt.Orientation.Vertical)
        
        # Top of left side: Grid container with filter boxes
        grid_container = QWidget()
        grid_layout = QVBoxLayout(grid_container)
        grid_layout.setContentsMargins(0, 0, 0, 0)
        grid_layout.setSpacing(5)
        
        # Warning banner for replace in source (hidden by default)
        home_warning = self._get_warning_banner('home_grid')
        grid_layout.addWidget(home_warning)
        home_warning.hide()  # Hidden by default
        
        # Filter panel (like memoQ)
        filter_panel = QWidget()
        filter_layout = QHBoxLayout(filter_panel)
        filter_layout.setContentsMargins(0, 0, 0, 0)
        filter_layout.setSpacing(10)
        
        # Source filter
        source_filter_label = QLabel("Source:")
        self.source_filter = self._ensure_shared_filter(
            'source_filter',
            "Type to filter source segments... (Press Enter or click Filter)",
            on_return=self.apply_filters,
        )
        
        # Target filter
        target_filter_label = QLabel("Target:")
        self.target_filter = self._ensure_shared_filter(
            'target_filter',
            "Type to filter target segments... (Press Enter or click Filter)",
            on_return=self.apply_filters,
        )
        
        # Filter button (activates the filter)
        apply_filter_btn = QPushButton("Filter")
        apply_filter_btn.clicked.connect(self.apply_filters)
        apply_filter_btn.setMaximumWidth(80)
        apply_filter_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 3px 5px;")
        
        # Clear filters button
        clear_filters_btn = QPushButton("Clear Filters")
        clear_filters_btn.clicked.connect(self.clear_filters)
        clear_filters_btn.setMaximumWidth(100)
        clear_filters_btn.setStyleSheet("padding: 3px 5px;")
        
        # Quick Filters dropdown menu
        quick_filter_btn = QPushButton("⚡ Quick Filters")
        quick_filter_btn.setMaximumWidth(130)
        quick_filter_btn.setStyleSheet("background-color: #D84315; color: white; font-weight: bold; padding: 3px 5px;")
        quick_filter_menu = QMenu(self)
        quick_filter_menu.addAction("🔍 Empty segments", lambda: self.apply_quick_filter("empty"))
        quick_filter_menu.addAction("❌ Not started", lambda: self.apply_quick_filter("not_started"))
        quick_filter_menu.addAction("✓ Confirmed", lambda: self.apply_quick_filter("confirmed"))
        quick_filter_menu.addAction("🔒 Locked", lambda: self.apply_quick_filter("locked"))
        quick_filter_menu.addAction("🔓 Not locked", lambda: self.apply_quick_filter("not_locked"))
        quick_filter_menu.addAction("💬 Commented", lambda: self.apply_quick_filter("commented"))
        quick_filter_btn.setMenu(quick_filter_menu)
        
        # Advanced Filters dialog button
        advanced_filter_btn = QPushButton("⚙️ Advanced Filters")
        advanced_filter_btn.clicked.connect(self.show_advanced_filters_dialog)
        advanced_filter_btn.setMaximumWidth(160)
        advanced_filter_btn.setStyleSheet("background-color: #2196F3; color: white; font-weight: bold; padding: 3px 5px;")

        # Sort dropdown button (similar to memoQ)
        sort_btn = QPushButton("⇅ Sort")
        sort_btn.setMaximumWidth(100)
        sort_btn.setStyleSheet("background-color: #FF9800; color: white; font-weight: bold; padding: 3px 5px;")
        sort_menu = QMenu(self)

        # Initialize sort state if not exists
        if not hasattr(self, 'current_sort'):
            self.current_sort = None  # None = document order

        # Sort by source text
        sort_menu.addAction("📝 Source A → Z", lambda: self.apply_sort('source_asc'))
        sort_menu.addAction("📝 Source Z → A", lambda: self.apply_sort('source_desc'))

        sort_menu.addSeparator()

        # Sort by target text
        sort_menu.addAction("📄 Target A → Z", lambda: self.apply_sort('target_asc'))
        sort_menu.addAction("📄 Target Z → A", lambda: self.apply_sort('target_desc'))

        sort_menu.addSeparator()

        # Sort by text length
        sort_menu.addAction("📏 Source (longer first)", lambda: self.apply_sort('source_length_desc'))
        sort_menu.addAction("📏 Source (shorter first)", lambda: self.apply_sort('source_length_asc'))
        sort_menu.addAction("📏 Target (longer first)", lambda: self.apply_sort('target_length_desc'))
        sort_menu.addAction("📏 Target (shorter first)", lambda: self.apply_sort('target_length_asc'))

        sort_menu.addSeparator()

        # Sort by match rate
        sort_menu.addAction("🎯 Match Rate (higher first)", lambda: self.apply_sort('match_desc'))
        sort_menu.addAction("🎯 Match Rate (lower first)", lambda: self.apply_sort('match_asc'))

        sort_menu.addSeparator()

        # Sort by frequency
        sort_menu.addAction("📊 Source Frequency (higher first)", lambda: self.apply_sort('source_freq_desc'))
        sort_menu.addAction("📊 Source Frequency (lower first)", lambda: self.apply_sort('source_freq_asc'))
        sort_menu.addAction("📊 Target Frequency (higher first)", lambda: self.apply_sort('target_freq_desc'))
        sort_menu.addAction("📊 Target Frequency (lower first)", lambda: self.apply_sort('target_freq_asc'))

        sort_menu.addSeparator()

        # Sort by last changed
        sort_menu.addAction("🕒 Last Changed (newest first)", lambda: self.apply_sort('modified_desc'))
        sort_menu.addAction("🕒 Last Changed (oldest first)", lambda: self.apply_sort('modified_asc'))

        sort_menu.addSeparator()

        # Sort by row status
        sort_menu.addAction("🚦 Row Status", lambda: self.apply_sort('status'))

        sort_menu.addSeparator()

        # Reset to document order
        sort_menu.addAction("↩️ Document Order (default)", lambda: self.apply_sort(None))

        sort_btn.setMenu(sort_menu)
        sort_btn.setToolTip("Sort segments by various criteria")

        # File filter dropdown (for multi-file projects)
        self.file_filter_combo = QComboBox()
        self.file_filter_combo.setMinimumWidth(150)
        self.file_filter_combo.setMaximumWidth(250)
        self.file_filter_combo.addItem("📁 All Files", None)
        self.file_filter_combo.currentIndexChanged.connect(self._on_file_filter_changed)
        self.file_filter_combo.setToolTip("Filter segments by file (multi-file projects only)")
        self.file_filter_combo.hide()  # Hidden by default, shown for multi-file projects

        # Show Invisibles button with dropdown menu
        show_invisibles_btn_home = QPushButton("¶ Show Invisibles")
        show_invisibles_btn_home.setMaximumWidth(140)
        show_invisibles_btn_home.setStyleSheet("background-color: #607D8B; color: white; font-weight: bold; padding: 3px 5px;")
        show_invisibles_menu_home = QMenu(show_invisibles_btn_home)

        # Use the same actions (they're stored as instance variables)
        if not hasattr(self, 'show_spaces_action'):
            # Create actions if they don't exist yet (first time)
            self.show_spaces_action = show_invisibles_menu_home.addAction("Spaces (·)")
            self.show_spaces_action.setCheckable(True)
            self.show_spaces_action.setChecked(False)
            self.show_spaces_action.triggered.connect(lambda: self.toggle_invisible_display('spaces'))

            self.show_tabs_action = show_invisibles_menu_home.addAction("Tabs (→)")
            self.show_tabs_action.setCheckable(True)
            self.show_tabs_action.setChecked(True)
            self.show_tabs_action.triggered.connect(lambda: self.toggle_invisible_display('tabs'))

            self.show_nbsp_action = show_invisibles_menu_home.addAction("Non-breaking Spaces (°)")
            self.show_nbsp_action.setCheckable(True)
            self.show_nbsp_action.setChecked(True)
            self.show_nbsp_action.triggered.connect(lambda: self.toggle_invisible_display('nbsp'))

            self.show_linebreaks_action = show_invisibles_menu_home.addAction("Line Breaks (¶)")
            self.show_linebreaks_action.setCheckable(True)
            self.show_linebreaks_action.setChecked(True)
            self.show_linebreaks_action.triggered.connect(lambda: self.toggle_invisible_display('linebreaks'))

            show_invisibles_menu_home.addSeparator()

            toggle_all_action = show_invisibles_menu_home.addAction("Toggle All")
            toggle_all_action.triggered.connect(self.toggle_all_invisibles)
        else:
            # Reuse existing actions
            show_invisibles_menu_home.addAction(self.show_spaces_action)
            show_invisibles_menu_home.addAction(self.show_tabs_action)
            show_invisibles_menu_home.addAction(self.show_nbsp_action)
            show_invisibles_menu_home.addAction(self.show_linebreaks_action)
            show_invisibles_menu_home.addSeparator()
            toggle_all_action = show_invisibles_menu_home.addAction("Toggle All")
            toggle_all_action.triggered.connect(self.toggle_all_invisibles)

        show_invisibles_btn_home.setMenu(show_invisibles_menu_home)

        # Spellcheck toggle button (reuse the same instance variable from Editor tab)
        if not hasattr(self, 'spellcheck_btn') or self.spellcheck_btn is None:
            # Create spellcheck button if it doesn't exist yet
            self.spellcheck_btn = QPushButton("📝 Spellcheck")
            self.spellcheck_btn.setMaximumWidth(120)
            self.spellcheck_btn.setCheckable(True)
            self.spellcheck_btn.setChecked(self.spellcheck_enabled)
            self._update_spellcheck_button_style()
            
            # Create dropdown menu for spellcheck options
            spellcheck_menu = QMenu(self.spellcheck_btn)
            
            # Toggle spellcheck action
            self.spellcheck_toggle_action = spellcheck_menu.addAction("Enable Spellcheck")
            self.spellcheck_toggle_action.setCheckable(True)
            self.spellcheck_toggle_action.setChecked(self.spellcheck_enabled)
            self.spellcheck_toggle_action.triggered.connect(self._toggle_spellcheck)
            
            spellcheck_menu.addSeparator()
            
            # Open custom dictionary action
            open_dict_action = spellcheck_menu.addAction("📖 Manage Custom Dictionary...")
            open_dict_action.triggered.connect(self._open_custom_dictionary_dialog)
            
            # Show spellcheck info
            info_action = spellcheck_menu.addAction("ℹ️ Spellcheck Info")
            info_action.triggered.connect(self._show_spellcheck_info)
            
            self.spellcheck_btn.setMenu(spellcheck_menu)
            self.spellcheck_btn.clicked.connect(self._toggle_spellcheck_from_button)

        filter_layout.addWidget(source_filter_label)
        filter_layout.addWidget(self.source_filter, stretch=1)
        filter_layout.addWidget(target_filter_label)
        filter_layout.addWidget(self.target_filter, stretch=1)
        filter_layout.addWidget(apply_filter_btn)
        filter_layout.addWidget(clear_filters_btn)
        filter_layout.addWidget(quick_filter_btn)
        filter_layout.addWidget(advanced_filter_btn)
        filter_layout.addWidget(sort_btn)  # Sort dropdown
        filter_layout.addWidget(self.file_filter_combo)  # File filter for multi-file projects
        filter_layout.addWidget(show_invisibles_btn_home)
        filter_layout.addWidget(self.spellcheck_btn)
        
        # Don't add filter_panel to grid_layout yet - will be positioned based on tabs_above_grid setting
        
        # Pagination controls (like Tkinter version)
        pagination_panel = QWidget()
        pagination_panel.setStyleSheet("padding: 5px;")
        pagination_layout = QHBoxLayout(pagination_panel)
        pagination_layout.setContentsMargins(10, 5, 10, 5)
        pagination_layout.setSpacing(10)
        
        # Pagination label (left side)
        if not hasattr(self, 'pagination_label') or not self._widget_is_alive(self.pagination_label):
            self.pagination_label = QLabel("Segments 1-50 of 0")
        self.pagination_label.setStyleSheet("color: #555;")
        pagination_layout.addWidget(self.pagination_label)

        # Tip label for Ctrl+, shortcut (subtle, helpful for new users)
        tip_label = QLabel(f"💡 Tip: {format_shortcut_for_display('Ctrl+,')} inserts the next tag from source")
        tip_label.setStyleSheet("color: #888; font-size: 9pt; margin-left: 20px;")
        tip_label.setToolTip("Select text first to wrap it with a tag pair (e.g., <b>selection</b>)")
        pagination_layout.addWidget(tip_label)

        pagination_layout.addStretch()
        
        # Pagination controls (right side)
        # First page button
        if not hasattr(self, 'first_page_btn') or not self._widget_is_alive(self.first_page_btn):
            self.first_page_btn = QPushButton("⏮ First")
            self.first_page_btn.setMaximumWidth(70)
            if hasattr(self, 'go_to_first_page'):
                self.first_page_btn.clicked.connect(self.go_to_first_page)
        pagination_layout.addWidget(self.first_page_btn)
        
        # Previous page button
        if not hasattr(self, 'prev_page_btn') or not self._widget_is_alive(self.prev_page_btn):
            self.prev_page_btn = QPushButton("◀ Prev")
            self.prev_page_btn.setMaximumWidth(70)
            if hasattr(self, 'go_to_prev_page'):
                self.prev_page_btn.clicked.connect(self.go_to_prev_page)
        pagination_layout.addWidget(self.prev_page_btn)
        
        # Page number input
        page_label = QLabel("Page:")
        pagination_layout.addWidget(page_label)
        
        if not hasattr(self, 'page_number_input') or not self._widget_is_alive(self.page_number_input):
            self.page_number_input = QLineEdit()
            self.page_number_input.setMaximumWidth(50)
            self.page_number_input.setText("1")
            if hasattr(self, 'go_to_page'):
                self.page_number_input.returnPressed.connect(self.go_to_page)
        pagination_layout.addWidget(self.page_number_input)
        
        if not hasattr(self, 'total_pages_label') or not self._widget_is_alive(self.total_pages_label):
            self.total_pages_label = QLabel("of 1")
        pagination_layout.addWidget(self.total_pages_label)
        
        # Next page button
        if not hasattr(self, 'next_page_btn') or not self._widget_is_alive(self.next_page_btn):
            self.next_page_btn = QPushButton("Next ▶")
            self.next_page_btn.setMaximumWidth(70)
            if hasattr(self, 'go_to_next_page'):
                self.next_page_btn.clicked.connect(self.go_to_next_page)
        pagination_layout.addWidget(self.next_page_btn)
        
        # Last page button
        if not hasattr(self, 'last_page_btn') or not self._widget_is_alive(self.last_page_btn):
            self.last_page_btn = QPushButton("Last ⏭")
            self.last_page_btn.setMaximumWidth(70)
            if hasattr(self, 'go_to_last_page'):
                self.last_page_btn.clicked.connect(self.go_to_last_page)
        pagination_layout.addWidget(self.last_page_btn)
        
        # Page size selector
        page_size_label = QLabel("Per page:")
        pagination_layout.addWidget(page_size_label)
        
        if not hasattr(self, 'page_size_combo') or not self._widget_is_alive(self.page_size_combo):
            self.page_size_combo = QComboBox()
            self.page_size_combo.addItems(["5", "10", "25", "50", "100", "200", "500", "All"])
            # Default to showing every segment ("All"). _maybe_auto_set_page_size()
            # drops large projects (>2000 segments) back to a paged size on load.
            if not hasattr(self, 'grid_page_size'):
                self.grid_page_size = 999999
            self.page_size_combo.setCurrentText(str(self.grid_page_size) if self.grid_page_size != 999999 else "All")
            if hasattr(self, 'on_page_size_changed'):
                self.page_size_combo.currentTextChanged.connect(self.on_page_size_changed)
        self.page_size_combo.setMaximumWidth(80)
        pagination_layout.addWidget(self.page_size_combo)
        
        # Create a combined filter + pagination container for flexible positioning
        filter_pagination_container = QWidget()
        filter_pagination_layout = QVBoxLayout(filter_pagination_container)
        filter_pagination_layout.setContentsMargins(0, 0, 0, 0)
        filter_pagination_layout.setSpacing(2)
        filter_pagination_layout.addWidget(filter_panel)
        filter_pagination_layout.addWidget(pagination_panel)
        
        # Store reference for later positioning
        self.filter_pagination_container = filter_pagination_container
        
        # Note: filter_pagination_container will be added to layout based on tabs_above_grid setting
        
        # Note: Pagination methods (go_to_first_page, go_to_prev_page, etc.) will be implemented
        # when pagination functionality is fully added. For now, buttons are created but won't work.
        
        # Translation Grid (create it if it doesn't exist or has been deleted)
        if not hasattr(self, 'table') or not self._widget_is_alive(self.table):
            self.create_translation_grid()
        grid_layout.addWidget(self.table)
        
        # Compact toolbar below grid with action buttons
        toolbar = QWidget()
        toolbar_layout = QHBoxLayout(toolbar)
        toolbar_layout.setContentsMargins(5, 5, 5, 5)
        toolbar_layout.setSpacing(8)
        
        # Segment info label
        tab_seg_info = QLabel("Segment: -")
        tab_seg_info.setStyleSheet("font-weight: bold;")
        toolbar_layout.addWidget(tab_seg_info)
        
        # View mode segmented control (WYSIWYG / Compact / Tags)
        from PyQt6.QtWidgets import QButtonGroup

        view_mode_container = QWidget()
        view_mode_layout = QHBoxLayout(view_mode_container)
        view_mode_layout.setContentsMargins(0, 0, 0, 0)
        view_mode_layout.setSpacing(0)

        # Create button group to ensure only one is checked
        view_mode_group = QButtonGroup(self)
        view_mode_group.setExclusive(True)

        _seg_btn_base = """
            QPushButton {{
                background-color: #757575;
                color: white;
                font-weight: bold;
                padding: 4px 6px;
                font-size: 8pt;
                border: none;
                {radius}
            }}
            QPushButton:checked {{
                background-color: #9C27B0;
            }}
            QPushButton:hover:!checked {{
                background-color: #858585;
            }}
            QPushButton:focus {{
                outline: none;
            }}
        """

        # WYSIWYG button (left)
        wysiwyg_btn = QPushButton("WYSIWYG")
        wysiwyg_btn.setCheckable(True)
        wysiwyg_btn.setChecked(False)
        wysiwyg_btn.setToolTip(
            f"WYSIWYG View ({format_shortcut_for_display('Ctrl+Shift+H')})\nShows formatted text without raw tags"
        )
        wysiwyg_btn.setStyleSheet(_seg_btn_base.format(
            radius="border-top-left-radius: 3px; border-bottom-left-radius: 3px;"
        ))
        wysiwyg_btn.clicked.connect(lambda: self._set_tag_view_mode('wysiwyg'))
        view_mode_group.addButton(wysiwyg_btn, 0)
        view_mode_layout.addWidget(wysiwyg_btn)

        # Compact button (middle) – shortens verbose tags to {1}, {/1}
        compact_btn = QPushButton("Compact")
        compact_btn.setCheckable(True)
        compact_btn.setChecked(False)
        compact_btn.setToolTip(
            "Compact Tag View\nShortens verbose tags like <bmk id=\"0\" ...> to {1}"
        )
        compact_btn.setStyleSheet(_seg_btn_base.format(radius=""))
        compact_btn.clicked.connect(lambda: self._set_tag_view_mode('compact'))
        view_mode_group.addButton(compact_btn, 2)
        view_mode_layout.addWidget(compact_btn)

        # Tags button (right)
        tags_btn = QPushButton("Tags")
        tags_btn.setCheckable(True)
        tags_btn.setChecked(True)  # Default: Tags mode
        tags_btn.setToolTip(
            f"Tag View ({format_shortcut_for_display('Ctrl+Shift+H')})\nShows raw tags like <b>bold</b>"
        )
        tags_btn.setStyleSheet(_seg_btn_base.format(
            radius="border-top-right-radius: 3px; border-bottom-right-radius: 3px;"
        ))
        tags_btn.clicked.connect(lambda: self._set_tag_view_mode('tags'))
        view_mode_group.addButton(tags_btn, 1)
        view_mode_layout.addWidget(tags_btn)

        toolbar_layout.addWidget(view_mode_container)

        # Store references for keyboard shortcut and programmatic access
        self.wysiwyg_btn = wysiwyg_btn
        self.compact_btn = compact_btn
        self.tags_btn = tags_btn
        self.view_mode_group = view_mode_group

        # Initialize tag view state: 'tags' (default), 'compact', or 'wysiwyg'
        if not hasattr(self, 'tag_view_mode'):
            self.tag_view_mode = 'tags'
        if not hasattr(self, 'show_tags'):
            self.show_tags = True  # Default: show tags
        
        # Status selector
        from modules.statuses import get_status, STATUSES
        status_label = QLabel("Status:")
        tab_status_combo = QComboBox()
        tab_status_combo.setMinimumWidth(130)  # Ensure full status text is visible
        for status_key in STATUSES.keys():
            definition = get_status(status_key)
            tab_status_combo.addItem(definition.label, status_key)
        tab_status_combo.currentIndexChanged.connect(self.on_tab_status_combo_changed)
        toolbar_layout.addWidget(status_label)
        toolbar_layout.addWidget(tab_status_combo)
        
        toolbar_layout.addWidget(QLabel("|"))  # Separator
        
        preview_prompt_btn = QPushButton("🧪 Preview Prompts")
        preview_prompt_btn.setToolTip("Preview the complete assembled prompt\n(System Prompt + Custom Prompts + current segment)")
        preview_prompt_btn.setStyleSheet("background-color: #9C27B0; color: white; font-weight: bold; padding: 3px 5px; border: none; outline: none;")
        preview_prompt_btn.clicked.connect(self._preview_combined_prompt_from_grid)
        toolbar_layout.addWidget(preview_prompt_btn)
        
        dictate_btn = QPushButton(f"🎤 Dictate{self._dictation_shortcut_label()}")
        dictate_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 3px 5px; border: none; outline: none;")
        dictate_btn.clicked.connect(self.start_voice_dictation)
        dictate_btn.setToolTip("Push-to-talk dictation – press your dictation shortcut (or click) to record, transcribe, and insert text. Set the key in Settings → Keyboard Shortcuts.")
        toolbar_layout.addWidget(dictate_btn)
        
        # Always-On Voice toggle button
        alwayson_btn = QPushButton("🎧 Always-On: OFF")
        alwayson_btn.setCheckable(True)
        alwayson_btn.setChecked(False)
        alwayson_btn.setStyleSheet("""
            QPushButton {
                background-color: #757575;
                color: white;
                font-weight: bold;
                padding: 3px 5px;
                border-radius: 3px;
            }
            QPushButton:checked {
                background-color: #2E7D32;
            }
            QPushButton:focus {
                outline: none;
            }
        """)
        alwayson_btn.setToolTip("Always-On listening – continuously monitors the mic and transcribes automatically\nNo need to press the dictation shortcut")
        alwayson_btn.clicked.connect(lambda checked: self._toggle_alwayson_from_grid_btn(checked, alwayson_btn))
        toolbar_layout.addWidget(alwayson_btn)
        self.grid_alwayson_btn = alwayson_btn  # Store reference

        toolbar_layout.addWidget(QLabel("|"))  # Separator

        # Log button – opens the session log in its own movable window.
        # Replaces the old right-panel "Session Log" tab; the same action is
        # also available via Tools ▸ Log Window.
        log_window_btn = QPushButton("📋 Log")
        log_window_btn.setToolTip("Open the session log in a separate window that can be moved to another screen")
        log_window_btn.clicked.connect(self.detach_log_window)
        toolbar_layout.addWidget(log_window_btn)

        toolbar_layout.addStretch()
        
        save_next_btn = QPushButton("✓ Confirm && Next")
        save_next_btn.setStyleSheet("background-color: #2196F3; color: white; font-weight: bold; padding: 3px 5px; border: none; outline: none;")
        save_next_btn.clicked.connect(self.confirm_selected_or_next)
        save_next_btn.setToolTip(
            f"Confirm current segment and go to next unconfirmed ({format_shortcut_for_display('Ctrl+Enter')})"
        )
        toolbar_layout.addWidget(save_next_btn)
        
        grid_layout.addWidget(toolbar)
        
        # Store references
        self.tab_seg_info = tab_seg_info
        self.tab_status_combo = tab_status_combo
        self.tab_dictate_btn = dictate_btn
        
        # Create TermLens and Session Log tabs widget
        from PyQt6.QtWidgets import QTabWidget
        from modules.termlens_widget import TermLensWidget
        
        bottom_tabs = QTabWidget()
        # Same Mac-elide fix as right_tabs: stop QTabBar from truncating
        # labels to "Term…" / "Sessio…" when there's plenty of room.
        bottom_tabs.tabBar().setElideMode(Qt.TextElideMode.ElideNone)
        bottom_tabs.tabBar().setUsesScrollButtons(True)
        bottom_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
        
        # TermLens tab
        self.termlens_widget = TermLensWidget(self, db_manager=self.db_manager, log_callback=self.log, theme_manager=self.theme_manager)
        set_help_topic(self.termlens_widget, HelpTopics.GLOSSARY_TERMLENS)
        self.termlens_widget.term_insert_requested.connect(self.insert_termlens_text)
        self.termlens_widget.edit_entry_requested.connect(self._on_termlens_edit_entry)
        self.termlens_widget.delete_entry_requested.connect(self._on_termlens_delete_entry)
        self.termlens_widget.font_size_changed.connect(self._on_termlens_font_size_changed)
        # v1.10.68: refresh button rebuilds the in-memory termbase
        # index (covers cross-process edits — e.g. terms deleted /
        # added via the Supervertaler for Trados plugin while
        # Workbench is open against the same shared SQLite database).
        self.termlens_widget.refresh_requested.connect(self._on_termlens_refresh_requested)

        # Apply saved termlens font settings
        font_settings = self.load_general_settings()
        termlens_family = font_settings.get('termlens_font_family', 'Segoe UI')
        termlens_size = font_settings.get('termlens_font_size', 10)
        termlens_bold = font_settings.get('termlens_font_bold', False)
        self.termlens_widget.set_font_settings(termlens_family, termlens_size, termlens_bold)
        
        # Session Log tab
        session_log_widget = QWidget()
        session_log_layout = QVBoxLayout(session_log_widget)
        session_log_layout.setContentsMargins(5, 5, 5, 5)
        
        self.session_log_text = QPlainTextEdit()
        self.session_log_text.setReadOnly(True)
        self.session_log_text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        self.session_log_text.setStyleSheet("font-family: 'Consolas', 'Courier New', monospace; font-size: 9pt;")
        session_log_layout.addWidget(self.session_log_text)
        
        # Segment-comments sub-tab. v1.10.56 restructured this into a
        # split view:
        #   • Top: scrollable list of every segment in the project with a
        #     comment. Each entry has a clickable "Segment #N" header
        #     that jumps to that segment in the grid (cross-page aware).
        #   • Bottom: editor for the CURRENTLY SELECTED segment's
        #     comment, identical to the pre-1.10.56 behaviour.
        # The list rebuilds when a comment is edited (via
        # _on_bottom_notes_changed) and when a project is loaded.
        notes_widget = QWidget()
        notes_layout = QVBoxLayout(notes_widget)
        notes_layout.setContentsMargins(5, 5, 5, 5)
        notes_layout.setSpacing(4)

        from PyQt6.QtWidgets import QSplitter as _QSplitter, QScrollArea as _QScrollArea, QFrame as _QFrame
        comments_splitter = _QSplitter(Qt.Orientation.Vertical)

        # ── All-segment-comments list (top half) ──
        self._segment_comments_list_container = QWidget()
        self._segment_comments_list_layout = QVBoxLayout(self._segment_comments_list_container)
        self._segment_comments_list_layout.setContentsMargins(0, 0, 0, 0)
        self._segment_comments_list_layout.setSpacing(0)
        # Initial placeholder; replaced by _refresh_segment_comments_list().
        _initial_empty = QLabel("(No segment comments yet — add one in the editor below.)")
        _initial_empty.setStyleSheet("color: #999; font-style: italic; padding: 8px;")
        _initial_empty.setWordWrap(True)
        self._segment_comments_list_layout.addWidget(_initial_empty)
        self._segment_comments_list_layout.addStretch()

        comments_list_scroll = _QScrollArea()
        comments_list_scroll.setWidgetResizable(True)
        comments_list_scroll.setFrameShape(_QFrame.Shape.NoFrame)
        comments_list_scroll.setWidget(self._segment_comments_list_container)
        comments_splitter.addWidget(comments_list_scroll)
        # Stored so _open_comment_in_panel() can scroll a specific comment
        # (reached via "Open comment" on a cell's anchored highlight) into view.
        self._segment_comments_list_scroll = comments_list_scroll

        # ── Editor for current segment's comment (bottom half) ──
        edit_container = QWidget()
        edit_layout = QVBoxLayout(edit_container)
        edit_layout.setContentsMargins(0, 4, 0, 0)
        edit_layout.setSpacing(2)
        edit_header = QLabel("✏️ Comment on current segment")
        edit_header.setStyleSheet("font-weight: bold; font-size: 9pt; color: #555;")
        edit_layout.addWidget(edit_header)

        self.bottom_notes_edit = QPlainTextEdit()
        self.bottom_notes_edit.setPlaceholderText("Add a comment about this segment — context, translation concerns, anything worth flagging...")
        self.bottom_notes_edit.setStyleSheet("font-size: 10pt;")
        self.bottom_notes_edit.textChanged.connect(self._on_bottom_notes_changed)
        edit_layout.addWidget(self.bottom_notes_edit)
        comments_splitter.addWidget(edit_container)

        # Default split: list takes ~60%, edit ~40%. The user can drag.
        comments_splitter.setSizes([300, 200])
        comments_splitter.setStretchFactor(0, 3)
        comments_splitter.setStretchFactor(1, 2)
        notes_layout.addWidget(comments_splitter)
        
        # Add only TermLens tab to bottom_tabs (Segment Note and Session Log will be in right panel)
        bottom_tabs.addTab(self.termlens_widget, "🔍 TermLens")

        # QuickTrans docked panel – a trimmed, always-on version of the
        # QuickTrans popup. Auto-fetches cheap MT for the current segment;
        # LLMs stay on-demand. Lives as a second tab next to TermLens.
        try:
            from modules.quicktrans import QuickTransPanel
            self.quicktrans_panel = QuickTransPanel(self)
            self.quicktrans_panel.translation_selected.connect(self._insert_quicktrans_translation)
            bottom_tabs.addTab(self.quicktrans_panel, "⚡ QuickTrans")
        except Exception as e:
            self.quicktrans_panel = None
            self.log(f"Could not create QuickTrans panel: {e}")

        # Default to TermLens tab (index 0)
        bottom_tabs.setCurrentIndex(0)

        # Store reference to bottom_tabs for later access
        self.bottom_tabs = bottom_tabs
        # Keep the View-menu dock checkmarks in sync as the user switches
        # between the TermLens and QuickTrans tabs (and remember the choice).
        bottom_tabs.currentChanged.connect(self._on_bottom_tab_changed)
        
        # Proofreading note tab (read-only, AI-generated, keyed by LLM model)
        proofreading_notes_widget = QWidget()
        proofreading_notes_layout = QVBoxLayout(proofreading_notes_widget)
        proofreading_notes_layout.setContentsMargins(5, 5, 5, 5)

        from PyQt6.QtWidgets import QTextBrowser
        self.bottom_proofreading_notes_display = QTextBrowser()
        self.bottom_proofreading_notes_display.setOpenExternalLinks(False)
        self.bottom_proofreading_notes_display.setStyleSheet("font-size: 10pt;")
        self.bottom_proofreading_notes_display.setHtml(
            '<span style="color: #999; font-style: italic;">No proofreading comments for this segment.</span>'
        )
        proofreading_notes_layout.addWidget(self.bottom_proofreading_notes_display)

        # Store notes_widget, proofreading_notes_widget, and session_log_widget for adding to right panel later
        self._notes_widget_for_right_panel = notes_widget
        self._proofreading_notes_widget_for_right_panel = proofreading_notes_widget
        self._session_log_widget_for_right_panel = session_log_widget
        
        # Scratchpad widget (for private translator notes - project-level, not segment-level)
        scratchpad_widget = QWidget()
        scratchpad_layout = QVBoxLayout(scratchpad_widget)
        scratchpad_layout.setContentsMargins(5, 5, 5, 5)
        
        # Info label at top
        scratchpad_info = QLabel(
            "📝 <b>Private notes for this project</b><br>"
            "<small>Stored in .svproj file only – never exported to CAT tools</small>"
        )
        scratchpad_info.setWordWrap(True)
        scratchpad_info.setStyleSheet("color: #666; font-size: 9pt; padding: 3px; background: #f9f9f9; border-radius: 3px; margin-bottom: 5px;")
        scratchpad_layout.addWidget(scratchpad_info)
        
        self.scratchpad_edit = QPlainTextEdit()
        self.scratchpad_edit.setPlaceholderText(
            "Use this scratchpad for your private notes:\n\n"
            "• Terminology decisions\n"
            "• Client preferences\n"
            "• Research findings\n"
            "• Questions to ask\n"
            "• Reminders"
        )
        self.scratchpad_edit.setStyleSheet("font-family: 'Consolas', 'Courier New', monospace; font-size: 10pt;")
        self.scratchpad_edit.textChanged.connect(self._on_scratchpad_changed)
        scratchpad_layout.addWidget(self.scratchpad_edit)
        
        self._scratchpad_widget_for_right_panel = scratchpad_widget
        
        # Create a container for the left side that will hold everything
        left_container = QWidget()
        left_container_layout = QVBoxLayout(left_container)
        left_container_layout.setContentsMargins(0, 0, 0, 0)
        left_container_layout.setSpacing(0)
        
        # Add widgets based on tabs_above_grid setting
        if self.tabs_above_grid:
            # Layout: Filter/Pagination → TermLens tabs → Grid
            # This keeps terminology matches closest to the segment being edited
            left_container_layout.addWidget(filter_pagination_container)
            left_vertical_splitter.addWidget(bottom_tabs)
            left_vertical_splitter.addWidget(grid_container)
            # Set vertical splitter proportions: Tabs smaller, grid larger
            left_vertical_splitter.setSizes([200, 600])
        else:
            # Layout: Filter/Pagination → Grid → TermLens tabs (default)
            left_container_layout.addWidget(filter_pagination_container)
            left_vertical_splitter.addWidget(grid_container)
            left_vertical_splitter.addWidget(bottom_tabs)
            # Set vertical splitter proportions: Grid larger, tabs smaller
            left_vertical_splitter.setSizes([600, 200])
        
        left_container_layout.addWidget(left_vertical_splitter)
        left_vertical_splitter.setHandleWidth(8)
        left_vertical_splitter.setChildrenCollapsible(False)

        # Keep references so the View-menu items can reposition TermLens live
        self.grid_container = grid_container
        self.left_vertical_splitter = left_vertical_splitter
        
        # Allow left panel to shrink smaller so right panel can expand
        left_container.setMinimumWidth(400)  # Allow grid to shrink to 400px minimum
        
        # Add left side to main horizontal splitter
        main_horizontal_splitter.addWidget(left_container)

        # Pre-load match panel font settings so _create_match_panel() uses the saved values
        try:
            _pre_settings = self.load_general_settings()
            _mp_size = _pre_settings.get('match_panel_font_size', 10)
            if 7 <= _mp_size <= 18:
                SupervertalerQt.match_panel_font_size = _mp_size
            _mp_family = _pre_settings.get('match_panel_font_family', '')
            SupervertalerQt.match_panel_font_family = _mp_family
            SupervertalerQt.match_panel_font_bold = _pre_settings.get('match_panel_font_bold', False)
            # v1.9.307: Load TM pane layout preference (horizontal vs vertical stacking)
            # Default: True (vertical) – stacked gives each pane the full panel width
            self.match_panel_tm_vertical = _pre_settings.get('match_panel_tm_vertical', True)
        except Exception:
            self.match_panel_tm_vertical = True

        # Create tabbed container for right panel
        right_tabs = QTabWidget()
        right_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        right_tabs.tabBar().setDrawBase(False)
        right_tabs.tabBar().setExpanding(False)
        # macOS clips tab labels to ellipsis ("Match...", "AI Ass...",
        # "Proofreadin...", "Sessio...", "Scrat...") even when there is
        # plenty of horizontal space, because Qt's default ElideRight is
        # too aggressive on Mac. Force the tab bar to size each tab to its
        # full label and offer scroll buttons if the bar overflows the
        # available width — that way every label stays readable on Mac
        # and the layout is unchanged on Windows.
        right_tabs.tabBar().setElideMode(Qt.TextElideMode.ElideNone)
        right_tabs.tabBar().setUsesScrollButtons(True)
        right_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")

        # NOTE: Translation Results panel is deprecated - MT/LLM is now only via QuickTrans (Ctrl+Alt+Q global hotkey)
        # Panel is no longer created to save resources. Set to None for compatibility checks.
        self.translation_results_panel = None
        if not hasattr(self, 'results_panels'):
            self.results_panels = []

        # Track tab indices for visibility-aware default selection
        tab_index = 0
        preview_tab_index = -1

        # Tab 1: Match Panel (TermLens + TM Source/Target) - primary TM display
        match_panel_widget = self._create_match_panel()
        self.match_panel_widget = match_panel_widget  # Store reference for mode detection
        right_tabs.addTab(match_panel_widget, "🎯 Match Panel")
        match_panel_tab_index = tab_index
        tab_index += 1
        
        # Tab 2: Document Preview
        preview_widget = self._create_preview_tab()
        right_tabs.addTab(preview_widget, "📄 Preview")
        preview_tab_index = tab_index
        self._preview_tab_index = preview_tab_index  # Store for visibility checks
        tab_index += 1

        # Tab 3: Comments — single parent tab containing two sub-tabs
        # (Segment / Proofreading) so the related surfaces sit together
        # in the right panel. The Segment sub-tab additionally shows an
        # all-project list of every segment with a comment (built later
        # by _attach_all_segment_comments_list).
        from PyQt6.QtWidgets import QTabWidget as _QTabWidget
        comments_tab = QWidget()
        comments_tab_layout = QVBoxLayout(comments_tab)
        comments_tab_layout.setContentsMargins(0, 0, 0, 0)
        comments_tab_layout.setSpacing(0)

        self.comments_sub_tabs = _QTabWidget()
        # Keep the sub-tab bar visually subordinate to the parent tab bar.
        self.comments_sub_tabs.setDocumentMode(True)
        self.comments_sub_tabs.addTab(
            self._notes_widget_for_right_panel,
            "📝 Segment",
        )
        self.comments_sub_tabs.addTab(
            self._proofreading_notes_widget_for_right_panel,
            "✅ Proofreading",
        )
        comments_tab_layout.addWidget(self.comments_sub_tabs)
        right_tabs.addTab(comments_tab, "💬 Comments")
        # Index of the parent Comments tab, exposed for keyboard shortcuts
        # and any code that needs to switch to it programmatically (e.g.
        # the Ctrl+N "focus comments editor" handler in _focus_notes_tab).
        self._comments_tab_index = tab_index
        tab_index += 1

        # Session Log no longer has its own right-panel tab (v1.10.x). The
        # session_log_text widget below is still built and still receives every
        # log line, so it serves as the live buffer that the detached Log
        # window (toolbar "📋 Log" button / Tools ▸ Log Window) copies from.

        # Tab 6: Scratchpad (private translator notes for the whole project)
        right_tabs.addTab(self._scratchpad_widget_for_right_panel, "📝 Scratchpad")
        tab_index += 1

        # Note: AI Assistant tab is added later (after prompt_manager_qt is created)
        # via _add_assistant_to_right_panel(), since the grid is built before the AI tab.
        self._right_panel_next_tab_index = tab_index

        # Set default selected tab to Match Panel (always show Match Panel first)
        right_tabs.setCurrentIndex(match_panel_tab_index)
        
        # Store reference for later use
        self.right_tabs = right_tabs

        # Update AI Assistant context sidebar when its tab becomes visible
        def _on_right_tab_changed(index):
            if hasattr(self, '_assistant_tab_index') and index == self._assistant_tab_index:
                if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                    self.prompt_manager_qt._on_assistant_shown()
        right_tabs.currentChanged.connect(_on_right_tab_changed)

        # Allow right panel to expand larger for better splitter flexibility
        right_tabs.setMinimumWidth(250)  # Prevent complete collapse
        right_tabs.setMaximumWidth(16777215)  # Remove maximum width constraint (Qt max = 16777215)
        
        main_horizontal_splitter.addWidget(right_tabs)
        
        # Set stretch factors so both sides can resize freely
        main_horizontal_splitter.setStretchFactor(0, 7)  # Left panel gets 70% weight
        main_horizontal_splitter.setStretchFactor(1, 3)  # Right panel gets 30% weight
        
        # Set horizontal splitter proportions: Grid area larger, results smaller
        # Give more space to the grid+editor on the left
        main_horizontal_splitter.setSizes([900, 400])
        main_horizontal_splitter.setHandleWidth(8)
        main_horizontal_splitter.setChildrenCollapsible(False)
        
        layout.addWidget(main_horizontal_splitter)
        
        self.update_warning_banner()

        return widget
    
    # Pagination methods
    # Documents at or below this segment count open with every segment shown
    # ("All"); larger ones fall back to PAGE_FALLBACK_SIZE per page so the
    # initial visible-row layout stays snappy. Grid widgets are always built
    # for the whole document, so this only bounds how many rows are visible.
    PAGE_AUTO_ALL_THRESHOLD = 2000
    PAGE_FALLBACK_SIZE = 500

    def _maybe_auto_set_page_size(self):
        """Pick the grid page size once per freshly-opened project.

        Defaults to "All" (show every segment); for very large documents
        (> PAGE_AUTO_ALL_THRESHOLD segments) it falls back to
        PAGE_FALLBACK_SIZE per page. Runs once per project object — keyed on
        the project's identity — so re-renders (sort, filter, page change) and
        any manual "Per page" choice the user makes during the session are not
        overridden.
        """
        if not self.current_project or not self.current_project.segments:
            return
        key = id(self.current_project)
        if getattr(self, '_page_size_decided_for', None) == key:
            return
        self._page_size_decided_for = key

        total = len(self.current_project.segments)
        if total > self.PAGE_AUTO_ALL_THRESHOLD:
            self.grid_page_size = self.PAGE_FALLBACK_SIZE
            self.log(
                f"📄 Large project ({total} segments) — paginating at "
                f"{self.PAGE_FALLBACK_SIZE}/page. Switch to 'All' via the "
                f"Per-page selector if you prefer one page.")
        else:
            self.grid_page_size = 999999  # All
        self.grid_current_page = 0

        # Reflect the decision in the combo without retriggering the handler.
        if (hasattr(self, 'page_size_combo')
                and self._widget_is_alive(self.page_size_combo)):
            self.page_size_combo.blockSignals(True)
            self.page_size_combo.setCurrentText(
                "All" if self.grid_page_size >= 999999
                else str(self.grid_page_size))
            self.page_size_combo.blockSignals(False)

    def _get_total_pages(self) -> int:
        """Calculate total number of pages based on segment count and page size"""
        if not self.current_project or not self.current_project.segments:
            return 1
        total_segments = len(self.current_project.segments)
        if not hasattr(self, 'grid_page_size') or self.grid_page_size >= 999999:
            return 1  # "All" selected
        return max(1, (total_segments + self.grid_page_size - 1) // self.grid_page_size)
    
    def _update_pagination_ui(self):
        """Update pagination labels and button states"""
        if not self.current_project or not self.current_project.segments:
            if hasattr(self, 'pagination_label') and self._widget_is_alive(self.pagination_label):
                self.pagination_label.setText("Segments 0-0 of 0")
            if hasattr(self, 'total_pages_label') and self._widget_is_alive(self.total_pages_label):
                self.total_pages_label.setText("of 1")
            if hasattr(self, 'page_number_input') and self._widget_is_alive(self.page_number_input):
                self.page_number_input.setText("1")
            return

        total_segments = len(self.current_project.segments)
        total_pages = self._get_total_pages()

        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        if not hasattr(self, 'grid_page_size'):
            self.grid_page_size = 50

        # Clamp current page to valid range
        self.grid_current_page = max(0, min(self.grid_current_page, total_pages - 1))

        # Check if a filter is active (text filter or quick filter)
        filter_allowlist = getattr(self, '_active_text_filter_rows', None)
        is_filtered = filter_allowlist is not None

        # Calculate segment range for current page
        if self.grid_page_size >= 999999:
            # "All" mode - show all segments
            start_seg = 1
            end_seg = total_segments
        else:
            start_seg = self.grid_current_page * self.grid_page_size + 1
            end_seg = min((self.grid_current_page + 1) * self.grid_page_size, total_segments)

        # Update pagination label - show filtered count when filter is active
        if hasattr(self, 'pagination_label') and self._widget_is_alive(self.pagination_label):
            if is_filtered:
                filtered_count = len(filter_allowlist)
                self.pagination_label.setText(f"Showing {filtered_count} of {total_segments} segments")
            else:
                self.pagination_label.setText(f"Segments {start_seg}-{end_seg} of {total_segments}")
        
        if hasattr(self, 'total_pages_label') and self._widget_is_alive(self.total_pages_label):
            self.total_pages_label.setText(f"of {total_pages}")
        
        if hasattr(self, 'page_number_input') and self._widget_is_alive(self.page_number_input):
            self.page_number_input.setText(str(self.grid_current_page + 1))
        
        # Enable/disable navigation buttons
        is_first_page = self.grid_current_page == 0
        is_last_page = self.grid_current_page >= total_pages - 1
        
        if hasattr(self, 'first_page_btn') and self._widget_is_alive(self.first_page_btn):
            self.first_page_btn.setEnabled(not is_first_page)
        if hasattr(self, 'prev_page_btn') and self._widget_is_alive(self.prev_page_btn):
            self.prev_page_btn.setEnabled(not is_first_page)
        if hasattr(self, 'next_page_btn') and self._widget_is_alive(self.next_page_btn):
            self.next_page_btn.setEnabled(not is_last_page)
        if hasattr(self, 'last_page_btn') and self._widget_is_alive(self.last_page_btn):
            self.last_page_btn.setEnabled(not is_last_page)
    
    def _apply_pagination_to_grid(self):
        """Show/hide rows based on current page, optionally constrained by active filters.

        IMPORTANT: When a text filter is active (Filter Source/Target boxes), we show ALL
        matching rows across the entire document, ignoring pagination. This ensures users
        can find content regardless of which page they're on.
        
        When no filter is active, normal pagination applies.
        """
        if not self.current_project or not self.current_project.segments:
            return
        
        total_segments = len(self.current_project.segments)
        
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        if not hasattr(self, 'grid_page_size'):
            self.grid_page_size = 50
        
        # If a text filter (Filter Source/Target) is active, it will populate an allowlist
        # of row indices that are allowed to be visible.
        filter_allowlist = getattr(self, '_active_text_filter_rows', None)

        # Build set of empty structural segments (always hidden regardless of pagination/filter)
        segments = self.current_project.segments
        empty_rows = {i for i in range(total_segments) if not segments[i].source.strip()}

        # When a filter is active, show ALL matching rows (ignore pagination)
        # When no filter is active, apply normal pagination
        if filter_allowlist is not None:
            # Filter mode: show all matching rows across the entire document
            self.table.setUpdatesEnabled(False)
            try:
                for row in range(total_segments):
                    hidden = row not in filter_allowlist or row in empty_rows
                    self.table.setRowHidden(row, hidden)
            finally:
                self.table.setUpdatesEnabled(True)
        else:
            # Normal pagination mode
            if self.grid_page_size >= 999999:
                # "All" mode - show everything
                start_row = 0
                end_row = total_segments
            else:
                start_row = self.grid_current_page * self.grid_page_size
                end_row = min(start_row + self.grid_page_size, total_segments)

            # Batch show/hide for performance
            self.table.setUpdatesEnabled(False)
            try:
                for row in range(total_segments):
                    in_page = start_row <= row < end_row
                    hidden = not in_page or row in empty_rows
                    self.table.setRowHidden(row, hidden)
            finally:
                self.table.setUpdatesEnabled(True)

        # Recalculate heights for visible rows to prevent layout corruption.
        # Call synchronously first, then schedule a deferred resize to catch any
        # layout issues that Qt processes asynchronously.
        self._resize_visible_rows()
        QTimer.singleShot(50, self._resize_visible_rows)

        # Update pagination UI
        self._update_pagination_ui()
    
    def go_to_first_page(self):
        """Navigate to first page"""
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        if self.grid_current_page != 0:
            self.grid_current_page = 0
            self._apply_pagination_to_grid()
    
    def go_to_prev_page(self):
        """Navigate to previous page"""
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        if self.grid_current_page > 0:
            self.grid_current_page -= 1
            self._apply_pagination_to_grid()
    
    def go_to_next_page(self):
        """Navigate to next page"""
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        total_pages = self._get_total_pages()
        if self.grid_current_page < total_pages - 1:
            self.grid_current_page += 1
            self._apply_pagination_to_grid()
    
    def select_range_page_up(self):
        """Select a range of segments upward (Shift+Page Up).
        
        Extends selection from current row upward by one page worth of segments.
        If no selection anchor exists, starts from current row.
        """
        if not hasattr(self, 'table') or not self.table or not self.current_project:
            return
        
        current_row = self.table.currentRow()
        if current_row < 0:
            return
        
        # Calculate page size (number of segments to select). When "All" is
        # active (sentinel 999999) fall back to a screenful so Shift+PgUp
        # doesn't select to the top of the document.
        page_size = getattr(self, 'grid_page_size', 50)
        if not isinstance(page_size, int) or page_size >= 999999 or page_size <= 0:
            page_size = 50

        # Get or set the selection anchor (starting point for range selection)
        if not hasattr(self, '_selection_anchor_row'):
            self._selection_anchor_row = current_row

        # Calculate target row (one page up from current, but not below 0)
        target_row = max(0, current_row - page_size)
        
        # Select range from anchor to target
        self._select_range_between(self._selection_anchor_row, target_row)
        
        # Move focus to the target row
        self.table.setCurrentCell(target_row, 3)  # Column 3 is target cell
        self.table.scrollToItem(self.table.item(target_row, 0))
    
    def select_range_page_down(self):
        """Select a range of segments downward (Shift+Page Down).
        
        Extends selection from current row downward by one page worth of segments.
        If no selection anchor exists, starts from current row.
        """
        if not hasattr(self, 'table') or not self.table or not self.current_project:
            return
        
        current_row = self.table.currentRow()
        if current_row < 0:
            return
        
        # Calculate page size (number of segments to select). When "All" is
        # active (sentinel 999999) fall back to a screenful so Shift+PgDn
        # doesn't select to the bottom of the document.
        page_size = getattr(self, 'grid_page_size', 50)
        if not isinstance(page_size, int) or page_size >= 999999 or page_size <= 0:
            page_size = 50

        # Get or set the selection anchor (starting point for range selection)
        if not hasattr(self, '_selection_anchor_row'):
            self._selection_anchor_row = current_row

        # Calculate target row (one page down from current, but not beyond last segment)
        max_row = len(self.current_project.segments) - 1
        target_row = min(max_row, current_row + page_size)
        
        # Select range from anchor to target
        self._select_range_between(self._selection_anchor_row, target_row)
        
        # Move focus to the target row
        self.table.setCurrentCell(target_row, 3)  # Column 3 is target cell
        self.table.scrollToItem(self.table.item(target_row, 0))
    
    def _select_range_between(self, start_row: int, end_row: int):
        """Select all rows between start_row and end_row (inclusive).
        
        Args:
            start_row: Selection anchor row
            end_row: Target row to extend selection to
        """
        if not hasattr(self, 'table') or not self.table:
            return
        
        # Determine actual start and end (handle both directions)
        min_row = min(start_row, end_row)
        max_row = max(start_row, end_row)
        
        # Clear existing selection
        self.table.clearSelection()
        
        # Select the range using QTableWidgetSelectionRange
        col_count = self.table.columnCount()
        selection_range = QTableWidgetSelectionRange(min_row, 0, max_row, col_count - 1)
        self.table.setRangeSelected(selection_range, True)
    
    def _clear_selection_anchor(self):
        """Clear the selection anchor when a non-shift click occurs."""
        if hasattr(self, '_selection_anchor_row'):
            del self._selection_anchor_row

    def go_to_last_page(self):
        """Navigate to last page"""
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        total_pages = self._get_total_pages()
        if self.grid_current_page != total_pages - 1:
            self.grid_current_page = total_pages - 1
            self._apply_pagination_to_grid()
    
    def go_to_page(self):
        """Navigate to specific page from input field"""
        if not hasattr(self, 'page_number_input') or not self._widget_is_alive(self.page_number_input):
            return
        try:
            page_num = int(self.page_number_input.text())
            total_pages = self._get_total_pages()
            if not hasattr(self, 'grid_current_page'):
                self.grid_current_page = 0
            new_page = max(0, min(page_num - 1, total_pages - 1))  # Convert to 0-indexed and clamp
            if new_page != self.grid_current_page:
                self.grid_current_page = new_page
                self._apply_pagination_to_grid()
            else:
                # Even if same page, update the input to show clamped value
                self.page_number_input.setText(str(self.grid_current_page + 1))
        except ValueError:
            # Invalid input, reset to current page
            if hasattr(self, 'grid_current_page'):
                self.page_number_input.setText(str(self.grid_current_page + 1))
    
    def on_page_size_changed(self, text: str):
        """Handle page size change"""
        if not hasattr(self, 'grid_page_size'):
            self.grid_page_size = 50
        
        old_page_size = self.grid_page_size
        
        if text == "All":
            self.grid_page_size = 999999
        else:
            try:
                self.grid_page_size = int(text)
            except ValueError:
                self.grid_page_size = 50
        
        # Reset to first page when page size changes
        if not hasattr(self, 'grid_current_page'):
            self.grid_current_page = 0
        else:
            self.grid_current_page = 0
        
        # Apply the new pagination
        self._apply_pagination_to_grid()
    
    def _widget_is_alive(self, widget: Optional[QWidget]) -> bool:
        """Return True if the underlying Qt object still exists."""
        if widget is None:
            return False
        try:
            return not sip.isdeleted(widget)
        except RuntimeError:
            return False

    def _get_line_edit_text(self, attr_name: str) -> str:
        """Safely fetch text from a shared QLineEdit, handling deleted widgets."""
        widget = getattr(self, attr_name, None)
        if not self._widget_is_alive(widget):
            setattr(self, attr_name, None)
            return ""
        return widget.text()

    def _ensure_shared_filter(self, attr_name: str, placeholder: str,
                               on_change: Optional[Callable] = None,
                               on_return: Optional[Callable] = None) -> QLineEdit:
        """Create (or recreate) shared filter widgets that may be destroyed during layout swaps."""
        widget = getattr(self, attr_name, None)
        if not self._widget_is_alive(widget):
            widget = QLineEdit()
            if on_change is not None:
                widget.textChanged.connect(on_change)
            if on_return is not None:
                widget.returnPressed.connect(on_return)
            setattr(self, attr_name, widget)
        widget.setPlaceholderText(placeholder)
        return widget

    def _ensure_primary_filters_ready(self):
        """Make sure the grid filters exist before programmatic use."""
        self.source_filter = self._ensure_shared_filter(
            'source_filter',
            "Type to filter source segments...",
            on_change=self.apply_filters,
            on_return=self.apply_filters,
        )
        self.target_filter = self._ensure_shared_filter(
            'target_filter',
            "Type to filter target segments...",
            on_change=self.apply_filters,
            on_return=self.apply_filters,
        )

    def _build_warning_banner(self, key: str) -> QWidget:
        banner = QWidget()
        banner.setObjectName(f"{key}_warning_banner")
        banner.setStyleSheet(
            "background-color: #dc2626; color: white; padding: 8px; border-radius: 4px;"
        )
        warning_layout = QHBoxLayout(banner)
        warning_layout.setContentsMargins(10, 5, 10, 5)

        warning_icon = QLabel("⚠️")
        warning_icon.setStyleSheet("font-size: 16px; background: transparent;")
        warning_layout.addWidget(warning_icon)

        warning_text = QLabel(
            "<b>WARNING:</b> Replace in Source Text is ENABLED. "
            "This allows modifying your original source segments. Use with extreme caution!"
        )
        warning_text.setStyleSheet("background: transparent; font-weight: bold;")
        warning_text.setWordWrap(True)
        warning_layout.addWidget(warning_text, stretch=1)

        settings_link = QPushButton("⚙️ Disable in Options")
        settings_link.setStyleSheet(
            "background-color: rgba(255, 255, 255, 0.2); color: white; "
            "border: 1px solid white; padding: 4px 12px; border-radius: 3px; font-weight: bold;"
        )
        settings_link.setCursor(Qt.CursorShape.PointingHandCursor)
        settings_link.clicked.connect(self._go_to_settings_tab)
        warning_layout.addWidget(settings_link)

        return banner

    def _get_warning_banner(self, key: str) -> QWidget:
        """Return a per-host warning banner widget, recreating it if needed."""
        banner = self.warning_banners.get(key)
        if not self._widget_is_alive(banner):
            banner = self._build_warning_banner(key)
            self.warning_banners[key] = banner
        elif banner.parent() is not None:
            banner.setParent(None)
        return banner

    def create_translation_grid(self):
        """Create the translation grid (QTableWidget)"""
        self.table = QTableWidget()
        set_help_topic(self.table, HelpTopics.TRANSLATION_GRID)

        # Configure columns
        self.table.setColumnCount(5)
        self.table.setHorizontalHeaderLabels(["#", "Type", "Source", "Target", "Status"])

        # Explicitly set header font to normal weight (not bold)
        header = self.table.horizontalHeader()
        header_font = QFont(self.default_font_family, self.default_font_size, QFont.Weight.Normal)
        header.setFont(header_font)

        # Also set font on individual header items through the model (extra insurance)
        model = self.table.model()
        if model:
            for col in range(5):
                item = model.headerData(col, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
                model.setHeaderData(col, Qt.Orientation.Horizontal, item, Qt.ItemDataRole.DisplayRole)
                model.setHeaderData(col, Qt.Orientation.Horizontal, header_font, Qt.ItemDataRole.FontRole)

        # Column widths - Source and Target columns stretch to fill space, others are interactive
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)  # ID
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)  # Type
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)  # Source - stretch to fill space
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)  # Target - stretch to fill space
        header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive)  # Status - allow resizing
        header.setStretchLastSection(False)  # Don't auto-stretch last section (we use Stretch mode for Source/Target)

        # Optional: show Status column before Target
        # moveSection reorders the display without changing logical indices,
        # so all code referencing column 3 (Target) and 4 (Status) still works.
        settings = self.load_general_settings()
        self.status_column_before_target = settings.get('status_column_before_target', True)
        if self.status_column_before_target:
            header.moveSection(4, 3)  # Move Status (logical 4) to visual position 3

        # Recalculate row heights when columns are resized (text reflows to new width)
        header.sectionResized.connect(self._on_column_resized)

        # Set initial column widths - give Source and Target equal space
        # ID column width will be auto-adjusted by _update_segment_column_width() after segments load
        self.table.setColumnWidth(0, 35)   # ID - temporary, auto-adjusts to fit content
        self.table.setColumnWidth(1, 40)   # Type - narrower
        self.table.setColumnWidth(2, 400)  # Source
        self.table.setColumnWidth(3, 400)  # Target
        self.table.setColumnWidth(4, 75)   # Status - wide enough for icon + match %
        
        # Enable word wrap in cells (both display and edit mode)
        self.table.setWordWrap(True)
        
        # Apply custom delegate for word wrap in edit mode
        # Pass None for assistance_panel (keyboard shortcuts disabled for now)
        # Pass allow_replace_in_source setting to control source editing
        self.table.setItemDelegate(WordWrapDelegate(None, self.table, self.allow_replace_in_source))
        
        # Row behavior - Enable multi-selection with full row selection (memoQ-style)
        self.table.verticalHeader().setVisible(False)
        self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)  # Select full rows for multi-selection
        self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)  # Allow Ctrl/Shift multi-selection
        
        # Alternating row colors (simplified view)
        self.table.setAlternatingRowColors(True)

        # Simplified grid styling - subtle borders and padding only
        # Note: Colors are controlled by theme_manager.py for proper dark mode support
        self.table.setStyleSheet("""
            QTableWidget::item {
                padding: 4px;
                border: none;
                border-bottom: 1px solid rgba(128, 128, 128, 0.15);
                border-right: 1px solid rgba(128, 128, 128, 0.15);
            }
            QTableWidget::item:last-child {
                border-right: none;
            }
            /* Trados-style strong blue for the active row so the user can see
               at a glance which segment they're working on. The theme's
               default highlight is too pale (#e3f2fd ≈ near-white) for the
               editor grid, where the active segment needs to stand out from
               the alternating-row tint. */
            QTableWidget::item:selected {
                background-color: #CFE5F5;
                color: #000000;
            }
            QTableWidget::item:selected:!active {
                background-color: #DEE9F2;
                color: #000000;
            }

            /* Narrower scrollbar with visible arrow buttons */
            QScrollBar:vertical {
                border: none;
                background: #F0F0F0;
                width: 12px;
                margin: 12px 0 12px 0;
            }
            QScrollBar::handle:vertical {
                background: #C0C0C0;
                min-height: 20px;
                border-radius: 2px;
            }
            QScrollBar::handle:vertical:hover {
                background: #A0A0A0;
            }
            QScrollBar::add-line:vertical {
                height: 12px;
                background: #E0E0E0;
                subcontrol-position: bottom;
                subcontrol-origin: margin;
            }
            QScrollBar::add-line:vertical:hover {
                background: #2196F3;
            }
            QScrollBar::sub-line:vertical {
                height: 12px;
                background: #E0E0E0;
                subcontrol-position: top;
                subcontrol-origin: margin;
            }
            QScrollBar::sub-line:vertical:hover {
                background: #2196F3;
            }
            /* Arrow images */
            QScrollBar::up-arrow:vertical {
                image: url(assets/scrollbar_up.png);
                width: 8px;
                height: 8px;
            }
            QScrollBar::down-arrow:vertical {
                image: url(assets/scrollbar_down.png);
                width: 8px;
                height: 8px;
            }
        """)
        
        # Simplified editing: Double-click only (no F2 key) - companion tool philosophy
        # Grid is primarily for viewing/reviewing, with minor edits allowed
        self.table.setEditTriggers(QTableWidget.EditTrigger.DoubleClicked)
        
        # Enable context menu for bulk operations
        self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.table.customContextMenuRequested.connect(self.show_grid_context_menu)
        
        # Connect signals for debugging
        self.table.itemChanged.connect(self.on_cell_changed)
        self.table.currentCellChanged.connect(self.on_cell_selected)
        self.table.currentCellChanged.connect(self._update_row_selection_tint)
        self.table.itemClicked.connect(self.on_cell_clicked)
        self.table.cellDoubleClicked.connect(self.on_cell_double_clicked)

        # Add additional selection signal for row-based selection mode
        self.table.itemSelectionChanged.connect(self.on_selection_changed)

        # Reposition file boundary banners when the user scrolls or resizes columns
        self.table.verticalScrollBar().valueChanged.connect(self._update_file_boundary_labels)
        self.table.horizontalHeader().sectionResized.connect(lambda *_: self._update_file_boundary_labels())

        # Debug: Confirm signal connections
        self.log("🔌 Table signals connected: currentCellChanged, itemClicked, cellDoubleClicked, itemSelectionChanged")
        
        # Precision scroll buttons removed (user preference)
        # self.add_precision_scroll_buttons()
    
    def add_precision_scroll_buttons(self):
        """Add precision scroll buttons at top/bottom of scrollbar (memoQ-style)"""
        from PyQt6.QtWidgets import QPushButton
        from PyQt6.QtCore import Qt, QTimer
        
        # Get the vertical scrollbar
        scrollbar = self.table.verticalScrollBar()
        
        # Create scroll up button (appears at top)
        self.scroll_up_btn = QPushButton("▲", self.table)
        self.scroll_up_btn.setFixedSize(18, 22)
        self.scroll_up_btn.setStyleSheet("""
            QPushButton {
                background-color: rgba(240, 240, 240, 220);
                border: 1px solid #ccc;
                border-radius: 3px;
                font-size: 9pt;
                font-weight: bold;
                padding: 0px;
            }
            QPushButton:hover {
                background-color: #2196F3;
                color: white;
                border: 1px solid #1976D2;
            }
            QPushButton:pressed {
                background-color: #1565C0;
            }
            QPushButton:focus {
                outline: none;
            }
        """)
        self.scroll_up_btn.setToolTip("Scroll up one row (Precision scroll)")
        self.scroll_up_btn.clicked.connect(lambda: self.precision_scroll(-1))
        self.scroll_up_btn.raise_()  # Bring to front
        self.scroll_up_btn.show()  # Start visible for testing
        
        # Create scroll down button (appears at bottom)
        self.scroll_down_btn = QPushButton("▼", self.table)
        self.scroll_down_btn.setFixedSize(18, 22)
        self.scroll_down_btn.setStyleSheet("""
            QPushButton {
                background-color: rgba(240, 240, 240, 220);
                border: 1px solid #ccc;
                border-radius: 3px;
                font-size: 9pt;
                font-weight: bold;
                padding: 0px;
            }
            QPushButton:hover {
                background-color: #2196F3;
                color: white;
                border: 1px solid #1976D2;
            }
            QPushButton:pressed {
                background-color: #1565C0;
            }
            QPushButton:focus {
                outline: none;
            }
        """)
        self.scroll_down_btn.setToolTip("Scroll down one row (Precision scroll)")
        self.scroll_down_btn.clicked.connect(lambda: self.precision_scroll(1))
        self.scroll_down_btn.raise_()  # Bring to front
        self.scroll_down_btn.show()  # Start visible for testing
        
        # Position buttons initially
        QTimer.singleShot(100, self.position_precision_scroll_buttons)
        
        # Update position when table is resized
        self.table.resizeEvent = self._table_resize_event_wrapper(self.table.resizeEvent)
    
    def _table_resize_event_wrapper(self, original_resize_event):
        """Wrapper for table resize event to reposition scroll buttons"""
        def wrapped_resize_event(event):
            result = original_resize_event(event)
            self.position_precision_scroll_buttons()
            return result
        return wrapped_resize_event
    
    def precision_scroll(self, direction):
        """Scroll table by a small amount for fine-tuned navigation
        Args:
            direction: -1 for up, 1 for down
        """
        scrollbar = self.table.verticalScrollBar()
        current = scrollbar.value()
        
        # Use fixed pixel amounts based on divisor (inversely related)
        # Higher divisor = smaller scroll amount = finer control
        divisor = getattr(self, 'precision_scroll_divisor', 3)
        
        # Map divisor (1-10) to scroll amount (50-5 pixels)
        # divisor 1 = 50px, divisor 3 = 30px, divisor 5 = 20px, divisor 10 = 5px
        scroll_amount = max(5, 55 - (divisor * 5))
        
        new_value = current + (direction * scroll_amount)
        scrollbar.setValue(new_value)
    
    def position_precision_scroll_buttons(self):
        """Position precision scroll buttons at top/bottom of scrollbar"""
        if not hasattr(self, 'scroll_up_btn') or not hasattr(self, 'scroll_down_btn'):
            return
        
        scrollbar = self.table.verticalScrollBar()
        
        # Always position buttons, even if scrollbar not visible yet
        table_width = self.table.width()
        table_height = self.table.height()
        
        # Position buttons to the left of scrollbar (scrollbar is ~17px wide)
        scrollbar_width = scrollbar.width() if scrollbar.isVisible() else 17
        x_pos = table_width - scrollbar_width - 24  # 24 = button width + small margin
        
        # Up button at top (below header)
        header_height = self.table.horizontalHeader().height()
        self.scroll_up_btn.move(x_pos, header_height + 5)
        
        # Down button at bottom (above status bar area)
        self.scroll_down_btn.move(x_pos, table_height - 30)
        
        # Show/hide based on scrollbar visibility
        if scrollbar.isVisible():
            self.scroll_up_btn.show()
            self.scroll_down_btn.show()
        else:
            self.scroll_up_btn.hide()
            self.scroll_down_btn.hide()
    
    def create_assistance_panel(self):
        """Create the assistance panel widget (Translation Results) with compact resizeable design"""
        from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTabWidget
        from modules.translation_results_panel import TranslationResultsPanel
        
        tabs = QTabWidget()
        tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        tabs.tabBar().setDrawBase(False)
        tabs.tabBar().setExpanding(False)
        tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
        tabs.setTabPosition(QTabWidget.TabPosition.South)  # Tabs at bottom for easy access
        
        # Get match limits from settings
        general_settings = self.load_general_settings()
        match_limits = general_settings.get('match_limits', {
            "LLM": 3,
            "MT": 3,
            "TM": 5,
            "Termbases": 10
        })
        
        try:
            # Create translation results panel with dynamic font sizing
            results_panel = TranslationResultsPanel(tabs, parent_app=self)
            results_panel.match_inserted.connect(self.on_match_inserted)
            tabs.addTab(results_panel, "🔍 Translation Results")
            
            # Store reference so we can update it when segments are selected
            if not hasattr(self, 'results_panels'):
                self.results_panels = []
            self.results_panels.append(results_panel)
        except ImportError as e:
            print(f"Warning: Could not import TranslationResultsPanel: {e}")
            placeholder = QWidget()
            tabs.addTab(placeholder, "🔍 Translation Results")
        
        # Tab 2: Segment Editor
        editor_widget = QWidget()
        editor_layout = QVBoxLayout(editor_widget)
        editor_layout.setContentsMargins(10, 10, 10, 10)
        
        # Segment info header
        info_layout = QHBoxLayout()
        tab_seg_info = QLabel("Select a segment to edit")
        tab_seg_info.setStyleSheet("font-weight: bold; font-size: 11pt;")
        info_layout.addWidget(tab_seg_info, stretch=1)

        # Status selector
        from modules.statuses import STATUSES
        status_label = QLabel("Status:")
        tab_status_combo = QComboBox()
        tab_status_combo.setMinimumWidth(130)  # Ensure full status text is visible
        for status_key in STATUSES.keys():
            definition = get_status(status_key)
            tab_status_combo.addItem(definition.label, status_key)
        tab_status_combo.currentIndexChanged.connect(self.on_tab_status_combo_changed)
        info_layout.addWidget(status_label)
        info_layout.addWidget(tab_status_combo)
        editor_layout.addLayout(info_layout)
        
        # Source text (read-only)
        source_label = QLabel("Source:")
        source_label.setStyleSheet("font-weight: bold;")
        editor_layout.addWidget(source_label)
        tab_source_editor = QTextEdit()
        tab_source_editor.setReadOnly(True)
        tab_source_editor.setMaximumHeight(100)
        tab_source_editor.setStyleSheet("")
        editor_layout.addWidget(tab_source_editor)
        
        # Target text (editable)
        target_label = QLabel("Target:")
        target_label.setStyleSheet("font-weight: bold;")
        editor_layout.addWidget(target_label)
        tab_target_editor = QTextEdit()
        tab_target_editor.setMaximumHeight(100)
        tab_target_editor.textChanged.connect(self.on_tab_target_change)
        editor_layout.addWidget(tab_target_editor)
        
        # Store references to THIS panel's widgets so we can update them
        editor_widget.seg_info_label = tab_seg_info
        editor_widget.source_editor = tab_source_editor
        editor_widget.target_editor = tab_target_editor
        editor_widget.status_combo = tab_status_combo
        
        # Action buttons
        button_layout = QHBoxLayout()
        copy_btn = QPushButton("📋 Copy Source → Target")
        copy_btn.clicked.connect(self.copy_source_to_tab_target)
        clear_btn = QPushButton("🗑️ Clear Target")
        clear_btn.clicked.connect(self.clear_tab_target)

        # Voice dictation button
        dictate_btn = QPushButton(f"🎤 Dictate{self._dictation_shortcut_label()}")
        dictate_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
        dictate_btn.clicked.connect(self.start_voice_dictation)
        dictate_btn.setToolTip("Push-to-talk dictation – press your dictation shortcut (or click) to record, transcribe, and insert text. Set the key in Settings → Keyboard Shortcuts.")

        # Store reference to dictate button for state updates
        editor_widget.dictate_btn = dictate_btn

        save_btn = QPushButton("💾 Save")
        save_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 3px 5px;")
        save_btn.clicked.connect(self.save_tab_segment)
        save_next_btn = QPushButton(f"✓ Confirm && Next ({format_shortcut_for_display('Ctrl+Enter')})")
        save_next_btn.setStyleSheet("background-color: #2196F3; color: white; font-weight: bold; padding: 3px 5px;")
        save_next_btn.clicked.connect(self.confirm_selected_or_next)

        button_layout.addWidget(copy_btn)
        button_layout.addWidget(clear_btn)
        button_layout.addWidget(dictate_btn)
        button_layout.addStretch()
        button_layout.addWidget(save_btn)
        button_layout.addWidget(save_next_btn)
        editor_layout.addLayout(button_layout)
        
        editor_layout.addStretch()
        tabs.addTab(editor_widget, "📝 Segment Editor")
        
        # Tab 3: Comments Editor
        notes_widget = QWidget()
        notes_layout = QVBoxLayout(notes_widget)
        notes_layout.setContentsMargins(10, 10, 10, 10)
        
        notes_header = QLabel("Segment Comments")
        notes_header.setStyleSheet("font-weight: bold; font-size: 11pt;")
        notes_layout.addWidget(notes_header)
        
        tab_notes_editor = QTextEdit()
        tab_notes_editor.setPlaceholderText("Add comments for this segment...\n\n"
                                            "Comments are saved per segment and can include:\n"
                                            "• Translation context\n"
                                            "• Client preferences\n"
                                            "• Terminology decisions\n"
                                            "• Questions or clarifications needed")
        tab_notes_editor.textChanged.connect(self.on_tab_notes_change)
        notes_layout.addWidget(tab_notes_editor)
        
        # Store reference to THIS panel's comments editor
        notes_widget.notes_editor = tab_notes_editor
        
        notes_button_layout = QHBoxLayout()
        save_notes_btn = QPushButton("💾 Save Comments")
        save_notes_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
        save_notes_btn.clicked.connect(self.save_tab_notes)
        notes_button_layout.addStretch()
        notes_button_layout.addWidget(save_notes_btn)
        notes_layout.addLayout(notes_button_layout)
        
        tabs.addTab(notes_widget, "📋 Comments")
        
        # Store references to tab widgets for updates
        tabs.editor_widget = editor_widget
        tabs.notes_widget = notes_widget
        
        # Keep track of all tabbed panels
        if not hasattr(self, 'tabbed_panels'):
            self.tabbed_panels = []
        self.tabbed_panels.append(tabs)
        
        return tabs
    
    def on_match_selected(self, match):
        """Handle match selection from TranslationResultsPanel"""
        # This will be called when user selects a match in the panel
        # Can be used for highlighting, compare boxes, etc.
        pass
    
    def on_match_inserted(self, match_text: str):
        """
        Handle match insertion (user pressed Ctrl+number or Spacebar in match pane)
        Insert the match text at the cursor position in the currently selected target cell
        WITHOUT confirming the segment or moving to next
        """
        try:
            if not self.current_project or not self.table:
                return
            
            # Get current row
            row = self.table.currentRow()
            col = self.table.currentColumn()
            
            if row >= 0:
                # CRITICAL: Get segment by ID from grid, not by row index!
                id_item = self.table.item(row, 0)
                if not id_item:
                    self.log(f"⚠️ No segment ID found at row {row}")
                    return
                
                try:
                    segment_id = int(id_item.text())
                except (ValueError, AttributeError):
                    self.log(f"⚠️ Could not parse segment ID from row {row}")
                    return
                
                # Find segment by ID
                segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
                if not segment:
                    self.log(f"⚠️ Could not find segment with ID {segment_id}")
                    return
                
                # Get the target cell widget (EditableGridTextEditor)
                target_widget = self.table.cellWidget(row, 3)  # Column 3 is target
                
                if target_widget and isinstance(target_widget, QTextEdit):
                    # Insert text at cursor position
                    cursor = target_widget.textCursor()
                    cursor.insertText(match_text)
                    
                    # Update the segment data
                    new_target = target_widget.toPlainText()
                    # v1.9.306: Strip invisible markers before saving to segment data
                    if hasattr(self, 'reverse_invisible_replacements'):
                        new_target = self.reverse_invisible_replacements(new_target)
                    segment.target = new_target

                    # Set focus back to the target editor
                    target_widget.setFocus()

                    self.log(f"✓ Match inserted into segment {segment.id} at cursor position")
                elif col == 3:
                    # Fallback: If no widget exists, create one or set text directly
                    segment.target = match_text
                    
                    # Try to update via cellWidget first
                    if target_widget:
                        target_widget.setPlainText(match_text)
                    
                    self.log(f"✓ Match inserted into segment {row + 1}")
                else:
                    self.log(f"⚠ Please click on the target cell first to insert match")
            
        except Exception as e:
            self.log(f"Error inserting match: {e}")
            import traceback
            traceback.print_exc()
    
    # ========================================================================
    # PROJECT MANAGEMENT
    # ========================================================================
    
    def new_project(self):
        """Create a new project"""
        from PyQt6.QtWidgets import (
            QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, QTextEdit,
            QComboBox, QRadioButton, QButtonGroup, QPushButton, QTabWidget
        )
        from modules.simple_segmenter import SimpleSegmenter
        
        # Create dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("New Translation Project")
        dialog.setMinimumWidth(700)
        dialog.setMinimumHeight(500)
        
        layout = QVBoxLayout(dialog)
        
        # Project Settings Group
        settings_group = QGroupBox("Project Settings")
        settings_layout = QFormLayout()
        
        # Project name
        name_input = QLineEdit()
        name_input.setText("Untitled Project")
        name_input.selectAll()
        settings_layout.addRow("Project Name:", name_input)
        
        # Source language
        source_lang_combo = QComboBox()
        # Full language list matching Settings → Language Pair (with ISO 639-1 codes)
        available_langs = [
            ("Afrikaans", "af"), ("Albanian", "sq"), ("Arabic", "ar"), ("Armenian", "hy"),
            ("Basque", "eu"), ("Bengali", "bn"), ("Bulgarian", "bg"), ("Catalan", "ca"),
            ("Chinese (Simplified)", "zh-CN"), ("Chinese (Traditional)", "zh-TW"),
            ("Croatian", "hr"), ("Czech", "cs"), ("Danish", "da"), ("Dutch", "nl"),
            ("English", "en"), ("Estonian", "et"), ("Finnish", "fi"), ("French", "fr"),
            ("Galician", "gl"), ("Georgian", "ka"), ("German", "de"), ("Greek", "el"),
            ("Hebrew", "he"), ("Hindi", "hi"), ("Hungarian", "hu"), ("Icelandic", "is"),
            ("Indonesian", "id"), ("Irish", "ga"), ("Italian", "it"), ("Japanese", "ja"),
            ("Korean", "ko"), ("Latvian", "lv"), ("Lithuanian", "lt"), ("Macedonian", "mk"),
            ("Malay", "ms"), ("Norwegian", "no"), ("Persian", "fa"), ("Polish", "pl"),
            ("Portuguese", "pt"), ("Romanian", "ro"), ("Russian", "ru"), ("Serbian", "sr"),
            ("Slovak", "sk"), ("Slovenian", "sl"), ("Spanish", "es"), ("Swahili", "sw"),
            ("Swedish", "sv"), ("Thai", "th"), ("Turkish", "tr"), ("Ukrainian", "uk"),
            ("Urdu", "ur"), ("Vietnamese", "vi"), ("Welsh", "cy"),
        ]
        for lang_name, lang_code in available_langs:
            source_lang_combo.addItem(lang_name, lang_code)
        settings_layout.addRow("Source Language:", source_lang_combo)

        # Target language
        target_lang_combo = QComboBox()
        for lang_name, lang_code in available_langs:
            target_lang_combo.addItem(lang_name, lang_code)

        # Set defaults based on global language settings
        source_lang_combo.setCurrentText(self.source_language)
        target_lang_combo.setCurrentText(self.target_language)
        
        settings_layout.addRow("Target Language:", target_lang_combo)
        
        settings_group.setLayout(settings_layout)
        layout.addWidget(settings_group)
        
        # Import Options Group
        import_group = QGroupBox("Import Source Text")
        import_layout = QVBoxLayout()
        
        # Tab widget for different import methods
        import_tabs = QTabWidget()
        import_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        import_tabs.tabBar().setDrawBase(False)
        import_tabs.tabBar().setExpanding(False)
        import_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
        
        # Tab 1: Paste Text
        paste_tab = QWidget()
        paste_layout = QVBoxLayout(paste_tab)
        paste_layout.addWidget(QLabel("Paste or type your source text below:"))
        
        text_input = QTextEdit()
        text_input.setPlaceholderText(
            "Paste your text here...\n\n"
            "Text will be automatically segmented into sentences.\n"
            "Each sentence becomes a translatable segment."
        )
        paste_layout.addWidget(text_input)
        import_tabs.addTab(paste_tab, "📝 Paste Text")
        
        # Tab 2: Load from File
        file_tab = QWidget()
        file_layout = QVBoxLayout(file_tab)
        file_layout.addWidget(QLabel("Load text from a file:"))
        
        file_path_display = QLineEdit()
        file_path_display.setPlaceholderText("No file selected...")
        file_path_display.setReadOnly(True)
        
        browse_btn = QPushButton("📁 Browse...")
        
        def browse_file():
            file_path, _ = QFileDialog.getOpenFileName(
                dialog,
                "Select Text File",
                "",
                "Text Files (*.txt);;All Files (*.*)"
            )
            if file_path:
                file_path_display.setText(file_path)
                try:
                    with open(file_path, 'r', encoding='utf-8') as f:
                        text_input.setText(f.read())
                    import_tabs.setCurrentIndex(0)  # Switch to paste tab to show loaded text
                except Exception as e:
                    QMessageBox.warning(dialog, "Error", f"Could not load file:\n{e}")
        
        browse_btn.clicked.connect(browse_file)
        
        file_btn_layout = QHBoxLayout()
        file_btn_layout.addWidget(file_path_display)
        file_btn_layout.addWidget(browse_btn)
        file_layout.addLayout(file_btn_layout)
        file_layout.addStretch()
        
        import_tabs.addTab(file_tab, "📄 Load File")
        
        # Tab 3: Start Empty
        empty_tab = QWidget()
        empty_layout = QVBoxLayout(empty_tab)
        empty_layout.addWidget(QLabel("Create an empty project:"))
        empty_layout.addWidget(QLabel("You can add segments manually after creation."))
        empty_layout.addStretch()
        import_tabs.addTab(empty_tab, "\u2b50 Start Empty")
        
        import_layout.addWidget(import_tabs)
        import_group.setLayout(import_layout)
        layout.addWidget(import_group)
        
        # Buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        create_btn = QPushButton("Create Project")
        create_btn.setDefault(True)
        create_btn.setMinimumWidth(120)
        create_btn.clicked.connect(dialog.accept)
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setMinimumWidth(120)
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addWidget(create_btn)
        button_layout.addWidget(cancel_btn)
        layout.addLayout(button_layout)
        
        # Show dialog
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Create project
        project_name = name_input.text().strip() or "Untitled Project"
        source_lang = source_lang_combo.currentData()
        target_lang = target_lang_combo.currentData()
        
        # Validate languages
        if source_lang == target_lang:
            QMessageBox.warning(
                self,
                "Invalid Languages",
                "Source and target languages must be different!"
            )
            return
        
        # Create project
        self.current_project = Project(
            name=project_name,
            source_lang=source_lang,
            target_lang=target_lang,
            segments=[]
        )

        # Sync global language settings with new project languages
        self.source_language = source_lang
        self.target_language = target_lang

        # Process source text if provided
        source_text = text_input.toPlainText().strip()
        if source_text:
            try:
                segmenter = SimpleSegmenter()
                sentences = segmenter.segment_text(source_text)
                
                # Create segments
                for idx, sentence in enumerate(sentences, start=1):
                    if sentence.strip():
                        segment = Segment(
                            id=idx,
                            source=sentence.strip(),
                            target="",
                            status="untranslated"
                        )
                        self.current_project.segments.append(segment)
                
                self.log(f"Created {len(self.current_project.segments)} segments from source text")
            except Exception as e:
                QMessageBox.warning(
                    self,
                    "Segmentation Error",
                    f"Could not segment text:\n{e}\n\nProject created but no segments added."
                )
        
        # Update UI
        self.project_file_path = None
        self.project_modified = True  # Mark as modified since it hasn't been saved

        # Store original segment order for "Document Order" sort reset
        self._original_segment_order = self.current_project.segments.copy()

        self.update_window_title()
        self.load_segments_to_grid()
        self.initialize_tm_database()  # Initialize TM for this project
        
        # Clear scratchpad for new project
        self._update_scratchpad_for_project()
        # Rebuild the all-comments list (empty for a brand-new project)
        self._refresh_segment_comments_list()
        
        self.log(f"Created new project: {project_name} ({source_lang} → {target_lang})")
        
        # Prompt to save
        if self.current_project.segments:
            reply = QMessageBox.question(
                self,
                "Save Project",
                "Would you like to save your new project now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.Yes:
                self.save_project_as()
    
    # ────────────────────────────────────────────────────────────────────
    #  System tray integration (cross-platform via QSystemTrayIcon).
    #
    #  Three preferences live in settings.json under the "ui" section:
    #    close_to_tray             – clicking the window's X hides instead of quits
    #    start_minimized_to_tray   – launch with no visible window (tray only)
    #    close_to_tray_hint_shown  – internal: have we shown the first-time hint?
    #
    #  Toggleable from the tray icon's right-click menu, no Settings-dialog
    #  trip needed. On platforms with no system tray (e.g. headless Linux)
    #  the tray simply stays None and these features are no-ops.
    # ────────────────────────────────────────────────────────────────────

    def _setup_tray_icon(self):
        """Create the system-tray icon. Called once from main() after __init__."""
        from PyQt6.QtWidgets import QSystemTrayIcon, QMenu
        from PyQt6.QtGui import QAction, QIcon

        self._tray_icon = None
        self._really_quit = False  # Set by tray "Quit" so closeEvent doesn't hide

        if not QSystemTrayIcon.isSystemTrayAvailable():
            print("[Tray] System tray unavailable on this platform – skipping")
            return

        # Prefer the high-DPI PNG family from assets/ – those scale much
        # better in the Windows tray than icon.ico (which collapses to a
        # blurry 16-px frame). Fall back to icon.ico, then the window
        # icon, so older installs without the PNGs still get *something*.
        icon = None
        for candidate in ("icon_24.png", "icon_16x16.png", "icon_128.png", "icon.ico"):
            icon_path = get_resource_path(f"assets/{candidate}")
            if icon_path.exists():
                icon = QIcon(str(icon_path))
                break
        if icon is None or icon.isNull():
            icon = self.windowIcon()

        self._tray_icon = QSystemTrayIcon(icon, self)
        self._tray_icon.setToolTip("Supervertaler Workbench")

        prefs = self._load_settings_section("ui")
        menu = QMenu()

        show_action = QAction("Show Workbench", self)
        show_action.triggered.connect(self._tray_show_window)
        menu.addAction(show_action)

        # Quick tab-jump entries (v1.10.2 of issue #199). Mirror the
        # global hotkeys: Ctrl+Alt+L = SuperLookup, Ctrl+Alt+C =
        # Clipboard, etc. Each entry shows Workbench (if hidden) and
        # switches main_tabs to the target tab. Indices are looked up
        # lazily via getattr so the menu still builds even if a tab
        # hasn't been added (e.g. an older build with fewer top tabs).
        def _make_jumper(attr_name):
            def _jump():
                idx = getattr(self, attr_name, None)
                if idx is None:
                    return
                self._tray_show_window()
                if hasattr(self, 'main_tabs'):
                    try:
                        self.main_tabs.setCurrentIndex(idx)
                    except Exception:
                        pass
            return _jump

        jump_lookup = QAction("Open SuperLookup", self)
        jump_lookup.triggered.connect(_make_jumper('superlookup_tab_index'))
        menu.addAction(jump_lookup)

        jump_clipboard = QAction("Open Clipboard", self)
        jump_clipboard.triggered.connect(_make_jumper('clipboard_tab_index'))
        menu.addAction(jump_clipboard)

        jump_voice = QAction("Open Voice", self)
        jump_voice.triggered.connect(_make_jumper('voice_tab_index'))
        menu.addAction(jump_voice)

        jump_settings = QAction("Open Settings", self)
        jump_settings.triggered.connect(_make_jumper('settings_tab_index'))
        menu.addAction(jump_settings)

        menu.addSeparator()

        self._tray_close_to_tray_action = QAction("Close to tray (instead of quit)", self)
        self._tray_close_to_tray_action.setCheckable(True)
        self._tray_close_to_tray_action.setChecked(bool(prefs.get("close_to_tray", False)))
        self._tray_close_to_tray_action.toggled.connect(self._on_toggle_close_to_tray)
        menu.addAction(self._tray_close_to_tray_action)

        self._tray_start_minimized_action = QAction("Start minimized to tray", self)
        self._tray_start_minimized_action.setCheckable(True)
        self._tray_start_minimized_action.setChecked(bool(prefs.get("start_minimized_to_tray", False)))
        self._tray_start_minimized_action.toggled.connect(self._on_toggle_start_minimized)
        menu.addAction(self._tray_start_minimized_action)

        # "Start with computer" – registers an HKCU Run entry on Windows,
        # ~/Library/LaunchAgents plist on macOS, ~/.config/autostart .desktop
        # on Linux. No elevation needed; the auto-started instance uses the
        # GUI Python variant so no terminal window appears at boot.
        try:
            from modules import autostart as _autostart_mod
            self._autostart_mod = _autostart_mod
            if _autostart_mod.is_supported():
                self._tray_autostart_action = QAction("Start with computer", self)
                self._tray_autostart_action.setCheckable(True)
                self._tray_autostart_action.setChecked(_autostart_mod.is_enabled())
                self._tray_autostart_action.toggled.connect(self._on_toggle_autostart)
                menu.addAction(self._tray_autostart_action)
        except Exception as e:
            print(f"[Tray] Autostart menu item not available: {e}")
            self._autostart_mod = None

        menu.addSeparator()

        quit_action = QAction("Quit Supervertaler", self)
        quit_action.triggered.connect(self._tray_quit)
        menu.addAction(quit_action)

        self._tray_icon.setContextMenu(menu)
        self._tray_icon.activated.connect(self._on_tray_activated)
        self._tray_icon.show()

        # Tell Qt not to quit when all windows are closed – we manage lifecycle
        # via the tray "Quit" item. Otherwise hiding the only window via close
        # would terminate the app on some platforms.
        QApplication.instance().setQuitOnLastWindowClosed(False)

    def _on_tray_activated(self, reason):
        """Left-click the tray icon → toggle window visibility (Windows/Linux convention)."""
        from PyQt6.QtWidgets import QSystemTrayIcon
        if reason == QSystemTrayIcon.ActivationReason.Trigger:
            if self.isVisible():
                self.hide()
            else:
                self._tray_show_window()

    def _tray_show_window(self):
        """Restore, raise, and focus the main window from the tray."""
        self.show()
        # Clear the minimized state if Qt set it during hide.
        self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized)
        self.raise_()
        self.activateWindow()

    def _on_esc_quick_lookup_dismiss(self):
        """Hide Workbench to the system tray when Esc is pressed
        while the user is on a "quick lookup" top tab.

        Two gates always apply:

        1. Current tab is one of SuperLookup / Clipboard / Voice.
           On Editor / TMs / Termbases / AI / Settings, Esc keeps
           its natural editor / dialog / combo-box semantics
           (cancel-edit, close-popup, etc.).

        2. A system-tray icon exists. Without a tray, hiding would
           strand the user in an invisible window. Fail closed on
           platforms without tray support.

        Per-tab text-input gate (v1.10.19 refinement):

        - **SuperLookup**: always hide regardless of focus. The user
          observed (correctly) that SuperLookup is a single-purpose
          query surface – there's no state to preserve, the search
          field re-seeds on the next summon, and the only thing Esc
          could reasonably mean here is "get me out of here". So we
          skip the focus check and dismiss unconditionally.
        - **Clipboard / Voice**: skip the dismiss if a text-input
          widget has focus (QLineEdit / QTextEdit / QPlainTextEdit /
          QAbstractSpinBox / editable QComboBox), to preserve
          standard Qt-app conventions (Esc clears the field, closes
          a popup, etc.) when the user might plausibly be typing
          something they don't want to lose. Once focus is on the
          list / tree / button / empty area, Esc dismisses to tray.

        Wired in v1.10.17, made focus-aware in v1.10.18, made
        always-hide-on-SuperLookup in v1.10.19.
        """
        if not hasattr(self, 'main_tabs'):
            return
        if getattr(self, '_tray_icon', None) is None:
            return

        superlookup_idx = getattr(self, 'superlookup_tab_index', None)
        clipboard_idx = getattr(self, 'clipboard_tab_index', None)
        voice_idx = getattr(self, 'voice_tab_index', None)
        current = self.main_tabs.currentIndex()

        # SuperLookup: unconditional hide.
        if superlookup_idx is not None and current == superlookup_idx:
            self.hide()
            return

        # Clipboard / Voice: focus-aware hide.
        focus_aware_indices = {clipboard_idx, voice_idx}
        focus_aware_indices.discard(None)
        if current not in focus_aware_indices:
            # Some other tab – not a quick-lookup surface, do nothing.
            return

        try:
            from PyQt6.QtWidgets import (
                QLineEdit, QTextEdit, QPlainTextEdit, QComboBox,
                QAbstractSpinBox,
            )
            focused = QApplication.focusWidget()
            if focused is not None:
                text_input_types = (
                    QLineEdit, QTextEdit, QPlainTextEdit,
                    QAbstractSpinBox,
                )
                if isinstance(focused, text_input_types):
                    return
                if isinstance(focused, QComboBox) and focused.isEditable():
                    return
                # Walk up: a QLineEdit nested inside a custom
                # composite widget should also count as a text input.
                w = focused.parent()
                hops = 0
                while w is not None and hops < 5:
                    if isinstance(w, text_input_types):
                        return
                    w = w.parent()
                    hops += 1
        except Exception:
            # If the introspection fails for any reason, fall
            # through to hide – safer than silently doing nothing.
            pass

        self.hide()

    def _on_toggle_close_to_tray(self, checked: bool):
        prefs = self._load_settings_section("ui")
        prefs["close_to_tray"] = bool(checked)
        self._save_settings_section("ui", prefs)

    def _on_toggle_start_minimized(self, checked: bool):
        prefs = self._load_settings_section("ui")
        prefs["start_minimized_to_tray"] = bool(checked)
        self._save_settings_section("ui", prefs)

    def _on_toggle_autostart(self, checked: bool):
        """Register or unregister Supervertaler with the OS autostart facility."""
        mod = getattr(self, "_autostart_mod", None)
        if mod is None:
            return
        ok = mod.enable() if checked else mod.disable()
        # Re-sync the checkbox with reality – if the registry / plist write
        # failed silently, don't leave the menu showing a stale state.
        actual = mod.is_enabled()
        if actual != checked:
            self._tray_autostart_action.blockSignals(True)
            self._tray_autostart_action.setChecked(actual)
            self._tray_autostart_action.blockSignals(False)
        if ok and getattr(self, "_tray_icon", None) is not None and checked:
            try:
                from PyQt6.QtWidgets import QSystemTrayIcon
                self._tray_icon.showMessage(
                    "Supervertaler",
                    "Will start automatically with your computer. "
                    "Tip: enable 'Start minimized to tray' to skip the window at boot.",
                    QSystemTrayIcon.MessageIcon.Information,
                    4000,
                )
            except Exception:
                pass

    def _tray_quit(self):
        """Real quit from tray menu – bypasses the close-to-tray hide."""
        self._really_quit = True
        self.close()  # Triggers closeEvent which now does cleanup, then accepts
        QApplication.instance().quit()

    def closeEvent(self, event):
        """Handle application close - cleanup hotkey processes"""
        # Close-to-tray: hide instead of quit, unless the tray menu explicitly
        # asked for a real quit. Skipped if the tray icon failed to initialise.
        if (not getattr(self, "_really_quit", False)
                and getattr(self, "_tray_icon", None) is not None):
            prefs = self._load_settings_section("ui")
            if prefs.get("close_to_tray", False):
                event.ignore()
                self.hide()
                if not prefs.get("close_to_tray_hint_shown", False):
                    try:
                        from PyQt6.QtWidgets import QSystemTrayIcon
                        self._tray_icon.showMessage(
                            "Supervertaler is still running",
                            "Click the tray icon to bring it back, or right-click "
                            "for Quit.",
                            QSystemTrayIcon.MessageIcon.Information,
                            5000,
                        )
                    except Exception:
                        pass
                    prefs["close_to_tray_hint_shown"] = True
                    self._save_settings_section("ui", prefs)
                return

        try:
            if hasattr(self, 'lookup_tab'):
                # Clean up pynput hotkey manager (cross-platform)
                if hasattr(self.lookup_tab, '_hotkey_manager') and self.lookup_tab._hotkey_manager:
                    try:
                        self.lookup_tab._hotkey_manager.stop()
                        print("[Superlookup] pynput hotkey manager stopped")
                    except Exception as e:
                        print(f"[Superlookup] Error stopping pynput: {e}")

                # Clean up ahk library if used (Windows fallback)
                if hasattr(self.lookup_tab, '_using_ahk_library') and self.lookup_tab._using_ahk_library:
                    if hasattr(self.lookup_tab, '_ahk') and self.lookup_tab._ahk:
                        try:
                            self.lookup_tab._ahk.stop_hotkeys()
                            print("[Superlookup] ahk library hotkeys stopped")
                        except Exception as e:
                            print(f"[Superlookup] Error stopping ahk library: {e}")

                # Terminate external AutoHotkey process if running (Windows fallback)
                if hasattr(self.lookup_tab, 'ahk_process') and self.lookup_tab.ahk_process:
                    try:
                        self.lookup_tab.ahk_process.terminate()
                        self.lookup_tab.ahk_process.wait(timeout=2)
                        print("[Superlookup] AHK process terminated")
                    except Exception:
                        try:
                            self.lookup_tab.ahk_process.kill()
                        except Exception:
                            pass
        except Exception as e:
            print(f"[Superlookup] Error during hotkey cleanup: {e}")

        # Accept the close event.
        event.accept()
        # Because _setup_tray_icon flipped setQuitOnLastWindowClosed to False
        # (so dialogs / hidden windows wouldn't terminate the app), we need
        # to ask Qt to quit explicitly when close-to-tray is OFF – otherwise
        # closing the main window would leave a headless process behind.
        if getattr(self, "_tray_icon", None) is not None:
            QApplication.instance().quit()

    def open_project(self):
        """Open a project file"""
        file_path, _ = fdh.get_open_file_name(
            self,
            "Open Project",
            "Supervertaler Projects (*.svproj);;Legacy Projects (*.json);;All Files (*.*)"
        )
        
        if file_path:
            self.load_project(file_path)
    
    def load_project(self, file_path: str):
        """Load project from file"""
        # ── Progress dialog (indeterminate, modal) ────────────────────
        # Loading large projects (1000+ segments) takes seconds because
        # the grid build runs on the main thread. The dialog gives the
        # user visible feedback and prevents the OS marking the window
        # "Not Responding".
        from PyQt6.QtWidgets import QProgressDialog
        progress = QProgressDialog(
            f"Opening {os.path.basename(file_path)}…",
            None,  # no cancel button
            0, 0, self
        )
        progress.setWindowTitle("Opening project")
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.show()
        QApplication.processEvents()

        try:
            progress.setLabelText("Reading project file…")
            QApplication.processEvents()

            # Try UTF-8 first, fall back to latin-1 if it fails
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    data = json.load(f)
            except UnicodeDecodeError:
                self.log(f"⚠ UTF-8 decoding failed, trying latin-1 encoding...")
                with open(file_path, 'r', encoding='latin-1') as f:
                    data = json.load(f)
            
            # If no name in file, use filename
            if 'name' not in data:
                data['name'] = Path(file_path).stem
            
            self.current_project = Project.from_dict(data)
            self.project_file_path = file_path
            self.project_modified = False

            # Store original segment order for "Document Order" sort reset
            self._original_segment_order = self.current_project.segments.copy()

            # Always reset sort state when loading - project should open in document order
            self.current_sort = None

            # Sync global language settings with project languages
            if self.current_project.source_lang:
                self.source_language = self.current_project.source_lang
            if self.current_project.target_lang:
                self.target_language = self.current_project.target_lang

            # Restore prompt settings if they exist (unified library)
            if hasattr(self.current_project, 'prompt_settings') and self.current_project.prompt_settings:
                prompt_settings = self.current_project.prompt_settings
                if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                    library = self.prompt_manager_qt.library
                    
                    # Restore primary prompt
                    primary_path = prompt_settings.get('active_primary_prompt_path')
                    if primary_path:
                        # Check if it's an external prompt (path starts with [EXTERNAL])
                        if primary_path.startswith('[EXTERNAL] '):
                            # Extract the actual file path
                            external_file_path = primary_path[len('[EXTERNAL] '):]
                            if os.path.exists(external_file_path):
                                success, _ = library.set_external_primary_prompt(external_file_path)
                                if success:
                                    # Update the UI label
                                    display_name = Path(external_file_path).stem
                                    self.prompt_manager_qt.primary_prompt_label.setText(f"📁 {display_name}")
                                    self.prompt_manager_qt.primary_prompt_label.setStyleSheet("color: #000; font-weight: bold;")
                                    # Display the external prompt in the editor
                                    self.prompt_manager_qt._display_external_prompt_in_editor(external_file_path, display_name)
                                    self.log(f"✓ Restored external primary prompt: {display_name}")
                            else:
                                self.log(f"⚠ External prompt file not found: {external_file_path}")
                        elif primary_path in library.prompts:
                            # Regular library prompt
                            self.prompt_manager_qt._set_primary_prompt(primary_path)
                            self.log(f"✓ Restored primary prompt: {primary_path}")
                    
                    # Restore attached prompts
                    attached_paths = prompt_settings.get('attached_prompt_paths', [])
                    for path in attached_paths:
                        if path in library.prompts:
                            # Use the UI method to update both internal state and UI
                            self.prompt_manager_qt._attach_prompt(path)
                            self.log(f"✓ Restored attached prompt: {path}")
                    
                    # Restore mode
                    mode = prompt_settings.get('mode', 'single')
                    if hasattr(self.prompt_manager_qt, 'set_mode'):
                        self.prompt_manager_qt.set_mode(mode)
                
                # Restore image context folder
                image_folder = prompt_settings.get('image_context_folder')
                if image_folder and hasattr(self, 'figure_context') and self.figure_context:
                    try:
                        if os.path.exists(image_folder):
                            count = self.figure_context.load_from_folder(image_folder)
                            if count > 0:
                                if hasattr(self, 'image_context_status_label'):
                                    self.image_context_status_label.setText(f"✅ {count} images loaded: {Path(image_folder).name}")
                                    self.image_context_status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 11px; padding: 5px;")
                                self.log(f"✅ Restored image context: {count} images from {image_folder}")
                        else:
                            self.log(f"⚠️  Saved image folder not found: {image_folder}")
                    except Exception as e:
                        self.log(f"❌ Error restoring image context: {e}")
                
                # Restore Image Extractor file list
                extractor_files = prompt_settings.get('image_extractor_files', [])
                if extractor_files and hasattr(self, 'image_extractor_file_list'):
                    self.image_extractor_file_list.clear()
                    valid_files = []
                    for file_path in extractor_files:
                        if os.path.exists(file_path):
                            self.image_extractor_file_list.addItem(file_path)
                            valid_files.append(file_path)
                        else:
                            self.log(f"⚠️ Image extractor file not found: {file_path}")
                    if valid_files:
                        self.log(f"✅ Restored {len(valid_files)} files in Image Extractor")
            
            # Restore original DOCX path for structure-preserving export
            if hasattr(self.current_project, 'original_docx_path') and self.current_project.original_docx_path:
                docx_path = self.current_project.original_docx_path
                if os.path.exists(docx_path):
                    self.original_docx = docx_path
                    self.current_document_path = docx_path
                    self.log(f"✓ Restored original DOCX path: {Path(docx_path).name}")
                else:
                    self.log(f"⚠️ Original DOCX not found: {docx_path}")
            
            # Restore SDLPPX handler for Trados package projects
            if hasattr(self.current_project, 'sdlppx_source_path') and self.current_project.sdlppx_source_path:
                sdlppx_path = self.current_project.sdlppx_source_path
                if os.path.exists(sdlppx_path):
                    try:
                        from modules.sdlppx_handler import TradosPackageHandler
                        self.sdlppx_handler = TradosPackageHandler(log_callback=self.log)
                        package = self.sdlppx_handler.load_package(sdlppx_path)
                        if package:
                            self.sdlppx_source_file = sdlppx_path  # Store source file path for export
                            self.log(f"✓ Restored Trados package handler: {Path(sdlppx_path).name}")
                            self.log(f"  → SDLRPX export enabled for this project")
                        else:
                            self.log(f"⚠️ Could not load SDLPPX package: {sdlppx_path}")
                            self.sdlppx_handler = None
                    except Exception as e:
                        self.log(f"⚠️ Could not restore SDLPPX handler: {e}")
                        self.sdlppx_handler = None
                else:
                    self.log(f"⚠️ SDLPPX package not found: {sdlppx_path}")
                    self.sdlppx_handler = None

            # Restore standalone SDLXLIFF handler
            sdlxliff_paths = getattr(self.current_project, 'sdlxliff_source_paths', None)
            if sdlxliff_paths:
                existing = [p for p in sdlxliff_paths if os.path.exists(p)]
                if existing:
                    try:
                        from modules.sdlppx_handler import StandaloneSDLXLIFFHandler
                        handler = StandaloneSDLXLIFFHandler(log_callback=self.log)
                        if handler.load(existing):
                            self.sdlxliff_handler = handler
                            self.sdlxliff_source_files = existing
                            self.log(f"✓ Restored SDLXLIFF handler: {len(existing)} file(s)")
                            self.log(f"  → SDLXLIFF export enabled for this project")
                        else:
                            self.log(f"⚠️ Could not reload SDLXLIFF files")
                            self.sdlxliff_handler = None
                    except Exception as e:
                        self.log(f"⚠️ Could not restore SDLXLIFF handler: {e}")
                        self.sdlxliff_handler = None
                else:
                    missing = [Path(p).name for p in sdlxliff_paths if not os.path.exists(p)]
                    self.log(f"⚠️ SDLXLIFF source file(s) not found: {', '.join(missing)}")
                    self.sdlxliff_handler = None

            seg_count = len(self.current_project.segments) if self.current_project else 0
            progress.setLabelText(f"Loading {seg_count:,} segments into grid…")
            QApplication.processEvents()

            def _grid_progress(done, total):
                progress.setLabelText(
                    f"Loading segments into grid… ({done:,} / {total:,})"
                )
                QApplication.processEvents()

            self.load_segments_to_grid(progress_callback=_grid_progress)

            progress.setLabelText("Initialising translation memory…")
            QApplication.processEvents()
            self.initialize_tm_database()  # Initialize TM for this project
            self.update_window_title()
            self.add_to_recent_projects(file_path)
            
            # Auto-resize rows for better initial display
            self.auto_resize_rows()
            
            # Update file filter dropdown for multi-file projects
            if hasattr(self, '_update_file_filter_combo'):
                self._update_file_filter_combo()
            
            # Restore activated TMs for this project
            if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                if project_id:
                    # First deactivate all TMs for this project (start clean)
                    all_tms = self.tm_metadata_mgr.get_all_tms()
                    for tm in all_tms:
                        self.tm_metadata_mgr.deactivate_tm(tm['id'], project_id)

                    # Then restore saved TM activations if they exist
                    if hasattr(self.current_project, 'tm_settings') and self.current_project.tm_settings:
                        activated_tm_ids = self.current_project.tm_settings.get('activated_tm_ids', [])
                        if activated_tm_ids:
                            # Activate the saved TMs and restore read_only status
                            tm_read_only_status = self.current_project.tm_settings.get('tm_read_only_status', {})
                            for tm_id in activated_tm_ids:
                                # Find TM by tm_id (not db id)
                                tm = next((t for t in all_tms if t['tm_id'] == tm_id), None)
                                if tm:
                                    self.tm_metadata_mgr.activate_tm(tm['id'], project_id)
                                    # Restore read_only status if saved
                                    if tm_id in tm_read_only_status:
                                        read_only = tm_read_only_status[tm_id]
                                        self.tm_metadata_mgr.set_read_only(tm['id'], read_only)
                                        status = "read-only" if read_only else "writable"
                                        self.log(f"✓ Restored TM: {tm['name']} (activated, {status})")
                                    else:
                                        self.log(f"✓ Restored activated TM: {tm['name']}")
                                else:
                                    self.log(f"⚠️ Could not find TM with tm_id: {tm_id}")
                    else:
                        # NEW PROJECT (no saved tm_settings): Start with clean slate
                        # User should explicitly select which TMs to use for this project
                        pass

                    # Refresh TM UI to show (de)activations
                    if hasattr(self, 'tm_tab_refresh_callback'):
                        self.tm_tab_refresh_callback()

            # Restore activated termbases for this project
            if hasattr(self, 'termbase_mgr') and self.termbase_mgr and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                if project_id:
                    # First deactivate all termbases for this project (start clean)
                    all_termbases = self.termbase_mgr.get_all_termbases()
                    for tb in all_termbases:
                        self.termbase_mgr.deactivate_termbase(tb['id'], project_id)
                    
                    # Then restore saved active termbases (if any)
                    if hasattr(self.current_project, 'termbase_settings') and self.current_project.termbase_settings:
                        active_termbase_ids = self.current_project.termbase_settings.get('active_termbase_ids', [])
                        termbase_priorities = self.current_project.termbase_settings.get('termbase_priorities', {})
                        
                        if active_termbase_ids:
                            for tb_id in active_termbase_ids:
                                # Find termbase by id
                                tb = next((t for t in all_termbases if t['id'] == tb_id), None)
                                if tb:
                                    self.termbase_mgr.activate_termbase(tb_id, project_id)
                                    self.log(f"✓ Restored activated termbase: {tb['name']}")
                                    
                                    # Restore priority if saved
                                    if str(tb_id) in termbase_priorities:
                                        priority = termbase_priorities[str(tb_id)]
                                        self.termbase_mgr.set_termbase_priority(tb_id, project_id, priority)
                                        self.log(f"✓ Restored termbase priority: {tb['name']} → #{priority}")
                                else:
                                    self.log(f"⚠️ Could not find termbase with id: {tb_id}")
                        else:
                            self.log(f"📋 No active termbases saved for this project")
                    else:
                        self.log(f"📋 No termbase settings found in project file")
                    
                    # Refresh termbase UI to show restored activations
                    if hasattr(self, 'termbase_tab_refresh_callback'):
                        self.termbase_tab_refresh_callback()
                        self.log(f"✓ Refreshed termbase UI with restored activations")
            
            # The legacy "active non-translatables lists" project setting
            # was removed in v1.9.393 along with the standalone NT tab; NTs
            # now ride on termbase activations and don't need their own
            # per-project state. Any nt_settings field in older project
            # files is ignored on load.

            # Restore spellcheck settings from project
            if self.current_project.spellcheck_settings:
                spellcheck_settings = self.current_project.spellcheck_settings
                self.spellcheck_enabled = spellcheck_settings.get('enabled', False)
                TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)
                
                # Update UI elements
                if hasattr(self, 'spellcheck_btn'):
                    self.spellcheck_btn.setChecked(self.spellcheck_enabled)
                if hasattr(self, 'spellcheck_toggle_action'):
                    self.spellcheck_toggle_action.setChecked(self.spellcheck_enabled)
                self._update_spellcheck_button_style()
                
                # Always use the project's target language for spellcheck
                # (ignore any saved language - target language is the correct one)
                project_target = self.current_project.target_lang if self.current_project else self.target_language
                if project_target and self.spellcheck_manager.set_language(project_target):
                    if self.spellcheck_enabled:
                        self.log(f"✓ Spellcheck enabled for target language: {project_target}")
                    else:
                        self.log(f"✓ Spellcheck initialized for target language: {project_target} (disabled)")
                else:
                    self.log(f"⚠ Spellcheck dictionary not available for {project_target}")
            else:
                # No spellcheck settings in project - always initialize for target language
                # (even if spellcheck is disabled, set the language so it's ready when enabled)
                if self.current_project:
                    project_target = self.current_project.target_lang
                    if project_target and self.spellcheck_manager.set_language(project_target):
                        self.log(f"✓ Spellcheck initialized for target language: {project_target}")
            
            # Restore results pane font sizes from project
            if hasattr(self.current_project, 'ui_settings') and self.current_project.ui_settings:
                match_size = self.current_project.ui_settings.get('results_match_font_size')
                compare_size = self.current_project.ui_settings.get('results_compare_font_size')
                
                if match_size and 7 <= match_size <= 16:
                    from modules.translation_results_panel import CompactMatchItem
                    CompactMatchItem.set_font_size(match_size)
                    if hasattr(self, 'results_panels'):
                        for panel in self.results_panels:
                            if hasattr(panel, 'set_font_size'):
                                panel.set_font_size(match_size)
                
                if compare_size and 7 <= compare_size <= 14:
                    from modules.translation_results_panel import TranslationResultsPanel
                    TranslationResultsPanel.compare_box_font_size = compare_size
                    if hasattr(self, 'results_panels'):
                        for panel in self.results_panels:
                            if hasattr(panel, 'set_compare_box_font_size'):
                                panel.set_compare_box_font_size(compare_size)
            
            # Set Superlookup language dropdowns to match project languages
            if hasattr(self, 'lookup_tab') and self.lookup_tab and self.current_project:
                source_lang = self.current_project.source_lang
                target_lang = self.current_project.target_lang
                if source_lang or target_lang:
                    self.lookup_tab.set_project_languages(source_lang, target_lang)
                    self.log(f"✓ Set Superlookup languages: {source_lang} → {target_lang}")
            
            # Load project-specific settings (override general settings while project is active)
            if hasattr(self.current_project, 'general_settings_overrides') and self.current_project.general_settings_overrides:
                project_settings = self.current_project.general_settings_overrides
                
                # Override general settings with project-specific ones
                if 'auto_center_active_segment' in project_settings:
                    self.auto_center_active_segment = project_settings['auto_center_active_segment']
                    self.log(f"✓ Project override: auto-center = {self.auto_center_active_segment}")
                
                if 'auto_propagate_exact_matches' in project_settings:
                    self.auto_propagate_exact_matches = project_settings['auto_propagate_exact_matches']
                    self.log(f"✓ Project override: auto-propagate = {self.auto_propagate_exact_matches}")
                
                if 'auto_insert_100_percent_matches' in project_settings:
                    self.auto_insert_100_percent_matches = project_settings['auto_insert_100_percent_matches']
                    self.log(f"✓ Project override: auto-insert 100% = {self.auto_insert_100_percent_matches}")
                
                if 'auto_confirm_100_percent_matches' in project_settings:
                    self.auto_confirm_100_percent_matches = project_settings['auto_confirm_100_percent_matches']
                    self.log(f"✓ Project override: auto-confirm 100% = {self.auto_confirm_100_percent_matches}")
                
                if 'enable_termbase_grid_highlighting' in project_settings:
                    self.enable_termbase_grid_highlighting = project_settings['enable_termbase_grid_highlighting']
                    self.log(f"✓ Project override: termbase highlighting = {self.enable_termbase_grid_highlighting}")
                
                if 'precision_scroll_divisor' in project_settings:
                    self.precision_scroll_divisor = project_settings['precision_scroll_divisor']
                    self.log(f"✓ Project override: precision scroll = {self.precision_scroll_divisor}")
                
                if 'termbase_hide_shorter_matches' in project_settings:
                    self.termbase_hide_shorter_matches = project_settings['termbase_hide_shorter_matches']
                
                if 'enable_smart_word_selection' in project_settings:
                    self.enable_smart_word_selection = project_settings['enable_smart_word_selection']
                
                if 'enable_sound_effects' in project_settings:
                    self.enable_sound_effects = project_settings['enable_sound_effects']
                    self.log(f"✓ Project override: sound effects = {self.enable_sound_effects}")
                
                if 'sound_effects_map' in project_settings:
                    self.sound_effects_map = project_settings['sound_effects_map']
                    self.log(f"✓ Project override: sound effects map loaded")
            
            # Update scratchpad with project's private notes
            self._update_scratchpad_for_project()
            # Rebuild the all-comments list from the loaded project
            self._refresh_segment_comments_list()
            
            self.log(f"✓ Loaded project: {self.current_project.name} ({len(self.current_project.segments)} segments)")

            
            # Start background batch processing of termbase matches for all segments
            # This pre-fills the cache while user works on the project
            self._start_termbase_batch_worker()

            # v1.10.69: install the auto-refresh file watcher on the
            # active SQLite DB so cross-process termbase edits (most
            # commonly via the Supervertaler for Trados plugin sharing
            # the same DB) automatically trigger an index rebuild.
            # Snapshot-gated and debounced; see
            # _setup_termbase_db_watcher for the full rationale.
            try:
                self._setup_termbase_db_watcher()
            except Exception as _e:
                # Non-fatal — the manual 🔄 button on TermLens always
                # works regardless of whether the watcher succeeded.
                self.log(f"⚠️ Could not install termbase DB auto-refresh watcher: {_e}")

            # Start prefetch worker for first 50 segments (instant switching like memoQ)
            if len(self.current_project.segments) > 0:
                prefetch_ids = [seg.id for seg in self.current_project.segments[:50]]
                self._start_prefetch_worker(prefetch_ids)

        except UnicodeDecodeError as e:
            error_msg = (
                f"Failed to load project - file encoding error:\n\n"
                f"{str(e)}\n\n"
                f"The project file may be corrupted or saved with an incompatible encoding.\n"
                f"This can happen if the file was edited outside Supervertaler.\n\n"
                f"Try opening the file in a text editor and re-saving it as UTF-8."
            )
            QMessageBox.critical(self, "Encoding Error", error_msg)
            self.log(f"✗ Encoding error loading project: {e}")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to load project:\n{str(e)}")
            self.log(f"✗ Error loading project: {e}")
        finally:
            try:
                progress.close()
            except Exception:
                pass

    def _load_synonyms_bulk(self):
        """Load every synonym from ``termbase_synonyms`` in one query,
        return as ``{term_id: {'source': [text, …], 'target': [text, …]}}``.

        v1.10.73 (Tier 2). Called by ``_build_termbase_index`` once per
        rebuild so per-term synonym lookups during search are pure
        dict access — no N+1 query storm. Synonyms are filtered to
        non-forbidden entries (Trados does the same in
        ``BulkLoadTargetSynonyms``) and ordered by ``display_order``
        when present so the "preferred synonym first" convention
        users set in the Edit Termbase Entry dialog is preserved
        through to the TermLens display.

        Defensive about schema variation: the ``forbidden`` and
        ``display_order`` columns were added in later schema
        migrations, so we check for their presence via
        ``PRAGMA table_info`` (mirrors the pattern in
        ``modules/termbase_entry_editor.load_synonyms``). Older
        databases — or in-memory test fixtures that haven't run
        every migration — still work, just without the
        forbidden-filter or the display-order sort.

        Returns ``{}`` on any error so the caller can carry on with
        an empty synonym map instead of failing the whole index
        build over a synonym query hiccup. The TermLens display just
        won't show synonyms in that case — same as today.
        """
        result = {}
        try:
            if not hasattr(self, 'db_manager') or self.db_manager is None:
                return result
            cur = self.db_manager.cursor
            cur.execute("PRAGMA table_info(termbase_synonyms)")
            cols = [r[1] for r in cur.fetchall()]
            has_forbidden = 'forbidden' in cols
            has_display_order = 'display_order' in cols

            where_clause = "WHERE COALESCE(forbidden, 0) = 0" if has_forbidden else ""
            order_clause = ("ORDER BY term_id, COALESCE(display_order, 0), id"
                            if has_display_order
                            else "ORDER BY term_id, id")
            cur.execute(
                f"SELECT term_id, synonym_text, language "
                f"FROM termbase_synonyms {where_clause} {order_clause}"
            )
            for term_id, syn_text, language in cur.fetchall():
                if not term_id or not syn_text:
                    continue
                bucket = result.setdefault(term_id, {'source': [], 'target': []})
                if language == 'source':
                    bucket['source'].append(syn_text)
                elif language == 'target':
                    bucket['target'].append(syn_text)
                # Anything else (legacy 'both', NULL, etc.) is silently
                # ignored — schema only documents 'source' and 'target'.
        except Exception as e:
            try:
                self.log(f"⚠️ Bulk synonym load failed: {e}")
            except Exception:
                pass
        return result

    def _build_termbase_index(self):
        """
        Build in-memory index of ALL terms from activated termbases (v1.9.182).

        This is called ONCE on project load and replaces thousands of per-word
        database queries with a single bulk load + fast in-memory lookups.

        Performance: Reduces 349-segment termbase search from 365 seconds to <1 second.
        """
        import re
        import time
        start_time = time.time()

        if not self.current_project or not hasattr(self, 'db_manager') or not self.db_manager:
            return

        project_id = self.current_project.id if hasattr(self.current_project, 'id') else None

        # Project languages, normalised to ISO codes for direction comparison.
        # Empty string when not set – that disables the swap logic below and
        # the index is built in its raw (forward) orientation, matching the
        # historical behaviour for projects without language metadata.
        project_source_code = self._convert_language_to_code(self.current_project.source_lang or '') or ''
        project_target_code = self._convert_language_to_code(self.current_project.target_lang or '') or ''

        # Query ALL terms from activated termbases in ONE query.
        # Includes tb.source_lang / tb.target_lang so we can detect
        # reverse-direction termbases and orient them to the project,
        # and t.is_nontranslatable so NT highlighting can branch off
        # the same index without a second pass over the database.
        #
        # v1.10.73 (Tier 1): also pulls ``definition``, ``url``,
        # ``source_abbreviation`` and ``target_abbreviation``. These
        # columns have existed in the schema for ages but were never
        # SELECTed here, so the TermBlock tooltip's pre-existing
        # ``_meta_lines`` rendering helper got empty strings and
        # silently suppressed the rows. Adding them to the SELECT
        # makes the tooltip immediately show Abbr / Definition /
        # Domain / URL with zero extra UI code. ``COALESCE(…, '')``
        # so legacy databases without those columns (or with NULLs)
        # come back as empty strings rather than blowing up the
        # SELECT or feeding ``None`` downstream.
        query = """
            SELECT
                t.id, t.source_term, t.target_term, t.termbase_id,
                t.domain, t.notes, t.project, t.client, t.forbidden,
                tb.is_project_termbase, tb.name as termbase_name,
                tb.source_lang as tb_source_lang, tb.target_lang as tb_target_lang,
                CASE WHEN COALESCE(ta.priority, 0) = 1 OR tb.is_project_termbase = 1 THEN 1 ELSE 0 END as ranking,
                COALESCE(t.is_nontranslatable, 0) as is_nontranslatable,
                COALESCE(t.definition, '') as definition,
                COALESCE(t.url, '') as url,
                COALESCE(t.source_abbreviation, '') as source_abbreviation,
                COALESCE(t.target_abbreviation, '') as target_abbreviation
            FROM termbase_terms t
            LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
            LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id
                AND ta.project_id = ? AND ta.is_active = 1
            WHERE (ta.is_active = 1 OR tb.is_project_termbase = 1)
        """

        # v1.10.73 (Tier 2): bulk-load every term's synonyms in ONE
        # extra query, keyed by term_id, before iterating the main
        # result set. Same single-query pattern Trados uses in
        # ``BulkLoadTargetSynonyms`` — orders of magnitude faster than
        # one query per term, and the synonym row count (typically a
        # few hundred per termbase) stays manageable. Synonyms are
        # then attached to each index entry below, so
        # ``_search_termbase_in_memory`` can copy them into match
        # results without re-querying the DB.
        synonyms_by_term = self._load_synonyms_bulk()

        new_index = []
        try:
            self.db_manager.cursor.execute(query, [project_id or 0])
            rows = self.db_manager.cursor.fetchall()

            for row in rows:
                source_term = row[1]
                target_term = row[2]
                tb_source_code = self._convert_language_to_code(row[11] or '') or ''
                tb_target_code = self._convert_language_to_code(row[12] or '') or ''
                # v1.10.73 (Tier 1) extra metadata columns.
                row_definition = row[15] if len(row) > 15 else ''
                row_url = row[16] if len(row) > 16 else ''
                row_src_abbr = row[17] if len(row) > 17 else ''
                row_tgt_abbr = row[18] if len(row) > 18 else ''

                # If the termbase runs the opposite direction to the project
                # (e.g. termbase en→nl in an nl→en project), swap source/target
                # so the index lookup key is always in the project's source
                # language. Without this swap, _search_termbase_in_memory
                # would search Dutch segment text against English source_term
                # values and find nothing.
                #
                # v1.10.73 (Tier 1): also swap source/target
                # **abbreviations** in lockstep — they're per-language
                # too, so they need the same flip when the termbase
                # runs opposite the project direction. Otherwise the
                # tooltip would show the abbreviation pair in the
                # wrong order (English abbreviation labelled as
                # Dutch and vice versa).
                #
                # v1.10.73 (Tier 2): also swap source/target
                # **synonym lists**. Same reason — synonyms are
                # per-language, so when source/target swap their
                # synonym lists swap with them.
                synonym_pair = synonyms_by_term.get(row[0], {'source': [], 'target': []})
                source_synonyms = list(synonym_pair.get('source', []))
                target_synonyms = list(synonym_pair.get('target', []))
                if (project_source_code and project_target_code
                        and tb_source_code and tb_target_code
                        and tb_source_code == project_target_code
                        and tb_target_code == project_source_code):
                    source_term, target_term = target_term, source_term
                    row_src_abbr, row_tgt_abbr = row_tgt_abbr, row_src_abbr
                    source_synonyms, target_synonyms = target_synonyms, source_synonyms

                if not source_term:
                    continue

                source_term_lower = source_term.lower().strip()
                if len(source_term_lower) < 2:
                    continue

                # Pre-compile regex pattern for word-boundary matching
                # This avoids recompiling the same pattern thousands of times
                try:
                    # Handle terms with punctuation differently
                    if any(c in source_term_lower for c in '.%,/-'):
                        pattern = re.compile(r'(?<!\w)' + re.escape(source_term_lower) + r'(?!\w)')
                    else:
                        pattern = re.compile(r'\b' + re.escape(source_term_lower) + r'\b')
                except re.error:
                    # If regex fails, use simple substring matching
                    pattern = None

                # v1.10.75 (Tier 3c): build abbreviation patterns
                # for pipe-separated source_abbreviation variants
                # (e.g. "GC|G.C.|gc"). Each variant gets its own
                # regex with the same word-boundary semantics as the
                # main term pattern. Stored as
                # ``[(variant_lower, compiled_pattern), …]`` so the
                # search loop can iterate variants and report which
                # one matched. Empty source_abbreviation OR empty
                # target_abbreviation means "no abbreviation chip" —
                # we want both sides because the abbreviation chip
                # displays target_abbreviation as the primary text;
                # without one the chip would have nothing to show.
                abbreviation_variants = []
                if row_src_abbr and row_tgt_abbr:
                    for variant in row_src_abbr.split('|'):
                        variant = variant.strip()
                        if not variant:
                            continue
                        variant_lower = variant.lower()
                        try:
                            if any(c in variant_lower for c in '.%,/-'):
                                v_pattern = re.compile(
                                    r'(?<!\w)' + re.escape(variant_lower) + r'(?!\w)')
                            else:
                                v_pattern = re.compile(
                                    r'\b' + re.escape(variant_lower) + r'\b')
                            abbreviation_variants.append((variant, variant_lower, v_pattern))
                        except re.error:
                            pass

                new_index.append({
                    'term_id': row[0],
                    'source_term': source_term,
                    'source_term_lower': source_term_lower,
                    'target_term': target_term,
                    'termbase_id': row[3],
                    'domain': row[4],
                    'notes': row[5],
                    'project': row[6],
                    'client': row[7],
                    'forbidden': row[8],
                    'is_project_termbase': row[9],
                    'termbase_name': row[10],
                    'ranking': row[13],
                    'is_nontranslatable': bool(row[14]),
                    # v1.10.73 (Tier 1): metadata fields that were
                    # in the schema but never SELECTed before.
                    'definition': row_definition,
                    'url': row_url,
                    'source_abbreviation': row_src_abbr,
                    'target_abbreviation': row_tgt_abbr,
                    # v1.10.73 (Tier 2): synonyms bulk-loaded from
                    # termbase_synonyms, already direction-swapped
                    # above so lists match the project's source/
                    # target orientation.
                    'source_synonyms': source_synonyms,
                    'target_synonyms': target_synonyms,
                    # v1.10.75 (Tier 3c): pipe-split abbreviation
                    # variants with pre-compiled regexes for the
                    # abbreviation-as-primary chip rendering.
                    'abbreviation_variants': abbreviation_variants,
                    'pattern': pattern,  # Pre-compiled regex (main source_term)
                })

            # Sort by term length (longest first) for better phrase matching
            new_index.sort(key=lambda x: len(x['source_term_lower']), reverse=True)

            # Thread-safe update of the index
            with self.termbase_index_lock:
                self.termbase_index = new_index

            elapsed = time.time() - start_time
            self.log(f"✅ Built termbase index: {len(new_index)} terms in {elapsed:.2f}s")

            # v1.10.69: take a fresh snapshot of the termbase tables
            # so the file watcher's snapshot-gate (see
            # _on_termbase_db_debounce_fire) recognises subsequent
            # own-writes as "already accounted for" and skips
            # redundant rebuilds. Without this, every own-write would
            # cause a no-op rebuild ~2 s later when the file watcher
            # fired — harmless but wasteful.
            try:
                self._termbase_db_last_snapshot = self._snapshot_termbase_db_state()
            except Exception:
                pass

        except Exception as e:
            self.log(f"❌ Failed to build termbase index: {e}")
            import traceback
            self.log(traceback.format_exc())

    def _search_termbase_in_memory(self, source_text: str) -> dict:
        """
        Search termbase using in-memory index (v1.9.182).

        This replaces _search_termbases_thread_safe() for batch operations.
        Instead of N database queries (one per word), we do:
        - 1 pass through the index (typically ~1000 terms)
        - Fast string 'in' check + pre-compiled regex validation

        Performance: <1ms per segment vs 1+ second per segment.
        """
        if not source_text:
            return {}

        with self.termbase_index_lock:
            if not self.termbase_index:
                return {}
            index = self.termbase_index  # Local reference for thread safety

        source_lower = source_text.lower()
        matches = {}

        for term in index:
            term_lower = term['source_term_lower']

            # Quick substring check first (very fast, implemented in C)
            main_in_source = term_lower in source_lower

            # Word boundary validation using pre-compiled pattern.
            # When the main source_term doesn't substring-hit, we
            # still need to consider abbreviation matches below.
            main_matched = False
            if main_in_source:
                pattern = term.get('pattern')
                if pattern is None or pattern.search(source_lower):
                    main_matched = True

            # v1.10.75 (Tier 3c): try every abbreviation variant
            # in addition to the main source_term. When the
            # abbreviation form ("GC") appears in the source as a
            # separate token, register a SECOND entry in the matches
            # dict keyed by a synthetic ``abbr_<id>_<idx>`` key, so
            # the TermLens widget gets a chip for the abbreviation
            # form (purple bg, target_abbreviation as display text)
            # in addition to (or instead of) the full-term chip.
            abbr_hits = []  # [(variant_text, variant_lower)]
            for variant_text, variant_lower, abbr_pattern in term.get('abbreviation_variants', []):
                if variant_lower not in source_lower:
                    continue
                if abbr_pattern.search(source_lower):
                    abbr_hits.append((variant_text, variant_lower))

            if not (main_matched or abbr_hits):
                continue

            # Term matches! Add to results
            term_id = term['term_id']

            # v1.10.75 (Tier 3c): build a base match dict that both
            # the main entry and any abbreviation entries share —
            # they all carry the same metadata (definition / domain /
            # notes / URL / synonyms / flags / etc.) since those are
            # properties of the underlying termbase term, not of the
            # particular surface form that matched.
            base = {
                'term_id': term_id,
                'termbase_id': term['termbase_id'],
                'termbase_name': term['termbase_name'],
                'ranking': term['ranking'],
                'is_project_termbase': term['is_project_termbase'],
                'forbidden': term['forbidden'],
                'domain': term['domain'],
                'notes': term['notes'],
                'project': term['project'],
                'client': term['client'],
                # v1.10.73 (Tier 1): metadata fields surfaced for
                # the TermBlock tooltip's ``_meta_lines`` renderer.
                'definition': term.get('definition', ''),
                'url': term.get('url', ''),
                'source_abbreviation': term.get('source_abbreviation', ''),
                'target_abbreviation': term.get('target_abbreviation', ''),
                # v1.10.73 (Tier 2): synonyms.
                'source_synonyms': term.get('source_synonyms', []),
                'target_synonyms': term.get('target_synonyms', []),
                # v1.10.75 (Tier 3a): forbidden / NT flags drive chip bg.
                'is_nontranslatable': term.get('is_nontranslatable', False),
            }

            if main_matched:
                matches[term_id] = dict(base, **{
                    'source': term['source_term'],
                    'translation': term['target_term'],
                    'matched_via_abbreviation': False,
                })

            # v1.10.75 (Tier 3c): one entry per matched abbreviation
            # variant, keyed by a synthetic string so it doesn't
            # collide with the main ``matches[term_id]`` integer key.
            # ``source`` is the variant text ("GC"); ``translation``
            # is the target_abbreviation (also "GC" typically) so the
            # chip displays the abbreviation pair, not the full term.
            for i, (variant_text, variant_lower) in enumerate(abbr_hits):
                synthetic_key = f"abbr_{term_id}_{i}"
                matches[synthetic_key] = dict(base, **{
                    'source': variant_text,
                    'translation': term.get('target_abbreviation', '') or term['target_term'],
                    'matched_via_abbreviation': True,
                })

        return matches

    def _start_termbase_batch_worker(self):
        """
        Start background thread to batch-process termbase matches for all segments.
        This pre-fills the cache while the user works on the project.
        """
        if not self.current_project or len(self.current_project.segments) == 0:
            return

        # Build in-memory termbase index FIRST (v1.9.182)
        # This is the key optimization: load all terms once, then do fast in-memory lookups
        self._build_termbase_index()

        # 🧪 EXPERIMENTAL: Skip batch worker if cache kill switch is enabled
        if getattr(self, 'disable_all_caches', False):
            self.log("🧪 Termbase batch worker SKIPPED (caches disabled)")
            return

        # Stop any existing worker thread
        self.termbase_batch_stop_event.set()
        if self.termbase_batch_worker_thread and self.termbase_batch_worker_thread.is_alive():
            self.log("⏹️  Stopping existing termbase batch worker...")
            self.termbase_batch_worker_thread.join(timeout=2)

        # Reset stop event for new worker
        self.termbase_batch_stop_event.clear()

        # Start new background worker thread
        segment_count = len(self.current_project.segments)
        self.log(f"🔄 Starting background termbase batch processor for {segment_count} segments...")
        
        self.termbase_batch_worker_thread = threading.Thread(
            target=self._termbase_batch_worker_run,
            args=(self.current_project.segments,),
            daemon=True  # Daemon thread - won't prevent program exit
        )
        self.termbase_batch_worker_thread.start()
    
    def _termbase_batch_worker_run(self, segments):
        """
        Background worker thread: process all segments and populate termbase cache.
        Runs in separate thread to not block UI.

        v1.9.182: Now uses in-memory termbase index for 1000x faster lookups.
        Old approach: 365 seconds for 349 segments (1 second/segment)
        New approach: <1 second for 349 segments (<3ms/segment)
        """
        if not segments:
            return

        try:
            processed = 0
            cached = 0
            with_matches = 0
            start_time = time.time()

            for segment in segments:
                # Check if stop event was signaled (user closed project or started new one)
                if self.termbase_batch_stop_event.is_set():
                    self.log(f"⏹️  Termbase batch worker stopped by user (processed {processed} segments)")
                    break

                segment_id = segment.id

                # Skip if already in cache (thread-safe check)
                with self.termbase_cache_lock:
                    if segment_id in self.termbase_cache:
                        cached += 1
                        continue

                # v1.9.182: Use in-memory index for instant lookup (no database queries!)
                try:
                    matches = self._search_termbase_in_memory(segment.source)

                    # Store in cache (thread-safe) - even empty results to avoid re-lookup
                    with self.termbase_cache_lock:
                        self.termbase_cache[segment_id] = matches

                    processed += 1
                    if matches:
                        with_matches += 1

                except Exception as e:
                    self.log(f"❌ Error processing segment {segment_id} in batch worker: {e}")
                    continue

            elapsed = time.time() - start_time
            total_cached = len(self.termbase_cache)
            rate = processed / elapsed if elapsed > 0 else 0
            self.log(f"✅ Termbase batch worker complete: {processed} segments in {elapsed:.2f}s " +
                     f"({rate:.0f} seg/sec, {with_matches} with matches)")

        except Exception as e:
            self.log(f"❌ Termbase batch worker error: {e}")
            import traceback
            self.log(traceback.format_exc())
    
    def _search_termbases_thread_safe(self, source_text: str, cursor, source_lang: str = None, target_lang: str = None, project_id: int = None) -> Dict[str, str]:
        """
        Search termbases using a provided cursor (thread-safe for background threads).
        This method allows background workers to query the database without SQLite threading errors.

        Implements BIDIRECTIONAL matching: searches both source_term and target_term columns.
        When a match is found on target_term, source and target are swapped in the result.
        This matches memoQ/Trados behavior where a NL→EN termbase also works for EN→NL projects.

        Args:
            source_text: The source text to search for
            cursor: A database cursor from a thread-local connection
            source_lang: Source language code
            target_lang: Target language code
            project_id: Current project ID (required to filter by activated termbases)

        Returns:
            Dictionary of {term: translation} matches
        """
        if not source_text or not cursor:
            return {}

        try:
            # Convert language names to codes (match interactive search logic)
            source_lang_code = self._convert_language_to_code(source_lang) if source_lang else None
            target_lang_code = self._convert_language_to_code(target_lang) if target_lang else None

            words = source_text.split()
            source_text_lower = source_text.lower()
            matches: Dict[str, Dict] = {}

            import re

            for word in words:
                clean_word = word.strip('.,!?;:')
                if len(clean_word) < 2:
                    continue

                try:
                    # JOIN termbases AND termbase_activation to filter by activated termbases
                    # This matches the logic in database_manager.py search_termbases()
                    # BIDIRECTIONAL: Search both source_term (forward) and target_term (reverse)
                    # Using UNION to combine both directions
                    query = """
                        SELECT * FROM (
                            -- Forward match: search source_term
                            SELECT
                                t.id, t.source_term, t.target_term, t.termbase_id,
                                t.domain, t.notes, t.project, t.client, t.forbidden,
                                tb.is_project_termbase, tb.name as termbase_name,
                                CASE WHEN COALESCE(ta.priority, 0) = 1 OR tb.is_project_termbase = 1 THEN 1 ELSE 0 END as ranking,
                                'source' as match_direction
                            FROM termbase_terms t
                            LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
                            LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
                            WHERE LOWER(t.source_term) LIKE ?
                            AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
                    """
                    params = [project_id if project_id else 0, f"%{clean_word.lower()}%"]

                    # Language filters for forward query
                    if source_lang_code:
                        query += " AND (t.source_lang = ? OR (t.source_lang IS NULL AND tb.source_lang = ?) OR (t.source_lang IS NULL AND tb.source_lang IS NULL))"
                        params.extend([source_lang_code, source_lang_code])
                    if target_lang_code:
                        query += " AND (t.target_lang = ? OR (t.target_lang IS NULL AND tb.target_lang = ?) OR (t.target_lang IS NULL AND tb.target_lang IS NULL))"
                        params.extend([target_lang_code, target_lang_code])

                    # Reverse match: search target_term, swap source/target in output
                    query += """
                            UNION ALL
                            -- Reverse match: search target_term, swap columns
                            SELECT
                                t.id, t.target_term as source_term, t.source_term as target_term,
                                t.termbase_id,
                                t.domain, t.notes, t.project, t.client, t.forbidden,
                                tb.is_project_termbase, tb.name as termbase_name,
                                CASE WHEN COALESCE(ta.priority, 0) = 1 OR tb.is_project_termbase = 1 THEN 1 ELSE 0 END as ranking,
                                'target' as match_direction
                            FROM termbase_terms t
                            LEFT JOIN termbases tb ON CAST(t.termbase_id AS INTEGER) = tb.id
                            LEFT JOIN termbase_activation ta ON ta.termbase_id = tb.id AND ta.project_id = ? AND ta.is_active = 1
                            WHERE LOWER(t.target_term) LIKE ?
                            AND (ta.is_active = 1 OR tb.is_project_termbase = 1)
                    """
                    params.extend([project_id if project_id else 0, f"%{clean_word.lower()}%"])

                    # Language filters for reverse query (swapped)
                    if source_lang_code:
                        # For reverse: source_lang filters target_lang column
                        query += " AND (t.target_lang = ? OR (t.target_lang IS NULL AND tb.target_lang = ?) OR (t.target_lang IS NULL AND tb.target_lang IS NULL))"
                        params.extend([source_lang_code, source_lang_code])
                    if target_lang_code:
                        # For reverse: target_lang filters source_lang column
                        query += " AND (t.source_lang = ? OR (t.source_lang IS NULL AND tb.source_lang = ?) OR (t.source_lang IS NULL AND tb.source_lang IS NULL))"
                        params.extend([target_lang_code, target_lang_code])

                    # Close UNION and limit
                    query += ") combined LIMIT 30"
                    cursor.execute(query, params)
                    results = cursor.fetchall()

                    for row in results:
                        # Uniform access (columns are already swapped for reverse matches)
                        source_term = row[1] if isinstance(row, tuple) else row['source_term']
                        target_term = row[2] if isinstance(row, tuple) else row['target_term']
                        match_direction = row[12] if isinstance(row, tuple) else row.get('match_direction', 'source')
                        if not source_term or not target_term:
                            continue

                        # Phrase-level validation with word boundaries (case-insensitive)
                        pattern = re.compile(r"\b" + re.escape(source_term.lower()) + r"\b")
                        if not pattern.search(source_text_lower):
                            continue

                        term_id = row[0] if isinstance(row, tuple) else row['id']
                        termbase_id = row[3] if isinstance(row, tuple) else row['termbase_id']
                        domain = row[4] if isinstance(row, tuple) else row['domain']
                        notes = row[5] if isinstance(row, tuple) else row['notes']
                        project = row[6] if isinstance(row, tuple) else row['project']
                        client = row[7] if isinstance(row, tuple) else row['client']
                        forbidden = row[8] if isinstance(row, tuple) else row['forbidden']
                        is_project_tb = row[9] if isinstance(row, tuple) else row['is_project_termbase']
                        termbase_name = row[10] if isinstance(row, tuple) else row['termbase_name']
                        ranking = row[11] if isinstance(row, tuple) else row.get('ranking', None)

                        existing = matches.get(source_term.strip())
                        # Deduplicate: keep numerically lowest ranking (highest priority)
                        # For project termbases, ranking is None so they always win
                        # Also prefer 'source' matches over 'target' matches when equal
                        if existing:
                            existing_ranking = existing.get('ranking', None)
                            existing_direction = existing.get('match_direction', 'source')
                            if is_project_tb:
                                # Project termbase always wins
                                pass
                            elif existing.get('is_project_termbase'):
                                # Existing is project termbase, skip this one
                                continue
                            elif existing_ranking is not None and ranking is not None:
                                # Both have rankings, keep lower (higher priority)
                                if existing_ranking < ranking:
                                    continue
                                elif existing_ranking == ranking:
                                    # Same ranking: prefer source match over target match
                                    if existing_direction == 'source' and match_direction == 'target':
                                        continue

                        matches[source_term.strip()] = {
                            'translation': target_term.strip(),
                            'term_id': term_id,
                            'termbase_id': termbase_id,
                            'ranking': ranking,
                            'domain': domain or '',
                            'notes': notes or '',
                            'project': project or '',
                            'client': client or '',
                            'forbidden': forbidden or False,
                            'is_project_termbase': bool(is_project_tb),
                            'termbase_name': termbase_name or '',
                            'target_synonyms': [],  # Will be populated below
                            'match_direction': match_direction  # Track if this was a reverse match
                        }

                        # Fetch synonyms for this term
                        # For reverse matches, fetch 'source' synonyms since they become targets
                        try:
                            synonym_lang = 'source' if match_direction == 'target' else 'target'
                            cursor.execute("""
                                SELECT synonym_text FROM termbase_synonyms
                                WHERE term_id = ? AND language = ? AND forbidden = 0
                            """, (term_id, synonym_lang))
                            synonym_rows = cursor.fetchall()
                            for syn_row in synonym_rows:
                                synonym = syn_row[0] if isinstance(syn_row, tuple) else syn_row['synonym_text']
                                if synonym:
                                    matches[source_term.strip()]['target_synonyms'].append(synonym)
                        except Exception:
                            pass  # Synonyms are optional, don't fail on error
                except Exception:
                    # Skip word on any error; continue with next word
                    continue

            return matches
        except Exception:
            return {}
    
    def _trigger_idle_prefetch(self, current_row: int):
        """
        Trigger prefetch for the next few segments while user is idle.
        Called after user stops typing (debounced) - uses their thinking time productively.
        
        This makes Ctrl+Enter feel INSTANT because matches are already cached.
        Also triggers PROACTIVE HIGHLIGHTING for upcoming segments with glossary matches.
        """
        import json

        if not self.current_project or current_row < 0:
            return
        
        try:
            # Prefetch next 5 segments (enough for fast workflow, not too many to waste resources)
            next_segment_ids = []
            already_cached_ids = []
            start_idx = current_row + 1
            end_idx = min(start_idx + 5, len(self.current_project.segments))

            for seg in self.current_project.segments[start_idx:end_idx]:
                # Check if already cached
                with self.translation_matches_cache_lock:
                    if seg.id not in self.translation_matches_cache:
                        next_segment_ids.append(seg.id)
                    else:
                        already_cached_ids.append(seg.id)

            # For already-cached segments, trigger proactive highlighting immediately
            # This handles the case where segments were cached earlier but not highlighted
            for seg_id in already_cached_ids:
                try:
                    with self.termbase_cache_lock:
                        termbase_raw = self.termbase_cache.get(seg_id, {})
                    if termbase_raw:
                        termbase_json = json.dumps(termbase_raw)
                        # Apply highlighting on main thread (we're already on main thread here)
                        self._apply_proactive_highlighting(seg_id, termbase_json)
                except Exception:
                    pass  # Silent failure for proactive highlighting
            
            if next_segment_ids:
                # Start prefetch in background (silent, no logging)
                self._start_prefetch_worker(next_segment_ids)
        except Exception:
            pass  # Silent failure - prefetch is optimization, not critical
    
    def _start_prefetch_worker(self, segment_ids):
        """
        Start background thread to prefetch TM/MT/LLM matches for given segments.
        This enables instant switching between segments without waiting for match lookups.
        """
        if not segment_ids:
            return
        
        # 🧪 EXPERIMENTAL: Skip prefetch if cache kill switch is enabled
        if getattr(self, 'disable_all_caches', False):
            return
        
        # Stop any existing worker thread
        self.prefetch_stop_event.set()
        if self.prefetch_worker_thread and self.prefetch_worker_thread.is_alive():
            self.prefetch_worker_thread.join(timeout=2)
        
        # Reset stop event for new worker
        self.prefetch_stop_event.clear()
        
        # Start new background worker thread (silent - no logging)
        self.prefetch_worker_thread = threading.Thread(
            target=self._prefetch_worker_run,
            args=(segment_ids,),
            daemon=True  # Daemon thread - won't prevent program exit
        )
        self.prefetch_worker_thread.start()
    
    def _prefetch_worker_run(self, segment_ids):
        """
        Background worker: prefetch TM/MT/LLM matches for given segments.
        Runs in separate thread to avoid blocking UI.
        
        Creates its own database connection for thread-safe termbase lookups.
        Also emits signal to apply proactive highlighting on the main thread.
        """
        import sqlite3
        import json
        
        # Create thread-local database connection for termbase searches
        thread_db_cursor = None
        thread_db_connection = None
        try:
            if hasattr(self, 'db_manager') and self.db_manager and hasattr(self.db_manager, 'db_path'):
                thread_db_connection = sqlite3.connect(self.db_manager.db_path, timeout=15)
                thread_db_connection.row_factory = sqlite3.Row
                thread_db_connection.execute("PRAGMA journal_mode=WAL")
                thread_db_cursor = thread_db_connection.cursor()
        except Exception:
            pass  # Continue without thread-local connection - will use cache only
        
        try:
            for idx, segment_id in enumerate(segment_ids):
                # Check stop signal
                if self.prefetch_stop_event.is_set():
                    return
                
                # Check if already cached
                with self.translation_matches_cache_lock:
                    if segment_id in self.translation_matches_cache:
                        continue  # Already cached, skip
                
                # Find segment
                segment = None
                if self.current_project:
                    for seg in self.current_project.segments:
                        if seg.id == segment_id:
                            segment = seg
                            break
                
                if not segment:
                    continue
                
                # Fetch TM/termbase matches (pass cursor for thread-safe termbase lookups)
                matches = self._fetch_all_matches_for_segment(segment, thread_db_cursor)

                # Count matches for logging and proactive highlighting
                tm_count = len(matches.get("TM", []))
                tb_count = len(matches.get("Termbases", []))
                mt_count = len(matches.get("MT", []))
                llm_count = len(matches.get("LLM", []))
                total_matches = tm_count + tb_count + mt_count + llm_count
                
                # Only cache results if we found something
                # Don't cache empty results - let main thread do fresh lookup
                if total_matches > 0:
                    with self.translation_matches_cache_lock:
                        self.translation_matches_cache[segment_id] = matches

                # PROACTIVE HIGHLIGHTING: Emit signal to apply highlighting on main thread
                # This makes upcoming segments show their glossary matches immediately
                if tb_count > 0:
                    try:
                        # Extract raw termbase matches from cache for highlighting
                        with self.termbase_cache_lock:
                            termbase_raw = self.termbase_cache.get(segment_id, {})

                        if termbase_raw:
                            # Convert to JSON for thread-safe signal transfer
                            termbase_json = json.dumps(termbase_raw)
                            # Emit signal - will be handled on main thread
                            self._proactive_highlight_signal.emit(segment_id, termbase_json)
                    except Exception:
                        pass  # Silent fail for proactive highlighting
            
        except Exception as e:
            self.log(f"Error in prefetch worker: {e}")
        
        finally:
            # Close thread-local database connection
            if thread_db_cursor:
                try:
                    thread_db_cursor.close()
                except:
                    pass
            if thread_db_connection:
                try:
                    thread_db_connection.close()
                except:
                    pass
    
    def _fetch_all_matches_for_segment(self, segment, thread_db_cursor=None):
        """
        Fetch TM, MT, and LLM matches for a single segment.
        Used by prefetch worker. Returns matches_dict with all match types.
        
        Args:
            segment: The segment to fetch matches for
            thread_db_cursor: Optional thread-local database cursor for termbase searches.
                              If provided and segment not in termbase_cache, will do direct lookup.
        """
        from modules.translation_results_panel import TranslationMatch
        
        matches_dict = {
            "LLM": [],
            "NT": [],
            "MT": [],
            "TM": [],
            "Termbases": [],
            "NonTrans": []  # Non-translatables
        }
        
        # Get project languages
        source_lang = getattr(self.current_project, 'source_lang', None) if self.current_project else None
        target_lang = getattr(self.current_project, 'target_lang', None) if self.current_project else None
        
        if not source_lang or not target_lang:
            return matches_dict
        
        # Convert language names to codes
        source_lang_code = self._convert_language_to_code(source_lang)
        target_lang_code = self._convert_language_to_code(target_lang)
        
        # 1. TM matches - SKIP in prefetch worker (TM search not thread-safe)
        # TM will be fetched on-demand when user navigates to segment
        pass
        
        # 2. MT matches (if enabled)
        if self.enable_mt_matching:
            # Use the same MT fetching logic as the main search
            # For now, we'll skip MT in prefetch to avoid rate limiting
            # MT will still be fetched on-demand when user clicks
            pass
        
        # 3. LLM matches (if enabled)
        if self.enable_llm_matching:
            # Skip LLM in prefetch - too expensive and slow
            # LLM will still be fetched on-demand when user clicks
            pass
        
        # 4. Termbase matches - try cache first, then direct lookup if cursor provided
        termbase_matches_raw = None
        
        with self.termbase_cache_lock:
            if segment.id in self.termbase_cache:
                termbase_matches_raw = self.termbase_cache[segment.id]
        
        # If not in cache and we have a thread-local cursor, do direct lookup
        if termbase_matches_raw is None and thread_db_cursor is not None:
            try:
                # Get project_id for activation filtering
                current_project_id = None
                if hasattr(self, 'current_project') and self.current_project and hasattr(self.current_project, 'id'):
                    current_project_id = self.current_project.id
                
                termbase_matches_raw = self._search_termbases_thread_safe(
                    segment.source,
                    thread_db_cursor,
                    source_lang=source_lang,
                    target_lang=target_lang,
                    project_id=current_project_id
                )
                # Also populate the termbase cache for future use
                if termbase_matches_raw:
                    with self.termbase_cache_lock:
                        self.termbase_cache[segment.id] = termbase_matches_raw
            except Exception:
                pass  # Silently continue
        
        # Convert raw termbase matches to TranslationMatch objects
        if termbase_matches_raw:
            for term_id, match_info in termbase_matches_raw.items():
                # Extract source term, translation, ranking, and other metadata from match_info
                if isinstance(match_info, dict):
                    source_term = match_info.get('source', '')
                    target_term = match_info.get('translation', '')
                    ranking = match_info.get('ranking', None)
                    forbidden = match_info.get('forbidden', False)
                    is_project_termbase = match_info.get('is_project_termbase', False)
                    termbase_name = match_info.get('termbase_name', 'Default')
                else:
                    # Backward compatibility: if just string (shouldn't happen with new code)
                    source_term = str(term_id)
                    target_term = match_info
                    ranking = None
                    forbidden = False
                    is_project_termbase = False
                    termbase_name = 'Default'

                match_obj = TranslationMatch(
                    source=source_term,
                    target=target_term,
                    relevance=95,
                    metadata={
                        'termbase_name': termbase_name,
                        'ranking': ranking,
                        'forbidden': forbidden,
                        'is_project_termbase': is_project_termbase,
                        'term_id': match_info.get('term_id') if isinstance(match_info, dict) else None,
                        'termbase_id': match_info.get('termbase_id') if isinstance(match_info, dict) else None,
                        'domain': match_info.get('domain', '') if isinstance(match_info, dict) else '',
                        'notes': match_info.get('notes', '') if isinstance(match_info, dict) else '',
                        'project': match_info.get('project', '') if isinstance(match_info, dict) else '',
                        'client': match_info.get('client', '') if isinstance(match_info, dict) else '',
                        'target_synonyms': match_info.get('target_synonyms', []) if isinstance(match_info, dict) else []
                    },
                    match_type='Termbase',
                    compare_source=source_term,
                    provider_code='TB'
                )
                matches_dict["Termbases"].append(match_obj)
        
        return matches_dict
    
    def stop_prefetch_worker(self):
        """Stop the background prefetch worker gracefully"""
        if self.prefetch_worker_thread and self.prefetch_worker_thread.is_alive():
            self.log("⏹️  Stopping prefetch worker...")
            self.prefetch_stop_event.set()
            self.prefetch_worker_thread.join(timeout=2)
            self.log("✓ Prefetch worker stopped")
    
    def stop_termbase_batch_worker(self):
        """Stop the background termbase batch worker gracefully"""
        if self.termbase_batch_worker_thread and self.termbase_batch_worker_thread.is_alive():
            self.log("⏹️  Stopping termbase batch worker...")
            self.termbase_batch_stop_event.set()
            self.termbase_batch_worker_thread.join(timeout=2)
            self.log("✓ Termbase batch worker stopped")
    
    def save_segment_to_activated_tms(self, source: str, target: str):
        """
        Save segment to all writable TMs for current project.

        Note: Uses get_writable_tm_ids() which checks the Write checkbox (read_only=0),
        NOT get_active_tm_ids() which checks the Read checkbox (is_active=1).

        Respects tm_save_mode setting:
        - 'latest': Overwrites existing entries with same source (keeps only newest translation)
        - 'all': Keeps all translations with different targets (default SQLite behavior)

        If hide_outer_wrapping_tags is enabled, strips structural tags (like <li-o>, <p>)
        before saving to TM for better matching leverage.

        Args:
            source: Source text
            target: Target text
        """
        if not self.current_project:
            return

        # Strip invisible character markers (↵, ·, →, °) to prevent marker leakage into TM
        if hasattr(self, 'reverse_invisible_replacements'):
            source = self.reverse_invisible_replacements(source)
            target = self.reverse_invisible_replacements(target)

        # Strip outer structural tags if setting is enabled (for cleaner TM matching)
        if self.hide_outer_wrapping_tags:
            source, _ = strip_outer_wrapping_tags(source)
            target, _ = strip_outer_wrapping_tags(target)

        if not hasattr(self.current_project, 'source_lang') or not hasattr(self.current_project, 'target_lang'):
            return

        # Get WRITABLE TM IDs for this project (Write checkbox enabled)
        tm_ids = []

        if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
            if hasattr(self, 'current_project') and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None

                if project_id:
                    # Use get_writable_tm_ids() to find TMs with Write enabled
                    tm_ids = self.tm_metadata_mgr.get_writable_tm_ids(project_id)
                else:
                    self.log(f"⚠️ Cannot save to TM: project has no 'id' attribute!")
            else:
                self.log(f"⚠️ Cannot save to TM: No current project loaded!")
        else:
            self.log(f"⚠️ Cannot save to TM: TM metadata manager not available!")

        # If no TMs have Write enabled, skip saving
        if not tm_ids:
            self.log("⚠️ No TMs with Write enabled - segment not saved to TM.")
            self.log(f"   - To fix: Go to Resources > Translation Memories > TM List and enable the Write checkbox")
            return

        # Check TM save mode: 'latest' = overwrite, 'all' = keep all variants
        overwrite_mode = getattr(self, 'tm_save_mode', 'latest') == 'latest'

        # Save to each writable TM
        saved_count = 0
        for tm_id in tm_ids:
            try:
                self.db_manager.add_translation_unit(
                    source=source,
                    target=target,
                    source_lang=self.current_project.source_lang,
                    target_lang=self.current_project.target_lang,
                    tm_id=tm_id,
                    overwrite=overwrite_mode
                )
                saved_count += 1
            except Exception as e:
                self.log(f"⚠️ Could not save to TM '{tm_id}': {e}")

        if saved_count > 0:
            mode_note = " (overwrite)" if overwrite_mode else ""
            msg = f"💾 Saved segment to {saved_count} TM(s){mode_note}"
            self._queue_tm_save_log(msg)
            # NOTE: Removed cache invalidation here - it was destroying batch worker's cache
            # on every Ctrl+Enter, making navigation extremely slow. The small chance of
            # seeing stale TM matches is far less important than responsive navigation.
    
    def invalidate_translation_cache(self, smart_invalidation=True):
        """
        Invalidate prefetch cache when TM/Termbase is updated.
        
        Args:
            smart_invalidation: If True, only clear future segments (keeps already-seen segments cached).
                               If False, clear entire cache.
        
        Smart mode: When translator saves segment N, only clears cache for segments > N.
        This way, going backwards still uses cache, but going forward gets fresh TM matches.
        """
        with self.translation_matches_cache_lock:
            if not smart_invalidation:
                # Full invalidation (e.g., termbase import, bulk TM import)
                cache_size = len(self.translation_matches_cache)
                if cache_size > 0:
                    self.translation_matches_cache.clear()
                    self.log(f"🔄 Full cache invalidated ({cache_size} segments cleared)")
            else:
                # Smart invalidation: Only clear segments ahead of current position
                if hasattr(self, 'table') and self.table:
                    current_row = self.table.currentRow()
                    if current_row >= 0 and self.current_project:
                        # Clear cache for all segments AFTER current position
                        segments_to_clear = []
                        for seg in self.current_project.segments[current_row + 1:]:
                            if seg.id in self.translation_matches_cache:
                                segments_to_clear.append(seg.id)
                        
                        for seg_id in segments_to_clear:
                            del self.translation_matches_cache[seg_id]
                        
                        if segments_to_clear:
                            self.log(f"🔄 Smart cache: cleared {len(segments_to_clear)} future segments (keeping past segments cached)")
                else:
                    # Fallback: full clear if we can't determine position
                    cache_size = len(self.translation_matches_cache)
                    if cache_size > 0:
                        self.translation_matches_cache.clear()
                        self.log(f"🔄 Cache invalidated ({cache_size} segments)")
    
    def save_project(self):
        """Save current project"""
        if not self.current_project:
            return
        
        if not self.project_file_path:
            self.save_project_as()
        else:
            self.save_project_to_file(self.project_file_path)
    
    def save_project_as(self):
        """Save project with new filename"""
        if not self.current_project:
            return

        file_path, _ = fdh.get_save_file_name(
            self,
            "Save Project As",
            "Supervertaler Projects (*.svproj);;All Files (*.*)"
        )

        if file_path:
            # Ensure .svproj extension
            if not file_path.lower().endswith('.svproj'):
                file_path += '.svproj'
            # Update project name to match the new filename
            new_name = Path(file_path).stem
            self.current_project.name = new_name

            self.save_project_to_file(file_path)
            self.project_file_path = file_path
            self.add_to_recent_projects(file_path)
    
    def save_project_to_file(self, file_path: str):
        """Save project to specified file"""
        try:
            self.current_project.modified = datetime.now().isoformat()
            
            # DEBUG: Log first 5 segments before saving
            self.log(f"💾 DEBUG: Saving {len(self.current_project.segments)} segments")
            for i, seg in enumerate(self.current_project.segments[:7]):
                self.log(f"💾 Seg {seg.id}: target='{seg.target[:50] if seg.target else 'EMPTY'}...', status={seg.status}")
            
            # Save prompt settings if prompt manager is available
            if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                # Unified prompt library - save active primary and attached prompts
                library = self.prompt_manager_qt.library
                self.current_project.prompt_settings = {
                    'active_primary_prompt_path': library.active_primary_prompt_path,
                    'attached_prompt_paths': library.attached_prompt_paths.copy() if library.attached_prompt_paths else [],
                    'mode': getattr(self.prompt_manager_qt, 'current_mode', 'single'),
                }
            
            # Save image/figure context folder path
            if hasattr(self, 'figure_context') and self.figure_context and self.figure_context.figure_context_folder:
                if not self.current_project.prompt_settings:
                    self.current_project.prompt_settings = {}
                self.current_project.prompt_settings['image_context_folder'] = self.figure_context.figure_context_folder
            
            # Save Image Extractor file list (DOCX files added for image extraction)
            if hasattr(self, 'image_extractor_file_list') and self.image_extractor_file_list:
                if not self.current_project.prompt_settings:
                    self.current_project.prompt_settings = {}
                extractor_files = [self.image_extractor_file_list.item(i).text() 
                                   for i in range(self.image_extractor_file_list.count())]
                if extractor_files:
                    self.current_project.prompt_settings['image_extractor_files'] = extractor_files
            
            # Save activated TM IDs and read_only status for this project
            if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and hasattr(self.current_project, 'id'):
                project_id = self.current_project.id
                if project_id:
                    activated_tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
                    if not hasattr(self.current_project, 'tm_settings'):
                        self.current_project.tm_settings = {}
                    self.current_project.tm_settings['activated_tm_ids'] = activated_tm_ids or []
                    
                    # Save read_only status for each activated TM
                    tm_read_only_status = {}
                    all_tms = self.tm_metadata_mgr.get_all_tms()
                    for tm in all_tms:
                        if tm['tm_id'] in activated_tm_ids:
                            tm_read_only_status[tm['tm_id']] = tm.get('read_only', True)
                    self.current_project.tm_settings['tm_read_only_status'] = tm_read_only_status
            
            # Save results pane font sizes
            if hasattr(self, 'results_panels') and self.results_panels:
                from modules.translation_results_panel import CompactMatchItem, TranslationResultsPanel
                if not self.current_project.ui_settings:
                    self.current_project.ui_settings = {}
                if hasattr(CompactMatchItem, 'font_size_pt'):
                    self.current_project.ui_settings['results_match_font_size'] = CompactMatchItem.font_size_pt
                if hasattr(TranslationResultsPanel, 'compare_box_font_size'):
                    self.current_project.ui_settings['results_compare_font_size'] = TranslationResultsPanel.compare_box_font_size
            
            # Save activated termbase IDs and priorities for this project
            if hasattr(self, 'termbase_mgr') and self.termbase_mgr and hasattr(self.current_project, 'id'):
                project_id = self.current_project.id
                if project_id:
                    active_termbase_ids = self.termbase_mgr.get_active_termbase_ids(project_id)
                    if not hasattr(self.current_project, 'termbase_settings'):
                        self.current_project.termbase_settings = {}
                    self.current_project.termbase_settings['active_termbase_ids'] = active_termbase_ids or []
                    
                    # Also save priorities for each active termbase
                    termbase_priorities = {}
                    for tb_id in active_termbase_ids:
                        priority = self.termbase_mgr.get_termbase_priority(tb_id, project_id)
                        if priority is not None:
                            termbase_priorities[str(tb_id)] = priority
                    self.current_project.termbase_settings['termbase_priorities'] = termbase_priorities
            
            # Save spellcheck settings to project (spellcheck_settings is now properly initialized in dataclass)
            self.current_project.spellcheck_settings['enabled'] = self.spellcheck_enabled
            if hasattr(self, 'target_language'):
                self.current_project.spellcheck_settings['language'] = self.target_language
            
            # Save current general settings as project overrides (while project is active)
            # These will override general_settings.json when this project is loaded
            self.current_project.general_settings_overrides = {
                'auto_center_active_segment': self.auto_center_active_segment,
                'auto_propagate_exact_matches': self.auto_propagate_exact_matches,
                'auto_insert_100_percent_matches': self.auto_insert_100_percent_matches,
                'auto_confirm_100_percent_matches': self.auto_confirm_100_percent_matches,
                'enable_termbase_grid_highlighting': self.enable_termbase_grid_highlighting,
                'precision_scroll_divisor': self.precision_scroll_divisor,
                'termbase_hide_shorter_matches': self.termbase_hide_shorter_matches,
                'enable_smart_word_selection': self.enable_smart_word_selection,
                'enable_sound_effects': self.enable_sound_effects,
                'sound_effects_map': getattr(self, 'sound_effects_map', {}),
            }
            
            # Save original DOCX path for structure-preserving export
            original_path = getattr(self, 'original_docx', None) or getattr(self, 'current_document_path', None)
            if original_path and os.path.exists(original_path):
                self.current_project.original_docx_path = original_path

            # IMPORTANT: Always save segments in original document order, not sorted order
            # Store current sort state and temporarily restore original order
            current_sort_state = getattr(self, 'current_sort', None)
            current_segments = self.current_project.segments.copy()  # Save current (possibly sorted) order

            # Restore original order for saving
            if hasattr(self, '_original_segment_order') and self._original_segment_order:
                self.current_project.segments = self._original_segment_order.copy()

            # Sanitise: strip any Show-Invisibles display markers that should
            # never appear in the saved file.  This is a belt-and-braces guard;
            # the markers should already be absent from segment.target, but if
            # any slipped through (e.g. from a race between textChanged and the
            # grid refresh), we catch them here at save time.
            for seg in self.current_project.segments:
                if seg.target:
                    seg.target = strip_invisible_markers(seg.target)

            with open(file_path, 'w', encoding='utf-8') as f:
                json.dump(self.current_project.to_dict(), f, indent=2, ensure_ascii=False)

            # Restore the current (sorted) order after saving
            self.current_project.segments = current_segments
            
            self.project_modified = False
            self.update_window_title()
            self.log(f"✓ Saved project: {Path(file_path).name}")
            
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to save project:\n{str(e)}")
            self.log(f"✗ Error saving project: {e}")

    def restart_auto_backup_timer(self):
        """Restart the auto backup timer based on current settings"""
        from PyQt6.QtCore import QTimer
        from datetime import datetime

        # Stop existing timer if running
        if self.auto_backup_timer:
            self.auto_backup_timer.stop()
            self.auto_backup_timer = None

        # Load settings
        general_settings = self.load_general_settings()
        enabled = general_settings.get('enable_auto_backup', True)
        interval_minutes = general_settings.get('backup_interval_minutes', 5)

        if not enabled:
            self.log("Auto backup disabled")
            return

        # Create and start timer
        self.auto_backup_timer = QTimer()
        self.auto_backup_timer.timeout.connect(self.perform_auto_backup)
        interval_ms = interval_minutes * 60 * 1000  # Convert minutes to milliseconds
        self.auto_backup_timer.start(interval_ms)

        self.log(f"Auto backup enabled: every {interval_minutes} minute(s)")

    def perform_auto_backup(self):
        """Perform automatic backup of project and TMX"""
        from datetime import datetime
        from modules.tmx_generator import TMXGenerator

        # Check if there's a project to backup
        if not self.current_project or not self.project_file_path:
            return

        try:
            # Save project.json
            self.save_project_to_file(self.project_file_path)

            # Export TMX to same folder as project
            project_dir = Path(self.project_file_path).parent
            project_name = Path(self.project_file_path).stem
            tmx_file_path = project_dir / f"{project_name}_backup.tmx"

            # Prepare segments for TMX export
            source_segments = []
            target_segments = []

            for segment in self.current_project.segments:
                source_text = segment.source
                target_text = segment.target if segment.target else ""

                if source_text:
                    source_segments.append(source_text)
                    target_segments.append(target_text)

            if source_segments:
                # Generate and save TMX
                tmx_generator = TMXGenerator(log_callback=self.log)

                source_lang = self.current_project.source_lang or "en"
                target_lang = self.current_project.target_lang or "nl"

                tmx_tree = tmx_generator.generate_tmx(
                    source_segments=source_segments,
                    target_segments=target_segments,
                    source_lang=source_lang,
                    target_lang=target_lang
                )

                tmx_generator.save_tmx(tmx_tree, str(tmx_file_path))

            # Update last backup time
            self.last_backup_time = datetime.now()
            timestamp = self.last_backup_time.strftime("%H:%M:%S")

            self.log(f"💾 Auto backup completed at {timestamp}: {Path(self.project_file_path).name} + {tmx_file_path.name}")

        except Exception as e:
            self.log(f"⚠️ Auto backup failed: {e}")

    def close_project(self):
        """Close current project"""
        if not self.current_project:
            QMessageBox.information(self, "No Project", "No project is currently open")
            return
        
        # Check for unsaved changes
        if self.project_modified:
            reply = QMessageBox.question(
                self, "Unsaved Changes",
                "Save current project before closing?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel
            )
            if reply == QMessageBox.StandardButton.Cancel:
                return
            elif reply == QMessageBox.StandardButton.Yes:
                self.save_project()
                # If save was cancelled or failed, project_modified will still be True
                if self.project_modified:
                    return
        
        # Stop background batch workers
        self.stop_termbase_batch_worker()
        self.stop_prefetch_worker()
        
        # Clear caches for this project
        self.termbase_cache.clear()
        self.translation_matches_cache.clear()
        
        # Clear project data
        self.current_project = None
        self.project_file_path = None
        self.project_modified = False
        
        # Clear the grid
        self.clear_grid()
        
        # Clear translation results in all panels
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                if hasattr(panel, 'clear_results'):
                    panel.clear_results()
                if hasattr(panel, 'tm_results_table'):
                    panel.tm_results_table.setRowCount(0)
                if hasattr(panel, 'termbase_results_table'):
                    panel.termbase_results_table.setRowCount(0)
        
        # Update window title
        self.update_window_title()
        
        self.log("✓ Project closed")
    
    def update_recent_menu(self):
        """Update recent projects menu"""
        self.recent_menu.clear()
        
        recent_projects = self.load_recent_projects()
        
        if not recent_projects:
            no_recent = QAction("No recent projects", self)
            no_recent.setEnabled(False)
            self.recent_menu.addAction(no_recent)
            return
        
        # Add menu items for each recent project
        for i, project_info in enumerate(recent_projects[:self.MAX_RECENT_PROJECTS]):
            path = project_info['path']
            name = project_info['name']
            
            # Show relative path if in workspace, otherwise just filename
            try:
                rel_path = Path(path).relative_to(Path.cwd())
                display_name = f"{i+1}. {name} ({rel_path.parent})"
            except ValueError:
                display_name = f"{i+1}. {name}"
            
            action = QAction(display_name, self)
            action.setStatusTip(path)
            action.triggered.connect(lambda checked, p=path: self._load_recent_project_with_resize(p))
            self.recent_menu.addAction(action)
        
        # Add separator and "Clear Recent" option
        if recent_projects:
            self.recent_menu.addSeparator()
            clear_action = QAction("Clear Recent Projects", self)
            clear_action.triggered.connect(self.clear_recent_projects)
            self.recent_menu.addAction(clear_action)
    
    def _load_recent_project_with_resize(self, project_path: str):
        """Load a project from recent menu and auto-enable row resize"""
        self.load_project(project_path)
        # Auto-enable row resize after loading
        if hasattr(self, 'auto_resize_rows'):
            QTimer.singleShot(100, self.auto_resize_rows)  # Slight delay to ensure grid is fully loaded
    
    def add_to_recent_projects(self, file_path: str):
        """Add project to recent projects list"""
        if not file_path or not os.path.exists(file_path):
            return
        
        # Load existing recent projects
        recent_projects = self.load_recent_projects()
        
        # Create new entry
        new_entry = {
            'path': os.path.abspath(file_path),
            'name': self.current_project.name if self.current_project else Path(file_path).stem,
            'last_opened': datetime.now().isoformat()
        }
        
        # Remove if already exists (to move to top)
        recent_projects = [p for p in recent_projects if p['path'] != new_entry['path']]
        
        # Add to beginning
        recent_projects.insert(0, new_entry)
        
        # Keep only MAX_RECENT_PROJECTS
        recent_projects = recent_projects[:self.MAX_RECENT_PROJECTS]
        
        # Save back to file
        self.save_recent_projects(recent_projects)
        
        # Update menu and home display
        self.update_recent_menu()
        self.update_recent_projects_display()
    
    def load_recent_projects(self) -> List[Dict[str, str]]:
        """Load recent projects from file"""
        if not self.recent_projects_file.exists():
            return []
        
        try:
            # Try UTF-8 first, fall back to latin-1 if it fails
            try:
                with open(self.recent_projects_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
            except UnicodeDecodeError:
                self.log(f"⚠ UTF-8 decoding failed for recent projects, trying latin-1...")
                with open(self.recent_projects_file, 'r', encoding='latin-1') as f:
                    data = json.load(f)
            
            # Handle both old dict format and new list format
            if isinstance(data, dict):
                # Old format: convert to list
                recent = []
                for key, value in data.items():
                    if isinstance(value, list):
                        # Extract path and name from old list format
                        for item in value:
                            if isinstance(item, dict):
                                recent.append(item)
                            else:
                                # String path
                                recent.append({
                                    'path': str(item),
                                    'name': Path(str(item)).stem,
                                    'last_opened': datetime.now().isoformat()
                                })
                    elif isinstance(value, str):
                        recent.append({
                            'path': value,
                            'name': Path(value).stem,
                            'last_opened': datetime.now().isoformat()
                        })
                return recent
            elif isinstance(data, list):
                # New format: already a list
                # Ensure all entries have required fields
                normalized = []
                for item in data:
                    if isinstance(item, dict) and 'path' in item:
                        # Ensure all required fields exist
                        if 'name' not in item:
                            item['name'] = Path(item['path']).stem
                        if 'last_opened' not in item:
                            item['last_opened'] = datetime.now().isoformat()
                        # Only include if file still exists
                        if os.path.exists(item['path']):
                            normalized.append(item)
                return normalized
            
            return []
        
        except Exception as e:
            self.log(f"Error loading recent projects: {e}")
            return []
    
    def save_recent_projects(self, recent_projects: List[Dict[str, str]]):
        """Save recent projects to file"""
        try:
            # Ensure directory exists
            self.user_data_path.mkdir(parents=True, exist_ok=True)
            
            with open(self.recent_projects_file, 'w', encoding='utf-8') as f:
                json.dump(recent_projects, f, indent=2, ensure_ascii=False)
        
        except Exception as e:
            self.log(f"Error saving recent projects: {e}")
    
    def update_recent_projects_display(self):
        """Update the recent projects display on the home screen"""
        if not hasattr(self, 'recent_projects_layout'):
            return
        
        # Clear existing widgets
        for i in reversed(range(self.recent_projects_layout.count())):
            item = self.recent_projects_layout.itemAt(i)
            if item and item.widget():
                item.widget().deleteLater()
        
        # Load recent projects
        recent_projects = self.load_recent_projects()
        
        if not recent_projects:
            no_recent = QLabel("No recent projects")
            no_recent.setStyleSheet("color: #888; font-style: italic; padding: 10px;")
            no_recent.setAlignment(Qt.AlignmentFlag.AlignCenter)
            self.recent_projects_layout.addWidget(no_recent)
            return
        
        # Add project buttons
        for project_info in recent_projects[:5]:  # Show max 5 on home screen
            path = project_info['path']
            name = project_info.get('name', Path(path).stem)
            
            btn = QPushButton(f"📄 {name}")
            btn.setStyleSheet("text-align: left; padding: 8px; border-radius: 3px;")
            btn.setCursor(Qt.CursorShape.PointingHandCursor)
            btn.clicked.connect(lambda checked, p=path: self.load_project(p))
            self.recent_projects_layout.addWidget(btn)
        
        self.recent_projects_layout.addStretch()
    
    def clear_recent_projects(self):
        """Clear all recent projects"""
        reply = QMessageBox.question(
            self,
            "Clear Recent Projects",
            "Are you sure you want to clear all recent projects?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        
        if reply == QMessageBox.StandardButton.Yes:
            self.save_recent_projects([])
            self.update_recent_menu()
            if hasattr(self, 'recent_projects_layout'):
                self.update_recent_projects_display()
            self.log("Recent projects cleared")
    
    # ========================================================================
    # MEMOQ BILINGUAL DOCX IMPORT/EXPORT
    # ========================================================================
    
    def import_docx(self):
        """Import a monolingual DOCX document"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select DOCX File",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )

        if not file_path:
            return

        # Show import options dialog (language pair selection)
        dialog = QDialog(self)
        dialog.setWindowTitle("Import DOCX Options")
        dialog.setMinimumWidth(500)

        layout = QVBoxLayout(dialog)

        # Info message
        info_label = QLabel(
            "You selected the Monolingual DOCX import workflow.\n\n"
            "Projects created with this option can be exported as standard formats "
            "(DOCX, plain text, etc.), but they cannot be exported as memoQ bilingual "
            "DOCX files. If you need memoQ round-tripping, use 'Import memoQ bilingual document'."
        )
        info_label.setWordWrap(True)
        layout.addWidget(info_label)

        layout.addSpacing(15)

        # Language pair selection
        lang_group = QGroupBox("Language Pair")
        lang_layout = QHBoxLayout(lang_group)
        
        # Common languages for translation
        languages = [
            ("English", "en"),
            ("Dutch", "nl"),
            ("German", "de"),
            ("French", "fr"),
            ("Spanish", "es"),
            ("Italian", "it"),
            ("Portuguese", "pt"),
            ("Polish", "pl"),
            ("Russian", "ru"),
            ("Chinese", "zh"),
            ("Japanese", "ja"),
            ("Korean", "ko"),
        ]
        
        source_label = QLabel("Source:")
        source_combo = QComboBox()
        for name, code in languages:
            source_combo.addItem(name, code)
        
        # Load last used languages from settings, or use current project, or default to English
        general_settings = self.load_general_settings()
        last_source = general_settings.get('last_import_source_lang', 
                                          self.current_project.source_lang if self.current_project else 'en')
        last_target = general_settings.get('last_import_target_lang',
                                          self.current_project.target_lang if self.current_project else 'nl')
        
        # Set source combo to last used language
        source_index = 0  # Default to English
        for i in range(source_combo.count()):
            if source_combo.itemData(i) == last_source:
                source_index = i
                break
        source_combo.setCurrentIndex(source_index)
        
        arrow_label = QLabel(" → ")
        arrow_label.setStyleSheet("font-weight: bold; font-size: 14px;")
        
        target_label = QLabel("Target:")
        target_combo = QComboBox()
        for name, code in languages:
            target_combo.addItem(name, code)
        
        # Set target combo to last used language
        target_index = 1  # Default to Dutch
        for i in range(target_combo.count()):
            if target_combo.itemData(i) == last_target:
                target_index = i
                break
        target_combo.setCurrentIndex(target_index)
        
        lang_layout.addWidget(source_label)
        lang_layout.addWidget(source_combo)
        lang_layout.addWidget(arrow_label)
        lang_layout.addWidget(target_label)
        lang_layout.addWidget(target_combo)
        lang_layout.addStretch()
        
        layout.addWidget(lang_group)

        layout.addSpacing(20)

        # Buttons
        button_layout = QHBoxLayout()
        ok_btn = QPushButton("Import")
        ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)

        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)

        # Show dialog
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        # Store selected languages for import
        self._import_source_lang = source_combo.currentData()
        self._import_target_lang = target_combo.currentData()

        # Save selected languages to settings for next time
        general_settings = self.load_general_settings()
        general_settings['last_import_source_lang'] = self._import_source_lang
        general_settings['last_import_target_lang'] = self._import_target_lang
        self.save_general_settings(general_settings)

        self.import_docx_from_path(file_path)

    # Formats other than DOCX that we route through Okapi extract → merge.
    # DOCX has its own dedicated menu entry; TMX is for TM import (separate
    # path); everything else the sidecar reports as supported lands here.
    OKAPI_OTHER_FORMATS = [
        ("Adobe InDesign Markup", "*.idml"),
        ("HTML", "*.html *.htm"),
        ("XLIFF 1.2", "*.xliff *.xlf"),
        ("PO (gettext)", "*.po"),
        ("Microsoft Excel", "*.xlsx"),
        ("Microsoft PowerPoint", "*.pptx"),
    ]

    # All formats import_document accepts. DOCX is listed first because it's
    # the most common case; the rest come from OKAPI_OTHER_FORMATS so this
    # list and the file filter stay in lockstep when new formats are added.
    @property
    def IMPORT_DOCUMENT_FORMATS(self):
        return [("Word Document", "*.docx")] + self.OKAPI_OTHER_FORMATS

    def import_document(self):
        """Import any supported document format via the Okapi sidecar.

        Replaces the older ``import_docx`` and ``import_okapi_format``
        menu handlers, which were two cosmetically-different entry points
        for the same underlying engine. Every supported format (DOCX,
        IDML, HTML, XLIFF, PO, XLSX, PPTX) goes through the Okapi
        sidecar — the DOCX/non-DOCX split in the old menu was a stale
        leftover from when there used to be two engines.

        File picker shows an "All supported formats" filter by default,
        with per-format filters available for users who want them. The
        language-pair dialog and the actual extraction are unchanged
        from the previous ``import_okapi_format`` flow.
        """
        # Build file-filter list: All-supported bucket first, then per-format.
        all_patterns = " ".join(p for _, p in self.IMPORT_DOCUMENT_FORMATS)
        filter_parts = [f"All supported formats ({all_patterns})"]
        filter_parts.extend(
            f"{name} ({pattern})" for name, pattern in self.IMPORT_DOCUMENT_FORMATS
        )
        filter_parts.append("All Files (*.*)")

        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Import document",
            "",
            ";;".join(filter_parts),
        )
        if not file_path:
            return

        # Language-pair dialog (same UI as the previous separate flows).
        dialog = QDialog(self)
        dialog.setWindowTitle("Import document – Options")
        dialog.setMinimumWidth(500)
        set_help_topic(dialog, HelpTopics.IMPORT_FORMATS)
        layout = QVBoxLayout(dialog)

        info_label = QLabel(
            f"Selected file: <b>{os.path.basename(file_path)}</b><br><br>"
            "The file will be extracted via the Okapi sidecar – the same "
            "round-trip pipeline used for every supported format. You can "
            "later export back to the original format via "
            "<i>File → Export → Export Translated Document…</i>."
        )
        info_label.setWordWrap(True)
        info_label.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info_label)
        layout.addSpacing(15)

        # Same language list / combo-init pattern as the legacy flows.
        languages = [
            ("English", "en"), ("Dutch", "nl"), ("German", "de"),
            ("French", "fr"), ("Spanish", "es"), ("Italian", "it"),
            ("Portuguese", "pt"), ("Polish", "pl"), ("Russian", "ru"),
            ("Chinese", "zh"), ("Japanese", "ja"), ("Korean", "ko"),
        ]
        general_settings = self.load_general_settings()
        last_source = general_settings.get(
            'last_import_source_lang',
            self.current_project.source_lang if self.current_project else 'en')
        last_target = general_settings.get(
            'last_import_target_lang',
            self.current_project.target_lang if self.current_project else 'nl')

        lang_group = QGroupBox("Language Pair")
        lang_layout = QHBoxLayout(lang_group)
        source_combo = QComboBox()
        target_combo = QComboBox()
        for name, code in languages:
            source_combo.addItem(name, code)
            target_combo.addItem(name, code)
        for i in range(source_combo.count()):
            if source_combo.itemData(i) == last_source:
                source_combo.setCurrentIndex(i)
                break
        for i in range(target_combo.count()):
            if target_combo.itemData(i) == last_target:
                target_combo.setCurrentIndex(i)
                break

        arrow = QLabel(" → ")
        arrow.setStyleSheet("font-weight: bold; font-size: 14px;")
        lang_layout.addWidget(QLabel("Source:"))
        lang_layout.addWidget(source_combo)
        lang_layout.addWidget(arrow)
        lang_layout.addWidget(QLabel("Target:"))
        lang_layout.addWidget(target_combo)
        lang_layout.addStretch()
        layout.addWidget(lang_group)
        layout.addSpacing(20)

        button_layout = QHBoxLayout()
        ok_btn = QPushButton("Import")
        ok_btn.setStyleSheet(
            "background-color: #4CAF50; color: white; font-weight: bold; "
            "border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)

        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        self._import_source_lang = source_combo.currentData()
        self._import_target_lang = target_combo.currentData()
        general_settings['last_import_source_lang'] = self._import_source_lang
        general_settings['last_import_target_lang'] = self._import_target_lang
        self.save_general_settings(general_settings)

        # import_docx_from_path is generic at the extract level – the
        # "docx" in its name is historical. Once we hand it the path,
        # the Okapi sidecar takes over and the format-specific bits
        # (extraction, segmentation, project build) are handled by
        # the existing pipeline.
        self.import_docx_from_path(file_path)

    def import_okapi_format(self):
        """Import any format the Okapi sidecar can extract (IDML, HTML, XLIFF, PO, …).

        Mirrors the DOCX import flow: file picker → language pair dialog →
        ``import_docx_from_path``. The path-based importer is already
        format-agnostic at the extraction step (it just calls
        ``okapi_sidecar.extract``), so once we hand it the file path the
        existing Okapi round-trip plumbing takes care of the rest – the
        project ends up tagged with ``import_engine = "okapi"`` and the
        original file path is stored on the project for ``_try_okapi_merge_export``
        to use later.
        """
        # Build file filter list: per-format entries + an "All supported"
        # bucket for users who don't know what their file is.
        all_patterns = " ".join(p for _, p in self.OKAPI_OTHER_FORMATS)
        filter_parts = [f"All supported formats ({all_patterns})"]
        filter_parts.extend(f"{name} ({pattern})" for name, pattern in self.OKAPI_OTHER_FORMATS)
        filter_parts.append("All Files (*.*)")

        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Import via Okapi",
            "",
            ";;".join(filter_parts)
        )
        if not file_path:
            return

        # Language-pair dialog (mirrors import_docx). Kept duplicated to
        # avoid disturbing the working DOCX path during this v1 rollout.
        dialog = QDialog(self)
        dialog.setWindowTitle("Import via Okapi – Options")
        dialog.setMinimumWidth(500)
        # Standard "?" help badge in the dialog title bar – opens the
        # Supported File Formats reference. Matches the convention used
        # elsewhere in the app for context-sensitive help.
        set_help_topic(dialog, HelpTopics.IMPORT_FORMATS)
        layout = QVBoxLayout(dialog)

        info_label = QLabel(
            f"<b>{Path(file_path).name}</b><br><br>"
            f"Supervertaler will use the Okapi sidecar to extract translatable "
            f"content from this file. When you later export via "
            f"<i>File → Export → Original format (via Okapi)…</i>, the same "
            f"sidecar reconstructs the file in its original format, byte-for-byte."
        )
        info_label.setWordWrap(True)
        layout.addWidget(info_label)
        layout.addSpacing(15)

        lang_group = QGroupBox("Language Pair")
        lang_layout = QHBoxLayout(lang_group)

        languages = [
            ("English", "en"), ("Dutch", "nl"), ("German", "de"),
            ("French", "fr"), ("Spanish", "es"), ("Italian", "it"),
            ("Portuguese", "pt"), ("Polish", "pl"), ("Russian", "ru"),
            ("Chinese", "zh"), ("Japanese", "ja"), ("Korean", "ko"),
        ]

        source_combo = QComboBox()
        target_combo = QComboBox()
        for name, code in languages:
            source_combo.addItem(name, code)
            target_combo.addItem(name, code)

        general_settings = self.load_general_settings()
        last_source = general_settings.get('last_import_source_lang',
                                           self.current_project.source_lang if self.current_project else 'en')
        last_target = general_settings.get('last_import_target_lang',
                                           self.current_project.target_lang if self.current_project else 'nl')
        for i in range(source_combo.count()):
            if source_combo.itemData(i) == last_source:
                source_combo.setCurrentIndex(i)
                break
        for i in range(target_combo.count()):
            if target_combo.itemData(i) == last_target:
                target_combo.setCurrentIndex(i)
                break

        arrow = QLabel(" → ")
        arrow.setStyleSheet("font-weight: bold; font-size: 14px;")
        lang_layout.addWidget(QLabel("Source:"))
        lang_layout.addWidget(source_combo)
        lang_layout.addWidget(arrow)
        lang_layout.addWidget(QLabel("Target:"))
        lang_layout.addWidget(target_combo)
        lang_layout.addStretch()
        layout.addWidget(lang_group)
        layout.addSpacing(20)

        button_layout = QHBoxLayout()
        ok_btn = QPushButton("Import")
        ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)

        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        self._import_source_lang = source_combo.currentData()
        self._import_target_lang = target_combo.currentData()
        general_settings['last_import_source_lang'] = self._import_source_lang
        general_settings['last_import_target_lang'] = self._import_target_lang
        self.save_general_settings(general_settings)

        # import_docx_from_path is generic at the extract level – it asks the
        # Okapi sidecar to parse whatever path it gets, then builds segments
        # from the result. The "docx" in its name is historical.
        self.import_docx_from_path(file_path)

    def export_okapi_merge(self):
        """Export an Okapi-imported project back to its original format.

        Pairs with ``import_okapi_format``: looks up the original file path
        stored on import, opens a Save As dialog defaulted to the same
        extension, and calls ``_try_okapi_merge_export`` to round-trip
        the translation through the sidecar.
        """
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "Nothing to Export", "Open a project first.")
            return
        if getattr(self.current_project, 'import_engine', '') != 'okapi':
            QMessageBox.warning(
                self, "Not an Okapi project",
                "This menu entry only works for projects imported via "
                "<i>File → Import → Other format (via Okapi)…</i> (or DOCX, "
                "but DOCX has its own dedicated export entry).<br><br>"
                "The current project was imported through a different path."
            )
            return

        original_path = (getattr(self, 'original_docx', None)
                         or getattr(self, 'current_document_path', None)
                         or getattr(self.current_project, 'original_docx_path', None))
        if not original_path or not os.path.exists(original_path):
            QMessageBox.warning(
                self, "Original File Not Found",
                f"The original source file is needed for the Okapi merge step "
                f"but couldn't be located:<br><br>"
                f"<code>{original_path or 'no path stored'}</code><br><br>"
                f"Re-import the source file to refresh the link, then try "
                f"the export again."
            )
            return

        ext = Path(original_path).suffix.lower()  # includes the dot
        ext_label = ext.lstrip('.').upper() or "file"
        default_name = Path(original_path).stem + "_translated" + ext

        file_path, _ = QFileDialog.getSaveFileName(
            self,
            f"Export {ext_label} via Okapi",
            default_name,
            f"{ext_label} files (*{ext});;All Files (*.*)"
        )
        if not file_path:
            return

        ok = self._try_okapi_merge_export(
            self.current_project.segments, original_path, file_path
        )
        if not ok:
            self.log("⚠ Okapi merge export did not complete – check the log above.")

    def _ensure_okapi_sidecar(self) -> bool:
        """Make sure the Okapi sidecar is up. Returns True on success.

        Handles three scenarios:

        1. Sidecar is already running – return True immediately.
        2. Sidecar is installed (JAR present) but not running – start it.
        3. Sidecar is not installed (typical for pip-installed users
           without the bundled JRE) – offer a one-click download from
           the GitHub release, with a progress dialog. After the download
           completes, start the sidecar and verify.

        Shows a clear error dialog and returns False if any step fails
        or the user declines the download.
        """
        if not self.okapi_sidecar:
            QMessageBox.critical(
                self,
                "Okapi sidecar unavailable",
                "The Okapi sidecar client is not initialised. This is "
                "usually caused by a missing 'requests' Python package. "
                "Please reinstall Supervertaler."
            )
            return False

        # Fast path: already running.
        if self.okapi_sidecar.is_running():
            return True

        # Path 2: installed but not running. Try to start it.
        if self.okapi_sidecar.is_installed():
            self.log("Starting Okapi sidecar…")
            if self.okapi_sidecar.start() and self.okapi_sidecar.is_running():
                return True
            QMessageBox.critical(
                self,
                "Okapi sidecar failed to start",
                "DOCX import requires the Okapi sidecar, but it failed "
                "to start.\n\n"
                "What to try:\n"
                "• Restart Supervertaler.\n"
                "• Check that port 8090 isn't taken by another app.\n"
                "• Check the diagnostic log for errors mentioning "
                "\"okapi sidecar\"."
            )
            return False

        # Path 3: not installed at all – offer a download.
        # Four flavours by platform + arch:
        #  - Windows:        ~70 MB bundle (JAR + bundled Windows JRE).
        #  - Apple Silicon:  ~50 MB bundle (JAR + bundled arm64 JRE).
        #  - Intel Mac:      ~28 MB JAR; requires system Java.
        #  - Linux:          ~28 MB JAR; requires system Java.
        sysname = platform.system()
        is_apple_silicon = (sysname == "Darwin"
                            and platform.machine() == "arm64")
        has_bundled_jre = (sysname == "Windows") or is_apple_silicon

        if not has_bundled_jre:
            # Intel Mac / Linux: verify Java is reachable before
            # downloading the JAR. Otherwise we'd download 28 MB and
            # then fail to launch, which is a worse experience than
            # telling the user up-front what's missing.
            java_path = self.okapi_sidecar._find_java()
            if java_path is None:
                if sysname == "Darwin":
                    install_hint = (
                        "Install Java first (one-time setup):\n"
                        "  • Homebrew (recommended): brew install --cask temurin@17\n"
                        "  • Or download from https://adoptium.net\n\n"
                        "Verify with: java -version\n"
                        "Then restart Supervertaler and re-try the import."
                    )
                else:
                    install_hint = (
                        "Install Java first (one-time setup):\n"
                        "  • Debian/Ubuntu: sudo apt install default-jre\n"
                        "  • Fedora/RHEL:  sudo dnf install java-latest-openjdk\n"
                        "  • Arch:         sudo pacman -S jre-openjdk\n"
                        "  • Or download from https://www.java.com\n\n"
                        "Then re-try the import."
                    )
                self.log("⚠️ Showing 'Java required' dialog – "
                         "DOCX import blocked, no system Java found "
                         f"({sysname})")
                QMessageBox.critical(
                    self,
                    "Java required",
                    "DOCX import requires the Okapi sidecar, a small "
                    "Java service. No Java runtime was found on your "
                    "system.\n\n" + install_hint
                )
                self.log("ℹ️ User dismissed 'Java required' dialog – "
                         "import aborted")
                return False

        if has_bundled_jre:
            size_text = "about 50 MB" if is_apple_silicon else "about 70 MB"
            extra_note = ""
        else:
            size_text = "about 28 MB"
            extra_note = (
                "Java was detected on your system, so no separate "
                "Java install is needed.\n\n"
            )

        self.log(f"ℹ️ Showing 'Download Okapi sidecar?' dialog "
                 f"({size_text}, has_bundled_jre={has_bundled_jre})")
        reply = QMessageBox.question(
            self,
            "Download Okapi sidecar?",
            "DOCX import requires the Okapi sidecar – a small Java "
            "service that handles document extraction.\n\n"
            "Supervertaler can download and install it for you "
            f"({size_text}, one-time download). It will be saved to "
            "your local app data folder.\n\n"
            f"{extra_note}"
            "Download now?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes,
        )
        if reply != QMessageBox.StandardButton.Yes:
            self.log("ℹ️ User declined sidecar download – import aborted")
            return False
        self.log("ℹ️ User accepted sidecar download – starting fetch")

        progress = QProgressDialog(
            "Downloading Okapi sidecar…",
            None,  # not cancellable mid-download (extraction would leave a mess)
            0, 100, self
        )
        progress.setWindowTitle("Installing Okapi sidecar")
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.show()
        QApplication.processEvents()

        def _dl_progress(done, total):
            if total > 0:
                pct = int(done * 100 / total)
                progress.setValue(pct)
                progress.setLabelText(
                    f"Downloading Okapi sidecar… "
                    f"({done // (1024*1024)} / {total // (1024*1024)} MB)"
                )
            else:
                progress.setLabelText(
                    f"Downloading Okapi sidecar… "
                    f"({done // (1024*1024)} MB)"
                )
            QApplication.processEvents()

        try:
            ok = self.okapi_sidecar.download_install(progress_callback=_dl_progress)
        finally:
            try:
                progress.close()
            except Exception:
                pass

        if not ok:
            QMessageBox.critical(
                self,
                "Sidecar download failed",
                "The Okapi sidecar could not be downloaded. Check your "
                "internet connection and try again.\n\n"
                "If the problem persists, you can download the full "
                "Supervertaler release zip from GitHub – it ships with "
                "the sidecar bundled."
            )
            return False

        # Now try to start the freshly-installed sidecar.
        self.log("Starting freshly-installed Okapi sidecar…")
        if self.okapi_sidecar.start() and self.okapi_sidecar.is_running():
            self.log(f"✅ Okapi sidecar v{self.okapi_sidecar.get_version() or '?'} ready")
            return True

        QMessageBox.critical(
            self,
            "Sidecar installed but failed to start",
            "The Okapi sidecar was downloaded successfully, but it "
            "failed to launch.\n\n"
            "Check the diagnostic log for errors. You may need to "
            "restart Supervertaler."
        )
        return False

    def import_docx_from_path(self, file_path):
        """Import a monolingual DOCX document from a given path"""
        try:
            # Check if this is a re-import of the current project's source file
            reimport_mode = False
            preserved_project_id = None
            preserved_tm_settings = None
            preserved_termbase_settings = None

            if self.current_project:
                current_source = getattr(self, 'current_document_path', None) or \
                                 getattr(self.current_project, 'original_docx_path', None)

                # Normalize paths for comparison
                if current_source and os.path.normpath(file_path) == os.path.normpath(current_source):
                    # Same file - ask user what they want to do
                    dialog = QMessageBox(self)
                    dialog.setWindowTitle("Re-import Document")
                    dialog.setText(f"This file is already loaded in the current project:\n\n{os.path.basename(file_path)}")
                    dialog.setInformativeText("Do you want to re-import it into the current project (keeping TM and termbase settings), or create a new project?")

                    reimport_btn = dialog.addButton("Re-import into Current Project", QMessageBox.ButtonRole.AcceptRole)
                    new_proj_btn = dialog.addButton("Create New Project", QMessageBox.ButtonRole.ActionRole)
                    cancel_btn = dialog.addButton(QMessageBox.StandardButton.Cancel)

                    dialog.exec()

                    clicked = dialog.clickedButton()
                    if clicked == cancel_btn:
                        return
                    elif clicked == reimport_btn:
                        reimport_mode = True
                        # Preserve project ID and settings
                        preserved_project_id = self.current_project.id
                        preserved_tm_settings = getattr(self.current_project, 'tm_settings', None)
                        preserved_termbase_settings = getattr(self.current_project, 'termbase_settings', None)
                        self.log(f"📋 Re-importing into current project (preserving TM settings)")
                    # else: new_proj_btn clicked - continue with new project

            self.log(f"Importing: {os.path.basename(file_path)}")

            # ── Okapi-only import ─────────────────────────────────────
            # DOCX import always goes through the Okapi sidecar – the
            # old engine-choice dialog is gone. _ensure_okapi_sidecar()
            # handles the three scenarios (already running, installed
            # but not yet started, not installed → offer download).
            if not self._ensure_okapi_sidecar():
                return

            segmented = None

            # ── Progress dialog (indeterminate, modal) ────────────────
            # Covers the whole import: extraction, segmentation, project
            # build, and grid load. Large DOCX files via the Okapi route
            # can take a while – this gives the user visible feedback.
            from PyQt6.QtWidgets import QProgressDialog
            progress = QProgressDialog(
                f"Importing {os.path.basename(file_path)}…",
                None,  # no cancel button – extraction can't be safely interrupted
                0, 0, self
            )
            progress.setWindowTitle("Importing document")
            progress.setWindowModality(Qt.WindowModality.WindowModal)
            progress.setMinimumDuration(0)
            progress.setAutoClose(False)
            progress.setAutoReset(False)
            progress.show()
            QApplication.processEvents()

            try:
                # Read the language pair the user just selected in the
                # Import DOCX Options dialog. The dialog stores them on
                # self as _import_source_lang / _import_target_lang (set
                # at lines 27256-27257). Earlier versions of this code
                # accidentally read self._last_import_source_lang /
                # _last_import_target_lang – attribute names that are
                # never assigned anywhere – which silently fell through
                # to the default 'en' for both source and target. End
                # result: regardless of what the user picked in the
                # dialog, Okapi was always asked to extract as English-
                # to-English, applying English segmentation rules to
                # non-English source texts and showing the wrong
                # direction in the progress dialog. Fixed in v1.9.421.
                src_lang = getattr(self, '_import_source_lang', None) or 'en'
                trg_lang = getattr(self, '_import_target_lang', None) or 'en'

                progress.setLabelText(
                    f"Extracting with Okapi ({src_lang.upper()} → {trg_lang.upper()})…\n"
                    f"This may take a while for large documents."
                )
                QApplication.processEvents()

                self.log(f"🔧 Extracting via Okapi sidecar ({src_lang} → {trg_lang})...")

                # Run extraction on a worker thread and pump the Qt event
                # loop on the main thread so the dialog stays animated and
                # the OS doesn't mark the window "Not Responding".
                import threading, time as _time
                _result_holder: dict = {}
                _exc_holder: dict = {}

                def _okapi_worker():
                    try:
                        _result_holder['v'] = self.okapi_sidecar.extract(
                            file_path,
                            source_lang=src_lang,
                            target_lang=trg_lang,
                            segment=True,
                        )
                    except Exception as _e:
                        _exc_holder['e'] = _e

                _t = threading.Thread(target=_okapi_worker, daemon=True)
                _t.start()
                _start = _time.monotonic()
                while _t.is_alive():
                    QApplication.processEvents()
                    _t.join(0.05)
                    _elapsed = int(_time.monotonic() - _start)
                    progress.setLabelText(
                        f"Extracting with Okapi ({src_lang.upper()} → {trg_lang.upper()})…\n"
                        f"Working on the document – {_elapsed}s elapsed."
                    )
                    QApplication.processEvents()

                if 'e' in _exc_holder:
                    raise _exc_holder['e']
                result = _result_holder['v']

                # Convert Okapi segments → (para_id, text) tuples
                # Filter out non-body content (headers, footers, textbox
                # names) and use formatting tags when available.
                tu_id_to_para = {}
                next_para = 0
                segmented = []
                skipped_count = 0

                for seg in result.get('segments', []):
                    tu_id = seg.get('id', '')
                    sub_doc = (seg.get('subDocument') or '').lower()
                    text = seg.get('source', '').strip()
                    tagged_text = (seg.get('sourceWithTags') or '').strip()

                    # ── Filter: skip header/footer content ─────────
                    # Okapi names subdocuments after the XML part
                    # (e.g. "document.xml", "header1.xml", "footer2.xml").
                    # Keep everything except headers and footers.
                    if sub_doc.startswith(('header', 'footer')):
                        skipped_count += 1
                        continue

                    # Skip empty segments
                    if not text:
                        skipped_count += 1
                        continue

                    # Assign paragraph IDs (same TU = same paragraph)
                    if tu_id not in tu_id_to_para:
                        tu_id_to_para[tu_id] = next_para
                        next_para += 1
                    para_id = tu_id_to_para[tu_id]

                    # Use tagged text (with <b>/<i> etc.) if available,
                    # otherwise use plain text.
                    display_text = tagged_text if tagged_text else text
                    segmented.append((para_id, display_text, tu_id, seg.get('segmentIndex', 0)))

                self.log(f"✅ Okapi extracted {len(segmented)} segments "
                         f"from {result.get('textUnitCount', '?')} text units "
                         f"(filter: {result.get('filterUsed', '?')}"
                         f"{f', skipped {skipped_count} non-body' if skipped_count else ''})")

            except Exception as e:
                # Hard-fail – the python-docx fallback is gone.
                try:
                    progress.close()
                except Exception:
                    pass
                self.log(f"✗ Okapi extraction failed: {e}")
                QMessageBox.critical(
                    self,
                    "DOCX import failed",
                    f"Okapi extraction failed for:\n"
                    f"{os.path.basename(file_path)}\n\n"
                    f"Details: {e}\n\n"
                    "Check the diagnostic log for the full traceback."
                )
                return

            self.original_docx = file_path

            progress.setLabelText("Building project…")
            QApplication.processEvents()

            # Create new project with the imported segments
            from dataclasses import dataclass
            from typing import List, Dict, Any, Optional
            from datetime import datetime
            
            @dataclass
            class ImportedSegment:
                id: int
                source: str
                target: str = ""
                status: str = "untranslated"
                notes: str = ""
                para_id: Optional[int] = None
                is_table: bool = False
                table_info: Optional[tuple] = None
                style: str = "Normal"
                doc_position: int = 0
                type: str = "#"  # Default segment type
                okapi_tu_id: str = ""
                okapi_seg_idx: int = -1

            # Convert segments
            imported_segments = []
            for seg_id, (para_id, text, okapi_tu_id, okapi_seg_idx) in enumerate(segmented, 1):
                # Okapi import: no per-paragraph metadata (table info, styles
                # like Heading1) is currently piped through. Style detection
                # could be added later by extending the sidecar /extract
                # response with a per-segment style hint.
                para_info = None

                is_table = False
                table_info = None
                style = "Normal"
                doc_position = para_id if para_id else seg_id
                
                if para_info:
                    style = para_info.style or "Normal"
                    doc_position = getattr(para_info, 'document_position', para_id)
                    if getattr(para_info, 'is_table_cell', False):
                        is_table = True
                        table_info = (
                            getattr(para_info, 'table_index', 0),
                            getattr(para_info, 'row_index', 0),
                            getattr(para_info, 'cell_index', 0)
                        )
                
                # Set type to "¶" so the preview renderer recognises
                # paragraph boundaries (it checks paragraph_id changes
                # only when type is "¶" or "para").
                seg_type = "¶"

                segment = ImportedSegment(
                    id=seg_id,
                    source=text,
                    target="",
                    status="untranslated",
                    notes="",
                    para_id=para_id,
                    is_table=is_table,
                    table_info=table_info,
                    style=style,
                    doc_position=doc_position,
                    type=seg_type,
                    okapi_tu_id=okapi_tu_id,
                    okapi_seg_idx=okapi_seg_idx
                )
                imported_segments.append(segment)
            
            # Convert ImportedSegment objects to proper Segment objects
            segments = []
            for imported_seg in imported_segments:
                segment = Segment(
                    id=imported_seg.id,
                    source=imported_seg.source,
                    target=imported_seg.target,
                    status=imported_seg.status,
                    notes=imported_seg.notes,
                    type=imported_seg.type,  # Use actual type from import
                    paragraph_id=imported_seg.para_id if imported_seg.para_id else 0,
                    style=imported_seg.style,  # CRITICAL: Preserve style for heading detection!
                    document_position=imported_seg.doc_position,
                    is_table_cell=imported_seg.is_table,
                    table_info=imported_seg.table_info,
                    okapi_tu_id=imported_seg.okapi_tu_id,
                    okapi_segment_index=imported_seg.okapi_seg_idx
                )
                segments.append(segment)
            
            # Create new project using the proper Project class
            # Use user-selected languages from import dialog
            source_lang = getattr(self, '_import_source_lang', None) or 'en'
            target_lang = getattr(self, '_import_target_lang', None) or 'nl'
            
            project = Project(
                name=f"DOCX Import - {os.path.basename(file_path)}",
                source_lang=source_lang,
                target_lang=target_lang,
                segments=segments
            )

            # Tag project as Okapi-imported – the export path branches on
            # this to call the sidecar's /merge endpoint.
            project.import_engine = "okapi"

            # If re-importing, restore the preserved project ID and settings
            if reimport_mode and preserved_project_id is not None:
                project.id = preserved_project_id
                if preserved_tm_settings:
                    project.tm_settings = preserved_tm_settings
                if preserved_termbase_settings:
                    project.termbase_settings = preserved_termbase_settings
                self.log(f"✓ Preserved project ID: {preserved_project_id}")

            # Reset project-type specific highlighting flags
            TagHighlighter._is_cafetran_project = False
            TagHighlighter._is_markdown_project = False

            # Set as current project and load into grid
            self.current_project = project
            self.current_document_path = file_path  # Store document path

            # CRITICAL: Update _original_segment_order for new import
            # This prevents old segments from being saved instead of new ones
            self._original_segment_order = self.current_project.segments.copy()

            progress.setLabelText(f"Loading {len(segments):,} segments into grid…")
            QApplication.processEvents()

            def _grid_progress(done, total):
                progress.setLabelText(
                    f"Loading segments into grid… ({done:,} / {total:,})"
                )
                QApplication.processEvents()

            self.load_segments_to_grid(progress_callback=_grid_progress)

            # Initialize TM for this project
            self.initialize_tm_database()

            # Clear translation caches to ensure fresh TM lookups
            # (important for re-imports where segments have changed)
            with self.translation_matches_cache_lock:
                self.translation_matches_cache.clear()
            self.termbase_cache.clear()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                # Ensure new project starts with clean slate (no TMs/glossaries selected)
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Update status
            para_count = len(set(s[0] for s in segmented))
            self.log(f"✓ Loaded {len(segments)} segments from {para_count} paragraphs")
            self.log(f"📍 Project language pair: {project.source_lang.upper()} → {project.target_lang.upper()}")
            self.update_window_title()  # Update window title to show project is loaded

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Auto-generate markdown if enabled
            if hasattr(self, 'auto_generate_markdown') and self.auto_generate_markdown:
                if hasattr(self, 'prompt_manager_qt'):
                    self.prompt_manager_qt.generate_markdown_for_current_document()

            # Refresh AI Assistant context
            if hasattr(self, 'prompt_manager_qt'):
                self.prompt_manager_qt.refresh_context()

            # Build the in-memory termbase index and pre-fill the match
            # cache the same way the saved-project load path does. Without
            # this, the first cell-select on a freshly-imported DOCX falls
            # through to a brute-force termbase scan – fine on a small
            # database but reproducibly produces multi-second freezes
            # (one user reported 20s on a 4 100-term database, all 40
            # termbases technically present even though deactivated for
            # the new project). The save/load path was already wired up
            # via _start_termbase_batch_worker() (line 25720); the fresh-
            # import path was missing the same call. v1.9.423.
            try:
                self._start_termbase_batch_worker()
            except Exception as _e:
                # Don't let a background-worker failure block the import
                # itself – the grid is already loaded and visible by now.
                self.log(f"⚠ Failed to start termbase batch worker after import: {_e}")

            # Prefetch TM/MT/LLM matches for the first 50 segments so
            # initial cell-clicks land on cached results instead of a
            # cold lookup. Mirrors the saved-project load path.
            try:
                if len(self.current_project.segments) > 0:
                    prefetch_ids = [seg.id for seg in self.current_project.segments[:50]]
                    self._start_prefetch_worker(prefetch_ids)
            except Exception as _e:
                self.log(f"⚠ Failed to start prefetch worker after import: {_e}")

            progress.close()

            QMessageBox.information(
                self,
                "Import Complete",
                f"Successfully imported {len(segments)} segments from:\n{os.path.basename(file_path)}\n\n"
                f"📍 Language pair: {source_lang.upper()} → {target_lang.upper()}"
            )

        except Exception as e:
            try:
                progress.close()
            except Exception:
                pass
            self.log(f"✗ Import failed: {str(e)}")
            QMessageBox.critical(
                self,
                "Import Error",
                f"Failed to import DOCX:\n\n{str(e)}"
            )
    
    # ========================================================================
    # SIMPLE TEXT FILE IMPORT/EXPORT
    # ========================================================================
    
    def import_simple_txt(self):
        """
        Import a simple text file or Markdown file where each line is a source segment.
        
        This is the simplest possible import format:
        - Each line becomes one source segment
        - Empty lines are preserved as empty segments (for structure)
        - After translation, export produces a matching file with translations
        - Markdown files are imported as-is (syntax preserved for round-trip)
        """
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Text or Markdown File",
            "",
            "Text Files (*.txt *.md);;Markdown (*.md);;Plain Text (*.txt);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        # Show import options dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("Import Text / Markdown File")
        dialog.setMinimumWidth(500)
        
        layout = QVBoxLayout(dialog)
        
        # Detect if it's a Markdown file
        is_markdown = file_path.lower().endswith('.md')
        
        # Info message
        if is_markdown:
            info_label = QLabel(
                "📝 <b>Markdown File Import</b><br><br>"
                "Each line in the file will become one source segment.<br>"
                "Markdown syntax (<b>**bold**</b>, <i>*italic*</i>, # headings, etc.) is preserved.<br>"
                "After translation, export will produce a matching Markdown file.<br><br>"
                "<b>Tip:</b> Keep Markdown syntax intact when translating!"
            )
        else:
            info_label = QLabel(
                "📄 <b>Simple Text File Import</b><br><br>"
                "Each line in the file will become one source segment.<br>"
                "After translation, you can export a matching file with translations.<br><br>"
                "Empty lines are preserved to maintain document structure."
            )
        info_label.setWordWrap(True)
        info_label.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info_label)
        
        layout.addSpacing(15)
        
        # Language pair selection
        lang_group = QGroupBox("Language Pair")
        lang_layout = QHBoxLayout(lang_group)
        
        # Common languages for translation
        languages = [
            ("English", "en"),
            ("Dutch", "nl"),
            ("German", "de"),
            ("French", "fr"),
            ("Spanish", "es"),
            ("Italian", "it"),
            ("Portuguese", "pt"),
            ("Polish", "pl"),
            ("Russian", "ru"),
            ("Chinese", "zh"),
            ("Japanese", "ja"),
            ("Korean", "ko"),
        ]
        
        source_label = QLabel("Source:")
        source_combo = QComboBox()
        for name, code in languages:
            source_combo.addItem(name, code)

        # Load last used languages from settings, or use current project, or default to English
        general_settings = self.load_general_settings()
        last_source = general_settings.get('last_import_source_lang',
                                          self.current_project.source_lang if self.current_project else 'en')
        last_target = general_settings.get('last_import_target_lang',
                                          self.current_project.target_lang if self.current_project else 'nl')

        # Set source combo to last used language
        source_index = 0  # Default to English
        for i in range(source_combo.count()):
            if source_combo.itemData(i) == last_source:
                source_index = i
                break
        source_combo.setCurrentIndex(source_index)

        arrow_label = QLabel(" → ")
        arrow_label.setStyleSheet("font-weight: bold; font-size: 14px;")

        target_label = QLabel("Target:")
        target_combo = QComboBox()
        for name, code in languages:
            target_combo.addItem(name, code)

        # Set target combo to last used language
        target_index = 1  # Default to Dutch
        for i in range(target_combo.count()):
            if target_combo.itemData(i) == last_target:
                target_index = i
                break
        target_combo.setCurrentIndex(target_index)

        lang_layout.addWidget(source_label)
        lang_layout.addWidget(source_combo)
        lang_layout.addWidget(arrow_label)
        lang_layout.addWidget(target_label)
        lang_layout.addWidget(target_combo)
        lang_layout.addStretch()

        layout.addWidget(lang_group)

        layout.addSpacing(10)

        # Sentence segmentation option
        last_segment_setting = general_settings.get('last_import_sentence_segment', False)
        segment_checkbox = CheckmarkCheckBox("Split lines into sentences")
        segment_checkbox.setChecked(last_segment_setting)
        segment_checkbox.setToolTip(
            "When checked, long lines will be split into individual sentences\n"
            "for easier translation. Sentences are automatically rejoined on export."
        )
        layout.addWidget(segment_checkbox)

        # Markdown caveat (shown/hidden based on checkbox state)
        if is_markdown:
            md_note = QLabel(
                "<i>Markdown syntax (links, code spans, URLs) is protected during splitting.\n"
                "Complex formatting may still need review after export.</i>"
            )
            md_note.setStyleSheet("color: #FF9800; font-size: 11px; margin-left: 22px;")
            md_note.setWordWrap(True)
            md_note.setVisible(segment_checkbox.isChecked())
            segment_checkbox.toggled.connect(md_note.setVisible)
            layout.addWidget(md_note)

        layout.addSpacing(20)

        # Buttons
        button_layout = QHBoxLayout()
        ok_btn = QPushButton("Import")
        ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)
        
        # Show dialog
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        # Check if this is a re-import of the same file
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.original_txt_path', 'current_document_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        # Store selected options
        source_lang = source_combo.currentData()
        target_lang = target_combo.currentData()
        use_sentence_segmentation = segment_checkbox.isChecked()

        # Save selected settings for next time
        general_settings = self.load_general_settings()
        general_settings['last_import_source_lang'] = source_lang
        general_settings['last_import_target_lang'] = target_lang
        general_settings['last_import_sentence_segment'] = use_sentence_segmentation
        self.save_general_settings(general_settings)

        try:
            self.log(f"📄 Importing simple text file: {os.path.basename(file_path)}")
            
            # Read the file
            with open(file_path, 'r', encoding='utf-8') as f:
                lines = f.readlines()
            
            # Create segments from lines
            segments = []
            if use_sentence_segmentation:
                if is_markdown:
                    from modules.simple_segmenter import MarkdownSegmenter
                    segmenter = MarkdownSegmenter()
                else:
                    from modules.simple_segmenter import SimpleSegmenter
                    segmenter = SimpleSegmenter()
                for line_num, line in enumerate(lines, 1):
                    text = line.rstrip('\r\n')
                    if not text.strip():
                        # Empty line → empty segment (hidden in grid, preserved for export)
                        segments.append(Segment(
                            id=len(segments) + 1, source=text, target="",
                            status="untranslated", notes="", type="para",
                            paragraph_id=line_num, document_position=line_num
                        ))
                    else:
                        sentences = segmenter.segment_text(text)
                        for sentence in sentences:
                            segments.append(Segment(
                                id=len(segments) + 1, source=sentence, target="",
                                status="untranslated", notes="", type="para",
                                paragraph_id=line_num, document_position=line_num
                            ))
            else:
                for line_num, line in enumerate(lines, 1):
                    text = line.rstrip('\r\n')
                    segments.append(Segment(
                        id=len(segments) + 1, source=text, target="",
                        status="untranslated", notes="", type="para",
                        paragraph_id=line_num, document_position=line_num
                    ))
            
            if not segments:
                QMessageBox.warning(
                    self, "Empty File",
                    "The file contains no text to import."
                )
                return
            
            # Create new project
            project = Project(
                name=f"{'MD' if is_markdown else 'TXT'} Import - {os.path.basename(file_path)}",
                source_lang=source_lang,
                target_lang=target_lang,
                segments=segments
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store original file path for export
            project.original_txt_path = file_path

            # Reset project-type specific highlighting flags
            TagHighlighter._is_cafetran_project = False
            TagHighlighter._is_markdown_project = is_markdown

            # Set as current project and load into grid
            self.current_project = project
            self.current_document_path = file_path

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            self.load_segments_to_grid()

            # Initialize TM for this project
            self.initialize_tm_database()

            # Clear caches to ensure fresh TM lookups
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Update status
            empty_count = sum(1 for seg in segments if not seg.source.strip())
            file_type = "Markdown file" if is_markdown else "text file"
            line_count = len(lines)
            self.log(f"✓ Loaded {len(segments)} segments from {line_count} lines ({file_type})")
            if use_sentence_segmentation:
                self.log(f"  ✂️ Sentence segmentation applied")
            if is_markdown:
                self.log(f"  📝 Markdown syntax highlighting enabled")
            if empty_count > 0:
                self.log(f"  ({empty_count} empty lines preserved for structure)")
            self.log(f"📍 Project language pair: {source_lang.upper()} → {target_lang.upper()}")
            self.update_window_title()
            
            # Refresh AI Assistant context
            if hasattr(self, 'prompt_manager_qt'):
                self.prompt_manager_qt.refresh_context()
            
            QMessageBox.information(
                self,
                "Import Complete",
                f"Successfully imported {len(segments)} lines from:\n{os.path.basename(file_path)}\n\n"
                f"📍 Language pair: {source_lang.upper()} → {target_lang.upper()}"
            )
            
        except UnicodeDecodeError:
            # Try with different encodings
            self.log("⚠️ UTF-8 failed, trying other encodings...")
            for encoding in ['latin-1', 'cp1252', 'iso-8859-1']:
                try:
                    with open(file_path, 'r', encoding=encoding) as f:
                        lines = f.readlines()
                    self.log(f"✓ Successfully read file with {encoding} encoding")
                    # Re-run import with this encoding
                    segments = []
                    if use_sentence_segmentation:
                        if is_markdown:
                            from modules.simple_segmenter import MarkdownSegmenter
                            segmenter = MarkdownSegmenter()
                        else:
                            from modules.simple_segmenter import SimpleSegmenter
                            segmenter = SimpleSegmenter()
                        for line_num, line in enumerate(lines, 1):
                            text = line.rstrip('\r\n')
                            if not text.strip():
                                segments.append(Segment(
                                    id=len(segments) + 1, source=text, target="",
                                    status="untranslated", notes="", type="para",
                                    paragraph_id=line_num, document_position=line_num
                                ))
                            else:
                                for sentence in segmenter.segment_text(text):
                                    segments.append(Segment(
                                        id=len(segments) + 1, source=sentence, target="",
                                        status="untranslated", notes="", type="para",
                                        paragraph_id=line_num, document_position=line_num
                                    ))
                    else:
                        for line_num, line in enumerate(lines, 1):
                            text = line.rstrip('\r\n')
                            segments.append(Segment(
                                id=len(segments) + 1, source=text, target="",
                                status="untranslated", notes="", type="para",
                                paragraph_id=line_num, document_position=line_num
                            ))
                    
                    if segments:
                        project = Project(
                            name=f"TXT Import - {os.path.basename(file_path)}",
                            source_lang=source_lang,
                            target_lang=target_lang,
                            segments=segments
                        )
                        project.original_txt_path = file_path
                        self.current_project = project
                        self.current_document_path = file_path
                        # CRITICAL: Update _original_segment_order for new import
                        self._original_segment_order = self.current_project.segments.copy()
                        self.load_segments_to_grid()
                        self.initialize_tm_database()
                        self.log(f"✓ Loaded {len(segments)} lines from text file")
                        self.update_window_title()
                        if hasattr(self, 'prompt_manager_qt'):
                            self.prompt_manager_qt.refresh_context()
                        QMessageBox.information(
                            self,
                            "Import Complete",
                            f"Successfully imported {len(segments)} lines from:\n{os.path.basename(file_path)}\n\n"
                            f"📍 Language pair: {source_lang.upper()} → {target_lang.upper()}\n"
                            f"📝 Encoding: {encoding}"
                        )
                        return
                except:
                    continue
            
            QMessageBox.critical(
                self,
                "Encoding Error",
                "Could not read the text file.\n\n"
                "The file may use an unsupported character encoding.\n"
                "Try saving the file as UTF-8 and importing again."
            )
            
        except Exception as e:
            self.log(f"✗ Import failed: {str(e)}")
            QMessageBox.critical(
                self,
                "Import Error",
                f"Failed to import text file:\n\n{str(e)}"
            )
    
    def export_simple_txt(self):
        """
        Export translations as a simple text file, one line per segment.
        
        The output file will have the exact same structure as the input:
        - Each segment becomes one line
        - Empty segments become empty lines
        - Target text is used (or source if no translation)
        """
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project with segments first")
                return
            
            segments = list(self.current_project.segments)
            
            # Check translation status
            translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
            total_count = len(segments)
            
            if translated_count == 0:
                QMessageBox.warning(
                    self, "No Translations",
                    "No translated segments found.\n\n"
                    "Please translate some segments before exporting."
                )
                return
            
            # Show export options dialog
            dialog = QDialog(self)
            dialog.setWindowTitle("Export Simple Text File")
            dialog.setMinimumWidth(450)
            
            layout = QVBoxLayout(dialog)
            
            # Status info
            if translated_count < total_count:
                status_label = QLabel(
                    f"⚠️ <b>{translated_count} of {total_count}</b> segments are translated."
                )
                status_label.setTextFormat(Qt.TextFormat.RichText)
                status_label.setStyleSheet("color: #FF9800;")
            else:
                status_label = QLabel(
                    f"✅ All <b>{total_count}</b> segments are translated."
                )
                status_label.setTextFormat(Qt.TextFormat.RichText)
                status_label.setStyleSheet("color: #4CAF50;")
            layout.addWidget(status_label)
            
            layout.addSpacing(10)
            
            # Options group
            options_group = QGroupBox("Untranslated Segments")
            options_layout = QVBoxLayout(options_group)
            
            # Radio buttons for handling untranslated
            use_source_radio = CheckmarkRadioButton("Use source text for untranslated segments")
            use_source_radio.setChecked(True)
            use_empty_radio = CheckmarkRadioButton("Leave untranslated segments empty")
            
            options_layout.addWidget(use_source_radio)
            options_layout.addWidget(use_empty_radio)
            layout.addWidget(options_group)
            
            layout.addSpacing(10)
            
            # Encoding option
            encoding_group = QGroupBox("File Encoding")
            encoding_layout = QHBoxLayout(encoding_group)
            
            encoding_combo = QComboBox()
            encoding_combo.addItem("UTF-8 (recommended)", "utf-8")
            encoding_combo.addItem("UTF-8 with BOM", "utf-8-sig")
            encoding_combo.addItem("Latin-1 (ISO-8859-1)", "latin-1")
            encoding_combo.addItem("Windows-1252", "cp1252")
            encoding_layout.addWidget(encoding_combo)
            encoding_layout.addStretch()
            layout.addWidget(encoding_group)
            
            layout.addSpacing(20)
            
            # Buttons
            button_layout = QHBoxLayout()
            ok_btn = QPushButton("Export")
            ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
            ok_btn.clicked.connect(dialog.accept)
            cancel_btn = QPushButton("Cancel")
            cancel_btn.clicked.connect(dialog.reject)
            
            button_layout.addStretch()
            button_layout.addWidget(cancel_btn)
            button_layout.addWidget(ok_btn)
            layout.addLayout(button_layout)
            
            if dialog.exec() != QDialog.DialogCode.Accepted:
                return
            
            # Get options
            use_source_fallback = use_source_radio.isChecked()
            encoding = encoding_combo.currentData()
            
            # Get save path - detect if source was a markdown file
            is_markdown = (hasattr(self.current_project, 'original_txt_path')
                          and self.current_project.original_txt_path
                          and self.current_project.original_txt_path.lower().endswith('.md'))

            default_name = ""
            default_ext = ".md" if is_markdown else ".txt"
            if hasattr(self.current_project, 'original_txt_path') and self.current_project.original_txt_path:
                base = os.path.splitext(os.path.basename(self.current_project.original_txt_path))[0]
                default_name = f"{base}_translated{default_ext}"
            elif self.current_project.name:
                default_name = self.current_project.name.replace(" ", "_") + f"_translated{default_ext}"

            if is_markdown:
                file_filter = "Markdown Files (*.md);;Text Files (*.txt);;All Files (*.*)"
            else:
                file_filter = "Text Files (*.txt);;Markdown Files (*.md);;All Files (*.*)"

            file_path, _ = QFileDialog.getSaveFileName(
                self,
                "Export Text / Markdown File",
                default_name,
                file_filter
            )

            if not file_path:
                return

            # Ensure valid extension
            if not file_path.lower().endswith(('.txt', '.md')):
                file_path += default_ext
            
            # Build output lines, grouping segments by paragraph_id
            # (when sentence segmentation was used, multiple segments share a paragraph_id)
            from itertools import groupby
            output_lines = []
            for _para_id, group in groupby(segments, key=lambda s: s.paragraph_id):
                parts = []
                for seg in group:
                    if seg.target and seg.target.strip():
                        parts.append(seg.target)
                    elif use_source_fallback:
                        parts.append(seg.source)
                    else:
                        parts.append("")
                # Join sentence segments with space; empty segments produce empty lines
                if any(p.strip() for p in parts):
                    line = ' '.join(p for p in parts if p.strip())
                else:
                    line = ""
                output_lines.append(line)

            # Write file
            with open(file_path, 'w', encoding=encoding, newline='\n') as f:
                for line in output_lines:
                    f.write(line + '\n')
            
            self.log(f"✓ Exported {len(output_lines)} lines ({len(segments)} segments) to: {os.path.basename(file_path)}")
            self.log(f"  Encoding: {encoding}")

            QMessageBox.information(
                self,
                "Export Complete",
                f"Successfully exported {len(output_lines)} lines to:\n{os.path.basename(file_path)}\n\n"
                f"Translated: {translated_count} / {total_count} segments"
            )
            
        except Exception as e:
            self.log(f"✗ Export failed: {str(e)}")
            QMessageBox.critical(
                self,
                "Export Error",
                f"Failed to export text file:\n\n{str(e)}"
            )
    
    def export_for_ai(self):
        """
        Export segments in AI-readable [SEGMENT] format.
        
        Output format:
            [SEGMENT 0001]
            NL: Source text here
            EN: Target text here (or empty if not translated)
            
            [SEGMENT 0002]
            ...
        
        This format is designed for:
        - AI translation services (ChatGPT, Claude, etc.)
        - AI-assisted review and editing
        - Easy parsing and re-import
        """
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project with segments first")
                return
            
            segments = list(self.current_project.segments)
            
            # Get language codes
            source_lang = self.current_project.source_lang or "Source"
            target_lang = self.current_project.target_lang or "Target"
            
            # Convert to short codes (uppercase) for the format
            source_code = self._convert_language_to_code(source_lang).upper() if source_lang else "SRC"
            target_code = self._convert_language_to_code(target_lang).upper() if target_lang else "TGT"
            
            # Check translation status
            translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
            total_count = len(segments)
            
            # Show export options dialog
            dialog = QDialog(self)
            dialog.setWindowTitle("Export for AI")
            dialog.setMinimumWidth(500)
            
            layout = QVBoxLayout(dialog)
            
            # Info label
            info_label = QLabel(
                "Export segments in a format optimized for AI systems.\n"
                "Each segment is numbered and labeled with language codes."
            )
            info_label.setWordWrap(True)
            layout.addWidget(info_label)
            
            layout.addSpacing(10)
            
            # Status info
            if translated_count > 0:
                status_label = QLabel(
                    f"📊 <b>{translated_count} of {total_count}</b> segments have translations."
                )
            else:
                status_label = QLabel(
                    f"📊 <b>{total_count}</b> segments (none translated yet)."
                )
            status_label.setTextFormat(Qt.TextFormat.RichText)
            layout.addWidget(status_label)
            
            layout.addSpacing(10)
            
            # Language codes group
            lang_group = QGroupBox("Language Codes")
            lang_layout = QGridLayout(lang_group)
            
            lang_layout.addWidget(QLabel("Source:"), 0, 0)
            source_code_edit = QLineEdit(source_code)
            source_code_edit.setMaximumWidth(80)
            source_code_edit.setToolTip("Language code for source text (e.g., NL, EN, DE)")
            lang_layout.addWidget(source_code_edit, 0, 1)
            
            lang_layout.addWidget(QLabel("Target:"), 0, 2)
            target_code_edit = QLineEdit(target_code)
            target_code_edit.setMaximumWidth(80)
            target_code_edit.setToolTip("Language code for target text (e.g., EN, NL, FR)")
            lang_layout.addWidget(target_code_edit, 0, 3)
            
            lang_layout.setColumnStretch(4, 1)  # Push controls left
            layout.addWidget(lang_group)
            
            # Numbering options
            numbering_group = QGroupBox("Segment Numbering")
            numbering_layout = QGridLayout(numbering_group)
            
            numbering_layout.addWidget(QLabel("Start at:"), 0, 0)
            start_spin = QSpinBox()
            start_spin.setRange(1, 99999)
            start_spin.setValue(1)
            start_spin.setMaximumWidth(80)
            numbering_layout.addWidget(start_spin, 0, 1)
            
            numbering_layout.addWidget(QLabel("Zero padding:"), 0, 2)
            padding_spin = QSpinBox()
            padding_spin.setRange(1, 8)
            padding_spin.setValue(4)
            padding_spin.setMaximumWidth(60)
            padding_spin.setToolTip("Number of digits (4 = 0001, 0002...)")
            numbering_layout.addWidget(padding_spin, 0, 3)
            
            numbering_layout.setColumnStretch(4, 1)
            layout.addWidget(numbering_group)
            
            # Content options
            content_group = QGroupBox("Content Options")
            content_layout = QVBoxLayout(content_group)
            
            include_both_radio = CheckmarkRadioButton("Include both source and target (bilingual)")
            include_both_radio.setChecked(True)
            source_only_radio = CheckmarkRadioButton("Source only (for AI translation)")
            target_only_radio = CheckmarkRadioButton("Target only (translated segments)")
            
            content_layout.addWidget(include_both_radio)
            content_layout.addWidget(source_only_radio)
            content_layout.addWidget(target_only_radio)
            layout.addWidget(content_group)
            
            # Filter options
            filter_group = QGroupBox("Segment Filter")
            filter_layout = QVBoxLayout(filter_group)
            
            all_segments_radio = CheckmarkRadioButton("All segments")
            all_segments_radio.setChecked(True)
            untranslated_radio = CheckmarkRadioButton("Untranslated segments only (empty target)")
            translated_radio = CheckmarkRadioButton("Translated segments only (has target)")
            
            filter_layout.addWidget(all_segments_radio)
            filter_layout.addWidget(untranslated_radio)
            filter_layout.addWidget(translated_radio)
            layout.addWidget(filter_group)
            
            layout.addSpacing(10)
            
            # Preview
            preview_group = QGroupBox("Preview")
            preview_layout = QVBoxLayout(preview_group)
            preview_text = QTextEdit()
            preview_text.setReadOnly(True)
            preview_text.setMaximumHeight(120)
            preview_text.setStyleSheet("font-family: Consolas, monospace; font-size: 11px;")
            preview_layout.addWidget(preview_text)
            layout.addWidget(preview_group)
            
            def update_preview():
                src_code = source_code_edit.text().strip() or "SRC"
                tgt_code = target_code_edit.text().strip() or "TGT"
                start = start_spin.value()
                pad = padding_spin.value()
                
                # Use generic example text (not from current segment)
                sample_source = "Source text goes here..."
                sample_target = "Target text goes here..."
                
                if include_both_radio.isChecked():
                    preview = f"[SEGMENT {start:0{pad}d}]\n{src_code}: {sample_source}\n{tgt_code}: {sample_target}\n"
                elif source_only_radio.isChecked():
                    preview = f"[SEGMENT {start:0{pad}d}]\n{src_code}: {sample_source}\n"
                else:
                    preview = f"[SEGMENT {start:0{pad}d}]\n{tgt_code}: {sample_target}\n"
                
                preview_text.setPlainText(preview)
            
            # Connect signals for live preview
            source_code_edit.textChanged.connect(update_preview)
            target_code_edit.textChanged.connect(update_preview)
            start_spin.valueChanged.connect(update_preview)
            padding_spin.valueChanged.connect(update_preview)
            include_both_radio.toggled.connect(update_preview)
            source_only_radio.toggled.connect(update_preview)
            target_only_radio.toggled.connect(update_preview)
            
            update_preview()  # Initial preview
            
            layout.addSpacing(10)
            
            # Buttons
            button_layout = QHBoxLayout()
            ok_btn = QPushButton("Export")
            ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
            ok_btn.clicked.connect(dialog.accept)
            cancel_btn = QPushButton("Cancel")
            cancel_btn.clicked.connect(dialog.reject)
            
            button_layout.addStretch()
            button_layout.addWidget(cancel_btn)
            button_layout.addWidget(ok_btn)
            layout.addLayout(button_layout)
            
            if dialog.exec() != QDialog.DialogCode.Accepted:
                return
            
            # Get options
            src_code = source_code_edit.text().strip() or "SRC"
            tgt_code = target_code_edit.text().strip() or "TGT"
            start_at = start_spin.value()
            padding = padding_spin.value()
            
            # Determine content mode
            if source_only_radio.isChecked():
                content_mode = "source_only"
            elif target_only_radio.isChecked():
                content_mode = "target_only"
            else:
                content_mode = "bilingual"
            
            # Filter segments
            if untranslated_radio.isChecked():
                filtered_segments = [s for s in segments if not (s.target and s.target.strip())]
            elif translated_radio.isChecked():
                filtered_segments = [s for s in segments if s.target and s.target.strip()]
            else:
                filtered_segments = segments
            
            if not filtered_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments match the selected filter criteria."
                )
                return
            
            # Get save path
            default_name = ""
            if self.current_project.name:
                default_name = self.current_project.name.replace(" ", "_") + "_ai_format.txt"
            
            file_path, _ = QFileDialog.getSaveFileName(
                self,
                "Export for AI",
                default_name,
                "Text Files (*.txt);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            # Ensure .txt extension
            if not file_path.lower().endswith('.txt'):
                file_path += '.txt'
            
            # Build output
            output_lines = []
            segment_num = start_at
            
            for seg in filtered_segments:
                output_lines.append(f"[SEGMENT {segment_num:0{padding}d}]")
                
                if content_mode == "bilingual":
                    output_lines.append(f"{src_code}: {seg.source}")
                    output_lines.append(f"{tgt_code}: {seg.target or ''}")
                elif content_mode == "source_only":
                    output_lines.append(f"{src_code}: {seg.source}")
                else:  # target_only
                    output_lines.append(f"{tgt_code}: {seg.target or ''}")
                
                output_lines.append("")  # Blank line between segments
                segment_num += 1
            
            # Write file
            with open(file_path, 'w', encoding='utf-8', newline='\n') as f:
                f.write('\n'.join(output_lines))
            
            self.log(f"✓ Exported {len(filtered_segments)} segments in AI format to: {os.path.basename(file_path)}")
            
            QMessageBox.information(
                self,
                "Export Complete",
                f"Successfully exported {len(filtered_segments)} segments to:\n{os.path.basename(file_path)}\n\n"
                f"Format: [SEGMENT XXXX] with {src_code}/{tgt_code} labels"
            )
            
        except Exception as e:
            self.log(f"✗ Export for AI failed: {str(e)}")
            QMessageBox.critical(
                self,
                "Export Error",
                f"Failed to export AI format:\n\n{str(e)}"
            )
    
    def export_bilingual_table_markdown(self):
        """Export current project segments as Markdown table (for AI systems)"""
        try:
            if not self.current_project or not self.current_project.segments:
                QMessageBox.warning(self, "No Project", "Please open a project with segments first")
                return
            
            segments = list(self.current_project.segments)
            
            # Get language names
            source_lang = self.current_project.source_lang or "Source"
            target_lang = self.current_project.target_lang or "Target"
            
            # Check translation status
            translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
            total_count = len(segments)
            
            # Show export options dialog
            dialog = QDialog(self)
            dialog.setWindowTitle("Export as Markdown Table")
            dialog.setMinimumWidth(500)
            
            layout = QVBoxLayout(dialog)
            
            # Info label
            info_label = QLabel(
                "Export segments as a <b>Markdown table</b> optimized for AI systems.\n"
                "The table format renders beautifully in ChatGPT, Claude, and all AI chat interfaces."
            )
            info_label.setWordWrap(True)
            info_label.setTextFormat(Qt.TextFormat.RichText)
            layout.addWidget(info_label)
            
            layout.addSpacing(10)
            
            # Status info
            status_label = QLabel(
                f"📊 <b>{translated_count} of {total_count}</b> segments have translations."
                if translated_count > 0
                else f"📊 <b>{total_count}</b> segments (none translated yet)."
            )
            status_label.setTextFormat(Qt.TextFormat.RichText)
            layout.addWidget(status_label)
            
            layout.addSpacing(10)
            
            # Content options
            content_group = QGroupBox("Content Options")
            content_layout = QVBoxLayout(content_group)
            
            include_both_radio = CheckmarkRadioButton(f"Include both {source_lang} and {target_lang} (bilingual table)")
            include_both_radio.setChecked(True)
            source_only_radio = CheckmarkRadioButton(f"{source_lang} only (for AI translation)")
            target_only_radio = CheckmarkRadioButton(f"{target_lang} only (translated segments)")
            
            content_layout.addWidget(include_both_radio)
            content_layout.addWidget(source_only_radio)
            content_layout.addWidget(target_only_radio)
            layout.addWidget(content_group)
            
            # Filter options
            filter_group = QGroupBox("Segment Filter")
            filter_layout = QVBoxLayout(filter_group)
            
            all_segments_radio = CheckmarkRadioButton("All segments")
            all_segments_radio.setChecked(True)
            untranslated_radio = CheckmarkRadioButton("Untranslated segments only (empty target)")
            translated_radio = CheckmarkRadioButton("Translated segments only (has target)")
            
            filter_layout.addWidget(all_segments_radio)
            filter_layout.addWidget(untranslated_radio)
            filter_layout.addWidget(translated_radio)
            layout.addWidget(filter_group)
            
            layout.addSpacing(10)
            
            # Preview
            preview_group = QGroupBox("Preview")
            preview_layout = QVBoxLayout(preview_group)
            preview_text = QTextEdit()
            preview_text.setReadOnly(True)
            preview_text.setMaximumHeight(150)
            preview_text.setStyleSheet("font-family: Consolas, monospace; font-size: 10px;")
            preview_layout.addWidget(preview_text)
            layout.addWidget(preview_group)
            
            def update_preview():
                # Show preview with sample data
                if include_both_radio.isChecked():
                    preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
                    preview += f"| Segment | {source_lang} | {target_lang} |\n"
                    preview += "|---------|" + "-" * (len(source_lang) + 2) + "|" + "-" * (len(target_lang) + 2) + "|\n"
                    preview += "| 1 | Source text example... | Target text example... |\n"
                    preview += "| 2 | Another segment... | Translation here... |"
                elif source_only_radio.isChecked():
                    preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
                    preview += f"| Segment | {source_lang} |\n"
                    preview += "|---------|" + "-" * (len(source_lang) + 2) + "|\n"
                    preview += "| 1 | Source text example... |\n"
                    preview += "| 2 | Another segment... |"
                else:
                    preview = f"# Translation Project: {self.current_project.name or 'Untitled'}\n\n"
                    preview += f"| Segment | {target_lang} |\n"
                    preview += "|---------|" + "-" * (len(target_lang) + 2) + "|\n"
                    preview += "| 1 | Target text example... |\n"
                    preview += "| 2 | Translation here... |"
                
                preview_text.setPlainText(preview)
            
            # Connect signals for live preview
            include_both_radio.toggled.connect(update_preview)
            source_only_radio.toggled.connect(update_preview)
            target_only_radio.toggled.connect(update_preview)
            
            update_preview()  # Initial preview
            
            layout.addSpacing(10)
            
            # Buttons
            button_layout = QHBoxLayout()
            ok_btn = QPushButton("Export")
            ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; border: none; outline: none;")
            ok_btn.clicked.connect(dialog.accept)
            cancel_btn = QPushButton("Cancel")
            cancel_btn.clicked.connect(dialog.reject)
            
            button_layout.addStretch()
            button_layout.addWidget(cancel_btn)
            button_layout.addWidget(ok_btn)
            layout.addLayout(button_layout)
            
            if dialog.exec() != QDialog.DialogCode.Accepted:
                return
            
            # Determine content mode
            if source_only_radio.isChecked():
                content_mode = "source_only"
            elif target_only_radio.isChecked():
                content_mode = "target_only"
            else:
                content_mode = "bilingual"
            
            # Filter segments
            if untranslated_radio.isChecked():
                filtered_segments = [s for s in segments if not (s.target and s.target.strip())]
            elif translated_radio.isChecked():
                filtered_segments = [s for s in segments if s.target and s.target.strip()]
            else:
                filtered_segments = segments
            
            if not filtered_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments match the selected filter criteria."
                )
                return
            
            # Get save path
            default_name = ""
            if self.current_project.name:
                default_name = self.current_project.name.replace(" ", "_") + "_table.md"
            
            file_path, _ = QFileDialog.getSaveFileName(
                self,
                "Export Markdown Table",
                default_name,
                "Markdown Files (*.md);;All Files (*.*)"
            )
            
            if not file_path:
                return
            
            # Ensure .md extension
            if not file_path.lower().endswith('.md'):
                file_path += '.md'
            
            # Build Markdown table
            output_lines = []
            output_lines.append(f"# Translation Project: {self.current_project.name or 'Untitled'}")
            output_lines.append("")
            output_lines.append(f"**Languages:** {source_lang} → {target_lang}")
            output_lines.append(f"**Segments:** {len(filtered_segments)}")
            output_lines.append(f"**Exported:** {datetime.now().strftime('%Y-%m-%d %H:%M')}")
            output_lines.append("")
            
            # Table header
            if content_mode == "bilingual":
                output_lines.append(f"| Segment | {source_lang} | {target_lang} |")
                output_lines.append("|---------|" + "-" * (len(source_lang) + 2) + "|" + "-" * (len(target_lang) + 2) + "|")
            elif content_mode == "source_only":
                output_lines.append(f"| Segment | {source_lang} |")
                output_lines.append("|---------|" + "-" * (len(source_lang) + 2) + "|")
            else:  # target_only
                output_lines.append(f"| Segment | {target_lang} |")
                output_lines.append("|---------|" + "-" * (len(target_lang) + 2) + "|")
            
            # Table rows
            for i, seg in enumerate(filtered_segments, 1):
                # Escape pipe characters in text for Markdown tables
                source_text = seg.source.replace("|", "\\|").replace("\n", " ")
                target_text = (seg.target or "").replace("|", "\\|").replace("\n", " ")
                
                if content_mode == "bilingual":
                    output_lines.append(f"| {i} | {source_text} | {target_text} |")
                elif content_mode == "source_only":
                    output_lines.append(f"| {i} | {source_text} |")
                else:  # target_only
                    output_lines.append(f"| {i} | {target_text} |")
            
            # Write file
            with open(file_path, 'w', encoding='utf-8', newline='\n') as f:
                f.write('\n'.join(output_lines))
            
            self.log(f"✓ Exported {len(filtered_segments)} segments as Markdown table to: {os.path.basename(file_path)}")
            
            QMessageBox.information(
                self,
                "Export Complete",
                f"Successfully exported {len(filtered_segments)} segments as Markdown table to:\n\n{os.path.basename(file_path)}\n\n"
                f"This format renders beautifully in ChatGPT, Claude, and other AI chat interfaces!"
            )
            
        except Exception as e:
            self.log(f"✗ Markdown table export failed: {str(e)}")
            QMessageBox.critical(
                self,
                "Export Error",
                f"Failed to export Markdown table:\n\n{str(e)}"
            )
    
    # ========================================================================
    # MULTI-FILE FOLDER IMPORT
    # ========================================================================
    
    def import_folder_multifile(self):
        """
        Import multiple files from a folder as a single multi-file project.
        
        Supports:
        - DOCX files (monolingual)
        - TXT files (simple text, one segment per line)
        - memoQ bilingual DOCX (if detected)
        
        All project resources (TM, termbases, prompts) apply to all files.
        """
        from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
                                      QGroupBox, QComboBox, QPushButton, QScrollArea,
                                      QFileDialog)
        
        # Select folder
        folder_path = QFileDialog.getExistingDirectory(
            self,
            "Select Folder with Files to Import",
            "",
            QFileDialog.Option.ShowDirsOnly
        )
        
        if not folder_path:
            return
        
        # Scan folder for supported files
        supported_extensions = {'.docx': 'docx', '.txt': 'txt', '.md': 'md'}
        found_files = []

        for filename in sorted(os.listdir(folder_path)):
            ext = os.path.splitext(filename)[1].lower()
            if ext in supported_extensions:
                full_path = os.path.join(folder_path, filename)
                found_files.append({
                    'name': filename,
                    'path': full_path,
                    'type': supported_extensions[ext],
                    'size': os.path.getsize(full_path)
                })

        if not found_files:
            QMessageBox.warning(
                self,
                "No Files Found",
                f"No supported files found in folder:\n{folder_path}\n\n"
                f"Supported formats: DOCX, TXT, MD (Markdown)"
            )
            return
        
        # Show import dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("📁 Import Folder - Multi-File Project")
        dialog.setMinimumWidth(600)
        dialog.setMinimumHeight(500)
        
        layout = QVBoxLayout(dialog)
        
        # Info
        info_label = QLabel(
            f"<b>📁 Multi-File Project Import</b><br><br>"
            f"Found <b>{len(found_files)}</b> files in: <i>{os.path.basename(folder_path)}</i><br><br>"
            f"All files will be combined into a single project.<br>"
            f"Project resources (TM, termbases, prompts) will apply to all files.<br>"
            f"You can track progress per file via File \u2192 Project Info."
        )
        info_label.setWordWrap(True)
        info_label.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info_label)
        
        layout.addSpacing(10)
        
        # File list with checkboxes
        files_group = QGroupBox(f"Files to Import ({len(found_files)} files)")
        files_layout = QVBoxLayout(files_group)
        
        # Create a scroll area for the checkboxes
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setMinimumHeight(200)
        scroll_area.setMaximumHeight(300)
        scroll_widget = QWidget()
        scroll_layout = QVBoxLayout(scroll_widget)
        scroll_layout.setContentsMargins(5, 5, 5, 5)
        scroll_layout.setSpacing(2)
        
        file_checkboxes = []
        for f in found_files:
            size_kb = f['size'] / 1024
            checkbox = CheckmarkCheckBox(f"{f['name']} ({f['type'].upper()}, {size_kb:.1f} KB)")
            checkbox.setChecked(True)  # Select all by default
            checkbox.setProperty("file_data", f)  # Store file data on checkbox
            file_checkboxes.append(checkbox)
            scroll_layout.addWidget(checkbox)
        
        scroll_layout.addStretch()
        scroll_area.setWidget(scroll_widget)
        files_layout.addWidget(scroll_area)
        
        # Selection buttons
        btn_layout = QHBoxLayout()
        select_all_btn = QPushButton("Select All")
        select_all_btn.clicked.connect(lambda: [cb.setChecked(True) for cb in file_checkboxes])
        deselect_all_btn = QPushButton("Deselect All")
        deselect_all_btn.clicked.connect(lambda: [cb.setChecked(False) for cb in file_checkboxes])
        btn_layout.addWidget(select_all_btn)
        btn_layout.addWidget(deselect_all_btn)
        btn_layout.addStretch()
        files_layout.addLayout(btn_layout)
        
        layout.addWidget(files_group)
        
        # Language pair selection
        lang_group = QGroupBox("Language Pair (applies to all files)")
        lang_layout = QHBoxLayout(lang_group)
        
        languages = [
            ("English", "en"),
            ("Dutch", "nl"),
            ("German", "de"),
            ("French", "fr"),
            ("Spanish", "es"),
            ("Italian", "it"),
            ("Portuguese", "pt"),
            ("Polish", "pl"),
            ("Russian", "ru"),
            ("Chinese", "zh"),
            ("Japanese", "ja"),
            ("Korean", "ko"),
        ]
        
        source_label = QLabel("Source:")
        source_combo = QComboBox()
        for name, code in languages:
            source_combo.addItem(name, code)

        # Load last used languages from settings, or use current project, or default to English
        general_settings = self.load_general_settings()
        last_source = general_settings.get('last_import_source_lang',
                                          self.current_project.source_lang if self.current_project else 'en')
        last_target = general_settings.get('last_import_target_lang',
                                          self.current_project.target_lang if self.current_project else 'nl')

        # Set source combo to last used language
        source_index = 0  # Default to English
        for i in range(source_combo.count()):
            if source_combo.itemData(i) == last_source:
                source_index = i
                break
        source_combo.setCurrentIndex(source_index)

        arrow_label = QLabel(" → ")
        arrow_label.setStyleSheet("font-weight: bold; font-size: 14px;")

        target_label = QLabel("Target:")
        target_combo = QComboBox()
        for name, code in languages:
            target_combo.addItem(name, code)

        # Set target combo to last used language
        target_index = 1  # Default to Dutch
        for i in range(target_combo.count()):
            if target_combo.itemData(i) == last_target:
                target_index = i
                break
        target_combo.setCurrentIndex(target_index)

        lang_layout.addWidget(source_label)
        lang_layout.addWidget(source_combo)
        lang_layout.addWidget(arrow_label)
        lang_layout.addWidget(target_label)
        lang_layout.addWidget(target_combo)
        lang_layout.addStretch()

        layout.addWidget(lang_group)
        
        # Import format options
        format_group = QGroupBox("Import Options")
        format_layout = QVBoxLayout(format_group)
        
        # memoQ format detection
        memoq_checkbox = CheckmarkCheckBox("Detect memoQ bilingual format (DOCX files with tables)")
        memoq_checkbox.setChecked(False)
        memoq_checkbox.setToolTip("If checked, DOCX files with bilingual tables will be imported as memoQ bilingual format")
        format_layout.addWidget(memoq_checkbox)

        # Sentence segmentation for TXT/MD files
        last_segment_setting = general_settings.get('last_import_sentence_segment', False)
        segment_checkbox = CheckmarkCheckBox("Split lines into sentences (TXT/MD files)")
        segment_checkbox.setChecked(last_segment_setting)
        segment_checkbox.setToolTip(
            "When checked, long lines in TXT/MD files will be split into individual\n"
            "sentences for easier translation. Sentences are automatically rejoined on export."
        )
        format_layout.addWidget(segment_checkbox)

        layout.addWidget(format_group)
        
        layout.addSpacing(10)
        
        # Buttons
        button_layout = QHBoxLayout()
        ok_btn = QPushButton("📥 Import Selected Files")
        ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 8px 16px; border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Get selected files from checkboxes
        selected_files = []
        for checkbox in file_checkboxes:
            if checkbox.isChecked():
                selected_files.append(checkbox.property("file_data"))
        
        if not selected_files:
            QMessageBox.warning(self, "No Files Selected", "Please select at least one file to import.")
            return
        
        # Get language settings
        source_lang = source_combo.currentData()
        target_lang = target_combo.currentData()
        detect_memoq = memoq_checkbox.isChecked()
        use_sentence_segmentation = segment_checkbox.isChecked()

        # Save selected settings for next time
        general_settings = self.load_general_settings()
        general_settings['last_import_source_lang'] = source_lang
        general_settings['last_import_target_lang'] = target_lang
        general_settings['last_import_sentence_segment'] = use_sentence_segmentation
        self.save_general_settings(general_settings)

        # Import files
        self._import_multifile_project(folder_path, selected_files, source_lang, target_lang, detect_memoq, use_sentence_segmentation)
    
    def _import_multifile_project(self, folder_path: str, files: list, source_lang: str, target_lang: str, detect_memoq: bool, sentence_segment: bool = False):
        """
        Import multiple files into a single project.

        Args:
            folder_path: Path to the folder containing files
            files: List of file dictionaries with 'name', 'path', 'type'
            source_lang: Source language code
            target_lang: Target language code
            detect_memoq: Whether to detect memoQ bilingual format
            sentence_segment: Whether to split TXT/MD lines into sentences
        """
        from datetime import datetime
        import shutil

        self.log(f"📁 Importing multi-file project from: {os.path.basename(folder_path)}")
        self.log(f"   Files to import: {len(files)}")

        # ── Okapi sidecar pre-flight ──────────────────────────────────
        # DOCX files in this batch always go through the Okapi sidecar.
        # If any DOCX is selected we need the sidecar running before the
        # loop starts; failing fast here is much friendlier than failing
        # halfway through importing a multi-file project.
        has_docx = any(f.get('type') == 'docx' for f in files)
        if has_docx and not self._ensure_okapi_sidecar():
            return

        # Create _source_files folder to store copies of original files
        # This ensures we always have the originals for format-preserving export
        source_backup_folder = os.path.join(folder_path, "_source_files")
        try:
            os.makedirs(source_backup_folder, exist_ok=True)
            self.log(f"   📦 Backing up source files to: _source_files/")
        except Exception as e:
            self.log(f"   ⚠️ Could not create backup folder: {str(e)}")
            source_backup_folder = None

        if not hasattr(self, 'segmenter'):
            from modules.simple_segmenter import SimpleSegmenter
            self.segmenter = SimpleSegmenter()
        
        all_segments = []
        file_metadata = []  # Track file info for the project
        current_segment_id = 1
        
        # Progress dialog
        progress_dialog = QProgressDialog("Importing files...", "Cancel", 0, len(files), self)
        progress_dialog.setWindowTitle("Importing Multi-File Project")
        progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
        progress_dialog.setMinimumDuration(0)
        
        for idx, file_info in enumerate(files):
            if progress_dialog.wasCanceled():
                self.log("❌ Import cancelled by user")
                return
            
            progress_dialog.setValue(idx)
            progress_dialog.setLabelText(f"Importing: {file_info['name']} ({idx+1}/{len(files)})")
            QApplication.processEvents()
            
            file_id = idx + 1
            file_name = file_info['name']
            file_path = file_info['path']
            file_type = file_info['type']
            
            self.log(f"   📄 [{idx+1}/{len(files)}] {file_name}")
            
            try:
                file_segments = []
                
                # Copy original file to backup folder for safe keeping
                backup_path = None
                if source_backup_folder:
                    try:
                        backup_path = os.path.join(source_backup_folder, file_name)
                        shutil.copy2(file_path, backup_path)
                        self.log(f"      📦 Backed up: {file_name}")
                    except Exception as e:
                        self.log(f"      ⚠️ Backup failed: {str(e)}")
                        backup_path = None
                
                if file_type in ('txt', 'md'):
                    # Simple text / Markdown file
                    # Empty lines are preserved as empty segments for structure
                    # (they are hidden in the grid but kept for export fidelity)
                    with open(file_path, 'r', encoding='utf-8') as f:
                        lines = f.readlines()

                    if sentence_segment:
                        if file_type == 'md':
                            from modules.simple_segmenter import MarkdownSegmenter
                            txt_segmenter = MarkdownSegmenter()
                        else:
                            from modules.simple_segmenter import SimpleSegmenter
                            txt_segmenter = SimpleSegmenter()
                        for line_num, line in enumerate(lines, 1):
                            text = line.rstrip('\n\r')
                            if not text.strip():
                                file_segments.append(Segment(
                                    id=current_segment_id, source=text, target="",
                                    status=DEFAULT_STATUS.key, file_id=file_id,
                                    file_name=file_name, paragraph_id=line_num,
                                    document_position=line_num
                                ))
                                current_segment_id += 1
                            else:
                                for sentence in txt_segmenter.segment_text(text):
                                    file_segments.append(Segment(
                                        id=current_segment_id, source=sentence, target="",
                                        status=DEFAULT_STATUS.key, file_id=file_id,
                                        file_name=file_name, paragraph_id=line_num,
                                        document_position=line_num
                                    ))
                                    current_segment_id += 1
                    else:
                        for line_num, line in enumerate(lines, 1):
                            text = line.rstrip('\n\r')
                            file_segments.append(Segment(
                                id=current_segment_id, source=text, target="",
                                status=DEFAULT_STATUS.key, file_id=file_id,
                                file_name=file_name, paragraph_id=line_num,
                                document_position=line_num
                            ))
                            current_segment_id += 1
                
                elif file_type == 'docx':
                    # ── Okapi extraction ─────────────────────────────
                    # Calls the sidecar's /extract endpoint and converts
                    # the result into Segment objects that carry the
                    # okapi_tu_id / okapi_segment_index metadata required
                    # by the round-trip /merge endpoint at export time.
                    self.log(f"      🔧 Extracting via Okapi sidecar…")
                    result = self.okapi_sidecar.extract(
                        file_path,
                        source_lang=source_lang,
                        target_lang=target_lang,
                        segment=True,
                    )

                    tu_id_to_para = {}
                    next_para = 0
                    skipped_count = 0

                    for seg in result.get('segments', []):
                        tu_id = seg.get('id', '')
                        sub_doc = (seg.get('subDocument') or '').lower()
                        text = seg.get('source', '').strip()
                        tagged_text = (seg.get('sourceWithTags') or '').strip()

                        # Skip header/footer subdocuments and empty segs.
                        if sub_doc.startswith(('header', 'footer')):
                            skipped_count += 1
                            continue
                        if not text:
                            skipped_count += 1
                            continue

                        # Same-TU segments share a paragraph id.
                        if tu_id not in tu_id_to_para:
                            tu_id_to_para[tu_id] = next_para
                            next_para += 1
                        para_id = tu_id_to_para[tu_id]

                        # Prefer the tagged form so inline formatting is
                        # visible in the editor (matches single-file path).
                        display_text = tagged_text if tagged_text else text

                        segment = Segment(
                            id=current_segment_id,
                            source=display_text,
                            target="",
                            status=DEFAULT_STATUS.key,
                            type="¶",
                            paragraph_id=para_id,
                            file_id=file_id,
                            file_name=file_name,
                            okapi_tu_id=tu_id,
                            okapi_segment_index=seg.get('segmentIndex', 0),
                        )
                        file_segments.append(segment)
                        current_segment_id += 1

                    self.log(f"      ✅ Okapi: {len(file_segments)} segments "
                             f"from {result.get('textUnitCount', '?')} TUs"
                             f"{f', skipped {skipped_count} non-body' if skipped_count else ''}")
                
                # Track file metadata - use backup path if available, otherwise original
                stored_path = backup_path if backup_path and os.path.exists(backup_path) else file_path
                file_meta = {
                    'id': file_id,
                    'name': file_name,
                    'path': stored_path,
                    'type': file_type,
                    'segment_count': len(file_segments),
                    'start_segment_id': file_segments[0].id if file_segments else None,
                    'end_segment_id': file_segments[-1].id if file_segments else None
                }
                file_metadata.append(file_meta)
                
                all_segments.extend(file_segments)
                self.log(f"      ✓ {len(file_segments)} segments")
                
            except Exception as e:
                self.log(f"      ❌ Error: {str(e)}")
                QMessageBox.warning(
                    self,
                    "Import Error",
                    f"Failed to import file:\n{file_name}\n\nError: {str(e)}\n\nContinuing with remaining files..."
                )
        
        progress_dialog.setValue(len(files))
        progress_dialog.close()
        
        if not all_segments:
            QMessageBox.warning(self, "No Segments", "No segments were imported from the selected files.")
            return
        
        # Create multi-file project
        project = Project(
            name=f"Multi-File Project - {os.path.basename(folder_path)}",
            source_lang=source_lang,
            target_lang=target_lang,
            segments=all_segments,
            is_multifile=True,
            files=file_metadata
        )

        # Tag the project as Okapi-imported when at least one DOCX was
        # imported. Mixed projects (DOCX + TXT/MD) still get the tag –
        # the export path routes per-file by file_type, so TXT/MD files
        # use their normal text-based export.
        if has_docx:
            project.import_engine = "okapi"

        # Set as current project
        self.current_project = project
        self.current_document_path = folder_path

        # CRITICAL: Update _original_segment_order for new import
        self._original_segment_order = self.current_project.segments.copy()

        # Load into grid
        self.load_segments_to_grid()

        # Initialize TM and clear stale resources from previous project
        self.initialize_tm_database()
        self._clear_caches_after_import()
        self._deactivate_all_resources_for_new_project()

        # Update UI
        self.update_window_title()
        
        # Update file filter dropdown for multi-file projects
        if hasattr(self, '_update_file_filter_combo'):
            self._update_file_filter_combo()
        
        # Log summary
        self.log(f"✓ Multi-file project created:")
        self.log(f"   Total segments: {len(all_segments)}")
        self.log(f"   Files: {len(file_metadata)}")
        self.log(f"   Language pair: {source_lang.upper()} → {target_lang.upper()}")
        
        # Show success message with summary
        file_summary = "\n".join([f"  • {f['name']}: {f['segment_count']} segments" for f in file_metadata[:10]])
        if len(file_metadata) > 10:
            file_summary += f"\n  ... and {len(file_metadata) - 10} more files"
        
        QMessageBox.information(
            self,
            "Multi-File Import Complete",
            f"Successfully imported {len(all_segments)} segments from {len(file_metadata)} files.\n\n"
            f"Language pair: {source_lang.upper()} → {target_lang.upper()}\n\n"
            f"Files:\n{file_summary}\n\n"
            f"Use File \u2192 Project Info to track translation progress per file."
        )

    def relocate_source_folder(self):
        """
        Relocate the source folder for a multi-file project.
        
        This allows users to repoint to moved/renamed source folders so that
        DOCX exports can preserve original formatting.
        """
        from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QHeaderView
        
        if not self.current_project:
            QMessageBox.warning(self, "No Project", "Please open a project first.")
            return
        
        # Check if this is a multi-file project
        is_multifile = getattr(self.current_project, 'is_multifile', False)
        files = getattr(self.current_project, 'files', [])
        
        if not is_multifile or not files:
            QMessageBox.information(
                self,
                "Single-File Project",
                "This feature is for multi-file projects only.\n\n"
                "Multi-file projects are created via:\n"
                "File → Import → Folder (Multiple Files)..."
            )
            return
        
        # Check current status of source files
        missing_count = 0
        found_count = 0
        for f in files:
            path = f.get('path', '')
            if path:
                path = os.path.normpath(path)
                if os.path.exists(path):
                    found_count += 1
                else:
                    missing_count += 1
            else:
                missing_count += 1
        
        # Ask user to select new folder
        info_text = (
            f"Current source file status:\n"
            f"  ✓ Found: {found_count} files\n"
            f"  ✗ Missing: {missing_count} files\n\n"
            f"Select the folder containing the original source files.\n"
            f"Files will be matched by filename."
        )
        
        result = QMessageBox.question(
            self,
            "🔗 Relocate Source Folder",
            info_text + "\n\nProceed to select folder?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        
        if result != QMessageBox.StandardButton.Yes:
            return
        
        # Select new folder
        new_folder = QFileDialog.getExistingDirectory(
            self,
            "Select Folder Containing Original Source Files",
            os.path.dirname(self.current_document_path) if self.current_document_path else ""
        )
        
        if not new_folder:
            return
        
        # Scan new folder for matching files
        self.log(f"🔗 Relocating source folder to: {new_folder}")
        
        # Build a map of available files in the new folder (including subfolders)
        available_files = {}
        for root, dirs, filenames in os.walk(new_folder):
            for filename in filenames:
                full_path = os.path.join(root, filename)
                # Store by filename (case-insensitive matching)
                available_files[filename.lower()] = full_path
        
        self.log(f"   Found {len(available_files)} files in selected folder")
        
        # Try to match each project file
        updated_count = 0
        not_found = []
        
        for file_info in files:
            file_name = file_info.get('name', '')
            if not file_name:
                continue
            
            # Try to find matching file
            matched_path = available_files.get(file_name.lower())
            
            if matched_path:
                old_path = file_info.get('path', '')
                file_info['path'] = matched_path
                updated_count += 1
                self.log(f"   ✓ {file_name} → {matched_path}")
            else:
                not_found.append(file_name)
                self.log(f"   ✗ {file_name} - not found in selected folder")
        
        # Update project
        self.current_project.files = files
        
        # Show results
        if not_found:
            not_found_str = "\n".join([f"  • {f}" for f in not_found[:10]])
            if len(not_found) > 10:
                not_found_str += f"\n  ... and {len(not_found) - 10} more"
            
            QMessageBox.warning(
                self,
                "Partial Match",
                f"Updated {updated_count} file paths.\n\n"
                f"Files not found ({len(not_found)}):\n{not_found_str}\n\n"
                f"These files will use fallback export (no formatting preserved).\n\n"
                f"Save the project to keep these changes."
            )
        else:
            QMessageBox.information(
                self,
                "✓ Source Folder Relocated",
                f"Successfully updated all {updated_count} file paths.\n\n"
                f"DOCX exports will now preserve original formatting.\n\n"
                f"Save the project to keep these changes."
            )
        
        self.log(f"✓ Relocated {updated_count}/{len(files)} source file paths")

    def export_folder_multifile(self):
        """
        Export a multi-file project to a folder with separate files.
        
        Each file in the project is exported as a separate file in the target folder.
        Supports DOCX (target only) and TXT formats.
        """
        from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, 
                                      QGroupBox, QComboBox, QPushButton, QTableWidget,
                                      QTableWidgetItem, QHeaderView, QProgressDialog,
                                      QFileDialog, QButtonGroup)
        
        if not self.current_project:
            QMessageBox.warning(self, "No Project", "Please open a project first.")
            return
        
        # Check if this is a multi-file project
        is_multifile = getattr(self.current_project, 'is_multifile', False)
        files = getattr(self.current_project, 'files', [])
        
        if not is_multifile or not files:
            QMessageBox.information(
                self,
                "Single-File Project",
                "This is a single-file project.\n\n"
                "Use the regular export options (Target Only DOCX, Simple Text, etc.) instead.\n\n"
                "Folder export is for multi-file projects imported via:\n"
                "File → Import → Folder (Multiple Files)..."
            )
            return
        
        # Show export dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("📁 Export Multi-File Project")
        dialog.setMinimumWidth(650)
        dialog.setMinimumHeight(450)
        
        layout = QVBoxLayout(dialog)
        
        # Info
        segments = self.current_project.segments
        total_segs = len(segments)
        translated_segs = sum(1 for s in segments if s.target and s.target.strip())
        
        info_label = QLabel(
            f"<b>📁 Export Multi-File Project</b><br><br>"
            f"Project: {self.current_project.name}<br>"
            f"Files: {len(files)} | Segments: {translated_segs}/{total_segs} translated"
        )
        info_label.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(info_label)
        
        layout.addSpacing(10)
        
        # Export format selection
        format_group = QGroupBox("Export Format")
        format_layout = QVBoxLayout(format_group)

        format_btn_group = QButtonGroup(dialog)

        original_radio = CheckmarkRadioButton("Original Format - Each file exports to its source format")
        original_radio.setChecked(True)
        format_btn_group.addButton(original_radio, 5)
        format_layout.addWidget(original_radio)

        txt_radio = CheckmarkRadioButton("Plain Text (.txt) - One line per segment")
        format_btn_group.addButton(txt_radio, 1)
        format_layout.addWidget(txt_radio)

        md_radio = CheckmarkRadioButton("Markdown (.md) - One line per segment (preserves syntax)")
        format_btn_group.addButton(md_radio, 4)
        format_layout.addWidget(md_radio)

        docx_radio = CheckmarkRadioButton("Word Document (.docx) - Target text only")
        format_btn_group.addButton(docx_radio, 2)
        format_layout.addWidget(docx_radio)

        bilingual_radio = CheckmarkRadioButton("Bilingual Table (.docx) - Source | Target columns")
        format_btn_group.addButton(bilingual_radio, 3)
        format_layout.addWidget(bilingual_radio)
        
        layout.addWidget(format_group)
        
        layout.addSpacing(10)
        
        # File preview table
        preview_group = QGroupBox(f"Files to Export ({len(files)} files)")
        preview_layout = QVBoxLayout(preview_group)
        
        preview_table = QTableWidget()
        preview_table.setColumnCount(4)
        preview_table.setHorizontalHeaderLabels(["Original File", "Segments", "Translated", "Output Name"])
        preview_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        preview_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
        preview_table.setRowCount(len(files))
        preview_table.setAlternatingRowColors(True)
        preview_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        
        for row, file_info in enumerate(files):
            file_id = file_info['id']
            file_name = file_info['name']
            
            # Count segments for this file
            file_segs = [s for s in segments if getattr(s, 'file_id', None) == file_id]
            total = len(file_segs)
            translated = sum(1 for s in file_segs if s.target and s.target.strip())
            
            # Generate output name
            base_name = os.path.splitext(file_name)[0]
            output_name = f"{base_name}_translated.txt"
            
            preview_table.setItem(row, 0, QTableWidgetItem(file_name))
            preview_table.setItem(row, 1, QTableWidgetItem(str(total)))
            preview_table.setItem(row, 2, QTableWidgetItem(f"{translated}/{total}"))
            preview_table.setItem(row, 3, QTableWidgetItem(output_name))
        
        preview_layout.addWidget(preview_table)
        layout.addWidget(preview_group)
        
        # Map file types to extensions for "Original Format" export
        _type_to_ext = {'txt': '.txt', 'md': '.md', 'docx': '.docx'}

        # Update output names when format changes
        def update_output_names():
            for row, file_info in enumerate(files):
                base_name = os.path.splitext(file_info['name'])[0]
                suffix = "_bilingual" if bilingual_radio.isChecked() else "_translated"
                if original_radio.isChecked():
                    ext = _type_to_ext.get(file_info.get('type', 'txt'), '.txt')
                elif txt_radio.isChecked():
                    ext = ".txt"
                elif md_radio.isChecked():
                    ext = ".md"
                else:
                    ext = ".docx"
                output_name = f"{base_name}{suffix}{ext}"
                preview_table.setItem(row, 3, QTableWidgetItem(output_name))

        original_radio.toggled.connect(update_output_names)
        txt_radio.toggled.connect(update_output_names)
        md_radio.toggled.connect(update_output_names)
        docx_radio.toggled.connect(update_output_names)
        bilingual_radio.toggled.connect(update_output_names)
        update_output_names()  # Set initial preview with original format selected
        
        layout.addSpacing(10)
        
        # Buttons
        button_layout = QHBoxLayout()
        ok_btn = QPushButton("📤 Export to Folder...")
        ok_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 8px 16px; border: none; outline: none;")
        ok_btn.clicked.connect(dialog.accept)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        
        button_layout.addStretch()
        button_layout.addWidget(cancel_btn)
        button_layout.addWidget(ok_btn)
        layout.addLayout(button_layout)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Get export format
        if original_radio.isChecked():
            export_format = "original"
        elif txt_radio.isChecked():
            export_format = "txt"
        elif md_radio.isChecked():
            export_format = "md"
        elif docx_radio.isChecked():
            export_format = "docx"
        else:
            export_format = "bilingual"
        
        # Select output folder
        output_folder = QFileDialog.getExistingDirectory(
            self,
            "Select Output Folder for Exported Files",
            "",
            QFileDialog.Option.ShowDirsOnly
        )
        
        if not output_folder:
            return
        
        # Export each file
        self._export_multifile_to_folder(output_folder, export_format)
    
    def _export_multifile_to_folder(self, output_folder: str, export_format: str):
        """
        Export each file in a multi-file project to the output folder.
        
        Args:
            output_folder: Path to output folder
            export_format: 'txt', 'docx', or 'bilingual'
        """
        files = getattr(self.current_project, 'files', [])
        segments = self.current_project.segments
        
        self.log(f"📤 Exporting multi-file project to: {output_folder}")
        self.log(f"   Format: {export_format.upper()}")
        
        # Progress dialog
        progress_dialog = QProgressDialog("Exporting files...", "Cancel", 0, len(files), self)
        progress_dialog.setWindowTitle("Exporting Multi-File Project")
        progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
        progress_dialog.setMinimumDuration(0)
        
        exported_count = 0
        total_segments_exported = 0
        files_missing_originals = []  # Track files where we couldn't preserve formatting
        
        for idx, file_info in enumerate(files):
            if progress_dialog.wasCanceled():
                self.log("❌ Export cancelled by user")
                break
            
            progress_dialog.setValue(idx)
            progress_dialog.setLabelText(f"Exporting: {file_info['name']} ({idx+1}/{len(files)})")
            QApplication.processEvents()
            
            file_id = file_info['id']
            file_name = file_info['name']
            base_name = os.path.splitext(file_name)[0]
            
            # Get original file path for format preservation
            # Normalize path separators (fix mixed forward/back slashes from Google Drive paths)
            original_path = file_info.get('path', None)
            if original_path:
                original_path = os.path.normpath(original_path)
            
            # Check if file exists (for format preservation during DOCX export)
            path_exists = False
            if original_path:
                path_exists = os.path.exists(original_path)
                # Fallback: try to actually open the file (more reliable for virtual filesystems like Google Drive)
                if not path_exists:
                    try:
                        with open(original_path, 'rb') as f:
                            f.read(1)
                        path_exists = True
                    except:
                        pass
            
            # Get segments for this file
            file_segments = [s for s in segments if getattr(s, 'file_id', None) == file_id]
            
            if not file_segments:
                self.log(f"   ⚠️ {file_name}: No segments found, skipping")
                continue
            
            try:
                # Resolve per-file format when "Original Format" is selected
                file_format = export_format
                if export_format == "original":
                    file_format = file_info.get('type', 'txt')

                format_note = ""
                if file_format in ("txt", "md"):
                    # Export as plain text / Markdown
                    ext = ".md" if file_format == "md" else ".txt"
                    output_path = os.path.join(output_folder, f"{base_name}_translated{ext}")
                    self._export_file_as_txt(file_segments, output_path)

                elif file_format == "docx":
                    # Export as DOCX (preserving original formatting if possible)
                    output_path = os.path.join(output_folder, f"{base_name}_translated.docx")
                    if not path_exists:
                        format_note = " (no original - plain text)"
                        files_missing_originals.append(file_name)
                    self._export_file_as_docx(file_segments, output_path, original_path if path_exists else None)

                elif file_format == "bilingual":
                    # Export as bilingual table
                    output_path = os.path.join(output_folder, f"{base_name}_bilingual.docx")
                    self._export_file_as_bilingual(file_segments, output_path)
                
                exported_count += 1
                total_segments_exported += len(file_segments)
                self.log(f"   ✓ {file_name} → {os.path.basename(output_path)} ({len(file_segments)} segments){format_note}")
                
            except Exception as e:
                self.log(f"   ❌ {file_name}: Export failed - {str(e)}")
        
        progress_dialog.setValue(len(files))
        progress_dialog.close()
        
        # Show summary
        self.log(f"✓ Export complete: {exported_count}/{len(files)} files, {total_segments_exported} segments")
        
        # Build message with warning if files were missing originals
        message = (
            f"Successfully exported {exported_count} files to:\n{output_folder}\n\n"
            f"Total segments: {total_segments_exported}\n"
            f"Format: {export_format.upper()}"
        )
        
        if files_missing_originals and export_format == "docx":
            message += (
                f"\n\n⚠️ {len(files_missing_originals)} file(s) exported without original formatting "
                f"(source files not found).\n\n"
                f"To preserve formatting, use:\n"
                f"File → Export → Relocate Source Folder..."
            )
            self.log(f"   ⚠️ {len(files_missing_originals)} files without original formatting")
        
        QMessageBox.information(
            self,
            "Export Complete",
            message
        )
    
    def _export_file_as_txt(self, segments: list, output_path: str):
        """Export segments to a plain text file, grouping by paragraph_id."""
        import re
        from itertools import groupby

        def strip_tags(text: str) -> str:
            """Remove formatting tags from text."""
            if not text:
                return ""
            text = re.sub(r'</?b>', '', text)
            text = re.sub(r'</?i>', '', text)
            text = re.sub(r'</?u>', '', text)
            text = re.sub(r'</?li-[ob]>', '', text)
            return text

        with open(output_path, 'w', encoding='utf-8') as f:
            for _para_id, group in groupby(segments, key=lambda s: s.paragraph_id):
                parts = []
                for seg in group:
                    text = seg.target if seg.target and seg.target.strip() else seg.source
                    parts.append(strip_tags(text))
                # Join sentence segments with space; empty segments produce empty lines
                if any(p.strip() for p in parts):
                    line = ' '.join(p for p in parts if p.strip())
                else:
                    line = ""
                f.write(line + '\n')
    
    def _export_file_as_docx(self, segments: list, output_path: str, original_path: str = None):
        """
        Export segments to a DOCX file, preserving original formatting if original file exists.

        For Okapi-imported projects (the standard path for new projects)
        this calls the sidecar's /merge endpoint, which gives a faithful
        round-trip via the Okapi Framework filter writers. For legacy
        standard-imported projects we fall back to the python-docx
        DOCXHandler, which preserves formatting by paragraph-text match.

        Args:
            segments: List of Segment objects for this file
            output_path: Path to save the exported file
            original_path: Path to the original DOCX file (for format preservation)
        """
        from docx import Document
        from docx.shared import Pt
        import re

        # ── Okapi merge path ──────────────────────────────────────────
        # If this project was imported via Okapi AND we have the original
        # file, route through the sidecar's /merge endpoint. The segments
        # carry okapi_tu_id / okapi_segment_index that the merge needs.
        is_okapi_project = (
            self.current_project is not None
            and getattr(self.current_project, 'import_engine', '') == 'okapi'
        )
        if (is_okapi_project and original_path and os.path.exists(original_path)
                and self.okapi_sidecar and self.okapi_sidecar.is_running()
                and any(getattr(s, 'okapi_tu_id', '') for s in segments)):
            try:
                translations = []
                skipped = 0
                for seg in segments:
                    tu_id = getattr(seg, 'okapi_tu_id', '') or ''
                    seg_idx = getattr(seg, 'okapi_segment_index', -1)
                    if not tu_id or seg_idx is None or seg_idx < 0:
                        skipped += 1
                        continue
                    translation = (seg.target.strip()
                                   if seg.target and seg.target.strip()
                                   else seg.source)
                    # Strip Supervertaler-only tags Okapi wouldn't know.
                    translation = re.sub(r'</?(?:bi|li-[bo]|li)>', '',
                                         translation)
                    translations.append({
                        'id': tu_id,
                        'segmentIndex': seg_idx,
                        'translation': translation,
                    })

                if translations:
                    src_lang = self.current_project.source_lang
                    trg_lang = self.current_project.target_lang
                    self.log(f"      🔧 Okapi merge ({len(translations)} segments)…")
                    self.okapi_sidecar.merge(
                        original_path=original_path,
                        translations=translations,
                        source_lang=src_lang,
                        target_lang=trg_lang,
                        output_path=output_path,
                    )
                    if skipped:
                        self.log(f"      ⚠️ {skipped} segments without Okapi metadata "
                                 f"used source text in merge")
                    return
                # else: no segments had Okapi metadata – fall through.
            except Exception as e:
                self.log(f"      ⚠️ Okapi merge failed, falling back to standard: {e}")
                # Fall through to the python-docx path below.
        
        def strip_tags(text: str) -> str:
            """Remove formatting tags from text."""
            if not text:
                return ""
            text = re.sub(r'</?b>', '', text)
            text = re.sub(r'</?i>', '', text)
            text = re.sub(r'</?u>', '', text)
            text = re.sub(r'</?li-[ob]>', '', text)
            return text
        
        def file_accessible(path: str) -> bool:
            """Check if file exists and is accessible (works better with Google Drive)."""
            if not path:
                return False
            # Try os.path.exists first
            if os.path.exists(path):
                return True
            # Fallback: try to actually open the file (more reliable for virtual filesystems)
            try:
                with open(path, 'rb') as f:
                    f.read(1)
                return True
            except:
                return False
        
        # If we have the original file, use DOCXHandler for proper format preservation
        if original_path and file_accessible(original_path):
            self.log(f"      📄 Using original file as template: {os.path.basename(original_path)}")
            
            try:
                # Create a fresh DOCXHandler for this file
                from modules.docx_handler import DOCXHandler
                handler = DOCXHandler()
                
                # Import the original to set up paragraph mapping
                handler.import_docx(original_path)
                
                # Convert segments to the format expected by export_docx
                export_segments = []
                for seg in segments:
                    text = seg.target if seg.target and seg.target.strip() else seg.source
                    export_segments.append({
                        'paragraph_id': getattr(seg, 'paragraph_id', 0),
                        'source': seg.source,
                        'target': text
                    })
                
                # Use the handler's export which preserves all formatting
                target_lang = self.current_project.target_lang if hasattr(self.current_project, 'target_lang') else None
                handler.export_docx(export_segments, output_path, preserve_formatting=True, target_lang=target_lang)
                return
                
            except Exception as e:
                self.log(f"      ⚠️ DOCXHandler export failed ({str(e)}), falling back to simple export")
        
        # Fallback: create new document (loses formatting)
        self.log(f"      ⚠️ Original file not found, creating new document (formatting not preserved)")
        
        doc = Document()
        
        for segment in segments:
            text = segment.target if segment.target and segment.target.strip() else segment.source
            text = strip_tags(text)
            para = doc.add_paragraph(text)
            for run in para.runs:
                run.font.size = Pt(11)

        # Set document language to target language
        if hasattr(self.current_project, 'target_lang') and self.current_project.target_lang:
            target_lang_code = get_docx_language_code(self.current_project.target_lang)
            set_docx_language(doc, target_lang_code)
            self.log(f"✓ Set document language to: {target_lang_code}")

        doc.save(output_path)

    def _export_file_as_bilingual(self, segments: list, output_path: str):
        """Export segments to a bilingual DOCX table (Source | Target columns)."""
        from docx import Document
        from docx.shared import Inches, Pt
        import re
        
        def strip_tags(text: str) -> str:
            """Remove formatting tags from text for clean display."""
            if not text:
                return ""
            # Remove inline formatting tags
            text = re.sub(r'</?b>', '', text)
            text = re.sub(r'</?i>', '', text)
            text = re.sub(r'</?u>', '', text)
            text = re.sub(r'</?li-[ob]>', '', text)
            return text
        
        doc = Document()
        
        # Add header
        header = doc.add_paragraph()
        header.add_run(f"Bilingual Export - {len(segments)} segments").bold = True
        
        # Create table
        table = doc.add_table(rows=1, cols=3)
        table.style = 'Table Grid'
        
        # Header row
        hdr_cells = table.rows[0].cells
        hdr_cells[0].text = "#"
        hdr_cells[1].text = "Source"
        hdr_cells[2].text = "Target"
        
        # Make header bold
        for cell in hdr_cells:
            for para in cell.paragraphs:
                for run in para.runs:
                    run.bold = True
        
        # Add segments
        for segment in segments:
            row_cells = table.add_row().cells
            row_cells[0].text = str(segment.id)
            row_cells[1].text = strip_tags(segment.source)
            row_cells[2].text = strip_tags(segment.target) if segment.target else ""
        
        # Set column widths using fixed layout and explicit grid
        from docx.oxml.ns import qn
        from docx.oxml import OxmlElement
        tbl = table._tbl
        tbl_pr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')

        tbl_layout = OxmlElement('w:tblLayout')
        tbl_layout.set(qn('w:type'), 'fixed')
        tbl_pr.append(tbl_layout)

        # 3-column layout: narrow # (5%), equal source/target (47.5% each)
        section = doc.sections[0]
        usable_twips = int((section.page_width - section.left_margin - section.right_margin) / 914400 * 1440)
        col_twips = [int(0.05 * usable_twips), int(0.475 * usable_twips), 0]
        col_twips[2] = usable_twips - col_twips[0] - col_twips[1]

        tbl_w = OxmlElement('w:tblW')
        tbl_w.set(qn('w:w'), str(usable_twips))
        tbl_w.set(qn('w:type'), 'dxa')
        tbl_pr.append(tbl_w)

        old_grid = tbl.find(qn('w:tblGrid'))
        if old_grid is not None:
            tbl.remove(old_grid)
        new_grid = OxmlElement('w:tblGrid')
        for tw in col_twips:
            gc = OxmlElement('w:gridCol')
            gc.set(qn('w:w'), str(tw))
            new_grid.append(gc)
        tbl.insert(tbl.index(tbl_pr) + 1, new_grid)

        for row in table.rows:
            for i, tw in enumerate(col_twips):
                tc = row.cells[i]._tc
                tcPr = tc.tcPr
                if tcPr is None:
                    tcPr = OxmlElement('w:tcPr')
                    tc.insert(0, tcPr)
                tcW = tcPr.find(qn('w:tcW'))
                if tcW is None:
                    tcW = OxmlElement('w:tcW')
                    tcPr.insert(0, tcW)
                tcW.set(qn('w:w'), str(tw))
                tcW.set(qn('w:type'), 'dxa')

        doc.save(output_path)

    def _interpret_memoq_status(self, status_text: str, has_target: bool) -> Tuple[str, Optional[int]]:
        """Map memoQ status text to internal status and extract match percentage."""
        status_def, match_percent = match_memoq_status(status_text)
        if status_def.key == DEFAULT_STATUS.key and has_target:
            return STATUSES["pretranslated"].key, match_percent
        return status_def.key, match_percent

    def import_memoq_bilingual(self):
        """Import memoQ bilingual DOCX file (table format)"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select memoQ Bilingual DOCX File",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        # Show formatting options dialog
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QGroupBox
        
        dialog = QDialog(self)
        dialog.setWindowTitle("memoQ Bilingual Import Options")
        dialog.setMinimumWidth(450)
        layout = QVBoxLayout(dialog)
        
        # Info text
        info_label = QLabel(
            "This workflow imports memoQ bilingual DOCX for round-tripping.\n"
            "The project can be exported back to memoQ bilingual DOCX format."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
        layout.addWidget(info_label)
        
        # Formatting options group
        format_group = QGroupBox("Formatting Handling")
        format_layout = QVBoxLayout(format_group)
        
        # Option 1: Ignore formatting (using CheckmarkCheckBox)
        ignore_checkbox = CheckmarkCheckBox("Ignore inline formatting")
        ignore_checkbox.setToolTip("Bold, italic, and underline formatting will not be transferred to translations.\nUse this if formatting causes issues or isn't needed.")
        ignore_checkbox.setChecked(False)  # Default: use smart formatting
        format_layout.addWidget(ignore_checkbox)
        
        ignore_desc = QLabel("    When checked, formatting (bold/italic/underline) will not be applied to translations.")
        ignore_desc.setStyleSheet("color: #888; font-size: 10px; margin-left: 20px;")
        format_layout.addWidget(ignore_desc)
        
        # Option 2: Smart formatting (using CheckmarkCheckBox)
        smart_checkbox = CheckmarkCheckBox("Smart formatting transfer")
        smart_checkbox.setToolTip("Attempts to identify formatted text in source and apply matching formatting to corresponding words in the translation.")
        smart_checkbox.setChecked(True)  # Default
        format_layout.addWidget(smart_checkbox)
        
        smart_desc = QLabel("    When checked, tries to match formatted source phrases to their translations.")
        smart_desc.setStyleSheet("color: #888; font-size: 10px; margin-left: 20px;")
        format_layout.addWidget(smart_desc)
        
        # Make checkboxes mutually exclusive
        def on_ignore_changed(checked):
            if checked:
                smart_checkbox.setChecked(False)
        
        def on_smart_changed(checked):
            if checked:
                ignore_checkbox.setChecked(False)
        
        ignore_checkbox.toggled.connect(on_ignore_changed)
        smart_checkbox.toggled.connect(on_smart_changed)
        
        layout.addWidget(format_group)
        
        # Note about tags - escape the angle brackets so they show literally
        note_label = QLabel(
            "ℹ️ Note: Inline XML tags (like &lt;b&gt;text&lt;/b&gt;) are always preserved regardless of this setting."
        )
        note_label.setWordWrap(True)
        note_label.setStyleSheet("color: #2196F3; font-size: 10px; margin-top: 10px;")
        layout.addWidget(note_label)
        
        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Store formatting preference
        self.memoq_smart_formatting = smart_checkbox.isChecked()
        self.log(f"📄 memoQ import: {'Smart formatting transfer' if self.memoq_smart_formatting else 'Ignore formatting'}")

        # Check if this is a re-import of the same file
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.memoq_source_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        try:
            from docx import Document
            
            # Load the bilingual DOCX
            doc = Document(file_path)
            
            if not doc.tables:
                QMessageBox.critical(
                    self, "Error",
                    "No table found in the DOCX file.\n\nExpected memoQ bilingual format with a table."
                )
                return
            
            table = doc.tables[0]
            
            # Validate table structure (should have at least 3 rows: header, column names, data)
            if len(table.rows) < 3:
                QMessageBox.critical(
                    self, "Error",
                    f"Invalid table structure.\n\nExpected at least 3 rows, found {len(table.rows)}."
                )
                return
            
            # CLASSIC APPROACH: Extract source segments to simple line-by-line list
            source_segments = []
            target_segments = []
            formatting_map = {}  # segment_index -> list of formatting info
            metadata = []  # Store comments, status, etc. for later
            
            for row_idx in range(2, len(table.rows)):
                row = table.rows[row_idx]
                
                # Ensure we have at least 3 cells (0=segment#, 1=source, 2=target)
                if len(row.cells) >= 3:
                    source_cell = row.cells[1]
                    target_cell = row.cells[2]
                    
                    # Convert runs to HTML-tagged text if smart formatting is enabled
                    if self.memoq_smart_formatting:
                        source_text = runs_to_tagged_text(source_cell.paragraphs)
                        target_text = runs_to_tagged_text(target_cell.paragraphs)
                    else:
                        source_text = source_cell.text.strip()
                        target_text = target_cell.text.strip()
                    
                    # Always add (even if empty) to maintain alignment
                    source_segments.append(source_text)
                    target_segments.append(target_text)
                    
                    # Store metadata
                    comment_text = row.cells[3].text.strip() if len(row.cells) >= 4 else ""
                    status_text = row.cells[4].text.strip() if len(row.cells) >= 5 else ""
                    metadata.append({
                        'comment': comment_text,
                        'status': status_text,
                        'target': target_text
                    })
                    
                    # Also extract formatting info for backwards compatibility / export
                    formatting_info = []
                    for paragraph in source_cell.paragraphs:
                        for run in paragraph.runs:
                            run_text = run.text
                            if run_text:  # Only store runs with actual text
                                formatting_info.append({
                                    'text': run_text,
                                    'bold': run.bold == True,
                                    'italic': run.italic == True,
                                    'underline': run.underline == True
                                })
                    
                    # Store formatting for this segment
                    if formatting_info:
                        segment_idx = len(source_segments) - 1
                        formatting_map[segment_idx] = formatting_info
            
            if not source_segments:
                QMessageBox.warning(self, "Warning", "No segments found in the bilingual file.")
                return
            
            # Detect languages from table header (row 1, columns 1 and 2)
            header_row = table.rows[1]
            source_lang = "en"  # Default
            target_lang = "nl"  # Default
            
            if len(header_row.cells) >= 3:
                source_header = header_row.cells[1].text.strip().lower()
                target_header = header_row.cells[2].text.strip().lower()
                
                # Try to detect language from header
                lang_map = {
                    'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                    'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'polish': 'pl',
                    'czech': 'cs', 'slovak': 'sk', 'hungarian': 'hu', 'romanian': 'ro',
                    'bulgarian': 'bg', 'greek': 'el', 'russian': 'ru', 'ukrainian': 'uk',
                    'swedish': 'sv', 'danish': 'da', 'finnish': 'fi', 'norwegian': 'no',
                    'japanese': 'ja', 'chinese': 'zh', 'korean': 'ko', 'arabic': 'ar',
                    'turkish': 'tr', 'hebrew': 'he'
                }
                
                for lang_name, lang_code in lang_map.items():
                    if lang_name in source_header:
                        source_lang = lang_code
                    if lang_name in target_header:
                        target_lang = lang_code
            
            # SAFETY STEP: Save source segments to TXT file for user verification
            txt_file_path = Path(file_path).with_suffix('.txt')
            try:
                with open(txt_file_path, 'w', encoding='utf-8') as f:
                    f.write('\n'.join(source_segments))
                self.log(f"✓ Extracted {len(source_segments)} segments to: {txt_file_path.name}")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to save TXT file:\n\n{e}")
                return
            
            # Show verification dialog
            reply = QMessageBox.question(
                self,
                "Verify Extracted Segments",
                f"Extracted {len(source_segments)} source segments from memoQ file.\n\n"
                f"A text file has been saved for verification:\n{txt_file_path.name}\n\n"
                f"Please check this file to ensure all segments were extracted correctly.\n\n"
                f"Languages detected: {source_lang.upper()} → {target_lang.upper()}\n\n"
                f"Continue with import?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes
            )
            
            if reply != QMessageBox.StandardButton.Yes:
                self.log("✗ User cancelled memoQ import after extraction")
                return
            
            # Store the bilingual DOCX metadata for later export
            self.memoq_source_file = file_path
            self.memoq_source_segments = source_segments
            self.memoq_target_segments = target_segments
            self.memoq_formatting_map = formatting_map
            self.memoq_metadata = metadata
            
            # Create simple project with sequential IDs
            project_name = Path(file_path).stem
            self.current_project = Project(
                name=project_name,
                source_lang=source_lang,
                target_lang=target_lang,
                segments=[]
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(self.current_project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store memoQ source path in project for persistence across saves
            self.current_project.memoq_source_path = file_path

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Create segments with simple sequential IDs
            for idx, source_text in enumerate(source_segments):
                existing_target = target_segments[idx] if idx < len(target_segments) else ""
                memoq_status_text = metadata[idx].get('status', '') if idx < len(metadata) else ""

                # Use memoQ status parser to extract proper status + match percentage
                if memoq_status_text:
                    status, match_percent = self._interpret_memoq_status(
                        memoq_status_text, bool(existing_target.strip())
                    )
                else:
                    status = "draft" if existing_target.strip() else "not_started"
                    match_percent = None

                segment = Segment(
                    id=idx + 1,  # Sequential: 1, 2, 3, 4...
                    source=source_text,
                    target=existing_target,
                    status=status,
                    match_percent=match_percent,
                    type="para",
                    notes=metadata[idx].get('comment', '') if idx < len(metadata) else "",
                    memoQ_status=memoq_status_text
                )
                self.current_project.segments.append(segment)
            
            # Update UI
            self.project_file_path = None
            self.project_modified = True

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()

            # Clear caches to ensure fresh TM lookups
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # If smart formatting was used, auto-enable Tags view so user sees the tags
            if self.memoq_smart_formatting:
                self._enable_tag_view_after_import()

            self.log(f"✓ Imported memoQ bilingual DOCX: {len(source_segments)} segments from {Path(file_path).name}")

            # Store current document path for AI Assistant
            self.current_document_path = file_path

            # Generate markdown for AI Assistant if enabled
            if hasattr(self, 'auto_generate_markdown') and self.auto_generate_markdown:
                if hasattr(self, 'prompt_manager_qt'):
                    self.prompt_manager_qt.generate_markdown_for_current_document()

            # Refresh AI Assistant context
            if hasattr(self, 'prompt_manager_qt'):
                self.prompt_manager_qt.refresh_context()

            QMessageBox.information(
                self, "Import Successful",
                f"Imported {len(source_segments)} segment(s) from memoQ bilingual DOCX.\n\n"
                f"File: {Path(file_path).name}\n"
                f"Languages: {source_lang} → {target_lang}"
            )
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for memoQ bilingual DOCX import.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import memoQ bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ memoQ import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def import_memoq_rtf(self):
        """Import memoQ bilingual RTF file (table format)"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select memoQ Bilingual RTF File",
            "",
            "RTF Files (*.rtf);;All Files (*.*)"
        )

        if not file_path:
            return

        # Show formatting options dialog (same as DOCX import)
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QGroupBox

        dialog = QDialog(self)
        dialog.setWindowTitle("memoQ Bilingual Import Options")
        dialog.setMinimumWidth(450)
        layout = QVBoxLayout(dialog)

        # Info text
        info_label = QLabel(
            "This workflow imports memoQ bilingual RTF for round-tripping.\n"
            "The project can be exported back to memoQ bilingual RTF format."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
        layout.addWidget(info_label)

        # Formatting options group
        format_group = QGroupBox("Formatting Handling")
        format_layout = QVBoxLayout(format_group)

        # Option 1: Ignore formatting
        ignore_checkbox = CheckmarkCheckBox("Ignore inline formatting")
        ignore_checkbox.setToolTip("Bold, italic, and underline formatting will not be transferred to translations.\nUse this if formatting causes issues or isn't needed.")
        ignore_checkbox.setChecked(False)
        format_layout.addWidget(ignore_checkbox)

        ignore_desc = QLabel("    When checked, formatting (bold/italic/underline) will not be applied to translations.")
        ignore_desc.setStyleSheet("color: #888; font-size: 10px; margin-left: 20px;")
        format_layout.addWidget(ignore_desc)

        # Option 2: Smart formatting
        smart_checkbox = CheckmarkCheckBox("Smart formatting transfer")
        smart_checkbox.setToolTip("Attempts to identify formatted text in source and apply matching formatting to corresponding words in the translation.")
        smart_checkbox.setChecked(True)
        format_layout.addWidget(smart_checkbox)

        smart_desc = QLabel("    When checked, tries to match formatted source phrases to their translations.")
        smart_desc.setStyleSheet("color: #888; font-size: 10px; margin-left: 20px;")
        format_layout.addWidget(smart_desc)

        # Make checkboxes mutually exclusive
        def on_ignore_changed(checked):
            if checked:
                smart_checkbox.setChecked(False)

        def on_smart_changed(checked):
            if checked:
                ignore_checkbox.setChecked(False)

        ignore_checkbox.toggled.connect(on_ignore_changed)
        smart_checkbox.toggled.connect(on_smart_changed)

        layout.addWidget(format_group)

        # Note about tags
        note_label = QLabel(
            "ℹ️ Note: Inline XML tags (like &lt;b&gt;text&lt;/b&gt;) are always preserved regardless of this setting."
        )
        note_label.setWordWrap(True)
        note_label.setStyleSheet("color: #2196F3; font-size: 10px; margin-top: 10px;")
        layout.addWidget(note_label)

        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)

        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        # Store formatting preference
        self.memoq_smart_formatting = smart_checkbox.isChecked()
        self.log(f"📄 memoQ RTF import: {'Smart formatting transfer' if self.memoq_smart_formatting else 'Ignore formatting'}")

        # Check for re-import scenario
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.memoq_rtf_source_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        try:
            from modules.memoqrtf_handler import MemoQRTFHandler

            # Load the bilingual RTF
            handler = MemoQRTFHandler()
            handler.preserve_formatting = self.memoq_smart_formatting
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load memoQ bilingual RTF file.\n\n"
                    "Please ensure the file is a valid memoQ bilingual export."
                )
                return

            if not handler.segments:
                QMessageBox.warning(self, "Warning", "No segments found in the bilingual RTF file.")
                return

            # Extract segments
            source_segments = [seg.source_text for seg in handler.segments]
            target_segments = [seg.target_text for seg in handler.segments]
            metadata = [{
                'comment': seg.comment,
                'status': seg.status,
                'guid': seg.guid,
                'target': seg.target_text
            } for seg in handler.segments]

            # SAFETY STEP: Save source segments to TXT file for user verification
            txt_file_path = Path(file_path).with_suffix('.txt')
            try:
                with open(txt_file_path, 'w', encoding='utf-8') as f:
                    f.write('\n'.join(source_segments))
                self.log(f"✓ Extracted {len(source_segments)} segments to: {txt_file_path.name}")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to save TXT file:\n\n{e}")
                return

            # Show verification dialog
            reply = QMessageBox.question(
                self,
                "Verify Extracted Segments",
                f"Extracted {len(source_segments)} source segments from memoQ RTF file.\n\n"
                f"A text file has been saved for verification:\n{txt_file_path.name}\n\n"
                f"Please check this file to ensure all segments were extracted correctly.\n\n"
                f"Languages detected: {handler.source_lang.upper()} → {handler.target_lang.upper()}\n\n"
                f"Continue with import?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes
            )

            if reply != QMessageBox.StandardButton.Yes:
                self.log("✗ User cancelled memoQ RTF import after extraction")
                return

            # Store the bilingual RTF metadata for later export
            self.memoq_rtf_source_file = file_path
            self.memoq_rtf_handler = handler
            self.memoq_rtf_metadata = metadata

            # Create project
            project_name = Path(file_path).stem
            self.current_project = Project(
                name=project_name,
                source_lang=handler.source_lang,
                target_lang=handler.target_lang,
                segments=[]
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(self.current_project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store memoQ RTF source path in project for persistence across saves
            self.current_project.memoq_rtf_source_path = file_path

            # Sync global language settings
            self.source_language = handler.source_lang
            self.target_language = handler.target_lang

            # Create segments
            for idx, source_text in enumerate(source_segments):
                existing_target = target_segments[idx] if idx < len(target_segments) else ""
                memoq_status_text = metadata[idx].get('status', '') if idx < len(metadata) else ""

                # Use memoQ status parser to extract proper status + match percentage
                if memoq_status_text:
                    status, match_percent = self._interpret_memoq_status(
                        memoq_status_text, bool(existing_target.strip())
                    )
                else:
                    status = "draft" if existing_target.strip() else "not_started"
                    match_percent = None

                segment = Segment(
                    id=idx + 1,
                    source=source_text,
                    target=existing_target,
                    status=status,
                    match_percent=match_percent,
                    type="para",
                    notes=metadata[idx].get('comment', '') if idx < len(metadata) else "",
                    memoQ_status=memoq_status_text
                )
                self.current_project.segments.append(segment)

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self._original_segment_order = self.current_project.segments.copy()
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()

            # Clear caches
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows
            self.auto_resize_rows()

            # Initialize spellcheck
            self._initialize_spellcheck_for_target_language(handler.target_lang)

            # If smart formatting was used, auto-enable Tags view so user sees the tags
            if self.memoq_smart_formatting:
                self._enable_tag_view_after_import()

            self.log(f"✓ Imported memoQ bilingual RTF: {len(source_segments)} segments from {Path(file_path).name}")

            # Store current document path for AI Assistant
            self.current_document_path = file_path

            # Generate markdown for AI Assistant if enabled
            if hasattr(self, 'auto_generate_markdown') and self.auto_generate_markdown:
                if hasattr(self, 'prompt_manager_qt'):
                    self.prompt_manager_qt.generate_markdown_for_current_document()

            # Refresh AI Assistant context
            if hasattr(self, 'prompt_manager_qt'):
                self.prompt_manager_qt.refresh_context()

            QMessageBox.information(
                self, "Import Successful",
                f"Imported {len(source_segments)} segment(s) from memoQ bilingual RTF.\n\n"
                f"File: {Path(file_path).name}\n"
                f"Languages: {handler.source_lang} → {handler.target_lang}"
            )

        except ImportError as e:
            QMessageBox.critical(
                self, "Import Error",
                f"Failed to import memoQ RTF handler module:\n\n{str(e)}"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import memoQ bilingual RTF:\n\n{str(e)}")
            self.log(f"✗ memoQ RTF import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def export_memoq_bilingual(self):
        """Export to memoQ bilingual DOCX format with translations"""
        # Check if we have segments
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return

        # Check if a memoQ source file was imported - check both instance var and project
        memoq_source = None
        if hasattr(self, 'memoq_source_file') and self.memoq_source_file:
            memoq_source = self.memoq_source_file
        elif hasattr(self.current_project, 'memoq_source_path') and self.current_project.memoq_source_path:
            memoq_source = self.current_project.memoq_source_path
            # Verify it still exists
            if Path(memoq_source).exists():
                self.memoq_source_file = memoq_source
                self.log(f"✓ Restored memoQ source from project: {Path(memoq_source).name}")
            else:
                memoq_source = None
        
        if not memoq_source:
            # Prompt user to select the original memoQ bilingual file
            reply = QMessageBox.question(
                self, "Select memoQ Source File",
                "To export to memoQ format, please select the original memoQ bilingual DOCX file.\n\n"
                "This is the file you originally imported from memoQ.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original memoQ Bilingual DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )

                if file_path:
                    self.memoq_source_file = file_path
                    self.log(f"✓ memoQ source file set: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return
        
        try:
            from docx import Document
            from docx.shared import RGBColor
            
            segments = list(self.current_project.segments)
            translations = [seg.target for seg in segments]

            if not translations or all(not t.strip() for t in translations):
                QMessageBox.warning(self, "Warning", "No translations found to export.")
                return
            
            # Load the original bilingual DOCX
            doc = Document(self.memoq_source_file)
            table = doc.tables[0]
            
            # Write translations to target column (column 2) and update status
            segments_updated = 0
            segments_with_formatting = 0
            
            for i, translation in enumerate(translations):
                segment = segments[i]
                row_idx = i + 2  # Skip header rows (0 and 1)

                # Safety check: ensure we don't go beyond available rows
                if row_idx >= len(table.rows):
                    self.log(f"⚠ Warning: Row {row_idx} exceeds table rows ({len(table.rows)}), stopping at segment {i}")
                    break

                row = table.rows[row_idx]
                num_cells = len(row.cells)

                # Write translation to column 2 (target) with formatting
                if num_cells >= 3:
                    target_cell = row.cells[2]

                    # Check for embedded HTML tags first (new approach)
                    if has_formatting_tags(translation):
                        segments_with_formatting += 1
                        formatting_info = None  # Tags are in the text itself
                    else:
                        # Get formatting info directly from source cell (preserves color/formatting across sessions)
                        formatting_info = []
                        if num_cells >= 2:
                            source_cell = row.cells[1]
                            for paragraph in source_cell.paragraphs:
                                for run in paragraph.runs:
                                    if run.text:
                                        # Capture color if present
                                        color_hex = None
                                        try:
                                            # Method 1: Check standard rgb property
                                            if run.font.color and run.font.color.rgb:
                                                color_hex = str(run.font.color.rgb)
                                            # Method 2: Check XML directly for w:color val (more robust)
                                            elif run.element.rPr is not None:
                                                from docx.oxml.ns import qn
                                                color_elem = run.element.rPr.find(qn('w:color'))
                                                if color_elem is not None:
                                                    color_hex = color_elem.get(qn('w:val'))
                                                # Method 3: Check if run uses a character style (e.g., mqInternal)
                                                # memoQ stores tag colors in character styles, not inline
                                                if not color_hex:
                                                    style_elem = run.element.rPr.find(qn('w:rStyle'))
                                                    if style_elem is not None:
                                                        style_id = style_elem.get(qn('w:val'))
                                                        if style_id:
                                                            # Lookup the style and get its color
                                                            try:
                                                                style = doc.styles[style_id]
                                                                if style.font.color and style.font.color.rgb:
                                                                    color_hex = str(style.font.color.rgb)
                                                            except KeyError:
                                                                pass  # Style not found
                                        except Exception as e:
                                            if i == 0: 
                                                print(f"Color extract error: {e}")
                                        
                                        # Split run text by tags to allow granular formatting transfer
                                        # This ensures that if a tag like {1} is merged with text in one run,
                                        # we still extract {1} as a separate formatting unit.
                                        import re
                                        # Matches {1}, [1], <tag>, or </tag>
                                        parts = re.split(r'(\{\d+\}|\[\d+\]|<\/?[a-zA-Z0-9_]+[^>]*>)', run.text)
                                        
                                        for part in parts:
                                            if not part:
                                                continue
                                                
                                            formatting_info.append({
                                                'text': part,
                                                'bold': run.bold == True,
                                                'italic': run.italic == True,
                                                'underline': run.underline == True,
                                                'color': color_hex
                                            })
            
                        # Debug logging for first segment to help troubleshoot
                        if i == 0 and formatting_info:
                            color_tags = [f['text'] for f in formatting_info if f.get('color')]
                            if color_tags:
                                self.log(f"🔍 Debug: Found colored tags in first segment: {color_tags}")

                        if any(f.get('bold') or f.get('italic') or f.get('underline') or f.get('color') for f in formatting_info):
                            segments_with_formatting += 1

                    # Apply formatting to the target cell
                    self._apply_formatting_to_cell(target_cell, translation, formatting_info)
                    segments_updated += 1

                # Update comments in column 3 (if column exists)
                if num_cells >= 4:
                    if segment.notes and segment.notes.strip():
                        row.cells[3].text = segment.notes.strip()

                # Update status column using compose_memoq_status (if column exists)
                if num_cells >= 5:
                    existing = row.cells[4].text
                    row.cells[4].text = compose_memoq_status(segment.status, segment.match_percent, existing)
            
            # Prompt user to save the updated bilingual file
            # Use the same directory as the original import file
            source_path = Path(self.memoq_source_file)
            default_name = str(source_path.parent / (source_path.stem + "_translated.docx"))
            save_path, _ = QFileDialog.getSaveFileName(
                self,
                "Save memoQ Bilingual DOCX",
                default_name,
                "Word Documents (*.docx);;All Files (*.*)"
            )
            
            if save_path:
                doc.save(save_path)
                
                # Build success message
                success_msg = (
                    f"✓ Successfully exported {segments_updated} translation(s) to memoQ bilingual DOCX!\n\n"
                    f"File saved: {Path(save_path).name}\n\n"
                )
                
                if segments_with_formatting > 0:
                    success_msg += (
                        f"✓ Formatting preserved in {segments_with_formatting} segment(s)\n"
                        f"  (bold, italic, underline)\n\n"
                    )
                
                success_msg += (
                    f"✓ Status updated to 'Confirmed'\n"
                    f"✓ Table structure preserved\n\n"
                    f"You can now import this file back into memoQ."
                )
                
                self.log(f"✓ Exported {segments_updated} translations to memoQ bilingual DOCX: {Path(save_path).name}")
                
                QMessageBox.information(self, "Export Successful", success_msg)
        
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for memoQ bilingual DOCX export.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export memoQ bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ memoQ export failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def _apply_formatting_to_cell(self, cell, text, formatting_info=None):
        """
        Apply formatting to a cell based on embedded HTML tags or formatting info from source.
        
        Priority:
        1. If text contains <b>, <i>, <u> tags, parse and apply them directly
        2. Otherwise, use formatting_info for smart transfer (legacy approach)
        3. If neither, just add plain text
        """
        # Clear existing paragraphs
        cell._element.clear_content()
        
        # Add new paragraph
        paragraph = cell.add_paragraph()
        
        if not text:
            return
        
        # PRIORITY 1: Check for embedded HTML tags in the text
        if has_formatting_tags(text):
            # Parse the tags and create runs with formatting
            runs = tagged_text_to_runs(text)
            
            for run_info in runs:
                run_text = run_info.get('text', '')
                if not run_text:
                    continue
                    
                run = paragraph.add_run(run_text)
                
                if run_info.get('bold'):
                    run.bold = True
                if run_info.get('italic'):
                    run.italic = True
                if run_info.get('underline'):
                    run.underline = True
                if run_info.get('subscript'):
                    run.font.subscript = True
                if run_info.get('superscript'):
                    run.font.superscript = True

            return
        
        # PRIORITY 2: Use formatting_info for smart transfer (legacy approach)
        # Default to True so it works across restarts/without re-importing
        use_smart = getattr(self, 'memoq_smart_formatting', True)
        
        if not formatting_info:
            # No formatting info - just add plain text
            paragraph.add_run(text)
            return
        
        if not use_smart:
            # Explicitly disabled by user (only matters if they just imported)
            paragraph.add_run(text)
            return
        
        # Smart formatting transfer algorithm
        # Step 1: Identify formatted regions in source
        formatted_phrases = []
        for fmt in formatting_info:
            fmt_text = fmt.get('text', '')
            if fmt_text and (fmt.get('bold') or fmt.get('italic') or fmt.get('underline') or fmt.get('color')):
                formatted_phrases.append({
                    'text': fmt_text.strip(),
                    'bold': fmt.get('bold', False),
                    'italic': fmt.get('italic', False),
                    'underline': fmt.get('underline', False),
                    'color': fmt.get('color')
                })
        
        if not formatted_phrases:
            # No formatted text found - add plain text
            paragraph.add_run(text)
            return
        
        # Step 2: Try to find matching phrases in target text
        # Build a list of regions to format in the target
        target_lower = text.lower()
        format_regions = []  # List of (start, end, formatting_dict)
        
        for phrase_info in formatted_phrases:
            source_phrase = phrase_info['text'].lower()
            
            # Try exact match first
            idx = target_lower.find(source_phrase)
            if idx >= 0:
                format_regions.append((idx, idx + len(source_phrase), phrase_info))
                continue
            
            # Try word-by-word matching for multi-word phrases
            source_words = source_phrase.split()
            if len(source_words) > 1:
                # Try to find the first and last word to estimate region
                first_word = source_words[0]
                last_word = source_words[-1]
                
                first_idx = target_lower.find(first_word)
                # Search for last word after first word
                if first_idx >= 0:
                    last_idx = target_lower.find(last_word, first_idx)
                    if last_idx >= 0:
                        # Found a plausible region
                        end_idx = last_idx + len(last_word)
                        # Only use if the region is reasonable (not too different in length)
                        region_len = end_idx - first_idx
                        source_len = len(source_phrase)
                        if 0.3 <= region_len / source_len <= 3.0:
                            format_regions.append((first_idx, end_idx, phrase_info))
        
        if not format_regions:
            # No matches found - just add plain text
            paragraph.add_run(text)
            return
        
        # Step 3: Sort regions and merge overlaps
        format_regions.sort(key=lambda x: x[0])
        
        # Step 4: Build the formatted paragraph with runs
        current_pos = 0
        for start, end, fmt_info in format_regions:
            # Ensure we don't go backwards
            if start < current_pos:
                start = current_pos
            if end <= start:
                continue
            
            # Add unformatted text before this region
            if start > current_pos:
                unformatted_text = text[current_pos:start]
                paragraph.add_run(unformatted_text)
            
            # Add formatted text
            formatted_text = text[start:end]
            run = paragraph.add_run(formatted_text)
            
            if fmt_info.get('bold'):
                run.bold = True
            if fmt_info.get('italic'):
                run.italic = True
            if fmt_info.get('underline'):
                run.underline = True
            
            # Apply color if present
            if fmt_info.get('color'):
                try:
                    from docx.shared import RGBColor
                    hex_color = fmt_info['color']
                    # Convert "FF0000" hex to RGB
                    if len(hex_color) == 6:
                        r = int(hex_color[0:2], 16)
                        g = int(hex_color[2:4], 16)
                        b = int(hex_color[4:6], 16)
                        run.font.color.rgb = RGBColor(r, g, b)
                except Exception:
                    pass
            
            current_pos = end
        
        # Add any remaining unformatted text
        if current_pos < len(text):
            paragraph.add_run(text[current_pos:])
    
    # ========================================================================
    # MEMOQ XLIFF IMPORT/EXPORT
    # ========================================================================
    
    def import_memoq_xliff(self):
        """Import memoQ XLIFF (.mqxliff) file"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select memoQ XLIFF File",
            "",
            "memoQ XLIFF (*.mqxliff);;All Files (*.*)"
        )
        
        if not file_path:
            return

        # Check if this is a re-import of the same file
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.mqxliff_source_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        try:
            from modules.mqxliff_handler import MQXLIFFHandler

            # Load the file
            handler = MQXLIFFHandler()
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load memoQ XLIFF file."
                )
                return
            
            # Extract segments (including targets for pretranslated files)
            mqxliff_segments = handler.extract_bilingual_segments()

            if not mqxliff_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments found in the memoQ XLIFF file."
                )
                return

            # Count pretranslated segments
            pretranslated_count = sum(1 for s in mqxliff_segments if s.get('target', '').strip())

            # Convert to internal Segment format
            segments = []
            for i, mq_seg in enumerate(mqxliff_segments):
                # Map status from mqxliff
                status = mq_seg.get('status', 'not_started')
                if status not in ['not_started', 'pre_translated', 'draft', 'translated', 'confirmed', 'locked']:
                    status = 'not_started'

                segment = Segment(
                    id=i + 1,
                    source=mq_seg.get('source', ''),
                    target=mq_seg.get('target', ''),
                    status=status,
                    match_percent=mq_seg.get('match_percent'),
                    notes="",
                )
                segments.append(segment)
            
            # Store the handler and original path for round-trip export
            self.mqxliff_handler = handler
            self.mqxliff_source_file = file_path
            
            # Get language codes from handler
            source_lang = self._normalize_language_code(handler.source_lang)
            target_lang = self._normalize_language_code(handler.target_lang)
            
            # Create new project
            file_name = Path(file_path).stem
            self.current_project = Project(
                name=file_name,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(self.current_project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store memoQ XLIFF source path in project for persistence across saves
            self.current_project.mqxliff_source_path = file_path

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()

            # Clear caches to ensure fresh TM lookups
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Log success
            self.log(f"✓ Imported {len(segments)} segments from memoQ XLIFF: {Path(file_path).name}")
            self.log(f"  Source: {source_lang}, Target: {target_lang}")
            if pretranslated_count > 0:
                self.log(f"  Pretranslated: {pretranslated_count} segments with target text")

            # Build message with pretranslation info
            msg = f"Successfully imported {len(segments)} segment(s) from memoQ XLIFF.\n\nLanguages: {source_lang} → {target_lang}"
            if pretranslated_count > 0:
                msg += f"\n\nPretranslated: {pretranslated_count} segment(s) with target text loaded."

            QMessageBox.information(
                self, "Import Successful",
                msg
            )
        except Exception as e:
            self.log(f"❌ Error importing memoQ XLIFF: {e}")
            QMessageBox.critical(
                self, "Import Error",
                f"Error importing memoQ XLIFF file:\n\n{str(e)}"
            )
    
    def _normalize_language_code(self, lang_code: str) -> str:
        """Convert ISO language codes to full language names."""
        # Common ISO 639-1 codes to full names
        lang_map = {
            'en': 'English', 'en-US': 'English', 'en-GB': 'English',
            'nl': 'Dutch', 'nl-NL': 'Dutch', 'nl-BE': 'Dutch',
            'de': 'German', 'de-DE': 'German', 'de-AT': 'German',
            'fr': 'French', 'fr-FR': 'French', 'fr-BE': 'French',
            'es': 'Spanish', 'es-ES': 'Spanish', 'es-MX': 'Spanish',
            'it': 'Italian', 'it-IT': 'Italian',
            'pt': 'Portuguese', 'pt-PT': 'Portuguese', 'pt-BR': 'Portuguese',
            'pl': 'Polish', 'pl-PL': 'Polish',
            'cs': 'Czech', 'cs-CZ': 'Czech',
            'sk': 'Slovak', 'sk-SK': 'Slovak',
            'hu': 'Hungarian', 'hu-HU': 'Hungarian',
            'ro': 'Romanian', 'ro-RO': 'Romanian',
            'bg': 'Bulgarian', 'bg-BG': 'Bulgarian',
            'el': 'Greek', 'el-GR': 'Greek',
            'ru': 'Russian', 'ru-RU': 'Russian',
            'uk': 'Ukrainian', 'uk-UA': 'Ukrainian',
            'ja': 'Japanese', 'ja-JP': 'Japanese',
            'zh': 'Chinese', 'zh-CN': 'Chinese', 'zh-TW': 'Chinese',
            'ko': 'Korean', 'ko-KR': 'Korean',
            'ar': 'Arabic', 'ar-SA': 'Arabic',
            'he': 'Hebrew', 'he-IL': 'Hebrew',
            'tr': 'Turkish', 'tr-TR': 'Turkish',
            'sv': 'Swedish', 'sv-SE': 'Swedish',
            'da': 'Danish', 'da-DK': 'Danish',
            'fi': 'Finnish', 'fi-FI': 'Finnish',
            'no': 'Norwegian', 'nb-NO': 'Norwegian', 'nn-NO': 'Norwegian',
        }
        
        # Try exact match first
        if lang_code in lang_map:
            return lang_map[lang_code]
        
        # Try lowercase match
        lang_lower = lang_code.lower()
        if lang_lower in lang_map:
            return lang_map[lang_lower]
        
        # Try base code (before hyphen)
        if '-' in lang_code:
            base_code = lang_code.split('-')[0].lower()
            if base_code in lang_map:
                return lang_map[base_code]
        
        # Return original if no match found
        return lang_code

    def export_memoq_rtf(self):
        """Export to memoQ bilingual RTF format with translations"""
        # Check if we have segments
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return

        # Check if a memoQ RTF source file was imported
        memoq_rtf_source = None
        if hasattr(self, 'memoq_rtf_source_file') and self.memoq_rtf_source_file:
            memoq_rtf_source = self.memoq_rtf_source_file
        elif hasattr(self.current_project, 'memoq_rtf_source_path') and self.current_project.memoq_rtf_source_path:
            memoq_rtf_source = self.current_project.memoq_rtf_source_path
            if Path(memoq_rtf_source).exists():
                self.memoq_rtf_source_file = memoq_rtf_source
                self.log(f"✓ Restored memoQ RTF source from project: {Path(memoq_rtf_source).name}")
            else:
                memoq_rtf_source = None

        if not memoq_rtf_source:
            # Prompt user to select the original memoQ bilingual RTF file
            reply = QMessageBox.question(
                self, "Select memoQ Source File",
                "To export to memoQ RTF format, please select the original memoQ bilingual RTF file.\n\n"
                "This is the file you originally imported from memoQ.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original memoQ Bilingual RTF",
                    "",
                    "RTF Files (*.rtf);;All Files (*.*)"
                )

                if file_path:
                    self.memoq_rtf_source_file = file_path
                    self.log(f"✓ memoQ RTF source file set: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return

        try:
            from modules.memoqrtf_handler import MemoQRTFHandler

            segments = list(self.current_project.segments)
            translations = [seg.target for seg in segments]

            if not translations or all(not t.strip() for t in translations):
                QMessageBox.warning(self, "Warning", "No translations found to export.")
                return

            # Load or reuse the RTF handler
            if hasattr(self, 'memoq_rtf_handler') and self.memoq_rtf_handler:
                handler = self.memoq_rtf_handler
            else:
                handler = MemoQRTFHandler()
                if not handler.load(self.memoq_rtf_source_file):
                    QMessageBox.critical(
                        self, "Error",
                        "Failed to load the original memoQ bilingual RTF file."
                    )
                    return
                self.memoq_rtf_handler = handler

            # Update translations in handler
            segments_updated = 0
            for i, translation in enumerate(translations):
                if i < len(handler.segments):
                    handler.segments[i].target_text = translation
                    if translation.strip():
                        segments_updated += 1

            # Prompt for output file
            default_name = Path(self.memoq_rtf_source_file).stem + "_translated.rtf"
            default_dir = str(Path(self.memoq_rtf_source_file).parent)

            output_path, _ = QFileDialog.getSaveFileName(
                self,
                "Save Translated memoQ Bilingual RTF",
                str(Path(default_dir) / default_name),
                "RTF Files (*.rtf);;All Files (*.*)"
            )

            if not output_path:
                self.log("Export cancelled by user")
                return

            # Save the modified RTF
            if handler.save(output_path):
                self.log(f"✓ Exported memoQ RTF: {segments_updated} translations to {Path(output_path).name}")

                QMessageBox.information(
                    self, "Export Successful",
                    f"Exported {segments_updated} translation(s) to memoQ bilingual RTF.\n\n"
                    f"File: {Path(output_path).name}\n\n"
                    f"You can now import this file back into memoQ."
                )
            else:
                QMessageBox.critical(self, "Export Error", "Failed to save the translated RTF file.")

        except ImportError as e:
            QMessageBox.critical(
                self, "Export Error",
                f"Failed to import memoQ RTF handler module:\n\n{str(e)}"
            )
        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export memoQ bilingual RTF:\n\n{str(e)}")
            self.log(f"✗ memoQ RTF export failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def export_memoq_xliff(self):
        """Export to memoQ XLIFF format with translations"""
        # Check if we have segments
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return
        
        # Check if a memoQ XLIFF source file was imported - check both instance var and project
        mqxliff_source = None
        if hasattr(self, 'mqxliff_source_file') and self.mqxliff_source_file:
            mqxliff_source = self.mqxliff_source_file
        elif hasattr(self.current_project, 'mqxliff_source_path') and self.current_project.mqxliff_source_path:
            mqxliff_source = self.current_project.mqxliff_source_path
            # Verify it still exists
            if Path(mqxliff_source).exists():
                self.mqxliff_source_file = mqxliff_source
                self.log(f"✓ Restored memoQ XLIFF source from project: {Path(mqxliff_source).name}")
                # Reload the handler
                try:
                    from modules.mqxliff_handler import MQXLIFFHandler
                    self.mqxliff_handler = MQXLIFFHandler()
                    if not self.mqxliff_handler.load(mqxliff_source):
                        QMessageBox.critical(self, "Error", "Failed to reload memoQ XLIFF source file")
                        return
                except Exception as e:
                    QMessageBox.critical(self, "Error", f"Failed to reload memoQ XLIFF file: {str(e)}")
                    return
            else:
                mqxliff_source = None
        
        if not mqxliff_source:
            # Prompt user to select the original memoQ XLIFF file
            reply = QMessageBox.question(
                self, "Select memoQ XLIFF Source File",
                "To export to memoQ XLIFF format, please select the original memoQ XLIFF file.\n\n"
                "This is the file you originally imported from memoQ.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original memoQ XLIFF File",
                    "",
                    "memoQ XLIFF (*.mqxliff);;All Files (*.*)"
                )
                
                if file_path:
                    self.mqxliff_source_file = file_path
                    self.log(f"✓ memoQ XLIFF source file set: {Path(file_path).name}")
                    
                    # Load the handler
                    try:
                        from modules.mqxliff_handler import MQXLIFFHandler
                        self.mqxliff_handler = MQXLIFFHandler()
                        if not self.mqxliff_handler.load(file_path):
                            QMessageBox.critical(self, "Error", "Failed to load memoQ XLIFF source file")
                            return
                    except Exception as e:
                        QMessageBox.critical(self, "Error", f"Failed to load memoQ XLIFF file: {str(e)}")
                        return
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return
        
        # Get output file path
        default_name = Path(self.mqxliff_source_file).stem + "_translated.mqxliff"
        output_path, _ = QFileDialog.getSaveFileName(
            self,
            "Save Translated memoQ XLIFF",
            default_name,
            "memoQ XLIFF (*.mqxliff);;All Files (*.*)"
        )
        
        if not output_path:
            return
        
        try:
            # Get translations from current segments
            translations = [seg.target for seg in self.current_project.segments]
            
            # Update the handler with translations
            updated_count = self.mqxliff_handler.update_target_segments(translations)
            
            # Save the updated file
            if self.mqxliff_handler.save(output_path):
                self.log(f"✓ Exported {updated_count} segments to memoQ XLIFF: {Path(output_path).name}")
                QMessageBox.information(
                    self, "Export Successful",
                    f"Successfully exported {updated_count} translated segment(s) to memoQ XLIFF.\n\n"
                    f"File: {Path(output_path).name}\n\n"
                    f"You can now import this file back into memoQ."
                )
            else:
                QMessageBox.critical(
                    self, "Export Error",
                    "Failed to save memoQ XLIFF file."
                )
        except Exception as e:
            self.log(f"❌ Error exporting memoQ XLIFF: {e}")
            QMessageBox.critical(
                self, "Export Error",
                f"Error exporting memoQ XLIFF file:\n\n{str(e)}"
            )
    
    # ========================================================================
    # CAFETRAN BILINGUAL DOCX IMPORT/EXPORT
    # ========================================================================
    
    def import_cafetran_bilingual(self):
        """Import CafeTran bilingual DOCX file (table format with pipe symbols)"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select CafeTran Bilingual DOCX File",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return

        # Check if this is a re-import of the same file
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.cafetran_source_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        try:
            from modules.cafetran_docx_handler import CafeTranDOCXHandler

            # Check if this is a valid CafeTran bilingual DOCX
            if not CafeTranDOCXHandler.is_cafetran_bilingual_docx(file_path):
                QMessageBox.warning(
                    self, "Invalid Format",
                    "This file does not appear to be a CafeTran bilingual DOCX.\n\n"
                    "Expected format: Table with columns ID | Source | Target | Notes | *"
                )
                return
            
            # Load the file
            handler = CafeTranDOCXHandler()
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load CafeTran bilingual DOCX file."
                )
                return
            
            # Extract segments
            cafetran_segments = handler.extract_source_segments()
            
            if not cafetran_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments found in the CafeTran bilingual DOCX file."
                )
                return
            
            # Convert to internal Segment format - preserve pipe symbols for round-trip
            segments = []
            for i, ct_seg in enumerate(cafetran_segments):
                segment = Segment(
                    id=i + 1,
                    source=ct_seg.source_with_pipes,  # Preserve pipe symbols for formatting
                    target=ct_seg.target_with_pipes if ct_seg.target_with_pipes else "",
                    status=STATUSES["pretranslated"].key if ct_seg.target_with_pipes else DEFAULT_STATUS.key,
                    notes=ct_seg.notes,
                )
                segments.append(segment)
            
            # Store the handler and original path for round-trip export
            self.cafetran_handler = handler
            self.cafetran_source_file = file_path
            
            # Enable CafeTran-specific highlighting (red pipe symbols)
            TagHighlighter._is_cafetran_project = True
            
            # Create new project
            file_name = Path(file_path).stem
            self.current_project = Project(
                name=file_name,
                segments=segments,
                source_lang=self.source_lang_combo.currentText() if hasattr(self, 'source_lang_combo') else "en",
                target_lang=self.target_lang_combo.currentText() if hasattr(self, 'target_lang_combo') else "nl"
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(self.current_project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store CafeTran source path in project for persistence across saves
            self.current_project.cafetran_source_path = file_path

            # Sync global language settings with imported project languages
            self.source_language = self.current_project.source_lang
            self.target_language = self.current_project.target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()

            # Clear caches to ensure fresh TM lookups
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            target_lang = self.current_project.target_lang if self.current_project else 'nl'
            self._initialize_spellcheck_for_target_language(target_lang)

            # Log success
            self.log(f"✓ Imported {len(segments)} segments from CafeTran bilingual DOCX: {Path(file_path).name}")
            
            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from CafeTran bilingual DOCX.\n\n"
                f"File: {Path(file_path).name}\n\n"
                f"Note: Pipe symbol formatting (|) is preserved for export.\n"
                f"You can export back to CafeTran format after translation."
            )
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for CafeTran bilingual DOCX import.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import CafeTran bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ CafeTran import failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def import_trados_bilingual(self):
        """Import Trados Studio bilingual review DOCX file"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Trados Bilingual Review DOCX File",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        # Show important workflow dialog with critical preparation steps
        workflow_dialog = QMessageBox(self)
        workflow_dialog.setWindowTitle("⚠️ Important: Trados Bilingual Review Workflow")
        workflow_dialog.setIcon(QMessageBox.Icon.Warning)
        workflow_dialog.setText(
            "<b>The Trados bilingual review format requires specific preparation!</b><br><br>"
            "This format is designed for <b>review only</b>, not translation from scratch. "
            "For the round-trip to work correctly, you must prepare the file in Trados first."
        )
        workflow_dialog.setInformativeText(
            "<b>Required Preparation Steps in Trados Studio:</b><br><br>"
            "1. Import your document into Trados Studio<br>"
            "2. Select all segments: click the first segment <i>number</i>, "
            "scroll to the bottom, then <b>Shift+click</b> the last segment number<br>"
            "3. Copy source to target for all segments<br>"
            "4. Save project<br>"
            "5. Export → Bilingual Review (DOCX)<br>"
            "6. Open in Word, select all target text, delete it, save<br><br>"
            "<b>Then in Supervertaler:</b><br>"
            "7. Import the prepared bilingual review DOCX<br>"
            "8. Translate with AI<br>"
            "9. Export → Trados Studio → Bilingual Review<br>"
            "10. <b>Rename</b> the exported file to match the original Trados export name<br>"
            "&nbsp;&nbsp;&nbsp;&nbsp;<i>Supervertaler adds \"_translated\" to the filename, e.g.:<br>"
            "&nbsp;&nbsp;&nbsp;&nbsp;MyFile_review<b>_translated</b>.docx → rename to MyFile_review.docx</i><br>"
            "11. Update from bilingual review in Trados<br><br>"
            "<i>See Help → CAT Tool Workflow Guide for details.</i>"
        )
        workflow_dialog.setStandardButtons(
            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
        )
        workflow_dialog.setDefaultButton(QMessageBox.StandardButton.Ok)
        workflow_dialog.button(QMessageBox.StandardButton.Ok).setText("I understand, continue")
        workflow_dialog.button(QMessageBox.StandardButton.Cancel).setText("Cancel import")
        
        if workflow_dialog.exec() != QMessageBox.StandardButton.Ok:
            self.log("✗ User cancelled Trados import")
            return

        # Check if this is a re-import of the same file
        reimport_mode, preserved_id, preserved_tm, preserved_tb = self._check_reimport_same_file(
            file_path, ['current_project.trados_source_path']
        )
        if reimport_mode is None:  # User cancelled
            return

        try:
            from modules.trados_docx_handler import TradosDOCXHandler, detect_bilingual_docx_type
            
            # Check if this is a valid Trados bilingual DOCX
            file_type = detect_bilingual_docx_type(file_path)
            if file_type != "trados":
                QMessageBox.warning(
                    self, "Invalid Format",
                    "This file does not appear to be a Trados bilingual review DOCX.\n\n"
                    "Expected format: Table with columns:\n"
                    "Segment ID | Segment status | Source segment | Target segment"
                )
                return
            
            # Load the file
            handler = TradosDOCXHandler()
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load Trados bilingual review DOCX file."
                )
                return
            
            # Extract segments
            trados_segments = handler.extract_source_segments()
            
            if not trados_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments found in the Trados bilingual review DOCX file."
                )
                return
            
            # Convert to internal Segment format - preserve tags for round-trip
            segments = []
            for i, t_seg in enumerate(trados_segments):
                segment = Segment(
                    id=i + 1,
                    source=t_seg.source_text,  # Preserve tags like <11>text</11>
                    target=t_seg.target_text if t_seg.target_text else "",
                    status=STATUSES["pretranslated"].key if t_seg.target_text else DEFAULT_STATUS.key,
                    notes=f"Trados Status: {t_seg.status}",
                )
                segments.append(segment)
            
            # Store the handler and original path for round-trip export
            self.trados_handler = handler
            self.trados_source_file = file_path
            
            # Note: trados_source_path will be set on the project after it's created
            
            # Show language selection dialog (Trados files don't specify languages)
            from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QDialogButtonBox, QGroupBox
            
            lang_dialog = QDialog(self)
            lang_dialog.setWindowTitle("Select Languages")
            lang_dialog.setMinimumWidth(350)
            lang_layout = QVBoxLayout(lang_dialog)
            
            # Info text
            info_label = QLabel(
                "Trados bilingual review files do not specify the source and target languages.\n"
                "Please select the correct language pair for this project:"
            )
            info_label.setWordWrap(True)
            info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
            lang_layout.addWidget(info_label)
            
            # Language group
            lang_group = QGroupBox("Language Pair")
            lang_group_layout = QVBoxLayout(lang_group)
            
            # Source language
            source_row = QHBoxLayout()
            source_row.addWidget(QLabel("Source Language:"))
            source_combo = QComboBox()
            # Full language list (same as New Project dialog)
            available_languages = [
                "Afrikaans", "Albanian", "Arabic", "Armenian", "Basque", "Bengali",
                "Bulgarian", "Catalan", "Chinese (Simplified)", "Chinese (Traditional)",
                "Croatian", "Czech", "Danish", "Dutch", "English", "Estonian",
                "Finnish", "French", "Galician", "Georgian", "German", "Greek",
                "Hebrew", "Hindi", "Hungarian", "Icelandic", "Indonesian", "Irish",
                "Italian", "Japanese", "Korean", "Latvian", "Lithuanian", "Macedonian",
                "Malay", "Norwegian", "Persian", "Polish", "Portuguese", "Romanian",
                "Russian", "Serbian", "Slovak", "Slovenian", "Spanish", "Swahili",
                "Swedish", "Thai", "Turkish", "Ukrainian", "Urdu", "Vietnamese", "Welsh"
            ]
            source_combo.addItems(available_languages)
            # Try to match current UI selection
            current_source = self.source_lang_combo.currentText() if hasattr(self, 'source_lang_combo') else "English"
            source_idx = source_combo.findText(current_source)
            if source_idx >= 0:
                source_combo.setCurrentIndex(source_idx)
            source_row.addWidget(source_combo)
            lang_group_layout.addLayout(source_row)

            # Target language
            target_row = QHBoxLayout()
            target_row.addWidget(QLabel("Target Language:"))
            target_combo = QComboBox()
            target_combo.addItems(available_languages)
            # Try to match current UI selection
            current_target = self.target_lang_combo.currentText() if hasattr(self, 'target_lang_combo') else "Dutch"
            target_idx = target_combo.findText(current_target)
            if target_idx >= 0:
                target_combo.setCurrentIndex(target_idx)
            target_row.addWidget(target_combo)
            lang_group_layout.addLayout(target_row)

            lang_layout.addWidget(lang_group)

            # Buttons
            lang_buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
            lang_buttons.accepted.connect(lang_dialog.accept)
            lang_buttons.rejected.connect(lang_dialog.reject)
            lang_layout.addWidget(lang_buttons)

            if lang_dialog.exec() != QDialog.DialogCode.Accepted:
                self.log("✗ User cancelled Trados import during language selection")
                return
            
            source_lang = source_combo.currentText()
            target_lang = target_combo.currentText()
            
            # Create new project with user-selected languages
            file_name = Path(file_path).stem
            self.current_project = Project(
                name=file_name,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang
            )

            # Apply preserved settings if re-importing
            self._apply_reimport_settings(self.current_project, reimport_mode, preserved_id, preserved_tm, preserved_tb)

            # Store Trados source path in project for persistence across saves
            self.current_project.trados_source_path = file_path

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()

            # Clear caches to ensure fresh TM lookups
            self._clear_caches_after_import()

            # Only deactivate resources for truly NEW projects (not re-imports)
            if not reimport_mode:
                self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Count segments with tags
            tagged_count = sum(1 for s in trados_segments if s.source_tags)
            
            # Log success
            self.log(f"✓ Imported {len(segments)} segments from Trados bilingual DOCX: {Path(file_path).name}")
            if tagged_count:
                self.log(f"  {tagged_count} segments contain inline tags")
            
            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from Trados bilingual review DOCX.\n\n"
                f"File: {Path(file_path).name}\n"
                f"Languages: {source_lang} → {target_lang}\n"
                f"Segments with tags: {tagged_count}\n\n"
                f"Note: Inline tags (e.g., <11>text</11>) are preserved for export.\n"
                f"Export back to Trados format after translation to maintain tag styling."
            )
            
        except ImportError as e:
            QMessageBox.critical(
                self, "Missing Dependency",
                f"Required library is missing: {e}\n\n"
                "Install dependencies with: pip install python-docx lxml"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import Trados bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ Trados import failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def export_trados_bilingual(self):
        """Export translations back to Trados bilingual review DOCX format"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "No project is currently loaded.")
            return
        
        # Check if we have a Trados source - either from handler or project data
        trados_source = None
        
        # First check if handler is already loaded
        if hasattr(self, 'trados_handler') and self.trados_handler:
            trados_source = getattr(self, 'trados_source_file', None)
        
        # If not, check if project has the source path saved
        if not trados_source and hasattr(self.current_project, 'trados_source_path') and self.current_project.trados_source_path:
            trados_source = self.current_project.trados_source_path
        
        if not trados_source:
            # Prompt user to select the original Trados bilingual file
            reply = QMessageBox.question(
                self, "Select Trados Source File",
                "To export to Trados format, please select the original Trados bilingual review DOCX file.\n\n"
                "This is the file you originally imported from Trados.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Trados Bilingual Review DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )
                
                if file_path:
                    trados_source = file_path
                    self.trados_source_file = file_path
                    # Also save to project for future exports
                    self.current_project.trados_source_path = file_path
                    self.log(f"✓ Trados source file set: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return
        
        # Check if source file still exists
        if not Path(trados_source).exists():
            # File not found - give user a chance to locate it
            reply = QMessageBox.question(
                self, "Source File Not Found",
                f"The original Trados source file cannot be found:\n\n"
                f"{trados_source}\n\n"
                f"Would you like to browse for the file in its new location?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Trados Bilingual Review DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )
                
                if file_path:
                    trados_source = file_path
                    self.trados_source_file = file_path
                    self.current_project.trados_source_path = file_path
                    self.log(f"✓ Trados source file relocated: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return
        
        # Reload the handler if needed
        if not hasattr(self, 'trados_handler') or not self.trados_handler:
            try:
                from modules.trados_docx_handler import TradosDOCXHandler
                self.trados_handler = TradosDOCXHandler()
                if not self.trados_handler.load(trados_source):
                    QMessageBox.critical(self, "Error", "Failed to reload the Trados source file.")
                    return
                self.trados_source_file = trados_source
                self.log(f"✓ Reloaded Trados source file for export")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to reload Trados source:\n\n{str(e)}")
                return
        
        # Suggest output filename
        source_path = Path(self.trados_source_file)
        suggested_name = source_path.stem + "_translated" + source_path.suffix
        
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Trados Bilingual DOCX",
            str(source_path.parent / suggested_name),
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        try:
            # Collect translations from grid
            translations = {}
            for row in range(self.table.rowCount()):
                target_widget = self.table.cellWidget(row, 3)  # Target column
                if target_widget:
                    target_text = target_widget.toPlainText().strip()
                    # v1.9.306: Strip invisible markers before export
                    if hasattr(self, 'reverse_invisible_replacements'):
                        target_text = self.reverse_invisible_replacements(target_text).strip()
                    if target_text:
                        # Row index in handler is 1-based (row 0 is header)
                        translations[row + 1] = target_text
            
            # Update the handler with translations
            updated = self.trados_handler.update_target_segments(translations)
            
            # Save to new file
            if self.trados_handler.save(file_path):
                self.log(f"✓ Exported {updated} translated segments to Trados DOCX: {Path(file_path).name}")
                
                QMessageBox.information(
                    self, "Export Successful",
                    f"Successfully exported {updated} translation(s) to Trados bilingual DOCX.\n\n"
                    f"File: {Path(file_path).name}\n\n"
                    f"This file can be imported back into Trados Studio.\n"
                    f"Tag formatting has been preserved."
                )
            else:
                QMessageBox.critical(self, "Export Error", "Failed to save the Trados bilingual DOCX file.")
                
        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export Trados bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ Trados export failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def import_sdlppx_package(self):
        """Import a Trados Studio SDLPPX package file."""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Trados Studio Package (SDLPPX)",
            "",
            "Trados Packages (*.sdlppx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        try:
            from modules.sdlppx_handler import TradosPackageHandler

            # Load the package behind a progress dialog so large packages
            # (hundreds of SDLXLIFFs) don't look frozen during extraction/parsing.
            load_progress = QProgressDialog(
                f"Opening {Path(file_path).name}...", "Cancel", 0, 100, self
            )
            load_progress.setWindowTitle("Importing Trados Package")
            load_progress.setWindowModality(Qt.WindowModality.WindowModal)
            load_progress.setMinimumDuration(0)
            load_progress.setAutoClose(False)
            load_progress.setAutoReset(False)
            load_progress.setValue(0)
            QApplication.processEvents()

            def _on_load_progress(stage: str, current: int, total: int, message: str) -> bool:
                if load_progress.wasCanceled():
                    return False
                if stage == 'extract':
                    # Extraction is a single zipfile.extractall() call – can't
                    # stream progress through it; show indeterminate-style label.
                    load_progress.setRange(0, 0)
                    load_progress.setLabelText(message)
                elif stage == 'parse':
                    load_progress.setRange(0, max(total, 1))
                    load_progress.setValue(current)
                    load_progress.setLabelText(
                        f"Parsing SDLXLIFF: {message} ({current}/{total})"
                        if total else f"Parsing: {message}"
                    )
                QApplication.processEvents()
                return not load_progress.wasCanceled()

            handler = TradosPackageHandler(log_callback=self.log)
            package = handler.load_package(file_path, progress_callback=_on_load_progress)
            load_progress.close()

            if not package:
                if load_progress.wasCanceled():
                    self.log("❌ SDLPPX load cancelled by user")
                    handler.cleanup()
                    return
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load the Trados Studio package.\n\n"
                    "Please ensure the file is a valid .sdlppx file."
                )
                return
            
            # Show package info dialog
            from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDialogButtonBox, QGroupBox, QTextEdit
            
            info_dialog = QDialog(self)
            info_dialog.setWindowTitle("Trados Package Information")
            info_dialog.setMinimumWidth(500)
            info_layout = QVBoxLayout(info_dialog)
            
            # Project info and file list
            info_text = f"<b>Project:</b> {package.project_name}<br>"
            info_text += f"<b>Languages:</b> {package.source_lang} → {package.target_lang}<br>"
            
            # Count segments and locked segments
            total_segments = sum(len(f.segments) for f in package.xliff_files)
            locked_count = sum(
                1 for f in package.xliff_files for s in f.segments
                if s.locked or s.trans_unit_id.startswith('lockTU_')
            )
            translatable_count = total_segments - locked_count
            file_info = [(Path(f.file_path).name, len(f.segments)) for f in package.xliff_files]

            info_text += f"<b>Total segments:</b> {total_segments}"
            if locked_count:
                info_text += f" ({translatable_count} translatable, {locked_count} locked)"
            info_text += "<br><br>"
            info_text += "<b>Files in package:</b><br>"
            for fname, count in file_info:
                info_text += f"  • {fname}: {count} segments<br>"

            info_label = QLabel(info_text)
            info_label.setWordWrap(True)
            info_layout.addWidget(info_label)

            # Option to include locked segments
            include_locked_cb = None
            if locked_count:
                include_locked_cb = CheckmarkCheckBox(
                    f"Include locked/non-translatable segments for context ({locked_count} segments)"
                )
                include_locked_cb.setChecked(False)
                include_locked_cb.setToolTip(
                    "Locked segments are non-translatable content that Trados marks as protected.\n"
                    "Including them provides context but may slow loading for large packages."
                )
                info_layout.addWidget(include_locked_cb)
                info_layout.addSpacing(5)

            # Buttons
            buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
            buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Import")
            buttons.accepted.connect(info_dialog.accept)
            buttons.rejected.connect(info_dialog.reject)
            info_layout.addWidget(buttons)
            
            if info_dialog.exec() != QDialog.DialogCode.Accepted:
                self.log("✗ User cancelled SDLPPX import")
                handler.cleanup()
                return
            
            # Build segments per-file so the multi-file views system activates.
            # Filter out lock TU structural segments always; optionally include
            # locked (translate="no") segments if user checked the box.
            include_locked = (include_locked_cb.isChecked()
                              if include_locked_cb else False)
            is_multifile = len(package.xliff_files) > 1
            segments = []
            file_metadata = []
            global_index = 0
            total_locked_filtered = 0

            # Progress dialog so large packages don't look frozen
            total_files = len(package.xliff_files)
            progress_dialog = QProgressDialog(
                "Building segments...", "Cancel", 0, total_files, self
            )
            progress_dialog.setWindowTitle("Importing Trados Package")
            progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
            progress_dialog.setMinimumDuration(0)

            cancelled = False
            for file_idx, xliff_file in enumerate(package.xliff_files):
                if progress_dialog.wasCanceled():
                    cancelled = True
                    break

                file_id = file_idx + 1
                file_name = Path(xliff_file.file_path).name
                file_start = global_index

                progress_dialog.setValue(file_idx)
                progress_dialog.setLabelText(
                    f"Building segments: {file_name} ({file_idx + 1}/{total_files})"
                )
                QApplication.processEvents()

                for sdl_seg in xliff_file.segments:
                    # Lock TU structural segments are always excluded
                    if sdl_seg.trans_unit_id.startswith('lockTU_'):
                        total_locked_filtered += 1
                        continue
                    # Locked (translate="no") segments: include or skip
                    is_locked = sdl_seg.locked
                    if is_locked and not include_locked:
                        total_locked_filtered += 1
                        continue
                    seg = self._map_sdlxliff_segment(
                        sdl_seg, global_index,
                        file_id=file_id if is_multifile else None,
                        file_name=file_name if is_multifile else "",
                        locked=is_locked
                    )
                    segments.append(seg)
                    global_index += 1

                if is_multifile:
                    file_segs = segments[file_start:global_index]
                    file_metadata.append({
                        'id': file_id,
                        'name': file_name,
                        'path': xliff_file.file_path,
                        'type': 'sdlxliff',
                        'segment_count': len(file_segs),
                        'start_segment_id': file_segs[0].id if file_segs else None,
                        'end_segment_id': file_segs[-1].id if file_segs else None,
                    })

            progress_dialog.setValue(total_files)

            if cancelled:
                self.log("❌ SDLPPX import cancelled by user")
                handler.cleanup()
                return

            if total_locked_filtered:
                self.log(f"Filtered {total_locked_filtered} "
                         f"locked/non-translatable segments "
                         f"({len(segments)} translatable)")

            if not segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No translatable segments found in the package."
                )
                handler.cleanup()
                return

            # Normalize language codes to full names
            source_lang = self._normalize_language_code(package.source_lang)
            target_lang = self._normalize_language_code(package.target_lang)

            # Create new project (multi-file when package has multiple SDLXLIFF files)
            self.current_project = Project(
                name=package.project_name or Path(file_path).stem,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang,
                is_multifile=is_multifile,
                files=file_metadata if is_multifile else None
            )
            
            # Store handler and package info for round-trip export
            self.sdlppx_handler = handler
            self.sdlppx_source_file = file_path
            self.current_project.sdlppx_source_path = file_path

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()
            self._clear_caches_after_import()
            self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Activate multi-file views dropdown if applicable
            self._update_file_filter_combo()

            # Count pretranslated segments
            pretrans_count = sum(1 for s in segments if s.target)

            self.log(f"✓ Imported {len(segments)} segments from Trados package: {Path(file_path).name}")
            if pretrans_count:
                self.log(f"  {pretrans_count} segments are pretranslated")
            if is_multifile:
                self.log(f"  {len(file_metadata)} files in package (multi-file view active)")

            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from Trados Studio package.\n\n"
                f"Package: {Path(file_path).name}\n"
                f"Files: {len(package.xliff_files)}\n"
                f"Languages: {source_lang} → {target_lang}\n"
                f"Pretranslated: {pretrans_count}\n\n"
                f"After translation, export back as SDLRPX to return to the Trados user."
            )
            
        except ImportError as e:
            QMessageBox.critical(
                self, "Missing Dependency",
                f"Required library is missing: {e}\n\n"
                "Install dependencies with: pip install lxml"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import Trados package:\n\n{str(e)}")
            self.log(f"✗ SDLPPX import failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def get_translator_name(self) -> str:
        """Return the user-configured translator name, falling back to system username."""
        settings = self.load_general_settings()
        name = settings.get('translator_name', '').strip()
        if name:
            return name
        return os.environ.get('USERNAME', os.environ.get('USER', 'user'))

    def _build_sdlxliff_translations_dict(self):
        """Build segment_id -> target_text mapping for SDLXLIFF export.

        Uses sdl_segment_id field (preferred) with fallback to parsing
        from notes for backward compatibility with old project files.
        """
        translations = {}
        for segment in self.current_project.segments:
            target_text = segment.target.strip() if segment.target else ''
            if not target_text:
                continue
            # Primary: dedicated sdl_segment_id field
            seg_id = getattr(segment, 'sdl_segment_id', '')
            # Backward compat: parse from notes (old project files)
            if not seg_id:
                notes = getattr(segment, 'notes', '') or ''
                if "Segment:" in notes:
                    for part in notes.split("|"):
                        part = part.strip()
                        if part.startswith("Segment:"):
                            seg_id = part.replace("Segment:", "").strip()
                            break
            if seg_id:
                translations[seg_id] = target_text
        return translations

    def _build_sdlxliff_comments_dict(self):
        """Build segment_id -> comment_text mapping for SDLXLIFF comment export."""
        segment_comments = {}
        for segment in self.current_project.segments:
            seg_id = getattr(segment, 'sdl_segment_id', '')
            if not seg_id:
                continue
            notes = (getattr(segment, 'notes', '') or '').strip()
            if notes:
                segment_comments[seg_id] = notes
        return segment_comments

    def export_sdlrpx_package(self):
        """Export translations back to a Trados Studio SDLRPX return package."""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "No project is currently loaded.")
            return
        
        # Check if we have an SDLPPX handler
        sdlppx_source = None
        
        if hasattr(self, 'sdlppx_handler') and self.sdlppx_handler:
            sdlppx_source = getattr(self, 'sdlppx_source_file', None)
        
        if not sdlppx_source and hasattr(self.current_project, 'sdlppx_source_path'):
            sdlppx_source = self.current_project.sdlppx_source_path
        
        if not sdlppx_source:
            QMessageBox.warning(
                self, "No SDLPPX Source",
                "This project was not imported from an SDLPPX package.\n\n"
                "To export as SDLRPX, you must first import an SDLPPX package."
            )
            return
        
        # Check if source exists
        if not Path(sdlppx_source).exists():
            # Try to locate the file
            reply = QMessageBox.question(
                self, "Source Package Not Found",
                f"The original SDLPPX package cannot be found:\n\n"
                f"{sdlppx_source}\n\n"
                f"Would you like to browse for the file?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original SDLPPX Package",
                    "",
                    "Trados Packages (*.sdlppx);;All Files (*.*)"
                )
                
                if file_path:
                    sdlppx_source = file_path
                    self.sdlppx_source_file = file_path
                    self.current_project.sdlppx_source_path = file_path
                else:
                    return
            else:
                return
        
        # Reload handler if needed
        if not hasattr(self, 'sdlppx_handler') or not self.sdlppx_handler:
            try:
                from modules.sdlppx_handler import TradosPackageHandler
                self.sdlppx_handler = TradosPackageHandler(log_callback=self.log)
                package = self.sdlppx_handler.load_package(sdlppx_source)
                if not package:
                    QMessageBox.critical(self, "Error", "Failed to reload the SDLPPX package.")
                    return
                self.sdlppx_source_file = sdlppx_source
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to reload SDLPPX:\n\n{str(e)}")
                return
        
        # Suggest output filename
        source_path = Path(sdlppx_source)
        suggested_name = source_path.stem + ".sdlrpx"
        
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Trados Return Package (SDLRPX)",
            str(source_path.parent / suggested_name),
            "Trados Return Packages (*.sdlrpx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        try:
            # Sync grid targets to segment objects to ensure we have latest edits
            self._sync_grid_targets_to_segments(self.current_project.segments)

            # Build translations and comments dicts
            translations = self._build_sdlxliff_translations_dict()
            segment_comments = self._build_sdlxliff_comments_dict()

            total_segs = len(self.current_project.segments)
            segs_with_target = sum(1 for s in self.current_project.segments if s.target and s.target.strip())
            segs_with_id = sum(1 for s in self.current_project.segments
                               if getattr(s, 'sdl_segment_id', '') or
                               ('Segment:' in (getattr(s, 'notes', '') or '')))
            self.log(f"  Project: {total_segs} segments, {segs_with_target} with target text, {segs_with_id} with SDLXLIFF segment IDs")
            self.log(f"  Translations to export: {len(translations)}, comments: {len(segment_comments)}")
            if not translations:
                self.log("  WARNING: No translations found to export!")
                if segs_with_target == 0:
                    self.log("    → No segments have target text. Translate segments before exporting.")
                elif segs_with_id == 0:
                    self.log("    → No segments have SDLXLIFF segment IDs. "
                             "This project may not have been imported from an SDLPPX package.")
                else:
                    self.log("    → Segments have targets and IDs, but translations couldn't be built.")
                # Log a few segments for diagnosis
                for seg in self.current_project.segments[:5]:
                    sid = getattr(seg, 'sdl_segment_id', '')
                    has_target = bool(seg.target and seg.target.strip())
                    self.log(f"    Seg {seg.id}: target={'yes' if has_target else 'NO'}, "
                             f"sdl_id='{sid}'")

            # Update the handler with translations
            updated = self.sdlppx_handler.update_translations(translations)
            self.log(f"  Handler matched: {updated} of {len(translations)} segments")

            if updated == 0 and translations:
                # Translations exist but handler couldn't match any - log segment IDs for diagnosis
                self.log("  WARNING: Handler matched 0 segments! Possible segment ID mismatch.")
                handler_ids = set()
                for xf in self.sdlppx_handler.package.xliff_files:
                    for s in xf.segments:
                        handler_ids.add(s.segment_id)
                trans_keys = set(translations.keys())
                self.log(f"    Translation keys sample: {list(trans_keys)[:3]}")
                self.log(f"    Handler segment IDs sample: {list(handler_ids)[:3]}")

            # Store comments and translator name on handler for export
            self.sdlppx_handler.segment_comments = segment_comments
            self.sdlppx_handler.username = self.get_translator_name()

            # Export return package
            result_path = self.sdlppx_handler.create_return_package(file_path)
            
            if result_path:
                self.log(f"✓ Exported {updated} translated segments to SDLRPX: {Path(file_path).name}")
                
                QMessageBox.information(
                    self, "Export Successful",
                    f"Successfully exported Trados return package.\n\n"
                    f"File: {Path(file_path).name}\n"
                    f"Translations updated: {updated}\n\n"
                    f"This file can be imported back into Trados Studio by the project manager."
                )
            else:
                QMessageBox.critical(self, "Export Error", "Failed to create the SDLRPX return package.")
                
        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export SDLRPX:\n\n{str(e)}")
            self.log(f"✗ SDLRPX export failed: {str(e)}")
            import traceback
            traceback.print_exc()

    # ─── Standalone SDLXLIFF import/export ────────────────────────────────────

    def _map_sdlxliff_segment(self, sdl_seg, index: int, file_id: int = None,
                              file_name: str = "", locked: bool = False) -> 'Segment':
        """
        Convert an SDLSegment to an internal Segment with proper status mapping.

        Shared by import_sdlppx_package() and import_standalone_sdlxliff().
        Maps SDL origin/percent/text-match to the 13-level Supervertaler status hierarchy.

        Args:
            sdl_seg: SDLSegment to convert
            index: 0-based index used to assign segment ID
            file_id: Optional file ID for multi-file projects
            file_name: Optional file name for multi-file projects
            locked: Whether this segment is locked/non-translatable
        """
        # Determine status based on origin, match percent, and text-match attribute
        if sdl_seg.target_text:
            if sdl_seg.origin == 'tm':
                if sdl_seg.match_percent >= 100:
                    text_match = getattr(sdl_seg, 'text_match', '')
                    if text_match == 'SourceAndTarget':
                        status = STATUSES["cm"].key
                    else:
                        status = STATUSES["tm_100"].key
                elif sdl_seg.match_percent >= 75:
                    status = STATUSES["tm_fuzzy"].key
                else:
                    status = STATUSES["pretranslated"].key
            elif sdl_seg.origin == 'perfect-match':
                status = STATUSES["pm"].key
            elif sdl_seg.origin in ('document-match', 'auto-propagated'):
                status = STATUSES["repetition"].key
            elif sdl_seg.origin in ('nmt', 'mt', 'machine-translation', 'auto-translation'):
                status = STATUSES["machine_translated"].key
            elif sdl_seg.origin == 'interactive':
                status = STATUSES["draft"].key
            else:
                status = STATUSES["pretranslated"].key
        else:
            status = DEFAULT_STATUS.key

        # Overlay Trados confirmation status (conf attribute) over origin-based status.
        # In Trados, conf and origin are orthogonal dimensions:
        #   - origin = how translation was obtained (TM, MT, interactive, etc.)
        #   - conf   = workflow state (Draft, Translated, ApprovedTranslation, etc.)
        # When conf indicates an explicit user action (confirmed/approved/rejected),
        # it takes priority. Draft segments keep the origin-based status (shows TM
        # match type), and match_percent still preserves match info independently.
        sdl_conf = getattr(sdl_seg, 'status', 'not_translated')
        if sdl_seg.target_text:
            if sdl_conf == 'translated':
                # Trados conf="Translated" = translator confirmed the segment
                status = STATUSES["confirmed"].key
            elif sdl_conf == 'approved':
                # Trados conf="ApprovedTranslation" or "ApprovedSignOff"
                status = STATUSES["approved"].key
            elif sdl_conf == 'rejected':
                status = STATUSES["rejected"].key

        # Determine match_percent from origin
        segment_match_percent = None
        if sdl_seg.origin:
            text_match = getattr(sdl_seg, 'text_match', '')
            if sdl_seg.origin == 'tm' and sdl_seg.match_percent > 0:
                if text_match == 'SourceAndTarget':
                    segment_match_percent = 101
                else:
                    segment_match_percent = sdl_seg.match_percent
            elif sdl_seg.origin == 'perfect-match':
                segment_match_percent = 102

        # Import Trados comments into notes (if any)
        comment_texts = []
        if hasattr(sdl_seg, 'comments') and sdl_seg.comments:
            for cmt in sdl_seg.comments:
                prefix = f"[{cmt['user']}] " if cmt.get('user') else ""
                comment_texts.append(f"{prefix}{cmt['text']}")

        return Segment(
            id=index + 1,
            source=sdl_seg.source_text,
            target=sdl_seg.target_text if sdl_seg.target_text else "",
            status=status,
            match_percent=segment_match_percent,
            notes="\n".join(comment_texts),
            locked=locked,
            file_id=file_id,
            file_name=file_name,
            sdl_segment_id=sdl_seg.segment_id,
        )

    def import_standalone_sdlxliff(self):
        """Import standalone SDLXLIFF file(s) directly (without Trados package)."""
        file_paths, _ = QFileDialog.getOpenFileNames(
            self,
            "Select SDLXLIFF File(s)",
            "",
            "SDLXLIFF Files (*.sdlxliff);;All Files (*.*)"
        )
        if not file_paths:
            return

        try:
            from modules.sdlppx_handler import StandaloneSDLXLIFFHandler

            self.log(f"Importing {len(file_paths)} SDLXLIFF file(s)...")

            # Progress dialog so large files don't look frozen during parsing.
            first_name = Path(file_paths[0]).name
            load_progress = QProgressDialog(
                f"Opening {first_name}...", "Cancel", 0, max(len(file_paths), 1), self
            )
            load_progress.setWindowTitle("Importing SDLXLIFF")
            load_progress.setWindowModality(Qt.WindowModality.WindowModal)
            load_progress.setMinimumDuration(0)
            load_progress.setAutoClose(False)
            load_progress.setAutoReset(False)
            load_progress.setValue(0)
            QApplication.processEvents()

            def _on_load_progress(stage: str, current: int, total: int, message: str) -> bool:
                if load_progress.wasCanceled():
                    return False
                load_progress.setRange(0, max(total, 1))
                load_progress.setValue(current)
                load_progress.setLabelText(
                    f"Parsing SDLXLIFF: {message} ({current}/{total})"
                    if total else f"Parsing: {message}"
                )
                QApplication.processEvents()
                return not load_progress.wasCanceled()

            handler = StandaloneSDLXLIFFHandler(log_callback=self.log)
            load_ok = handler.load(file_paths, progress_callback=_on_load_progress)
            cancelled = load_progress.wasCanceled()
            load_progress.close()

            if cancelled:
                self.log("❌ SDLXLIFF import cancelled by user")
                return
            if not load_ok:
                QMessageBox.critical(
                    self, "Import Error",
                    "Failed to load the SDLXLIFF file(s). Check the log for details."
                )
                return

            all_sdl_segments = handler.get_all_segments()

            # Count locked segments and ask user whether to include them
            total_count = len(all_sdl_segments)
            locked_count = sum(
                1 for s in all_sdl_segments
                if s.locked or s.trans_unit_id.startswith('lockTU_')
            )
            include_locked = False
            if locked_count and (total_count - locked_count) > 0:
                reply = QMessageBox.question(
                    self, "Locked Segments Detected",
                    f"Found {locked_count:,} locked/non-translatable segments "
                    f"out of {total_count:,} total.\n\n"
                    f"Include locked segments in the grid?\n"
                    f"(They provide context but may slow loading.)",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No
                )
                include_locked = (reply == QMessageBox.StandardButton.Yes)

            # Build segments per-file so the multi-file UI activates
            is_multifile = len(handler.xliff_files) > 1
            segments = []
            file_metadata = []
            global_index = 0

            for file_idx, xliff_file in enumerate(handler.xliff_files):
                file_id = file_idx + 1
                file_name = Path(xliff_file.file_path).name
                file_start = global_index

                for sdl_seg in xliff_file.segments:
                    if sdl_seg.trans_unit_id.startswith('lockTU_'):
                        continue
                    is_locked = sdl_seg.locked
                    if is_locked and not include_locked:
                        continue
                    seg = self._map_sdlxliff_segment(
                        sdl_seg, global_index,
                        file_id=file_id if is_multifile else None,
                        file_name=file_name if is_multifile else "",
                        locked=is_locked
                    )
                    segments.append(seg)
                    global_index += 1

                if is_multifile:
                    file_segs = segments[file_start:global_index]
                    if file_segs:
                        file_metadata.append({
                            'id': file_id,
                            'name': file_name,
                            'path': xliff_file.file_path,
                            'type': 'sdlxliff',
                            'segment_count': len(file_segs),
                            'start_segment_id': file_segs[0].id,
                            'end_segment_id': file_segs[-1].id,
                        })

            if not segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No translatable segments found in the selected file(s)."
                )
                return

            # Normalize language codes to full names
            source_lang = self._normalize_language_code(handler.get_source_lang())
            target_lang = self._normalize_language_code(handler.get_target_lang())

            # Create project name
            if len(file_paths) == 1:
                project_name = Path(file_paths[0]).stem
            else:
                project_name = f"{Path(file_paths[0]).stem} (+{len(file_paths) - 1} files)"

            # Create new project
            self.current_project = Project(
                name=project_name,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang,
                is_multifile=is_multifile,
                files=file_metadata if is_multifile else None
            )

            # Store handler and source paths for round-trip export
            self.sdlxliff_handler = handler
            self.sdlxliff_source_files = file_paths
            self.current_project.sdlxliff_source_paths = file_paths

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Sync global language settings
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()
            self._clear_caches_after_import()
            self._deactivate_all_resources_for_new_project()
            self.auto_resize_rows()
            self._initialize_spellcheck_for_target_language(target_lang)
            self._update_file_filter_combo()

            # Count pretranslated segments
            pretrans_count = sum(1 for s in segments if s.target)

            files_info = f"Files: {len(file_paths)}\n" if len(file_paths) > 1 else ""
            self.log(f"✓ Imported {len(segments)} segments from {len(file_paths)} SDLXLIFF file(s)")
            if pretrans_count:
                self.log(f"  {pretrans_count} segments are pretranslated")

            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from SDLXLIFF.\n\n"
                f"{files_info}"
                f"Languages: {source_lang} → {target_lang}\n"
                f"Pretranslated: {pretrans_count}\n\n"
                f"After translation, export back as SDLXLIFF (.sdlxliff) to return to the Trados user."
            )

        except ImportError as e:
            QMessageBox.critical(
                self, "Missing Dependency",
                f"Failed to import SDLXLIFF handler:\n\n{str(e)}"
            )
        except Exception as e:
            QMessageBox.critical(
                self, "Import Error",
                f"Failed to import SDLXLIFF file(s):\n\n{str(e)}"
            )
            self.log(f"✗ SDLXLIFF import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def import_sdlxliff_folder(self):
        """Import all .sdlxliff files from a selected folder (optionally recursive)."""
        folder = QFileDialog.getExistingDirectory(
            self,
            "Select Folder Containing SDLXLIFF Files",
            ""
        )
        if not folder:
            return

        # Discover all .sdlxliff files in the folder
        folder_path = Path(folder)
        file_paths = sorted(folder_path.glob("**/*.sdlxliff"))

        if not file_paths:
            # Try non-recursive as fallback (already covered by ** but be explicit in message)
            QMessageBox.warning(
                self, "No SDLXLIFF Files Found",
                f"No .sdlxliff files were found in:\n{folder}\n\n"
                "Make sure the folder contains .sdlxliff files."
            )
            return

        file_paths = [str(p) for p in file_paths]
        self.log(f"Found {len(file_paths)} SDLXLIFF file(s) in folder: {folder}")

        try:
            from modules.sdlppx_handler import StandaloneSDLXLIFFHandler

            # Progress dialog so large folders don't look frozen during parsing.
            load_progress = QProgressDialog(
                f"Opening {len(file_paths)} SDLXLIFF file(s)...", "Cancel",
                0, max(len(file_paths), 1), self
            )
            load_progress.setWindowTitle("Importing SDLXLIFF Folder")
            load_progress.setWindowModality(Qt.WindowModality.WindowModal)
            load_progress.setMinimumDuration(0)
            load_progress.setAutoClose(False)
            load_progress.setAutoReset(False)
            load_progress.setValue(0)
            QApplication.processEvents()

            def _on_load_progress(stage: str, current: int, total: int, message: str) -> bool:
                if load_progress.wasCanceled():
                    return False
                load_progress.setRange(0, max(total, 1))
                load_progress.setValue(current)
                load_progress.setLabelText(
                    f"Parsing SDLXLIFF: {message} ({current}/{total})"
                    if total else f"Parsing: {message}"
                )
                QApplication.processEvents()
                return not load_progress.wasCanceled()

            handler = StandaloneSDLXLIFFHandler(log_callback=self.log)
            load_ok = handler.load(file_paths, progress_callback=_on_load_progress)
            cancelled = load_progress.wasCanceled()
            load_progress.close()

            if cancelled:
                self.log("❌ SDLXLIFF folder import cancelled by user")
                return
            if not load_ok:
                QMessageBox.critical(
                    self, "Import Error",
                    "Failed to load the SDLXLIFF file(s). Check the log for details."
                )
                return

            all_sdl_segments = handler.get_all_segments()

            # Count locked segments and ask user whether to include them
            total_count = len(all_sdl_segments)
            locked_count = sum(
                1 for s in all_sdl_segments
                if s.locked or s.trans_unit_id.startswith('lockTU_')
            )
            include_locked = False
            if locked_count and (total_count - locked_count) > 0:
                reply = QMessageBox.question(
                    self, "Locked Segments Detected",
                    f"Found {locked_count:,} locked/non-translatable segments "
                    f"out of {total_count:,} total.\n\n"
                    f"Include locked segments in the grid?\n"
                    f"(They provide context but may slow loading.)",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No
                )
                include_locked = (reply == QMessageBox.StandardButton.Yes)

            if not all_sdl_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No translatable segments found in the SDLXLIFF files."
                )
                return

            # Build segments and file_metadata per-file so the multi-file UI activates
            is_multifile = len(handler.xliff_files) > 1
            segments = []
            file_metadata = []
            global_index = 0

            for file_idx, xliff_file in enumerate(handler.xliff_files):
                file_id = file_idx + 1
                file_name = Path(xliff_file.file_path).name
                file_start = global_index

                for sdl_seg in xliff_file.segments:
                    # Lock TU structural segments are always excluded
                    if sdl_seg.trans_unit_id.startswith('lockTU_'):
                        continue
                    # Locked (translate="no") segments: include or skip
                    is_locked = sdl_seg.locked
                    if is_locked and not include_locked:
                        continue
                    seg = self._map_sdlxliff_segment(
                        sdl_seg, global_index,
                        file_id=file_id if is_multifile else None,
                        file_name=file_name if is_multifile else "",
                        locked=is_locked
                    )
                    segments.append(seg)
                    global_index += 1

                if is_multifile:
                    file_segs = segments[file_start:global_index]
                    file_metadata.append({
                        'id': file_id,
                        'name': file_name,
                        'path': xliff_file.file_path,
                        'type': 'sdlxliff',
                        'segment_count': len(file_segs),
                        'start_segment_id': file_segs[0].id if file_segs else None,
                        'end_segment_id': file_segs[-1].id if file_segs else None,
                    })

            # Normalize language codes to full names
            source_lang = self._normalize_language_code(handler.get_source_lang())
            target_lang = self._normalize_language_code(handler.get_target_lang())

            # Create project name from folder
            project_name = f"{folder_path.name} ({len(file_paths)} files)"

            # Create new project
            self.current_project = Project(
                name=project_name,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang,
                is_multifile=is_multifile,
                files=file_metadata if is_multifile else None
            )

            # Store handler and source paths for round-trip export
            self.sdlxliff_handler = handler
            self.sdlxliff_source_files = file_paths
            self.current_project.sdlxliff_source_paths = file_paths

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Sync global language settings
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()
            self._clear_caches_after_import()
            self._deactivate_all_resources_for_new_project()
            self.auto_resize_rows()
            self._initialize_spellcheck_for_target_language(target_lang)
            self._update_file_filter_combo()

            # Count pretranslated segments
            pretrans_count = sum(1 for s in segments if s.target)

            self.log(f"✓ Imported {len(segments)} segments from {len(file_paths)} SDLXLIFF file(s) in folder: {folder_path.name}")
            if pretrans_count:
                self.log(f"  {pretrans_count} segments are pretranslated")

            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from {len(file_paths)} SDLXLIFF file(s).\n\n"
                f"Folder: {folder_path.name}\n"
                f"Languages: {source_lang} → {target_lang}\n"
                f"Pretranslated: {pretrans_count}\n\n"
                f"After translation, export back as SDLXLIFF (.sdlxliff) to return to the Trados user."
            )

        except ImportError as e:
            QMessageBox.critical(
                self, "Missing Dependency",
                f"Failed to import SDLXLIFF handler:\n\n{str(e)}"
            )
        except Exception as e:
            QMessageBox.critical(
                self, "Import Error",
                f"Failed to import SDLXLIFF folder:\n\n{str(e)}"
            )
            self.log(f"✗ SDLXLIFF folder import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def export_standalone_sdlxliff(self):
        """Export translations back to standalone SDLXLIFF file(s)."""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export.")
            return

        try:
            from modules.sdlppx_handler import StandaloneSDLXLIFFHandler

            # Try to find handler and source files
            handler = getattr(self, 'sdlxliff_handler', None)
            source_files = getattr(self, 'sdlxliff_source_files', None)

            # Try project-stored paths if instance vars not available
            if not handler or not source_files:
                stored_paths = getattr(self.current_project, 'sdlxliff_source_paths', None)
                if stored_paths:
                    existing = [p for p in stored_paths if Path(p).exists()]
                    if existing:
                        handler = StandaloneSDLXLIFFHandler(log_callback=self.log)
                        if handler.load(existing):
                            self.sdlxliff_handler = handler
                            self.sdlxliff_source_files = existing
                            source_files = existing
                        else:
                            handler = None

            # Prompt user to locate source files if not found
            if not handler or not source_files:
                reply = QMessageBox.question(
                    self, "Source File Not Found",
                    "The original SDLXLIFF source file(s) could not be found.\n\n"
                    "Would you like to locate them? This is needed to preserve "
                    "the original file structure during export.",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if reply != QMessageBox.StandardButton.Yes:
                    return

                file_paths, _ = QFileDialog.getOpenFileNames(
                    self,
                    "Select Original SDLXLIFF File(s)",
                    "",
                    "SDLXLIFF Files (*.sdlxliff);;All Files (*.*)"
                )
                if not file_paths:
                    return

                handler = StandaloneSDLXLIFFHandler(log_callback=self.log)
                if not handler.load(file_paths):
                    QMessageBox.critical(self, "Error", "Failed to load the selected SDLXLIFF file(s).")
                    return

                self.sdlxliff_handler = handler
                self.sdlxliff_source_files = file_paths
                self.current_project.sdlxliff_source_paths = file_paths
                source_files = file_paths

            # Sync grid targets to segment objects
            self._sync_grid_targets_to_segments(self.current_project.segments)

            # Build translations and comments dicts
            translations = self._build_sdlxliff_translations_dict()
            segment_comments = self._build_sdlxliff_comments_dict()

            if not translations:
                QMessageBox.warning(
                    self, "No Translations",
                    "No translated segments found to export."
                )
                return

            # Update handler with translations, comments, and translator name
            updated = handler.update_translations(translations)
            handler.segment_comments = segment_comments
            handler.username = self.get_translator_name()
            self.log(f"Updated {updated} segment(s) in SDLXLIFF handler, {len(segment_comments)} comment(s)")

            # Save file(s)
            if len(handler.xliff_files) == 1:
                # Single file – use Save As dialog
                default_name = Path(source_files[0]).stem + "_translated.sdlxliff"
                default_dir = str(Path(source_files[0]).parent / default_name)

                output_path, _ = QFileDialog.getSaveFileName(
                    self,
                    "Save Translated SDLXLIFF",
                    default_dir,
                    "SDLXLIFF Files (*.sdlxliff);;All Files (*.*)"
                )
                if not output_path:
                    return

                # Safety: never overwrite source
                if Path(output_path).resolve() == Path(source_files[0]).resolve():
                    QMessageBox.warning(
                        self, "Cannot Overwrite Source",
                        "Cannot save to the same path as the source file.\n"
                        "Please choose a different filename."
                    )
                    return

                success = handler.save_file(handler.xliff_files[0], output_path)
                if success:
                    QMessageBox.information(
                        self, "Export Successful",
                        f"Successfully exported translated SDLXLIFF.\n\n"
                        f"File: {Path(output_path).name}\n"
                        f"Translations updated: {updated}\n\n"
                        f"This file can be opened in Trados Studio."
                    )
                    self.log(f"✓ Exported SDLXLIFF: {output_path}")
                else:
                    QMessageBox.critical(self, "Export Error", "Failed to save the SDLXLIFF file.")
            else:
                # Multi-file – select output directory
                output_dir = QFileDialog.getExistingDirectory(
                    self,
                    "Select Output Folder for Translated SDLXLIFF Files",
                    str(Path(source_files[0]).parent)
                )
                if not output_dir:
                    return

                saved_paths = handler.save_all(output_dir)
                if saved_paths:
                    QMessageBox.information(
                        self, "Export Successful",
                        f"Successfully exported {len(saved_paths)} translated SDLXLIFF file(s) to:\n\n"
                        f"{output_dir}\n\n"
                        f"Translations updated: {updated}\n\n"
                        f"These files can be opened in Trados Studio."
                    )
                    self.log(f"✓ Exported {len(saved_paths)} SDLXLIFF files to {output_dir}")
                else:
                    QMessageBox.critical(self, "Export Error", "Failed to save the SDLXLIFF files.")

        except Exception as e:
            QMessageBox.critical(
                self, "Export Error",
                f"Failed to export SDLXLIFF:\n\n{str(e)}"
            )
            self.log(f"✗ SDLXLIFF export failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def import_phrase_bilingual(self):
        """Import Phrase (Memsource) bilingual DOCX file"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Phrase Bilingual DOCX File",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )

        if not file_path:
            return

        # Show confirmation dialog
        reply = QMessageBox.question(
            self,
            "Confirm Phrase Bilingual Import",
            f"You are about to import a Phrase (Memsource) bilingual DOCX file.\n\n"
            f"File: {Path(file_path).name}\n\n"
            f"This workflow is specifically for Phrase bilingual files with:\n"
            f"• Multiple tables with 7 columns\n"
            f"• Inline tags like {{1}}, {{1>text<1}}\n"
            f"• Editable target column (Column 5)\n\n"
            f"The project can be exported back to Phrase format after translation.\n\n"
            f"Continue with import?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes
        )

        if reply != QMessageBox.StandardButton.Yes:
            self.log("✗ User cancelled Phrase import")
            return

        try:
            from modules.phrase_docx_handler import PhraseDOCXHandler
            from modules.trados_docx_handler import detect_bilingual_docx_type

            # Check if this is a valid Phrase bilingual DOCX
            file_type = detect_bilingual_docx_type(file_path)
            if file_type != "phrase":
                QMessageBox.warning(
                    self, "Invalid Format",
                    "This file does not appear to be a Phrase bilingual DOCX.\n\n"
                    "Expected: a table with 7 or 8 columns whose first column "
                    "contains segment IDs of the form <id>:<index> "
                    "(e.g. SSOMDWjYi5xvD7wq_dc10:0)."
                )
                return

            # Load the file
            handler = PhraseDOCXHandler()
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load Phrase bilingual DOCX file."
                )
                return

            # Extract segments
            phrase_segments = handler.extract_source_segments()

            if not phrase_segments:
                QMessageBox.warning(
                    self, "No Segments",
                    "No segments found in the Phrase bilingual DOCX file."
                )
                return

            # Convert to internal Segment format - preserve tags for round-trip
            segments = []
            for i, p_seg in enumerate(phrase_segments):
                segment = Segment(
                    id=i + 1,
                    source=p_seg.source_text,  # Preserve tags like {1}text{1}
                    target=p_seg.target_text if p_seg.target_text else "",
                    status=STATUSES["pretranslated"].key if p_seg.target_text else DEFAULT_STATUS.key,
                    notes=f"Phrase ID: {p_seg.segment_id} | Status: {p_seg.status_code}",
                )
                segments.append(segment)

            # Store the handler and original path for round-trip export
            self.phrase_handler = handler
            self.phrase_source_file = file_path

            # v1.10.33: auto-detect the language pair from the file
            # before opening the dialog. Phrase always writes a column-
            # header table like ``Source (cs) | Target (de-de)`` and
            # also tags content runs with w:lang attributes, so the
            # information is right there in the file – no reason to
            # make the user pick manually. The handler returns ISO
            # codes (or ``None`` per side if detection failed); we
            # translate to the English names the picker uses.
            from modules.language_codes import iso_to_english_name
            try:
                detected_src_iso, detected_tgt_iso = handler.detect_language_pair()
            except Exception as e:
                print(f"[Phrase import] language detection error: {e}")
                detected_src_iso = detected_tgt_iso = None
            detected_src_name = iso_to_english_name(detected_src_iso)
            detected_tgt_name = iso_to_english_name(detected_tgt_iso)

            # Show language selection dialog – pre-filled with the
            # auto-detected pair when available, falling back to the
            # main UI's current selection if a side couldn't be
            # detected. The user always sees the dialog so they can
            # override a misdetection or pick a sub-variant.
            from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QDialogButtonBox, QGroupBox

            lang_dialog = QDialog(self)
            lang_dialog.setWindowTitle("Select Languages")
            lang_dialog.setMinimumWidth(350)
            lang_layout = QVBoxLayout(lang_dialog)

            # Info text – call out auto-detection success so the user
            # knows the pre-fill came from the file rather than from
            # their current Workbench setting.
            if detected_src_name and detected_tgt_name:
                info_text = (
                    f"Auto-detected from file: "
                    f"<b>{detected_src_name}</b> → <b>{detected_tgt_name}</b>.<br>"
                    f"Confirm or change below."
                )
            elif detected_src_name or detected_tgt_name:
                # Partial detection. Tell the user which side was found.
                partial = []
                if detected_src_name:
                    partial.append(f"source = <b>{detected_src_name}</b>")
                if detected_tgt_name:
                    partial.append(f"target = <b>{detected_tgt_name}</b>")
                info_text = (
                    f"Auto-detected from file: {', '.join(partial)}.<br>"
                    f"Please pick the remaining side and confirm."
                )
            else:
                info_text = (
                    "Couldn't auto-detect the language pair – please pick "
                    "the source and target language for this project:"
                )
            info_label = QLabel(info_text)
            info_label.setTextFormat(Qt.TextFormat.RichText)
            info_label.setWordWrap(True)
            info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
            lang_layout.addWidget(info_label)

            # Language group
            lang_group = QGroupBox("Language Pair")
            lang_group_layout = QVBoxLayout(lang_group)

            # Source language
            source_row = QHBoxLayout()
            source_row.addWidget(QLabel("Source Language:"))
            source_combo = QComboBox()
            # Full language list (same as New Project dialog)
            available_languages = [
                "Afrikaans", "Albanian", "Arabic", "Armenian", "Basque", "Bengali",
                "Bulgarian", "Catalan", "Chinese (Simplified)", "Chinese (Traditional)",
                "Croatian", "Czech", "Danish", "Dutch", "English", "Estonian",
                "Finnish", "French", "Galician", "Georgian", "German", "Greek",
                "Hebrew", "Hindi", "Hungarian", "Icelandic", "Indonesian", "Irish",
                "Italian", "Japanese", "Korean", "Latvian", "Lithuanian", "Macedonian",
                "Malay", "Norwegian", "Persian", "Polish", "Portuguese", "Romanian",
                "Russian", "Serbian", "Slovak", "Slovenian", "Spanish", "Swahili",
                "Swedish", "Thai", "Turkish", "Ukrainian", "Urdu", "Vietnamese", "Welsh"
            ]
            source_combo.addItems(available_languages)
            # Prefer the auto-detected source; fall back to the main UI
            # current selection so the dialog still works on a file
            # that doesn't expose its language metadata.
            current_source = detected_src_name or (
                self.source_lang_combo.currentText()
                if hasattr(self, 'source_lang_combo') else "English"
            )
            source_idx = source_combo.findText(current_source)
            if source_idx >= 0:
                source_combo.setCurrentIndex(source_idx)
            source_row.addWidget(source_combo)
            lang_group_layout.addLayout(source_row)

            # Target language
            target_row = QHBoxLayout()
            target_row.addWidget(QLabel("Target Language:"))
            target_combo = QComboBox()
            target_combo.addItems(available_languages)
            current_target = detected_tgt_name or (
                self.target_lang_combo.currentText()
                if hasattr(self, 'target_lang_combo') else "Dutch"
            )
            target_idx = target_combo.findText(current_target)
            if target_idx >= 0:
                target_combo.setCurrentIndex(target_idx)
            target_row.addWidget(target_combo)
            lang_group_layout.addLayout(target_row)

            lang_layout.addWidget(lang_group)

            # Buttons
            lang_buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
            lang_buttons.accepted.connect(lang_dialog.accept)
            lang_buttons.rejected.connect(lang_dialog.reject)
            lang_layout.addWidget(lang_buttons)

            if lang_dialog.exec() != QDialog.DialogCode.Accepted:
                self.log("✗ User cancelled Phrase import during language selection")
                return

            source_lang = source_combo.currentText()
            target_lang = target_combo.currentText()

            # Create new project with user-selected languages
            file_name = Path(file_path).stem
            self.current_project = Project(
                name=file_name,
                segments=segments,
                source_lang=source_lang,
                target_lang=target_lang
            )

            # Store Phrase source path in project for persistence across saves
            self.current_project.phrase_source_path = file_path

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()
            self._clear_caches_after_import()
            self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            # Count segments with tags
            tagged_count = sum(1 for s in phrase_segments if s.source_tags)

            # Log success
            self.log(f"✓ Imported {len(segments)} segments from Phrase bilingual DOCX: {Path(file_path).name}")
            if tagged_count:
                self.log(f"  {tagged_count} segments contain inline tags")

            QMessageBox.information(
                self, "Import Successful",
                f"Successfully imported {len(segments)} segment(s) from Phrase bilingual DOCX.\n\n"
                f"File: {Path(file_path).name}\n"
                f"Languages: {source_lang} → {target_lang}\n"
                f"Segments with tags: {tagged_count}\n\n"
                f"Note: Inline tags (e.g., {{1}}, {{1>text<1}}) are preserved for export.\n"
                f"Export back to Phrase format after translation to maintain compatibility."
            )

        except ImportError as e:
            QMessageBox.critical(
                self, "Missing Dependency",
                f"Required library is missing: {e}\n\n"
                "Install dependencies with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import Phrase bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ Phrase import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def export_phrase_bilingual(self):
        """Export translations back to Phrase bilingual DOCX format"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "No project is currently loaded.")
            return

        # Check if we have a Phrase source - either from handler or project data
        phrase_source = None

        # First check if handler is already loaded
        if hasattr(self, 'phrase_handler') and self.phrase_handler:
            phrase_source = getattr(self, 'phrase_source_file', None)

        # If not, check if project has the source path saved
        if not phrase_source and hasattr(self.current_project, 'phrase_source_path') and self.current_project.phrase_source_path:
            phrase_source = self.current_project.phrase_source_path

        if not phrase_source:
            # Prompt user to select the original Phrase bilingual file
            reply = QMessageBox.question(
                self, "Select Phrase Source File",
                "To export to Phrase format, please select the original Phrase bilingual DOCX file.\n\n"
                "This is the file you originally imported from Phrase.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Phrase Bilingual DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )

                if file_path:
                    phrase_source = file_path
                    self.phrase_source_file = file_path
                    # Also save to project for future exports
                    self.current_project.phrase_source_path = file_path
                    self.log(f"✓ Phrase source file set: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return

        # Check if source file still exists
        if not Path(phrase_source).exists():
            # File not found - give user a chance to locate it
            reply = QMessageBox.question(
                self, "Source File Not Found",
                f"The original Phrase source file cannot be found:\n\n"
                f"{phrase_source}\n\n"
                f"Would you like to browse for the file in its new location?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Phrase Bilingual DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )

                if file_path:
                    phrase_source = file_path
                    self.phrase_source_file = file_path
                    self.current_project.phrase_source_path = file_path
                    self.log(f"✓ Phrase source file relocated: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return

        # Reload the handler if needed
        if not hasattr(self, 'phrase_handler') or not self.phrase_handler:
            try:
                from modules.phrase_docx_handler import PhraseDOCXHandler
                self.phrase_handler = PhraseDOCXHandler()
                if not self.phrase_handler.load(phrase_source):
                    QMessageBox.critical(self, "Error", "Failed to reload the Phrase source file.")
                    return
                self.phrase_handler.extract_source_segments()  # Ensure segments are loaded
                self.phrase_source_file = phrase_source
                self.log(f"✓ Reloaded Phrase source file for export")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to reload Phrase source:\n\n{str(e)}")
                return

        # Suggest output filename
        source_path = Path(self.phrase_source_file)
        suggested_name = source_path.stem + "_translated" + source_path.suffix

        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Phrase Bilingual DOCX",
            str(source_path.parent / suggested_name),
            "Word Documents (*.docx);;All Files (*.*)"
        )

        if not file_path:
            return

        try:
            # Collect translations from the project's segment list, not
            # from grid cell widgets.
            #
            # v1.10.32 bug fix: prior code did
            #
            #     target_widget = self.table.cellWidget(row, 3)
            #     notes_widget  = self.table.cellWidget(row, 4)   # ← stale
            #     ...
            #     notes_text    = notes_widget.toPlainText()
            #
            # which crashed with
            #
            #     'QWidget' object has no attribute 'toPlainText'
            #
            # because the current grid layout has only five columns
            # (#, Type, Source, Target, Status) – column 4 is the
            # Status indicator (a plain ``QWidget`` badge, not a
            # ``SegmentTextEdit``). The old layout had a Notes column
            # at index 4; when that was retired, the Phrase export
            # path wasn't updated.
            #
            # The data we need is already on the segment objects: at
            # import time ``import_phrase_bilingual`` writes
            # ``notes=f"Phrase ID: {segment_id} | Status: {status}"``
            # to each segment, and the Target text lives on
            # ``segment.target``. Read straight from there –
            # bypasses the grid entirely and works even when the
            # editor isn't currently visible / built.
            translations = {}
            import re
            phrase_id_re = re.compile(r"Phrase ID:\s*([^\s|]+)")
            for segment in self.current_project.segments:
                notes_text = (segment.notes or "").strip()
                if "Phrase ID:" not in notes_text:
                    continue
                match = phrase_id_re.search(notes_text)
                if not match:
                    continue
                target_text = (segment.target or "").strip()
                if not target_text:
                    continue
                translations[match.group(1)] = target_text

            # Update the handler with translations
            updated = self.phrase_handler.update_target_segments(translations)

            # Save to new file
            if self.phrase_handler.save(file_path):
                self.log(f"✓ Exported {updated} translated segments to Phrase DOCX: {Path(file_path).name}")

                QMessageBox.information(
                    self, "Export Successful",
                    f"Successfully exported {updated} translation(s) to Phrase bilingual DOCX.\n\n"
                    f"File: {Path(file_path).name}\n\n"
                    f"This file can be imported back into Phrase (Memsource).\n"
                    f"Only Column 5 (target text) has been updated."
                )
            else:
                QMessageBox.critical(self, "Export Error", "Failed to save the Phrase bilingual DOCX file.")

        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export Phrase bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ Phrase export failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def import_dejavu_bilingual(self):
        """Import Déjà Vu X3 bilingual RTF file (table format)"""
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Déjà Vu X3 Bilingual RTF File",
            "",
            "RTF Files (*.rtf);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        # Show info dialog about the Déjà Vu workflow
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Déjà Vu X3 Bilingual Import")
        dialog.setMinimumWidth(450)
        layout = QVBoxLayout(dialog)
        
        # Info text
        info_label = QLabel(
            "This workflow imports Déjà Vu X3 bilingual RTF for round-tripping.\n\n"
            "• Déjà Vu inline tags (e.g., {00108}text{00109}) will be preserved\n"
            "• The project can be exported back to Déjà Vu RTF format\n"
            "• Column structure: ID | Source | Target | Comments"
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
        layout.addWidget(info_label)
        
        # Note about tags
        note_label = QLabel(
            "ℹ️ Note: Déjà Vu tags {NNNNN} will be highlighted in the translation grid."
        )
        note_label.setWordWrap(True)
        note_label.setStyleSheet("color: #2196F3; font-size: 10px; margin-top: 10px;")
        layout.addWidget(note_label)
        
        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        try:
            from modules.dejavurtf_handler import DejaVuRTFHandler
            
            # Load the bilingual RTF
            handler = DejaVuRTFHandler()
            if not handler.load(file_path):
                QMessageBox.critical(
                    self, "Error",
                    "Failed to load the Déjà Vu RTF file.\n\nPlease check the file format."
                )
                return
            
            # Extract segments
            segments_data = handler.extract_source_segments()
            
            if not segments_data:
                QMessageBox.warning(self, "Warning", "No segments found in the Déjà Vu RTF file.")
                return
            
            # Detect languages from handler
            source_lang = handler.source_lang or "nl"
            target_lang = handler.target_lang or "es"
            
            # Store the handler for later export
            self.dejavu_handler = handler
            self.dejavu_source_file = file_path
            
            # Create project
            project_name = Path(file_path).stem
            self.current_project = Project(
                name=project_name,
                source_lang=source_lang,
                target_lang=target_lang,
                segments=[]
            )
            
            # Store Déjà Vu source path in project for persistence
            self.current_project.dejavu_source_path = file_path

            # Sync global language settings with imported project languages
            self.source_language = source_lang
            self.target_language = target_lang

            # Create segments
            for idx, seg_data in enumerate(segments_data):
                segment = Segment(
                    id=idx + 1,
                    source=seg_data.source_text,
                    target=seg_data.target_text or '',
                    status="draft" if seg_data.target_text.strip() else "not_started",
                    type="para",
                    notes=seg_data.comment or '',
                    dejavu_segment_id=seg_data.segment_id,
                    dejavu_row_index=seg_data.row_index,
                )
                self.current_project.segments.append(segment)

            # CRITICAL: Update _original_segment_order for new import
            self._original_segment_order = self.current_project.segments.copy()

            # Update UI
            self.project_file_path = None
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            self.initialize_tm_database()
            self._clear_caches_after_import()
            self._deactivate_all_resources_for_new_project()

            # Auto-resize rows for better initial display
            self.auto_resize_rows()

            # Initialize spellcheck for target language
            self._initialize_spellcheck_for_target_language(target_lang)

            self.log(f"✓ Imported Déjà Vu X3 bilingual RTF: {len(segments_data)} segments from {Path(file_path).name}")

            # Store current document path for AI Assistant
            self.current_document_path = file_path

            # Refresh AI Assistant context
            if hasattr(self, 'prompt_manager_qt'):
                self.prompt_manager_qt.refresh_context()

            QMessageBox.information(
                self, "Import Successful",
                f"Imported {len(segments_data)} segment(s) from Déjà Vu X3 bilingual RTF.\n\n"
                f"File: {Path(file_path).name}\n"
                f"Languages: {source_lang.upper()} → {target_lang.upper()}\n\n"
                f"Déjà Vu tags have been preserved in the source text."
            )
            
        except ImportError as e:
            QMessageBox.critical(
                self, "Import Error",
                f"Failed to import Déjà Vu RTF module:\n\n{str(e)}"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import Déjà Vu RTF:\n\n{str(e)}")
            self.log(f"✗ Déjà Vu import failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def export_dejavu_bilingual(self):
        """Export translations back to Déjà Vu X3 bilingual RTF format"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "No project is currently loaded.")
            return

        # Check if we have a Déjà Vu source
        dejavu_source = None

        # First check if handler is already loaded
        if hasattr(self, 'dejavu_handler') and self.dejavu_handler:
            dejavu_source = getattr(self, 'dejavu_source_file', None)

        # If not, check if project has the source path saved
        if not dejavu_source and hasattr(self.current_project, 'dejavu_source_path') and self.current_project.dejavu_source_path:
            dejavu_source = self.current_project.dejavu_source_path

        if not dejavu_source:
            # Prompt user to select the original Déjà Vu bilingual file
            reply = QMessageBox.question(
                self, "Select Déjà Vu Source File",
                "To export to Déjà Vu format, please select the original Déjà Vu bilingual RTF file.\n\n"
                "This is the file you originally exported from Déjà Vu X3.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Déjà Vu Bilingual RTF",
                    "",
                    "RTF Files (*.rtf);;All Files (*.*)"
                )

                if file_path:
                    dejavu_source = file_path
                    self.dejavu_source_file = file_path
                    self.current_project.dejavu_source_path = file_path
                    self.log(f"✓ Déjà Vu source file set: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return

        # Check if source file still exists
        if not Path(dejavu_source).exists():
            reply = QMessageBox.question(
                self, "Source File Not Found",
                f"The original Déjà Vu source file cannot be found:\n\n"
                f"{dejavu_source}\n\n"
                f"Would you like to browse for the file in its new location?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original Déjà Vu Bilingual RTF",
                    "",
                    "RTF Files (*.rtf);;All Files (*.*)"
                )

                if file_path:
                    dejavu_source = file_path
                    self.dejavu_source_file = file_path
                    self.current_project.dejavu_source_path = file_path
                    self.log(f"✓ Déjà Vu source file relocated: {Path(file_path).name}")
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return

        # Reload the handler if needed
        if not hasattr(self, 'dejavu_handler') or not self.dejavu_handler:
            try:
                from modules.dejavurtf_handler import DejaVuRTFHandler
                self.dejavu_handler = DejaVuRTFHandler()
                if not self.dejavu_handler.load(dejavu_source):
                    QMessageBox.critical(self, "Error", "Failed to reload the Déjà Vu source file.")
                    return
                self.dejavu_handler.extract_source_segments()
                self.dejavu_source_file = dejavu_source
                self.log(f"✓ Reloaded Déjà Vu source file for export")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to reload Déjà Vu source:\n\n{str(e)}")
                return

        # Suggest output filename
        source_path = Path(self.dejavu_source_file)
        suggested_name = source_path.stem + "_translated" + source_path.suffix

        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Export Déjà Vu Bilingual RTF",
            str(source_path.parent / suggested_name),
            "RTF Files (*.rtf);;All Files (*.*)"
        )

        if not file_path:
            return

        try:
            # First sync grid content to project segments
            self._sync_grid_targets_to_segments(self.current_project.segments)
            
            # Collect translations from project segments
            translations = {}
            for segment in self.current_project.segments:
                # Use row_index if available (most reliable for Déjà Vu)
                if segment.dejavu_row_index is not None:
                    if segment.target and segment.target.strip():
                        translations[segment.dejavu_row_index] = segment.target
                # Fallback to segment ID mapping
                elif segment.dejavu_segment_id:
                    if segment.target and segment.target.strip():
                        translations[segment.dejavu_segment_id] = segment.target
            
            self.log(f"Collected {len(translations)} translations from project")

            # Update the handler with translations
            if any(isinstance(k, int) for k in translations.keys()):
                # Use row index method
                row_translations = {k: v for k, v in translations.items() if isinstance(k, int)}
                updated = self.dejavu_handler.update_translations_by_index(row_translations)
            else:
                # Use segment ID method
                updated = self.dejavu_handler.update_translations(translations)

            # Save to new file
            if self.dejavu_handler.save(file_path):
                self.log(f"✓ Exported {updated} translated segments to Déjà Vu RTF: {Path(file_path).name}")

                QMessageBox.information(
                    self, "Export Successful",
                    f"Successfully exported {updated} translation(s) to Déjà Vu bilingual RTF.\n\n"
                    f"File: {Path(file_path).name}\n\n"
                    f"This file can be imported back into Déjà Vu X3.\n"
                    f"Déjà Vu tags have been preserved."
                )
            else:
                QMessageBox.critical(self, "Export Error", "Failed to save the Déjà Vu bilingual RTF file.")

        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export Déjà Vu bilingual RTF:\n\n{str(e)}")
            self.log(f"✗ Déjà Vu export failed: {str(e)}")
            import traceback
            traceback.print_exc()

    def import_review_table(self):
        """Import a Supervertaler Bilingual Table to update translations in current project.
        
        This allows proofreaders to make changes in the exported DOCX bilingual table
        and have those changes re-imported back into the project.
        """
        import re
        
        # Check if we have a project open
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(
                self, "No Project Open",
                "Please open a project first before importing a bilingual table.\n\n"
                "The bilingual table will update translations in the current project."
            )
            return
        
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Select Bilingual Table DOCX to Import",
            "",
            "Word Documents (*.docx);;All Files (*.*)"
        )
        
        if not file_path:
            return
        
        try:
            from docx import Document
            
            doc = Document(file_path)
            
            # Find the table
            if not doc.tables:
                QMessageBox.warning(
                    self, "Invalid Format",
                    "No table found in the document.\n\n"
                    "Expected a Supervertaler Bilingual Table with columns:\n"
                    "#, Source, Target, Status, Notes"
                )
                return
            
            table = doc.tables[0]
            
            # Check header row
            if len(table.rows) < 2:
                QMessageBox.warning(
                    self, "Invalid Format",
                    "The table appears to be empty or has no data rows."
                )
                return
            
            header_row = table.rows[0]
            headers = [cell.text.strip().lower() for cell in header_row.cells]
            
            # Validate headers
            expected_headers = ['#', 'source', 'target', 'status', 'notes']
            if len(headers) < 3:  # At minimum we need #, source, target
                QMessageBox.warning(
                    self, "Invalid Format",
                    f"Expected columns: #, Source, Target, Status, Notes\n"
                    f"Found: {', '.join(headers)}"
                )
                return
            
            # Parse table rows
            imported_data = []
            parse_errors = []
            
            for row_idx, row in enumerate(table.rows[1:], start=1):
                cells = row.cells
                if len(cells) < 3:
                    continue
                
                try:
                    seg_num_text = cells[0].text.strip()
                    if not seg_num_text:
                        continue
                    seg_num = int(seg_num_text)
                    source_text = cells[1].text.strip()
                    target_text = cells[2].text.strip()
                    
                    # Optional fields
                    status_text = cells[3].text.strip() if len(cells) > 3 else ''
                    notes_text = cells[4].text.strip() if len(cells) > 4 else ''
                    
                    imported_data.append({
                        'segment_num': seg_num,
                        'source': source_text,
                        'target': target_text,
                        'status': status_text,
                        'notes': notes_text,
                        'row_idx': row_idx
                    })
                except ValueError as e:
                    parse_errors.append(f"Row {row_idx}: Could not parse segment number")
            
            if not imported_data:
                QMessageBox.warning(
                    self, "No Data Found",
                    "No valid segment data found in the bilingual table."
                )
                return
            
            # Compare with current project and find changes
            current_segments = list(self.current_project.segments)
            changes = []
            mismatches = []
            
            for data in imported_data:
                seg_num = data['segment_num']
                seg_idx = seg_num - 1  # Convert to 0-based index
                
                if seg_idx < 0 or seg_idx >= len(current_segments):
                    mismatches.append(f"Segment {seg_num}: Not found in project (project has {len(current_segments)} segments)")
                    continue
                
                current_seg = current_segments[seg_idx]
                current_source = current_seg.source if hasattr(current_seg, 'source') else ''
                current_target = current_seg.target if hasattr(current_seg, 'target') else ''
                
                # Check if source matches (sanity check)
                if data['source'] != current_source:
                    # Allow for minor whitespace differences
                    if data['source'].strip() != current_source.strip():
                        mismatches.append(f"Segment {seg_num}: Source text mismatch")
                        continue
                
                # Check if target changed
                if data['target'] != current_target:
                    changes.append({
                        'segment_num': seg_num,
                        'segment_idx': seg_idx,
                        'old_target': current_target,
                        'new_target': data['target'],
                        'notes': data['notes']
                    })
            
            if not changes and not mismatches:
                QMessageBox.information(
                    self, "No Changes",
                    f"Compared {len(imported_data)} segments - no changes detected."
                )
                return
            
            # Show preview dialog for changes
            preview_text = f"Found {len(changes)} change(s) to apply:\n\n"
            
            for i, change in enumerate(changes[:10]):  # Show first 10
                preview_text += f"Segment {change['segment_num']}:\n"
                old_preview = change['old_target'][:50] + ('...' if len(change['old_target']) > 50 else '')
                new_preview = change['new_target'][:50] + ('...' if len(change['new_target']) > 50 else '')
                preview_text += f"  Old: {old_preview}\n"
                preview_text += f"  New: {new_preview}\n\n"
            
            if len(changes) > 10:
                preview_text += f"... and {len(changes) - 10} more changes\n\n"
            
            if mismatches:
                preview_text += f"\n⚠ {len(mismatches)} segment(s) could not be matched:\n"
                for mismatch in mismatches[:5]:
                    preview_text += f"  • {mismatch}\n"
                if len(mismatches) > 5:
                    preview_text += f"  ... and {len(mismatches) - 5} more\n"
            
            preview_text += "\nApply these changes?"
            
            reply = QMessageBox.question(
                self, "Review Changes",
                preview_text,
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply != QMessageBox.StandardButton.Yes:
                self.log("Bilingual table import cancelled by user")
                return
            
            # Apply changes
            applied_count = 0
            for change in changes:
                seg_idx = change['segment_idx']
                current_segments[seg_idx].target = change['new_target']
                
                # Reset status to 'not_started' - flags segment for translator review
                # This matches the behavior when manually editing a segment
                if hasattr(current_segments[seg_idx], 'status'):
                    current_segments[seg_idx].status = 'not_started'
                
                # Add note if provided
                if change['notes']:
                    if hasattr(current_segments[seg_idx], 'notes'):
                        existing_notes = current_segments[seg_idx].notes or ''
                        if existing_notes:
                            current_segments[seg_idx].notes = f"{existing_notes}\n[Review: {change['notes']}]"
                        else:
                            current_segments[seg_idx].notes = f"[Review: {change['notes']}]"
                    else:
                        current_segments[seg_idx].notes = f"[Review: {change['notes']}]"
                
                applied_count += 1
            
            # Mark project as modified and refresh UI
            self.project_modified = True
            self.update_window_title()
            self.load_segments_to_grid()
            
            self.log(f"✓ Applied {applied_count} change(s) from bilingual table: {Path(file_path).name}")
            
            QMessageBox.information(
                self, "Import Complete",
                f"Successfully applied {applied_count} change(s) from the bilingual table.\n\n"
                f"Changed segments set to 'Not Started' for translator review.\n"
                f"Comments from the review have been added to segment comments."
            )
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for bilingual table import.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Import Error", f"Failed to import bilingual table:\n\n{str(e)}")
            self.log(f"✗ Bilingual table import failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def export_cafetran_bilingual(self):
        """Export to CafeTran bilingual DOCX format with translations"""
        # Check if we have segments
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Data", "No segments to export")
            return
        
        # Check if a CafeTran source file was imported - check both instance var and project
        cafetran_source = None
        if hasattr(self, 'cafetran_source_file') and self.cafetran_source_file:
            cafetran_source = self.cafetran_source_file
        elif hasattr(self.current_project, 'cafetran_source_path') and self.current_project.cafetran_source_path:
            cafetran_source = self.current_project.cafetran_source_path
            # Verify it still exists
            if Path(cafetran_source).exists():
                self.cafetran_source_file = cafetran_source
                self.log(f"✓ Restored CafeTran source from project: {Path(cafetran_source).name}")
                # Reload the handler
                try:
                    from modules.cafetran_docx_handler import CafeTranDOCXHandler
                    self.cafetran_handler = CafeTranDOCXHandler()
                    if not self.cafetran_handler.load(cafetran_source):
                        QMessageBox.critical(self, "Error", "Failed to reload CafeTran source file")
                        return
                    self.cafetran_handler.extract_source_segments()
                except Exception as e:
                    QMessageBox.critical(self, "Error", f"Failed to reload CafeTran file: {str(e)}")
                    return
            else:
                cafetran_source = None
        
        if not cafetran_source:
            # Prompt user to select the original CafeTran bilingual file
            reply = QMessageBox.question(
                self, "Select CafeTran Source File",
                "To export to CafeTran format, please select the original CafeTran bilingual DOCX file.\n\n"
                "This is the file you originally imported from CafeTran.\n\n"
                "Would you like to select it now?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                file_path, _ = QFileDialog.getOpenFileName(
                    self,
                    "Select Original CafeTran Bilingual DOCX",
                    "",
                    "Word Documents (*.docx);;All Files (*.*)"
                )
                
                if file_path:
                    self.cafetran_source_file = file_path
                    self.log(f"✓ CafeTran source file set: {Path(file_path).name}")
                    
                    # Load the handler
                    try:
                        from modules.cafetran_docx_handler import CafeTranDOCXHandler
                        self.cafetran_handler = CafeTranDOCXHandler()
                        if not self.cafetran_handler.load(file_path):
                            QMessageBox.critical(self, "Error", "Failed to load CafeTran source file")
                            return
                        self.cafetran_handler.extract_source_segments()
                    except Exception as e:
                        QMessageBox.critical(self, "Error", f"Failed to load CafeTran file: {str(e)}")
                        return
                else:
                    self.log("Export cancelled - no source file selected")
                    return
            else:
                self.log("Export cancelled")
                return
        
        try:
            from modules.cafetran_docx_handler import CafeTranDOCXHandler
            
            segments = list(self.current_project.segments)
            translations = [seg.target for seg in segments]
            
            if not translations or all(not t.strip() for t in translations):
                QMessageBox.warning(self, "Warning", "No translations found to export.")
                return
            
            # Update the handler with translations
            if hasattr(self, 'cafetran_handler') and self.cafetran_handler:
                handler = self.cafetran_handler
            else:
                handler = CafeTranDOCXHandler()
                if not handler.load(self.cafetran_source_file):
                    QMessageBox.critical(self, "Error", "Failed to load CafeTran source file")
                    return
                handler.extract_source_segments()
            
            # Update target segments with translations
            handler.update_target_segments(translations)
            
            # Prompt user to save the file
            source_path = Path(self.cafetran_source_file)
            default_name = str(source_path.parent / (source_path.stem + "_translated.docx"))
            save_path, _ = QFileDialog.getSaveFileName(
                self,
                "Save CafeTran Bilingual DOCX",
                default_name,
                "Word Documents (*.docx);;All Files (*.*)"
            )
            
            if save_path:
                if handler.save(save_path):
                    self.log(f"✓ Exported {len(translations)} translations to CafeTran bilingual DOCX: {Path(save_path).name}")
                    
                    QMessageBox.information(
                        self, "Export Successful",
                        f"Successfully exported {len(translations)} translation(s) to CafeTran bilingual DOCX!\n\n"
                        f"File saved: {Path(save_path).name}\n\n"
                        f"✓ Pipe symbol formatting preserved\n"
                        f"✓ Table structure preserved\n\n"
                        f"You can now import this file back into CafeTran."
                    )
                else:
                    QMessageBox.critical(self, "Error", "Failed to save CafeTran bilingual DOCX")
            
        except ImportError:
            QMessageBox.critical(
                self, "Missing Dependency",
                "The 'python-docx' library is required for CafeTran bilingual DOCX export.\n\n"
                "Install it with: pip install python-docx"
            )
        except Exception as e:
            QMessageBox.critical(self, "Export Error", f"Failed to export CafeTran bilingual DOCX:\n\n{str(e)}")
            self.log(f"✗ CafeTran export failed: {str(e)}")
            import traceback
            traceback.print_exc()
    
    # ========================================================================
    # GRID MANAGEMENT
    # ========================================================================
    
    def load_segments_to_grid(self, progress_callback=None):
        """Load segments into the grid with termbase highlighting.

        progress_callback: optional callable(rows_done, total_rows). Called every
        ~25 rows during the build loop so callers can update a progress dialog
        and pump the Qt event loop, keeping the UI responsive on large imports.
        """
        self.log(f"🔄🔄🔄 load_segments_to_grid CALLED - this will RELOAD grid from segment data!")

        # Ensure original segment order is stored (for Document Order sort)
        if self.current_project and not hasattr(self, '_original_segment_order'):
            self._original_segment_order = self.current_project.segments.copy()

        # Clear row color settings cache to ensure fresh settings are loaded
        if hasattr(self, '_row_color_settings_cached'):
            delattr(self, '_row_color_settings_cached')
        
        # DEBUG: Log segment data BEFORE loading to grid
        if self.current_project and self.current_project.segments:
            self.log(f"🔄 BEFORE LOAD: First 7 segments:")
            for seg in self.current_project.segments[:7]:
                self.log(f"🔄   Seg {seg.id} (obj {id(seg)}): target='{seg.target[:30] if seg.target else 'EMPTY'}...', len={len(seg.target)}, status={seg.status}")
        
        if not self.current_project or not self.current_project.segments:
            self.clear_grid()
            return

        # Decide the page size for a newly-opened project (All, or paged for
        # very large docs). Runs once per project; no-op on re-renders.
        self._maybe_auto_set_page_size()

        self.table.setRowCount(len(self.current_project.segments))

        previous_suppression = self._suppress_target_change_handlers
        self._suppress_target_change_handlers = True
        
        # Pre-calculate list numbers for numbered lists
        # Track consecutive <li-o> or <li> items and assign numbers
        list_counter = 0
        last_was_list = False
        list_numbers = {}  # {segment_index: list_number}
        
        for idx, segment in enumerate(self.current_project.segments):
            source_text = segment.source.strip()
            # Support both new tags (<li-o>, <li-b>) and legacy <li> tag
            is_list_item = source_text.startswith('<li-o>') or source_text.startswith('<li-b>') or source_text.startswith('<li>')
            # <li-o> is ordered, <li> (legacy) is treated as ordered for numbering
            is_ordered = source_text.startswith('<li-o>') or (source_text.startswith('<li>') and not source_text.startswith('<li-b>'))
            
            # Check if it's a numbered list item (vs bullet)
            # If text inside <li-o> or <li> starts with a number pattern, extract it
            # Otherwise, if it's part of a consecutive list sequence, count it
            if is_list_item and is_ordered:
                import re
                # Check for number at start: <li-o>1. or <li>1. or <li-o>2) etc
                num_match = re.match(r'^<li(?:-o)?>\s*(\d+)[.)\s]', source_text)
                if num_match:
                    # Has explicit number in text
                    list_numbers[idx] = int(num_match.group(1))
                    list_counter = int(num_match.group(1))
                    last_was_list = True
                elif last_was_list:
                    # Continue numbering from previous
                    list_counter += 1
                    list_numbers[idx] = list_counter
                else:
                    # First item, start at 1
                    list_counter = 1
                    list_numbers[idx] = list_counter
                    last_was_list = True
            else:
                # Not a list item, reset counter
                last_was_list = False
                list_counter = 0

        try:
            _total_rows = len(self.current_project.segments)
            for row, segment in enumerate(self.current_project.segments):
                # Yield to the Qt event loop every ~25 rows so the window
                # repaints and the OS doesn't mark it "Not Responding".
                if progress_callback is not None and row % 25 == 0:
                    try:
                        progress_callback(row, _total_rows)
                    except Exception:
                        pass

                # Clear any previous cell widgets
                self.table.removeCellWidget(row, 2)  # Source
                self.table.removeCellWidget(row, 3)  # Target
                self.table.removeCellWidget(row, 4)  # Match
                self.table.removeCellWidget(row, 5)  # Status
                
                # ID - Segment number (black in light themes, theme text color in dark themes)
                id_item = QTableWidgetItem(str(segment.id))
                id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable)  # Read-only
                id_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                # Black for light themes, theme text color for dark themes
                theme = self.theme_manager.current_theme
                segment_num_color = "black" if theme.name in ["Light (Default)", "Soft Gray", "Warm Cream", "Sepia", "High Contrast"] else theme.text
                id_item.setForeground(QColor(segment_num_color))
                id_item.setBackground(QColor())  # Default background from theme
                # Smaller font for segment numbers
                seg_num_font = QFont(self.default_font_family, max(9, self.default_font_size - 1))
                id_item.setFont(seg_num_font)
                self.table.setItem(row, 0, id_item)
                
                # Type - show segment type based on style and content
                # Determine type display from style attribute and segment type
                style = getattr(segment, 'style', 'Normal')
                
                # Check for list items - use stored list_type/list_number if available
                list_type = getattr(segment, 'list_type', '')
                list_number_stored = getattr(segment, 'list_number', None)
                
                # Use pre-calculated list number from our loop above
                list_number_calculated = list_numbers.get(row, None)
                
                # Fallback: detect from source text if list_type not set
                source_text = segment.source.strip()
                is_list_item = bool(list_type) or (
                    source_text.startswith('<li-o>') or  # Tagged ordered list item
                    source_text.startswith('<li-b>') or  # Tagged bullet list item
                    source_text.startswith('<li>') or    # Legacy list tag
                    source_text.lstrip().startswith(('• ', '- ', '* ', '· ')) or
                    (len(source_text) > 2 and source_text[0].isdigit() and source_text[1:3] in ('. ', ') '))
                )
                
                # Determine type display
                if 'Title' in style:
                    type_display = "Title"
                elif 'Heading 1' in style or 'Heading1' in style:
                    type_display = "H1"
                elif 'Heading 2' in style or 'Heading2' in style:
                    type_display = "H2"
                elif 'Heading 3' in style or 'Heading3' in style:
                    type_display = "H3"
                elif 'Heading 4' in style or 'Heading4' in style:
                    type_display = "H4"
                elif 'Subtitle' in style:
                    type_display = "Sub"
                elif is_list_item:
                    # Show list number for numbered lists, bullet for unordered
                    # Check for <li-b> tag first (explicit bullet)
                    if '<li-b>' in source_text:
                        type_display = "•"
                    # Check for bullet patterns in text
                    elif source_text.lstrip().startswith(('• ', '- ', '* ', '· ')):
                        type_display = "•"
                    elif list_type == "bullet":
                        type_display = "•"
                    elif list_number_stored is not None:
                        type_display = f"#{list_number_stored}"
                    elif list_number_calculated is not None:
                        type_display = f"#{list_number_calculated}"
                    elif '<li-o>' in source_text or '<li>' in source_text:
                        # Ordered list - use calculated number or show #?
                        type_display = f"#{list_numbers.get(row, '?')}"
                    elif list_type == "numbered":
                        type_display = "#?"
                    else:
                        # Fallback: check for number at start of text
                        import re
                        num_match = re.match(r'^(\d+)[.)\s]', source_text)
                        if num_match:
                            type_display = f"#{num_match.group(1)}"
                        elif '<li-o>' in source_text:
                            # Has <li-o> but no number - numbered list
                            type_display = "#?"
                        else:
                            type_display = "li"
                elif segment.type and segment.type != "para":
                    # Handle # type specially - only show #N for actual numbered items
                    if segment.type == "#":
                        # Plain "#" means continuation text within a list, show as paragraph
                        type_display = "¶"
                    elif segment.type.startswith("#") and len(segment.type) > 1:
                        # Has a number like "#1", "#2" - keep it
                        type_display = segment.type
                    else:
                        type_display = segment.type.upper()
                else:
                    type_display = "¶"  # Paragraph symbol
                
                type_item = QTableWidgetItem(type_display)
                type_item.setFlags(type_item.flags() & ~Qt.ItemFlag.ItemIsEditable)  # Read-only
                type_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)

                # Color-code by type for better visibility
                if type_display in ("H1", "H2", "H3", "H4", "Title"):
                    type_item.setForeground(QColor("#1976D2"))  # Blue for headings (works in both themes)
                elif type_display.startswith("#") or type_display in ("•", "li"):
                    type_item.setForeground(QColor("#388E3C"))  # Green for list items (works in both themes)

                # Slightly smaller font for type symbols (kept in sync with apply_font_to_grid).
                # The ¶ paragraph mark renders heavier than the text labels, so shave an
                # extra point off it specifically.
                _type_delta = 2 if type_display == "¶" else 1
                type_font = QFont(self.default_font_family, max(6, self.default_font_size - _type_delta))
                type_item.setFont(type_font)

                self.table.setItem(row, 1, type_item)
                
                # Source - Use read-only QTextEdit widget for easy text selection
                # Strip outer wrapping tags if setting is enabled (display only, data unchanged)
                source_for_display = segment.source
                stripped_source_tag = None
                if self.hide_outer_wrapping_tags:
                    stripped, stripped_source_tag = strip_outer_wrapping_tags(segment.source)
                    source_for_display = stripped
                # Apply compact tag shortening if in compact mode
                # Build tag_map from source so target uses same numbering
                _compact_tag_map = None
                if getattr(self, 'tag_view_mode', 'tags') == 'compact':
                    _compact_tag_map = {}
                    source_for_display = compact_tags(source_for_display, _compact_tag_map)
                # Apply invisible character replacements for display only
                source_display_text = self.apply_invisible_replacements(source_for_display)
                source_editor = ReadOnlyGridTextEditor(source_display_text, self.table, row)

                # Initialize empty termbase matches (will be populated lazily on segment selection or by background worker)
                source_editor.termbase_matches = {}

                # Set font to match grid – both setFont (so editor knows its
                # logical font) AND apply_grid_font_size (so the size lives in
                # the widget's own stylesheet and wins over ThemeManager's
                # app-level QTextEdit rule at non-100% UI scale).
                font = QFont(self.default_font_family, self.default_font_size)
                source_editor.setFont(font)
                source_editor.apply_grid_font_size(self.default_font_size)

                # Set as cell widget (allows easy text selection)
                self.table.setCellWidget(row, 2, source_editor)

                # Also set a placeholder item for row height calculation
                source_item = QTableWidgetItem()
                source_item.setFlags(Qt.ItemFlag.NoItemFlags)  # No interaction
                self.table.setItem(row, 2, source_item)

                # Target - Use editable QTextEdit widget for easy text selection and editing
                # Strip outer wrapping tags if enabled (will be auto-restored when saving)
                target_for_display = segment.target
                stripped_target_tag = None
                if self.hide_outer_wrapping_tags:
                    stripped, stripped_target_tag = strip_outer_wrapping_tags(segment.target)
                    target_for_display = stripped
                    # Store stripped tag on segment for restore during save
                    # Use source tag as reference (target should match source structure)
                    segment._stripped_outer_tag = stripped_source_tag or stripped_target_tag

                # Apply compact tag shortening to target (display only – reversed before saving)
                if _compact_tag_map is not None:
                    target_for_display = compact_tags(target_for_display, _compact_tag_map)
                # Apply invisible character replacements for display (will be reversed when saving)
                target_display_text = self.apply_invisible_replacements(target_for_display)
                target_editor = EditableGridTextEditor(target_display_text, self.table, row, self.table)
                target_editor.setFont(font)
                target_editor.apply_grid_font_size(self.default_font_size)
                # Store stripped tag on editor for auto-restore
                target_editor._stripped_outer_tag = stripped_source_tag or stripped_target_tag
                # Store compact tag map for reverse-expanding on save
                target_editor._compact_tag_map = _compact_tag_map

                # Make locked segments read-only (included for context only)
                if getattr(segment, 'locked', False):
                    target_editor.setReadOnly(True)

                # Connect text changes to update segment
                # Use a factory function to create a proper closure that captures the segment ID
                def make_target_changed_handler(segment_id, editor_widget):
                    # Create debounce timer for expensive operations
                    debounce_timer = None
                    
                    def on_target_text_changed():
                        nonlocal debounce_timer
                        new_text = editor_widget.toPlainText()

                        # Reverse compact tag placeholders before saving
                        compact_map = getattr(editor_widget, '_compact_tag_map', None)
                        if compact_map:
                            new_text = expand_compact_tags(new_text, compact_map)

                        # Reverse invisible character replacements before saving
                        new_text = self.reverse_invisible_replacements(new_text)

                        # Auto-restore stripped outer wrapping tags if they were hidden
                        stripped_tag = getattr(editor_widget, '_stripped_outer_tag', None)
                        if stripped_tag and self.hide_outer_wrapping_tags:
                            # Only re-add if the text doesn't already have the tag
                            if not new_text.strip().startswith(f'<{stripped_tag}'):
                                new_text = f'<{stripped_tag}>{new_text}</{stripped_tag}>'

                        # DEBUG: Log EVERY call to catch the culprit (only in debug mode)
                        if self.debug_mode_enabled:
                            self.log(f"🔔 textChanged FIRED: segment_id={segment_id}, new_text='{new_text[:20] if new_text else 'EMPTY'}...'")

                        # CRITICAL: Ignore spurious textChanged event from Qt's queued document changes
                        # When signals are unblocked after setPlainText(), Qt delivers queued events
                        # This flag prevents false TM saves during grid load/filter/refresh operations
                        if not editor_widget._initial_load_complete:
                            editor_widget._initial_load_complete = True
                            if self.debug_mode_enabled:
                                self.log(f"🔔 textChanged IGNORED (initial load) for segment {segment_id}")
                            return

                        if self._suppress_target_change_handlers:
                            if self.debug_mode_enabled:
                                self.log(f"🔔 textChanged SUPPRESSED for segment {segment_id}")
                            return
                        
                        # CRITICAL: Find segment by ID, not by row index!
                        # Row indices can change, but segment IDs are stable
                        target_segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
                        if not target_segment:
                            return
                        
                        # Capture old values for undo
                        old_target = target_segment.target
                        old_status = target_segment.status
                        
                        # Update the target text
                        if self.debug_mode_enabled:
                            self.log(f"📝 BEFORE update: seg {segment_id} target='{target_segment.target[:30] if target_segment.target else 'EMPTY'}...', status={target_segment.status}, obj_id={id(target_segment)}")
                        target_segment.target = new_text

                        # If invisible markers are active, re-apply them so the widget display
                        # stays in sync (e.g. ↵ after Shift+Enter when Show Invisibles is on).
                        # In compact mode, use compact text for display (not the expanded save text)
                        if hasattr(self, 'invisible_display_settings') and any(self.invisible_display_settings.values()):
                            display_source = new_text
                            compact_map = getattr(editor_widget, '_compact_tag_map', None)
                            if compact_map:
                                display_source = compact_tags(display_source, compact_map)
                            display_text = self.apply_invisible_replacements(display_source)
                            if display_text != editor_widget.toPlainText():
                                # Cursor position in the clean (marker-free) new_text.
                                # The widget currently shows the old display text; we need to
                                # map the cursor to its position in new_text first.
                                cur = editor_widget.textCursor()
                                old_display_pos = cur.position()
                                old_display = editor_widget.toPlainText()
                                # Convert old_display_pos → clean-text position by stripping
                                # markers that appear before cursor in the OLD display text.
                                old_before_cursor = old_display[:old_display_pos]
                                clean_pos = len(self.reverse_invisible_replacements(old_before_cursor))
                                # Now map clean_pos → new display_text position.
                                # apply_invisible_replacements inserts markers before each
                                # invisible char; count them in the display text up to clean_pos.
                                new_display_before = self.apply_invisible_replacements(display_source[:clean_pos])
                                new_pos = min(len(new_display_before), len(display_text))
                                editor_widget.blockSignals(True)
                                editor_widget.setPlainText(display_text)
                                cur.setPosition(new_pos)
                                editor_widget.setTextCursor(cur)
                                editor_widget._initial_load_complete = False
                                editor_widget.blockSignals(False)

                        # Reset 'confirmed' status to 'draft' when user edits the segment
                        # This prevents auto-saving to TM until user re-confirms the edit
                        new_status = old_status
                        if old_status == 'confirmed' and new_text != old_target:
                            from PyQt6.QtCore import QTimer as QTimerLocal
                            new_status = 'draft'
                            target_segment.status = new_status
                            if self.debug_mode_enabled:
                                self.log(f"📝 Status reset: confirmed → draft (segment edited)")
                            # Refresh the status icon in the grid (debounced to avoid UI lag)
                            QTimerLocal.singleShot(0, lambda sid=segment_id: self._refresh_segment_status_by_id(sid))
                        
                        if self.debug_mode_enabled:
                            self.log(f"📝 AFTER update: seg {segment_id} target='{target_segment.target[:30] if target_segment.target else 'EMPTY'}...', status={target_segment.status}, obj_id={id(target_segment)}")
                        
                        # Record undo state with any status change
                        self.record_undo_state(segment_id, old_target, new_text, old_status, new_status)
                        
                        # Mark project as modified
                        self.project_modified = True
                        
                        # DEBOUNCED: Expensive UI/DB operations (only after user stops typing)
                        # Cancel previous timer
                        if debounce_timer:
                            debounce_timer.stop()
                        
                        # Schedule expensive operations after 500ms of inactivity
                        from PyQt6.QtCore import QTimer
                        debounce_timer = QTimer()
                        debounce_timer.setSingleShot(True)
                        # CRITICAL: Use default parameter to capture new_text BY VALUE, not by reference
                        # This prevents the closure from capturing a variable that changes later
                        debounce_timer.timeout.connect(lambda text=new_text: self._handle_target_text_debounced_by_id(
                            segment_id, text
                        ))
                        debounce_timer.start(1000)  # 1000ms delay (increased for better responsiveness)
                            
                    return on_target_text_changed
                
                # Set as cell widget FIRST (before connecting signals)
                self.table.setCellWidget(row, 3, target_editor)
                
                # Also set a placeholder item for row height calculation
                target_item = QTableWidgetItem()
                target_item.setFlags(Qt.ItemFlag.NoItemFlags)  # No interaction
                self.table.setItem(row, 3, target_item)
                
                # CRITICAL: Connect textChanged AFTER widget is in table and setText is done
                # This prevents programmatic setText from triggering TM saves during grid reload
                target_editor.textChanged.connect(make_target_changed_handler(segment.id, target_editor))
                
                # DO NOT unblock signals here - they stay blocked until end of load_segments_to_grid

                # Pre-populate status cell item so gridlines render before widget assignment
                status_placeholder = QTableWidgetItem()
                status_placeholder.setFlags(Qt.ItemFlag.NoItemFlags)
                status_placeholder.setBackground(QColor(get_status(segment.status).color))
                self.table.setItem(row, 4, status_placeholder)

                # Status column (icon + match + comment)
                self._update_status_cell(row, segment)
                
                # Apply alternating row colors to source and target widgets
                self._apply_row_color(row, source_editor, target_editor)

                # File boundary separator for multi-file projects
                if getattr(self.current_project, 'is_multifile', False) and row > 0:
                    prev_file_id = getattr(self.current_project.segments[row - 1], 'file_id', None)
                    curr_file_id = getattr(segment, 'file_id', None)
                    if curr_file_id is not None and prev_file_id is not None and curr_file_id != prev_file_id:
                        source_editor.set_file_boundary(True)
                        target_editor.set_file_boundary(True)
                        # Re-apply row color so the border-top takes effect in the stylesheet
                        self._apply_row_color(row, source_editor, target_editor)


            # Apply current font
            self.apply_font_to_grid()
            
            # Auto-resize rows
            self.auto_resize_rows()
            self._enforce_status_row_heights()
            
            self.log(f"✓ Loaded {len(self.current_project.segments)} segments to grid")
            
            # Apply pagination - show only segments for current page
            self._apply_pagination_to_grid()

            # Reposition file boundary banners (pagination may have changed row visibility)
            self._update_file_boundary_labels()

            # Apply current tag view mode (WYSIWYG or Tags)
            if hasattr(self, 'show_tags') and self.show_tags:
                # If tags mode is enabled, refresh to show raw tags
                self._refresh_grid_display_mode()

            # Also refresh List view if it exists
            if hasattr(self, 'list_tree'):
                self.refresh_list_view()
        finally:
            self._suppress_target_change_handlers = previous_suppression
            
            # NOW unblock all target editor signals - grid loading is complete
            # This MUST happen after suppression flag is restored to avoid race conditions
            for row in range(self.table.rowCount()):
                target_widget = self.table.cellWidget(row, 3)
                if target_widget:
                    target_widget.blockSignals(False)

            # v1.10.57 Phase C: paint background highlight on any
            # anchored comment ranges. Done after all cells are
            # populated and signals are unblocked, so the highlight
            # applies to the final character formatting layer.
            try:
                self._apply_comment_anchors_to_all_cells()
            except Exception as e:
                self.log(f"⚠ Anchor highlights skipped: {e}")

            # Update progress stats in status bar
            self.update_progress_stats()

            # Refresh document preview
            self.refresh_preview()

            # Deferred row resize: columns use Stretch mode, so their final widths
            # are only known after Qt processes the layout. Schedule a resize for
            # after all pending events are processed.
            QTimer.singleShot(0, self._resize_visible_rows)

    # =========================================================================
    # COMPARE PANEL TAB
    # =========================================================================

    def _create_compare_panel(self) -> QWidget:
        """Create the Compare Panel tab with 4 comparison boxes:
        Current Source, Machine Translation, TM Source, TM Target
        With navigation arrows for multiple matches.
        """
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.setSpacing(4)
        
        # Get theme colors
        if hasattr(self, 'theme_manager') and self.theme_manager:
            theme = self.theme_manager.current_theme
            # Check if theme is dark by checking the theme name or a dark attribute
            is_dark = getattr(theme, 'is_dark', 'dark' in theme.name.lower())
            border_color = theme.border
            title_color = theme.text_disabled
            text_color = theme.text
            # Use theme-appropriate colors for boxes
            box1_bg = theme.panel_info if hasattr(theme, 'panel_info') else ("#2d3748" if is_dark else "#e3f2fd")
            box2_bg = theme.panel_warning if hasattr(theme, 'panel_warning') else ("#3d3520" if is_dark else "#fff3cd")
            box3_bg = theme.panel_success if hasattr(theme, 'panel_success') else ("#1e3a2f" if is_dark else "#d4edda")
            box4_bg = theme.panel_neutral if hasattr(theme, 'panel_neutral') else ("#2d2d3d" if is_dark else "#e8e8f0")
        else:
            is_dark = False
            border_color = "#ddd"
            title_color = "#666"
            text_color = "#333"
            box1_bg = "#e3f2fd"
            box2_bg = "#fff3cd"
            box3_bg = "#d4edda"
            box4_bg = "#e8e8f0"
        
        # Header with segment info
        header_layout = QHBoxLayout()
        self.compare_panel_segment_label = QLabel("Segment: -")
        self.compare_panel_segment_label.setStyleSheet(f"font-weight: bold; font-size: 11px; color: {title_color};")
        header_layout.addWidget(self.compare_panel_segment_label)
        header_layout.addStretch()
        layout.addLayout(header_layout)
        
        # Store text edits and navigation state for theme updates
        self.compare_panel_text_edits = []
        self.compare_panel_mt_matches = []  # List of MT matches
        self.compare_panel_tm_matches = []  # List of TM matches
        self.compare_panel_mt_index = 0     # Current MT match index
        self.compare_panel_tm_index = 0     # Current TM match index
        
        # Box 1: Current Source (blue-ish) - no navigation needed
        box1_container, self.compare_panel_current_source, _, _ = self._create_compare_panel_box(
            "📝 Current Source", box1_bg, text_color, border_color, has_navigation=False)
        layout.addWidget(box1_container, 1)
        
        # Box 2: Machine Translation (purple-ish) - with navigation
        box2_container, self.compare_panel_mt, self.compare_panel_mt_nav_label, mt_nav_btns = self._create_compare_panel_box(
            "🤖 MT", box4_bg, text_color, border_color, has_navigation=True, shortcut_badge_text="0", shortcut_badge_tooltip="Alt+0")
        if mt_nav_btns:
            mt_nav_btns[0].clicked.connect(lambda: self._compare_panel_nav_mt(-1))
            mt_nav_btns[1].clicked.connect(lambda: self._compare_panel_nav_mt(1))
        layout.addWidget(box2_container, 1)
        
        # Box 3: TM Source (yellow-ish) - with navigation
        box3_container, self.compare_panel_tm_source, self.compare_panel_tm_nav_label, tm_nav_btns = self._create_compare_panel_box(
            "📚 TM Source", box2_bg, text_color, border_color, has_navigation=True)
        if tm_nav_btns:
            tm_nav_btns[0].clicked.connect(lambda: self._compare_panel_nav_tm(-1))
            tm_nav_btns[1].clicked.connect(lambda: self._compare_panel_nav_tm(1))
        layout.addWidget(box3_container, 1)
        
        # Box 4: TM Target (green-ish) - syncs with TM Source navigation
        box4_container, self.compare_panel_tm_target, self.compare_panel_tm_target_label, _ = self._create_compare_panel_box(
            "✅ TM Target", box3_bg, text_color, border_color, has_navigation=False, show_metadata_label=True,
            shortcut_badge_text="00", shortcut_badge_tooltip="Alt+0,0")
        layout.addWidget(box4_container, 1)
        
        return widget
    
    def _create_match_panel(self) -> QWidget:
        """Create Match Panel with TermLens + TM Source/Target boxes."""
        widget = QWidget()
        main_layout = QVBoxLayout(widget)
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)
        
        # Initialize compare_panel_text_edits if not exists (needed for _create_compare_panel_box)
        if not hasattr(self, 'compare_panel_text_edits'):
            self.compare_panel_text_edits = []
        
        # Vertical splitter: TermLens (top) | TM boxes (bottom)
        splitter = QSplitter(Qt.Orientation.Vertical)
        
        # TOP: Container with header + TermLens
        termlens_container = QWidget()
        termlens_layout = QVBoxLayout(termlens_container)
        termlens_layout.setContentsMargins(4, 4, 4, 0)
        termlens_layout.setSpacing(2)
        
        # TermLens header row: label (left) + tip (right)
        if hasattr(self, 'theme_manager') and self.theme_manager:
            header_color = self.theme_manager.current_theme.text_disabled
        else:
            header_color = "#666"

        termlens_header_row = QHBoxLayout()
        termlens_header_row.setContentsMargins(0, 0, 0, 0)
        termlens_header_row.setSpacing(0)

        termlens_header = QLabel("📖 TermLens")
        termlens_header.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {header_color};")
        termlens_header_row.addWidget(termlens_header)

        termlens_header_row.addStretch()

        termlens_tip = QLabel("💡 Tip: Press F5 to refresh if matches are missing")
        termlens_tip.setStyleSheet(f"font-size: 9pt; color: {header_color}; font-style: italic;")
        termlens_header_row.addWidget(termlens_tip)

        termlens_layout.addLayout(termlens_header_row)
        
        # Third TermLens instance for Match Panel
        self.termlens_widget_match = TermLensWidget(self, db_manager=self.db_manager, log_callback=self.log, theme_manager=self.theme_manager)
        set_help_topic(self.termlens_widget_match, HelpTopics.GLOSSARY_TERMLENS)
        # Connect TermLens signals
        self.termlens_widget_match.term_insert_requested.connect(self.insert_termlens_text)
        self.termlens_widget_match.edit_entry_requested.connect(self._on_termlens_edit_entry)
        self.termlens_widget_match.delete_entry_requested.connect(self._on_termlens_delete_entry)
        # v1.10.68: refresh button on the Match-panel TermLens too —
        # both instances share the same backing index, so the handler
        # is the same.
        self.termlens_widget_match.refresh_requested.connect(self._on_termlens_refresh_requested)
        self.termlens_widget_match.font_size_changed.connect(self._on_termlens_font_size_changed)
        # Apply font settings
        font_settings = self.load_general_settings()
        termlens_family = font_settings.get('termlens_font_family', 'Segoe UI')
        termlens_size = font_settings.get('termlens_font_size', 10)
        termlens_bold = font_settings.get('termlens_font_bold', False)
        self.termlens_widget_match.set_font_settings(termlens_family, termlens_size, termlens_bold)
        termlens_layout.addWidget(self.termlens_widget_match)

        # Wrap the TermLens section + a QuickTrans panel in a tab pair, so the
        # user can have TermLens below the grid and QuickTrans here (or vice
        # versa). Mirrors the under-grid TermLens | QuickTrans dock.
        from PyQt6.QtWidgets import QTabWidget as _QTabWidget
        self.match_top_tabs = _QTabWidget()
        self.match_top_tabs.addTab(termlens_container, "📖 TermLens")
        try:
            from modules.quicktrans import QuickTransPanel
            self.quicktrans_panel_match = QuickTransPanel(self)
            self.quicktrans_panel_match.translation_selected.connect(self._insert_quicktrans_translation)
            self.match_top_tabs.addTab(self.quicktrans_panel_match, "⚡ QuickTrans")
            self.match_top_tabs.currentChanged.connect(self._on_match_top_tab_changed)
        except Exception as e:
            self.quicktrans_panel_match = None
            self.log(f"Could not create Match Panel QuickTrans panel: {e}")

        splitter.addWidget(self.match_top_tabs)
        
        # BOTTOM: Container for TM Source + TM Target boxes
        # v1.9.307: Store references for layout switching (horizontal ↔ vertical)
        # Default layout matches self.match_panel_tm_vertical (True = vertical)
        self._tm_container = QWidget()
        if getattr(self, 'match_panel_tm_vertical', True):
            self._tm_layout = QVBoxLayout(self._tm_container)
        else:
            self._tm_layout = QHBoxLayout(self._tm_container)
        self._tm_layout.setContentsMargins(0, 0, 0, 0)
        self._tm_layout.setSpacing(0)

        # Get theme-aware colors for TM boxes (same as Compare Panel)
        if hasattr(self, 'theme_manager') and self.theme_manager:
            theme = self.theme_manager.current_theme
            is_dark = getattr(theme, 'is_dark', 'dark' in theme.name.lower())
            border_color = theme.border
            text_color = theme.text
            # Green background - theme-appropriate shade
            tm_box_bg = theme.panel_success if hasattr(theme, 'panel_success') else ("#1e3a2f" if is_dark else "#d4edda")
        else:
            tm_box_bg = "#d4edda"    # Green (light mode)
            text_color = "#333"
            border_color = "#ddd"

        # TM Source box (GREEN, with navigation)
        self.match_panel_tm_matches = []  # Separate match list
        self.match_panel_tm_index = 0     # Separate navigation index

        self._tm_source_container, self.match_panel_tm_source, self.match_panel_tm_nav_label, tm_nav_btns = self._create_compare_panel_box(
            "📚 TM Source", tm_box_bg, text_color, border_color, has_navigation=True)
        if tm_nav_btns:
            tm_nav_btns[0].clicked.connect(lambda: self._match_panel_nav_tm(-1))
            tm_nav_btns[1].clicked.connect(lambda: self._match_panel_nav_tm(1))
        # Right-click context menu for TM Source
        self.match_panel_tm_source.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.match_panel_tm_source.customContextMenuRequested.connect(self._match_panel_tm_context_menu)
        self._tm_layout.addWidget(self._tm_source_container, 1)

        # TM Target box (GREEN, with metadata and shortcut badge)
        self._tm_target_container, self.match_panel_tm_target, self.match_panel_tm_target_label, _ = self._create_compare_panel_box(
            "✅ TM Target", tm_box_bg, text_color, border_color, has_navigation=False, show_metadata_label=True,
            shortcut_badge_text="0", shortcut_badge_tooltip="Alt+0")
        # Right-click context menu for TM Target
        self.match_panel_tm_target.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.match_panel_tm_target.customContextMenuRequested.connect(self._match_panel_tm_context_menu)
        self._tm_layout.addWidget(self._tm_target_container, 1)

        # Force stylesheet on the tm_container itself (the parent widget holding both boxes)
        self._tm_container.setStyleSheet("background-color: transparent;")

        splitter.addWidget(self._tm_container)

        # Set initial splitter sizes (60% TermLens, 40% TM boxes)
        splitter.setSizes([600, 400])

        main_layout.addWidget(splitter)


        return widget

    def _toggle_match_panel_tm_layout(self, save=True):
        """Toggle TM Source / TM Target panes between horizontal (side-by-side)
        and vertical (stacked) layout in the Match Panel.

        v1.9.307: Accessible via right-click context menu and View > Match Panel menu.

        Args:
            save: If True, persist the preference to general_settings.json.
                  Set to False during startup restore to avoid redundant writes.
        """
        from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout

        if not hasattr(self, '_tm_container') or not hasattr(self, '_tm_source_container'):
            return

        # Remove widgets from current layout (keeps widget alive)
        self._tm_layout.removeWidget(self._tm_source_container)
        self._tm_layout.removeWidget(self._tm_target_container)

        # Delete the old layout by orphaning it onto a temporary widget
        QWidget().setLayout(self._tm_container.layout())

        # Flip the orientation
        self.match_panel_tm_vertical = not self.match_panel_tm_vertical

        # Create new layout with the desired orientation
        if self.match_panel_tm_vertical:
            self._tm_layout = QVBoxLayout(self._tm_container)
        else:
            self._tm_layout = QHBoxLayout(self._tm_container)
        self._tm_layout.setContentsMargins(0, 0, 0, 0)
        self._tm_layout.setSpacing(0)

        # Re-add widgets in the same order
        self._tm_layout.addWidget(self._tm_source_container, 1)
        self._tm_layout.addWidget(self._tm_target_container, 1)

        # Sync View menu checkbox
        if hasattr(self, 'match_panel_vertical_action'):
            self.match_panel_vertical_action.setChecked(self.match_panel_tm_vertical)

        # Persist preference
        if save:
            try:
                settings = self._load_general_settings_from_file()
                settings['match_panel_tm_vertical'] = self.match_panel_tm_vertical
                self.save_general_settings(settings)
            except Exception:
                pass

    def _match_panel_nav_tm(self, direction: int):
        """Navigate TM matches in Match Panel (-1 = prev, 1 = next)"""
        if not self.match_panel_tm_matches:
            return
        
        new_index = self.match_panel_tm_index + direction
        if 0 <= new_index < len(self.match_panel_tm_matches):
            self.match_panel_tm_index = new_index
            self._update_match_panel_tm_display()
    
    def _update_match_panel_tm_display(self):
        """Update Match Panel TM Source and TM Target display with current match"""
        if not self.match_panel_tm_matches:
            self.match_panel_tm_source.setPlainText("(No TM match)")
            self.match_panel_tm_target.setPlainText("(No TM match)")
            if hasattr(self, 'match_panel_tm_nav_label') and self.match_panel_tm_nav_label:
                self.match_panel_tm_nav_label.setText("(0/0)")
            if hasattr(self, 'match_panel_tm_target_label') and self.match_panel_tm_target_label:
                self.match_panel_tm_target_label.setText("")
            return
        
        match = self.match_panel_tm_matches[self.match_panel_tm_index]
        total = len(self.match_panel_tm_matches)
        idx = self.match_panel_tm_index + 1

        # Support both dict and TranslationMatch objects
        if isinstance(match, dict):
            tm_source_text = match.get('source', '')
            target_text = match.get('target', '')
            tm_name = match.get('tm_name', 'Unknown TM')
            match_pct = match.get('match_pct', 0)
            reverse_match = match.get('reverse_match', False)
        else:
            tm_source_text = getattr(match, 'compare_source', '') or getattr(match, 'source', '')
            target_text = getattr(match, 'target', '')
            md = match.metadata if hasattr(match, 'metadata') and match.metadata else {}
            tm_name = md.get('tm_name', 'Unknown TM')
            match_pct = getattr(match, 'relevance', 0)
            reverse_match = bool(md.get('reverse_match', False))

        # Update navigation label
        if hasattr(self, 'match_panel_tm_nav_label') and self.match_panel_tm_nav_label:
            nav_html = f"(<span style='font-size:8px'>{idx}/{total}</span>)"
            self.match_panel_tm_nav_label.setText(nav_html)

        # Update TM Source text with diff highlighting
        # Strip any stale invisible markers from TM data before processing
        current_source = getattr(self, 'match_panel_current_source', '')
        clean_tm_source = tm_source_text
        if clean_tm_source and hasattr(self, 'reverse_invisible_replacements'):
            clean_tm_source = self.reverse_invisible_replacements(clean_tm_source)
        if clean_tm_source and current_source:
            self._set_compare_panel_text_with_diff(self.match_panel_tm_source, current_source, clean_tm_source)
        else:
            # Apply invisible replacements for display (e.g. ↵ for line breaks)
            tm_src_display = clean_tm_source or "(No TM match)"
            if clean_tm_source and hasattr(self, 'apply_invisible_replacements'):
                tm_src_display = self.apply_invisible_replacements(tm_src_display)
            self.match_panel_tm_source.setPlainText(tm_src_display)

        # Update TM Target text (no diff highlighting needed for target)
        # Strip any stale invisible markers first (in case TM data was saved with markers),
        # then re-apply current invisible display settings for clean rendering
        target_display = target_text if target_text else ""
        if target_display and hasattr(self, 'reverse_invisible_replacements'):
            target_display = self.reverse_invisible_replacements(target_display)
        if target_display and hasattr(self, 'apply_invisible_replacements'):
            target_display = self.apply_invisible_replacements(target_display)
        self.match_panel_tm_target.setPlainText(target_display)

        # Update metadata label (TM name, percentage, optional reverse-match chip)
        if hasattr(self, 'match_panel_tm_target_label') and self.match_panel_tm_target_label:
            # v1.10.50: append a small "⇄ reversed" chip when the match came
            # from a TM whose source/target languages are the inverse of this
            # project's. The chip uses a muted amber colour so it reads as
            # "informational, not alarming." Tooltip on the label spells it
            # out for users who don't recognise the symbol.
            reverse_chip = ""
            if reverse_match:
                reverse_chip = (
                    " <span style='font-size:8px; color:#b45309; "
                    "background-color:rgba(245,158,11,0.18); "
                    "padding:1px 5px; border-radius:6px;'>⇄ reversed</span>"
                )
                # v1.10.52: wrap tooltip text in HTML with explicit colours
                # so it survives Windows-11 OS-level tooltip rendering, which
                # in dark mode ignores both the per-widget QToolTip stylesheet
                # and the QPalette ToolTipBase/Text roles. Inline HTML in the
                # tooltip text itself is always honoured.
                self.match_panel_tm_target_label.setToolTip(
                    "<div style='background-color:#1f2937; color:#f9fafb; "
                    "padding:6px 8px; max-width:360px;'>"
                    "This match comes from a TM whose source/target languages "
                    "are the inverse of this project. The match is from the "
                    "TM's target column; Workbench has swapped fields so it "
                    "displays normally."
                    "</div>"
                )
            else:
                self.match_panel_tm_target_label.setToolTip("")
            metadata_html = (
                f"<span style='font-size:10px'>{tm_name}</span> "
                f"(<span style='font-size:8px'>{match_pct}%</span>)"
                f"{reverse_chip}"
            )
            self.match_panel_tm_target_label.setText(metadata_html)
    
    def _match_panel_tm_context_menu(self, pos):
        """Right-click context menu for the TM Source / TM Target boxes in the Match Panel."""
        from PyQt6.QtWidgets import QMenu
        from PyQt6.QtGui import QAction

        sender = self.sender()
        menu = sender.createStandardContextMenu()

        # Fix blurry context menu rendering (high-DPI / transparent background inheritance)
        menu.setStyleSheet("""
            QMenu {
                background-color: palette(window);
                border: 1px solid palette(mid);
            }
            QMenu::item {
                padding: 4px 20px;
            }
            QMenu::item:selected {
                background-color: palette(highlight);
                color: palette(highlighted-text);
            }
            QMenu::separator {
                height: 1px;
                background: palette(mid);
                margin: 4px 8px;
            }
        """)

        # v1.9.307: Layout toggle – always available (even with no TM matches)
        menu.addSeparator()
        is_vertical = getattr(self, 'match_panel_tm_vertical', True)
        layout_action = QAction("↕ Stack TM Source / Target Vertically", menu)
        layout_action.setCheckable(True)
        layout_action.setChecked(is_vertical)
        layout_action.triggered.connect(lambda _checked: self._toggle_match_panel_tm_layout())
        menu.addAction(layout_action)

        if not self.match_panel_tm_matches:
            menu.exec(sender.mapToGlobal(pos))
            return

        match = self.match_panel_tm_matches[self.match_panel_tm_index]

        # Support both dict and TranslationMatch objects
        if isinstance(match, dict):
            tm_id = match.get('tm_id', '')
            tm_name = match.get('tm_name', '') or tm_id or 'TM'
        else:
            # TranslationMatch object
            tm_id = match.metadata.get('tm_id', '') if hasattr(match, 'metadata') and match.metadata else ''
            tm_name = match.metadata.get('tm_name', '') if hasattr(match, 'metadata') and match.metadata else ''
            tm_name = tm_name or tm_id or 'TM'

        menu.addSeparator()
        edit_action = QAction(f"✏️ Edit TM Entry ({tm_name})", menu)
        edit_action.triggered.connect(self._match_panel_edit_tm_entry)
        if not tm_id:
            edit_action.setEnabled(False)
            edit_action.setText("✏️ Edit TM Entry (no TM ID available)")
        menu.addAction(edit_action)

        delete_action = QAction(f"🗑️ Delete TM Entry ({tm_name})", menu)
        delete_action.triggered.connect(self._match_panel_delete_tm_entry)
        if not tm_id:
            delete_action.setEnabled(False)
            delete_action.setText("🗑️ Delete TM Entry (no TM ID available)")
        menu.addAction(delete_action)

        menu.exec(sender.mapToGlobal(pos))

    def _get_match_panel_tm_fields(self, match):
        """Extract tm_id, tm_name, source, target from a Match Panel match (dict or TranslationMatch)."""
        if isinstance(match, dict):
            return (match.get('tm_id', ''),
                    match.get('tm_name', '') or match.get('tm_id', '') or 'TM',
                    match.get('source', ''),
                    match.get('target', ''))
        else:
            md = match.metadata if hasattr(match, 'metadata') and match.metadata else {}
            return (md.get('tm_id', ''),
                    md.get('tm_name', '') or md.get('tm_id', '') or 'TM',
                    getattr(match, 'source', ''),
                    getattr(match, 'target', ''))

    def _match_panel_edit_tm_entry(self):
        """Edit the TM entry currently displayed in the Match Panel."""
        if not self.match_panel_tm_matches:
            return

        from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QLabel,
                                     QTextEdit, QDialogButtonBox, QMessageBox)

        match = self.match_panel_tm_matches[self.match_panel_tm_index]
        tm_id, tm_name, old_source, old_target = self._get_match_panel_tm_fields(match)

        if not tm_id:
            QMessageBox.warning(self, "Cannot Edit",
                                "TM identifier is not available for this entry.")
            return

        dialog = QDialog(self)
        dialog.setWindowTitle(f"Edit TM Entry – {tm_name}")
        dialog.setMinimumWidth(520)
        layout = QVBoxLayout(dialog)
        layout.setSpacing(8)

        layout.addWidget(QLabel("Source (read-only):"))
        source_edit = QTextEdit()
        source_edit.setPlainText(old_source)
        source_edit.setReadOnly(True)
        source_edit.setMaximumHeight(90)
        layout.addWidget(source_edit)

        layout.addWidget(QLabel("Target:"))
        target_edit = QTextEdit()
        target_edit.setPlainText(old_target)
        target_edit.setMaximumHeight(120)
        layout.addWidget(target_edit)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)

        if dialog.exec() != QDialog.DialogCode.Accepted:
            return

        new_target = target_edit.toPlainText()
        if new_target == old_target:
            return  # Nothing changed

        updated = False
        try:
            if hasattr(self, 'tm_manager') and self.tm_manager and hasattr(self.tm_manager, 'update_entry'):
                updated = self.tm_manager.update_entry(tm_id, old_source, old_target,
                                                       old_source, new_target)
            elif hasattr(self, 'db_manager') and self.db_manager and hasattr(self.db_manager, 'update_entry'):
                updated = self.db_manager.update_entry(tm_id, old_source, old_target,
                                                       old_source, new_target)
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to update TM entry:\n{e}")
            return

        if updated:
            self.log(f"✏️ Updated TM entry in '{tm_name}'")
            # Patch the in-memory match so the display reflects the change immediately
            if isinstance(match, dict):
                match['target'] = new_target
            else:
                match.target = new_target
            self._update_match_panel_tm_display()
            # Force re-lookup next time this segment is navigated to
            if hasattr(self, '_last_selected_row'):
                self._last_selected_row = None
        else:
            QMessageBox.warning(self, "Not Found",
                                "The entry could not be found in the database.\n"
                                "It may have already been changed or deleted.")

    def _match_panel_delete_tm_entry(self):
        """Delete the TM entry currently displayed in the Match Panel."""
        if not self.match_panel_tm_matches:
            return

        from PyQt6.QtWidgets import QMessageBox

        match = self.match_panel_tm_matches[self.match_panel_tm_index]
        tm_id, tm_name, source_text, target_text = self._get_match_panel_tm_fields(match)

        if not tm_id:
            QMessageBox.warning(self, "Cannot Delete",
                                "TM identifier is not available for this entry.")
            return

        reply = QMessageBox.question(
            self,
            "Confirm Deletion",
            f"Delete TM entry from '{tm_name}'?\n\n"
            f"Source: {source_text[:120]}\n"
            f"Target: {target_text[:120]}\n\n"
            "This action cannot be undone.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        if reply != QMessageBox.StandardButton.Yes:
            return

        try:
            if hasattr(self, 'tm_manager') and self.tm_manager and hasattr(self.tm_manager, 'delete_entry'):
                self.tm_manager.delete_entry(tm_id, source_text, target_text)
            elif hasattr(self, 'db_manager') and self.db_manager and hasattr(self.db_manager, 'delete_entry'):
                self.db_manager.delete_entry(tm_id, source_text, target_text)
            else:
                QMessageBox.critical(self, "Error",
                                     "No TM manager available to perform deletion.")
                return
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to delete TM entry:\n{e}")
            return

        self.log(f"🗑️ Deleted TM entry from '{tm_name}'")

        # Remove from the in-memory list and update display
        self.match_panel_tm_matches.pop(self.match_panel_tm_index)
        # Clamp index in case we deleted the last item
        if self.match_panel_tm_index >= len(self.match_panel_tm_matches):
            self.match_panel_tm_index = max(0, len(self.match_panel_tm_matches) - 1)
        self._update_match_panel_tm_display()

        # Force re-lookup next time this segment is navigated to
        if hasattr(self, '_last_selected_row'):
            self._last_selected_row = None

    def _create_compare_panel_box(self, label: str, bg_color: str, text_color: str, border_color: str,
                                   has_navigation: bool = False, show_metadata_label: bool = False,
                                   shortcut_badge_text: str = None, shortcut_badge_tooltip: str = None) -> tuple:
        """Create a single comparison box for the Compare Panel
        
        Returns: (container, text_edit, nav_label, nav_buttons) where nav_buttons is [prev_btn, next_btn] or None
        """
        container = QFrame()
        container.setStyleSheet(f"""
            QFrame {{
                background-color: {bg_color};
                border: 1px solid {border_color};
                border-radius: 4px;
            }}
        """)
        
        main_layout = QVBoxLayout(container)
        main_layout.setContentsMargins(6, 4, 6, 6)
        main_layout.setSpacing(2)
        
        # Header row with label, navigation, and metadata
        header_layout = QHBoxLayout()
        header_layout.setSpacing(4)
        
        # Title label
        title_label = QLabel(label)
        title_label.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {text_color}; background: transparent; border: none;")
        header_layout.addWidget(title_label)

        # Optional shortcut badge (TermLens-style blue bubble)
        if shortcut_badge_text:
            badge_width = 14 if len(shortcut_badge_text) == 1 else 20
            badge_label = QLabel(shortcut_badge_text)
            badge_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            badge_label.setFixedSize(badge_width, 14)
            badge_label.setStyleSheet("""
                QLabel {
                    background-color: #1976D2;
                    color: white;
                    border-radius: 7px;
                    font-size: 9px;
                    font-weight: bold;
                    padding: 0px;
                }
            """)
            if shortcut_badge_tooltip:
                badge_label.setToolTip(
                    f"Press {format_shortcut_for_display(shortcut_badge_tooltip)} to insert"
                )
            header_layout.addWidget(badge_label)

        # Add stretch to push navigation controls to the right (aligned with scrollbar)
        header_layout.addStretch()

        nav_label = None
        nav_buttons = None

        if has_navigation or show_metadata_label:
            # Navigation: (1/3) ◄ ► provider_name • 95%
            nav_label = QLabel("(0/0)")
            nav_label.setStyleSheet(f"font-size: 9pt; color: {text_color}; background: transparent; border: none;")
            # Store color so later rich-text updates can preserve theme color
            nav_label._compare_text_color = text_color
            header_layout.addWidget(nav_label)

        if has_navigation:
            # Detect theme for arrow color
            is_dark_theme = hasattr(self, 'theme_manager') and self.theme_manager and 'dark' in self.theme_manager.current_theme.name.lower()
            arrow_color = "#FFFFFF" if is_dark_theme else "#333333"

            # Create clickable label class with theme update capability
            from PyQt6.QtCore import pyqtSignal

            class ClickableArrow(QLabel):
                clicked = pyqtSignal()

                def __init__(self, arrow_symbol, parent=None):
                    """arrow_symbol: '◀' or '▶'"""
                    self.arrow_symbol = arrow_symbol
                    super().__init__("", parent)
                    self.setCursor(Qt.CursorShape.PointingHandCursor)

                def set_color(self, color):
                    """Update arrow color for current theme"""
                    self.setStyleSheet(f"""
                        QLabel {{
                            color: {color};
                            background: transparent;
                            border: none;
                            font-size: 11px;
                            font-weight: bold;
                        }}
                    """)
                    self.setText(self.arrow_symbol)

                def mousePressEvent(self, event):
                    self.clicked.emit()
                    super().mousePressEvent(event)

            # Prev arrow - using ◀
            prev_btn = ClickableArrow("◀")
            prev_btn.set_color(arrow_color)
            prev_btn.setFixedSize(16, 16)
            prev_btn.setAlignment(Qt.AlignmentFlag.AlignCenter)
            header_layout.addWidget(prev_btn)

            # Next arrow - using ▶
            next_btn = ClickableArrow("▶")
            next_btn.set_color(arrow_color)
            next_btn.setFixedSize(16, 16)
            next_btn.setAlignment(Qt.AlignmentFlag.AlignCenter)
            header_layout.addWidget(next_btn)

            # Store reference for theme updates
            if not hasattr(self, 'theme_aware_arrows'):
                self.theme_aware_arrows = []
            self.theme_aware_arrows.extend([prev_btn, next_btn])

            nav_buttons = [prev_btn, next_btn]

        main_layout.addLayout(header_layout)
        
        # Text area
        text_edit = QTextEdit()
        text_edit.setReadOnly(True)
        _mp_font_size = SupervertalerQt.match_panel_font_size  # Use live value so saved zoom is respected
        text_edit.setStyleSheet(f"""
            QTextEdit {{
                background-color: transparent;
                border: none;
                font-size: {_mp_font_size}px;
                padding: 2px;
                color: {text_color};
            }}
        """)
        # Also set document default font so rich-text content (cursor-inserted diffs) respects size
        from PyQt6.QtGui import QFont as _QFont
        _doc_font = _QFont()
        if SupervertalerQt.match_panel_font_family:
            _doc_font.setFamily(SupervertalerQt.match_panel_font_family)
        _doc_font.setPointSize(_mp_font_size)
        _doc_font.setBold(SupervertalerQt.match_panel_font_bold)
        text_edit.document().setDefaultFont(_doc_font)
        text_edit.setMinimumHeight(50)
        main_layout.addWidget(text_edit, 1)
        
        # Track for font updates and theming
        self.compare_panel_text_edits.append((text_edit, title_label, container, bg_color))
        
        return container, text_edit, nav_label, nav_buttons
    
    def _compare_panel_nav_mt(self, direction: int):
        """Navigate MT matches in Compare Panel (-1 = prev, 1 = next)"""
        if not self.compare_panel_mt_matches:
            return
        
        new_index = self.compare_panel_mt_index + direction
        if 0 <= new_index < len(self.compare_panel_mt_matches):
            self.compare_panel_mt_index = new_index
            self._update_compare_panel_mt_display()
    
    def _compare_panel_nav_tm(self, direction: int):
        """Navigate TM matches in Compare Panel (-1 = prev, 1 = next)"""
        if not self.compare_panel_tm_matches:
            return
        
        new_index = self.compare_panel_tm_index + direction
        if 0 <= new_index < len(self.compare_panel_tm_matches):
            self.compare_panel_tm_index = new_index
            self._update_compare_panel_tm_display()
    
    def _update_compare_panel_mt_display(self):
        """Update MT section display with current match"""
        if not self.compare_panel_mt_matches:
            self.compare_panel_mt.setPlainText("(No MT available)")
            if hasattr(self, 'compare_panel_mt_nav_label') and self.compare_panel_mt_nav_label:
                self.compare_panel_mt_nav_label.setText("(0/0)")
            return
        
        match = self.compare_panel_mt_matches[self.compare_panel_mt_index]
        total = len(self.compare_panel_mt_matches)
        
        # Update navigation label with emphasis:
        # - Provider name: larger (readability)
        try:
            import html
            provider_escaped = html.escape(str(match.get('provider', 'MT')))
        except Exception:
            provider_escaped = str(match.get('provider', 'MT'))

        idx = self.compare_panel_mt_index + 1
        nav_html = (
            f"(<span style='font-size:8px'>{idx}/{total}</span>) "
            f"<span style='font-size:10px'>{provider_escaped}</span>"
        )

        if hasattr(self, 'compare_panel_mt_nav_label') and self.compare_panel_mt_nav_label:
            # Avoid a fixed font-size stylesheet overriding rich text emphasis, but keep theme color
            label_color = getattr(self.compare_panel_mt_nav_label, '_compare_text_color', None)
            if label_color:
                self.compare_panel_mt_nav_label.setStyleSheet(
                    f"color: {label_color}; background: transparent; border: none;"
                )
            else:
                self.compare_panel_mt_nav_label.setStyleSheet("background: transparent; border: none;")
            self.compare_panel_mt_nav_label.setText(nav_html)
        
        # Update text
        self.compare_panel_mt.setPlainText(match.get('translation', ''))
    
    def _update_compare_panel_tm_display(self):
        """Update TM Source and TM Target display with current match"""
        if not self.compare_panel_tm_matches:
            self.compare_panel_tm_source.setPlainText("(No TM match)")
            self.compare_panel_tm_target.setPlainText("(No TM match)")
            if hasattr(self, 'compare_panel_tm_nav_label') and self.compare_panel_tm_nav_label:
                self.compare_panel_tm_nav_label.setText("(0/0)")
            if hasattr(self, 'compare_panel_tm_target_label') and self.compare_panel_tm_target_label:
                self.compare_panel_tm_target_label.setText("(0/0)")
            return
        
        match = self.compare_panel_tm_matches[self.compare_panel_tm_index]
        total = len(self.compare_panel_tm_matches)

        # Get metadata
        tm_name = match.get('tm_name', 'TM')
        match_pct = match.get('match_pct', 0)
        reverse_match = bool(match.get('reverse_match', False))

        # Update metadata labels with emphasis:
        # - TM name: larger
        # - Match %: larger + bold
        try:
            import html
            tm_name_escaped = html.escape(str(tm_name))
        except Exception:
            tm_name_escaped = str(tm_name)

        idx = self.compare_panel_tm_index + 1
        try:
            match_pct_display = int(match_pct)
        except Exception:
            match_pct_display = match_pct

        # v1.10.50: optional "⇄ reversed" chip when the match came from a TM
        # whose source/target languages are the inverse of this project's.
        reverse_chip = ""
        if reverse_match:
            reverse_chip = (
                " <span style='font-size:8px; color:#b45309; "
                "background-color:rgba(245,158,11,0.18); "
                "padding:1px 5px; border-radius:6px;'>⇄ reversed</span>"
            )

        nav_html = (
            f"(<span style='font-size:8px'>{idx}/{total}</span>) "
            f"<span style='font-size:10px'>{tm_name_escaped}</span> "
            f"<span style='font-size:8px'>•</span> "
            f"<span style='font-size:10px; font-weight:700'>{match_pct_display}%</span>"
            f"{reverse_chip}"
        )

        # Tooltip on both labels (set whether or not chip is present so we
        # clear the tooltip when navigating away from a reversed match).
        # v1.10.52: wrap in HTML with explicit colours so the tooltip stays
        # readable under Windows-11 dark-mode tooltip rendering, which
        # ignores both QToolTip stylesheet and ToolTipBase/Text palette.
        reverse_tooltip = (
            "<div style='background-color:#1f2937; color:#f9fafb; "
            "padding:6px 8px; max-width:360px;'>"
            "This match comes from a TM whose source/target languages are "
            "the inverse of this project. The match is from the TM's "
            "target column; Workbench has swapped fields so it displays "
            "normally."
            "</div>"
        ) if reverse_match else ""

        if hasattr(self, 'compare_panel_tm_nav_label') and self.compare_panel_tm_nav_label:
            # Avoid a fixed font-size stylesheet overriding rich text emphasis, but keep theme color
            label_color = getattr(self.compare_panel_tm_nav_label, '_compare_text_color', None)
            if label_color:
                self.compare_panel_tm_nav_label.setStyleSheet(f"color: {label_color}; background: transparent; border: none;")
            else:
                self.compare_panel_tm_nav_label.setStyleSheet("background: transparent; border: none;")
            self.compare_panel_tm_nav_label.setText(nav_html)
            self.compare_panel_tm_nav_label.setToolTip(reverse_tooltip)

        if hasattr(self, 'compare_panel_tm_target_label') and self.compare_panel_tm_target_label:
            label_color = getattr(self.compare_panel_tm_target_label, '_compare_text_color', None)
            if label_color:
                self.compare_panel_tm_target_label.setStyleSheet(f"color: {label_color}; background: transparent; border: none;")
            else:
                self.compare_panel_tm_target_label.setStyleSheet("background: transparent; border: none;")
            self.compare_panel_tm_target_label.setText(nav_html)
            self.compare_panel_tm_target_label.setToolTip(reverse_tooltip)
        
        # Update TM Source with diff highlighting
        # Strip any stale invisible markers from TM data before processing
        current_source = self.compare_panel_current_source.toPlainText()
        tm_source = match.get('source', '')
        if tm_source and hasattr(self, 'reverse_invisible_replacements'):
            tm_source = self.reverse_invisible_replacements(tm_source)
        if tm_source and current_source:
            self._set_compare_panel_text_with_diff(self.compare_panel_tm_source, current_source, tm_source)
        else:
            # Apply invisible replacements for display (e.g. ↵ for line breaks)
            cp_src_display = tm_source or "(No TM match)"
            if tm_source and hasattr(self, 'apply_invisible_replacements'):
                cp_src_display = self.apply_invisible_replacements(cp_src_display)
            self.compare_panel_tm_source.setPlainText(cp_src_display)

        # Update TM Target – strip stale markers, then re-apply current settings
        tm_target_text = match.get('target', '')
        if tm_target_text and hasattr(self, 'reverse_invisible_replacements'):
            tm_target_text = self.reverse_invisible_replacements(tm_target_text)
        if tm_target_text and hasattr(self, 'apply_invisible_replacements'):
            tm_target_text = self.apply_invisible_replacements(tm_target_text)
        self.compare_panel_tm_target.setPlainText(tm_target_text)
    
    def set_compare_panel_matches(self, segment_id: int, current_source: str, 
                                   tm_matches: list = None, mt_matches: list = None):
        """Set all matches for Match Panel (and Compare Panel if it exists)
        
        Args:
            segment_id: Current segment ID
            current_source: The source text of the current segment
            tm_matches: List of dicts with keys: source, target, tm_name, match_pct
            mt_matches: List of dicts with keys: translation, provider
        """
        # Always update Match Panel with TM matches
        self.match_panel_tm_matches = tm_matches or []
        self.match_panel_tm_index = 0
        self.match_panel_current_source = current_source  # Store for diff highlighting
        self._update_match_panel_tm_display()
        
        # Update Compare Panel if it exists (legacy support)
        if hasattr(self, 'compare_panel_current_source') and self.compare_panel_current_source:
            # Update segment label
            if hasattr(self, 'compare_panel_segment_label'):
                self.compare_panel_segment_label.setText(f"Segment {segment_id + 1}")
            
            # Update Current Source
            self.compare_panel_current_source.setPlainText(current_source)
            
            # Store matches and reset indices
            self.compare_panel_tm_matches = tm_matches or []
            self.compare_panel_mt_matches = mt_matches or []
            self.compare_panel_tm_index = 0
            self.compare_panel_mt_index = 0
            
            # Update displays
            self._update_compare_panel_mt_display()
            self._update_compare_panel_tm_display()

    def update_compare_panel(self, segment_id: int, current_source: str, 
                              tm_source: str = "", tm_target: str = "", 
                              mt_translation: str = "", tm_match_percent: int = 0,
                              tm_name: str = "TM", mt_provider: str = "MT"):
        """Update the Match Panel with new data for the current segment (legacy single-match method)
        
        This is the legacy method that accepts single TM/MT matches. For multiple matches,
        use set_compare_panel_matches() instead.
        """
        # Convert to the new format and use set_compare_panel_matches
        tm_matches = []
        mt_matches = []
        
        if tm_source or tm_target:
            tm_matches = [{
                'source': tm_source,
                'target': tm_target,
                'tm_name': tm_name,
                'match_pct': tm_match_percent
            }]
        
        if mt_translation:
            mt_matches = [{
                'translation': mt_translation,
                'provider': mt_provider
            }]
        
        self.set_compare_panel_matches(segment_id, current_source, tm_matches, mt_matches)
    
    def _set_compare_panel_text_with_diff(self, text_edit: QTextEdit, current: str, tm_source: str):
        """
        Set text with diff highlighting using memoQ-style track changes view:
        - Normal text: identical to current
        - Red underline: text added in current (insertion)
        - Red strikethrough: text removed from TM (deletion)

        This displays the TM source annotated with tracked changes.
        Newline characters (\n) are preserved as actual line breaks in the display.
        """
        import difflib
        import re

        text_edit.clear()
        cursor = text_edit.textCursor()

        # Create formatters - memoQ track changes style
        normal_format = QTextCharFormat()

        # Red strikethrough for deletions (text in TM but not in current)
        delete_format = QTextCharFormat()
        delete_format.setForeground(QColor("#cc0000"))  # Red text
        delete_format.setFontStrikeOut(True)

        # Red underline for insertions (text in current but not in TM)
        add_format = QTextCharFormat()
        add_format.setForeground(QColor("#cc0000"))  # Red text
        add_format.setFontUnderline(True)

        # Tokenize preserving \n as explicit tokens so they survive the diff
        def tokenize(text):
            """Split text into words while preserving \\n as separate tokens."""
            tokens = []
            # Split on \n first, keeping \n as tokens
            parts = re.split(r'(\n)', text)
            for part in parts:
                if part == '\n':
                    tokens.append('\n')
                elif part:
                    tokens.extend(part.split())
            return tokens

        current_words = tokenize(current)
        tm_words = tokenize(tm_source)

        matcher = difflib.SequenceMatcher(None, current_words, tm_words)

        result_parts = []  # list of (type, token_list) tuples
        for tag, i1, i2, j1, j2 in matcher.get_opcodes():
            if tag == 'equal':
                result_parts.append(('normal', tm_words[j1:j2]))
            elif tag == 'replace':
                result_parts.append(('delete', tm_words[j1:j2]))
                result_parts.append(('add', current_words[i1:i2]))
            elif tag == 'insert':
                # Text in TM but not in current - strikethrough
                result_parts.append(('delete', tm_words[j1:j2]))
            elif tag == 'delete':
                # Text in current but not in TM - underline
                result_parts.append(('add', current_words[i1:i2]))

        # Helper to insert tokens, using insertBlock() for \n
        def insert_tokens(cursor, tokens, fmt):
            first = True
            for token in tokens:
                if token == '\n':
                    # Show ↵ marker if linebreaks invisibles are enabled
                    if hasattr(self, 'invisible_display_settings') and self.invisible_display_settings.get('linebreaks', False):
                        cursor.insertText('↵', fmt)
                    cursor.insertBlock()
                    first = True  # no leading space after newline
                else:
                    if not first:
                        cursor.insertText(' ', fmt)
                    cursor.insertText(token, fmt)
                    first = False

        # Render the parts with proper spacing
        need_space = False  # track whether we need a space before next non-newline token
        for part_type, tokens in result_parts:
            if not tokens:
                continue
            fmt = normal_format if part_type == 'normal' else (delete_format if part_type == 'delete' else add_format)
            # Add space between parts (unless after a newline or at start)
            if need_space and tokens[0] != '\n':
                cursor.insertText(' ', normal_format)
            insert_tokens(cursor, tokens, fmt)
            # After rendering, check if last token was a newline
            need_space = tokens[-1] != '\n'

        text_edit.setTextCursor(cursor)
    
    def _refresh_compare_panel_theme(self):
        """Refresh Match Panel TM box colors based on current theme"""
        if not hasattr(self, 'compare_panel_text_edits'):
            return
        
        # Get theme colors
        if hasattr(self, 'theme_manager') and self.theme_manager:
            theme = self.theme_manager.current_theme
            # Check if theme is dark by checking the theme name or a dark attribute
            is_dark = getattr(theme, 'is_dark', 'dark' in theme.name.lower())
            border_color = theme.border
            text_color = theme.text
            # Use theme-appropriate colors for Match Panel boxes (both green)
            panel_success = theme.panel_success if hasattr(theme, 'panel_success') else ("#1e3a2f" if is_dark else "#d4edda")
            box_colors = [
                panel_success,  # 0: Match Panel - TM Source (green)
                panel_success,  # 1: Match Panel - TM Target (green)
            ]
        else:
            return
        
        # Update each box
        for i, (text_edit, label_widget, container, _) in enumerate(self.compare_panel_text_edits):
            bg_color = box_colors[i] if i < len(box_colors) else box_colors[-1]
            
            container.setStyleSheet(f"""
                QFrame {{
                    background-color: {bg_color};
                    border: 1px solid {border_color};
                    border-radius: 4px;
                }}
            """)
            
            label_widget.setStyleSheet(f"font-weight: bold; font-size: 9px; color: {text_color}; background: transparent; border: none;")
            
            text_edit.setStyleSheet(f"""
                QTextEdit {{
                    background-color: transparent;
                    border: none;
                    font-size: 10px;
                    padding: 2px;
                    color: {text_color};
                }}
            """)

    # =========================================================================
    # DOCUMENT PREVIEW TAB
    # =========================================================================

    def _create_preview_tab(self) -> QWidget:
        """Create the Preview tab widget with live document preview and click-to-navigate"""
        from PyQt6.QtWidgets import QScrollArea

        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        # Create scroll area for the preview
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setStyleSheet("border: none;")

        # Create the preview text widget
        preview_text = QTextEdit()
        preview_text.setReadOnly(True)
        preview_text.setStyleSheet("""
            QTextEdit {
                background-color: white;
                border: none;
                padding: 20px 30px;
                font-family: Georgia, 'Times New Roman', serif;
                font-size: 11pt;
                line-height: 1.5;
            }
        """)

        # Enable click handling for navigation
        preview_text.mousePressEvent = lambda event: self._on_preview_click(event, preview_text)
        preview_text.setCursor(Qt.CursorShape.PointingHandCursor)

        scroll_area.setWidget(preview_text)
        layout.addWidget(scroll_area)

        # Store reference for updates
        widget.preview_text = preview_text
        widget.scroll_area = scroll_area

        # Track segment positions for click navigation
        widget.segment_positions = {}  # {(start_pos, end_pos): segment_id}

        # Track current highlighted segment
        widget.current_highlighted_segment_id = None

        # Store reference in instance for easy access
        if not hasattr(self, 'preview_widgets'):
            self.preview_widgets = []
        self.preview_widgets.append(widget)

        return widget

    def _on_preview_click(self, event, preview_text: QTextEdit):
        """Handle click on preview text to navigate to corresponding segment"""
        from PyQt6.QtGui import QMouseEvent

        # Get cursor position from click
        cursor = preview_text.cursorForPosition(event.pos())
        click_pos = cursor.position()

        # Find which segment this position belongs to
        parent_widget = preview_text.parent()
        while parent_widget and not hasattr(parent_widget, 'segment_positions'):
            parent_widget = parent_widget.parent()

        if not parent_widget or not hasattr(parent_widget, 'segment_positions'):
            # Call parent's mousePressEvent for default behavior
            QTextEdit.mousePressEvent(preview_text, event)
            return

        segment_positions = parent_widget.segment_positions

        for (start_pos, end_pos), segment_id in segment_positions.items():
            if start_pos <= click_pos <= end_pos:
                # Navigate to this segment in the grid
                self._navigate_to_segment_in_grid(segment_id)
                break

        # Call parent's mousePressEvent for default behavior
        QTextEdit.mousePressEvent(preview_text, event)

    def _navigate_to_segment_in_grid(self, segment_id: int):
        """Navigate to a specific segment by ID in the grid"""
        if not self.current_project or not hasattr(self, 'table') or not self.table:
            return

        # Find the row for this segment ID
        for row in range(self.table.rowCount()):
            id_item = self.table.item(row, 0)
            if id_item:
                try:
                    row_segment_id = int(id_item.text())
                    if row_segment_id == segment_id:
                        # Switch to Grid tab first
                        if hasattr(self, 'main_tabs'):
                            self.main_tabs.setCurrentIndex(0)  # Grid tab
                        
                        # Handle pagination - switch to correct page if needed
                        if hasattr(self, 'page_size_combo') and self.page_size_combo.currentText() != "All":
                            try:
                                page_size = int(self.page_size_combo.currentText())
                                target_page = (row // page_size) + 1
                                if hasattr(self, 'page_number_input'):
                                    self.page_number_input.setText(str(target_page))
                                    self.go_to_page()
                            except ValueError:
                                pass

                        # Select this row and focus the target cell
                        self.table.setCurrentCell(row, 3)  # Column 3 = Target
                        self.table.scrollToItem(self.table.item(row, 0), QTableWidget.ScrollHint.PositionAtCenter)
                        
                        target_widget = self.table.cellWidget(row, 3)
                        if target_widget:
                            target_widget.setFocus()
                            # Place cursor at end of text
                            if isinstance(target_widget, QTextEdit):
                                cursor = target_widget.textCursor()
                                cursor.movePosition(cursor.MoveOperation.End)
                                target_widget.setTextCursor(cursor)

                        self.log(f"📄 Preview: Navigated to segment {segment_id}")
                        return
                except (ValueError, AttributeError):
                    continue

    def _get_current_segment_id(self) -> Optional[int]:
        """Get the ID of the currently selected segment in the grid"""
        if not self.current_project or not hasattr(self, 'table') or not self.table:
            return None
        
        current_row = self.table.currentRow()
        if current_row < 0:
            return None
        
        id_item = self.table.item(current_row, 0)
        if id_item:
            try:
                return int(id_item.text())
            except (ValueError, AttributeError):
                pass
        
        return None

    def _is_preview_tab_active(self) -> bool:
        """Check if the Preview tab is currently selected in the right panel.
        
        Used to optimize performance by skipping heavy lookups when user is
        navigating in Preview mode (they're likely just reading, not translating).
        """
        if not hasattr(self, 'right_tabs') or not self.right_tabs:
            return False
        if not hasattr(self, '_preview_tab_index'):
            return False
        return self.right_tabs.currentIndex() == self._preview_tab_index

    def _scroll_preview_to_segment(self, segment_id: int):
        """Scroll the preview to show the specified segment and highlight it.
        
        ⚡ PERFORMANCE: Only does work if Preview tab is actually visible.
        This prevents expensive operations (looping through all segments,
        updating char formatting) when user is working in the grid.
        """
        # ⚡ Skip entirely if Preview tab is not visible - major performance optimization
        if not self._is_preview_tab_active():
            return
        
        if not hasattr(self, 'preview_widgets') or not self.preview_widgets:
            return
        
        for widget in self.preview_widgets:
            if not hasattr(widget, 'segment_positions') or not hasattr(widget, 'preview_text'):
                continue
            
            preview_text = widget.preview_text
            
            # Find the position of the target segment
            target_start = None
            target_end = None
            for (start_pos, end_pos), seg_id in widget.segment_positions.items():
                if seg_id == segment_id:
                    target_start = start_pos
                    target_end = end_pos
                    break
            
            if target_start is None:
                continue
            
            # Update highlighting - need to re-render to show new selection
            # Store the new current segment ID and re-render
            widget.current_highlighted_segment_id = segment_id
            
            # Clear existing formatting and re-apply with new highlight
            cursor = preview_text.textCursor()
            
            # First, remove any existing yellow highlight from ALL segments
            for (start_pos, end_pos), seg_id in widget.segment_positions.items():
                cursor.setPosition(start_pos)
                cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
                fmt = cursor.charFormat()
                # Reset background - use white or status-based color
                if seg_id == segment_id:
                    fmt.setBackground(QColor('#fff9c4'))  # Yellow for current
                else:
                    # Find the segment to get its status
                    seg = next((s for s in self.current_project.segments if s.id == seg_id), None)
                    if seg:
                        if seg.status == 'not_started':
                            fmt.setBackground(QColor('#ffe6e6'))  # Light red
                        elif seg.status in ('draft', 'pretranslated'):
                            fmt.setBackground(QColor('#e6ffe6'))  # Light green
                        elif seg.status in ('confirmed', 'approved', 'proofread'):
                            fmt.setBackground(QColor('#e6f3ff'))  # Light blue
                        else:
                            fmt.setBackground(QColor('white'))
                    else:
                        fmt.setBackground(QColor('white'))
                cursor.mergeCharFormat(fmt)
            
            # Now scroll to the target segment, centered in viewport
            scroll_cursor = preview_text.textCursor()
            scroll_cursor.setPosition(target_start)
            preview_text.setTextCursor(scroll_cursor)
            self._center_cursor_in_preview(preview_text)

    def _center_cursor_in_preview(self, preview_text: QTextEdit):
        """Center the current cursor position in the preview viewport"""
        # Get the cursor rectangle (position in document coordinates)
        cursor_rect = preview_text.cursorRect()
        
        # Get viewport height
        viewport_height = preview_text.viewport().height()
        
        # Calculate scroll position to center the cursor
        # cursor_rect.top() is relative to the viewport, we need document position
        scrollbar = preview_text.verticalScrollBar()
        current_scroll = scrollbar.value()
        
        # The cursor rect top is relative to viewport, so add current scroll to get document position
        cursor_doc_y = current_scroll + cursor_rect.top()
        
        # Calculate target scroll to put cursor in center
        target_scroll = cursor_doc_y - (viewport_height // 2)
        
        # Clamp to valid scroll range
        target_scroll = max(0, min(target_scroll, scrollbar.maximum()))
        
        scrollbar.setValue(target_scroll)

    def refresh_preview(self):
        """Refresh all preview tabs with current document content"""
        if not self.current_project or not self.current_project.segments:
            # Clear previews if no project
            if hasattr(self, 'preview_widgets'):
                for widget in self.preview_widgets:
                    if hasattr(widget, 'preview_text'):
                        widget.preview_text.clear()
                        widget.segment_positions = {}
            return

        if not hasattr(self, 'preview_widgets'):
            return

        for widget in self.preview_widgets:
            self._render_preview(widget)

    def _render_preview(self, widget):
        """Render the document preview with realistic formatting"""
        if not hasattr(widget, 'preview_text'):
            return

        preview_text = widget.preview_text
        preview_text.clear()
        widget.segment_positions = {}

        if not self.current_project or not self.current_project.segments:
            return

        cursor = preview_text.textCursor()
        cursor.movePosition(QTextCursor.MoveOperation.Start)

        # Pre-calculate list numbers for numbered list items
        import re
        list_numbers = {}  # {segment_id: list_number}
        list_counter = 0
        prev_was_ordered_list = False

        for seg in self.current_project.segments:
            source_text = seg.source.strip() if seg.source else ""

            # Check for list tags (new <li-o>, <li-b> and legacy <li>)
            has_li_ordered = '<li-o>' in source_text or ('<li>' in source_text and '<li-b>' not in source_text)
            has_li_bullet = '<li-b>' in source_text

            if has_li_ordered:
                # Check for explicit number in text
                num_match = re.match(r'^<li(?:-o)?>\s*(\d+)[.)\s]', source_text)
                if num_match:
                    list_numbers[seg.id] = int(num_match.group(1))
                    list_counter = int(num_match.group(1))
                else:
                    # Reset counter if starting a new list (gap since last ordered item)
                    if not prev_was_ordered_list:
                        list_counter = 0
                    list_counter += 1
                    list_numbers[seg.id] = list_counter
                prev_was_ordered_list = True
            else:
                # Not an ordered list item - mark gap for potential new list
                prev_was_ordered_list = False

        # Render all segments
        current_segment_id = self._get_current_segment_id()
        segments = self.current_project.segments
        
        for idx, seg in enumerate(segments):
            style = getattr(seg, 'style', 'Normal') or 'Normal'
            paragraph_id = getattr(seg, 'paragraph_id', 0)
            seg_type = getattr(seg, 'type', 'para') or 'para'
            
            # Normalize type for comparison - grid shows symbols like "¶" for para, "Sub" for subtitle
            # Type field values: "para", "heading", "list_item", "table_cell", "Sub", "¶", etc.
            seg_type_lower = seg_type.lower() if seg_type else 'para'
            
            # Check if this is a list item (need source_text first)
            source_text = seg.source.strip() if seg.source else ""

            # Check if this is a heading/subtitle
            # Also detect bold-wrapped short text as section headings (e.g., <b>TECHNICAL FIELD</b>)
            is_heading = ('Heading' in style or 'Title' in style or 'Subtitle' in style
                          or seg_type_lower in ('heading', 'sub', 'subtitle', 'title'))

            # Detect bold section headings: <b>TEXT</b> where text is short and likely a heading
            if not is_heading and source_text.startswith('<b>') and source_text.endswith('</b>'):
                inner_text = source_text[3:-4].strip()  # Remove <b> and </b>
                # Short text (under 80 chars) that's bold is likely a section heading
                if len(inner_text) < 80 and '<' not in inner_text:
                    is_heading = True
            is_list_item = ('<li-o>' in source_text or '<li-b>' in source_text or 
                           '<li>' in source_text or seg_type_lower == 'list_item')
            
            # Check if this is a new paragraph (¶ symbol or "para" type)
            is_new_paragraph = seg_type == '¶' or seg_type_lower == 'para'
            
            # Determine spacing before this segment
            need_double_break = False  # Empty line (for paragraph separation)
            need_single_break = False  # Line break (for headings, list items)
            need_space = False         # Just a space (running text in same paragraph)
            
            if idx > 0:
                prev_seg = segments[idx - 1]
                prev_style = getattr(prev_seg, 'style', 'Normal') or 'Normal'
                prev_paragraph_id = getattr(prev_seg, 'paragraph_id', 0)
                prev_type = getattr(prev_seg, 'type', 'para') or 'para'
                prev_type_lower = prev_type.lower() if prev_type else 'para'
                
                prev_is_heading = ('Heading' in prev_style or 'Title' in prev_style or
                                   'Subtitle' in prev_style or
                                   prev_type_lower in ('heading', 'sub', 'subtitle', 'title'))
                # Also detect bold section headings in previous segment
                prev_source = prev_seg.source.strip() if prev_seg.source else ""
                if not prev_is_heading and prev_source.startswith('<b>') and prev_source.endswith('</b>'):
                    prev_inner = prev_source[3:-4].strip()
                    if len(prev_inner) < 80 and '<' not in prev_inner:
                        prev_is_heading = True
                prev_is_paragraph = prev_type == '¶' or prev_type_lower == 'para'
                prev_is_list = prev_type_lower == 'list_item' or '<li' in (prev_seg.source or '')
                
                # Rules for spacing:
                # 1. After a heading: double break (empty line)
                if prev_is_heading:
                    need_double_break = True
                # 2. Before a heading: double break
                elif is_heading:
                    need_double_break = True
                # 3. New paragraph (different paragraph_id or new ¶ type): double break
                elif is_new_paragraph and prev_is_paragraph:
                    # If paragraph_id is being used and changed, it's a new paragraph
                    if paragraph_id != prev_paragraph_id and (paragraph_id != 0 or prev_paragraph_id != 0):
                        need_double_break = True
                    # If both are ¶ type, they are separate paragraphs
                    elif prev_type == '¶' and seg_type == '¶':
                        need_double_break = True
                    else:
                        # Same paragraph, running text
                        need_space = True
                # 4. List items: ordered list items (like claims) get paragraph spacing
                elif is_list_item or prev_is_list:
                    # Ordered list items (<li-o>) get double breaks for visual separation
                    is_ordered_list = '<li-o>' in source_text or ('<li>' in source_text and '<li-b>' not in source_text)
                    prev_is_ordered = '<li-o>' in (prev_seg.source or '') or ('<li>' in (prev_seg.source or '') and '<li-b>' not in (prev_seg.source or ''))
                    if is_ordered_list or prev_is_ordered:
                        need_double_break = True  # Paragraph-like spacing for numbered items
                    else:
                        need_single_break = True  # Bullet lists stay compact
                # 5. Default: space (running text)
                else:
                    need_space = True
            
            # Insert spacing/breaks
            if need_double_break:
                cursor.insertText("\n\n")  # Empty line between paragraphs
            elif need_single_break:
                cursor.insertText("\n")    # Line break
            elif need_space:
                cursor.insertText(" ")     # Space between sentences
            
            # Set formatting based on style
            char_format = QTextCharFormat()
            char_format.setFontFamily("Georgia")
            
            # Check type field for heading detection as well
            if is_heading and seg_type_lower in ('sub', 'subtitle'):
                # Subtitle style (like "TECHNISCH DOMEIN")
                char_format.setFontPointSize(13)
                char_format.setFontWeight(QFont.Weight.Bold)
                char_format.setForeground(QColor('#1f4068'))
            elif 'Heading 1' in style or 'Heading1' in style or 'Title' in style or seg_type_lower == 'title':
                char_format.setFontPointSize(18)
                char_format.setFontWeight(QFont.Weight.Bold)
                char_format.setForeground(QColor('#1a1a2e'))
            elif 'Heading 2' in style or 'Heading2' in style:
                char_format.setFontPointSize(15)
                char_format.setFontWeight(QFont.Weight.Bold)
                char_format.setForeground(QColor('#16213e'))
            elif 'Heading 3' in style or 'Heading3' in style or seg_type_lower == 'heading':
                char_format.setFontPointSize(13)
                char_format.setFontWeight(QFont.Weight.Bold)
                char_format.setForeground(QColor('#1f4068'))
            elif is_heading:
                # Bold section headings detected from <b>TEXT</b> pattern
                char_format.setFontPointSize(12)
                char_format.setFontWeight(QFont.Weight.Bold)
                char_format.setForeground(QColor('#1a365d'))
            else:
                char_format.setFontPointSize(11)
                char_format.setForeground(QColor('#2d2d2d'))

            # Determine what text to display
            if seg.target and seg.target.strip():
                display_text = seg.target
                is_translated = True
            else:
                display_text = seg.source if seg.source else ""
                is_translated = False

            # Set background based on status
            if seg.status == 'not_started':
                char_format.setBackground(QColor('#ffe6e6'))  # Light red
            elif seg.status in ('draft', 'pretranslated'):
                char_format.setBackground(QColor('#e6ffe6'))  # Light green
            elif seg.status in ('confirmed', 'approved', 'proofread'):
                char_format.setBackground(QColor('#e6f3ff'))  # Light blue
            else:
                char_format.setBackground(QColor('white'))

            # Highlight current segment
            if seg.id == current_segment_id:
                char_format.setBackground(QColor('#fff9c4'))  # Light yellow

            # Gray out untranslated text
            if not is_translated:
                char_format.setForeground(QColor('#888888'))
                char_format.setFontItalic(True)

            # Store position for click navigation
            start_pos = cursor.position()

            # Render text with list formatting
            self._render_formatted_text(cursor, display_text, char_format, 
                                        list_number=list_numbers.get(seg.id))

            end_pos = cursor.position()
            widget.segment_positions[(start_pos, end_pos)] = seg.id
            
            # Track position of current segment for scrolling
            if seg.id == current_segment_id:
                widget.current_segment_start_pos = start_pos

            # Note: Line breaks are now handled at the START of each segment based on context
            # This ensures running text flows naturally while paragraphs are separated

        # Add final newline at end of document
        cursor.insertText("\n")

        # Scroll to current segment if we have one
        if hasattr(widget, 'current_segment_start_pos') and widget.current_segment_start_pos is not None:
            # Create cursor at the current segment position
            scroll_cursor = preview_text.textCursor()
            scroll_cursor.setPosition(widget.current_segment_start_pos)
            preview_text.setTextCursor(scroll_cursor)
            # Center the segment in the viewport
            # Use QTimer to delay centering until after layout is complete
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(0, lambda pt=preview_text: self._center_cursor_in_preview(pt))
        else:
            # Set cursor to beginning if no current segment
            cursor.movePosition(QTextCursor.MoveOperation.Start)
            preview_text.setTextCursor(cursor)

    def _render_formatted_text(self, cursor, text: str, base_format: QTextCharFormat,
                                list_number: Optional[int] = None):
        """Render text with list prefix and inline formatting tags preserved"""
        import re

        # Check for list tags
        has_li_bullet = '<li-b>' in text
        has_li_ordered = '<li-o>' in text or ('<li>' in text and '<li-b>' not in text)

        # Remove list tags from display  
        text = re.sub(r'</?li-b>', '', text)
        text = re.sub(r'</?li-o>', '', text)
        text = re.sub(r'</?li>', '', text)

        # Add list prefix if this is a list item
        if has_li_bullet or has_li_ordered:
            bullet_format = QTextCharFormat(base_format)
            bullet_format.setForeground(QColor('#ff6600'))  # Orange
            bullet_format.setFontWeight(QFont.Weight.Bold)
            bullet_format.setFontItalic(False)

            if has_li_ordered and list_number:
                cursor.insertText(f"{list_number}. ", bullet_format)
            elif has_li_ordered:
                cursor.insertText("• ", bullet_format)  # Fallback if no number
            else:
                cursor.insertText("• ", bullet_format)

        # Parse text for inline formatting tags
        tag_pattern = re.compile(r'(</?(?:b|i|u|bi|sub|sup)>)')
        parts = tag_pattern.split(text)

        is_bold = False
        is_italic = False
        is_underline = False
        is_subscript = False
        is_superscript = False

        for part in parts:
            if not part:
                continue

            if part == '<b>':
                is_bold = True
                continue
            elif part == '</b>':
                is_bold = False
                continue
            elif part == '<i>':
                is_italic = True
                continue
            elif part == '</i>':
                is_italic = False
                continue
            elif part == '<u>':
                is_underline = True
                continue
            elif part == '</u>':
                is_underline = False
                continue
            elif part == '<bi>':
                is_bold = True
                is_italic = True
                continue
            elif part == '</bi>':
                is_bold = False
                is_italic = False
                continue
            elif part == '<sub>':
                is_subscript = True
                continue
            elif part == '</sub>':
                is_subscript = False
                continue
            elif part == '<sup>':
                is_superscript = True
                continue
            elif part == '</sup>':
                is_superscript = False
                continue

            # Apply formatting to text
            text_format = QTextCharFormat(base_format)
            
            if is_bold:
                text_format.setFontWeight(QFont.Weight.Bold)
            if is_italic:
                text_format.setFontItalic(True)
            if is_underline:
                text_format.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
            if is_subscript:
                text_format.setVerticalAlignment(QTextCharFormat.VerticalAlignment.AlignSubScript)
            if is_superscript:
                text_format.setVerticalAlignment(QTextCharFormat.VerticalAlignment.AlignSuperScript)

            cursor.insertText(part, text_format)

    def highlight_preview_segment(self, segment_id: Optional[int] = None):
        """Update the preview to highlight the specified segment (or current if None)"""
        if segment_id is None:
            segment_id = self._get_current_segment_id()
        
        if not segment_id:
            return
            
        # Store for later reference
        self._highlighted_segment_id = segment_id
        
        # Re-render preview to update highlighting
        # (In a full implementation, we'd just update the highlight without full re-render)

    def _create_status_cell_widget(self, segment: Segment) -> QWidget:
        widget = QWidget()
        layout = QHBoxLayout(widget)
        layout.setContentsMargins(2, 0, 2, 0)  # Zero vertical margins to prevent icon clipping on single-line rows
        layout.setSpacing(1)
        layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)  # Center content vertically
        # Remove fixed minimum height - let it adapt to row height
        widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        # Note: Do NOT set WA_TransparentForMouseEvents - we want tooltips to work

        # Check if segment has notes or proofreading notes
        has_notes = bool(segment.notes and segment.notes.strip())
        has_proofreading = bool(getattr(segment, 'proofreading_notes', None))
        has_any_notes = has_notes or has_proofreading

        # Widget background always transparent
        widget.setStyleSheet("background: transparent;")
        if has_any_notes:
            tooltip_parts = []
            if has_notes:
                tooltip_parts.append(f"Segment comment: {segment.notes.strip()}")
            if has_proofreading:
                model_count = len(segment.proofreading_notes)
                tooltip_parts.append(f"Proofreading comment: {model_count} LLM result{'s' if model_count != 1 else ''}")
            widget.setToolTip("\n".join(tooltip_parts))

        # Comment access from the Status column (Trados-parity). Build a
        # tooltip from the segment's actual comments (anchored + segment-level)
        # and, when there are any, wire a right-click "Open comment(s)" menu so
        # segment-level comments — which have no highlighted range in the cells
        # — are reachable straight from the status indicator. The eventFilter
        # appends ``comment_tooltip`` to the hovered status glyph's tooltip.
        seg_comments = [c for c in (getattr(segment, 'comments', None) or [])
                        if c.text and c.text.strip()]
        if seg_comments:
            tip_lines = []
            for c in seg_comments[:4]:
                prefix = f"⚓{c.anchor_field} " if c.is_anchored else ""
                tip_lines.append(f"💬 {prefix}{c.text.strip()}")
            if len(seg_comments) > 4:
                tip_lines.append(f"…(+{len(seg_comments) - 4} more)")
            widget.setProperty("comment_tooltip", "\n".join(tip_lines))
            first_cid = seg_comments[0].id
            widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
            widget.customContextMenuRequested.connect(
                lambda pos, w=widget, cid=first_cid, n=len(seg_comments):
                    self._status_cell_comment_menu(w, pos, cid, n)
            )

        status_def = get_status(segment.status)

        # Status icons: ✔ (green text glyph) and ❌ (native emoji, ~16px on Windows)
        # Use CSS font-size for the checkmark to match the emoji size visually.
        # The ✔ is a text character so font-size controls it; emojis ignore font-size.
        # IMPORTANT: On single-line rows, vertical space is very tight (~20-24px).
        # The icon + container margins + QLabel internal padding must fit within that.
        is_locked = getattr(segment, 'locked', False)

        # Trados-style coloured text badge (PM / CM) in place of the emoji icon.
        # The badge already conveys the match level, so we don't repeat the 101/102%.
        if status_def.badge_text:
            badge = QLabel(status_def.badge_text)
            badge.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignCenter)
            badge.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
            badge.setStyleSheet(
                f"background-color:{status_def.badge_bg}; color:{status_def.badge_fg};"
                " border-radius:3px; padding:0px 4px; font-size:10px; font-weight:bold;"
            )
            badge.setProperty("status_tooltip", status_def.label)
            badge.installEventFilter(self)
            layout.addWidget(badge)
            if is_locked:
                lock_lbl = QLabel('<span style="font-size:14px; line-height:1;">🔒</span>')
                lock_lbl.setTextFormat(Qt.TextFormat.RichText)
                lock_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
                lock_lbl.setContentsMargins(0, 0, 0, 0)
                lock_lbl.setProperty("status_tooltip", "Locked")
                lock_lbl.installEventFilter(self)
                layout.addWidget(lock_lbl)
            if has_any_notes:
                widget.setStyleSheet("background-color: rgba(255, 152, 0, 0.35);")
            layout.addStretch(1)
            return widget

        icon_text = "🔒" if is_locked else status_def.icon
        if segment.status == "confirmed" and not is_locked:
            icon_html = f'<span style="color:#2e7d32; font-size:14px; line-height:1;">{icon_text}</span>'
        else:
            icon_html = f'<span style="font-size:14px; line-height:1;">{icon_text}</span>'

        status_label = QLabel(icon_html)
        status_label.setTextFormat(Qt.TextFormat.RichText)  # Enable HTML rendering
        status_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
        status_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)  # Expand vertically to match row height
        status_label.setContentsMargins(0, 0, 0, 0)  # No internal padding to prevent clipping in tight rows
        # Store tooltip text as property - we'll show it manually to avoid black tooltip bug
        status_label.setProperty("status_tooltip", status_def.label)
        status_label.installEventFilter(self)

        # Add orange background if segment has any notes (using stylesheet for background only)
        if has_any_notes:
            status_label.setStyleSheet("padding: 2px 4px; background-color: rgba(255, 152, 0, 0.35); border-radius: 3px;")
        # Note: No stylesheet for non-notes case to avoid interfering with HTML color
        layout.addWidget(status_label)

        # Show match percentage if available, otherwise show the status short_label
        if segment.match_percent is not None:
            match_text = f"{segment.match_percent}%"
            match_label = QLabel(match_text)
            match_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            match_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
            if segment.match_percent >= 101:
                match_label.setStyleSheet("color: #1b5e20; font-weight: bold; font-size: 11px; padding: 0px;")
                match_label.setProperty("status_tooltip", "Context match (101% or better)")
            elif segment.match_percent >= 100:
                match_label.setStyleSheet("color: #2e7d32; font-weight: bold; font-size: 11px; padding: 0px;")
                match_label.setProperty("status_tooltip", "Exact match (100%)")
            elif segment.match_percent >= 90:
                match_label.setStyleSheet("color: #1565C0; font-weight: bold; font-size: 11px; padding: 0px;")
                match_label.setProperty("status_tooltip", f"High fuzzy match {segment.match_percent}%")
            else:
                match_label.setStyleSheet("color: #0d47a1; font-size: 11px; padding: 0px;")
                match_label.setProperty("status_tooltip", f"Fuzzy match {segment.match_percent}%")
            match_label.installEventFilter(self)
            layout.addWidget(match_label)
        elif status_def.short_label:
            # No match %, show a short text abbreviation to the right of the icon (e.g. "CM", "MT", "Rep")
            short_lbl = QLabel(status_def.short_label)
            short_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
            short_lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
            short_lbl.setStyleSheet("color: #444; font-size: 10px; font-weight: bold; padding: 0px;")
            short_lbl.setProperty("status_tooltip", status_def.label)
            short_lbl.installEventFilter(self)
            layout.addWidget(short_lbl)

        layout.addStretch(1)

        return widget

    def _status_cell_comment_menu(self, widget, pos, comment_id, count):
        """Right-click menu on the Status column for a segment that has comments.

        Lets segment-level comments (no highlighted range in the cells) be
        opened straight from the status indicator.
        """
        from PyQt6.QtWidgets import QMenu
        from PyQt6.QtGui import QAction
        menu = QMenu(widget)
        label = "💬 Open comment" if count <= 1 else f"💬 Open comments ({count})"
        act = QAction(label, menu)
        act.triggered.connect(lambda: self._open_comment_in_panel(comment_id))
        menu.addAction(act)
        menu.exec(widget.mapToGlobal(pos))

    def _get_custom_tooltip(self):
        """Get or create the custom tooltip label widget."""
        if not hasattr(self, '_custom_tooltip') or self._custom_tooltip is None:
            self._custom_tooltip = QLabel()
            self._custom_tooltip.setWindowFlags(
                Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint
            )
            self._custom_tooltip.setStyleSheet("""
                QLabel {
                    background-color: #f5f5f5;
                    color: #333333;
                    border: 1px solid #d0d0d0;
                    padding: 4px 8px;
                    font-size: 12px;
                }
            """)
            self._custom_tooltip.hide()
        return self._custom_tooltip

    def eventFilter(self, obj, event):
        """Handle tooltip events for status labels using custom tooltip widget."""
        from PyQt6.QtCore import QEvent

        tooltip_text = obj.property("status_tooltip") if hasattr(obj, 'property') else None

        if tooltip_text:
            if event.type() == QEvent.Type.ToolTip:
                # Append the segment's comment summary (stored on the parent
                # status widget) so hovering the Status column reveals comments,
                # including segment-level ones that have no highlighted range.
                display_text = tooltip_text
                try:
                    parent = obj.parentWidget()
                    ctip = parent.property("comment_tooltip") if parent is not None else None
                    if ctip:
                        display_text = f"{tooltip_text}\n{ctip}"
                except Exception:
                    pass
                # Show our custom tooltip instead of Qt's
                tooltip = self._get_custom_tooltip()
                tooltip.setText(display_text)
                tooltip.adjustSize()
                # Position near the cursor
                pos = event.globalPos()
                tooltip.move(pos.x() + 10, pos.y() + 10)
                tooltip.show()
                return True  # Consume the event to prevent Qt's tooltip
            elif event.type() == QEvent.Type.Leave:
                # Hide custom tooltip when mouse leaves
                if hasattr(self, '_custom_tooltip') and self._custom_tooltip:
                    self._custom_tooltip.hide()

        return super().eventFilter(obj, event)

    def _update_status_cell(self, row: int, segment: Segment):
        status_widget = self._create_status_cell_widget(segment)
        self.table.setCellWidget(row, 4, status_widget)
        status_item = self.table.item(row, 4)
        if status_item is None:
            status_item = QTableWidgetItem()
            self.table.setItem(row, 4, status_item)
        status_item.setText("")
        status_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
        # Set background color on table item (widget is transparent)
        status_item.setBackground(QColor(get_status(segment.status).color))
        status_item.setData(Qt.ItemDataRole.DisplayRole, "")
        status_widget.updateGeometry()

    def _refresh_segment_status(self, segment: Segment):
        if not self.current_project:
            return
        updated_row = None
        for row, seg in enumerate(self.current_project.segments):
            if seg.id == segment.id:
                self._update_status_cell(row, seg)
                updated_row = row
                break

        # Update list view entry in place (if visible)
        if hasattr(self, 'list_tree') and self.list_tree:
            matches = self.list_tree.findItems(str(segment.id), Qt.MatchFlag.MatchExactly, 0)
            for item in matches:
                status_def = get_status(segment.status)
                match_text = f"{segment.match_percent}%" if segment.match_percent is not None else ""
                display = f"{status_def.icon}"
                if match_text:
                    display += f"  {match_text}"
                if segment.notes and segment.notes.strip():
                    display += "  💬"
                if getattr(segment, 'proofreading_notes', None):
                    display += "  ✅"
                item.setText(2, display)
                status_tooltip = status_def.label
                if segment.match_percent is not None:
                    status_tooltip += f" | {segment.match_percent}% match"
                if segment.notes and segment.notes.strip():
                    status_tooltip += f"\nSegment comment: {segment.notes.strip()}"
                if getattr(segment, 'proofreading_notes', None):
                    model_count = len(segment.proofreading_notes)
                    status_tooltip += f"\nProofreading comment: {model_count} LLM result{'s' if model_count != 1 else ''}"
                item.setToolTip(2, status_tooltip)
                item.setBackground(2, QColor(status_def.color))

        # Recalculate row height after status cell update to prevent layout corruption
        if updated_row is not None:
            self._auto_resize_single_row(updated_row)

    def _refresh_segment_status_by_id(self, segment_id: int):
        """Refresh the status display for a segment by its ID."""
        if not self.current_project:
            return
        segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
        if segment:
            self._refresh_segment_status(segment)

    def _enforce_status_row_heights(self):
        """No longer needed - status widgets now adapt to row height."""
        # This method is kept for backward compatibility but does nothing
        pass


    
    def clear_grid(self):
        """Clear all rows from grid"""
        self.table.setRowCount(0)
    
    def set_termlens_position(self, position, save=True):
        """Position the under-grid TermLens panel live.

        position: 'above' or 'below' to show it in that spot, or None to hide it.
        Reorders the vertical splitter so no project reopen is needed, and keeps
        the View-menu checkmarks in sync.
        """
        if not hasattr(self, 'bottom_tabs') or not hasattr(self, 'left_vertical_splitter'):
            return

        splitter = self.left_vertical_splitter
        tabs = self.bottom_tabs

        if position == 'above':
            if splitter.indexOf(tabs) != 0:
                splitter.insertWidget(0, tabs)  # moves it; grid drops to index 1
            tabs.setVisible(True)
            splitter.setSizes([200, 600])
            self.tabs_above_grid = True
            visible = True
        elif position == 'below':
            if splitter.indexOf(tabs) != 1:
                splitter.insertWidget(1, tabs)
            tabs.setVisible(True)
            splitter.setSizes([600, 200])
            self.tabs_above_grid = False
            visible = True
        else:  # hide; keep last-known position so re-showing returns there
            tabs.setVisible(False)
            visible = False

        # The under-grid dock now holds two tabs (TermLens + QuickTrans), so
        # the menu checkmarks depend on both visibility/position AND which tab
        # is active. _sync_bottom_dock_menu() works that out.
        self._sync_bottom_dock_menu()

        if save:
            settings = self.load_general_settings()
            settings['termlens_under_grid_visible'] = visible
            settings['tabs_above_grid'] = self.tabs_above_grid
            self.save_general_settings(settings)

    def _sync_bottom_dock_menu(self):
        """Keep the 'Under-grid panel' radio items (Hide / Above / Below) in
        sync with the dock's current visibility and position. The TermLens vs
        QuickTrans choice is made via the dock's tab bar, not the menu."""
        if not hasattr(self, 'bottom_tabs') or not hasattr(self, 'underdock_hide_action'):
            return
        try:
            visible = self.bottom_tabs.isVisible()
        except Exception:
            return
        above = visible and getattr(self, 'tabs_above_grid', False)
        below = visible and not getattr(self, 'tabs_above_grid', False)
        # setChecked does not emit triggered(), so this won't recurse.
        self.underdock_above_action.setChecked(above)
        self.underdock_below_action.setChecked(below)
        self.underdock_hide_action.setChecked(not visible)

    def _on_bottom_tab_changed(self, _idx):
        """Tab switched in the under-grid dock: remember which tab the user
        prefers (unless we're restoring at startup)."""
        if not getattr(self, '_restoring_dock', False):
            self._save_bottom_dock_active_tab()

    def _save_bottom_dock_active_tab(self):
        if not hasattr(self, 'bottom_tabs'):
            return
        try:
            cur = self.bottom_tabs.currentWidget()
            name = 'quicktrans' if cur is getattr(self, 'quicktrans_panel', None) else 'termlens'
            s = self.load_general_settings()
            s['bottom_dock_active_tab'] = name
            self.save_general_settings(s)
        except Exception:
            pass

    def _on_match_top_tab_changed(self, _idx):
        """Match Panel top tab switched (TermLens / QuickTrans): remember the
        choice (unless we're restoring at startup)."""
        if getattr(self, '_restoring_dock', False):
            return
        try:
            cur = self.match_top_tabs.currentWidget()
            name = 'quicktrans' if cur is getattr(self, 'quicktrans_panel_match', None) else 'termlens'
            s = self.load_general_settings()
            s['match_top_active_tab'] = name
            self.save_general_settings(s)
        except Exception:
            pass

    def _insert_quicktrans_translation(self, translation: str):
        """Insert a QuickTrans-panel result into the current segment's target
        cell. Replaces the selection if there is one, otherwise the whole
        target. Mirrors the popup's insert behaviour."""
        try:
            row = self.table.currentRow()
            if row < 0:
                return
            target_widget = self.table.cellWidget(row, 3)
            if not target_widget or not hasattr(target_widget, 'toPlainText'):
                return
            focus_widget = QApplication.focusWidget()
            if (focus_widget is target_widget and hasattr(focus_widget, 'textCursor')
                    and focus_widget.textCursor().hasSelection()):
                cursor = focus_widget.textCursor()
                cursor.insertText(translation)
            else:
                target_widget.setPlainText(translation)
            if hasattr(self, 'segments') and row < len(self.segments):
                new_target = target_widget.toPlainText()
                if hasattr(self, 'reverse_invisible_replacements'):
                    new_target = self.reverse_invisible_replacements(new_target)
                self.segments[row].target = new_target
                self.mark_segment_modified(row)
            self.log("✅ Inserted QuickTrans translation")
        except Exception as e:
            self.log(f"Error inserting QuickTrans translation: {e}")

    def _update_file_boundary_labels(self):
        """Create/reposition floating filename banner labels at file boundaries in multi-file projects.

        Labels are parented to the table viewport so they scroll with the table automatically.
        Called after load_segments_to_grid(), auto_resize_rows(), and on scroll/resize.
        """
        # Clean up existing labels
        for lbl in getattr(self, '_file_boundary_labels', []):
            lbl.setParent(None)
            lbl.deleteLater()
        self._file_boundary_labels = []

        if not hasattr(self, 'table') or not self.table:
            return
        if not self.current_project or not getattr(self.current_project, 'is_multifile', False):
            return

        segments = self.current_project.segments
        viewport = self.table.viewport()
        viewport_width = viewport.width()

        for row, segment in enumerate(segments):
            if row == 0:
                continue  # First file has no preceding banner
            prev_file_id = getattr(segments[row - 1], 'file_id', None)
            curr_file_id = getattr(segment, 'file_id', None)
            if curr_file_id is None or prev_file_id is None or curr_file_id == prev_file_id:
                continue
            if self.table.isRowHidden(row):
                continue  # Don't show banner when file is filtered out

            # Get the Y position of this row in the viewport
            rect = self.table.visualRect(self.table.model().index(row, 0))
            if rect.isNull() or rect.top() < 0:
                continue

            file_name = getattr(segment, 'file_name', '') or f"File {curr_file_id}"
            lbl = QLabel(f"  📄  {file_name}", viewport)
            lbl.setFixedHeight(20)
            lbl.setFixedWidth(viewport_width)
            lbl.move(0, rect.top() - 20)
            lbl.setStyleSheet(
                "QLabel {"
                "  background-color: #1a3a5c;"
                "  color: #e8f0fe;"
                "  font-size: 10px;"
                "  font-weight: bold;"
                "  padding-left: 8px;"
                "  border-bottom: 1px solid #2196F3;"
                "}"
            )
            lbl.show()
            lbl.raise_()
            self._file_boundary_labels.append(lbl)

    def auto_resize_rows(self):
        """Auto-resize all rows to fit content - Compact version"""
        if not hasattr(self, 'table') or not self.table:
            return

        # Reduce width slightly to account for padding and prevent text cut-off
        width_reduction = 8

        # Manually calculate and set row heights for compact display
        for row in range(self.table.rowCount()):
            # Keep UI responsive during large grid updates
            if row % 50 == 0:
                QApplication.processEvents()
            self._auto_resize_single_row(row, width_reduction)

        self.log("✓ Auto-resized rows to fit content (compact)")
        self._enforce_status_row_heights()
        self._update_file_boundary_labels()
    
    def _auto_resize_single_row(self, row: int, width_reduction: int = 8):
        """Auto-resize a single row to fit its content. Called automatically after text changes."""
        if not hasattr(self, 'table') or not self.table:
            return
        if row < 0 or row >= self.table.rowCount():
            return

        # Minimum column width threshold - if columns are narrower than this,
        # layout isn't ready yet and we should skip resizing
        MIN_COL_WIDTH = 100

        max_height = 1

        # Check source cell (column 2)
        source_widget = self.table.cellWidget(row, 2)
        if source_widget and isinstance(source_widget, ReadOnlyGridTextEditor):
            doc = source_widget.document()
            if doc:
                col_width = self.table.columnWidth(2) - width_reduction
                if col_width >= MIN_COL_WIDTH:
                    doc.setTextWidth(col_width)
                    # Use documentLayout().documentSize() to force fresh calculation
                    layout = doc.documentLayout()
                    if layout:
                        size = layout.documentSize()
                        if size.isValid():
                            height = int(size.height())
                            max_height = max(max_height, height)

        # Check target cell (column 3)
        target_widget = self.table.cellWidget(row, 3)
        if target_widget and isinstance(target_widget, EditableGridTextEditor):
            doc = target_widget.document()
            if doc:
                col_width = self.table.columnWidth(3) - width_reduction
                if col_width >= MIN_COL_WIDTH:
                    doc.setTextWidth(col_width)
                    # Use documentLayout().documentSize() to force fresh calculation
                    layout = doc.documentLayout()
                    if layout:
                        size = layout.documentSize()
                        if size.isValid():
                            height = int(size.height())
                            max_height = max(max_height, height)
        
        # Set row height with minimal padding
        # Minimum 32px to accommodate status icons (16px) + match text + padding without any cutoff
        compact_height = max(max_height + 2, 32)

        # Add space for the file boundary banner (20px) at the BOTTOM of the last row of each file.
        # The banner floats above the NEXT row's top, so the space must come from this row's height.
        if (self.current_project and
                getattr(self.current_project, 'is_multifile', False)):
            segments = self.current_project.segments
            next_row = row + 1
            if next_row < len(segments):
                curr_file_id = getattr(segments[row], 'file_id', None)
                next_file_id = getattr(segments[next_row], 'file_id', None)
                if (curr_file_id is not None and next_file_id is not None
                        and curr_file_id != next_file_id):
                    compact_height += 20  # Banner sits in the extra space at the bottom of this row

        self.table.setRowHeight(row, compact_height)

    def _resize_visible_rows(self):
        """Resize only visible (non-hidden) rows for efficiency after pagination/filter changes."""
        if not hasattr(self, 'table') or not self.table:
            return
        row_count = self.table.rowCount()
        for row in range(row_count):
            if not self.table.isRowHidden(row):
                self._auto_resize_single_row(row)

    def _on_column_resized(self, logical_index: int, old_size: int, new_size: int):
        """Handle column resize - recalculate row heights for text reflow.

        Only reacts to Source (2) or Target (3) column changes since those
        contain wrapped text. Uses debouncing to avoid excessive recalculations
        during continuous dragging.
        """
        # Only care about Source or Target columns (they contain wrapped text)
        if logical_index not in (2, 3):
            return

        # Debounce: cancel previous timer and start a new one
        if hasattr(self, '_column_resize_timer') and self._column_resize_timer is not None:
            self._column_resize_timer.stop()

        self._column_resize_timer = QTimer()
        self._column_resize_timer.setSingleShot(True)
        self._column_resize_timer.timeout.connect(self._resize_visible_rows)
        self._column_resize_timer.start(150)  # 150ms debounce

    def apply_font_to_grid(self):
        """Apply selected font to all grid cells"""
        font = QFont(self.default_font_family, self.default_font_size)

        self.table.setFont(font)

        # Also update header font - same size as grid content, normal weight
        header_font = QFont(self.default_font_family, self.default_font_size, QFont.Weight.Normal)
        self.table.horizontalHeader().setFont(header_font)

        # Type column (1) uses its own slightly smaller font – must match the
        # formula used at grid-creation time so zoom scales ¶ along with the text.
        # The ¶ paragraph mark gets an extra point shaved off (heavier glyph).
        type_font_para = QFont(self.default_font_family, max(6, self.default_font_size - 2))
        type_font_label = QFont(self.default_font_family, max(6, self.default_font_size - 1))

        # Update fonts in QTextEdit widgets (source and target columns)
        if hasattr(self, 'table') and self.table:
            for row in range(self.table.rowCount()):
                # Keep UI responsive during large grid updates
                if row % 50 == 0:
                    QApplication.processEvents()

                # Type column (1) - QTableWidgetItem with paragraph mark / heading label
                type_item = self.table.item(row, 1)
                if type_item is not None:
                    type_item.setFont(type_font_para if type_item.text() == "¶" else type_font_label)

                # Source column (2) - ReadOnlyGridTextEditor.
                # Use apply_grid_font_size (sets the size in the widget's own
                # stylesheet) – setFont alone loses to ThemeManager's app-level
                # `QTextEdit { font-size }` rule emitted at non-100% UI scale.
                source_widget = self.table.cellWidget(row, 2)
                if source_widget and isinstance(source_widget, ReadOnlyGridTextEditor):
                    source_widget.setFont(font)
                    source_widget.apply_grid_font_size(self.default_font_size)

                # Target column (3) - EditableGridTextEditor (same reason)
                target_widget = self.table.cellWidget(row, 3)
                if target_widget and isinstance(target_widget, EditableGridTextEditor):
                    target_widget.setFont(font)
                    target_widget.apply_grid_font_size(self.default_font_size)

        # Adjust segment number column width based on font size
        self._update_segment_column_width()
    
    def _update_segment_column_width(self):
        """Adjust segment number column width to fit the largest segment number.
        
        Uses font metrics to calculate exact width needed for the highest segment number.
        """
        if not hasattr(self, 'table') or not self.table:
            return
        
        # Get the highest segment number we need to display
        max_segment = self.table.rowCount()
        if max_segment == 0:
            max_segment = 1
        
        # Use font metrics to calculate exact width needed
        font = QFont(self.default_font_family, self.default_font_size)
        fm = QFontMetrics(font)
        
        # Measure the width of the largest number (as string)
        text_width = fm.horizontalAdvance(str(max_segment))
        
        # Add padding (8px on each side = 16px total)
        new_width = text_width + 16

        # Ensure minimum width for very small numbers
        new_width = max(30, new_width)

        self.table.setColumnWidth(0, new_width)
    
    def set_font_family(self, family_name: str):
        """Set font family from menu"""
        self.default_font_family = family_name
        self.apply_font_to_grid()
        self.auto_resize_rows()
        self.log(f"✓ Font changed to {family_name}")
    
    def increase_font_size(self):
        """Increase grid font size."""
        self.default_font_size = min(72, self.default_font_size + 1)
        self.apply_font_to_grid()
        self.auto_resize_rows()
        self.log(f"✓ Font size: {self.default_font_size}")
        # Save font size to preferences
        self.save_current_font_sizes()
    
    def decrease_font_size(self):
        """Decrease grid font size."""
        self.default_font_size = max(7, self.default_font_size - 1)
        self.apply_font_to_grid()
        self.auto_resize_rows()
        self.log(f"✓ Font size: {self.default_font_size}")
        # Save font size to preferences
        self.save_current_font_sizes()
    
    def refresh_grid_tag_colors(self):
        """Refresh tag highlight colors in all grid cells"""
        if not hasattr(self, 'table') or not self.table:
            return

        for row in range(self.table.rowCount()):
            # Keep UI responsive during large grid updates
            if row % 50 == 0:
                QApplication.processEvents()

            # Source column (2) - ReadOnlyGridTextEditor
            source_widget = self.table.cellWidget(row, 2)
            if source_widget and isinstance(source_widget, ReadOnlyGridTextEditor):
                if hasattr(source_widget, 'highlighter'):
                    source_widget.highlighter.set_tag_color(EditableGridTextEditor.tag_highlight_color)

            # Target column (3) - EditableGridTextEditor
            target_widget = self.table.cellWidget(row, 3)
            if target_widget and isinstance(target_widget, EditableGridTextEditor):
                if hasattr(target_widget, 'highlighter'):
                    target_widget.highlighter.set_tag_color(EditableGridTextEditor.tag_highlight_color)

    def _apply_row_color(self, row: int, source_widget, target_widget):
        """Apply alternating row color to source and target widgets for a specific row"""
        # Load settings (with caching to avoid repeated file reads)
        # Cache is keyed by theme name to refresh when theme changes
        theme = self.theme_manager.current_theme
        cache_key = f'_row_color_cache_{theme.name}'

        if not hasattr(self, cache_key):
            settings = self.load_general_settings()
            # Always use current theme colors for row alternation, not saved settings
            # This ensures rows are dark in dark themes and light in light themes
            cached_data = {
                'enabled': settings.get('enable_alternating_row_colors', True),
                'even_color': theme.base,  # Always use theme color
                'odd_color': theme.alternate_bg  # Always use theme color
            }
            setattr(self, cache_key, cached_data)
            # Also update the old cache name for backwards compatibility
            self._row_color_settings_cached = cached_data
        else:
            self._row_color_settings_cached = getattr(self, cache_key)
        
        settings = self._row_color_settings_cached
        
        if not settings['enabled']:
            # Use theme default colors when disabled - remove hardcoded backgrounds
            # The theme system will handle the background colors
            return
        
        # Check if this row is a locked segment (non-translatable, included for context)
        segment = None
        if (self.current_project and self.current_project.segments
                and 0 <= row < len(self.current_project.segments)):
            segment = self.current_project.segments[row]

        if segment and getattr(segment, 'locked', False):
            # Locked segments get a clearly distinct background across all themes
            is_dark = theme.name in ("Dark Blue", "Nord Dark")
            color = '#4a3a30' if is_dark else '#F0D9C8'
            row_color = QColor(color)
        else:
            # Normal: determine color based on row index (even/odd)
            color = settings['even_color'] if row % 2 == 0 else settings['odd_color']
            row_color = QColor(color)

        # Apply to ID column (column 0)
        id_item = self.table.item(row, 0)
        if id_item:
            id_item.setBackground(row_color)
        
        # Apply to Type column (column 1) - but preserve heading/list item colors
        type_item = self.table.item(row, 1)
        if type_item:
            type_text = type_item.text()
            # Only apply row color if it's not a special type (headings/list items have their own colors)
            if type_text not in ("H1", "H2", "H3", "H4", "Title", "li"):
                type_item.setBackground(row_color)
        
        # Apply to source widget
        if hasattr(source_widget, 'set_background_color'):
            source_widget.set_background_color(color)

        # Apply to target widget
        if hasattr(target_widget, 'set_background_color'):
            target_widget.set_background_color(color)

    # Selection tint colour for the source/target cell widgets in the current
    # row. Matches the QTableWidget::item:selected QSS rule so the tint is
    # consistent across columns 0/1 (table items) and 2/3 (cell widgets).
    # Pale Trados-style icy blue – clearly visible against alternating row
    # tints without overpowering the cell content (termbase pills, tag
    # markers, search highlights).
    SELECTION_TINT = "#CFE5F5"

    def _update_row_selection_tint(self, current_row, current_col, previous_row, previous_col):
        """Tint every column of the active row with the selection blue.

        QSS `::item:selected` is meant to handle this for free, but Qt paints
        explicit ``item.setBackground()`` brushes on top of the QSS colour –
        and we set those brushes via _apply_row_color for the ID/Type cells.
        That's why without this method the source/target widgets show the
        selection blue but the segment-number column stays on its alternating
        colour, breaking the visual unity of the active row.

        We walk every column so any cell widget that knows how to be tinted
        (``set_background_color``) gets the same shade, and any plain
        QTableWidgetItem gets its background brush overridden. The previous
        row is reverted to its alternating colour via the existing helper.
        """
        if not hasattr(self, 'table') or self.table is None:
            return

        column_count = self.table.columnCount()
        tint = QColor(self.SELECTION_TINT)

        # Restore alternating colour on the row we left.
        if previous_row is not None and previous_row >= 0 and previous_row != current_row:
            try:
                src_w = self.table.cellWidget(previous_row, 2)
                tgt_w = self.table.cellWidget(previous_row, 3)
                if src_w is not None or tgt_w is not None:
                    # _apply_row_color handles cols 0/1 (items) + 2/3 (widgets).
                    self._apply_row_color(previous_row, src_w, tgt_w)
            except RuntimeError:
                # Widget may have been deleted (e.g., during grid reload).
                pass

        # Tint the new current row across every column.
        if current_row is not None and current_row >= 0:
            try:
                for col in range(column_count):
                    widget = self.table.cellWidget(current_row, col)
                    if widget is not None:
                        # Cell widgets that opt into themed backgrounds
                        # (source/target QTextEdits) take the tint via their
                        # API. Other cell widgets are left alone – they have
                        # their own opinions about how to render selection.
                        if hasattr(widget, 'set_background_color'):
                            # Force dark text so the cell stays readable on the
                            # pale-blue selection tint (light editor text on light
                            # blue is unreadable in dark themes).
                            widget.set_background_color(self.SELECTION_TINT, text_color='#000000')
                    else:
                        item = self.table.item(current_row, col)
                        if item is not None:
                            item.setBackground(tint)
            except RuntimeError:
                pass

    def apply_alternating_row_colors(self):
        """Apply alternating row colors to all source and target cells in the grid"""
        if not hasattr(self, 'table') or not self.table:
            return

        # Clear cached settings to force reload
        if hasattr(self, '_row_color_settings_cached'):
            delattr(self, '_row_color_settings_cached')

        for row in range(self.table.rowCount()):
            # Keep UI responsive during large grid updates
            if row % 50 == 0:
                QApplication.processEvents()

            source_widget = self.table.cellWidget(row, 2)
            target_widget = self.table.cellWidget(row, 3)

            if source_widget and target_widget:
                self._apply_row_color(row, source_widget, target_widget)

        # Re-apply the selection tint to the current row across all columns –
        # we just overwrote its cells with the alternating colour. Pass
        # previous_row=-1 so the helper skips the restore branch.
        current_row = self.table.currentRow()
        if current_row is not None and current_row >= 0:
            self._update_row_selection_tint(current_row, 0, -1, 0)

        self.log("✓ Alternating row colors applied")
    
    def on_font_changed(self):
        """Handle font change - legacy method for compatibility"""
        self.apply_font_to_grid()
        self.auto_resize_rows()

    
    def zoom_in(self):
        """Increase grid font size (zoom in)."""
        self.increase_font_size()
    
    def zoom_out(self):
        """Decrease grid font size (zoom out)."""
        self.decrease_font_size()
    
    def results_pane_zoom_in(self):
        """Increase font size in translation results pane"""
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                if hasattr(panel, 'zoom_in'):
                    panel.zoom_in()
            # Save font sizes to preferences
            self.save_current_font_sizes()
    
    def results_pane_zoom_out(self):
        """Decrease font size in translation results pane"""
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                if hasattr(panel, 'zoom_out'):
                    panel.zoom_out()
            # Save font sizes to preferences
            self.save_current_font_sizes()
    
    def results_pane_zoom_reset(self):
        """Reset font size in translation results pane to default"""
        if hasattr(self, 'results_panels'):
            for panel in self.results_panels:
                if hasattr(panel, 'reset_zoom'):
                    panel.reset_zoom()
            # Save font sizes to preferences
            self.save_current_font_sizes()
    
    # =========================================================================
    # MATCH PANEL ZOOM METHODS
    # =========================================================================
    
    # Class variables for Match Panel font (TM Source/Target boxes)
    match_panel_font_size = 10    # Default font size
    match_panel_font_family = ''  # Empty = system default; set from settings on startup
    match_panel_font_bold = False  # Default not bold

    def match_panel_zoom_in(self):
        """Increase font size in Match Panel TM boxes"""
        SupervertalerQt.match_panel_font_size = min(18, SupervertalerQt.match_panel_font_size + 1)
        self._apply_match_panel_font_size()
        self.save_current_font_sizes()
        self.log(f"Match Panel font size: {SupervertalerQt.match_panel_font_size}pt")

    def match_panel_zoom_out(self):
        """Decrease font size in Match Panel TM boxes"""
        SupervertalerQt.match_panel_font_size = max(7, SupervertalerQt.match_panel_font_size - 1)
        self._apply_match_panel_font_size()
        self.save_current_font_sizes()
        self.log(f"Match Panel font size: {SupervertalerQt.match_panel_font_size}pt")

    def match_panel_zoom_reset(self):
        """Reset font size in Match Panel TM boxes to default"""
        SupervertalerQt.match_panel_font_size = 10
        self._apply_match_panel_font_size()
        self.save_current_font_sizes()
        self.log("Match Panel font size reset to 10pt")
    
    def _apply_match_panel_font_size(self):
        """Apply current font settings to Match Panel TM Source/Target text edits.

        Both the QTextEdit stylesheet AND the document default font must be updated:
        - The stylesheet controls plain-text rendering.
        - The document default font controls rich-text content inserted via QTextCursor
          (e.g. diff-highlighted segments), which ignores the stylesheet font-size.
        """
        if not hasattr(self, 'compare_panel_text_edits'):
            return

        font_size = SupervertalerQt.match_panel_font_size
        font_family = SupervertalerQt.match_panel_font_family
        font_bold = SupervertalerQt.match_panel_font_bold

        # Build a QFont for the document default font
        from PyQt6.QtGui import QFont
        doc_font = QFont()
        if font_family:
            doc_font.setFamily(font_family)
        doc_font.setPointSize(font_size)
        doc_font.setBold(font_bold)

        for item in self.compare_panel_text_edits:
            text_edit = item[0]  # First element is the QTextEdit
            if not (text_edit and hasattr(text_edit, 'setStyleSheet')):
                continue

            # 1. Update stylesheet (for plain-text rendering)
            import re
            current_style = text_edit.styleSheet()
            new_style = re.sub(r'font-size:\s*\d+px;?', f'font-size: {font_size}px;', current_style)
            if 'font-size' not in new_style:
                new_style += f" font-size: {font_size}px;"
            text_edit.setStyleSheet(new_style)

            # 2. Update document default font (for rich-text / cursor-inserted content)
            text_edit.document().setDefaultFont(doc_font)
    
    def save_current_font_sizes(self):
        """Save current font sizes to preferences"""
        try:
            from modules.translation_results_panel import CompactMatchItem, TranslationResultsPanel
            general_settings = self.load_general_settings()
            general_settings['grid_font_family'] = self.default_font_family
            general_settings['grid_font_size'] = self.default_font_size
            if hasattr(CompactMatchItem, 'font_size_pt'):
                general_settings['results_match_font_size'] = CompactMatchItem.font_size_pt
            if hasattr(TranslationResultsPanel, 'compare_box_font_size'):
                general_settings['results_compare_font_size'] = TranslationResultsPanel.compare_box_font_size
            if hasattr(CompactMatchItem, 'show_tags'):
                general_settings['results_show_tags'] = CompactMatchItem.show_tags
            if hasattr(EditableGridTextEditor, 'tag_highlight_color'):
                general_settings['tag_highlight_color'] = EditableGridTextEditor.tag_highlight_color
            # Save Match Panel font settings
            general_settings['match_panel_font_size'] = SupervertalerQt.match_panel_font_size
            general_settings['match_panel_font_family'] = SupervertalerQt.match_panel_font_family
            general_settings['match_panel_font_bold'] = SupervertalerQt.match_panel_font_bold
            # Preserve other settings
            if 'restore_last_project' not in general_settings:
                general_settings['restore_last_project'] = False
            self.save_general_settings(general_settings)
        except Exception as e:
            # Silently fail - don't interrupt user workflow
            pass
    
    def load_general_settings(self) -> Dict[str, Any]:
        """Load general application settings"""
        # Initialize auto-propagation from loaded settings
        settings = self._load_general_settings_from_file()
        if 'auto_propagate_exact_matches' in settings:
            self.auto_propagate_exact_matches = settings['auto_propagate_exact_matches']
        if 'auto_insert_100_percent_matches' in settings:
            self.auto_insert_100_percent_matches = settings['auto_insert_100_percent_matches']
        if 'auto_confirm_100_percent_matches' in settings:
            self.auto_confirm_100_percent_matches = settings['auto_confirm_100_percent_matches']
        if 'auto_confirm_overwrite_existing' in settings:
            self.auto_confirm_overwrite_existing = settings['auto_confirm_overwrite_existing']
        # Load TM/termbase matching setting
        if 'enable_tm_termbase_matching' in settings:
            self.enable_tm_matching = settings['enable_tm_termbase_matching']
            self.enable_termbase_matching = settings['enable_tm_termbase_matching']
        # Load termbase grid highlighting setting
        if 'enable_termbase_grid_highlighting' in settings:
            self.enable_termbase_grid_highlighting = settings['enable_termbase_grid_highlighting']
        # Load auto-markdown setting
        self.auto_generate_markdown = settings.get('auto_generate_markdown', False)
        # Load TM save mode
        self.tm_save_mode = settings.get('tm_save_mode', 'latest')
        # Load debug mode settings
        self.debug_mode_enabled = settings.get('debug_mode_enabled', False)
        self.debug_auto_export = settings.get('debug_auto_export', False)
        # Load LLM matching setting (default: FALSE - too slow!)
        self.enable_llm_matching = settings.get('enable_llm_matching', False)
        # Load precision scroll divisor setting
        self.precision_scroll_divisor = settings.get('precision_scroll_divisor', 3)
        # Load auto-center active segment setting (default True, like memoQ/Trados)
        self.auto_center_active_segment = settings.get('auto_center_active_segment', True)
        # Load termbase display settings
        self.termbase_hide_shorter_matches = settings.get('termbase_hide_shorter_matches', False)
        # Load invisible character color
        self.invisible_char_color = settings.get('invisible_char_color', '#999999')
        # Load smart word selection setting
        self.enable_smart_word_selection = settings.get('enable_smart_word_selection', True)
        
        # Load alternating row color settings
        self.enable_alternating_row_colors = settings.get('enable_alternating_row_colors', True)
        self.even_row_color = settings.get('even_row_color', '#FFFFFF')
        self.odd_row_color = settings.get('odd_row_color', '#F0F0F0')

        # Load hide outer wrapping tags setting
        self.hide_outer_wrapping_tags = settings.get('hide_outer_wrapping_tags', False)

        # Load termbase highlight style settings
        self.termbase_highlight_style = settings.get('termbase_highlight_style', 'semibold')
        self.termbase_dotted_color = settings.get('termbase_dotted_color', '#808080')
        
        # Load tab position setting
        self.tabs_above_grid = settings.get('tabs_above_grid', False)
        
        # Load panel visibility settings
        self.show_translation_results_pane = settings.get('show_translation_results_pane', False)
        self.show_compare_panel = settings.get('show_compare_panel', True)

        # Load cache kill switch setting (default: False = caches ENABLED for performance)
        self.disable_all_caches = settings.get('disable_all_caches', False)

        # Load LLM provider settings for AI Assistant
        llm_settings = self.load_llm_settings()
        self.current_provider = llm_settings.get('provider', 'openai')
        provider_key = f"{self.current_provider}_model"
        self.current_model = llm_settings.get(provider_key)

        return settings
    
    # ═══════════════════════════════════════════════════════════════════════
    # Unified Settings Infrastructure
    # All settings are stored in settings/settings.json with top-level sections:
    #   "api_keys", "general", "ui", "features"
    # ═══════════════════════════════════════════════════════════════════════

    def _get_settings_dir(self) -> Path:
        """Get the settings subdirectory path"""
        return self.user_data_path / "workbench" / "settings"

    def _get_unified_settings_path(self) -> Path:
        """Get the unified settings.json file path"""
        return self._get_settings_dir() / "settings.json"

    def _load_unified_settings(self) -> Dict[str, Any]:
        """Load the entire unified settings file"""
        settings_file = self._get_unified_settings_path()
        if not settings_file.exists():
            return {"api_keys": {}, "general": {}, "ui": {}, "features": {}}
        try:
            with open(settings_file, 'r', encoding='utf-8') as f:
                data = json.load(f)
            # Ensure all sections exist
            for section in ("api_keys", "general", "ui", "features"):
                if section not in data:
                    data[section] = {}
            return data
        except Exception:
            return {"api_keys": {}, "general": {}, "ui": {}, "features": {}}

    def _save_unified_settings(self, data: Dict[str, Any]):
        """Save the entire unified settings file"""
        settings_dir = self._get_settings_dir()
        settings_dir.mkdir(parents=True, exist_ok=True)
        settings_file = settings_dir / "settings.json"
        try:
            with open(settings_file, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
        except Exception as e:
            self.log(f"⚠ Could not save settings: {str(e)}")

    def _load_settings_section(self, section: str) -> Dict[str, Any]:
        """Load a specific section from unified settings"""
        return self._load_unified_settings().get(section, {})

    def _save_settings_section(self, section: str, section_data: Dict[str, Any]):
        """Save a specific section to unified settings (preserves other sections)"""
        all_settings = self._load_unified_settings()
        all_settings[section] = section_data
        self._save_unified_settings(all_settings)

    def _migrate_settings_to_unified(self):
        """One-time migration from old settings files to unified settings/settings.json"""
        settings_dir = self._get_settings_dir()
        unified_file = settings_dir / "settings.json"

        # Skip if already migrated
        if unified_file.exists():
            return

        print("[Settings] Migrating to unified settings/settings.json ...")
        settings_dir.mkdir(parents=True, exist_ok=True)
        unified = {"api_keys": {}, "general": {}, "ui": {}, "features": {}}

        # 1. Migrate general_settings.json
        old_general = self.user_data_path / "general_settings.json"
        if old_general.exists():
            try:
                with open(old_general, 'r', encoding='utf-8') as f:
                    unified["general"] = json.load(f)
                old_general.rename(old_general.with_suffix('.json.migrated'))
            except Exception as e:
                print(f"[Settings] Warning: could not migrate general_settings.json: {e}")

        # 2. Migrate ui_preferences.json
        old_prefs = self.user_data_path / "ui_preferences.json"
        if old_prefs.exists():
            try:
                with open(old_prefs, 'r', encoding='utf-8') as f:
                    prefs = json.load(f)
                # Remove the nested general_settings (it's a stale duplicate of general_settings.json)
                prefs.pop('general_settings', None)
                unified["ui"] = prefs
                old_prefs.rename(old_prefs.with_suffix('.json.migrated'))
            except Exception as e:
                print(f"[Settings] Warning: could not migrate ui_preferences.json: {e}")

        # 3. Migrate feature_settings.json
        old_features = self.user_data_path / "feature_settings.json"
        if old_features.exists():
            try:
                with open(old_features, 'r', encoding='utf-8') as f:
                    unified["features"] = json.load(f)
                old_features.rename(old_features.with_suffix('.json.migrated'))
            except Exception as e:
                print(f"[Settings] Warning: could not migrate feature_settings.json: {e}")

        # 4. Migrate api_keys.txt
        api_keys_file = None
        for candidate in [self.user_data_path / "api_keys.txt",
                          Path(__file__).parent / "user_data_private" / "api_keys.txt"]:
            if candidate.exists():
                api_keys_file = candidate
                break

        if api_keys_file:
            try:
                api_keys = {}
                with open(api_keys_file, 'r', encoding='utf-8') as f:
                    for line in f:
                        line = line.strip()
                        if line and '=' in line and not line.startswith('#'):
                            key, value = line.split('=', 1)
                            api_keys[key.strip().lower()] = value.strip()
                # Migrate legacy 'google' key to canonical 'gemini' key
                if api_keys.get('google') and not api_keys.get('gemini'):
                    api_keys['gemini'] = api_keys['google']
                unified["api_keys"] = api_keys
                api_keys_file.rename(api_keys_file.with_suffix('.txt.migrated'))
            except Exception as e:
                print(f"[Settings] Warning: could not migrate api_keys.txt: {e}")

        # 5. Relocate satellite JSON files to settings/ subfolder
        import shutil
        for filename in ['find_replace_history.json', 'superlookup_history.json',
                         'recent_projects.json',
                         'themes.json', 'shortcuts.json', 'voice_commands.json']:
            old_path = self.user_data_path / filename
            new_path = settings_dir / filename
            if old_path.exists() and not new_path.exists():
                try:
                    shutil.move(str(old_path), str(new_path))
                except Exception as e:
                    print(f"[Settings] Warning: could not move {filename}: {e}")

        # 6. Write unified settings
        try:
            with open(unified_file, 'w', encoding='utf-8') as f:
                json.dump(unified, f, indent=2, ensure_ascii=False)
            print(f"[Settings] Migration complete: {unified_file}")
        except Exception as e:
            print(f"[Settings] Error writing unified settings: {e}")

    def _migrate_to_workbench_layout(self):
        """One-time migration: move Workbench files into a workbench/ subfolder.

        Old layout:
            ~/Supervertaler/settings/         → workbench/settings/
            ~/Supervertaler/dictionaries/     → workbench/dictionaries/
            ~/Supervertaler/voice_scripts/    → workbench/voice_scripts/
            ~/Supervertaler/ai_assistant/     → workbench/ai_assistant/
            ~/Supervertaler/web_cache/        → workbench/web_cache/
            ~/Supervertaler/projects/         → workbench/projects/

        Shared resources (prompt_library/, resources/) stay at the root.
        """
        import shutil

        flag_file = self.user_data_path / "workbench" / ".migrated"
        if flag_file.exists():
            return

        old_settings = self.user_data_path / "settings" / "settings.json"
        if not old_settings.exists():
            # Nothing to migrate – fresh install or already migrated.
            # Just ensure the workbench dir exists and write the flag.
            try:
                (self.user_data_path / "workbench" / "settings").mkdir(parents=True, exist_ok=True)
                flag_file.write_text(datetime.utcnow().isoformat(), encoding='utf-8')
            except Exception:
                pass
            return

        print("[Layout] Migrating to workbench/ subfolder layout ...")
        wb = self.user_data_path / "workbench"

        try:
            # Move settings/ → workbench/settings/
            old_settings_dir = self.user_data_path / "settings"
            new_settings_dir = wb / "settings"
            if old_settings_dir.exists() and not new_settings_dir.exists():
                new_settings_dir.mkdir(parents=True, exist_ok=True)
                for f in old_settings_dir.iterdir():
                    if f.is_file():
                        dest = new_settings_dir / f.name
                        if not dest.exists():
                            shutil.move(str(f), str(dest))
                # Remove old dir if empty
                try:
                    old_settings_dir.rmdir()
                except OSError:
                    pass

            # Move top-level directories → workbench/
            for dirname in ['dictionaries', 'voice_scripts', 'ai_assistant',
                            'web_cache', 'projects']:
                old_dir = self.user_data_path / dirname
                new_dir = wb / dirname
                if old_dir.exists() and not new_dir.exists():
                    try:
                        shutil.move(str(old_dir), str(new_dir))
                    except Exception as e:
                        print(f"[Layout] Warning: could not move {dirname}: {e}")

            flag_file.write_text(datetime.utcnow().isoformat(), encoding='utf-8')
            print("[Layout] Migration to workbench/ layout complete.")
        except Exception as e:
            print(f"[Layout] Warning: migration incomplete: {e}")

    def _load_general_settings_from_file(self) -> Dict[str, Any]:
        """Load general settings from unified settings.json (general section)"""

        defaults = {
            'restore_last_project': False,
            'auto_propagate_exact_matches': True,
            'auto_center_active_segment': True,  # Default to True (like memoQ/Trados)
            'enable_sound_effects': False,
            'sound_effects_map': {
                'glossary_term_added': 'asterisk',
                'glossary_created': 'asterisk',
                'match_inserted': 'ok',
                'glossary_term_duplicate': 'exclamation',
                'glossary_term_error': 'hand'
            },
            'grid_font_size': 11,
            'results_match_font_size': 9,
            'results_compare_font_size': 9
        }

        settings = self._load_settings_section("general")
        if not settings:
            return defaults

        # Merge with defaults to ensure all keys exist
        result = defaults.copy()
        result.update(settings)

        # Migrate termview_* → termlens_* settings keys (v1.9.347+)
        _tv_migrations = {
            'termview_under_grid_visible': 'termlens_under_grid_visible',
            'termview_font_family': 'termlens_font_family',
            'termview_font_size': 'termlens_font_size',
            'termview_font_bold': 'termlens_font_bold',
        }
        for old_key, new_key in _tv_migrations.items():
            if old_key in result and new_key not in result:
                result[new_key] = result.pop(old_key)

        return result

    def save_general_settings(self, settings: Dict[str, Any]):
        """Save general settings to unified settings.json (general section)"""
        try:
            self._save_settings_section("general", settings)
        except Exception as e:
            self.log(f"⚠ Could not save general settings: {str(e)}")

    def load_dictation_settings(self) -> Dict[str, Any]:
        """Load Voice settings"""
        defaults = {
            'model': 'base',
            'max_duration': 10,
            'language': 'Auto (use project target language)'
        }

        try:
            prefs = self._load_settings_section("ui")
            dictation = prefs.get('dictation_settings', {})
            result = defaults.copy()
            result.update(dictation)
            return result
        except:
            return defaults

    def save_dictation_settings(self, model: str, duration: int, language: str):
        """Save Voice settings"""
        # Load existing preferences to check if model changed
        all_settings = self._load_unified_settings()
        prefs = all_settings.setdefault("ui", {})
        old_model = prefs.get('dictation_settings', {}).get('model')

        # Update dictation settings
        prefs['dictation_settings'] = {
            'model': model,
            'max_duration': duration,
            'language': language
        }

        # Model download info
        model_sizes = {
            'tiny': '75 MB',
            'base': '142 MB',
            'small': '466 MB',
            'medium': '1.5 GB',
            'large': '2.9 GB'
        }

        # Save back
        try:
            self._save_unified_settings(all_settings)
            self.log(f"✓ Voice settings saved: Model={model}, Duration={duration}s")

            # Build message
            message = (
                f"Voice settings saved successfully!\n\n"
                f"Model: {model}\n"
                f"Max Duration: {duration} seconds\n"
                f"Language: {language}"
            )

            # Add download info if model changed
            if old_model != model:
                import os
                cache_path = os.path.expanduser('~/.cache/whisper')
                if os.name == 'nt':  # Windows
                    cache_path = os.path.join(os.environ.get('USERPROFILE', ''), '.cache', 'whisper')

                download_size = model_sizes.get(model, 'unknown size')
                message += (
                    f"\n\n📥 Model Download Info:\n"
                    f"If you haven't used the '{model}' model before, it will be\n"
                    f"downloaded automatically on first use ({download_size}).\n\n"
                    f"Storage location:\n{cache_path}"
                )

            QMessageBox.information(self, "Settings Saved", message)
        except Exception as e:
            self.log(f"⚠ Could not save Voice settings: {str(e)}")
            QMessageBox.warning(self, "Save Error", f"Could not save settings:\n{str(e)}")

    def _load_language_pair_from_disk(self):
        """Read just the source/target language pair from settings.json.

        This must run BEFORE the Language Pair tab UI is built, otherwise
        the combo boxes are populated from the stale hard-coded defaults
        (English / Dutch). Called early in __init__ where spellcheck and
        log may not be set up yet, so we avoid touching them here.
        """
        defaults = ('English', 'Dutch')
        try:
            prefs = self._load_settings_section("ui")
            lang_settings = prefs.get('language_settings', {}) or {}
            src = lang_settings.get('source_language') or defaults[0]
            tgt = lang_settings.get('target_language') or defaults[1]
            self.source_language = src
            self.target_language = tgt
            print(f"[LangSettings] Loaded from settings.json: {src} → {tgt}")
        except Exception as e:
            print(f"[LangSettings] Load failed, keeping defaults: {e!r}")

    # =========================================================
    # Voice dictation vocabulary biasing (v1.10.26 issue: Whisper
    # misheard "Supervertaler" as "Supervertile" / etc. – see
    # modules/voice_vocabulary.py for the full design).
    # =========================================================

    def load_voice_vocabulary_settings(self) -> Dict[str, Any]:
        """Load the user's voice-dictation vocabulary settings.

        Settings shape (stored under the ``voice_vocabulary`` key in
        the unified settings JSON):

            {
                "custom_terms": ["MyClient", "TechWord", ...],
                "replacements": [
                    {"heard": "supervertile", "meant": "Supervertaler"},
                    ...
                ],
                "use_termbase": true
            }

        The built-in defaults (modules.voice_vocabulary.DEFAULT_VOCABULARY
        and DEFAULT_REPLACEMENTS) are *always* applied on top of the
        user's settings, so brand names like "Supervertaler" are biased
        even on a fresh install with no customisation.
        """
        defaults = {
            'custom_terms': [],
            'replacements': [],
            'use_termbase': True,
        }
        try:
            section = self._load_settings_section("voice_vocabulary")
            result = defaults.copy()
            result.update(section or {})
            # Sanity-check shapes – tolerate strings that got saved as
            # JSON lists, etc. Bad entries are silently dropped.
            if not isinstance(result.get('custom_terms'), list):
                result['custom_terms'] = []
            if not isinstance(result.get('replacements'), list):
                result['replacements'] = []
            result['use_termbase'] = bool(result.get('use_termbase', True))
            return result
        except Exception as e:
            print(f"[VoiceVocab] load failed, using defaults: {e!r}")
            return defaults

    def save_voice_vocabulary_settings(self, custom_terms, replacements, use_termbase):
        """Persist the voice-dictation vocabulary settings. Replaces
        the entire ``voice_vocabulary`` section; partial updates aren't
        supported by design (the UI always writes all three fields).
        """
        try:
            self._save_settings_section("voice_vocabulary", {
                'custom_terms': [
                    str(t).strip() for t in (custom_terms or [])
                    if str(t).strip()
                ],
                'replacements': [
                    {
                        'heard': str(r.get('heard', '')).strip(),
                        'meant': str(r.get('meant', '')).strip(),
                    }
                    for r in (replacements or [])
                    if isinstance(r, dict)
                    and str(r.get('heard', '')).strip()
                    and str(r.get('meant', '')).strip()
                ],
                'use_termbase': bool(use_termbase),
            })
        except Exception as e:
            print(f"[VoiceVocab] save failed: {e!r}")

    def build_voice_initial_prompt(self) -> str:
        """Construct the Whisper ``initial_prompt`` from defaults +
        the user's custom terms + (optionally) the active project's
        termbase source-language entries.

        Called every time a new dictation surface is constructed
        (always-on listener, push-to-talk thread), so changes to
        the user's settings take effect on the next utterance
        without restarting Workbench.
        """
        settings = self.load_voice_vocabulary_settings()
        custom = settings.get('custom_terms', [])
        termbase_terms = []
        if settings.get('use_termbase', True):
            try:
                termbase_terms = self._collect_voice_dictation_termbase_terms()
            except Exception as e:
                print(f"[VoiceVocab] termbase pull failed: {e!r}")
        try:
            from modules.voice_vocabulary import build_initial_prompt
            return build_initial_prompt(
                custom_terms=custom,
                termbase_terms=termbase_terms,
            )
        except Exception as e:
            print(f"[VoiceVocab] prompt build failed: {e!r}")
            return ""

    def open_termbases_tab(self):
        """Navigate the main tab bar to the 🏷️ Termbases top tab.

        Looked up by tab text rather than a stored index so it
        survives any future re-ordering of top tabs. No-op if the
        Termbases tab isn't present (e.g. some future build that
        moves termbase management elsewhere). Returns True if we
        navigated, False if the tab wasn't found.

        Added in v1.10.31 for the voice-bias checkbox in the Voice
        tab – ticking "Also bias from your termbases" navigates here
        automatically so the user can pick which termbases to
        actually enable (the per-termbase 🎤 Voice column is opt-in
        as of v1.10.29).
        """
        if not hasattr(self, 'main_tabs'):
            return False
        for i in range(self.main_tabs.count()):
            if "Termbases" in self.main_tabs.tabText(i):
                self.main_tabs.setCurrentIndex(i)
                return True
        return False

    def _migrate_voice_dictation_default_off(self):
        """One-shot reset: clear every termbase's
        ``voice_dictation_enabled`` flag so the v1.10.29 opt-in
        default takes effect for users who upgraded from v1.10.28
        (where the flag was added with DEFAULT 1, populating every
        existing termbase as voice-enabled).

        Runs at most once per database, gated by a sentinel in the
        unified settings JSON. The user's manual selections after
        v1.10.29 are not touched – the sentinel is set the first
        time this method runs, and on every subsequent launch the
        method returns immediately.

        Skipped silently on any error; the worst that happens is
        the user sees their old v1.10.28 defaults persist and has
        to untick the 🎤 Voice column manually, which is the same
        UX they'd get without this migration.
        """
        try:
            prefs = self._load_settings_section("ui")
            if prefs.get("voice_dictation_opt_in_reset_applied"):
                return  # already migrated
            cursor = self.db_manager.cursor
            cursor.execute(
                "UPDATE termbases SET voice_dictation_enabled = 0"
            )
            self.db_manager.connection.commit()
            prefs["voice_dictation_opt_in_reset_applied"] = True
            self._save_settings_section("ui", prefs)
            self.log(
                "✓ Voice-dictation termbase flags reset to opt-in (v1.10.29 migration)"
            )
        except Exception as e:
            print(f"[VoiceVocab] opt-in reset migration error: {e!r}")

    def _collect_voice_dictation_termbase_terms(self) -> list:
        """Return the **target-language** ``target_term`` values of
        every term in every termbase whose **voice-dictation flag**
        is on. The list is used by ``build_voice_initial_prompt()``
        as additional vocabulary biasing for Whisper.

        Why target, not source: translators dictate the *target* side
        (the language they're producing). An EN→NL translator
        dictating Dutch needs Whisper to know Dutch terminology, not
        the English originals.

        Why no project filter: this used to require an open Workbench
        project (it called ``get_active_termbase_ids(project_id)``),
        which meant the feature silently did nothing when the user
        had Workbench open as a companion utility alongside their
        translation in Trados – the typical case. v1.10.28 swapped
        the per-project read/write activation for a separate
        per-termbase voice-dictation flag (column ``🎤 Voice`` in
        Termbase Manager) which is independent of project context.

        Best-effort: any database / API error is caught and turned
        into an empty list so it can never break dictation.
        """
        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            return []
        try:
            voice_ids = self.termbase_mgr.get_voice_enabled_termbase_ids() or []
        except Exception:
            return []
        out = []
        seen = set()
        for tb_id in voice_ids:
            try:
                terms = self.termbase_mgr.get_terms(tb_id) or []
            except Exception:
                continue
            for entry in terms:
                # Target-language column (what the translator dictates).
                tgt = (entry.get('target_term') or '').strip()
                if not tgt:
                    continue
                key = tgt.lower()
                if key in seen:
                    continue
                seen.add(key)
                out.append(tgt)
                # Cap to keep the prompt from ballooning past Whisper's
                # token budget. build_initial_prompt() trims further
                # if needed, but it's cheaper to cap here.
                if len(out) >= 200:
                    return out
        return out

    def load_language_settings(self):
        """Initialize spellcheck based on the already-loaded target language.

        The source/target pair itself is loaded earlier by
        _load_language_pair_from_disk() so the UI sees the right values.
        This method now handles only the spellcheck side-effects that
        depend on self.spellcheck_manager and self.log existing.
        """
        try:
            prefs = self._load_settings_section("ui")
            spellcheck_settings = prefs.get('spellcheck_settings', {}) or {}
            self.spellcheck_enabled = spellcheck_settings.get('enabled', False)
            TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)

            if self.spellcheck_manager and self.spellcheck_enabled:
                if self.spellcheck_manager.set_language(self.target_language):
                    self.log(f"✓ Spellcheck initialized for {self.target_language}")
                else:
                    self.log(f"⚠ Spellcheck dictionary not available for {self.target_language}")
        except Exception as e:
            print(f"[LangSettings] Spellcheck init failed: {e!r}")

    def save_language_settings(self, source_lang: str, target_lang: str):
        """Save language settings to preferences"""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['language_settings'] = {
                'source_language': source_lang,
                'target_language': target_lang
            }
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save language settings: {str(e)}")
    
    def _save_language_settings_from_ui(self, source_combo, target_combo):
        """Save language settings from UI"""
        self.source_language = source_combo.currentText()
        self.target_language = target_combo.currentText()
        self.save_language_settings(self.source_language, self.target_language)
        
        self.log(f"✓ Language settings saved: {self.source_language} → {self.target_language}")
        QMessageBox.information(self, "Settings Saved", 
                              f"Language settings have been saved:\n\n"
                              f"Source: {self.source_language}\n"
                              f"Target: {self.target_language}")
    
    def load_font_sizes_from_preferences(self):
        """Load and apply font sizes from preferences on startup"""
        try:
            general_settings = self.load_general_settings()
            
            # Load grid font family
            font_family = general_settings.get('grid_font_family', 'Calibri')
            if font_family:
                self.default_font_family = font_family
            
            # Load grid font size
            grid_size = general_settings.get('grid_font_size', 11)
            if 7 <= grid_size <= 72:
                self.default_font_size = grid_size
            
            # Apply font to grid if it exists
            if hasattr(self, 'table') and self.table is not None:
                self.apply_font_to_grid()
            
            # Load results pane font sizes
            match_size = general_settings.get('results_match_font_size', 9)
            compare_size = general_settings.get('results_compare_font_size', 9)
            show_tags = general_settings.get('results_show_tags', False)
            
            # Apply to all results panels
            if hasattr(self, 'results_panels'):
                if 7 <= match_size <= 16:
                    from modules.translation_results_panel import CompactMatchItem
                    CompactMatchItem.set_font_size(match_size)
                    for panel in self.results_panels:
                        if hasattr(panel, 'set_font_size'):
                            panel.set_font_size(match_size)
                
                if 7 <= compare_size <= 14:
                    from modules.translation_results_panel import TranslationResultsPanel
                    TranslationResultsPanel.compare_box_font_size = compare_size
                    for panel in self.results_panels:
                        if hasattr(panel, 'set_compare_box_font_size'):
                            panel.set_compare_box_font_size(compare_size)
                
                # Apply show_tags setting
                from modules.translation_results_panel import CompactMatchItem
                CompactMatchItem.show_tags = show_tags
                for panel in self.results_panels:
                    if hasattr(panel, 'set_show_tags'):
                        panel.set_show_tags(show_tags)
                
                # Load and apply tag color
                tag_color = general_settings.get('tag_highlight_color', '#7f0001')
                EditableGridTextEditor.tag_highlight_color = tag_color
                ReadOnlyGridTextEditor.tag_highlight_color = tag_color
                CompactMatchItem.tag_highlight_color = tag_color
                for panel in self.results_panels:
                    if hasattr(panel, 'set_tag_color'):
                        panel.set_tag_color(tag_color)
                
                # Load and apply badge text color
                badge_text_color = general_settings.get('badge_text_color', '#333333')
                CompactMatchItem.badge_text_color = badge_text_color
                
                # Load and apply focus border settings
                focus_border_color = general_settings.get('focus_border_color', '#f1b79a')
                focus_border_thickness = general_settings.get('focus_border_thickness', 2)
                EditableGridTextEditor.focus_border_color = focus_border_color
                EditableGridTextEditor.focus_border_thickness = focus_border_thickness
                
            # Load and apply Match Panel font settings
            match_panel_size = general_settings.get('match_panel_font_size', 10)
            if 7 <= match_panel_size <= 18:
                SupervertalerQt.match_panel_font_size = match_panel_size
            SupervertalerQt.match_panel_font_family = general_settings.get('match_panel_font_family', '')
            SupervertalerQt.match_panel_font_bold = general_settings.get('match_panel_font_bold', False)
            self._apply_match_panel_font_size()
                
            if hasattr(self, 'results_panels'):
                # Load and apply match limits
                match_limits = general_settings.get('match_limits', {
                    'LLM': 3,
                    'MT': 3,
                    'TM': 5,
                    'Termbases': 10
                })
                for panel in self.results_panels:
                    panel.match_limits = match_limits
                    
        except Exception as e:
            self.log(f"⚠ Could not load font sizes: {e}")
    
    def _init_usage_statistics(self):
        """Show opt-in dialog (once) and send anonymous usage ping if opted in."""
        try:
            from modules.usage_statistics import (
                has_been_asked, show_opt_in_dialog, set_opted_in, send_ping
            )
            settings_path = self._get_unified_settings_path()

            # Show opt-in dialog once (first launch or after update that adds this)
            if not has_been_asked(settings_path):
                opted_in = show_opt_in_dialog(self)
                set_opted_in(settings_path, opted_in)
                if opted_in:
                    self.log("📊 Usage statistics enabled – thank you!")
                else:
                    self.log("📊 Usage statistics declined – no data will be sent.")

            # Send anonymous ping in background (only if opted in)
            send_ping(settings_path, __version__)

        except Exception:
            # Never let stats code block startup
            pass

    def restore_last_project_if_enabled(self):
        """Restore the last opened project on startup if the setting is enabled"""
        try:
            # Check if setting is enabled
            general_settings = self.load_general_settings()
            if not general_settings.get('restore_last_project', False):
                return
            
            # Get recent projects
            recent_projects = self.load_recent_projects()
            if not recent_projects:
                return
            
            # Get the most recent project (first in the list)
            # Recent projects are sorted by most recent first
            last_project = recent_projects[0]
            project_path = last_project.get('path') if isinstance(last_project, dict) else last_project
            
            # Verify file exists
            if not project_path or not os.path.exists(project_path):
                self.log(f"⚠ Last project file not found: {project_path}")
                return
            
            # Load the project
            self.log(f"🔄 Restoring last project: {Path(project_path).name}")
            self.load_project(project_path)
            
        except Exception as e:
            # Silently fail - don't block startup if restoration fails
            self.log(f"⚠ Could not restore last project: {e}")
    
    
    def get_status_icon(self, status: str) -> str:
        """Get status icon for display"""
        return get_status(status).icon
    
    def _handle_target_text_debounced_by_id(self, segment_id, new_text):
        """
        Handle expensive target text change operations after user stops typing.
        Called 500ms after last keystroke to avoid UI lag.
        Uses segment ID to find the correct segment (not row index).
        """
        try:
            # Find segment by ID
            segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
            if not segment:
                return
            
            # Find current row for this segment
            row = next((i for i, seg in enumerate(self.current_project.segments) if seg.id == segment_id), None)
            if row is None:
                return
            
            # Note: Status is now set to "not_started" immediately on edit (in on_target_text_changed)
            # User must manually confirm/approve to change status to translated/confirmed
            
            # Update window title
            self.update_window_title()
            
            # Auto-resize only this row (fast, single-row operation)
            self._auto_resize_single_row(row)
            
            # Save to TM ONLY if segment is confirmed (user explicitly approved)
            # Do NOT save for 'draft' status - that's just machine/batch translated
            if segment.status == 'confirmed' and new_text.strip():
                try:
                    self.save_segment_to_activated_tms(segment.source, new_text)
                except Exception as e:
                    self.log(f"Warning: Could not save to TM: {e}")
            
            # Update status bar progress stats (debounced - only after user stops typing)
            self.update_progress_stats()
            
            # 🚀 IDLE PREFETCH: User stopped typing, prefetch next segments for instant Ctrl+Enter
            self._trigger_idle_prefetch(row)
            
        except Exception as e:
            self.log(f"Error in debounced target handler: {e}")
    
    def update_status_icon(self, row: int, status: str):
        """Update status icon for a specific row"""
        if not self.current_project or row >= len(self.current_project.segments):
            return
        segment = self.current_project.segments[row]
        old_status = segment.status
        old_target = segment.target
        segment.status = status
        
        # Record undo state for status change
        self.record_undo_state(segment.id, old_target, segment.target, old_status, status)
        
        self._refresh_segment_status(segment)
        
        # Save to TM ONLY if status changed to confirmed (user explicitly approved)
        # Do NOT save for 'draft' or 'approved' status - user must confirm first
        find_replace_active = getattr(self, 'find_replace_active', False)
        if not find_replace_active and status == 'confirmed' and segment.target.strip():
            try:
                self.save_segment_to_activated_tms(segment.source, segment.target)
            except Exception as e:
                self.log(f"Warning: Could not save to TM: {e}")
    
    def on_cell_changed(self, item: QTableWidgetItem):
        """Handle cell content changes - now mainly for placeholder items"""
        # Target cell changes are handled by the EditableGridTextEditor's textChanged signal
        # This method is kept for compatibility but should rarely be called now
        # Placeholder items (columns 2 and 3) are non-interactive, so this shouldn't trigger
        pass
    
    def on_cell_selected(self, current_row, current_col, previous_row, previous_col):
        """Handle cell selection change"""
        if self.debug_mode_enabled:
            self.log(f"🎯 on_cell_selected called: row {current_row}, col {current_col}")

        # 🚫 GUARD: Don't re-run lookups if we're staying on the same row
        # This prevents lookups when user edits text (focus changes within same row)
        if hasattr(self, '_last_selected_row') and self._last_selected_row == current_row:
            if self.debug_mode_enabled:
                self.log(f"⏭️ Skipping lookup - already on row {current_row}")
            return
        self._last_selected_row = current_row

        # ⚡ FAST PATH: Defer heavy lookups for ALL navigation (arrow keys, Ctrl+Enter, AND mouse clicks)
        # This makes segment navigation feel INSTANT - cursor moves first, lookups happen after
        # Reset any navigation flags
        self._arrow_key_navigation = False
        self._ctrl_enter_navigation = False

        # In filtered mode, use a longer debounce so rapid navigation stays snappy
        # but settling on a segment still triggers TM/MT/LLM lookups
        is_filtering = getattr(self, 'filtering_active', False)
        debounce_ms = 250 if is_filtering else 10

        # Schedule deferred lookup with short delay - debounce prevents hammering during rapid navigation
        if hasattr(self, '_deferred_lookup_timer') and self._deferred_lookup_timer:
            self._deferred_lookup_timer.stop()
        from PyQt6.QtCore import QTimer
        self._deferred_lookup_timer = QTimer()
        self._deferred_lookup_timer.setSingleShot(True)
        self._deferred_lookup_timer.timeout.connect(
            lambda r=current_row, c=current_col, pr=previous_row, pc=previous_col:
            self._on_cell_selected_full(r, c, pr, pc)
        )
        self._deferred_lookup_timer.start(debounce_ms)

        # Do minimal UI update immediately (orange highlight, scroll)
        self._on_cell_selected_minimal(current_row, previous_row)

        # In filtered mode, also update glossary highlights from cache (cheap)
        if is_filtering:
            self._on_cell_selected_glossary_only(current_row)
    
    def _center_row_in_viewport(self, row: int):
        """Center the given row vertically in the visible table viewport.
        
        Uses Qt's built-in scrollTo() with PositionAtCenter hint.
        """
        if row < 0 or row >= self.table.rowCount():
            return
        
        # Get the model index for any cell in this row (use column 0)
        index = self.table.model().index(row, 0)
        if index.isValid():
            # Use Qt's built-in centering - PositionAtCenter puts the item in the center of the viewport
            self.table.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
    
    def _on_cell_selected_minimal(self, current_row, previous_row):
        """Minimal UI update for fast arrow key navigation - just highlight and scroll"""
        try:
            # Clear previous highlighting
            if previous_row >= 0 and previous_row < self.table.rowCount():
                prev_id_item = self.table.item(previous_row, 0)
                if prev_id_item:
                    prev_id_item.setBackground(QBrush())
                    theme = self.theme_manager.current_theme
                    segment_num_color = "black" if theme.name in ["Light (Default)", "Soft Gray", "Warm Cream", "Sepia", "High Contrast"] else theme.text
                    prev_id_item.setForeground(QBrush(QColor(segment_num_color)))

            # Highlight current segment number in orange
            if current_row >= 0 and current_row < self.table.rowCount():
                current_id_item = self.table.item(current_row, 0)
                if current_id_item:
                    current_id_item.setBackground(QColor("#FFA500"))
                    current_id_item.setForeground(QColor("white"))
                
                # Auto-center if enabled
                if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
                    self._center_row_in_viewport(current_row)
        except Exception as e:
            if self.debug_mode_enabled:
                self.log(f"Error in minimal cell selection: {e}")

    def _on_cell_selected_glossary_only(self, current_row):
        """Update glossary highlights and TermLens from cache when in filtered mode.
        Skips TM/MT/LLM lookups - only handles cheap, cache-based termbase operations."""
        try:
            if not self.current_project or current_row < 0:
                return

            id_item = self.table.item(current_row, 0)
            if not id_item:
                return
            try:
                segment_id = int(id_item.text())
            except (ValueError, AttributeError):
                return

            segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
            if not segment:
                return

            # --- Termbase grid highlighting (from cache) ---
            if self.enable_termbase_grid_highlighting:
                tb_dict = {}
                with self.termbase_cache_lock:
                    tb_dict = self.termbase_cache.get(segment_id, {})

                if not tb_dict:
                    # Fall back to translation_matches_cache
                    with self.translation_matches_cache_lock:
                        cached_matches = self.translation_matches_cache.get(segment_id)
                    if cached_matches:
                        cached_tb = cached_matches.get("Termbases", [])
                        if cached_tb:
                            for match in cached_tb:
                                term_id = match.metadata.get('term_id') if match.metadata else None
                                key = term_id if term_id is not None else match.source
                                tb_dict[key] = {
                                    'source': match.source,
                                    'translation': match.target,
                                    'ranking': match.metadata.get('ranking', 0) if match.metadata else 0,
                                    'is_project_termbase': match.metadata.get('is_project_termbase', False) if match.metadata else False,
                                    'forbidden': match.metadata.get('forbidden', False) if match.metadata else False,
                                    'termbase_name': match.metadata.get('termbase_name', '') if match.metadata else '',
                                    'notes': match.metadata.get('notes', '') if match.metadata else '',
                                    'term_id': term_id,
                                    'termbase_id': match.metadata.get('termbase_id') if match.metadata else None,
                                }
                            if tb_dict:
                                with self.termbase_cache_lock:
                                    self.termbase_cache[segment_id] = tb_dict

                if tb_dict:
                    self.highlight_source_with_termbase(current_row, segment.source, tb_dict)
                else:
                    # No cached matches – do a fresh (cheap) termbase-only lookup
                    fresh_tb = self.find_termbase_matches_in_source(segment.source)
                    if fresh_tb:
                        self.highlight_source_with_termbase(current_row, segment.source, fresh_tb)

            # --- TermLens pane update ---
            if self.current_project and (
                (hasattr(self, 'termlens_widget') and self.termlens_widget) or
                (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match)
            ):
                termbase_matches = []
                with self.translation_matches_cache_lock:
                    tv_cached = self.translation_matches_cache.get(segment_id)
                if tv_cached:
                    termbase_matches = [
                        {
                            'source_term': match.source,
                            'target_term': match.target,
                            'termbase_name': match.metadata.get('termbase_name', '') if match.metadata else '',
                            'ranking': match.metadata.get('ranking', 0) if match.metadata else 0,
                            'is_project_termbase': match.metadata.get('is_project_termbase', False) if match.metadata else False,
                            'term_id': match.metadata.get('term_id') if match.metadata else None,
                            'termbase_id': match.metadata.get('termbase_id') if match.metadata else None,
                            'notes': match.metadata.get('notes', '') if match.metadata else ''
                        }
                        for match in tv_cached.get("Termbases", [])
                    ]

                nt_matches = self.find_nt_matches_in_source(segment.source)
                status_hint = self._get_termbase_status_hint()
                self._update_both_termlens(segment.source, termbase_matches, nt_matches, status_hint)
        except Exception as e:
            if self.debug_mode_enabled:
                self.log(f"Error in glossary-only cell selection: {e}")

    def _pcs(self, label: str):
        """Context manager that logs elapsed time for a named block during
        cell-selection. Always active – the timer itself is cheap, and we
        only call self.log() when a block exceeds 50 ms, so normal fast
        clicks produce no log output. Used to pinpoint which lookups in
        `_on_cell_selected_full` are responsible for the multi-second UI
        freezes that some users hit after clicking a segment.
        """
        from contextlib import contextmanager
        import time as _t

        @contextmanager
        def _timer():
            t0 = _t.perf_counter()
            try:
                yield
            finally:
                elapsed_ms = (_t.perf_counter() - t0) * 1000.0
                if elapsed_ms > 50.0:
                    self.log(f"⏱️ [cell-select] {label}: {elapsed_ms:.1f} ms")

        return _timer()

    def _on_cell_selected_full(self, current_row, current_col, previous_row, previous_col):
        """Full cell selection handler with lookups and highlighting"""
        # Top-level stopwatch – any total over ~250 ms is logged so the
        # multi-second freezes that some users hit show up in the log
        # without needing debug mode enabled. Fine-grained `self._pcs("…")`
        # blocks below show which step ate the time.
        import time as _profile_time
        _profile_t0 = _profile_time.perf_counter()

        # Clear text selections in previous row's source and target cells
        if previous_row >= 0 and previous_row < self.table.rowCount():
            # Clear source cell selection (column 2)
            source_widget = self.table.cellWidget(previous_row, 2)
            if source_widget and hasattr(source_widget, 'textCursor'):
                cursor = source_widget.textCursor()
                cursor.clearSelection()
                source_widget.setTextCursor(cursor)
            
            # Clear target cell selection (column 3)
            target_widget = self.table.cellWidget(previous_row, 3)
            if target_widget and hasattr(target_widget, 'textCursor'):
                cursor = target_widget.textCursor()
                cursor.clearSelection()
                target_widget.setTextCursor(cursor)
        
        # DEBUG: Also log all connected signals for this table (only in debug mode)
        if self.debug_mode_enabled:
            self.log(f"🔗 Table currentCellChanged signal connected: {self.table.currentCellChanged}")
            self.log(f"🔗 Table itemClicked signal connected: {self.table.itemClicked}")
            self.log(f"🔗 Table itemSelectionChanged signal connected: {self.table.itemSelectionChanged}")
        try:
            # Update segment number highlighting (orange = active)
            if previous_row >= 0 and previous_row < self.table.rowCount():
                prev_id_item = self.table.item(previous_row, 0)
                if prev_id_item:
                    prev_id_item.setBackground(QBrush())
                    theme = self.theme_manager.current_theme
                    segment_num_color = "black" if theme.name in ["Light (Default)", "Soft Gray", "Warm Cream", "Sepia", "High Contrast"] else theme.text
                    prev_id_item.setForeground(QBrush(QColor(segment_num_color)))

            if current_row >= 0 and current_row < self.table.rowCount():
                current_id_item = self.table.item(current_row, 0)
                if current_id_item:
                    current_id_item.setBackground(QColor("#FFA500"))
                    current_id_item.setForeground(QColor("white"))
                
                if getattr(self, 'auto_center_active_segment', False) and not getattr(self, 'filtering_active', False):
                    self._center_row_in_viewport(current_row)
            
            if not self.current_project or current_row < 0:
                return
            
            if current_row < len(self.current_project.segments):
                # CRITICAL: Get segment by ID from the grid, not by row index!
                # The row index might not match the segment's position in the list
                id_item = self.table.item(current_row, 0)
                if not id_item:
                    self.log(f"⚠️ No ID item found at row {current_row}")
                    return
                
                # Get segment ID from the grid cell
                try:
                    segment_id = int(id_item.text())
                except (ValueError, AttributeError):
                    self.log(f"⚠️ Could not parse segment ID from cell at row {current_row}")
                    return
                
                # Find the segment by ID in the project's segment list
                segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
                if not segment:
                    self.log(f"⚠️ Could not find segment with ID {segment_id} in project")
                    return
                
                # If segment has a batch TM match, display it in the Match Panel immediately
                batch_match = getattr(segment, '_batch_tm_match', None)
                if batch_match:
                    search_source = segment.source
                    if self.hide_outer_wrapping_tags:
                        search_source, _ = strip_outer_wrapping_tags(search_source)
                    tm_matches = [{
                        'source': batch_match.get('source', search_source),
                        'target': batch_match.get('target', ''),
                        'tm_name': batch_match.get('tm_name', 'TM'),
                        'tm_id': batch_match.get('tm_id', ''),
                        'match_pct': batch_match.get('match_pct', 100),
                        # v1.10.50: surface reverse-match info so the Match
                        # Panel can show a "⇄ reversed" chip.
                        'reverse_match': batch_match.get('reverse_match', False),
                    }]
                    self.set_compare_panel_matches(segment.id, search_source, tm_matches, [])

                # Update Preview panel - scroll to and highlight this segment
                self._scroll_preview_to_segment(segment_id)

                # Update Translation Results panel header with segment info
                if hasattr(self, 'results_panels'):
                    for panel in self.results_panels:
                        try:
                            panel.set_segment_info(segment.id, segment.source)
                        except Exception as e:
                            self.log(f"Error updating Translation Results panel header: {e}")
                
                # Update toolbar segment info and status
                if hasattr(self, 'tab_seg_info'):
                    try:
                        self.tab_seg_info.setText(f"Segment {segment.id}")
                        
                        # Update status combo if it exists
                        if hasattr(self, 'tab_status_combo'):
                            from modules.statuses import STATUSES
                            idx = -1
                            for i in range(self.tab_status_combo.count()):
                                if self.tab_status_combo.itemData(i) == segment.status:
                                    idx = i
                                    break
                            if idx >= 0:
                                self.tab_status_combo.blockSignals(True)
                                self.tab_status_combo.setCurrentIndex(idx)
                                self.tab_status_combo.blockSignals(False)
                    except Exception as e:
                        self.log(f"Error updating toolbar: {e}")
                
                # Update Translation Results panel notes
                if hasattr(self, 'translation_results_panel') and self.translation_results_panel:
                    try:
                        notes_text = segment.notes if segment.notes else ""
                        self.translation_results_panel.notes_edit.blockSignals(True)
                        self.translation_results_panel.notes_edit.setPlainText(notes_text)
                        self.translation_results_panel.notes_edit.blockSignals(False)
                    except Exception as e:
                        self.log(f"Error updating notes in Translation Results panel: {e}")
                
                # Update bottom Notes tab with segment notes
                self._update_bottom_notes_for_segment(segment)
                # Update Proofreading note tab
                self._update_proofreading_notes_for_segment(segment)

                # Update legacy tabbed panels (if they exist for other views)
                if hasattr(self, 'update_tab_segment_editor'):
                    self.update_tab_segment_editor(
                        segment_id=segment.id,
                        source_text=segment.source,
                        target_text=segment.target,
                        status=segment.status,
                        notes=segment.notes
                    )
                
                # ⚡ PERFORMANCE: Skip heavy lookups if Preview tab is open
                # User is likely just reading/reviewing, not actively translating
                if self._is_preview_tab_active():
                    if self.debug_mode_enabled:
                        self.log(f"⏭️ Preview tab active - skipping TM/Termbase lookups for segment {segment.id}")
                    return
                
                # Get termbase matches (from cache or search on-demand) - ONLY if enabled
                matches_dict = None  # Initialize at the top level
                cached_matches = None  # Initialize for cache skip path

                # 🧪 EXPERIMENTAL: Skip ALL cache checks if cache kill switch is enabled
                if not getattr(self, 'disable_all_caches', False):
                    # 🚀 CHECK PREFETCH CACHE FIRST for instant display (like memoQ)
                    segment_id = segment.id
                    with self.translation_matches_cache_lock:
                        if segment_id in self.translation_matches_cache:
                            cached_matches = self.translation_matches_cache[segment_id]
                            
                            # Count matches in each category
                            tm_count = len(cached_matches.get("TM", []))
                            tb_count = len(cached_matches.get("Termbases", []))
                            mt_count = len(cached_matches.get("MT", []))
                            llm_count = len(cached_matches.get("LLM", []))
                            
                            self.log(f"⚡ CACHE HIT for segment {segment_id}: TM={tm_count}, TB={tb_count}, MT={mt_count}, LLM={llm_count}")
                else:
                    if self.debug_mode_enabled:
                        self.log(f"🧪 Cache DISABLED - forcing fresh lookup for segment {segment.id}")
                
                # Process cached matches if we have them
                if cached_matches is not None:
                    # Display cached matches immediately
                    if hasattr(self, 'results_panels'):
                        for panel in self.results_panels:
                            try:
                                panel.clear()
                                panel.set_matches(cached_matches)
                            except Exception as e:
                                self.log(f"Error displaying cached matches: {e}")
                    
                    # 🔄 Update TermLens with cached termbase matches (always update, even if empty)
                    if self.current_project and ((hasattr(self, 'termlens_widget') and self.termlens_widget) or (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match)):
                        try:
                            # Convert TranslationMatch objects to dict format for termlens
                            termbase_matches = [
                                {
                                    'source_term': match.source,
                                    'target_term': match.target,
                                    'termbase_name': match.metadata.get('termbase_name', '') if match.metadata else '',
                                    'ranking': match.metadata.get('ranking', 0) if match.metadata else 0,
                                    'is_project_termbase': match.metadata.get('is_project_termbase', False) if match.metadata else False,
                                    'term_id': match.metadata.get('term_id') if match.metadata else None,
                                    'termbase_id': match.metadata.get('termbase_id') if match.metadata else None,
                                    'notes': match.metadata.get('notes', '') if match.metadata else ''
                                }
                                for match in cached_matches.get("Termbases", [])
                            ]
                            # Also get NT matches (fresh, not cached - they may have changed)
                            with self._pcs("find_nt_matches_in_source (cache-hit)"):
                                nt_matches = self.find_nt_matches_in_source(segment.source)

                            # Get status hint for termbase activation
                            with self._pcs("_get_termbase_status_hint (cache-hit)"):
                                status_hint = self._get_termbase_status_hint()

                            # Update both TermLens widgets (left and right)
                            with self._pcs("_update_both_termlens (cache-hit)"):
                                self._update_both_termlens(segment.source, termbase_matches, nt_matches, status_hint)
                        except Exception as e:
                            self.log(f"Error updating termlens from cache: {e}")
                    
                    # 🎯 AUTO-INSERT 100% TM MATCH from cache (if enabled in settings)
                    tm_count = len(cached_matches.get("TM", []))
                    if self.auto_insert_100_percent_matches and tm_count > 0:
                        # Check if segment target is empty (don't overwrite existing translations)
                        target_empty = not segment.target or len(segment.target.strip()) == 0
                        
                        if target_empty:
                            # Find first 100% match in cached TM results
                            best_match = None
                            for tm_match in cached_matches.get("TM", []):
                                # Use >= 99.5 to handle potential floating point issues
                                if float(tm_match.relevance) >= 99.5:
                                    best_match = tm_match
                                    break
                            
                            if best_match:
                                self.log(f"✨ Auto-inserting 100% TM match into segment {segment.id}")
                                self._auto_insert_tm_match(segment, best_match.target, current_row)
                                # Play 100% TM match alert sound
                                self._play_sound_effect('tm_100_percent_match')
                    
                    # 🔊 Play fuzzy match sound if fuzzy matches found from cache (but not 100%)
                    if tm_count > 0:
                        tm_matches = cached_matches.get("TM", [])
                        has_100_match = any(float(tm.relevance) >= 99.5 for tm in tm_matches)
                        has_fuzzy_match = any(float(tm.relevance) < 99.5 and float(tm.relevance) >= 50 for tm in tm_matches)
                        if has_fuzzy_match and not has_100_match:
                            self._play_sound_effect('tm_fuzzy_match')
                    
                    # Highlight termbase matches in source cell (from termbase_cache, dict format)
                    if self.enable_termbase_grid_highlighting:
                        with self.termbase_cache_lock:
                            tb_dict = self.termbase_cache.get(segment_id, {})
                        if not tb_dict:
                            # termbase_cache may be empty if prefetch ran before batch worker,
                            # or after termbase_cache was cleared. Fall back to the TB matches
                            # already in translation_matches_cache (cached_matches["Termbases"]).
                            cached_tb_matches = cached_matches.get("Termbases", [])
                            if cached_tb_matches:
                                tb_dict = {}
                                for match in cached_tb_matches:
                                    term_id = match.metadata.get('term_id') if match.metadata else None
                                    key = term_id if term_id is not None else match.source
                                    tb_dict[key] = {
                                        'source': match.source,
                                        'translation': match.target,
                                        'ranking': match.metadata.get('ranking', 0) if match.metadata else 0,
                                        'is_project_termbase': match.metadata.get('is_project_termbase', False) if match.metadata else False,
                                        'forbidden': match.metadata.get('forbidden', False) if match.metadata else False,
                                        'termbase_name': match.metadata.get('termbase_name', '') if match.metadata else '',
                                        'notes': match.metadata.get('notes', '') if match.metadata else '',
                                        'term_id': term_id,
                                        'termbase_id': match.metadata.get('termbase_id') if match.metadata else None,
                                    }
                                # Backfill termbase_cache so future navigations don't need this fallback
                                if tb_dict:
                                    with self.termbase_cache_lock:
                                        self.termbase_cache[segment_id] = tb_dict
                        if tb_dict:
                            with self._pcs("highlight_source_with_termbase (cache-hit)"):
                                self.highlight_source_with_termbase(current_row, segment.source, tb_dict)

                    # Skip the slow TERMBASE lookup below, we already have termbase matches cached
                    # But TM lookup was skipped in prefetch (not thread-safe), so schedule it now
                    matches_dict = cached_matches  # Set for later use

                    # v1.9.182: Schedule TM lookup even on cache hit (prefetch skips TM - not thread-safe)
                    # v1.9.261: Skip if segment already has batch TM match data from pre-translation
                    tm_count = len(cached_matches.get("TM", []))
                    has_batch_match = getattr(segment, '_batch_tm_match', None)

                    # v1.9.273: Update Match Panel from cached TM matches (fixes #164)
                    # On revisit, tm_count > 0 so _schedule_mt_and_llm_matches is skipped,
                    # but the Match Panel still needs to be updated with cached TM results.
                    if tm_count > 0:
                        tm_matches_for_panel = []
                        for tm in cached_matches.get("TM", []):
                            tm_name = tm.metadata.get('tm_name', 'TM') if tm.metadata else 'TM'
                            tm_id = tm.metadata.get('tm_id', '') if tm.metadata else ''
                            # v1.10.51: read reverse_match from the
                            # TranslationMatch's metadata so the chip
                            # survives the cache-hit re-render path too.
                            rev = bool(tm.metadata.get('reverse_match', False)) if tm.metadata else False
                            tm_matches_for_panel.append({
                                'source': tm.compare_source or tm.source,
                                'target': tm.target,
                                'tm_name': tm_name,
                                'tm_id': tm_id,
                                'match_pct': int(tm.relevance),
                                'reverse_match': rev,
                            })
                        self.set_compare_panel_matches(
                            segment_id,
                            segment.source,
                            tm_matches=tm_matches_for_panel,
                            mt_matches=getattr(self, '_compare_panel_mt_matches', [])
                        )

                    if tm_count == 0 and self.enable_tm_matching and not has_batch_match:
                        find_replace_active = getattr(self, 'find_replace_active', False)
                        if not find_replace_active:
                            # Get termbase matches for the lookup
                            termbase_matches_for_tm = [
                                {
                                    'source_term': match.source,
                                    'target_term': match.target,
                                    'termbase_name': match.metadata.get('termbase_name', '') if match.metadata else '',
                                }
                                for match in cached_matches.get("Termbases", [])
                            ]
                            self._schedule_mt_and_llm_matches(segment, termbase_matches_for_tm)
                
                # Check if TM/Termbase matching is enabled
                if not matches_dict and (not self.enable_tm_matching and not self.enable_termbase_matching):
                    self.log("⏭️ TM/Termbase matching disabled - skipping all lookups")
                elif not matches_dict:  # Only do slow lookup if cache missed
                    try:
                        source_widget = self.table.cellWidget(current_row, 2)  # Source column is column 2

                        # Termbase lookup (if enabled)
                        stored_matches = {}
                        if self.enable_termbase_matching:
                            # 🧪 EXPERIMENTAL: Skip termbase cache if cache kill switch is enabled
                            cache_checked = False
                            if not getattr(self, 'disable_all_caches', False):
                                # Check cache first (thread-safe) - uses `in` check to properly handle empty caches
                                with self.termbase_cache_lock:
                                    if segment_id in self.termbase_cache:
                                        stored_matches = self.termbase_cache[segment_id]
                                        cache_checked = True
                            
                            if not cache_checked and source_widget:
                                with self._pcs("find_termbase_matches_in_source"):
                                    stored_matches = self.find_termbase_matches_in_source(segment.source)

                                # Store in cache for future access (thread-safe) - EVEN IF EMPTY
                                # BUT skip cache storage if cache kill switch is enabled
                                if not getattr(self, 'disable_all_caches', False):
                                    with self.termbase_cache_lock:
                                        self.termbase_cache[segment_id] = stored_matches
                            
                            # CRITICAL FIX: Always update TermLens (even with empty results) - show "No matches" state
                            if self.current_project and ((hasattr(self, 'termlens_widget') and self.termlens_widget) or (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match)):
                                try:
                                    # Convert dict format to list format
                                    termbase_matches = [
                                        {
                                            'source_term': match_data.get('source', ''),
                                            'target_term': match_data.get('translation', ''),
                                            'termbase_name': match_data.get('termbase_name', ''),
                                            'ranking': match_data.get('ranking', 0),
                                            'is_project_termbase': match_data.get('is_project_termbase', False),
                                            'term_id': match_data.get('term_id'),
                                            'termbase_id': match_data.get('termbase_id'),
                                            'notes': match_data.get('notes', '')
                                        }
                                        for match_data in stored_matches.values()
                                    ] if stored_matches else []
                                    # Also get NT matches
                                    with self._pcs("find_nt_matches_in_source (miss)"):
                                        nt_matches = self.find_nt_matches_in_source(segment.source)

                                    # Get status hint for termbase activation
                                    with self._pcs("_get_termbase_status_hint (miss)"):
                                        status_hint = self._get_termbase_status_hint()

                                    # Update both TermLens widgets (left and right)
                                    with self._pcs("_update_both_termlens (miss)"):
                                        self._update_both_termlens(segment.source, termbase_matches, nt_matches, status_hint)
                                except Exception as e:
                                    self.log(f"Error refreshing termlens: {e}")

                            # Store in widget for backwards compatibility
                            if source_widget and hasattr(source_widget, 'termbase_matches'):
                                source_widget.termbase_matches = stored_matches
                            
                            # Highlight termbase matches and NT matches in source cell (if enabled)
                            if self.enable_termbase_grid_highlighting:
                                with self._pcs("highlight_source_with_termbase (miss)"):
                                    self.highlight_source_with_termbase(current_row, segment.source, stored_matches)
                        else:
                            self.log("⏭️ Termbase matching disabled - skipping termbase lookup")

                        if stored_matches:
                            # Convert stored matches to TranslationMatch objects
                            from modules.translation_results_panel import TranslationMatch
                            matches_dict = {
                                "LLM": [],
                                "NT": [],
                                "MT": [],
                                "TM": [],
                                "Termbases": [],
                                "NonTrans": []  # Non-translatables
                            }
                            
                            # Add non-translatable matches (now sourced from
                            # termbase entries flagged is_nontranslatable=1)
                            nt_matches = self.find_nt_matches_in_source(segment.source)
                            if nt_matches:
                                for nt_match in nt_matches:
                                    nt_obj = TranslationMatch(
                                        source=nt_match.get('text', ''),
                                        target=nt_match.get('text', ''),  # Same as source (not translated)
                                        relevance=100,  # Always 100% match
                                        metadata={
                                            'list_name': nt_match.get('list_name', 'Non-Translatables'),
                                            'is_non_translatable': True
                                        },
                                        match_type='NonTrans',
                                        compare_source=nt_match.get('text', ''),
                                        provider_code='NT'
                                    )
                                    matches_dict["NonTrans"].append(nt_obj)

                            for term_id_key, match_info in stored_matches.items():
                                # Extract all fields from match_info (including source term)
                                if isinstance(match_info, dict):
                                    source_term = match_info.get('source', '')
                                    target_term = match_info.get('translation', '')
                                    ranking = match_info.get('ranking', None)
                                    forbidden = match_info.get('forbidden', False)
                                    is_project_termbase = match_info.get('is_project_termbase', False)
                                    term_id = match_info.get('term_id')
                                    termbase_id = match_info.get('termbase_id')
                                    termbase_name = match_info.get('termbase_name', 'Unknown')
                                    domain = match_info.get('domain', '')
                                    notes = match_info.get('notes', '')
                                    project = match_info.get('project', '')
                                    client = match_info.get('client', '')
                                else:
                                    # Backward compatibility: if just string (shouldn't happen with new code)
                                    source_term = str(term_id_key)
                                    target_term = match_info
                                    ranking = None
                                    forbidden = False
                                    is_project_termbase = False
                                    term_id = None
                                    termbase_id = None
                                    termbase_name = 'Unknown'
                                    domain = ''
                                    notes = ''
                                    project = ''
                                    client = ''
                                
                                # Get target synonyms from match_info
                                target_synonyms = match_info.get('target_synonyms', []) if isinstance(match_info, dict) else []
                                
                                match_obj = TranslationMatch(
                                    source=source_term,
                                    target=target_term,
                                    relevance=95,  # High relevance for termbase matches
                                    metadata={
                                        'termbase_name': termbase_name,
                                        'ranking': ranking,
                                        'is_project_termbase': is_project_termbase,
                                        'domain': domain,
                                        'notes': notes,
                                        'project': project,
                                        'client': client,
                                        'forbidden': forbidden,
                                        'term_id': term_id,
                                        'termbase_id': termbase_id,
                                        'target_synonyms': target_synonyms
                                    },
                                    match_type='Termbase',
                                    compare_source=source_term,
                                    provider_code='TB'
                                )
                                matches_dict["Termbases"].append(match_obj)

                            # Get current project languages for all translation services
                            source_lang = getattr(self.current_project, 'source_lang', None) if self.current_project else None
                            target_lang = getattr(self.current_project, 'target_lang', None) if self.current_project else None

                            # Convert language names to codes if needed
                            if source_lang:
                                source_lang_code = self._convert_language_to_code(source_lang)
                            if target_lang:
                                target_lang_code = self._convert_language_to_code(target_lang)

                            # Clear panels first, then show termbase matches immediately
                            if hasattr(self, 'results_panels'):
                                for panel in self.results_panels:
                                    try:
                                        panel.clear()  # Clear old matches
                                        panel.set_matches(matches_dict)  # Add termbase matches immediately
                                    except Exception as e:
                                        self.log(f"Error updating results panel: {e}")
                        else:
                            # No termbase matches - but still check for NT matches
                            matches_dict = {
                                "LLM": [],
                                "NT": [],
                                "MT": [],
                                "TM": [],
                                "Termbases": [],
                                "NonTrans": []  # Non-translatables
                            }
                            
                            # Add non-translatable matches even without termbase matches
                            from modules.translation_results_panel import TranslationMatch
                            nt_matches = self.find_nt_matches_in_source(segment.source)
                            if nt_matches:
                                for nt_match in nt_matches:
                                    nt_obj = TranslationMatch(
                                        source=nt_match.get('text', ''),
                                        target=nt_match.get('text', ''),  # Same as source (not translated)
                                        relevance=100,  # Always 100% match
                                        metadata={
                                            'list_name': nt_match.get('list_name', 'Non-Translatables'),
                                            'is_non_translatable': True
                                        },
                                        match_type='NonTrans',
                                        compare_source=nt_match.get('text', ''),
                                        provider_code='NT'
                                    )
                                    matches_dict["NonTrans"].append(nt_obj)
                                
                                if matches_dict["NonTrans"]:
                                    self.log(f"📋 Found {len(matches_dict['NonTrans'])} NT matches")
                            
                            if hasattr(self, 'results_panels'):
                                for panel in self.results_panels:
                                    try:
                                        panel.clear()
                                        if matches_dict["NonTrans"]:
                                            panel.set_matches(matches_dict)
                                    except Exception as e:
                                        self.log(f"Error clearing results panel: {e}")
                    except Exception as e:
                        self.log(f"Error retrieving stored termbase matches: {e}")

                    # Schedule expensive searches (TM, MT, LLM) with debouncing to prevent UI blocking
                    # ONLY schedule if:
                    # 1. Cache miss OR cache hit with no TM matches (prefetch doesn't include TM - not thread-safe)
                    # 2. TM matching is enabled
                    # 3. Find/Replace is not active (to avoid slowdowns during navigation)
                    needs_tm_lookup = True
                    with self.translation_matches_cache_lock:
                        if segment_id in self.translation_matches_cache:
                            cached = self.translation_matches_cache[segment_id]
                            # v1.9.182: Check if TM matches exist - prefetch worker skips TM lookups
                            needs_tm_lookup = len(cached.get("TM", [])) == 0

                    # v1.9.261: Skip background TM lookup if segment has batch match data from pre-translation
                    has_batch_match = getattr(segment, '_batch_tm_match', None)
                    find_replace_active = getattr(self, 'find_replace_active', False)

                    if needs_tm_lookup and not has_batch_match and self.enable_tm_matching and not find_replace_active:
                        # Get termbase matches if they exist (could be None or empty)
                        termbase_matches = matches_dict.get('Termbases', []) if matches_dict else []
                        self._schedule_mt_and_llm_matches(segment, termbase_matches)
                
                # Trigger prefetch for next 20 segments (adaptive background caching)
                # Also trigger PROACTIVE HIGHLIGHTING for already-cached segments
                if self.current_project and current_row >= 0:
                    import json
                    next_segment_ids = []
                    start_idx = current_row + 1
                    end_idx = min(start_idx + 20, len(self.current_project.segments))

                    for seg in self.current_project.segments[start_idx:end_idx]:
                        # Check if already cached
                        with self.translation_matches_cache_lock:
                            is_cached = seg.id in self.translation_matches_cache
                        
                        if not is_cached:
                            next_segment_ids.append(seg.id)
                        else:
                            # PROACTIVE HIGHLIGHTING: Apply highlighting for cached segments
                            # that haven't been highlighted yet
                            try:
                                with self.termbase_cache_lock:
                                    termbase_raw = self.termbase_cache.get(seg.id, {})
                                if termbase_raw:
                                    termbase_json = json.dumps(termbase_raw)
                                    self._apply_proactive_highlighting(seg.id, termbase_json)
                            except Exception:
                                pass  # Silent failure for proactive highlighting

                    if next_segment_ids:
                        self._start_prefetch_worker(next_segment_ids)

        except Exception as e:
            self.log(f"Critical error in on_cell_selected: {e}")
        finally:
            _total_ms = (_profile_time.perf_counter() - _profile_t0) * 1000.0
            if _total_ms > 250.0:
                self.log(f"⏱️ [cell-select] TOTAL: {_total_ms:.1f} ms")

    def on_selection_changed(self):
        """Handle selection change for row-based selection mode"""
        try:
            # Get currently selected row
            current_row = self.table.currentRow()
            current_col = self.table.currentColumn()
            
            # ⚡ FAST PATH: Skip if this is arrow key navigation (already handled by on_cell_selected)
            if getattr(self, '_arrow_key_navigation', False):
                return  # Let on_cell_selected handle it
            
            # 🚫 GUARD: Skip if we're on the same row (prevents double-processing)
            if hasattr(self, '_last_selection_row') and self._last_selection_row == current_row:
                return
            self._last_selection_row = current_row
            
            # If we have a valid row selection, trigger the same logic as on_cell_selected
            if current_row >= 0:
                # Call on_cell_selected with appropriate parameters
                self.on_cell_selected(current_row, current_col, -1, -1)
        except Exception as e:
            self.log(f"Error in on_selection_changed: {e}")
    
    def test_cell_selection(self):
        """Test method to manually trigger cell selection for debugging"""
        self.log("🧪 TEST: Manually triggering cell selection for row 0")
        if self.table.rowCount() > 0:
            # Try to programmatically select first row
            self.table.selectRow(0)
            self.table.setCurrentCell(0, 2)  # Select source column
            self.on_cell_selected(0, 2, -1, -1)  # Manually trigger
        else:
            self.log("🧪 TEST: No rows available for testing")
    
    def get_selected_segments_from_grid(self):
        """Get list of selected segments from grid view"""
        if not self.current_project or not hasattr(self, 'table'):
            return []
        
        selected_rows = set()
        for item in self.table.selectedItems():
            selected_rows.add(item.row())
        
        segments = []
        for row in sorted(selected_rows):
            if 0 <= row < len(self.current_project.segments):
                segments.append(self.current_project.segments[row])
        
        return segments

    def _get_selected_or_filtered_segments(self, operation_name: str) -> list:
        """Get segments for bulk operation: selected rows first, then filtered rows as fallback.

        Args:
            operation_name: Human-readable name for the confirmation dialog (e.g. "Confirm Segments")

        Returns:
            List of Segment objects, or empty list if user cancels or nothing available.
        """
        selected = self.get_selected_segments_from_grid()
        if selected:
            return selected

        # No selection – check for filtered (visible) rows
        visible_rows = [row for row in range(self.table.rowCount())
                        if not self.table.isRowHidden(row)]
        total_rows = self.table.rowCount()

        if len(visible_rows) < total_rows and len(visible_rows) > 0:
            reply = QMessageBox.question(
                self, operation_name,
                f"No segments selected.\n\n"
                f"Apply '{operation_name}' to all {len(visible_rows)} filtered (visible) segments?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
            if reply == QMessageBox.StandardButton.Yes:
                return [self.current_project.segments[row] for row in visible_rows
                        if row < len(self.current_project.segments)]
            return []

        QMessageBox.information(self, "No Selection",
            "Please select one or more segments, or apply a filter first.")
        return []

    def _preview_combined_prompt_from_grid(self):
        """Preview combined prompt with the currently selected segment from grid"""
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout, QMessageBox
        
        # Check if project is loaded
        if not self.current_project:
            QMessageBox.warning(
                self,
                "No Project",
                "Please load a project first to preview prompts."
            )
            return
        
        # Check if prompt_manager_qt exists
        if not hasattr(self, 'prompt_manager_qt'):
            QMessageBox.warning(
                self,
                "Feature Not Available",
                "AI features are not available."
            )
            return
        
        # Get current segment from grid
        current_segment = None
        current_segment_id = "Preview"
        
        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row >= 0:
                # Map display row to actual segment index (for pagination)
                actual_index = current_row
                if hasattr(self, 'grid_row_to_segment_index') and self.grid_row_to_segment_index:
                    if current_row in self.grid_row_to_segment_index:
                        actual_index = self.grid_row_to_segment_index[current_row]
                
                # Get segment
                if actual_index < len(self.current_project.segments):
                    current_segment = self.current_project.segments[actual_index]
                    current_segment_id = f"Segment {current_segment.id}"
        
        # Fallback to first segment if none selected
        if not current_segment:
            if len(self.current_project.segments) > 0:
                current_segment = self.current_project.segments[0]
                current_segment_id = f"Example: Segment {current_segment.id}"
                QMessageBox.information(
                    self,
                    "No Segment Selected",
                    "No segment is currently selected. Using the first segment as an example.\n\n"
                    "To preview with a specific segment, please select a row in the grid first."
                )
            else:
                QMessageBox.warning(
                    self,
                    "No Segments",
                    "This project has no segments to preview."
                )
                return
        
        # Get languages
        source_lang = getattr(self.current_project, 'source_lang', 'Source Language')
        target_lang = getattr(self.current_project, 'target_lang', 'Target Language')
        
        # Get source and target text
        source_text = current_segment.source
        target_text = current_segment.target or ""

        # Get glossary terms for AI injection
        glossary_terms = self.get_ai_inject_glossary_terms()

        # Build combined prompt
        combined = self.prompt_manager_qt.build_final_prompt(
            source_text, source_lang, target_lang, glossary_terms=glossary_terms,
            target_text=target_text
        )
        
        # Check for figure/image context
        figure_info = ""
        figure_images = []
        if hasattr(self, 'figure_context') and self.figure_context:
            if self.figure_context.has_images():
                # Detect figure references in current segment
                figure_refs = self.figure_context.detect_figure_references(source_text)
                if figure_refs:
                    figure_images = self.figure_context.get_images_for_text(source_text)
                    if figure_images:
                        image_names = [ref for ref, _ in figure_images]
                        figure_info = f"🖼️ {len(figure_images)} image(s) will be sent with this prompt: {', '.join(f'Figure {ref}' for ref in image_names)}"
                    else:
                        figure_info = f"⚠️ Figure references detected ({', '.join(figure_refs)}) but images not found in loaded context"
                else:
                    total_images = len(self.figure_context.figure_context_map)
                    figure_info = f"ℹ️ {total_images} image(s) loaded but no figure references detected in this segment"
        
        # Build composition info
        composition_parts = []
        composition_parts.append(f"📍 {current_segment_id}")
        composition_parts.append(f"🌐 {source_lang} → {target_lang}")
        composition_parts.append(f"📏 Total prompt: {len(combined):,} characters")
        
        if self.prompt_manager_qt.library.active_primary_prompt:
            composition_parts.append(f"✓ Custom prompt attached")
        
        if self.prompt_manager_qt.library.attached_prompts:
            composition_parts.append(f"✓ {len(self.prompt_manager_qt.library.attached_prompts)} additional prompt(s) attached")

        if glossary_terms:
            composition_parts.append(f"📚 {len(glossary_terms)} termbase term(s) injected")

        if figure_info:
            composition_parts.append(figure_info)
        
        composition_text = "\n".join(composition_parts)
        
        # Create dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("🧪 Combined Prompt Preview")
        dialog.resize(900, 700)
        
        layout = QVBoxLayout(dialog)
        
        # Info label
        info_label = QLabel(
            "<b>Complete Assembled Prompt</b><br>"
            "This is what will be sent to the AI (System Prompt + Custom Prompts + segment text)<br><br>" +
            composition_text.replace("\n", "<br>")
        )
        info_label.setTextFormat(Qt.TextFormat.RichText)
        info_label.setWordWrap(True)
        info_label.setStyleSheet("padding: 10px; background-color: #e3f2fd; border-radius: 4px; margin-bottom: 10px;")
        layout.addWidget(info_label)
        
        # Additional image context notice if images are being sent
        if figure_images:
            image_notice = QLabel(
                f"📸 <b>Visual Context:</b> {len(figure_images)} image(s) will be sent alongside this text prompt as binary data<br>"
                f"<small>Images: {', '.join(f'Figure {ref}' for ref, _ in figure_images)}</small>"
            )
            image_notice.setTextFormat(Qt.TextFormat.RichText)
            image_notice.setWordWrap(True)
            image_notice.setStyleSheet("padding: 10px; border-radius: 4px; margin-bottom: 10px; border-left: 4px solid #ff9800;")
            layout.addWidget(image_notice)
        
        # Text editor for preview with syntax highlighting
        text_edit = QTextEdit()
        text_edit.setReadOnly(True)
        text_edit.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
        text_edit.setStyleSheet("font-family: 'Consolas', 'Courier New', monospace; font-size: 9pt;")

        # Format the prompt with color highlighting
        import html
        import re

        # Escape HTML entities first
        formatted_html = html.escape(combined)

        # Replace newlines with <br> for HTML
        formatted_html = formatted_html.replace('\n', '<br>')

        # Make "# SYSTEM PROMPT" bold and red
        formatted_html = re.sub(
            r'(#\s*SYSTEM\s*PROMPT)',
            r'<span style="color: #d32f2f; font-weight: bold; font-size: 11pt;">\1</span>',
            formatted_html,
            flags=re.IGNORECASE
        )

        # Make "# CUSTOM PROMPT" bold and red
        formatted_html = re.sub(
            r'(#\s*CUSTOM\s*PROMPT)',
            r'<span style="color: #d32f2f; font-weight: bold; font-size: 11pt;">\1</span>',
            formatted_html,
            flags=re.IGNORECASE
        )

        # Make "# TERMBASE" bold and orange
        formatted_html = re.sub(
            r'(#\s*TERMBASE)',
            r'<span style="color: #FF9800; font-weight: bold; font-size: 11pt;">\1</span>',
            formatted_html,
            flags=re.IGNORECASE
        )

        # Make source text section blue (pattern: "XX text:<br>..." until double line break or # header)
        # Match language code + " text:" followed by content until "# " or end
        formatted_html = re.sub(
            r'(\w{2,5}\s+text:)(<br>)(.*?)(<br><br>(?:#|\*\*YOUR TRANSLATION)|$)',
            r'<span style="color: #1565c0; font-weight: bold;">\1</span>\2<span style="color: #1565c0;">\3</span>\4',
            formatted_html,
            flags=re.DOTALL
        )

        # Wrap in pre-like styling div
        formatted_html = f'<div style="font-family: Consolas, Courier New, monospace; white-space: pre-wrap;">{formatted_html}</div>'

        text_edit.setHtml(formatted_html)
        layout.addWidget(text_edit, 1)
        
        # Close button
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        close_btn.setStyleSheet("padding: 8px 20px; font-weight: bold;")
        button_layout.addWidget(close_btn)
        layout.addLayout(button_layout)
        
        dialog.exec()

    
    def show_grid_context_menu(self, position):
        """Show context menu for grid view with bulk operations"""
        selected_segments = self.get_selected_segments_from_grid()

        if not selected_segments:
            return

        menu = QMenu(self)

        # Confirm selected segments action
        if len(selected_segments) >= 1:
            confirm_action = menu.addAction(f"✅ Confirm {len(selected_segments)} Segment(s)")
            confirm_action.setToolTip(f"Confirm {len(selected_segments)} selected segment(s)")
            confirm_action.triggered.connect(self.confirm_selected_segments)

        # Change Status submenu
        from modules.statuses import get_status
        status_menu = menu.addMenu(f"🏷️ Change Status ({len(selected_segments)})")
        # Common user-settable statuses (excluding TM-specific ones like pm, cm, tm_100, etc.)
        user_statuses = [
            ("not_started", "❌ Not started"),
            ("pretranslated", "🤖 Pre-translated"),
            ("draft", "✏️ Draft"),
            ("confirmed", "✔ Confirmed"),
            ("proofread", "🟪 Proofread"),
            ("approved", "⭐ Approved"),
            ("rejected", "🚫 Rejected"),
        ]
        for status_key, label in user_statuses:
            action = status_menu.addAction(label)
            action.triggered.connect(lambda checked, s=status_key: self.change_status_selected(s))

        # Lock / Unlock toggle
        locked_count = sum(1 for seg in selected_segments if getattr(seg, 'locked', False))
        unlocked_count = len(selected_segments) - locked_count
        if locked_count and unlocked_count:
            # Mixed: offer both
            lock_action = menu.addAction(f"🔒 Lock {unlocked_count} Segment(s)")
            lock_action.triggered.connect(lambda: self._set_locked_on_selected(selected_segments, True))
            unlock_action = menu.addAction(f"🔓 Unlock {locked_count} Segment(s)")
            unlock_action.triggered.connect(lambda: self._set_locked_on_selected(selected_segments, False))
        elif locked_count:
            unlock_action = menu.addAction(f"🔓 Unlock {locked_count} Segment(s)")
            unlock_action.triggered.connect(lambda: self._set_locked_on_selected(selected_segments, False))
        else:
            lock_action = menu.addAction(f"🔒 Lock {len(selected_segments)} Segment(s)")
            lock_action.triggered.connect(lambda: self._set_locked_on_selected(selected_segments, True))

        menu.addSeparator()

        # Clear translations action
        clear_action = menu.addAction("🗑️ Clear Translations")
        clear_action.setToolTip(f"Clear translations for {len(selected_segments)} selected segment(s)")
        clear_action.triggered.connect(lambda: self.clear_selected_translations(selected_segments, 'grid'))

        menu.addSeparator()

        # Select all action
        select_all_action = menu.addAction(f"📋 Select All ({format_shortcut_for_display('Ctrl+A')})")
        select_all_action.triggered.connect(lambda: self.table.selectAll())

        menu.exec(self.table.viewport().mapToGlobal(position))
    
    def _set_locked_on_selected(self, segments, locked: bool):
        """Lock or unlock selected segments."""
        changed = 0
        for seg in segments:
            if getattr(seg, 'locked', False) != locked:
                seg.locked = locked
                changed += 1
        if changed:
            self.project_modified = True
            self.load_segments_to_grid()
            self.update_window_title()
            action = "Locked" if locked else "Unlocked"
            self.log(f"{'🔒' if locked else '🔓'} {action} {changed} segment{'s' if changed != 1 else ''}")

    def clear_selected_translations(self, segments, view_type='grid'):
        """Clear translations for selected segments"""
        if not segments:
            return
        
        count = len(segments)
        cleared_count = 0
        
        for segment in segments:
            if segment.target:  # Only clear if there's something to clear
                segment.target = ""
                if segment.status != 'untranslated':
                    segment.status = 'untranslated'
                cleared_count += 1
        
        if cleared_count > 0:
            self.project_modified = True
            self.update_window_title()
            
            # Refresh the appropriate view
            if view_type == 'grid':
                self.load_segments_to_grid()
            elif view_type == 'list':
                self.refresh_list_view()
            
            self.log(f"✓ Cleared translations for {cleared_count} segment(s)")
        else:
            self.log(f"ℹ No translations to clear for selected segments")
    
    def clear_selected_translations_from_menu(self):
        """Clear translations for selected/filtered segments (called from Edit menu)"""
        # Only available in Grid view
        if self.current_project and hasattr(self, 'table') and self.table:
            segments = self._get_selected_or_filtered_segments("Clear Translations")
            if segments:
                self.clear_selected_translations(segments, 'grid')
        else:
            QMessageBox.information(self, "Not Available", "Please load a project first.")
    
    def copy_source_to_target_bulk(self):
        """Copy source text to target for selected or filtered segments (Edit > Bulk Operations)"""
        if not self.current_project or not hasattr(self, 'table') or not self.table:
            QMessageBox.information(self, "Not Available", "Please load a project first.")
            return

        selected_segments = self._get_selected_or_filtered_segments("Copy Source to Target")
        if not selected_segments:
            return
        
        # Confirm operation
        count = len(selected_segments)
        if count > 1:
            reply = QMessageBox.question(
                self,
                "Confirm Copy",
                f"Copy source to target for {count} segment(s)?\n\nThis will overwrite existing translations.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply != QMessageBox.StandardButton.Yes:
                return
        
        # Perform the copy
        copied_count = 0
        for segment in selected_segments:
            row = self._find_row_for_segment(segment.id)
            if row >= 0:
                # v1.9.306: Use segment.source (clean text) instead of source widget's display text
                # which may contain invisible markers (·, →, °, ↵, \u200B)
                source_text = segment.source

                # Update segment object
                segment.target = source_text

                # Update target widget with display-formatted text
                target_widget = self.table.cellWidget(row, 3)
                if target_widget:
                    target_display = self.apply_invisible_replacements(source_text) if hasattr(self, 'apply_invisible_replacements') else source_text
                    target_widget.blockSignals(True)
                    target_widget.setPlainText(target_display)
                    target_widget.blockSignals(False)

                copied_count += 1

        # Auto-resize rows to fit new content
        self.auto_resize_rows()
        self.update_progress_stats()
        
        self.log(f"✓ Copied source to target for {copied_count} segment(s)")
        QMessageBox.information(self, "Copy Complete", f"Copied source to target for {copied_count} segment(s).")

    # --- Non-translatable (number/code) helpers ---
    # Compiled once at class definition time. Matches text that contains ONLY
    # digits, whitespace, and punctuation – no Unicode letters of any script.
    # The character class covers common numeric/code content:
    #   digits, whitespace, hyphen/dash, period, slash, comma, semicolons,
    #   colon, parentheses, plus, equals, asterisk, hash, percent, degree,
    #   currency symbols (€$£¥), ampersand, at-sign, exclamation, question,
    #   square brackets, curly braces, pipe, tilde, caret, underscore, backslash.
    # Future locale-aware conversion belongs in _transform_non_translatable only.
    _NON_TRANSLATABLE_RE = re.compile(
        r'^[\d\s\-\.\/,;:()+=*#%°€$£¥&@!\?\[\]\{\}|~^_\\\'\"]+$'
    )

    @staticmethod
    def _is_non_translatable(source_text: str) -> bool:
        """Return True if source contains only numbers, codes, and punctuation (no letters)."""
        if not source_text or not source_text.strip():
            return False
        return bool(SupervertalerQt._NON_TRANSLATABLE_RE.match(source_text.strip()))

    @staticmethod
    def _transform_non_translatable(source_text: str, source_lang: str = None, target_lang: str = None) -> str:
        """Return the target text for a non-translatable segment.

        Currently returns source verbatim. This is the designed extension point
        for future locale-aware number/date conversion (e.g. decimal comma ↔ period).
        When that feature is added, only this method changes; callers and UI stay the same.
        """
        return source_text

    def copy_source_to_target_non_translatable_bulk(self):
        """Copy source to target for number/code-only segments (Edit > Bulk Operations).

        Segments that contain no alphabetic characters (only digits, punctuation,
        symbols) are considered non-translatable. Their source is copied verbatim
        into an empty target and the status is set to 'draft'.

        Only acts on segments with an empty target to avoid overwriting existing work.
        """
        if not self.current_project or not hasattr(self, 'table') or not self.table:
            QMessageBox.information(self, "Not Available", "Please load a project first.")
            return

        selected_segments = self._get_selected_or_filtered_segments("Copy Source to Target (Non-Translatable)")
        if not selected_segments:
            return

        # Identify qualifying segments: non-translatable source AND empty target
        qualifying = [
            seg for seg in selected_segments
            if self._is_non_translatable(seg.source) and not seg.target.strip()
        ]

        total_selected = len(selected_segments)
        count = len(qualifying)

        if count == 0:
            QMessageBox.information(
                self,
                "No Qualifying Segments",
                f"None of the {total_selected} selected/filtered segment(s) qualify.\n\n"
                "Qualifying segments must contain only numbers, codes, or punctuation "
                "(no letters) and have an empty target.",
            )
            return

        reply = QMessageBox.question(
            self,
            "Confirm Copy",
            f"Found {count} non-translatable segment(s) out of {total_selected} selected/filtered.\n\n"
            "Copy source to target and mark as Draft?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if reply != QMessageBox.StandardButton.Yes:
            return

        source_lang = getattr(self.current_project, 'source_language', None)
        target_lang = getattr(self.current_project, 'target_language', None)

        copied_count = 0
        for segment in qualifying:
            row = self._find_row_for_segment(segment.id)
            if row < 0:
                continue

            transformed = self._transform_non_translatable(segment.source, source_lang, target_lang)

            # Update segment data
            segment.target = transformed
            segment.status = 'draft'

            # Update target widget
            target_widget = self.table.cellWidget(row, 3)
            if target_widget:
                target_display = (
                    self.apply_invisible_replacements(transformed)
                    if hasattr(self, 'apply_invisible_replacements')
                    else transformed
                )
                target_widget.blockSignals(True)
                target_widget.setPlainText(target_display)
                target_widget.blockSignals(False)

            # Refresh status icon
            self._refresh_segment_status(segment)

            copied_count += 1

        self.auto_resize_rows()
        self.update_progress_stats()
        if hasattr(self, '_mark_project_modified'):
            self._mark_project_modified()

        self.log(f"✓ Copied source to target (non-translatable) for {copied_count} segment(s)")
        QMessageBox.information(
            self, "Copy Complete",
            f"Copied source to target for {copied_count} non-translatable segment(s)."
        )

    def _copy_source_to_target_selected(self, selected_segments):
        """Copy source to target for the given selected segments (keyboard shortcut path).

        Called from EditableGridTextEditor when Ctrl+Shift+S is pressed with
        multiple segments selected.  Unlike the menu bulk version, this skips
        the confirmation dialog for a snappy keyboard-driven workflow.
        """
        copied_count = 0
        for segment in selected_segments:
            row = self._find_row_for_segment(segment.id)
            if row >= 0:
                # v1.9.306: Use segment.source (clean text) instead of source widget's display text
                # which may contain invisible markers (·, →, °, ↵, \u200B)
                source_text = segment.source

                # Update segment object
                segment.target = source_text

                # Update target widget with display-formatted text
                target_widget = self.table.cellWidget(row, 3)
                if target_widget:
                    target_display = self.apply_invisible_replacements(source_text) if hasattr(self, 'apply_invisible_replacements') else source_text
                    target_widget.blockSignals(True)
                    target_widget.setPlainText(target_display)
                    target_widget.blockSignals(False)

                copied_count += 1

        if copied_count:
            self.auto_resize_rows()
            self.update_progress_stats()
            self.project_modified = True
            self.update_window_title()

        self.log(f"📋 Copied source to target for {copied_count} segment(s)")

    def show_clean_tags_dialog(self):
        """Show dialog to clean formatting tags from project segments"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "Please load a project with segments first.")
            return
        
        # Import dialog class from TMX editor module
        try:
            from modules.tmx_editor_qt import TmxTagCleanerDialog
        except ImportError:
            QMessageBox.warning(self, "Module Error", "Could not load tag cleaner dialog.")
            return
        
        segment_count = len(self.current_project.segments)
        dialog = TmxTagCleanerDialog(self, tu_count=segment_count)
        dialog.setWindowTitle("🧹 Clean Tags from Project")
        
        if dialog.exec() == QDialog.DialogCode.Accepted:
            self.clean_project_tags(dialog)
    
    def clean_project_tags(self, dialog):
        """Clean tags from project segments based on dialog settings"""
        import re
        
        # Get selected tag patterns
        patterns = []
        for name, cb in dialog.tag_checkboxes.items():
            if cb.isChecked():
                patterns.append(cb.tag_pattern)
        
        if not patterns:
            QMessageBox.information(self, "No Tags Selected", "Please select at least one tag type to clean.")
            return
        
        # Get replacement string
        replacement = " " if dialog.replace_space.isChecked() else ""
        
        # Get scope
        clean_source = dialog.scope_both.isChecked() or dialog.scope_source.isChecked()
        clean_target = dialog.scope_both.isChecked() or dialog.scope_target.isChecked()
        
        # Create patterns for both literal and XML-escaped versions
        # Files may store tags as literal <b> or escaped &lt;b&gt;
        expanded_patterns = []
        for p in patterns:
            expanded_patterns.append(p)  # Original pattern for literal tags
            # Create escaped version: < becomes &lt; and > becomes &gt;
            escaped_p = p.replace('<', '&lt;').replace('>', '&gt;')
            if escaped_p != p:
                expanded_patterns.append(escaped_p)
        
        # Compile combined pattern
        combined_pattern = "|".join(f"({p})" for p in expanded_patterns)
        regex = re.compile(combined_pattern)
        
        # Process segments
        modified_count = 0
        tags_removed = 0
        
        progress = QProgressDialog("Cleaning tags...", "Cancel", 0, len(self.current_project.segments), self)
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)
        
        for i, segment in enumerate(self.current_project.segments):
            if progress.wasCanceled():
                break
            
            progress.setValue(i)
            QApplication.processEvents()
            
            segment_modified = False
            
            if clean_source and segment.source:
                new_source, count = regex.subn(replacement, segment.source)
                if count > 0:
                    segment.source = new_source.strip() if replacement == "" else new_source
                    tags_removed += count
                    segment_modified = True
            
            if clean_target and segment.target:
                new_target, count = regex.subn(replacement, segment.target)
                if count > 0:
                    segment.target = new_target.strip() if replacement == "" else new_target
                    tags_removed += count
                    segment_modified = True
            
            if segment_modified:
                modified_count += 1
        
        progress.setValue(len(self.current_project.segments))
        
        # Refresh grid
        if modified_count > 0:
            self.project_modified = True
            self.load_segments_to_grid()
            self.update_window_title()
        
        # Show results
        scope_text = "both source and target" if (clean_source and clean_target) else ("source" if clean_source else "target")
        QMessageBox.information(
            self, "Tag Cleaning Complete",
            f"✅ Cleaned {modified_count:,} segment(s)\n"
            f"🏷️ Removed {tags_removed:,} tag(s)\n"
            f"📋 Scope: {scope_text}"
        )
        
        self.log(f"🧹 Tag cleaning: {modified_count} segments modified, {tags_removed} tags removed")
    
    # ========================================================================
    # PROOFREADING SYSTEM
    # ========================================================================
    
    def show_proofread_dialog(self):
        """Show dialog to configure and start proofreading"""
        if not self.current_project:
            QMessageBox.information(self, "No Project", "Please open or create a project first.")
            return
        
        # Count segments for each option
        confirmed_count = sum(1 for seg in self.current_project.segments if seg.status == 'confirmed')
        translated_count = sum(1 for seg in self.current_project.segments if seg.status in ['draft', 'confirmed'])
        
        # Count selected rows (not cells)
        selected_count = 0
        if hasattr(self, 'table'):
            selected_rows = set(item.row() for item in self.table.selectedItems())
            selected_count = len(selected_rows)
        
        all_count = len(self.current_project.segments)
        
        dialog = QDialog(self)
        dialog.setWindowTitle("✅ Proofread Translation")
        dialog.setModal(True)
        dialog.setMinimumWidth(600)
        
        layout = QVBoxLayout(dialog)
        
        # Header
        header = QLabel("<h3>✅ AI-Enhanced Translation Proofreading</h3>")
        layout.addWidget(header)
        
        info_label = QLabel(
            "Use AI to verify translation accuracy, completeness, terminology, and style.\n"
            "Results are stored in the Proofreading comments tab, keyed by LLM model."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; padding: 10px 0;")
        layout.addWidget(info_label)
        
        layout.addSpacing(10)
        
        # Segment selection
        segment_group = QGroupBox("Segments to Proofread")
        segment_layout = QVBoxLayout()
        
        confirmed_radio = CheckmarkRadioButton(f"✅ Confirmed only ({confirmed_count} segments)")
        confirmed_radio.setChecked(True)
        segment_layout.addWidget(confirmed_radio)
        
        translated_radio = CheckmarkRadioButton(f"📝 Translated + Confirmed ({translated_count} segments)")
        segment_layout.addWidget(translated_radio)
        
        selected_radio = CheckmarkRadioButton(f"🔹 Selected ({selected_count} segments)")
        selected_radio.setEnabled(selected_count > 0)
        if selected_count == 0:
            selected_radio.setToolTip("Select one or more rows in the grid to enable this option")
        segment_layout.addWidget(selected_radio)
        
        all_radio = CheckmarkRadioButton(f"🌐 All segments ({all_count} segments)")
        segment_layout.addWidget(all_radio)
        
        segment_group.setLayout(segment_layout)
        layout.addWidget(segment_group)
        
        layout.addSpacing(10)
        
        # LLM provider info
        settings = self.load_llm_settings()
        llm_provider = settings.get('provider', 'openai')
        model_key = f'{llm_provider}_model'
        llm_model = settings.get(model_key, 'gpt-5.5')
        
        llm_info = QLabel(f"📊 Using: {llm_provider.title()} ({llm_model})")
        llm_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px 0;")
        layout.addWidget(llm_info)
        
        layout.addSpacing(10)
        
        # Custom prompt override
        prompt_group = QGroupBox("Proofreading Prompt")
        prompt_layout = QVBoxLayout()

        # Load prompts from Bulk Operations/ folder in Prompt Library
        library_prompts = []
        default_prompt_name = "Default Proofreading Prompt"
        if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
            lib = getattr(self.prompt_manager_qt, 'library', None)
            if lib:
                # Reload from disk to pick up any recently created prompts
                lib.load_all_prompts()
                for rel_path, data in lib.prompts.items():
                    folder = data.get('_folder', '')
                    # Only include prompts under Bulk Operations/
                    if not folder.startswith('Bulk Operations'):
                        continue
                    # Skip the default built-in prompt (it's the "Default" entry)
                    if data.get('name', '') == default_prompt_name:
                        continue
                    label = data.get('name', rel_path)
                    library_prompts.append((rel_path, label, data.get('content', '')))
                library_prompts.sort(key=lambda x: x[1].lower())

        prompt_source_layout = QHBoxLayout()
        prompt_source_label = QLabel("Prompt:")
        prompt_source_layout.addWidget(prompt_source_label)

        prompt_combo = QComboBox()
        prompt_combo.addItem("Default (built-in)", "")
        for rel_path, label, content in library_prompts:
            prompt_combo.addItem(label, rel_path)
        prompt_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        prompt_source_layout.addWidget(prompt_combo)
        prompt_layout.addLayout(prompt_source_layout)

        prompt_text = QTextEdit()
        prompt_text.setPlaceholderText(
            "Default checks:\n"
            "1. Accuracy – Does target correctly convey source meaning?\n"
            "2. Completeness – Is anything missing or added?\n"
            "3. Terminology – Are technical terms correct and consistent?\n"
            "4. Grammar & Style – Is the text natural and error-free?\n\n"
            "Select a prompt from the library above, or type a custom prompt here."
        )
        prompt_text.setMaximumHeight(150)
        prompt_layout.addWidget(prompt_text)

        # When combo changes, load prompt content into text area
        def on_prompt_source_changed(index):
            rel_path = prompt_combo.itemData(index)
            if rel_path:
                # Load from library
                for rp, label, content in library_prompts:
                    if rp == rel_path:
                        prompt_text.setPlainText(content)
                        break
            else:
                prompt_text.clear()
        prompt_combo.currentIndexChanged.connect(on_prompt_source_changed)

        prompt_group.setLayout(prompt_layout)
        layout.addWidget(prompt_group)
        
        layout.addSpacing(20)
        
        # Buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_layout.addWidget(cancel_btn)
        
        start_btn = QPushButton("Start Proofreading")
        start_btn.setDefault(True)
        start_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
        start_btn.clicked.connect(dialog.accept)
        button_layout.addWidget(start_btn)
        
        layout.addLayout(button_layout)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Determine which segments to proofread
        segments_to_check = []
        if confirmed_radio.isChecked():
            segments_to_check = [(i, seg) for i, seg in enumerate(self.current_project.segments) if seg.status == 'confirmed']
        elif translated_radio.isChecked():
            segments_to_check = [(i, seg) for i, seg in enumerate(self.current_project.segments) if seg.status in ['draft', 'confirmed']]
        elif selected_radio.isChecked():
            selected_rows = set(item.row() for item in self.table.selectedItems())
            segments_to_check = [(i, seg) for i, seg in enumerate(self.current_project.segments) if i in selected_rows]
        else:  # all_radio
            segments_to_check = list(enumerate(self.current_project.segments))
        
        if not segments_to_check:
            QMessageBox.information(self, "No Segments", "No segments match the selected criteria.")
            return
        
        custom_prompt = prompt_text.toPlainText().strip()
        
        # Start proofreading
        self._run_proofreading(segments_to_check, llm_provider, llm_model, custom_prompt, settings)
    
    def _run_proofreading(self, segments_to_check, provider, model, custom_prompt="", settings=None):
        """Run proofreading on selected segments using a background thread"""
        # Create progress dialog
        progress_dialog = QDialog(self)
        progress_dialog.setWindowTitle("Proofreading in Progress")
        progress_dialog.setModal(True)
        progress_dialog.setMinimumWidth(500)

        dialog_layout = QVBoxLayout(progress_dialog)
        dialog_layout.setContentsMargins(12, 12, 12, 12)
        dialog_layout.setSpacing(8)

        current_label = QLabel("Initializing...")
        dialog_layout.addWidget(current_label)

        progress_bar = QProgressBar()
        progress_bar.setMaximum(len(segments_to_check))
        progress_bar.setValue(0)
        dialog_layout.addWidget(progress_bar)

        stats_label = QLabel("Checked: 0 | Issues Found: 0 | OK: 0")
        dialog_layout.addWidget(stats_label)

        button_layout = QHBoxLayout()
        button_layout.addStretch()

        cancel_btn = QPushButton("Cancel")
        cancel_btn.setStyleSheet("background-color: #e74c3c; color: white; font-weight: bold; padding: 6px 20px;")
        button_layout.addWidget(cancel_btn)

        close_btn = QPushButton("Close")
        close_btn.setEnabled(False)
        close_btn.clicked.connect(progress_dialog.accept)
        button_layout.addWidget(close_btn)

        dialog_layout.addLayout(button_layout)

        # Resolve custom_openai settings before spawning thread
        api_keys = self.load_api_keys()
        base_url = None
        custom_api_key = None
        if provider == 'custom_openai' and settings:
            profile = self._get_active_custom_profile(settings)
            if profile:
                profile_key = (profile.get('api_key') or '').strip()
                if profile_key:
                    custom_api_key = profile_key
                base_url = profile.get('endpoint') or None

        # Create worker thread
        worker = ProofreadWorker(
            segments_to_check=segments_to_check,
            provider=provider,
            model=model,
            custom_prompt=custom_prompt,
            api_keys=api_keys,
            source_lang=self.current_project.source_lang,
            target_lang=self.current_project.target_lang,
            base_url=base_url,
            custom_api_key=custom_api_key,
            http_proxy=self._get_proxy_url() if provider != 'gemini' else None,
        )

        # Keep reference to prevent GC
        self._proofread_worker = worker

        # Connect cancel button
        def on_cancel():
            worker.cancel()
            cancel_btn.setEnabled(False)
            cancel_btn.setText("Cancelling...")
            current_label.setText("Cancelling after current batch...")

        cancel_btn.clicked.connect(on_cancel)

        # Also cancel if dialog is closed via X button
        def on_dialog_rejected():
            worker.cancel()
        progress_dialog.rejected.connect(on_dialog_rejected)

        # Connect worker signals
        def on_progress(checked_count, message):
            progress_bar.setValue(checked_count)
            current_label.setText(message)
            self.log(f"📤 {message}")

        def on_stats(checked, issues, ok):
            progress_bar.setValue(checked)
            stats_label.setText(f"Checked: {checked} | Issues Found: {issues} | OK: {ok}")

        def on_segment_issue(row_idx, issue_text, model_name):
            segment = self.current_project.segments[row_idx]
            if not isinstance(getattr(segment, 'proofreading_notes', None), dict):
                segment.proofreading_notes = {}
            # Overwrite per-LLM: same model replaces its previous note, different models accumulate
            segment.proofreading_notes[model_name] = issue_text
            self.project_modified = True

        def on_batch_error(batch_start, batch_end, error_msg):
            if batch_start == 0 and batch_end == 0:
                # LLM init error
                current_label.setText(f"Error: {error_msg}")
            else:
                self.log(f"⚠️ Proofreading error for batch {batch_start}-{batch_end}: {error_msg}")

        def on_finished(checked, issues, ok):
            cancel_btn.setEnabled(False)
            cancel_btn.hide()
            close_btn.setEnabled(True)

            if worker._cancelled:
                current_label.setText(f"Proofreading cancelled. Checked: {checked} segments before cancellation.")
            else:
                current_label.setText("✓ Proofreading Complete!")

            self.load_segments_to_grid()
            self.update_window_title()

            self.log(f"═══════════════════════════════════════════════════════════")
            self.log(f"{'⊘ Proofreading Cancelled' if worker._cancelled else '✓ Proofreading Complete'}")
            self.log(f"   Checked: {checked} | Issues: {issues} | OK: {ok}")
            self.log(f"═══════════════════════════════════════════════════════════")

            self._proofread_worker = None

        worker.progress_update.connect(on_progress)
        worker.stats_update.connect(on_stats)
        worker.segment_issue.connect(on_segment_issue)
        worker.batch_error.connect(on_batch_error)
        worker.finished_proofreading.connect(on_finished)

        # Start worker and show dialog
        worker.start()
        progress_dialog.exec()
    
    def show_proofreading_results_dialog(self):
        """Show dialog with all proofreading results (from proofreading_notes dict)."""
        if not self.current_project:
            QMessageBox.information(self, "No Project", "Please open or create a project first.")
            return

        # Find segments with proofreading notes
        proofread_segments = []
        for i, segment in enumerate(self.current_project.segments):
            if getattr(segment, 'proofreading_notes', None):
                proofread_segments.append((i, segment))

        if not proofread_segments:
            QMessageBox.information(
                self,
                "No Proofreading Results",
                "No segments have proofreading comments.\n\n"
                "Run Edit → Bulk Operations → ✅ Proofread Translation to analyze your translation."
            )
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("✅ Proofreading Results")
        dialog.setModal(False)
        dialog.setMinimumWidth(900)
        dialog.setMinimumHeight(600)

        layout = QVBoxLayout(dialog)

        # Header
        header = QLabel(f"<h3>⚠️ {len(proofread_segments)} Segment{'s' if len(proofread_segments) != 1 else ''} with Proofreading Comments</h3>")
        layout.addWidget(header)

        info_label = QLabel("Double-click any row to navigate to that segment in the grid.")
        info_label.setStyleSheet("color: #666; padding: 5px 0;")
        layout.addWidget(info_label)

        # Results table
        table = QTableWidget()
        table.setColumnCount(5)
        table.setHorizontalHeaderLabels(["Seg #", "Source", "Target", "LLM", "Issue"])
        table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        table.horizontalHeader().setStretchLastSection(True)
        table.setColumnWidth(0, 60)
        table.setColumnWidth(1, 180)
        table.setColumnWidth(2, 180)
        table.setColumnWidth(3, 120)

        for row_idx, segment in proofread_segments:
            proofread = getattr(segment, 'proofreading_notes', {})
            for model_name, issue_text in proofread.items():
                table_row = table.rowCount()
                table.insertRow(table_row)

                # Segment number
                table.setItem(table_row, 0, QTableWidgetItem(str(row_idx + 1)))

                # Source (truncated)
                source_text = segment.source[:80] + "..." if len(segment.source) > 80 else segment.source
                table.setItem(table_row, 1, QTableWidgetItem(source_text))

                # Target (truncated)
                target_text = segment.target[:80] + "..." if len(segment.target) > 80 else segment.target
                table.setItem(table_row, 2, QTableWidgetItem(target_text))

                # LLM model name
                table.setItem(table_row, 3, QTableWidgetItem(model_name))

                # Issue text (truncated)
                display_issue = issue_text[:100] + "..." if len(issue_text) > 100 else issue_text
                table.setItem(table_row, 4, QTableWidgetItem(display_issue))

                # Store segment index in row data
                table.item(table_row, 0).setData(Qt.ItemDataRole.UserRole, row_idx)

        # Double-click to navigate
        def on_row_double_clicked(row):
            segment_idx = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
            self.jump_to_segment(segment_idx)
            dialog.lower()  # Send dialog to back so grid is visible

        table.cellDoubleClicked.connect(lambda row, col: on_row_double_clicked(row))

        layout.addWidget(table)

        # Buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()

        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        button_layout.addWidget(close_btn)

        layout.addLayout(button_layout)

        dialog.show()

    def send_segments_to_tm_dialog(self):
        """
        Show dialog to send segments to TM (similar to memoQ's Confirm and Update).
        Allows user to select scope and status filters, then sends matching segments 
        to the writable TM(s).
        """
        if not self.current_project or not self.current_project.segments:
            QMessageBox.warning(self, "No Project", "Please load a project first.")
            return
        
        if not hasattr(self, 'tm_metadata_mgr') or not self.tm_metadata_mgr:
            QMessageBox.warning(self, "No TM Manager", "Translation Memory system is not initialized.")
            return
        
        # Get available TMs
        all_tms = self.tm_metadata_mgr.get_all_tms()
        if not all_tms:
            QMessageBox.warning(self, "No TMs", 
                "No Translation Memories found.\n\nPlease create or import a TM in Resources → Translation Memories.")
            return
        
        # Get writable TMs (those with Write enabled)
        writable_tms = [tm for tm in all_tms if not tm.get('read_only', True)]
        if not writable_tms:
            QMessageBox.warning(self, "No Writable TMs", 
                "No writable Translation Memories found.\n\n"
                "Please enable Write (✓) for at least one TM in Resources → Translation Memories → TM List.")
            return
        
        # Create dialog
        dialog = QDialog(self)
        dialog.setWindowTitle("Send Segments to TM")
        dialog.setMinimumWidth(500)
        dialog.setMinimumHeight(400)
        layout = QVBoxLayout(dialog)
        
        # Info text
        info_label = QLabel(
            "Send translated segments to a Translation Memory.\n"
            "Only segments matching the selected criteria will be sent."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #666; margin-bottom: 10px;")
        layout.addWidget(info_label)
        
        # Scope group
        scope_group = QGroupBox("Scope")
        scope_layout = QVBoxLayout(scope_group)
        
        scope_project = CheckmarkCheckBox("Entire project")
        scope_project.setChecked(True)
        scope_selected = CheckmarkCheckBox("Selected segments only")
        scope_selected.setEnabled(False)  # Will enable if selection exists
        scope_selected.setChecked(False)
        
        # Check if we have a selection
        selected_segments = self.get_selected_segments_from_grid() if hasattr(self, 'get_selected_segments_from_grid') else []
        if selected_segments:
            scope_selected.setEnabled(True)
            scope_selected.setText(f"Selected segments only ({len(selected_segments)} selected)")
        
        # Make scope checkboxes mutually exclusive
        def on_scope_project_changed(checked):
            if checked:
                scope_selected.setChecked(False)
        
        def on_scope_selected_changed(checked):
            if checked:
                scope_project.setChecked(False)
        
        scope_project.toggled.connect(on_scope_project_changed)
        scope_selected.toggled.connect(on_scope_selected_changed)
        
        scope_layout.addWidget(scope_project)
        scope_layout.addWidget(scope_selected)
        layout.addWidget(scope_group)
        
        # Status filter group
        status_group = QGroupBox("Status of segments to send")
        status_layout = QGridLayout(status_group)
        
        # Import status definitions
        from modules.statuses import STATUSES
        
        # Create checkboxes for each status
        status_checkboxes = {}
        statuses_to_show = [
            ("confirmed", "Confirmed", True),       # Default checked
            ("draft", "Draft", False),
            ("proofread", "Proofread", True),       # Default checked
            ("approved", "Approved", True),         # Default checked
            ("pretranslated", "Pre-translated", False),
        ]
        
        row = 0
        col = 0
        for status_key, status_label, default_checked in statuses_to_show:
            status_def = STATUSES.get(status_key)
            if status_def:
                cb = CheckmarkCheckBox(f"{status_def.icon} {status_label}")
                cb.setChecked(default_checked)
                status_checkboxes[status_key] = cb
                status_layout.addWidget(cb, row, col)
                col += 1
                if col >= 2:
                    col = 0
                    row += 1
        
        # Add "All statuses" checkbox
        all_statuses_cb = CheckmarkCheckBox("All statuses (with translation)")
        all_statuses_cb.setChecked(False)
        
        def on_all_statuses_changed(checked):
            for cb in status_checkboxes.values():
                cb.setEnabled(not checked)
        
        all_statuses_cb.toggled.connect(on_all_statuses_changed)
        status_layout.addWidget(all_statuses_cb, row + 1, 0, 1, 2)
        
        layout.addWidget(status_group)
        
        # Target TM group
        tm_group = QGroupBox("Target Translation Memory")
        tm_layout = QVBoxLayout(tm_group)
        
        tm_combo = QComboBox()
        for tm in writable_tms:
            entry_count = tm.get('entry_count', 0)
            tm_combo.addItem(f"{tm['name']} ({entry_count} entries)", tm['id'])
        
        tm_layout.addWidget(tm_combo)
        
        # Note about multiple writable TMs
        if len(writable_tms) > 1:
            multi_note = QLabel(
                f"ℹ️ {len(writable_tms)} writable TMs available. Select which one to send segments to."
            )
            multi_note.setStyleSheet("color: #2196F3; font-size: 10px;")
            tm_layout.addWidget(multi_note)
        
        layout.addWidget(tm_group)
        
        # Options group
        options_group = QGroupBox("Options")
        options_layout = QVBoxLayout(options_group)
        
        overwrite_cb = CheckmarkCheckBox("Update existing entries (overwrite if source matches)")
        overwrite_cb.setChecked(True)
        overwrite_cb.setToolTip("If a segment with the same source text exists, update its target.")
        options_layout.addWidget(overwrite_cb)
        
        skip_empty_cb = CheckmarkCheckBox("Skip segments with empty translations")
        skip_empty_cb.setChecked(True)
        options_layout.addWidget(skip_empty_cb)
        
        layout.addWidget(options_group)
        
        # Preview section
        preview_group = QGroupBox("Preview")
        preview_layout = QVBoxLayout(preview_group)
        preview_label = QLabel("Click 'Preview' to see how many segments will be sent.")
        preview_label.setStyleSheet("color: #888;")
        preview_layout.addWidget(preview_label)
        
        def update_preview():
            # Count matching segments
            segments_to_check = selected_segments if scope_selected.isChecked() else self.current_project.segments
            
            matching = 0
            for seg in segments_to_check:
                # Check if has translation
                if skip_empty_cb.isChecked() and not seg.target.strip():
                    continue
                
                # Check status
                if all_statuses_cb.isChecked():
                    if seg.target.strip():  # Has any translation
                        matching += 1
                else:
                    for status_key, cb in status_checkboxes.items():
                        if cb.isChecked() and seg.status == status_key:
                            matching += 1
                            break
            
            preview_label.setText(f"✓ {matching} segment(s) will be sent to TM")
            preview_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
        
        preview_btn = QPushButton("Preview")
        preview_btn.clicked.connect(update_preview)
        preview_layout.addWidget(preview_btn)
        
        layout.addWidget(preview_group)
        
        # Buttons
        layout.addStretch()
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Send to TM")
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)
        
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        
        # Perform the operation
        target_tm_id = tm_combo.currentData()
        target_tm_name = tm_combo.currentText()
        
        segments_to_check = selected_segments if scope_selected.isChecked() else self.current_project.segments
        
        sent_count = 0
        skipped_count = 0
        error_count = 0
        
        # Get language codes
        source_lang = self.current_project.source_lang or "en"
        target_lang = self.current_project.target_lang or "nl"
        
        self.status_bar.showMessage(f"Sending segments to TM '{target_tm_name}'...")
        QApplication.processEvents()
        
        for seg in segments_to_check:
            try:
                # Check if has translation
                if skip_empty_cb.isChecked() and not seg.target.strip():
                    skipped_count += 1
                    continue
                
                # Check status
                should_send = False
                if all_statuses_cb.isChecked():
                    if seg.target.strip():
                        should_send = True
                else:
                    for status_key, cb in status_checkboxes.items():
                        if cb.isChecked() and seg.status == status_key:
                            should_send = True
                            break
                
                if not should_send:
                    skipped_count += 1
                    continue

                # Strip outer wrapping tags first (like <li-b>, <li-o>, <p>) if setting is enabled
                source_clean = seg.source
                target_clean = seg.target
                if self.hide_outer_wrapping_tags:
                    source_clean, _ = strip_outer_wrapping_tags(source_clean)
                    target_clean, _ = strip_outer_wrapping_tags(target_clean)

                # Then strip formatting tags (like <b>, <i>, <u>)
                source_clean = strip_formatting_tags(source_clean) if has_formatting_tags(source_clean) else source_clean
                target_clean = strip_formatting_tags(target_clean) if has_formatting_tags(target_clean) else target_clean

                # Add to TM using the correct method
                try:
                    self.tm_database.add_entry(
                        source=source_clean,
                        target=target_clean,
                        tm_id=target_tm_id
                    )
                    sent_count += 1
                except Exception as add_error:
                    self.log(f"⚠ Failed to add segment {seg.id}: {add_error}")
                    error_count += 1
                    
            except Exception as e:
                error_count += 1
                self.log(f"⚠ Error sending segment {seg.id} to TM: {e}")
        
        # Update TM entry count in metadata
        if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
            try:
                self.tm_metadata_mgr.update_entry_count(target_tm_id)
            except Exception as e:
                self.log(f"⚠ Could not update TM entry count: {e}")
        
        # Refresh TM list if visible
        if hasattr(self, 'refresh_tm_list'):
            try:
                self.refresh_tm_list()
            except:
                pass
        
        # Report results
        self.log(f"✓ Sent {sent_count} segments to TM '{target_tm_name}'")
        if skipped_count > 0:
            self.log(f"  Skipped: {skipped_count} (empty or non-matching status)")
        if error_count > 0:
            self.log(f"  Errors: {error_count}")
        
        self.status_bar.showMessage(f"✓ Sent {sent_count} segments to TM", 5000)
        
        QMessageBox.information(
            self, "Send to TM Complete",
            f"Sent {sent_count} segment(s) to TM '{target_tm_name}'.\n\n"
            f"Skipped: {skipped_count}\n"
            f"Errors: {error_count}"
        )
    
    def _restore_active_prompt(self, prompt_type: str, prompt_name: str):
        """Restore an active prompt by name"""
        if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
            return
        
        try:
            if prompt_type == 'translate':
                # Find and activate domain prompt
                for i in range(self.prompt_manager_qt.domain_tree.topLevelItemCount()):
                    item = self.prompt_manager_qt.domain_tree.topLevelItem(i)
                    filename = item.data(0, Qt.ItemDataRole.UserRole)
                    if filename:
                        prompt_data = self.prompt_manager_qt.prompt_library.get_prompt(filename)
                        if prompt_data and prompt_data.get('name') == prompt_name:
                            self.prompt_manager_qt.domain_tree.setCurrentItem(item)
                            self.prompt_manager_qt._activate_domain_expertise('translate')
                            break
            elif prompt_type == 'proofread':
                # Find and activate domain prompt
                for i in range(self.prompt_manager_qt.domain_tree.topLevelItemCount()):
                    item = self.prompt_manager_qt.domain_tree.topLevelItem(i)
                    filename = item.data(0, Qt.ItemDataRole.UserRole)
                    if filename:
                        prompt_data = self.prompt_manager_qt.prompt_library.get_prompt(filename)
                        if prompt_data and prompt_data.get('name') == prompt_name:
                            self.prompt_manager_qt.domain_tree.setCurrentItem(item)
                            self.prompt_manager_qt._activate_domain_expertise('proofread')
                            break
            elif prompt_type == 'project':
                # Find and activate project prompt
                for i in range(self.prompt_manager_qt.project_tree.topLevelItemCount()):
                    item = self.prompt_manager_qt.project_tree.topLevelItem(i)
                    filename = item.data(0, Qt.ItemDataRole.UserRole)
                    if filename:
                        prompt_data = self.prompt_manager_qt.prompt_library.get_prompt(filename)
                        if prompt_data and prompt_data.get('name') == prompt_name:
                            self.prompt_manager_qt.project_tree.setCurrentItem(item)
                            self.prompt_manager_qt._activate_project_guideline()
                            break
        except Exception as e:
            self.log(f"⚠ Could not restore {prompt_type} prompt '{prompt_name}': {e}")
    
    def _restore_active_style_guide(self, guide_name: str, language: str):
        """Restore an active style guide by name and language
        
        NOTE: Temporarily disabled for unified prompt library migration.
        TODO: Implement session restoration for unified library (save/restore active prompts)
        """
        if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
            return
        
        # TODO: Adapt this for unified prompt library
        # The new system uses multi-attach, so we'd need to:
        # 1. Find the style guide in library.prompts
        # 2. Attach it using library.attach_prompt(path)
        # For now, skip restoration - user can re-select prompts
        self.log(f"⚠ Style guide restoration not yet implemented in unified library: '{guide_name}' ({language})")
    
    def _schedule_delayed_lookup(self, segment, current_row):
        """Schedule TM and termbase lookup with delay (cancel if user moves to another segment)"""
        self.log(f"🚀 _schedule_delayed_lookup called for segment {current_row + 1}: '{segment.source[:50]}...'")
        
        # Cancel any pending lookup
        if self.lookup_timer:
            self.lookup_timer.stop()
            self.lookup_timer = None
        
        # Store current segment ID for checking if user moved away
        segment_id = id(segment)
        self.current_lookup_segment_id = segment_id
        
        # If matching is disabled, skip
        self.log(f"🔧 SCHEDULING CHECK: TM enabled={self.enable_tm_matching}, Termbase enabled={self.enable_termbase_matching}")
        if not (self.enable_tm_matching or self.enable_termbase_matching):
            self.log(f"🔧 SCHEDULING BLOCKED: Both TM and termbase matching are disabled")
            return
        self.log(f"🔧 SCHEDULING OK: At least one matching type is enabled")
        
        # Get user-configured delay (default 1.5 seconds)
        general_prefs = self.load_general_settings()
        lookup_delay = general_prefs.get('lookup_delay', 1500)
        
        # Schedule lookup after user-configured delay (allows user to navigate quickly without triggering searches)
        from PyQt6.QtCore import QTimer
        self.lookup_timer = QTimer()
        self.lookup_timer.setSingleShot(True)
        # CRITICAL: Capture variables by value using default parameters to avoid closure bugs
        self.lookup_timer.timeout.connect(lambda seg=segment, row=current_row, seg_id=segment_id: self._perform_delayed_lookup(seg, row, seg_id))
        self.lookup_timer.start(lookup_delay)
        self.log(f"🚀 _schedule_delayed_lookup: Will search segment {current_row + 1} in {lookup_delay}ms")
    
    def _perform_delayed_lookup(self, segment, current_row, expected_segment_id):
        """Perform TM and termbase lookup after delay (only if user is still on same segment)"""
        self.log(f"� DELAYED LOOKUP STARTED for segment {current_row + 1}: '{segment.source[:50]}...'")
        self.log(f"🚨 DELAYED LOOKUP: expected_id={expected_segment_id}, current_id={self.current_lookup_segment_id}")
        
        # Check if user moved to another segment
        if self.current_lookup_segment_id != expected_segment_id:
            self.log(f"� DELAYED LOOKUP CANCELLED: User moved to different segment")
            return  # User moved away, cancel lookup
            
        self.log(f"🚨 DELAYED LOOKUP CONTINUING: User still on same segment")
        
        # Check if still on the same row
        if current_row < 0 or current_row >= self.table.rowCount():
            return
        
        current_segment = self.table.item(current_row, 2)  # Source column
        if not current_segment or current_segment.text() != segment.source:
            return  # Segment changed
        
        try:
            # Update TM matches (if enabled)
            if self.enable_tm_matching:
                try:
                    self.search_and_display_tm_matches(segment.source)
                except Exception as e:
                    self.log(f"Error searching TM: {e}")
            
            # Update TranslationResultsPanel if available
            if hasattr(self, 'assistance_widget') and hasattr(self.assistance_widget, 'set_segment_info'):
                try:
                    # Generate matches dictionary for the panel
                    from modules.translation_results_panel import TranslationMatch
                    
                    matches_dict = {
                        "LLM": [],     # LLM Translation (appears first)
                        "NT": [],      # No Translation
                        "MT": [],      # Machine Translation
                        "TM": [],      # Translation Memory
                        "Termbases": [], # Terminology
                        "NonTrans": []  # Non-translatables
                    }
                    
                    # Generate TM matches from database if available (and enabled)
                    self.log(f"� DEBUG: Reached TM search section in _perform_delayed_lookup")
                    self.log(f"�🔍 TM SEARCH CHECK: TM enabled={self.enable_tm_matching}, has_db_manager={hasattr(self, 'db_manager')}, db_manager_valid={self.db_manager is not None if hasattr(self, 'db_manager') else False}")
                    if self.enable_tm_matching and hasattr(self, 'db_manager') and self.db_manager:
                        try:
                            self.log(f"🟢 TM SEARCH: Searching for matches for '{segment.source[:50]}...'")
                            
                            # Get current project languages
                            source_lang = getattr(self.current_project, 'source_lang', None) if self.current_project else None
                            target_lang = getattr(self.current_project, 'target_lang', None) if self.current_project else None
                            
                            # Convert language names to codes if needed
                            if source_lang:
                                source_lang = self._convert_language_to_code(source_lang)
                            if target_lang:
                                target_lang = self._convert_language_to_code(target_lang)
                            
                            self.log(f"🟢 TM SEARCH: Project languages: {source_lang} → {target_lang}")
                            
                            # Search in primary direction (current project direction)
                            tm_matches = self.db_manager.search_all(segment.source, max_results=10)
                            
                            # Also search in reverse direction (bidirectional TM matching)
                            # This is crucial for reusing TM in opposite direction
                            reverse_matches = []
                            if source_lang and target_lang and source_lang != target_lang:
                                try:
                                    # Search for segment.source as target text in reverse direction
                                    reverse_query = """
                                        SELECT source_text, target_text, source_lang, target_lang, tm_id, usage_count
                                        FROM translation_units 
                                        WHERE target_text = ? AND source_lang = ? AND target_lang = ?
                                        ORDER BY usage_count DESC
                                        LIMIT 5
                                    """
                                    self.db_manager.cursor.execute(reverse_query, (segment.source, target_lang, source_lang))
                                    reverse_rows = self.db_manager.cursor.fetchall()
                                    
                                    for row in reverse_rows:
                                        reverse_matches.append({
                                            'source': row['target_text'],  # Swap source/target for reverse match
                                            'target': row['source_text'],
                                            'match_pct': 95,  # High relevance for reverse exact match
                                            'tm_name': f"{row['tm_id'].replace('_', ' ').title()} (Reverse)",
                                            'tm_id': row['tm_id'],
                                            # v1.10.51: explicit flag in addition to the legacy
                                            # "(Reverse)" name suffix, so the Match Panel chip
                                            # works for this older code path too. This whole
                                            # block is now functionally redundant with the
                                            # bidirectional support in db.get_exact_match /
                                            # get_exact_matches_batch, but is left in place
                                            # until the call site is refactored.
                                            'reverse_match': True,
                                        })
                                    
                                    self.log(f"🟢 TM SEARCH: Found {len(reverse_matches)} reverse matches")
                                except Exception as e:
                                    self.log(f"Error in reverse TM search: {e}")
                            
                            # Combine primary and reverse matches
                            all_tm_matches = tm_matches + reverse_matches
                            
                            self.log(f"🟢 TM SEARCH: Total matches found: {len(all_tm_matches)} ({len(tm_matches)} primary + {len(reverse_matches)} reverse)")
                            
                            for match in all_tm_matches:
                                match_obj = TranslationMatch(
                                    source=match.get('source', ''),
                                    target=match.get('target', ''),
                                    relevance=match.get('match_pct', 0),
                                    metadata={
                                        'context': match.get('context', ''),
                                        'tm_name': match.get('tm_name', ''),
                                        'tm_id': match.get('tm_id', ''),
                                        'timestamp': match.get('created_at', ''),
                                        'direction': 'reverse' if 'Reverse' in match.get('tm_name', '') else 'primary'
                                    },
                                    match_type='TM',
                                    compare_source=match.get('source', ''),
                                    provider_code='TM'
                                )
                                matches_dict["TM"].append(match_obj)
                        except Exception as e:
                            self.log(f"Error loading TM matches: {e}")
                    
                    # Add termbase matches to the results panel (if enabled)
                    if self.enable_termbase_matching:
                        try:
                            if hasattr(self, 'db_manager') and self.db_manager:
                                # Search termbases directly to get full information including termbase_id
                                source_lang = getattr(self.current_project, 'source_lang', None) if self.current_project else 'en'  # Default to English
                                target_lang = getattr(self.current_project, 'target_lang', None) if self.current_project else 'nl'  # Default to Dutch
                                
                                # Debug logging
                                self.log(f"🔍 Termbase matching: segment='{segment.source}', langs={source_lang}->{target_lang}, project={self.current_project is not None}")
                                self.log(f"🔍 TM matching enabled: {self.enable_tm_matching}, TM database: {hasattr(self, 'tm_database') and self.tm_database is not None}")
                                project_id_raw = getattr(self.current_project, 'id', None) if (self.current_project and hasattr(self.current_project, 'id')) else None
                                
                                # Convert project_id to string if needed (database stores as TEXT)
                                project_id = str(project_id_raw) if project_id_raw is not None else None
                                
                                # Search for all terms in the source text
                                if segment.source:
                                    # First try exact phrase match, then fall back to word-by-word
                                    termbase_results_by_term = {}
                                    
                                    # Try exact phrase match first (whole source text)
                                    try:
                                        self.log("🟢 TERMBASE CALL: From delayed lookup (_delayed_lookup)")
                                        self.log(f"🔍 Searching termbases for exact phrase: '{segment.source.strip()}'")
                                        exact_results = self.db_manager.search_termbases(
                                            segment.source.strip(),
                                            source_lang=source_lang,
                                            target_lang=target_lang,
                                            project_id=project_id,
                                            min_length=len(segment.source.strip())
                                        )
                                        self.log(f"🔍 Exact phrase results: {len(exact_results)} matches")
                                        for tb_match in exact_results:
                                            source_term = tb_match.get('source_term', '').strip()
                                            target_term = tb_match.get('target_term', '').strip()
                                            termbase_id_raw = tb_match.get('termbase_id')
                                            
                                            try:
                                                termbase_id = int(termbase_id_raw) if termbase_id_raw else None
                                            except (ValueError, TypeError):
                                                termbase_id = termbase_id_raw
                                            
                                            if source_term and target_term and termbase_id:
                                                key = (source_term, target_term, termbase_id)
                                                if key not in termbase_results_by_term:
                                                    termbase_results_by_term[key] = tb_match
                                    except Exception as exact_error:
                                        self.log(f"Error in exact phrase search: {exact_error}")
                                    
                                    # Then search word-by-word for partial matches
                                    words = segment.source.split()
                                    for word in words:
                                        clean_word = word.strip('.,!?;:')
                                        if len(clean_word) < 2:
                                            continue
                                        
                                        try:
                                            tb_results = self.db_manager.search_termbases(
                                                clean_word,
                                                source_lang=source_lang,
                                                target_lang=target_lang,
                                                project_id=project_id,
                                                min_length=2
                                            )
                                            
                                            for tb_match in tb_results:
                                                source_term = tb_match.get('source_term', '').strip()
                                                target_term = tb_match.get('target_term', '').strip()
                                                termbase_id_raw = tb_match.get('termbase_id')
                                                
                                                # Convert termbase_id to int if it's stored as string
                                                try:
                                                    termbase_id = int(termbase_id_raw) if termbase_id_raw else None
                                                except (ValueError, TypeError):
                                                    termbase_id = termbase_id_raw  # Keep as-is if conversion fails
                                                
                                                if source_term and target_term and termbase_id:
                                                    # Store with termbase_id as key to avoid duplicates
                                                    key = (source_term, target_term, termbase_id)
                                                    if key not in termbase_results_by_term:
                                                        termbase_results_by_term[key] = tb_match
                                        except Exception as search_error:
                                            self.log(f"Error searching termbases for '{clean_word}': {search_error}")
                                            continue
                                    
                                    # Get termbase names for IDs found
                                    termbase_code_map = self.get_termbase_code_map()
                                    
                                    # Create TranslationMatch objects
                                    for (source_term, target_term, termbase_id), tb_match in termbase_results_by_term.items():
                                        try:
                                            # Get termbase code (or generate from name)
                                            termbase_code = self.get_termbase_code(termbase_id, termbase_code_map)
                                            
                                            # Get termbase ranking and is_project_termbase
                                            termbase_ranking = tb_match.get('ranking', None)  # From JOIN
                                            is_project_termbase = tb_match.get('is_project_termbase', False)
                                            
                                            match_obj = TranslationMatch(
                                                source=source_term,
                                                target=target_term,
                                                relevance=95,  # High relevance for termbase matches
                                                metadata={
                                                    'term_id': tb_match.get('id'),  # Term entry ID for editing
                                                    'termbase_id': termbase_id,
                                                    'termbase_name': tb_match.get('termbase_name', 'Unknown'),
                                                    'ranking': termbase_ranking,
                                                    'is_project_termbase': is_project_termbase,
                                                    'domain': tb_match.get('domain', ''),
                                                    'notes': tb_match.get('notes', ''),
                                                    'project': tb_match.get('project', ''),
                                                    'client': tb_match.get('client', ''),
                                                    'forbidden': tb_match.get('forbidden', False),
                                                    'target_synonyms': tb_match.get('target_synonyms', [])
                                                },
                                                match_type='Termbase',
                                                compare_source=source_term,
                                                provider_code=termbase_code
                                            )
                                            matches_dict["Termbases"].append(match_obj)
                                        except Exception as match_error:
                                            import traceback
                                            self.log(f"❌ Error creating termbase match: {match_error}")
                                            self.log(f"Traceback: {traceback.format_exc()}")
                                            continue
                                    
                                    if termbase_results_by_term:
                                        num_created = len(matches_dict["Termbases"])
                                        self.log(f"✓ Found {len(termbase_results_by_term)} termbase matches, created {num_created} TranslationMatch objects")
                                    else:
                                        self.log(f"ℹ️ No termbase matches found for segment: '{segment.source[:50]}...'")
                                else:
                                    self.log("⚠️ No source text in segment for termbase search")
                        
                            self.log("🎯 CHECKPOINT: Termbase search completed, about to continue to set_matches...")
                        except Exception as e:
                            import traceback
                            self.log(f"❌ Error adding termbase matches: {e}")
                            self.log(f"Traceback: {traceback.format_exc()}")
                    
                    self.log("🎯 CHECKPOINT: About to fetch LLM and MT, then call set_matches...")
                    
                    # Fetch LLM translation for current segment (async, updates panel when ready)
                    self._fetch_llm_translation_async(segment.source, segment, current_row)
                    
                    # Fetch MT translation for current segment (async, updates panel when ready)
                    self._fetch_mt_translation_async(segment.source, segment, current_row)
                    
                    # Display matches (LLM and MT will be added when ready)
                    total_matches = sum(len(matches) for matches in matches_dict.values())
                    self.log(f"🔍 Calling set_matches() with {total_matches} total matches:")
                    for match_type, matches in matches_dict.items():
                        if matches:
                            self.log(f"  {match_type}: {len(matches)} matches")
                            if match_type == "Termbases" and matches:
                                for i, match in enumerate(matches[:3]):  # Show first 3 for debugging
                                    self.log(f"    [{i}] {match.source} → {match.target} (relevance: {match.relevance})")
                    
                    # Update all tabbed results panels
                    if hasattr(self, 'results_panels') and self.results_panels:
                        self.log(f"✅ Updating {len(self.results_panels)} results panels")
                        for panel in self.results_panels:
                            try:
                                panel.set_matches(matches_dict)
                            except Exception as e:
                                self.log(f"Error updating panel: {e}")
                        self.log(f"✅ All panels updated successfully")
                    else:
                        self.log(f"❌ results_panels missing or empty: {getattr(self, 'results_panels', 'NOT_SET')}")
                except Exception as e:
                    self.log(f"Error updating TranslationResultsPanel: {e}")
        except Exception as e:
            self.log(f"Error in delayed lookup: {e}")
    
    def toggle_tm_termbase_matching(self, enabled: bool):
        """Toggle TM and termbase matching on/off"""
        self.enable_tm_matching = enabled
        self.enable_termbase_matching = enabled

        # Update checkbox in settings if it exists (prevents circular updates)
        if hasattr(self, 'tm_matching_checkbox') and self.tm_matching_checkbox:
            # Temporarily disconnect to prevent signal loop
            self.tm_matching_checkbox.blockSignals(True)
            self.tm_matching_checkbox.setChecked(enabled)
            self.tm_matching_checkbox.blockSignals(False)

        # Update all segment editor toggle buttons (Grid, List, Document views)
        if hasattr(self, 'tabbed_panels') and self.tabbed_panels:
            for tabs in self.tabbed_panels:
                if hasattr(tabs, 'editor_widget') and hasattr(tabs.editor_widget, 'tm_toggle_btn'):
                    btn = tabs.editor_widget.tm_toggle_btn
                    btn.blockSignals(True)
                    btn.setChecked(enabled)
                    btn.setText("🔍 TM/Termbase ON" if enabled else "🚫 TM/Termbase OFF")
                    btn.blockSignals(False)

        if enabled:
            self.log("✓ TM and Termbase matching enabled")
            # If a segment is currently selected, trigger lookup
            if hasattr(self, 'table') and self.table and hasattr(self, 'current_project') and self.current_project:
                current_row = self.table.currentRow()
                if current_row >= 0 and current_row < len(self.current_project.segments):
                    segment = self.current_project.segments[current_row]
                    self._schedule_delayed_lookup(segment, current_row)
        else:
            self.log("⚠ TM and Termbase matching disabled")
            # Cancel any pending lookup
            if self.lookup_timer:
                self.lookup_timer.stop()
                self.lookup_timer = None
    
    
    def on_cell_clicked(self, item: QTableWidgetItem):
        """Handle cell click - Status column clicks are disabled (use Segment Editor instead)"""
        self.log(f"🖱️ on_cell_clicked called: row {item.row()}, col {item.column()}")
        if not self.current_project:
            return

        row = item.row()
        col = item.column()
        
        # Reset selection anchor on normal click (so next Shift+PageUp/Down starts fresh)
        # Note: We set it to the clicked row, so Shift+Page will extend from here
        self._selection_anchor_row = row

        # Status column (4) - clicks disabled, use Segment Editor to change status
        # This prevents visual issues and unwanted status changes
        if col == 4:
            self.log(f"Status column click ignored - use Segment Editor to change status")
            return

    def on_cell_double_clicked(self, row: int, col: int):
        """Handle double-click on a cell - remove highlight widget and start editing if needed"""
        self.log(f"🖱️ on_cell_double_clicked called: row {row}, col {col}")

        # Check if this cell has a highlight widget (ClickableHighlightLabel)
        cell_widget = self.table.cellWidget(row, col)
        if cell_widget and isinstance(cell_widget, ClickableHighlightLabel):
            self.log(f"🖱️ Removing highlight widget and starting edit for row {row}, col {col}")

            # Get the plain text stored in the widget
            plain_text = cell_widget.plain_text

            # Get or create the underlying QTableWidgetItem
            item = self.table.item(row, col)
            if item is None:
                item = QTableWidgetItem(plain_text)
                item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
                self.table.setItem(row, col, item)
            else:
                # Restore the plain text and ensure it's editable
                item.setText(plain_text)
                item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)

            # Remove the cell widget
            self.table.removeCellWidget(row, col)

            # Start editing
            self.table.setCurrentCell(row, col)
            self.table.editItem(item)

    # ========================================================================
    # TRANSLATION MEMORY
    # ========================================================================
    
    def initialize_tm_database(self):
        """Initialize TM database when project is loaded"""
        if not self.current_project:
            return
        
        try:
            # Update TM database languages for the current project
            # (tm_database is already initialized at startup)
            if self.tm_database:
                self.tm_database.set_tm_languages(
                    self.current_project.source_lang,
                    self.current_project.target_lang
                )
            
            # Update Superlookup tab with TM database
            if hasattr(self, 'lookup_tab') and self.lookup_tab:
                self.lookup_tab.set_tm_database(self.tm_database)
            
            self.log(f"TM database configured ({self.current_project.source_lang} → {self.current_project.target_lang})")
        except Exception as e:
            self.log(f"Warning: Could not configure TM database: {e}")
            self.tm_database = None
    
    def _initialize_spellcheck_for_target_language(self, target_lang: str):
        """Initialize spellcheck for the given target language after import/load"""
        if not target_lang:
            return
        
        try:
            if hasattr(self, 'spellcheck_manager') and self.spellcheck_manager:
                if self.spellcheck_manager.set_language(target_lang):
                    self.log(f"✓ Spellcheck initialized for target language: {target_lang}")
                else:
                    self.log(f"⚠ Spellcheck dictionary not available for {target_lang}")
        except Exception as e:
            self.log(f"⚠ Could not initialize spellcheck: {e}")
    
    def _check_reimport_same_file(self, file_path: str, source_path_attrs: list) -> tuple:
        """Check if importing a file that's already loaded in the current project.

        Args:
            file_path: Path to the file being imported
            source_path_attrs: List of attribute names to check (e.g., ['memoq_source_path', 'current_document_path'])

        Returns:
            Tuple of (reimport_mode, preserved_project_id, preserved_tm_settings, preserved_termbase_settings)
            If user cancels, returns (None, None, None, None) to signal cancellation.
        """
        if not self.current_project:
            return (False, None, None, None)

        # Check all possible source path attributes
        current_source = None
        for attr in source_path_attrs:
            if attr.startswith('current_project.'):
                # Check attribute on project object
                proj_attr = attr.replace('current_project.', '')
                current_source = getattr(self.current_project, proj_attr, None)
            else:
                # Check attribute on self
                current_source = getattr(self, attr, None)
            if current_source:
                break

        if not current_source:
            return (False, None, None, None)

        # Normalize paths for comparison
        if os.path.normpath(file_path) != os.path.normpath(current_source):
            return (False, None, None, None)

        # Same file - ask user what they want to do
        dialog = QMessageBox(self)
        dialog.setWindowTitle("Re-import Document")
        dialog.setText(f"This file is already loaded in the current project:\n\n{os.path.basename(file_path)}")
        dialog.setInformativeText("Do you want to re-import it into the current project (keeping TM and termbase settings), or create a new project?")

        reimport_btn = dialog.addButton("Re-import into Current Project", QMessageBox.ButtonRole.AcceptRole)
        new_proj_btn = dialog.addButton("Create New Project", QMessageBox.ButtonRole.ActionRole)
        cancel_btn = dialog.addButton(QMessageBox.StandardButton.Cancel)

        dialog.exec()

        clicked = dialog.clickedButton()
        if clicked == cancel_btn:
            return (None, None, None, None)  # Signal cancellation
        elif clicked == reimport_btn:
            # Preserve project ID and settings
            preserved_id = self.current_project.id
            preserved_tm = getattr(self.current_project, 'tm_settings', None)
            preserved_tb = getattr(self.current_project, 'termbase_settings', None)
            self.log(f"📋 Re-importing into current project (preserving TM settings)")
            return (True, preserved_id, preserved_tm, preserved_tb)
        else:
            # Create new project
            return (False, None, None, None)

    def _apply_reimport_settings(self, project, reimport_mode: bool, preserved_id, preserved_tm, preserved_tb):
        """Apply preserved settings to project if in reimport mode.

        Args:
            project: The Project object to modify
            reimport_mode: Whether this is a re-import
            preserved_id: The project ID to preserve
            preserved_tm: The tm_settings to preserve
            preserved_tb: The termbase_settings to preserve
        """
        if reimport_mode and preserved_id is not None:
            project.id = preserved_id
            if preserved_tm:
                project.tm_settings = preserved_tm
            if preserved_tb:
                project.termbase_settings = preserved_tb
            self.log(f"✓ Preserved project ID: {preserved_id}")

    def _clear_caches_after_import(self):
        """Clear translation and termbase caches after import."""
        with self.translation_matches_cache_lock:
            self.translation_matches_cache.clear()
        self.termbase_cache.clear()

    def _deactivate_all_resources_for_new_project(self):
        """Ensure new projects start with a clean slate - no TMs or termbases selected.

        This is called when importing a new document to create a fresh project.
        By design, users should explicitly choose which TMs and glossaries to use
        for each project, rather than inheriting selections from previous work.
        """
        if not self.current_project or not hasattr(self.current_project, 'id'):
            return

        project_id = self.current_project.id
        if not project_id:
            return

        # Deactivate all TMs for this project (clean slate: Read OFF + Write OFF)
        if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
            all_tms = self.tm_metadata_mgr.get_all_tms()
            if all_tms:
                for tm in all_tms:
                    self.tm_metadata_mgr.deactivate_tm(tm['id'], project_id)
                    self.tm_metadata_mgr.set_read_only(tm['id'], True)  # Write OFF

        # Deactivate all termbases for this project (clean slate: Read OFF + Write OFF)
        if hasattr(self, 'termbase_mgr') and self.termbase_mgr:
            all_termbases = self.termbase_mgr.get_all_termbases()
            if all_termbases:
                for tb in all_termbases:
                    self.termbase_mgr.deactivate_termbase(tb['id'], project_id)
                    self.termbase_mgr.set_termbase_read_only(tb['id'], True)  # Write OFF

        # The legacy "deactivate NT lists" step was removed in v1.9.393
        # along with the standalone Non-Translatables tab – NTs now live
        # on termbase entries and follow the termbase activation state.

        # Refresh TM and termbase UI to reflect the deactivations
        if hasattr(self, 'tm_tab_refresh_callback') and self.tm_tab_refresh_callback:
            self.tm_tab_refresh_callback()
        if hasattr(self, 'termbase_tab_refresh_callback') and self.termbase_tab_refresh_callback:
            self.termbase_tab_refresh_callback()

    def search_and_display_tm_matches(self, source_text: str):
        """Search TM and Termbases and display matches with visual diff for fuzzy matches.

        If hide_outer_wrapping_tags is enabled, strips structural tags (like <li-o>, <p>)
        from the query before searching for better TM matching.
        """
        self.log(f"🚨 search_and_display_tm_matches called with source_text: '{source_text[:50]}...'")
        if not source_text or not source_text.strip():
            if hasattr(self, 'tm_display'):
                self.tm_display.clear()
            return

        # Strip outer structural tags if setting is enabled (for cleaner TM matching)
        if self.hide_outer_wrapping_tags:
            source_text, _ = strip_outer_wrapping_tags(source_text)
        
        # Initialize TM if not already done
        if not self.tm_database and self.current_project:
            self.initialize_tm_database()
        
        if not self.tm_database:
            if hasattr(self, 'tm_display'):
                self.tm_display.setHtml(
                    "<p style='color: #999;'><i>Translation Memory not available</i></p>"
                )
            return
        
        try:
            # Get activated TM IDs for current project
            tm_ids = None
            no_tms_activated = False
            tms_wrong_language = False
            if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                if project_id:
                    tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
                    # Check if no TMs are activated for this project
                    if tm_ids is not None and len(tm_ids) == 0:
                        no_tms_activated = True
                    elif tm_ids and len(tm_ids) > 0:
                        # Check if any activated TMs match the project's language pair
                        project_source = (self.current_project.source_lang or '').lower()
                        project_target = (self.current_project.target_lang or '').lower()
                        all_tms = self.tm_metadata_mgr.get_all_tms()
                        has_matching_language = False
                        for tm in all_tms:
                            if tm['id'] in tm_ids:
                                tm_source = (tm.get('source_lang') or '').lower()
                                tm_target = (tm.get('target_lang') or '').lower()
                                # Match if languages align (bidirectional) or TM has no language set
                                if (not tm_source and not tm_target) or \
                                   (tm_source == project_source and tm_target == project_target) or \
                                   (tm_source == project_target and tm_target == project_source):
                                    has_matching_language = True
                                    break
                        if not has_matching_language:
                            tms_wrong_language = True

            # Search for matches (using activated TMs if available)
            matches = self.tm_database.search_all(source_text, tm_ids=tm_ids, max_matches=5)
            
            # Auto-propagate exact match if enabled (check both settings)
            general_prefs = self.load_general_settings()
            auto_propagate_100 = general_prefs.get('auto_propagate_100', True)
            auto_propagate_enabled = self.auto_propagate_exact_matches or auto_propagate_100
            
            if matches and auto_propagate_enabled:
                first_match = matches[0]
                match_pct = first_match.get('match_pct', 0)
                
                # Check if we have a 100% match
                if match_pct == 100:
                    # Check if current segment is empty/untranslated
                    if hasattr(self, 'table') and hasattr(self, 'current_project'):
                        current_row = self.table.currentRow()
                        if current_row >= 0 and current_row < len(self.current_project.segments):
                            segment = self.current_project.segments[current_row]
                            
                            # Only auto-propagate if segment is empty or untranslated
                            if (not segment.target or not segment.target.strip() or 
                                segment.status in ["untranslated", "not_started", ""]):
                                
                                # Fill the segment
                                segment.target = first_match.get('target', '')
                                segment.status = "draft"
                                segment.modified = True
                                self.project_modified = True
                                
                                # Update grid (using cell widget)
                                target_widget = self.table.cellWidget(current_row, 3)
                                if target_widget and isinstance(target_widget, EditableGridTextEditor):
                                    # Strip outer wrapping tags if setting is enabled
                                    display_text = segment.target
                                    if self.hide_outer_wrapping_tags:
                                        display_text, _ = strip_outer_wrapping_tags(display_text)
                                    target_widget.setPlainText(display_text)

                                # Update status column
                                status_item = self.table.item(current_row, 1)
                                if status_item:
                                    status_item.setText("Translated")
                                    status_item.setBackground(QColor("#d1fae5"))  # Light green for translated

                                # Update project modified status (progress is handled by status bar)
                                self.log(f"✨ Auto-propagated 100% TM match for segment #{current_row + 1}")
            
            if not matches:
                if hasattr(self, 'tm_display'):
                    if no_tms_activated:
                        # Show helpful message when no TMs are activated
                        self.tm_display.setHtml(
                            f"<p style='color: #666;'><b>Source:</b> {source_text}</p>"
                            f"<p style='color: #E65100;'><i>No TMs activated for this project.</i></p>"
                            f"<p style='color: #999; font-size: 9pt;'>Go to <b>Resources → TM</b> to activate translation memories.</p>"
                        )
                    elif tms_wrong_language:
                        # Show message when TMs are activated but don't match project language pair
                        project_lang_pair = f"{self.current_project.source_lang} → {self.current_project.target_lang}" if self.current_project else ""
                        self.tm_display.setHtml(
                            f"<p style='color: #666;'><b>Source:</b> {source_text}</p>"
                            f"<p style='color: #E65100;'><i>Activated TMs don't match project language ({project_lang_pair}).</i></p>"
                            f"<p style='color: #999; font-size: 9pt;'>Go to <b>Resources → TM</b> to activate TMs for this language pair.</p>"
                        )
                    else:
                        self.tm_display.setHtml(
                            f"<p style='color: #666;'><b>Source:</b> {source_text}</p>"
                            f"<p style='color: #999;'><i>No translation memory matches found</i></p>"
                        )
                return
            
            # If using TranslationResultsPanel, populate it with TM and Termbase results
            if hasattr(self.assistance_widget, 'set_matches'):
                try:
                    from modules.translation_results_panel import TranslationMatch
                    
                    # Convert TM matches with full TM metadata
                    tm_matches = []
                    for match in matches:
                        # Get full TM metadata from tm_metadata_mgr if available
                        tm_metadata = {
                            'tm_name': match.get('tm_name', 'Unknown TM'),
                            'tm_id': match.get('tm_id', ''),
                            'context': match.get('context', '')
                        }
                        
                        # Fetch full TM details if tm_metadata_mgr is available
                        if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
                            tm_id_str = match.get('tm_id', '')
                            if tm_id_str:
                                # Find TM by tm_id string (not database ID)
                                all_tms = self.tm_metadata_mgr.get_all_tms()
                                for tm in all_tms:
                                    if tm['tm_id'] == tm_id_str:
                                        tm_metadata.update({
                                            'source_lang': tm.get('source_lang', ''),
                                            'target_lang': tm.get('target_lang', ''),
                                            'entry_count': tm.get('entry_count', 0),
                                            'modified_date': tm.get('modified_date', ''),
                                            'description': tm.get('description', '')
                                        })
                                        break
                        
                        tm_match = TranslationMatch(
                            source=match.get('source', ''),
                            target=match.get('target', ''),
                            relevance=int(match.get('match_pct', 0)),
                            metadata=tm_metadata,
                            match_type='TM',
                            compare_source=match.get('source', '')
                        )
                        tm_matches.append(tm_match)
                    
                    # Search for termbase matches
                    termbase_matches = []
                    try:
                        if hasattr(self, 'db_manager') and self.db_manager:
                            # Get current project language pair
                            source_lang = None
                            target_lang = None
                            project_id = None
                            
                            if self.current_project:
                                # current_project is a Project object, not a dict
                                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                                source_lang = self.current_project.source_lang if hasattr(self.current_project, 'source_lang') else None
                                target_lang = self.current_project.target_lang if hasattr(self.current_project, 'target_lang') else None
                            
                            # Search for individual words in the source text
                            words = source_text.split()
                            searched_terms = set()  # Track what we've already searched
                            
                            for word in words:
                                # Remove punctuation
                                clean_word = word.strip('.,!?;:')
                                if len(clean_word) < 2 or clean_word in searched_terms:
                                    continue
                                
                                searched_terms.add(clean_word)
                                
                                # Search termbases for this word
                                tb_results = self.db_manager.search_termbases(
                                    clean_word,
                                    source_lang=source_lang,
                                    target_lang=target_lang,
                                    project_id=project_id,
                                    min_length=2
                                )
                                
                                # Convert termbase results to TranslationMatch objects
                                source_text_lower = source_text.lower()
                                import re
                                for tb_match in tb_results:
                                    source_term_full = tb_match.get('source_term', '')
                                    target_term_full = tb_match.get('target_term', '')
                                    if not source_term_full or not target_term_full:
                                        continue
                                    # Require full phrase presence in segment (word-boundary)
                                    pattern = re.compile(r"\b" + re.escape(source_term_full.lower()) + r"\b")
                                    if not pattern.search(source_text_lower):
                                        continue
                                    # Deduplicate by term_id
                                    term_id = tb_match.get('id')
                                    if any(m.metadata.get('term_id') == term_id for m in termbase_matches):
                                        continue
                                    termbase_match = TranslationMatch(
                                        source=source_term_full,
                                        target=target_term_full,
                                        relevance=95,
                                        metadata={
                                            'term_id': term_id,
                                            'termbase_id': tb_match.get('termbase_id'),
                                            'termbase_name': tb_match.get('termbase_name', ''),
                                            'ranking': tb_match.get('ranking', None),
                                            'domain': tb_match.get('domain', ''),
                                            'notes': tb_match.get('notes', ''),
                                            'project': tb_match.get('project', ''),
                                            'client': tb_match.get('client', ''),
                                            'forbidden': tb_match.get('forbidden', False),
                                            'is_project_termbase': tb_match.get('is_project_termbase', False),
                                            'target_synonyms': tb_match.get('target_synonyms', [])
                                        },
                                        match_type='Termbase',
                                        compare_source=source_term_full
                                    )
                                    termbase_matches.append(termbase_match)
                    except Exception as e:
                        self.log(f"Error searching termbases: {e}")

                    
                    # Update the panel with both TM and Termbase matches
                    matches_dict = {}
                    if tm_matches:
                        matches_dict['TM'] = tm_matches
                    if termbase_matches:
                        matches_dict['Termbases'] = termbase_matches
                    
                    if matches_dict and hasattr(self, 'results_panels'):
                        for panel in self.results_panels:
                            try:
                                panel.set_matches(matches_dict)
                            except Exception as e:
                                self.log(f"Error updating panel: {e}")
                    return
                except Exception as e:
                    self.log(f"Error populating TranslationResultsPanel: {e}")
            
            # Fallback: display in tm_display if it exists
            if not hasattr(self, 'tm_display'):
                return
            
            # Build HTML display with diff highlighting
            html = f"<div style='font-family: {self.default_font_family}; font-size: {self.default_font_size}pt;'>"
            html += f"<p style='color: #666; margin-bottom: 10px;'><b>🔍 Searching for:</b><br>{source_text}</p>"
            html += "<hr style='border: 1px solid #ddd;'>"
            
            for idx, match in enumerate(matches, start=1):
                match_pct = match.get('match_pct', 0)
                tm_name = match.get('tm_name', 'Unknown TM')
                source_match = match.get('source', '')
                target_match = match.get('target', '')
                
                # Color based on match percentage
                if match_pct == 100:
                    color = "#22c55e"  # Green for exact match
                    badge_style = "background-color: #22c55e; color: white;"
                elif match_pct >= 95:
                    color = "#3b82f6"  # Blue for high match
                    badge_style = "background-color: #3b82f6; color: white;"
                elif match_pct >= 85:
                    color = "#f59e0b"  # Orange for medium match
                    badge_style = "background-color: #f59e0b; color: white;"
                else:
                    color = "#ef4444"  # Red for low match
                    badge_style = "background-color: #ef4444; color: white;"
                
                html += f"<div style='margin: 10px 0; padding: 10px; background-color: #f9fafb; border-left: 4px solid {color}; border-radius: 4px;'>"
                html += f"<div style='margin-bottom: 5px;'>"
                html += f"<span style='padding: 2px 8px; border-radius: 3px; font-weight: bold; font-size: 10pt; {badge_style}'>{match_pct}%</span> "
                html += f"<span style='color: #666; font-size: 9pt;'>{tm_name}</span>"
                html += "</div>"
                
                # Display source with diff if not 100% match
                if match_pct < 100:
                    diff_html = self.create_diff_html(source_text, source_match)
                    html += f"<p style='margin: 5px 0; color: #444;'><b>TM Source:</b><br>{diff_html}</p>"
                else:
                    html += f"<p style='margin: 5px 0; color: #444;'><b>TM Source:</b><br>{source_match}</p>"
                
                # Display target (clickable to insert)
                html += f"<p style='margin: 5px 0; color: #059669; font-weight: bold;'><b>TM Target:</b><br>"
                html += f"<span style='cursor: pointer; text-decoration: underline;' "
                html += f"onclick='alert(\"{target_match}\")'>{target_match}</span></p>"
                html += "</div>"
            
            html += "</div>"
            self.tm_display.setHtml(html)
            
        except Exception as e:
            self.log(f"Error searching TM: {e}")
            if hasattr(self, 'tm_display'):
                self.tm_display.setHtml(
                    f"<p style='color: #ef4444;'><b>Error:</b> {e}</p>"
                )
    
    def create_diff_html(self, current_text: str, tm_text: str) -> str:
        """Create HTML with visual diff highlighting between current and TM source
        
        Shows changes inline like memoQ:
        - Red strikethrough for deleted text (in TM but not in current)
        - Green underline + bold for added text (in current but not in TM)
        - Sequential display: deletions then additions in order
        """
        from difflib import SequenceMatcher
        
        s = SequenceMatcher(None, tm_text, current_text)
        html_parts = []
        
        for tag, i1, i2, j1, j2 in s.get_opcodes():
            tm_chunk = tm_text[i1:i2]
            current_chunk = current_text[j1:j2]
            
            if tag == 'equal':
                # Matching text - show normally
                html_parts.append(tm_chunk)
            elif tag == 'delete':
                # Text removed from TM - show with red strikethrough (like memoQ deletion)
                html_parts.append(f'<span style="color: #dc2626; text-decoration: line-through;">{tm_chunk}</span>')
            elif tag == 'insert':
                # Text added in current - show with green underline + bold (like memoQ insertion)
                html_parts.append(f'<span style="color: #22c55e; text-decoration: underline; font-weight: bold;">{current_chunk}</span>')
            elif tag == 'replace':
                # Text changed - show deletion (strikethrough) followed by insertion (underline)
                # This matches memoQ's behavior: old text strikethrough, new text underlined
                html_parts.append(f'<span style="color: #dc2626; text-decoration: line-through;">{tm_chunk}</span>')
                html_parts.append(' ')  # Add space between deletion and insertion
                html_parts.append(f'<span style="color: #22c55e; text-decoration: underline; font-weight: bold;">{current_chunk}</span>')
        
        return ''.join(html_parts)
    
    # ========================================================================
    # TERMBASE HIGHLIGHTING & MATCHING
    # ========================================================================
    
    def get_termbase_code_map(self) -> Dict[int, str]:
        """Get mapping of termbase_id to custom codes (from user preferences)"""
        try:
            prefs = self._load_settings_section("ui")
            return prefs.get('termbase_code_map', {})  # {termbase_id: "CODE"}
        except:
            return {}
    
    def save_termbase_code_map(self, code_map: Dict[int, str]):
        """Save termbase code mappings to user preferences"""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['termbase_code_map'] = code_map
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save termbase code map: {str(e)}")
    
    def get_termbase_code(self, termbase_id: int, code_map: Optional[Dict[int, str]] = None) -> str:
        """
        Get code for a termbase (custom code or auto-generated from name)
        
        Args:
            termbase_id: The termbase ID
            code_map: Optional pre-loaded code map (for performance)
            
        Returns:
            Code string (e.g., "MED", "LEG", "TECH") or empty string
        """
        if code_map is None:
            code_map = self.get_termbase_code_map()
        
        # Check if user has set a custom code
        if termbase_id in code_map:
            return code_map[termbase_id]
        
        # Auto-generate code from termbase name
        try:
            termbase_name = None
            
            if hasattr(self, 'termbase_mgr') and self.termbase_mgr:
                termbase = self.termbase_mgr.get_termbase(termbase_id)
                if termbase:
                    termbase_name = termbase.get('name', '') if isinstance(termbase, dict) else getattr(termbase, 'name', '')
            
            # Fallback: query database directly
            if not termbase_name and hasattr(self, 'db_manager') and self.db_manager:
                cursor = self.db_manager.cursor
                cursor.execute("SELECT name FROM termbases WHERE id = ?", (termbase_id,))
                row = cursor.fetchone()
                if row:
                    termbase_name = row[0]
            
            if not termbase_name:
                return ""
            
            # Generate code from name: take first 3-4 uppercase letters
            # Examples: "Medical Dictionary" -> "MED", "Legal Terms" -> "LEG", "Technical Glossary" -> "TECH"
            words = termbase_name.split()
            if len(words) >= 2:
                # Take first letter of first two words, then add more if needed
                code = (words[0][0] + words[1][0]).upper()
                if len(code) < 3 and len(words[0]) >= 2:
                    code = words[0][:3].upper()
            else:
                # Single word: take first 3 uppercase letters
                code = termbase_name[:3].upper()
            
            # Ensure code is 2-4 characters
            if len(code) > 4:
                code = code[:4]
            
            return code
        except Exception as e:
            self.log(f"Error generating termbase code: {e}")
            return ""
    
    def _convert_language_to_code(self, language: str) -> str:
        """Convert full language name to language code for termbase search"""
        language_map = {
            'Dutch': 'nl',
            'English': 'en', 
            'French': 'fr',
            'German': 'de',
            'Spanish': 'es',
            'Italian': 'it',
            'Portuguese': 'pt',
            'Russian': 'ru',
            'Chinese': 'zh',
            'Japanese': 'ja',
            'Korean': 'ko',
            'Arabic': 'ar'
        }
        return language_map.get(language, language.lower() if language else None)

    def find_termbase_matches_in_source(self, source_text: str) -> Dict[str, str]:
        """
        Find all termbase matches in source text
        Returns dict of {term: translation} for all matches found

        v1.9.182: Uses in-memory index for instant lookup when available.
        Falls back to per-word database queries if index not built.
        """
        if not source_text or not hasattr(self, 'db_manager') or not self.db_manager:
            return {}

        # v1.9.306: Defensive guard – strip any invisible character markers
        # that may have leaked into the source text (e.g. ·, →, °, ↵, \u200B)
        # so that multi-word terms like "hinge load" match correctly.
        if hasattr(self, 'reverse_invisible_replacements'):
            source_text = self.reverse_invisible_replacements(source_text)

        try:
            # v1.9.182: Use in-memory index for instant lookup (1000x faster)
            # The index is built on project load by _build_termbase_index()
            with self.termbase_index_lock:
                has_index = bool(self.termbase_index)

            if has_index:
                # Fast path: use pre-built in-memory index, then deduplicate
                matches = self._search_termbase_in_memory(source_text)
                return self._deduplicate_termbase_matches(matches)

            # Fallback: original per-word database query approach
            # (only used if index not yet built, e.g., during startup)
            source_lang = self.current_project.source_lang if self.current_project else None
            target_lang = self.current_project.target_lang if self.current_project else None

            # Convert language names to codes for termbase search
            source_lang_code = self._convert_language_to_code(source_lang) if source_lang else None
            target_lang_code = self._convert_language_to_code(target_lang) if target_lang else None

            # Strip HTML/XML/CAT tool tags from source text before word splitting
            # This handles <b>, </b>, <i>, memoQ {1}, [2}, Trados <1>, Déjà Vu {00001}, etc.
            import re
            clean_source_text = re.sub(r'</?(?:b|i|u|bi|sub|sup|li-[ob]|\d+)/?>', '', source_text)  # HTML/XML tags
            clean_source_text = re.sub(r'[\[{]\d+[}\]]', '', clean_source_text)  # memoQ/Phrase numeric tags: {1}, [2}, {3]
            clean_source_text = re.sub(r'\{\d{5}\}', '', clean_source_text)  # Déjà Vu tags: {00001}
            # memoQ content tags: [uicontrol id="..."}  or  {uicontrol]  or  [tagname ...}  or  {tagname]
            clean_source_text = re.sub(r'\[[^\[\]]*\}', '', clean_source_text)  # Opening: [anything}
            clean_source_text = re.sub(r'\{[^\{\}]*\]', '', clean_source_text)  # Closing: {anything]

            # Search termbases for all terms that appear in the source text
            # Split source text into words and search for each one
            words = clean_source_text.split()
            matches = {}
            # Use the ORIGINAL source text (with tags) for position matching later
            source_text_lower = source_text.lower()
            # Also create a tag-stripped lowercase version for pattern matching
            clean_source_lower = clean_source_text.lower()
            # Only log word count, not individual words (too verbose)
            
            # Comprehensive set of quote and punctuation characters to strip
            # Includes: straight quotes, curly quotes (left/right), German quotes, guillemets, single quotes
            # ALSO includes parentheses, brackets, and braces for terms like "(typisch)" or "[example]"
            # Using explicit characters to avoid encoding issues
            PUNCT_CHARS = '.,!?;:\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A()[]{}' 
            
            for word in words:
                # Remove punctuation including quotes (preserve internal punctuation like "gew.%")
                # First strip trailing punctuation
                clean_word = word.rstrip(PUNCT_CHARS)
                # Then strip leading punctuation
                clean_word = clean_word.lstrip(PUNCT_CHARS)
                
                if len(clean_word) < 2:  # Skip short words
                    continue
                
                # Get project ID for termbase priority lookup
                project_id = self.current_project.id if self.current_project and hasattr(self.current_project, 'id') else None
                
                # CRITICAL FIX: Search for BOTH the cleaned word AND the original word with punctuation
                # This allows glossary entries like "ca." to match source text "ca." where
                # the tokenized/cleaned version is "ca" but the glossary entry has the period
                words_to_search = [clean_word]
                # Also try searching with trailing punctuation (for entries like "ca.", "gew.%")
                original_word_stripped_leading = word.lstrip(PUNCT_CHARS)
                if original_word_stripped_leading != clean_word and len(original_word_stripped_leading) >= 2:
                    words_to_search.append(original_word_stripped_leading)
                
                termbase_results = []
                for search_word in words_to_search:
                    results = self.db_manager.search_termbases(
                        search_word,
                        source_lang=source_lang_code,
                        target_lang=target_lang_code,
                        project_id=project_id,
                        min_length=2
                    )
                    if results:
                        termbase_results.extend(results)
                
                if termbase_results:
                    for result in termbase_results:
                        source_term = result.get('source_term', '').strip()
                        target_term = result.get('target_term', '').strip()
                        ranking = result.get('ranking', None)
                        forbidden = result.get('forbidden', False)
                        is_project_termbase = result.get('is_project_termbase', False)
                        term_id = result.get('id')
                        termbase_id = result.get('termbase_id')
                        termbase_name = result.get('termbase_name', 'Unknown')
                        domain = result.get('domain', '')
                        notes = result.get('notes', '')
                        project = result.get('project', '')
                        client = result.get('client', '')
                        if not source_term or not target_term:
                            continue
                        # FILTER: Only keep if full source term appears in source text (case-insensitive)
                        # Use lookaround word boundaries to handle terms with punctuation like "gew.%"
                        import re
                        # Normalize source text: replace ALL quote variants with spaces
                        # This ensures word boundaries work correctly for terms in quotes
                        # Using Unicode escapes to avoid encoding issues
                        normalized_source = clean_source_lower
                        for quote_char in '\"\'\u201C\u201D\u201E\u00AB\u00BB\u2018\u2019\u201A\u2039\u203A':
                            normalized_source = normalized_source.replace(quote_char, ' ')
                        
                        # CRITICAL FIX: Strip trailing punctuation from glossary term before matching
                        # This allows entries like "De huidige uitvinding...problemen." (with period)
                        # to match source text "...problemen." where tokenization strips the period
                        # Strip from both ends to handle quotes and trailing punctuation
                        normalized_term = source_term.lower().rstrip(PUNCT_CHARS).lstrip(PUNCT_CHARS)
                        
                        # Check if term has punctuation - use different pattern
                        if any(char in normalized_term for char in ['.', '%', ',', '-', '/']):
                            pattern = re.compile(r'(?<!\w)' + re.escape(normalized_term) + r'(?!\w)')
                        else:
                            pattern = re.compile(r"\b" + re.escape(normalized_term) + r"\b")
                        
                        # Try matching on normalized (tag-stripped, quote-stripped) text first, 
                        # then tag-stripped, then original with tags
                        if not pattern.search(normalized_source) and not pattern.search(clean_source_lower) and not pattern.search(source_text_lower):
                            # Skip terms whose full phrase isn't in the segment
                            continue
                        # CRITICAL FIX: Use term_id as dict key instead of source_term
                        # This allows multiple translations for the same source term to coexist
                        # Example: "inrichting → device" AND "inrichting → apparatus" both displayed
                        # Old code: matches[source_term] = {...} - only kept one translation
                        # New code: matches[term_id] = {...} - keeps all translations
                        matches[term_id] = {
                            'source': source_term,
                            'translation': target_term,
                            'ranking': ranking,
                            'forbidden': forbidden,
                            'is_project_termbase': is_project_termbase,
                            'term_id': term_id,
                            'termbase_id': termbase_id,
                            'termbase_name': termbase_name,
                            'domain': domain,
                            'notes': notes,
                            'project': project,
                            'client': client,
                            'target_synonyms': result.get('target_synonyms', [])  # Include synonyms
                        }
            
            return self._deduplicate_termbase_matches(matches)
            
        except Exception as e:
            self.log(f"❌ Error finding termbase matches: {e}")
            import traceback
            self.log(f"Traceback: {traceback.format_exc()}")
            return {}
    
    def _deduplicate_termbase_matches(self, matches: dict) -> dict:
        """Deduplicate termbase matches: if the same source→target pair exists in
        multiple glossaries, keep only the one from the highest-ranked glossary
        (1=project > 0=background)."""
        filtered = {}
        seen_pairs = {}  # (source_lower, target_lower) -> (term_id, ranking)

        for term_id, match_info in matches.items():
            source_lower = match_info['source'].lower()
            target_lower = match_info['translation'].lower()
            pair_key = (source_lower, target_lower)
            ranking = match_info.get('ranking', 0)

            if pair_key in seen_pairs:
                existing_term_id, existing_ranking = seen_pairs[pair_key]
                if ranking > existing_ranking:
                    del filtered[existing_term_id]
                    filtered[term_id] = match_info
                    seen_pairs[pair_key] = (term_id, ranking)
            else:
                filtered[term_id] = match_info
                seen_pairs[pair_key] = (term_id, ranking)

        return filtered

    def find_nt_matches_in_source(self, source_text: str) -> list:
        """
        Find non-translatable matches in source text.

        NTs are now sourced from termbase entries flagged with
        ``is_nontranslatable = 1`` (the same convention the Trados plugin
        uses), rather than from a separate .svntl file system. The
        termbase index built by ``_build_termbase_index`` already carries
        the flag plus pre-compiled regex patterns, so this is a single
        pass with no extra database round-trips.

        Args:
            source_text: Source text to search in

        Returns:
            List of match dicts with 'text', 'start', 'end', 'list_name'
            (kept named ``list_name`` for compatibility with consumers
            inherited from the old .svntl system; populated with the
            termbase name).
        """
        if not source_text:
            return []

        try:
            with self.termbase_index_lock:
                if not self.termbase_index:
                    return []
                index = self.termbase_index
        except Exception:
            return []

        source_lower = source_text.lower()
        matches = []
        seen_positions = set()

        for term in index:
            if not term.get('is_nontranslatable'):
                continue

            term_lower = term.get('source_term_lower') or ''
            if not term_lower or term_lower not in source_lower:
                continue

            pattern = term.get('pattern')
            if pattern:
                try:
                    iter_matches = pattern.finditer(source_lower)
                except Exception:
                    continue
                for m in iter_matches:
                    start, end = m.start(), m.end()
                    key = (start, end)
                    if key in seen_positions:
                        continue
                    seen_positions.add(key)
                    matches.append({
                        'text': source_text[start:end],
                        'start': start,
                        'end': end,
                        'list_name': term.get('termbase_name', ''),
                        'term_id': term.get('term_id'),
                        'termbase_id': term.get('termbase_id'),
                    })

        # Sort by position, then by length (longer matches first for same start)
        matches.sort(key=lambda m: (m['start'], -(m['end'] - m['start'])))
        return matches
    
    def highlight_source_with_termbase(self, row: int, source_text: str, termbase_matches: Optional[Dict] = None):
        """
        Highlight termbase matches in the source column using text formatting.
        Does NOT replace the widget - just adds background colors to existing ReadOnlyGridTextEditor.
        
        Args:
            row: Row number in table
            source_text: Source text to highlight
            termbase_matches: Optional pre-computed matches dict (to avoid duplicate searches)
        """
        if not self.table or row < 0:
            return
        
        try:
            # Use provided matches or find them
            if termbase_matches is None:
                termbase_matches = self.find_termbase_matches_in_source(source_text)
            
            # Get the existing source widget (ReadOnlyGridTextEditor)
            source_widget = self.table.cellWidget(row, 2)
            if not source_widget or not hasattr(source_widget, 'highlight_termbase_matches'):
                return
            
            # Apply termbase highlighting first
            if termbase_matches:
                source_widget.highlight_termbase_matches(termbase_matches)
            
            # Also highlight non-translatables (pastel yellow). NTs now
            # come from termbase entries flagged is_nontranslatable=1, so
            # the search is always available – no nt_manager guard needed.
            nt_matches = self.find_nt_matches_in_source(source_text)
            if nt_matches and hasattr(source_widget, 'highlight_non_translatables'):
                source_widget.highlight_non_translatables(nt_matches)
            
        except Exception as e:
            self.log(f"Error highlighting termbase matches: {e}")
            import traceback
            self.log(f"Traceback: {traceback.format_exc()}")
    
    def _apply_proactive_highlighting(self, segment_id: int, termbase_matches_json: str):
        """
        Apply termbase highlighting proactively to a segment that was prefetched.
        Called on main thread via signal from prefetch worker.
        
        This enables "see-ahead" highlighting: while you're working on segment N,
        segments N+1, N+2, N+3 already show their glossary matches highlighted.
        
        Args:
            segment_id: The segment ID to highlight
            termbase_matches_json: JSON-encoded termbase matches dict (thread-safe transfer)
        """
        import json

        if not self.current_project or not self.table:
            return

        try:
            # Decode the matches from JSON
            termbase_matches = json.loads(termbase_matches_json) if termbase_matches_json else {}

            if not termbase_matches:
                return  # Nothing to highlight

            # Find the row for this segment ID
            row = -1
            for r in range(self.table.rowCount()):
                id_item = self.table.item(r, 0)
                if id_item:
                    try:
                        row_seg_id = int(id_item.text())
                        if row_seg_id == segment_id:
                            row = r
                            break
                    except ValueError:
                        continue

            if row < 0:
                return  # Segment not visible in current page

            # Get segment source text
            segment = None
            for seg in self.current_project.segments:
                if seg.id == segment_id:
                    segment = seg
                    break

            if not segment:
                return

            # Apply highlighting (this updates the source cell widget)
            self.highlight_source_with_termbase(row, segment.source, termbase_matches)

        except Exception:
            pass  # Silent failure for proactive highlighting
    
    def insert_term_translation(self, row: int, translation: str):
        """
        Double-click handler: insert termbase translation into target cell
        """
        if not self.table or row < 0 or row >= len(self.current_project.segments):
            return
        
        try:
            # Get the target cell
            target_item = self.table.item(row, 3)
            if not target_item:
                return
            
            # Insert translation (using cell widget)
            segment = self.current_project.segments[row]
            segment.target = translation
            target_widget = self.table.cellWidget(row, 3)
            if target_widget and isinstance(target_widget, EditableGridTextEditor):
                target_widget.setPlainText(translation)
            
            # Update status
            if segment.status in (DEFAULT_STATUS.key, 'pretranslated', 'rejected'):
                segment.status = 'draft'
                self.update_status_icon(row, 'draft')
            else:
                self._refresh_segment_status(segment)
            
            self.project_modified = True
            self.update_window_title()
            self.log(f"✓ Term translation inserted in segment {row + 1}")
            
        except Exception as e:
            self.log(f"Error inserting term translation: {e}")
    
    # ========================================================================
    # NAVIGATION & SEARCH
    # ========================================================================

    @staticmethod
    def _apply_case_pattern(original: str, replacement: str) -> str:
        """
        Adjust *replacement* to match the case pattern of *original*.

        Recognised patterns (checked in order of specificity):
          1. ALL CAPS          → replacement.upper()
          2. all lowercase     → replacement.lower()
          3. Title Case        → replacement.title()
          4. Sentence case     → first char upper, rest as-is from replacement
          5. Mixed / unknown   → replacement unchanged

        Only the first character is checked for (4) when the string is longer
        than one word; for single-word originals only UPPER/lower/Title are used.
        """
        if not original or not replacement:
            return replacement

        if original.isupper():
            return replacement.upper()
        if original.islower():
            return replacement.lower()
        if original.istitle():
            return replacement.title()
        # Sentence case: first char upper, rest lower-or-mixed in original
        if original[0].isupper() and original[1:].islower():
            return replacement[0].upper() + replacement[1:] if replacement else replacement
        # Fallback: preserve replacement as typed
        return replacement

    def show_find_replace_dialog(self):
        """Show unified Find & Replace dialog (Ctrl+F and Ctrl+H both open same dialog)
        Features:
        - History dropdowns for recent search/replace terms (last 20)
        - F&R Sets panel for batch operations
        - Pre-fills selected text from source (Find) or target (Replace)
        - Filters grid to show only matching segments
        """
        if not self.current_project or not self.current_project.segments:
            QMessageBox.information(self, "No Project", "Please open a project first.")
            return
        
        from PyQt6.QtWidgets import QCheckBox, QGroupBox, QToolButton, QFrame, QSplitter
        
        # Get selected text from source or target cells to pre-fill the Find field
        # Selected text always goes to Find What, never to Replace With
        prefill_find = ""
        
        current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
        if current_row >= 0:
            # Check target cell first (most common case - user is editing target)
            target_widget = self.table.cellWidget(current_row, 3)
            if target_widget and hasattr(target_widget, 'textCursor'):
                target_selection = target_widget.textCursor().selectedText().strip()
                if target_selection:
                    prefill_find = target_selection
                    self.log(f"🔍 Pre-filling Find with target selection: '{target_selection}'")
            
            # If no target selection, check source cell
            if not prefill_find:
                source_widget = self.table.cellWidget(current_row, 2)
                if source_widget and hasattr(source_widget, 'textCursor'):
                    source_selection = source_widget.textCursor().selectedText().strip()
                    if source_selection:
                        prefill_find = source_selection
                        self.log(f"🔍 Pre-filling Find with source selection: '{source_selection}'")
        
        # Disable background lookups while find/replace is active
        self.find_replace_active = True
        self.log("🔍 Find/Replace active - background lookups disabled")
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Find and Replace")
        dialog.setMinimumWidth(700)
        set_help_topic(dialog, HelpTopics.FIND_REPLACE)

        # Re-enable lookups when dialog closes
        def on_dialog_closed():
            self.find_replace_active = False
            self.log("✓ Find/Replace closed - background lookups re-enabled")
        
        dialog.finished.connect(on_dialog_closed)
        
        # Main vertical layout 
        main_v_layout = QVBoxLayout(dialog)
        main_v_layout.setSpacing(8)
        
        # Row 1: Find what (with history dropdown)
        find_layout = QHBoxLayout()
        find_label = QLabel("Find what:")
        find_label.setFixedWidth(75)
        find_layout.addWidget(find_label)
        self.find_input = HistoryComboBox()
        self.find_input.set_history(self.fr_history.find_history)
        self.find_input.setText(prefill_find)
        find_layout.addWidget(self.find_input, stretch=1)
        main_v_layout.addLayout(find_layout)
        
        # Row 2: Replace with (with history dropdown)
        replace_layout = QHBoxLayout()
        replace_label = QLabel("Replace with:")
        replace_label.setFixedWidth(75)
        replace_layout.addWidget(replace_label)
        self.replace_input = HistoryComboBox()
        self.replace_input.set_history(self.fr_history.replace_history)
        # Replace field always starts empty - user fills in what they want to replace with
        replace_layout.addWidget(self.replace_input, stretch=1)
        main_v_layout.addLayout(replace_layout)
        
        # Row 3: Options - all inline in one horizontal row
        options_layout = QHBoxLayout()
        options_layout.setSpacing(15)
        
        # Search in label + checkboxes
        options_layout.addWidget(QLabel("Search in:"))
        self.search_source_cb = CheckmarkCheckBox("Source")
        self.search_target_cb = CheckmarkCheckBox("Target")
        self.search_target_cb.setChecked(True)  # Default to target
        options_layout.addWidget(self.search_source_cb)
        options_layout.addWidget(self.search_target_cb)
        
        # Separator
        sep1 = QFrame()
        sep1.setFrameShape(QFrame.Shape.VLine)
        sep1.setFrameShadow(QFrame.Shadow.Sunken)
        options_layout.addWidget(sep1)
        
        # Match label + radio buttons
        options_layout.addWidget(QLabel("Match:"))
        self.match_group = QButtonGroup(dialog)
        match_anything = CheckmarkRadioButton("Anything")
        match_anything.setChecked(True)
        match_whole_words = CheckmarkRadioButton("Whole words")
        match_entire = CheckmarkRadioButton("Entire segment")
        
        self.match_group.addButton(match_anything, 0)
        self.match_group.addButton(match_whole_words, 1)
        self.match_group.addButton(match_entire, 2)
        
        options_layout.addWidget(match_anything)
        options_layout.addWidget(match_whole_words)
        options_layout.addWidget(match_entire)
        
        # Separator
        sep2 = QFrame()
        sep2.setFrameShape(QFrame.Shape.VLine)
        sep2.setFrameShadow(QFrame.Shadow.Sunken)
        options_layout.addWidget(sep2)
        
        # Case sensitive checkbox
        self.case_sensitive_cb = CheckmarkCheckBox("Case sensitive")
        options_layout.addWidget(self.case_sensitive_cb)

        # Auto-adjust case checkbox
        self.auto_case_cb = CheckmarkCheckBox("Auto-adjust case")
        self.auto_case_cb.setToolTip(
            "When replacing, automatically adjust the replacement text to match\n"
            "the case pattern of the matched text.\n\n"
            "Recognised patterns:\n"
            "  ALL CAPS  → replacement is uppercased\n"
            "  all lower → replacement is lowercased\n"
            "  Title Case → replacement is title-cased\n"
            "  Sentence case → first letter uppercased\n\n"
            "Has no effect when 'Case sensitive' is also checked."
        )
        options_layout.addWidget(self.auto_case_cb)

        options_layout.addStretch()
        main_v_layout.addLayout(options_layout)
        
        # Row 4: Action buttons - horizontal row, grouped to reduce
        # mis-clicks.  Find/Highlight buttons live to the left of a
        # visible separator; the destructive Replace buttons sit on
        # the right with a distinct background colour.
        buttons_layout = QHBoxLayout()
        buttons_layout.setSpacing(8)

        # Find / Highlight cluster (non-destructive)
        find_next_btn = QPushButton("Find next")
        find_next_btn.clicked.connect(lambda: self._fr_find_next())
        buttons_layout.addWidget(find_next_btn)

        find_all_btn = QPushButton("Find all")
        find_all_btn.clicked.connect(lambda: self._fr_find_all())
        buttons_layout.addWidget(find_all_btn)

        highlight_all_btn = QPushButton("Highlight all")
        highlight_all_btn.clicked.connect(lambda: self.highlight_all_matches())
        buttons_layout.addWidget(highlight_all_btn)

        clear_highlight_btn = QPushButton("Clear highlights")
        clear_highlight_btn.clicked.connect(lambda: self.clear_search_highlights())
        buttons_layout.addWidget(clear_highlight_btn)

        # Visual separator before the destructive Replace cluster
        fr_sep = QFrame()
        fr_sep.setFrameShape(QFrame.Shape.VLine)
        fr_sep.setFrameShadow(QFrame.Shadow.Sunken)
        buttons_layout.addWidget(fr_sep)

        # Replace cluster (destructive — distinct amber background so
        # a click here is visually unambiguous)
        replace_btn_style = (
            "QPushButton { background-color: #FFE082; font-weight: bold; }"
            "QPushButton:hover { background-color: #FFCC33; }"
        )
        replace_this_btn = QPushButton("Replace this")
        replace_this_btn.setStyleSheet(replace_btn_style)
        replace_this_btn.clicked.connect(lambda: self.replace_current_match())
        buttons_layout.addWidget(replace_this_btn)

        replace_all_btn = QPushButton("Replace all")
        replace_all_btn.setStyleSheet(replace_btn_style)
        replace_all_btn.clicked.connect(lambda: self._fr_replace_all())
        buttons_layout.addWidget(replace_all_btn)

        buttons_layout.addStretch()

        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.close)
        buttons_layout.addWidget(close_btn)

        # Keyboard shortcuts: Enter in the Find field triggers Find next;
        # Enter in the Replace field triggers Replace all (which already
        # shows a confirmation dialog before actually replacing).  Both
        # use lineEdit() because HistoryComboBox is a QComboBox.
        self.find_input.lineEdit().returnPressed.connect(self._fr_find_next)
        self.replace_input.lineEdit().returnPressed.connect(self._fr_replace_all)

        main_v_layout.addLayout(buttons_layout)
        
        # Horizontal separator line
        separator = QFrame()
        separator.setFrameShape(QFrame.Shape.HLine)
        separator.setFrameShadow(QFrame.Shadow.Sunken)
        main_v_layout.addWidget(separator)
        
        # F&R Sets collapsible panel
        fr_sets_frame = QFrame()
        fr_sets_layout = QVBoxLayout(fr_sets_frame)
        fr_sets_layout.setContentsMargins(0, 5, 0, 0)
        
        # Toggle button for collapsible panel
        toggle_layout = QHBoxLayout()
        self.fr_sets_toggle = QToolButton()
        self.fr_sets_toggle.setArrowType(Qt.ArrowType.RightArrow)
        self.fr_sets_toggle.setCheckable(True)
        self.fr_sets_toggle.setText("  F&R Sets: Save and Run Multiple Find and Replace Operations")
        self.fr_sets_toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
        self.fr_sets_toggle.setStyleSheet("QToolButton { font-weight: bold; border: none; }")
        toggle_layout.addWidget(self.fr_sets_toggle)
        
        # Add Current to Set button (always visible)
        add_to_set_btn = QPushButton("+ Add Current to Set")
        add_to_set_btn.setToolTip("Add current Find/Replace to the selected set")
        add_to_set_btn.clicked.connect(lambda: self._fr_add_to_set())
        toggle_layout.addWidget(add_to_set_btn)
        
        toggle_layout.addStretch()
        fr_sets_layout.addLayout(toggle_layout)
        
        # F&R Sets Manager widget (collapsible content)
        self.fr_sets_manager = FindReplaceSetsManager(str(self.user_data_path), parent=dialog)
        self.fr_sets_manager.setVisible(False)  # Collapsed by default
        self.fr_sets_manager.operation_selected.connect(self._fr_load_operation)
        self.fr_sets_manager.set_selected.connect(self._fr_run_set_batch)
        fr_sets_layout.addWidget(self.fr_sets_manager)
        
        # Toggle visibility
        def toggle_sets_panel(checked):
            self.fr_sets_manager.setVisible(checked)
            self.fr_sets_toggle.setArrowType(
                Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
            )
            # Resize dialog to fit content
            if checked:
                dialog.setMinimumHeight(500)
            else:
                dialog.setMinimumHeight(0)
                dialog.adjustSize()
        
        self.fr_sets_toggle.toggled.connect(toggle_sets_panel)
        
        main_v_layout.addWidget(fr_sets_frame)
        
        # Store dialog reference and show
        self.find_replace_dialog = dialog
        self.current_match_index = -1
        self.find_matches = []
        # Track which cells currently have search-term highlights so a
        # new search can clear just those, instead of forcing a full
        # load_segments_to_grid() rebuild (which py-spy showed was ~64%
        # of MainThread CPU during batch find).
        self._search_highlighted_cells: set = set()

        dialog.show()
    
    def _fr_find_next(self):
        """Wrapper for find_next_match that saves to history."""
        find_text = self.find_input.text()
        if find_text:
            self.fr_history.add_find(find_text)
            self.find_input.set_history(self.fr_history.find_history)
        self.find_next_match()
    
    def _fr_find_all(self):
        """Wrapper for find_all_matches that saves to history."""
        find_text = self.find_input.text()
        if find_text:
            self.fr_history.add_find(find_text)
            self.find_input.set_history(self.fr_history.find_history)
        self.find_all_matches()
    
    def _fr_replace_all(self):
        """Wrapper for replace_all_matches that saves to history."""
        find_text = self.find_input.text()
        replace_text = self.replace_input.text()
        if find_text:
            self.fr_history.add_operation(find_text, replace_text)
            self.find_input.set_history(self.fr_history.find_history)
            self.replace_input.set_history(self.fr_history.replace_history)
        self.replace_all_matches()
    
    def _fr_add_to_set(self):
        """Add current Find/Replace values to the selected F&R Set."""
        find_text = self.find_input.text()
        replace_text = self.replace_input.text()
        
        if not find_text:
            QMessageBox.information(self.find_replace_dialog, "Add to Set", "Please enter a Find term first.")
            return
        
        # Build operation from current dialog settings
        search_in = "both" if (self.search_source_cb.isChecked() and self.search_target_cb.isChecked()) else (
            "source" if self.search_source_cb.isChecked() else "target"
        )
        
        op = FindReplaceOperation(
            find_text=find_text,
            replace_text=replace_text,
            search_in=search_in,
            match_mode=self.match_group.checkedId(),
            case_sensitive=self.case_sensitive_cb.isChecked(),
            enabled=True,
            auto_case=self.auto_case_cb.isChecked(),
        )
        
        self.fr_sets_manager.add_current_operation_to_set(op)
        self.log(f"✅ Added F&R operation to set: '{find_text}' → '{replace_text}'")
    
    def _fr_load_operation(self, op: FindReplaceOperation):
        """Load an operation from a set into the dialog fields."""
        self.find_input.setText(op.find_text)
        self.replace_input.setText(op.replace_text)
        
        # Set search options
        if op.search_in == "both":
            self.search_source_cb.setChecked(True)
            self.search_target_cb.setChecked(True)
        elif op.search_in == "source":
            self.search_source_cb.setChecked(True)
            self.search_target_cb.setChecked(False)
        else:  # target
            self.search_source_cb.setChecked(False)
            self.search_target_cb.setChecked(True)
        
        # Set match mode
        btn = self.match_group.button(op.match_mode)
        if btn:
            btn.setChecked(True)
        
        self.case_sensitive_cb.setChecked(op.case_sensitive)
        self.auto_case_cb.setChecked(getattr(op, 'auto_case', False))

    def _fr_run_set_batch(self, fr_set: FindReplaceSet):
        """Run all enabled operations in a F&R Set as a batch (optimized for speed)."""
        enabled_ops = [op for op in fr_set.operations if op.enabled and op.find_text]
        
        if not enabled_ops:
            QMessageBox.information(self.find_replace_dialog, "Run All", "No enabled operations with find text.")
            return
        
        # Confirm
        reply = QMessageBox.question(
            self.find_replace_dialog,
            "Run Batch Replace",
            f"Run {len(enabled_ops)} replace operation(s) from '{fr_set.name}'?\n\n"
            "This will replace all occurrences in target segments.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        
        if reply != QMessageBox.StandardButton.Yes:
            return
        
        # OPTIMIZATION: Disable UI updates during batch processing
        self.table.setUpdatesEnabled(False)
        
        try:
            # Run each operation
            total_replaced = 0
            for op in enabled_ops:
                count = self._execute_single_fr_operation(op)
                total_replaced += count
                self.log(f"  '{op.find_text}' → '{op.replace_text}': {count} replacement(s)")
            
            self.project_modified = True
            self.update_window_title()
            
        finally:
            # OPTIMIZATION: Re-enable UI updates and refresh only target column cells
            self.table.setUpdatesEnabled(True)
            # Update all target cells in-place (batch operations can affect many segments)
            for row in range(len(self.current_project.segments)):
                segment = self.current_project.segments[row]
                target_widget = self.table.cellWidget(row, 3)
                if target_widget and hasattr(target_widget, 'setPlainText'):
                    target_widget.blockSignals(True)
                    # Strip outer wrapping tags if setting is enabled
                    display_text = segment.target
                    if self.hide_outer_wrapping_tags:
                        display_text, _ = strip_outer_wrapping_tags(display_text)
                    target_widget.setPlainText(display_text)
                    target_widget.blockSignals(False)
            self.table.viewport().update()
        
        QMessageBox.information(
            self.find_replace_dialog,
            "Batch Complete",
            f"Completed {len(enabled_ops)} operation(s).\nTotal replacements: {total_replaced}"
        )
    
    def _execute_single_fr_operation(self, op: FindReplaceOperation) -> int:
        """Execute a single F&R operation on all segments (optimized). Returns replacement count."""
        import re
        count = 0
        
        # OPTIMIZATION: Pre-filter segments - only check segments that might contain the text
        # Quick case-insensitive check to skip segments that definitely don't match
        search_text_lower = op.find_text.lower() if not op.case_sensitive else None
        
        for segment in self.current_project.segments:
            texts_to_check = []
            
            # Pre-filter: skip segments that can't possibly match
            if not op.case_sensitive:
                # Quick check: does the segment contain the search text at all?
                skip_segment = True
                if op.search_in in ("source", "both") and self.allow_replace_in_source:
                    if search_text_lower in segment.source.lower():
                        skip_segment = False
                if op.search_in in ("target", "both"):
                    if search_text_lower in segment.target.lower():
                        skip_segment = False
                if skip_segment:
                    continue
            
            if op.search_in in ("source", "both") and self.allow_replace_in_source:
                texts_to_check.append(("source", segment.source))
            if op.search_in in ("target", "both"):
                texts_to_check.append(("target", segment.target))
            
            auto_case = getattr(op, 'auto_case', False)

            for field_name, old_text in texts_to_check:
                if op.match_mode == 2:  # Entire segment
                    if op.case_sensitive:
                        if old_text == op.find_text:
                            new_text = op.replace_text
                        else:
                            new_text = old_text
                    else:
                        if old_text.lower() == op.find_text.lower():
                            new_text = self._apply_case_pattern(old_text, op.replace_text) if auto_case else op.replace_text
                        else:
                            new_text = old_text
                elif op.match_mode == 1:  # Whole words
                    pattern = r'\b' + re.escape(op.find_text) + r'\b'
                    if op.case_sensitive:
                        new_text = re.sub(pattern, op.replace_text, old_text)
                    elif auto_case:
                        def _ww_repl(m, _rt=op.replace_text):
                            return self._apply_case_pattern(m.group(0), _rt)
                        new_text = re.sub(pattern, _ww_repl, old_text, flags=re.IGNORECASE)
                    else:
                        new_text = re.sub(pattern, op.replace_text, old_text, flags=re.IGNORECASE)
                else:  # Anything
                    if op.case_sensitive:
                        new_text = old_text.replace(op.find_text, op.replace_text)
                    elif auto_case:
                        pattern = re.escape(op.find_text)
                        def _any_repl(m, _rt=op.replace_text):
                            return self._apply_case_pattern(m.group(0), _rt)
                        new_text = re.sub(pattern, _any_repl, old_text, flags=re.IGNORECASE)
                    else:
                        pattern = re.escape(op.find_text)
                        new_text = re.sub(pattern, op.replace_text, old_text, flags=re.IGNORECASE)
                
                if new_text != old_text:
                    count += 1
                    if field_name == "source":
                        segment.source = new_text
                    else:
                        segment.target = new_text
        
        return count
    
    def find_next_match(self):
        """Find next occurrence of search term, filtering grid to show only matching rows"""
        find_text = self.find_input.text()
        if not find_text:
            return

        search_source = self.search_source_cb.isChecked()
        search_target = self.search_target_cb.isChecked()
        case_sensitive = self.case_sensitive_cb.isChecked()
        match_mode = self.match_group.checkedId()

        # Find all matches if not already done
        if not self.find_matches:
            self.find_all_matches_internal(find_text, search_source, search_target, case_sensitive, match_mode)

            if self.find_matches:
                # Clear stale highlights from any previous search, then
                # apply the row-visibility filter.  We deliberately
                # avoid load_segments_to_grid() here — py-spy showed it
                # was ~64% of MainThread CPU just to toggle visibility.
                self._clear_search_highlights_in_cells(self._search_highlighted_cells)
                self._search_highlighted_cells = set()

                matching_rows = set(row for row, col in self.find_matches)
                segments = self.current_project.segments
                self.table.setUpdatesEnabled(False)
                try:
                    for row in range(len(segments)):
                        hidden = row not in matching_rows or not segments[row].source.strip()
                        self.table.setRowHidden(row, hidden)
                finally:
                    self.table.setUpdatesEnabled(True)

        if not self.find_matches:
            QMessageBox.information(self.find_replace_dialog, "Find", "No matches found.")
            return

        # Move to next match
        self.current_match_index = (self.current_match_index + 1) % len(self.find_matches)
        row, col = self.find_matches[self.current_match_index]

        # Highlight the current match
        segment = self.current_project.segments[row]
        text = segment.source if col == 2 else segment.target
        self.highlight_search_term(row, col, text, find_text)
        self._search_highlighted_cells.add((row, col))

        # Navigate to the match
        self.table.setCurrentCell(row, col)
        self.table.scrollToItem(self.table.item(row, col))
        self.log(f"Match {self.current_match_index + 1} of {len(self.find_matches)}")
    
    def find_all_matches(self):
        """Find and highlight all matches, filtering grid to show only matching rows"""
        find_text = self.find_input.text()
        if not find_text:
            return
        
        search_source = self.search_source_cb.isChecked()
        search_target = self.search_target_cb.isChecked()
        case_sensitive = self.case_sensitive_cb.isChecked()
        match_mode = self.match_group.checkedId()
        
        self.find_all_matches_internal(find_text, search_source, search_target, case_sensitive, match_mode)

        if self.find_matches:
            # Clear stale highlights from any previous search, then
            # apply the row-visibility filter — without the
            # load_segments_to_grid() rebuild that previously dominated
            # this code path.
            self._clear_search_highlights_in_cells(self._search_highlighted_cells)
            self._search_highlighted_cells = set()

            matching_rows = set(row for row, col in self.find_matches)
            segments = self.current_project.segments
            self.table.setUpdatesEnabled(False)
            try:
                for row in range(len(segments)):
                    hidden = row not in matching_rows or not segments[row].source.strip()
                    self.table.setRowHidden(row, hidden)

                # Highlight all matches with yellow
                for row, col in self.find_matches:
                    segment = self.current_project.segments[row]
                    text = segment.source if col == 2 else segment.target
                    self.highlight_search_term(row, col, text, find_text)
                    self._search_highlighted_cells.add((row, col))
            finally:
                self.table.setUpdatesEnabled(True)

            # Jump to first match
            first_row, first_col = self.find_matches[0]
            self.table.setCurrentCell(first_row, first_col)
            self.table.scrollToItem(self.table.item(first_row, first_col))
            self.current_match_index = 0

            self.log(f"Found {len(self.find_matches)} match(es) in {len(matching_rows)} segment(s)")
        else:
            QMessageBox.information(self.find_replace_dialog, "Find", "No matches found.")
    
    def find_all_matches_internal(self, find_text, search_source, search_target, case_sensitive, match_mode):
        """Internal method to find all matches"""
        import re
        
        self.find_matches = []
        
        for row, segment in enumerate(self.current_project.segments):
            texts_to_search = []
            if search_source:
                texts_to_search.append((segment.source, 2))
            if search_target:
                texts_to_search.append((segment.target, 3))
            
            for text, col in texts_to_search:
                if self.text_matches(text, find_text, case_sensitive, match_mode):
                    self.find_matches.append((row, col))
    
    def text_matches(self, text, find_text, case_sensitive, match_mode):
        """Check if text matches search criteria"""
        import re
        
        if match_mode == 2:  # Entire segment
            if case_sensitive:
                return text == find_text
            else:
                return text.lower() == find_text.lower()
        
        elif match_mode == 1:  # Whole words
            if case_sensitive:
                pattern = r'\b' + re.escape(find_text) + r'\b'
                return bool(re.search(pattern, text))
            else:
                pattern = r'\b' + re.escape(find_text) + r'\b'
                return bool(re.search(pattern, text, re.IGNORECASE))
        
        else:  # Anything (0)
            if case_sensitive:
                return find_text in text
            else:
                return find_text.lower() in text.lower()
    
    def _clear_search_highlights_in_cells(self, cells):
        """Clear yellow search-term highlights from each (row, col) cell.

        Used by find/replace to drop stale highlights from a previous
        search without forcing a full load_segments_to_grid() rebuild.
        Cells whose widget has been replaced since the highlight was
        applied are silently skipped.
        """
        if not cells:
            return
        from PyQt6.QtGui import QTextCursor, QTextCharFormat
        for row, col in cells:
            widget = self.table.cellWidget(row, col)
            if not widget or not hasattr(widget, 'document'):
                continue
            cursor = widget.textCursor()
            cursor.select(QTextCursor.SelectionType.Document)
            cursor.setCharFormat(QTextCharFormat())
            cursor.clearSelection()

    def highlight_search_term(self, row, col, text, find_text):
        """Highlight only the search term within the cell with yellow background.
        Uses the same system as filter highlighting - directly highlights within QTextEdit widget."""
        import re
        from PyQt6.QtGui import QTextCursor, QTextCharFormat, QColor
        
        if not text or not find_text:
            return
        
        # Get the cell widget (QTextEdit)
        widget = self.table.cellWidget(row, col)
        if not widget or not hasattr(widget, 'document'):
            return
        
        case_sensitive = self.case_sensitive_cb.isChecked()
        match_mode = self.match_group.checkedId()
        
        # Clear any existing highlights first
        cursor = widget.textCursor()
        cursor.select(QTextCursor.SelectionType.Document)
        clear_format = QTextCharFormat()
        cursor.setCharFormat(clear_format)
        cursor.clearSelection()
        
        # Create yellow highlight format
        highlight_format = QTextCharFormat()
        highlight_format.setBackground(QColor("#FFFF00"))  # Yellow background
        
        # Get document text
        document = widget.document()
        cursor = QTextCursor(document)
        full_text = document.toPlainText()
        
        if match_mode == 2:  # Entire segment
            # Highlight entire text
            cursor.select(QTextCursor.SelectionType.Document)
            cursor.mergeCharFormat(highlight_format)
        elif match_mode == 1:  # Whole words
            # Find whole word matches using regex
            if case_sensitive:
                pattern = r'\b' + re.escape(find_text) + r'\b'
                matches = re.finditer(pattern, full_text)
            else:
                pattern = r'\b' + re.escape(find_text) + r'\b'
                matches = re.finditer(pattern, full_text, re.IGNORECASE)
            
            for match in matches:
                cursor.setPosition(match.start())
                cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(match.group()))
                cursor.mergeCharFormat(highlight_format)
        else:  # Anything (0)
            # Find all occurrences
            if case_sensitive:
                search_text = find_text
                text_to_search = full_text
            else:
                search_text = find_text.lower()
                text_to_search = full_text.lower()
            
            pos = 0
            while True:
                pos = text_to_search.find(search_text, pos)
                if pos == -1:
                    break
                
                cursor.setPosition(pos)
                cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(find_text))
                cursor.mergeCharFormat(highlight_format)
                pos += len(find_text)
    
    def replace_current_match(self):
        """Replace the currently selected match"""
        if self.current_match_index < 0 or self.current_match_index >= len(self.find_matches):
            QMessageBox.information(self.find_replace_dialog, "Replace", "No match selected. Use 'Find next' first.")
            return
        
        find_text = self.find_input.text()
        replace_text = self.replace_input.text()
        row, col = self.find_matches[self.current_match_index]
        
        # Safety check: prevent replacing in source if disabled
        if col == 2 and not self.allow_replace_in_source:  # Source column
            QMessageBox.warning(
                self.find_replace_dialog,
                "Replace in Source Disabled",
                "Replacing in source text is disabled for safety.\n\n"
                "To enable this feature, go to Settings > General and check 'Allow Replace in Source Text'."
            )
            return
        
        segment = self.current_project.segments[row]
        case_sensitive = self.case_sensitive_cb.isChecked()
        auto_case = self.auto_case_cb.isChecked()
        match_mode = self.match_group.checkedId()

        # Determine which field to update
        if col == 2:  # Source column
            field_text = segment.source
        else:  # col == 3, Target column
            field_text = segment.target

        if match_mode == 2:  # Entire segment
            new_text = replace_text
        else:
            # Replace using the appropriate method
            import re
            if case_sensitive:
                new_text = field_text.replace(find_text, replace_text, 1)
            elif auto_case:
                pattern = re.escape(find_text)
                def _case_replacer_single(m):
                    return self._apply_case_pattern(m.group(0), replace_text)
                new_text = re.sub(pattern, _case_replacer_single, field_text, count=1, flags=re.IGNORECASE)
            else:
                pattern = re.escape(find_text)
                new_text = re.sub(pattern, replace_text, field_text, count=1, flags=re.IGNORECASE)

        # Update the appropriate field
        if col == 2:
            segment.source = new_text
        else:
            segment.target = new_text
        
        # Update table
        item = self.table.item(row, col)
        if item:
            item.setText(segment.target)
        
        self.project_modified = True
        self.update_window_title()
        
        # Refresh matches
        self.find_matches = []
        self.find_next_match()
    
    def replace_all_matches(self):
        """Replace all matches in target segments (optimized for speed)"""
        find_text = self.find_input.text()
        replace_text = self.replace_input.text()
        
        if not find_text:
            return
        
        search_source = self.search_source_cb.isChecked()
        search_target = self.search_target_cb.isChecked()
        case_sensitive = self.case_sensitive_cb.isChecked()
        auto_case = self.auto_case_cb.isChecked()
        match_mode = self.match_group.checkedId()

        # Safety check: warn if trying to replace in source when disabled
        if search_source and not self.allow_replace_in_source:
            reply = QMessageBox.warning(
                self.find_replace_dialog,
                "Replace in Source Disabled",
                "Replacing in source text is disabled for safety.\n\n"
                "Only target text will be replaced.\n\n"
                "To enable replacing in source, go to Settings > General and check 'Allow Replace in Source Text'.",
                QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
            )
            if reply == QMessageBox.StandardButton.Cancel:
                return
            # Continue with target only
            search_source = False
            if not search_target:
                return
        
        # Find all matches
        self.find_all_matches_internal(find_text, search_source, search_target, case_sensitive, match_mode)
        
        if not self.find_matches:
            QMessageBox.information(self.find_replace_dialog, "Replace All", "No matches found.")
            return
        
        # Count matches by column
        source_count = sum(1 for _, col in self.find_matches if col == 2)
        target_count = sum(1 for _, col in self.find_matches if col == 3)
        
        # Filter out source matches if not allowed
        if not self.allow_replace_in_source:
            self.find_matches = [(row, col) for row, col in self.find_matches if col == 3]
            if not self.find_matches:
                QMessageBox.information(self.find_replace_dialog, "Replace All", "No matches found in Target column.")
                return
        
        # Build confirmation message
        msg_parts = []
        if source_count > 0 and self.allow_replace_in_source:
            msg_parts.append(f"{source_count} in source")
        if target_count > 0:
            msg_parts.append(f"{target_count} in target")
        
        confirmation_msg = f"Replace {len(self.find_matches)} occurrence(s) of '{find_text}' with '{replace_text}'?\n\n"
        if msg_parts:
            confirmation_msg += f"({', '.join(msg_parts)})"
        
        # Extra warning if replacing in source
        if source_count > 0 and self.allow_replace_in_source:
            confirmation_msg += "\n\n⚠️ WARNING: This will modify source text!"
        
        # Confirm
        reply = QMessageBox.question(
            self.find_replace_dialog,
            "Replace All",
            confirmation_msg,
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        
        if reply != QMessageBox.StandardButton.Yes:
            return
        
        # OPTIMIZATION: Disable UI updates during batch replacement
        self.table.setUpdatesEnabled(False)
        
        try:
            # Perform replacements
            import re
            replaced_count = 0
            updated_rows = set()  # Track which rows need UI updates
            
            for row, col in self.find_matches:
                segment = self.current_project.segments[row]
                
                # Get the appropriate field
                if col == 2:  # Source
                    old_text = segment.source
                else:  # col == 3, Target
                    old_text = segment.target
                
                # Perform replacement
                if match_mode == 2:  # Entire segment
                    new_text = replace_text
                else:
                    if case_sensitive:
                        new_text = old_text.replace(find_text, replace_text)
                    elif auto_case:
                        pattern = re.escape(find_text)
                        def _case_repl(m, _rt=replace_text):
                            return self._apply_case_pattern(m.group(0), _rt)
                        new_text = re.sub(pattern, _case_repl, old_text, flags=re.IGNORECASE)
                    else:
                        pattern = re.escape(find_text)
                        new_text = re.sub(pattern, replace_text, old_text, flags=re.IGNORECASE)

                if new_text != old_text:
                    replaced_count += 1
                    updated_rows.add(row)

                    # Update the appropriate field
                    if col == 2:
                        segment.source = new_text
                    else:
                        old_target = segment.target
                        old_status = segment.status
                        segment.target = new_text
                        # Record undo state for find/replace operation
                        self.record_undo_state(segment.id, old_target, new_text, old_status, old_status)
                    
                    # OPTIMIZATION: Update only the affected cell widget in-place
                    cell_widget = self.table.cellWidget(row, col)
                    if cell_widget and hasattr(cell_widget, 'setPlainText'):
                        cell_widget.blockSignals(True)
                        # Strip outer wrapping tags if setting is enabled
                        display_text = new_text
                        if self.hide_outer_wrapping_tags:
                            display_text, _ = strip_outer_wrapping_tags(display_text)
                        cell_widget.setPlainText(display_text)
                        cell_widget.blockSignals(False)
            
            self.project_modified = True
            self.update_window_title()
            
            # Clear matches
            self.find_matches = []
            
        finally:
            # OPTIMIZATION: Re-enable UI updates without full grid reload
            self.table.setUpdatesEnabled(True)
            # Trigger a repaint of updated rows only
            for row in updated_rows:
                self.table.viewport().update()
        
        QMessageBox.information(self.find_replace_dialog, "Replace All", f"Replaced {replaced_count} occurrence(s).")
        self.log(f"✓ Replaced {replaced_count} occurrence(s) of '{find_text}'")
    
    def highlight_all_matches(self):
        """Highlight all matches in the grid (without filtering rows)"""
        find_text = self.find_input.text()
        if not find_text:
            return
        
        search_source = self.search_source_cb.isChecked()
        search_target = self.search_target_cb.isChecked()
        case_sensitive = self.case_sensitive_cb.isChecked()
        match_mode = self.match_group.checkedId()
        
        # Find all matches first
        self.find_all_matches_internal(find_text, search_source, search_target, case_sensitive, match_mode)
        
        if self.find_matches:
            # Clear any stale highlights, then apply fresh ones in a
            # single repaint pass.  No grid reload needed.
            self._clear_search_highlights_in_cells(self._search_highlighted_cells)
            self._search_highlighted_cells = set()

            self.table.setUpdatesEnabled(False)
            try:
                for row, col in self.find_matches:
                    segment = self.current_project.segments[row]
                    text = segment.source if col == 2 else segment.target
                    self.highlight_search_term(row, col, text, find_text)
                    self._search_highlighted_cells.add((row, col))
            finally:
                self.table.setUpdatesEnabled(True)

            self.log(f"Highlighted {len(self.find_matches)} match(es)")
        else:
            QMessageBox.information(self.find_replace_dialog, "Highlight All", "No matches found.")

    def clear_search_highlights(self):
        """Clear all search highlights and unhide all rows (for Find & Replace dialog)"""
        # Targeted highlight clear — no full grid rebuild.
        self._clear_search_highlights_in_cells(self._search_highlighted_cells)
        self._search_highlighted_cells = set()

        # Unhide all rows (empty structural segments stay hidden via pagination)
        self._apply_pagination_to_grid()

        self.find_matches = []
        self.current_match_index = -1
        self.log("Search highlights cleared")
    
    def force_refresh_matches(self):
        """Force refresh all glossary and TM matches for current segment.

        Bound to F5 and to the TermLens 🔄 button (v1.10.72: collapsed
        into a single code path). Does:

        1. **Smart index rebuild** — compares a fresh snapshot of the
           termbase tables against the snapshot captured at the end of
           the last ``_build_termbase_index()`` call (same gating the
           v1.10.69 file watcher uses). If anything changed in
           ``termbases`` or ``termbase_terms`` (count, max id, max
           modified_date), rebuilds the in-memory index from disk so
           cross-process edits (typically from the Supervertaler for
           Trados plugin sharing the same SQLite database) are picked
           up. If nothing changed, skips the rebuild — keeping the
           common-case F5 fast (<100 ms) for "I just want to redraw".

        2. **Cache clear** — drops the per-segment termbase match cache
           and the per-segment translation-matches cache for the
           current segment.

        3. **Fresh re-search** — termbases (via the in-memory index),
           TMs (via SQL), NT lists.

        4. **Full redraw** — TermLens widgets (both instances) and the
           Translation Results panel.

        Before v1.10.72 this was the "F5-only" path and a separate
        ``_post_termbase_delete_refresh`` did step 1 unconditionally.
        Having two paths was confusing — users didn't know which to
        press — and the lighter F5 path occasionally left the TermLens
        widget under-refreshed after term edits (the v1.10.71 issue).
        Collapsing into one path with smart gating fixes both: F5 and
        🔄 do the same thing, and the snapshot decides whether the
        rebuild step actually runs.
        """
        if not self.current_project:
            self.statusBar().showMessage("No project open", 3000)
            return

        # v1.10.72: smart index rebuild — only if the termbase tables
        # actually changed since we last rebuilt. The snapshot is
        # updated at the end of every ``_build_termbase_index()``
        # call, so own-writes that ran through a full rebuild don't
        # re-trigger here. Cross-process writes (Trados plugin) and
        # own-writes via the optimisation path that bypassed the
        # rebuild will both have shifted the snapshot, and the
        # rebuild fires.
        try:
            fresh_snap = self._snapshot_termbase_db_state()
            prev_snap = getattr(self, '_termbase_db_last_snapshot', None)
            if fresh_snap is None or prev_snap is None or fresh_snap != prev_snap:
                if prev_snap is not None and fresh_snap is not None:
                    self.log(
                        f"🔄 Termbase DB changed since last rebuild — "
                        f"rebuilding index "
                        f"(termbases:{prev_snap[0]}→{fresh_snap[0]}, "
                        f"terms:{prev_snap[2]}→{fresh_snap[2]})"
                    )
                else:
                    self.log("🔄 Termbase index snapshot missing — rebuilding")
                self._build_termbase_index()
                # _build_termbase_index updates _termbase_db_last_snapshot
                # so subsequent F5 / 🔄 presses skip the rebuild step.
        except Exception as e:
            self.log(f"⚠️ Smart index-rebuild check failed: {e}")
        
        # Get current segment
        current_row = self.table.currentRow()
        if current_row < 0:
            self.statusBar().showMessage("No segment selected", 3000)
            return
        
        # Get segment ID from the grid cell
        id_item = self.table.item(current_row, 0)
        if not id_item:
            self.statusBar().showMessage("Could not find segment", 3000)
            return
        
        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            self.statusBar().showMessage("Invalid segment ID", 3000)
            return
        
        # Find the segment
        segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
        if not segment:
            self.statusBar().showMessage(f"Segment {segment_id} not found", 3000)
            return
        
        self.log(f"🔄 Force refresh: Clearing all caches for segment {segment_id}...")
        
        # 1. Clear termbase cache for this segment (and all segments to be safe)
        with self.termbase_cache_lock:
            self.termbase_cache.clear()
            self.log("   ✓ Termbase cache cleared")
        
        # 2. Clear translation matches cache for this segment
        with self.translation_matches_cache_lock:
            if segment_id in self.translation_matches_cache:
                del self.translation_matches_cache[segment_id]
            self.log("   ✓ Translation matches cache cleared for this segment")
        
        # 3. Force fresh termbase search
        self.log("   🔍 Searching termbases...")
        termbase_matches = self.find_termbase_matches_in_source(segment.source)
        tb_count = len(termbase_matches) if termbase_matches else 0
        self.log(f"   ✓ Found {tb_count} termbase matches")
        
        # 4. Force fresh TM search
        self.log("   🔍 Searching translation memories...")
        tm_matches = []
        if hasattr(self, 'tm_manager') and self.tm_manager:
            try:
                # Get enabled TMs
                tm_settings = getattr(self.current_project, 'tm_settings', {})
                enabled_tms = tm_settings.get('tms', [])
                activated_tm_ids = [tm['tm_id'] for tm in enabled_tms if tm.get('read', False)]
                
                if activated_tm_ids:
                    source_lang = self.current_project.source_lang if self.current_project else None
                    target_lang = self.current_project.target_lang if self.current_project else None
                    
                    tm_matches = self.tm_manager.search_fuzzy(
                        segment.source,
                        source_lang=source_lang,
                        target_lang=target_lang,
                        tm_ids=activated_tm_ids,
                        max_results=self.general_settings.get('match_limit_TM', 10),
                        min_similarity=0.3
                    )
            except Exception as e:
                self.log(f"   ⚠️ TM search error: {e}")
        
        tm_count = len(tm_matches) if tm_matches else 0
        self.log(f"   ✓ Found {tm_count} TM matches")
        
        # 5. Get NT matches (fresh)
        nt_matches = self.find_nt_matches_in_source(segment.source)
        nt_count = len(nt_matches) if nt_matches else 0
        
        # 6. Update TermLens with fresh results
        if (hasattr(self, 'termlens_widget') and self.termlens_widget) or (hasattr(self, 'termlens_widget_match') and self.termlens_widget_match):
            try:
                # Convert termbase matches dict to list format for termlens
                # v1.10.73 (Tier 1 + Tier 2): forward all metadata
                # fields and source synonyms in addition to the
                # target_synonyms field that was already here. Brings
                # this builder into line with the two other call sites
                # so the TermBlock tooltip + alternative-translation
                # rendering have the same data regardless of which
                # refresh path got us here.
                tb_list = []
                for term_id, match_info in termbase_matches.items():
                    tb_list.append({
                        'source_term': match_info.get('source', ''),
                        'target_term': match_info.get('translation', ''),
                        'termbase_name': match_info.get('termbase_name', ''),
                        'ranking': match_info.get('ranking', 0),
                        'is_project_termbase': match_info.get('is_project_termbase', False),
                        'source_synonyms': match_info.get('source_synonyms', []),
                        'target_synonyms': match_info.get('target_synonyms', []),
                        'term_id': term_id,
                        'termbase_id': match_info.get('termbase_id'),
                        'notes': match_info.get('notes', ''),
                        'definition': match_info.get('definition', ''),
                        'domain': match_info.get('domain', ''),
                        'url': match_info.get('url', ''),
                        'source_abbreviation': match_info.get('source_abbreviation', ''),
                        'target_abbreviation': match_info.get('target_abbreviation', ''),
                        # v1.10.75 (Tier 3) flags
                        'forbidden': match_info.get('forbidden', False),
                        'is_nontranslatable': match_info.get('is_nontranslatable', False),
                        # v1.10.75 (Tier 3c) abbreviation marker
                        'matched_via_abbreviation': match_info.get('matched_via_abbreviation', False),
                    })
                # Get status hint for termbase activation
                status_hint = self._get_termbase_status_hint()

                # Update both TermLens widgets
                self._update_both_termlens(segment.source, tb_list, nt_matches, status_hint)
                self.log("   ✓ TermLens updated")
            except Exception as e:
                self.log(f"   ⚠️ TermLens update error: {e}")
        
        # 7. Update Translation Results panel with fresh results
        if hasattr(self, 'results_panels'):
            from modules.translation_results_panel import TranslationMatch
            
            # Build matches dict for panel
            matches = {
                "TM": [],
                "Termbases": [],
                "MT": [],
                "LLM": []
            }
            
            # Add TM matches
            for tm_match in tm_matches:
                matches["TM"].append(TranslationMatch(
                    source=tm_match.get('source', ''),
                    target=tm_match.get('target', ''),
                    relevance=tm_match.get('similarity', 0) * 100,
                    match_type="TM",
                    metadata={
                        'tm_name': tm_match.get('tm_name', 'Unknown TM'),
                    }
                ))
            
            # Add termbase matches
            for term_id, match_info in termbase_matches.items():
                matches["Termbases"].append(TranslationMatch(
                    source=match_info.get('source', ''),
                    target=match_info.get('translation', ''),
                    relevance=100,  # Termbase matches are always exact
                    match_type="Termbase",
                    metadata={
                        'termbase_name': match_info.get('termbase_name', ''),
                        'ranking': match_info.get('ranking', 0),
                        'is_project_termbase': match_info.get('is_project_termbase', False),
                        'notes': match_info.get('notes', ''),
                        'term_id': term_id,
                        'termbase_id': match_info.get('termbase_id')
                    }
                ))
            
            for panel in self.results_panels:
                try:
                    panel.clear()
                    panel.set_matches(matches)
                except Exception as e:
                    self.log(f"   ⚠️ Panel update error: {e}")
            self.log("   ✓ Translation Results panel updated")
        
        # 7b. Update Match Panel with TM matches (must use TranslationMatch objects for correct display)
        from modules.translation_results_panel import TranslationMatch
        tm_matches_for_panel = []
        for tm_match in tm_matches:
            tm_matches_for_panel.append(TranslationMatch(
                source=tm_match.get('source', ''),
                target=tm_match.get('target', ''),
                relevance=int(tm_match.get('similarity', 0) * 100),
                metadata={
                    'tm_name': tm_match.get('tm_name', 'TM'),
                    'tm_id': tm_match.get('tm_id', ''),
                },
                match_type='TM',
                compare_source=tm_match.get('source', ''),
                provider_code='TM'
            ))
        self.set_compare_panel_matches(segment_id, segment.source, tm_matches_for_panel, [])
        self.log("   ✓ Match Panel updated")
        
        # 8. Re-apply termbase highlighting in the grid source cell
        try:
            source_widget = self.table.cellWidget(current_row, 2)
            if source_widget and hasattr(source_widget, 'highlight_termbase_matches'):
                source_widget.highlight_termbase_matches(termbase_matches)
                self.log("   ✓ Grid highlighting updated")
            else:
                self.log("   ⚠️ Could not find source widget for highlighting")
        except Exception as e:
            self.log(f"   ⚠️ Highlighting error: {e}")
        
        # Show status
        total_matches = tb_count + tm_count + nt_count
        self.statusBar().showMessage(f"🔄 Refreshed: {tb_count} termbase, {tm_count} TM, {nt_count} NT matches", 5000)
        self.log(f"🔄 Force refresh complete: {total_matches} total matches for segment {segment_id}")

    def show_goto_dialog(self):
        """Show minimal dialog to jump to a specific segment - just type and press Enter"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.information(self, "No Project", "Please open a project first.")
            return
        
        max_segment = len(self.current_project.segments)
        
        # Create a minimal dialog
        from PyQt6.QtWidgets import QDialog, QLineEdit, QHBoxLayout
        from PyQt6.QtCore import Qt
        from PyQt6.QtGui import QIntValidator
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Go to Segment")
        dialog.setFixedWidth(250)
        dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
        
        layout = QHBoxLayout(dialog)
        layout.setContentsMargins(12, 12, 12, 12)
        
        label = QLabel(f"Segment (1-{max_segment}):")
        layout.addWidget(label)
        
        input_field = QLineEdit()
        input_field.setValidator(QIntValidator(1, max_segment))
        input_field.setPlaceholderText(str(self.table.currentRow() + 1))
        input_field.setFixedWidth(80)
        input_field.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(input_field)
        
        # Store reference for the handler
        self._goto_dialog = dialog
        self._goto_input = input_field
        self._goto_max = max_segment
        
        # Press Enter to jump directly
        def on_return_pressed():
            text = self._goto_input.text().strip()
            if text:
                try:
                    segment_num = int(text)
                    if 1 <= segment_num <= self._goto_max:
                        self._goto_dialog.accept()
                except ValueError:
                    pass
        
        input_field.returnPressed.connect(on_return_pressed)
        
        # Focus the input field
        input_field.setFocus()
        
        # Show dialog and handle result
        if dialog.exec() == QDialog.DialogCode.Accepted:
            text = input_field.text().strip()
            if text:
                try:
                    segment_num = int(text)
                    row = segment_num - 1
                    
                    # If pagination is active, switch to the correct page first
                    if hasattr(self, 'page_size_combo') and self.page_size_combo.currentText() != "All":
                        page_size = int(self.page_size_combo.currentText())
                        target_page = (row // page_size) + 1
                        # Set the page input and trigger navigation
                        if hasattr(self, 'page_number_input'):
                            self.page_number_input.setText(str(target_page))
                            self.go_to_page()
                    
                    # Select the cell
                    self.table.setCurrentCell(row, 3)  # Jump to Target column
                    self.table.scrollToItem(self.table.item(row, 0), QTableWidget.ScrollHint.PositionAtCenter)
                    
                    # Focus the target cell widget and place cursor inside
                    target_widget = self.table.cellWidget(row, 3)
                    if target_widget and isinstance(target_widget, QTextEdit):
                        target_widget.setFocus()
                        cursor = target_widget.textCursor()
                        cursor.movePosition(cursor.MoveOperation.End)
                        target_widget.setTextCursor(cursor)
                    
                    self.log(f"Jumped to segment {segment_num}")
                except ValueError:
                    pass
    
    def show_project_info_dialog(self, initial_tab=0):
        """Show comprehensive project information dialog with tabs.

        Args:
            initial_tab: Tab index to show initially (0=Overview, 1=File Progress)
        """
        if not self.current_project:
            QMessageBox.information(self, "No Project", "Please open or create a project first.")
            return

        from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox,
                                      QScrollArea, QFrame, QTabWidget, QTableWidget,
                                      QTableWidgetItem, QHeaderView, QProgressBar)
        from PyQt6.QtCore import Qt
        from PyQt6.QtGui import QColor
        from datetime import datetime

        proj = self.current_project

        # Calculate statistics
        total_segments = len(proj.segments) if proj.segments else 0
        translated = sum(1 for s in proj.segments if s.target and s.target.strip()) if proj.segments else 0
        confirmed = sum(1 for s in proj.segments if s.status == "confirmed") if proj.segments else 0
        draft = sum(1 for s in proj.segments if s.status == "draft") if proj.segments else 0
        not_started = sum(1 for s in proj.segments if s.status in ("not_started", "")) if proj.segments else 0

        # Word counts
        source_words = sum(len(s.source.split()) for s in proj.segments if s.source) if proj.segments else 0
        target_words = sum(len(s.target.split()) for s in proj.segments if s.target) if proj.segments else 0

        # Character counts
        source_chars = sum(len(s.source) for s in proj.segments if s.source) if proj.segments else 0
        target_chars = sum(len(s.target) for s in proj.segments if s.target) if proj.segments else 0

        dialog = QDialog(self)
        dialog.setWindowTitle("Project Information")
        dialog.resize(750, 700)
        dialog.setMinimumWidth(600)
        dialog.setMinimumHeight(500)

        main_layout = QVBoxLayout(dialog)
        main_layout.setSpacing(10)
        main_layout.setContentsMargins(15, 15, 15, 15)

        # ═══════════════ Tab Widget ═══════════════
        tabs = QTabWidget()
        tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        tabs.tabBar().setDrawBase(False)
        tabs.tabBar().setExpanding(False)
        tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")

        # ═══════════════════════════════════════════════
        # TAB 1: Overview
        # ═══════════════════════════════════════════════
        overview_scroll = QScrollArea()
        overview_scroll.setWidgetResizable(True)
        overview_scroll.setFrameShape(QFrame.Shape.NoFrame)

        overview_content = QWidget()
        layout = QVBoxLayout(overview_content)
        layout.setSpacing(12)

        # ─── Project Overview ───
        overview_group = QGroupBox("Project Overview")
        overview_layout = QVBoxLayout(overview_group)

        overview_layout.addWidget(QLabel(f"<b>Name:</b> {proj.name}"))
        if hasattr(self, 'current_project_path') and self.current_project_path:
            overview_layout.addWidget(QLabel(f"<b>File:</b> <span style='color: #666;'>{self.current_project_path}</span>"))

        overview_layout.addWidget(QLabel(f"<b>Languages:</b> {proj.source_lang} → {proj.target_lang}"))

        try:
            created_dt = datetime.fromisoformat(proj.created) if proj.created else None
            created_str = created_dt.strftime("%Y-%m-%d %H:%M") if created_dt else "Unknown"
        except:
            created_str = proj.created or "Unknown"

        try:
            modified_dt = datetime.fromisoformat(proj.modified) if proj.modified else None
            modified_str = modified_dt.strftime("%Y-%m-%d %H:%M") if modified_dt else "Unknown"
        except:
            modified_str = proj.modified or "Unknown"

        overview_layout.addWidget(QLabel(f"<b>Created:</b> {created_str}"))
        overview_layout.addWidget(QLabel(f"<b>Modified:</b> {modified_str}"))

        if proj.id:
            overview_layout.addWidget(QLabel(f"<b>Project ID:</b> <span style='color: #999;'>{proj.id}</span>"))

        layout.addWidget(overview_group)

        # ─── Statistics ───
        stats_group = QGroupBox("Statistics")
        stats_main_layout = QHBoxLayout(stats_group)

        left_col = QVBoxLayout()
        progress_pct = (translated / total_segments * 100) if total_segments > 0 else 0
        left_col.addWidget(QLabel(f"<b>Total Segments:</b> {total_segments:,}"))
        left_col.addWidget(QLabel(f"<b>Translated:</b> {translated:,} ({progress_pct:.1f}%)"))
        left_col.addWidget(QLabel(f"<b>Confirmed:</b> {confirmed:,}"))
        left_col.addWidget(QLabel(f"<b>Draft:</b> {draft:,}"))
        left_col.addWidget(QLabel(f"<b>Not Started:</b> {not_started:,}"))
        left_col.addStretch()

        right_col = QVBoxLayout()
        right_col.addWidget(QLabel(f"<b>Source Words:</b> {source_words:,}"))
        right_col.addWidget(QLabel(f"<b>Target Words:</b> {target_words:,}"))
        right_col.addWidget(QLabel(f"<b>Source Characters:</b> {source_chars:,}"))
        right_col.addWidget(QLabel(f"<b>Target Characters:</b> {target_chars:,}"))
        right_col.addStretch()

        stats_main_layout.addLayout(left_col)
        stats_main_layout.addLayout(right_col)

        layout.addWidget(stats_group)

        # ─── Source Files ───
        has_sources = any([
            proj.original_docx_path, proj.memoq_source_path, proj.cafetran_source_path,
            proj.trados_source_path, proj.sdlppx_source_path,
            getattr(proj, 'sdlxliff_source_paths', None), proj.original_txt_path,
            proj.is_multifile
        ])

        if has_sources:
            source_group = QGroupBox("Source Files")
            source_layout = QVBoxLayout(source_group)

            if proj.is_multifile and proj.files:
                source_layout.addWidget(QLabel(f"<b>Multi-file Project:</b> {len(proj.files)} files"))
                for f in proj.files[:5]:
                    source_layout.addWidget(QLabel(f"  \u2022 {f.get('name', 'Unknown')}"))
                if len(proj.files) > 5:
                    source_layout.addWidget(QLabel(f"  <i>... and {len(proj.files) - 5} more</i>"))
            else:
                if proj.original_docx_path:
                    source_layout.addWidget(QLabel(f"<b>DOCX:</b> {proj.original_docx_path}"))
                if proj.memoq_source_path:
                    source_layout.addWidget(QLabel(f"<b>memoQ:</b> {proj.memoq_source_path}"))
                if proj.cafetran_source_path:
                    source_layout.addWidget(QLabel(f"<b>CafeTran:</b> {proj.cafetran_source_path}"))
                if proj.trados_source_path:
                    source_layout.addWidget(QLabel(f"<b>Trados DOCX:</b> {proj.trados_source_path}"))
                if proj.sdlppx_source_path:
                    source_layout.addWidget(QLabel(f"<b>Trados Package:</b> {proj.sdlppx_source_path}"))
                sdlxliff_paths = getattr(proj, 'sdlxliff_source_paths', None)
                if sdlxliff_paths:
                    if len(sdlxliff_paths) == 1:
                        source_layout.addWidget(QLabel(f"<b>SDLXLIFF:</b> {sdlxliff_paths[0]}"))
                    else:
                        source_layout.addWidget(QLabel(f"<b>SDLXLIFF:</b> {len(sdlxliff_paths)} files"))
                        for p in sdlxliff_paths[:3]:
                            source_layout.addWidget(QLabel(f"  - {Path(p).name}"))
                        if len(sdlxliff_paths) > 3:
                            source_layout.addWidget(QLabel(f"  ... and {len(sdlxliff_paths) - 3} more"))
                if proj.original_txt_path:
                    source_layout.addWidget(QLabel(f"<b>Text File:</b> {proj.original_txt_path}"))

            layout.addWidget(source_group)

        # ─── AI & Prompts ───
        ai_group = QGroupBox("AI & Prompts")
        ai_layout = QVBoxLayout(ai_group)

        primary_prompt_text = "None"
        attached_count = 0
        if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
            library = self.prompt_manager_qt.library
            if library.active_primary_prompt_path:
                prompt_path = library.active_primary_prompt_path
                if prompt_path.startswith("[EXTERNAL]"):
                    import os
                    primary_prompt_text = f"{os.path.basename(prompt_path.replace('[EXTERNAL] ', ''))}"
                else:
                    primary_prompt_text = f"{prompt_path}"
            attached_count = len(library.attached_prompt_paths) if library.attached_prompt_paths else 0

        ai_layout.addWidget(QLabel(f"<b>Custom Prompt:</b> {primary_prompt_text}"))
        if attached_count > 0:
            ai_layout.addWidget(QLabel(f"<b>Attached Prompts:</b> {attached_count}"))

        layout.addWidget(ai_group)

        # ─── Translation Memories ───
        tm_group = QGroupBox("Translation Memories")
        tm_layout = QVBoxLayout(tm_group)

        active_tm_info = []
        if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr:
            try:
                all_tms = self.tm_metadata_mgr.get_all_tms()
                for tm in all_tms:
                    if self.tm_metadata_mgr.is_tm_active(tm['id'], proj.id):
                        active_tm_info.append((tm['name'], tm.get('entry_count', 0)))
            except Exception as e:
                self.log(f"Error getting TM info: {e}")

        if active_tm_info:
            for tm_name, tu_count in active_tm_info:
                tm_layout.addWidget(QLabel(f"<b>{tm_name}</b> ({tu_count:,} TUs)"))
        else:
            tm_layout.addWidget(QLabel("<span style='color: #999;'>No TMs activated</span>"))

        layout.addWidget(tm_group)

        # ─── Glossaries ───
        gloss_group = QGroupBox("Termbases")
        gloss_layout = QVBoxLayout(gloss_group)

        active_tb_info = []
        if hasattr(self, 'termbase_mgr') and self.termbase_mgr:
            try:
                all_termbases = self.termbase_mgr.get_all_termbases()
                for tb in all_termbases:
                    if self.termbase_mgr.is_termbase_active(tb['id'], proj.id):
                        active_tb_info.append((tb['name'], tb.get('term_count', 0)))
            except Exception as e:
                self.log(f"Error getting termbase info: {e}")

        if active_tb_info:
            for tb_name, term_count in active_tb_info:
                gloss_layout.addWidget(QLabel(f"<b>{tb_name}</b> ({term_count:,} terms)"))
        else:
            gloss_layout.addWidget(QLabel("<span style='color: #999;'>No termbases activated</span>"))

        layout.addWidget(gloss_group)

        # ─── Image Context ───
        image_group = QGroupBox("Image Context")
        image_layout = QVBoxLayout(image_group)

        if hasattr(self, 'figure_context') and self.figure_context and self.figure_context.has_images():
            count = self.figure_context.get_image_count()
            folder = self.figure_context.get_folder_name() or "Unknown folder"
            image_layout.addWidget(QLabel(f"<b>{count} image{'s' if count != 1 else ''}</b> loaded from: {folder}"))
        else:
            image_layout.addWidget(QLabel("<span style='color: #999;'>No images loaded</span>"))

        layout.addWidget(image_group)

        # ─── Other Settings ───
        settings_group = QGroupBox("Other Settings")
        settings_layout = QVBoxLayout(settings_group)

        if hasattr(self, 'spellcheck_manager') and self.spellcheck_manager:
            sp_enabled = self.spellcheck_manager.enabled
            sp_lang = self.spellcheck_manager._current_language or "Not set"
            sp_status = f"Enabled ({sp_lang})" if sp_enabled else "Disabled"
            settings_layout.addWidget(QLabel(f"<b>Spellcheck:</b> {sp_status}"))
        else:
            settings_layout.addWidget(QLabel(f"<b>Spellcheck:</b> Not available"))

        layout.addWidget(settings_group)

        layout.addStretch()

        overview_scroll.setWidget(overview_content)
        tabs.addTab(overview_scroll, "Overview")

        # ═══════════════════════════════════════════════
        # TAB 2: File Progress
        # ═══════════════════════════════════════════════
        progress_tab = QWidget()
        progress_layout = QVBoxLayout(progress_tab)

        is_multifile = getattr(proj, 'is_multifile', False)
        files = getattr(proj, 'files', [])

        if not is_multifile or not files:
            # Single-file project
            info_label = QLabel(
                "<b>Single-File Project</b><br><br>"
                "This is a single-file project. Multi-file progress tracking is available "
                "when you import a folder using:<br>"
                "<i>File \u2192 Import \u2192 Folder (Multiple Files)...</i>"
            )
            info_label.setWordWrap(True)
            info_label.setTextFormat(Qt.TextFormat.RichText)
            progress_layout.addWidget(info_label)
            progress_layout.addSpacing(20)
        else:
            # Multi-file header
            info_label = QLabel(
                f"<b>Multi-File Project: {len(files)} files</b><br>"
                f"Project: {proj.name}"
            )
            info_label.setTextFormat(Qt.TextFormat.RichText)
            progress_layout.addWidget(info_label)
            progress_layout.addSpacing(10)

        # Overall progress section (shown for both single and multi-file)
        self._add_overall_progress_section(progress_layout)

        if is_multifile and files:
            progress_layout.addSpacing(10)

            # Per-file progress table
            files_group = QGroupBox("Per-File Progress")
            files_layout = QVBoxLayout(files_group)

            table = QTableWidget()
            table.setColumnCount(7)
            table.setHorizontalHeaderLabels([
                "File", "Segments", "Words", "Translated", "Confirmed", "Progress", "Status"
            ])
            table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
            table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed)
            table.setColumnWidth(5, 120)
            table.setRowCount(len(files))
            table.setAlternatingRowColors(True)
            table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
            table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)

            segments = proj.segments
            confirmed_statuses = {'confirmed', 'proofread', 'approved'}

            for row, file_info in enumerate(files):
                file_id = file_info['id']
                file_name = file_info['name']

                file_segments = [s for s in segments if getattr(s, 'file_id', None) == file_id]
                total_segs = len(file_segments)

                total_words = sum(len(s.source.split()) for s in file_segments if s.source)
                translated_segs = sum(1 for s in file_segments if s.target and s.target.strip())
                confirmed_segs = sum(1 for s in file_segments if s.status in confirmed_statuses)

                trans_percent = (translated_segs / total_segs * 100) if total_segs > 0 else 0
                conf_percent = (confirmed_segs / total_segs * 100) if total_segs > 0 else 0

                if conf_percent == 100:
                    status = "Complete"
                    status_color = "#4CAF50"
                elif trans_percent == 100:
                    status = "Translated"
                    status_color = "#2196F3"
                elif trans_percent > 0:
                    status = "In Progress"
                    status_color = "#FF9800"
                else:
                    status = "Not Started"
                    status_color = "#9E9E9E"

                table.setItem(row, 0, QTableWidgetItem(file_name))
                table.setItem(row, 1, QTableWidgetItem(str(total_segs)))
                table.setItem(row, 2, QTableWidgetItem(str(total_words)))
                table.setItem(row, 3, QTableWidgetItem(f"{translated_segs}/{total_segs} ({trans_percent:.0f}%)"))
                table.setItem(row, 4, QTableWidgetItem(f"{confirmed_segs}/{total_segs} ({conf_percent:.0f}%)"))

                progress_bar = QProgressBar()
                progress_bar.setMinimum(0)
                progress_bar.setMaximum(100)
                progress_bar.setValue(int(conf_percent))
                progress_bar.setTextVisible(True)
                progress_bar.setFormat(f"{conf_percent:.0f}%")
                table.setCellWidget(row, 5, progress_bar)

                status_item = QTableWidgetItem(status)
                status_item.setForeground(QColor(status_color))
                table.setItem(row, 6, status_item)

                trans_item = table.item(row, 3)
                if trans_percent == 100:
                    trans_item.setForeground(QColor("#4CAF50"))
                elif trans_percent > 0:
                    trans_item.setForeground(QColor("#FF9800"))

            files_layout.addWidget(table)

            # Double-click to navigate
            def on_file_double_click(row, col):
                file_info = files[row]
                file_id = file_info['id']
                file_name = file_info['name']

                reply = QMessageBox.question(
                    dialog,
                    "Navigate to File",
                    f"Go to the first segment of:\n{file_name}?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if reply == QMessageBox.StandardButton.Yes:
                    for seg in segments:
                        if getattr(seg, 'file_id', None) == file_id:
                            self.go_to_segment(seg.id)
                            dialog.accept()
                            return

            table.cellDoubleClicked.connect(on_file_double_click)

            progress_layout.addWidget(files_group)

        tabs.addTab(progress_tab, "File Progress")

        # Set initial tab
        if initial_tab < tabs.count():
            tabs.setCurrentIndex(initial_tab)

        main_layout.addWidget(tabs)

        # ═══════════════ OK Button ═══════════════
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        ok_btn = QPushButton("OK")
        ok_btn.setDefault(True)
        ok_btn.clicked.connect(dialog.accept)
        button_layout.addWidget(ok_btn)
        main_layout.addLayout(button_layout)

        dialog.exec()
    
    def show_search_dialog(self):
        """Show enhanced search dialog with source/target options"""
        if not self.current_project or not self.current_project.segments:
            QMessageBox.information(self, "No Project", "Please open a project first.")
            return
        
        from PyQt6.QtWidgets import QDialog, QLineEdit, QRadioButton, QButtonGroup, QDialogButtonBox
        
        dialog = QDialog(self)
        dialog.setWindowTitle("Find in Segments")
        dialog.setMinimumWidth(400)
        
        layout = QVBoxLayout(dialog)
        
        # Search text input
        search_layout = QHBoxLayout()
        search_layout.addWidget(QLabel("Search for:"))
        search_input = QLineEdit()
        search_layout.addWidget(search_input)
        layout.addLayout(search_layout)
        
        # Search scope options
        scope_label = QLabel("Search in:")
        layout.addWidget(scope_label)
        
        radio_layout = QHBoxLayout()
        search_scope_group = QButtonGroup(dialog)
        
        both_radio = CheckmarkRadioButton("Both Source and Target")
        both_radio.setChecked(True)
        source_radio = CheckmarkRadioButton("Source only")
        target_radio = CheckmarkRadioButton("Target only")
        
        search_scope_group.addButton(both_radio, 0)
        search_scope_group.addButton(source_radio, 1)
        search_scope_group.addButton(target_radio, 2)
        
        radio_layout.addWidget(both_radio)
        radio_layout.addWidget(source_radio)
        radio_layout.addWidget(target_radio)
        layout.addLayout(radio_layout)
        
        # Dialog buttons
        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        button_box.accepted.connect(dialog.accept)
        button_box.rejected.connect(dialog.reject)
        layout.addWidget(button_box)
        
        # Show dialog and process
        if dialog.exec() == QDialog.DialogCode.Accepted:
            search_text = search_input.text()
            if search_text:
                scope = search_scope_group.checkedId()
                self.search_segments(search_text, scope)
    
    def search_segments(self, search_text: str, scope: int = 0):
        """Search for text in segments and highlight ONLY the exact search term
        
        Args:
            search_text: Text to search for
            scope: 0=both, 1=source only, 2=target only
        """
        if not self.current_project:
            return
        
        self._ensure_primary_filters_ready()

        # Populate filter boxes based on scope
        if scope == 0:  # Both
            self.source_filter.setText(search_text)
            self.target_filter.setText(search_text)
        elif scope == 1:  # Source only
            self.source_filter.setText(search_text)
            self.target_filter.clear()
        elif scope == 2:  # Target only
            self.source_filter.clear()
            self.target_filter.setText(search_text)
        
        # The filter boxes will trigger apply_filters() automatically
        # which handles highlighting and hiding
        
        # Find first match and jump to it
        search_lower = search_text.lower()
        for row, segment in enumerate(self.current_project.segments):
            source_match = scope != 2 and search_lower in segment.source.lower()
            target_match = scope != 1 and search_lower in segment.target.lower()
            
            if source_match or target_match:
                self.table.setCurrentCell(row, 3)
                self.table.scrollToItem(self.table.item(row, 3))
                break
    
    def _highlight_text_in_widget(self, row: int, col: int, search_term: str):
        """Highlight search term within a QTextEdit cell widget.
        
        Since source/target cells use setCellWidget() with QTextEdit editors,
        the delegate's paint() method is bypassed. We must highlight the text
        directly within the widget using QTextCursor and QTextCharFormat.
        
        NOTE: This method only ADDS yellow highlights - it does not clear existing
        formatting. Use _clear_filter_highlights_in_widget() to remove highlights.
        """
        widget = self.table.cellWidget(row, col)
        if not widget or not hasattr(widget, 'document'):
            return
        
        # Create yellow highlight format
        highlight_format = QTextCharFormat()
        highlight_format.setBackground(QColor("#FFFF00"))  # Yellow background
        
        # Find and highlight all occurrences (case-insensitive)
        document = widget.document()
        cursor = QTextCursor(document)
        
        search_term_lower = search_term.lower()
        text = document.toPlainText()
        text_lower = text.lower()
        
        # Find all occurrences
        pos = 0
        while True:
            pos = text_lower.find(search_term_lower, pos)
            if pos == -1:
                break
            
            # Select the match and apply highlight
            cursor.setPosition(pos)
            cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(search_term))
            cursor.mergeCharFormat(highlight_format)
            
            pos += len(search_term)
    
    def _clear_filter_highlights_in_widget(self, row: int, col: int):
        """Remove yellow filter highlights from a QTextEdit widget while preserving other formatting.
        
        This is more efficient than reloading the entire grid just to clear highlights.
        """
        widget = self.table.cellWidget(row, col)
        if not widget or not hasattr(widget, 'document'):
            return
        
        # Iterate through the document and clear only yellow backgrounds
        document = widget.document()
        cursor = QTextCursor(document)
        cursor.movePosition(QTextCursor.MoveOperation.Start)
        
        # Select the entire document
        cursor.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
        
        # Get current block and iterate character by character
        # For efficiency, we'll just clear all backgrounds that are yellow
        # This preserves termbase highlights (which are green shades) and tag colors
        block = document.begin()
        while block.isValid():
            it = block.begin()
            while not it.atEnd():
                fragment = it.fragment()
                if fragment.isValid():
                    fmt = fragment.charFormat()
                    bg = fmt.background().color()
                    # Check if it's a yellow highlight (filter highlight)
                    if bg.name().upper() == "#FFFF00":
                        # Clear this specific range
                        cursor.setPosition(fragment.position())
                        cursor.setPosition(fragment.position() + fragment.length(), QTextCursor.MoveMode.KeepAnchor)
                        clear_fmt = QTextCharFormat()
                        clear_fmt.setBackground(QColor(Qt.GlobalColor.transparent))
                        cursor.mergeCharFormat(clear_fmt)
                it += 1
            block = block.next()
    
    def apply_filters(self):
        """Apply source and target filters to show/hide rows and highlight matches.
        
        OPTIMIZED: No longer reloads the entire grid. Instead:
        1. Clears only filter highlights (preserves termbase/tag formatting)
        2. Shows/hides rows in place
        3. Applies new highlights to matching text
        """
        if not self.current_project:
            return
        
        # Safety check: ensure table and filter widgets exist
        if not hasattr(self, 'table') or self.table is None:
            return
        has_source = self._widget_is_alive(getattr(self, 'source_filter', None))
        has_target = self._widget_is_alive(getattr(self, 'target_filter', None))
        if not has_source and not has_target:
            return

        source_filter_text = self._get_line_edit_text('source_filter').strip()
        target_filter_text = self._get_line_edit_text('target_filter').strip()
        
        # If both empty, clear everything
        if not source_filter_text and not target_filter_text:
            self.clear_filters()
            return
        
        # Set flag to disable auto-center scrolling during filtering
        self.filtering_active = True

        # Track which rows match the active text filters so pagination can respect filtering
        matching_rows: set[int] = set()
        
        # Batch UI updates for performance
        self.table.setUpdatesEnabled(False)
        
        try:
            visible_count = 0
            segments = self.current_project.segments
            total_segments = len(segments)
            
            # Pre-compute lowercase filter texts
            source_filter_lower = source_filter_text.lower() if source_filter_text else None
            target_filter_lower = target_filter_text.lower() if target_filter_text else None
            
            # IMPORTANT: Always search through ALL segments, not just visible rows
            # Pagination state should not affect which segments we search
            for row in range(total_segments):
                if row >= total_segments:
                    break
                    
                segment = segments[row]
                source_lower = segment.source.lower()
                target_lower = segment.target.lower()
                
                source_match = not source_filter_lower or source_filter_lower in source_lower
                target_match = not target_filter_lower or target_filter_lower in target_lower
                
                show_row = source_match and target_match

                if show_row:
                    matching_rows.add(row)
                
                if show_row:
                    visible_count += 1
                    
                    # Clear previous filter highlights first (only yellow, preserves other formatting)
                    self._clear_filter_highlights_in_widget(row, 2)
                    self._clear_filter_highlights_in_widget(row, 3)
                    
                    # Highlight matching terms in the QTextEdit widgets
                    if source_filter_lower and source_filter_lower in source_lower:
                        self._highlight_text_in_widget(row, 2, source_filter_text)
                    
                    if target_filter_lower and target_filter_lower in target_lower:
                        self._highlight_text_in_widget(row, 3, target_filter_text)
                else:
                    # Clear highlights from hidden rows too (for when they become visible again)
                    self._clear_filter_highlights_in_widget(row, 2)
                    self._clear_filter_highlights_in_widget(row, 3)
        finally:
            # Re-enable UI updates
            self.table.setUpdatesEnabled(True)

        # Persist allowlist and apply combined pagination+filter visibility
        self._active_text_filter_rows = matching_rows
        if hasattr(self, '_apply_pagination_to_grid'):
            self._apply_pagination_to_grid()

        # Update status
        if source_filter_text or target_filter_text:
            self.log(f"Filter applied: showing {visible_count} of {len(self.current_project.segments)} segments")

    def clear_filters(self):
        """Clear all filter boxes, highlighting, and show all rows.
        
        OPTIMIZED: No longer reloads the entire grid. Instead:
        1. Clears only yellow filter highlights (preserves termbase/tag formatting)
        2. Shows all rows in place
        3. Much faster than reloading all widgets
        """
        source_widget = getattr(self, 'source_filter', None)
        target_widget = getattr(self, 'target_filter', None)
        has_source = self._widget_is_alive(source_widget)
        has_target = self._widget_is_alive(target_widget)
        if not has_source and not has_target:
            return

        # Clear delegate highlights and global search terms
        if hasattr(self, 'table') and self.table is not None:
            delegate = self.table.itemDelegate()
            if delegate:
                if hasattr(delegate, 'clear_all_highlights'):
                    delegate.clear_all_highlights()
                # Clear global search terms
                delegate.global_search_term = None
                delegate.global_source_search_term = None

        # Remember which segment was selected before clearing
        selected_segment_id = None
        current_column = 3  # Default to target column
        if self.current_project and hasattr(self, 'table') and self.table is not None:
            current_row = self.table.currentRow()
            current_column = self.table.currentColumn()
            
            if current_row >= 0:
                # Get segment ID directly from the ID cell (column 0)
                id_item = self.table.item(current_row, 0)
                if id_item:
                    try:
                        selected_segment_id = int(id_item.text())
                    except (ValueError, AttributeError):
                        pass

        if has_source:
            source_widget.blockSignals(True)
            source_widget.clear()
            source_widget.blockSignals(False)
        else:
            self.source_filter = None

        if has_target:
            target_widget.blockSignals(True)
            target_widget.clear()
            target_widget.blockSignals(False)
        else:
            self.target_filter = None

        # Clear any active text-filter allowlist so pagination can show rows normally
        self._active_text_filter_rows = None
        
        # OPTIMIZED: Clear highlights and show rows WITHOUT reloading grid
        if self.current_project:
            # Safety check: ensure table exists
            if not hasattr(self, 'table') or self.table is None:
                return
            
            # Batch UI updates for performance
            self.table.setUpdatesEnabled(False)
            
            try:
                row_count = self.table.rowCount()
                
                for row in range(row_count):
                    # Clear yellow filter highlights (preserves other formatting)
                    self._clear_filter_highlights_in_widget(row, 2)  # Source
                    self._clear_filter_highlights_in_widget(row, 3)  # Target
                    
                    # Show all rows
                    self.table.setRowHidden(row, False)
            finally:
                # Re-enable UI updates
                self.table.setUpdatesEnabled(True)
            
            # Clear filtering flag to re-enable auto-center
            self.filtering_active = False

            # Resolve the previously selected segment's row index BEFORE we
            # re-apply pagination. If pagination is on, we need to switch to
            # the page that contains this row first; otherwise the row stays
            # hidden and the scroll/select call below silently no-ops on a
            # hidden cell, leaving the user looking at page 1 even though
            # they were working at the end of the project.
            target_row = None
            if selected_segment_id is not None:
                for row, segment in enumerate(self.current_project.segments):
                    if segment.id == selected_segment_id:
                        target_row = row
                        break

            page_size = getattr(self, 'grid_page_size', 50)
            if target_row is not None and page_size and page_size < 999999:
                target_page = target_row // page_size
                if getattr(self, 'grid_current_page', 0) != target_page:
                    self.grid_current_page = target_page

            # Re-apply pagination after clearing filters (also resizes visible rows)
            if hasattr(self, '_apply_pagination_to_grid'):
                self._apply_pagination_to_grid()

            # Restore selection to the previously selected segment.
            # PositionAtCenter centres it in the viewport so the user lands
            # back where they were looking, not at the top of the page.
            if target_row is not None:
                self.table.setCurrentCell(target_row, current_column)
                self.table.scrollToItem(
                    self.table.item(target_row, 0),
                    QTableWidget.ScrollHint.PositionAtCenter
                )
        
        # Reset file filter to "All Files"
        if hasattr(self, 'file_filter_combo') and self.file_filter_combo:
            self.file_filter_combo.blockSignals(True)
            self.file_filter_combo.setCurrentIndex(0)
            self.file_filter_combo.blockSignals(False)
        
        self.log("Filters cleared")
    
    def _on_file_filter_changed(self, index):
        """Handle file filter dropdown change for multi-file projects."""
        if not self.current_project or not hasattr(self, 'file_filter_combo'):
            return

        data = self.file_filter_combo.currentData()

        # "Manage Views..." action
        if data == "manage_views":
            # Reset to previous selection (All Files) before opening dialog
            self.file_filter_combo.blockSignals(True)
            self.file_filter_combo.setCurrentIndex(0)
            self.file_filter_combo.blockSignals(False)
            self.show_manage_views_dialog()
            return

        if data is None:
            # "All Files" selected - re-apply pagination (keeps empty segments hidden)
            if hasattr(self, '_apply_pagination_to_grid'):
                self._apply_pagination_to_grid()
            self._update_file_boundary_labels()
            self.log("File filter: showing all files")
            return

        if not hasattr(self, 'table') or not self.table:
            return

        # View selected (dict with view_file_ids)
        if isinstance(data, dict) and 'view_file_ids' in data:
            view_file_ids = set(data['view_file_ids'])
            visible_count = 0
            for row, segment in enumerate(self.current_project.segments):
                if row >= self.table.rowCount():
                    break
                segment_file_id = getattr(segment, 'file_id', None)
                show_row = segment_file_id in view_file_ids and segment.source.strip()
                self.table.setRowHidden(row, not show_row)
                if show_row:
                    visible_count += 1
            self.log(f"View filter: showing {visible_count} segments from {len(view_file_ids)} files")
            self._update_file_boundary_labels()
            return

        # Single file selected (int file_id)
        file_id = data
        visible_count = 0
        for row, segment in enumerate(self.current_project.segments):
            if row >= self.table.rowCount():
                break
            segment_file_id = getattr(segment, 'file_id', None)
            show_row = segment_file_id == file_id and segment.source.strip()
            self.table.setRowHidden(row, not show_row)
            if show_row:
                visible_count += 1

        file_name = "Unknown"
        files = getattr(self.current_project, 'files', [])
        for f in files:
            if f['id'] == file_id:
                file_name = f['name']
                break

        self.log(f"File filter: showing {visible_count} segments from '{file_name}'")
        self._update_file_boundary_labels()

    def _update_file_filter_combo(self):
        """Update the file filter dropdown with files and views from the current project."""
        if not hasattr(self, 'file_filter_combo') or not self.file_filter_combo:
            return

        self.file_filter_combo.blockSignals(True)
        self.file_filter_combo.clear()
        self.file_filter_combo.addItem("All Files", None)

        is_multifile = getattr(self.current_project, 'is_multifile', False) if self.current_project else False
        files = getattr(self.current_project, 'files', []) if self.current_project else []

        if is_multifile and files:
            # Saved views section
            views = getattr(self.current_project, 'views', []) or []
            if views:
                self.file_filter_combo.insertSeparator(self.file_filter_combo.count())
                for view in views:
                    view_name = view.get('name', 'Unnamed View')
                    file_count = len(view.get('file_ids', []))
                    self.file_filter_combo.addItem(
                        f"\U0001F441 {view_name} ({file_count} files)",
                        {"view_file_ids": view.get('file_ids', [])}
                    )

            # Individual files section
            self.file_filter_combo.insertSeparator(self.file_filter_combo.count())
            for file_info in files:
                file_id = file_info['id']
                file_name = file_info['name']
                seg_count = file_info.get('segment_count', 0)
                self.file_filter_combo.addItem(f"\U0001F4C4 {file_name} ({seg_count} seg)", file_id)

            # Manage Views action
            self.file_filter_combo.insertSeparator(self.file_filter_combo.count())
            self.file_filter_combo.addItem("Manage Views...", "manage_views")

            self.file_filter_combo.show()
        else:
            self.file_filter_combo.hide()

        self.file_filter_combo.blockSignals(False)

    def show_manage_views_dialog(self):
        """Show dialog to create, edit, and delete views for multi-file projects."""
        from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
                                      QTableWidget, QTableWidgetItem, QHeaderView,
                                      QPushButton, QLineEdit, QCheckBox, QGroupBox,
                                      QInputDialog)
        from PyQt6.QtCore import Qt

        if not self.current_project or not getattr(self.current_project, 'is_multifile', False):
            QMessageBox.information(self, "Not Available",
                                    "Views are only available for multi-file projects.")
            return

        proj = self.current_project
        files = getattr(proj, 'files', []) or []
        if not files:
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Manage Views")
        dialog.setMinimumWidth(550)
        dialog.setMinimumHeight(400)

        layout = QVBoxLayout(dialog)

        info_label = QLabel(
            "Views let you work on a subset of files in a multi-file project.\n"
            "Create a view by selecting files, then use the file filter dropdown to activate it."
        )
        info_label.setWordWrap(True)
        layout.addWidget(info_label)

        layout.addSpacing(10)

        # ─── Existing Views Table ───
        views = getattr(proj, 'views', []) or []

        views_group = QGroupBox(f"Saved Views ({len(views)})")
        views_layout = QVBoxLayout(views_group)

        views_table = QTableWidget()
        views_table.setColumnCount(3)
        views_table.setHorizontalHeaderLabels(["View Name", "Files", "Segments"])
        views_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        views_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        views_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        views_table.setAlternatingRowColors(True)

        def refresh_views_table():
            current_views = getattr(proj, 'views', []) or []
            views_table.setRowCount(len(current_views))
            views_group.setTitle(f"Saved Views ({len(current_views)})")
            for row, view in enumerate(current_views):
                views_table.setItem(row, 0, QTableWidgetItem(view.get('name', '')))
                view_file_ids = set(view.get('file_ids', []))
                file_names = [f.get('name', '?') for f in files if f['id'] in view_file_ids]
                views_table.setItem(row, 1, QTableWidgetItem(", ".join(file_names)))
                seg_count = sum(
                    1 for s in proj.segments
                    if getattr(s, 'file_id', None) in view_file_ids
                )
                views_table.setItem(row, 2, QTableWidgetItem(str(seg_count)))

        refresh_views_table()
        views_layout.addWidget(views_table)

        # View action buttons
        view_btn_layout = QHBoxLayout()

        def delete_selected_view():
            row = views_table.currentRow()
            if row < 0:
                return
            current_views = getattr(proj, 'views', []) or []
            if row >= len(current_views):
                return
            view_name = current_views[row].get('name', 'this view')
            reply = QMessageBox.question(
                dialog, "Delete View",
                f"Delete view \"{view_name}\"?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.Yes:
                current_views.pop(row)
                proj.views = current_views
                refresh_views_table()
                self._update_file_filter_combo()

        delete_btn = QPushButton("Delete Selected")
        delete_btn.clicked.connect(delete_selected_view)
        view_btn_layout.addWidget(delete_btn)
        view_btn_layout.addStretch()
        views_layout.addLayout(view_btn_layout)

        layout.addWidget(views_group)

        # ─── Create New View ───
        create_group = QGroupBox("Create New View")
        create_layout = QVBoxLayout(create_group)

        name_layout = QHBoxLayout()
        name_layout.addWidget(QLabel("Name:"))
        name_input = QLineEdit()
        name_input.setPlaceholderText("e.g. Chapters 1-3")
        name_layout.addWidget(name_input)
        create_layout.addLayout(name_layout)

        create_layout.addWidget(QLabel("Select files to include:"))

        # File checkboxes
        file_checkboxes = []
        for file_info in files:
            file_id = file_info['id']
            file_name = file_info['name']
            seg_count = file_info.get('segment_count', 0)
            cb = CheckmarkCheckBox(f"{file_name} ({seg_count} segments)")
            cb.setProperty("file_id", file_id)
            file_checkboxes.append(cb)
            create_layout.addWidget(cb)

        # Select all / none
        sel_layout = QHBoxLayout()
        select_all_btn = QPushButton("Select All")
        select_all_btn.clicked.connect(lambda: [cb.setChecked(True) for cb in file_checkboxes])
        select_none_btn = QPushButton("Select None")
        select_none_btn.clicked.connect(lambda: [cb.setChecked(False) for cb in file_checkboxes])
        sel_layout.addWidget(select_all_btn)
        sel_layout.addWidget(select_none_btn)
        sel_layout.addStretch()
        create_layout.addLayout(sel_layout)

        def create_view():
            name = name_input.text().strip()
            if not name:
                QMessageBox.warning(dialog, "Name Required", "Please enter a name for the view.")
                return
            selected_ids = [
                cb.property("file_id") for cb in file_checkboxes if cb.isChecked()
            ]
            if not selected_ids:
                QMessageBox.warning(dialog, "No Files Selected",
                                    "Please select at least one file for the view.")
                return
            if proj.views is None:
                proj.views = []
            proj.views.append({"name": name, "file_ids": selected_ids})
            name_input.clear()
            for cb in file_checkboxes:
                cb.setChecked(False)
            refresh_views_table()
            self._update_file_filter_combo()
            self.log(f"Created view '{name}' with {len(selected_ids)} files")

        create_btn = QPushButton("Create View")
        create_btn.clicked.connect(create_view)
        create_layout.addWidget(create_btn)

        layout.addWidget(create_group)

        # ─── Close button ───
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        button_layout.addWidget(close_btn)
        layout.addLayout(button_layout)

        dialog.exec()

    def toggle_invisible_display(self, char_type):
        """Toggle display of specific invisible character type"""
        if not hasattr(self, 'invisible_display_settings'):
            self.invisible_display_settings = {
                'spaces': False,
                'tabs': False,
                'nbsp': False,
                'linebreaks': False
            }

        # Toggle the setting
        self.invisible_display_settings[char_type] = not self.invisible_display_settings[char_type]

        # Refresh the grid to show/hide invisibles
        self.refresh_grid_invisibles()

        # Log the change
        status = "enabled" if self.invisible_display_settings[char_type] else "disabled"
        char_names = {
            'spaces': 'Spaces',
            'tabs': 'Tabs',
            'nbsp': 'Non-breaking Spaces',
            'linebreaks': 'Line Breaks'
        }
        self.log(f"Show invisibles: {char_names[char_type]} {status}")

    def toggle_all_invisibles(self):
        """Toggle all invisible characters on or off"""
        if not hasattr(self, 'invisible_display_settings'):
            self.invisible_display_settings = {
                'spaces': False,
                'tabs': False,
                'nbsp': False,
                'linebreaks': False
            }

        # Check if any are currently on
        any_on = any(self.invisible_display_settings.values())

        # Toggle all to opposite state
        new_state = not any_on
        self.invisible_display_settings = {
            'spaces': new_state,
            'tabs': new_state,
            'nbsp': new_state,
            'linebreaks': new_state
        }

        # Update menu checkboxes – block signals so setChecked() does NOT re-fire
        # toggle_invisible_display() and cause 4 extra grid reloads
        for action_attr in ('show_spaces_action', 'show_tabs_action',
                            'show_nbsp_action', 'show_linebreaks_action'):
            action = getattr(self, action_attr, None)
            if action is not None:
                action.blockSignals(True)
                action.setChecked(new_state)
                action.blockSignals(False)

        # Refresh the grid (in-place, no full reload)
        self.refresh_grid_invisibles()

        status = "enabled" if new_state else "disabled"
        self.log(f"Show invisibles: All {status}")

    def refresh_grid_invisibles(self):
        """Refresh invisible-character display in-place without reloading the grid.

        Iterates existing cell widgets and re-applies (or removes) invisible-character
        substitutions directly, avoiding the expensive full grid rebuild that
        load_segments_to_grid() would trigger.

        Signal handling strategy
        -----------------------
        We call setPlainText() with Qt signals blocked on each widget.  Unlike
        load_segments_to_grid() we do NOT unblock signals afterwards – the widgets
        already have their textChanged handlers connected from the original grid
        load, and those handlers remain active for future user edits.  Keeping
        signals blocked only for the duration of setPlainText() (and the brief
        queued-event window) prevents stale invisible markers from being written
        back into segment.target.

        We also raise _suppress_target_change_handlers as a belt-and-braces guard
        so that any signal that does slip through (e.g. on an already-focused cell
        whose queued event fires before blockSignals takes effect) is silently
        ignored by the on_target_text_changed handler.
        """
        if not hasattr(self, 'table') or not self.table:
            return
        if not hasattr(self, 'invisible_display_settings'):
            return
        if not hasattr(self, 'current_project') or not self.current_project:
            return

        # Update the legacy boolean used by word-wrap logic
        self.showing_invisible_spaces = self.invisible_display_settings.get('spaces', False)

        segments = self.current_project.segments
        row_count = self.table.rowCount()

        # Suppress the target-changed handler for all cells during the refresh
        old_suppress = getattr(self, '_suppress_target_change_handlers', False)
        self._suppress_target_change_handlers = True
        try:
            for row in range(row_count):
                if row >= len(segments):
                    break
                segment = segments[row]

                # --- Source column (col 2) – read-only, no save risk ---
                source_widget = self.table.cellWidget(row, 2)
                if source_widget is not None:
                    source_for_display = segment.source
                    if self.hide_outer_wrapping_tags:
                        stripped, _ = strip_outer_wrapping_tags(source_for_display)
                        source_for_display = stripped
                    if getattr(self, 'tag_view_mode', 'tags') == 'compact':
                        source_for_display = compact_tags(source_for_display)
                    new_source_text = self.apply_invisible_replacements(source_for_display)
                    source_widget.blockSignals(True)
                    source_widget.setPlainText(new_source_text)
                    source_widget.blockSignals(False)

                # --- Target column (col 3) ---
                # Always use segment.target (the clean, marker-free canonical text)
                # as the source of truth, then re-apply current marker settings.
                target_widget = self.table.cellWidget(row, 3)
                if target_widget is not None:
                    target_for_display = segment.target
                    if self.hide_outer_wrapping_tags:
                        stripped, _ = strip_outer_wrapping_tags(target_for_display)
                        target_for_display = stripped
                    # Apply compact tag shortening (display only – reversed before saving)
                    if getattr(self, 'tag_view_mode', 'tags') == 'compact':
                        # Re-use the tag_map built from source so numbering stays consistent
                        tag_map = {}
                        src_display = segment.source
                        if self.hide_outer_wrapping_tags:
                            src_display, _ = strip_outer_wrapping_tags(src_display)
                        compact_tags(src_display, tag_map)
                        target_for_display = compact_tags(target_for_display, tag_map)
                        target_widget._compact_tag_map = tag_map
                    else:
                        target_widget._compact_tag_map = None
                    new_target_text = self.apply_invisible_replacements(target_for_display)
                    target_widget.blockSignals(True)
                    target_widget.setPlainText(new_target_text)
                    # Keep signals blocked – user edits will unblock naturally when
                    # the widget is next focused and the handler fires from keystrokes.
                    # We restore signals here so the widget stays interactive, but
                    # _suppress_target_change_handlers guards against the queued event.
                    target_widget.blockSignals(False)
                    # Reset initial-load flag so the single queued textChanged event
                    # that Qt delivers after blockSignals(False) is eaten harmlessly.
                    target_widget._initial_load_complete = False
        finally:
            self._suppress_target_change_handlers = old_suppress

        # Resize rows since space→middle-dot substitution changes text width
        self.auto_resize_rows()

        # Refresh Match Panel TM panes so ↵ markers appear/disappear with toggling
        if hasattr(self, 'match_panel_tm_matches') and self.match_panel_tm_matches:
            try:
                self._update_match_panel_tm_display()
            except Exception:
                pass

    def _refresh_source_column_display(self):
        """Refresh grid to reflect hide_outer_wrapping_tags setting.

        Reloads the entire grid to properly handle:
        - Tag stripping on both Source and Target columns
        - WYSIWYG list numbering/bullet prefixes
        - Proper list position calculation
        """
        if not hasattr(self, 'table') or not self.table:
            return
        if not hasattr(self, 'current_project') or not self.current_project:
            return

        # Reload grid to apply changes with proper list numbering calculation
        self.load_segments_to_grid()

    def apply_invisible_replacements(self, text):
        """Apply invisible character replacements to text based on settings"""
        if not hasattr(self, 'invisible_display_settings'):
            return text

        result = text

        # Replace spaces with middle dot (·) followed by zero-width space for word-wrap capability
        # The zero-width space (U+200B) provides a line-break opportunity
        if self.invisible_display_settings.get('spaces', False):
            result = result.replace(' ', '·\u200B')

        # Replace tabs with right arrow (→) followed by zero-width space
        if self.invisible_display_settings.get('tabs', False):
            result = result.replace('\t', '→\u200B')

        # Replace non-breaking spaces with degree symbol (°)
        # Note: We don't add zero-width space here since NBSP should NOT break
        if self.invisible_display_settings.get('nbsp', False):
            result = result.replace('\u00A0', '°')  # Unicode NBSP
            result = result.replace('\u202F', '°')  # Narrow NBSP

        # Replace line breaks with return arrow (↵)
        if self.invisible_display_settings.get('linebreaks', False):
            result = result.replace('\n', '↵\n')
            result = result.replace('\r', '↵')

        return result

    def reverse_invisible_replacements(self, text):
        """Reverse ALL invisible character replacements to get original text.

        NOTE: We always strip ALL marker types unconditionally, regardless of
        which settings are currently enabled.  This is necessary because:
        - The user may toggle a setting OFF after markers were already placed in
          the widget; the markers must still be removed from the saved text.
        - refresh_grid_invisibles() calls setPlainText() with the clean
          segment.target text, but a queued textChanged event fired afterwards
          would see the freshly-set text (which may contain markers) and must
          still clean it correctly.
        """
        result = text

        # Reverse spaces (middle dot + zero-width space → space) – always
        result = result.replace('·\u200B', ' ')
        result = result.replace('·', ' ')  # Fallback for any without zero-width space

        # Reverse tabs (right arrow + zero-width space → tab) – always
        result = result.replace('→\u200B', '\t')
        result = result.replace('→', '\t')  # Fallback

        # Reverse non-breaking spaces (degree symbol → NBSP) – always
        # We can't distinguish between \u00A0 and \u202F after replacement,
        # so we default to the more common \u00A0
        result = result.replace('°', '\u00A0')

        # Reverse line breaks (return arrow → line break) – always
        result = result.replace('↵\n', '\n')
        result = result.replace('↵', '\r')
        # Legacy: pilcrow was used as line-break marker before v1.9.295
        result = result.replace('¶\n', '\n')
        result = result.replace('¶', '\r')

        # Always strip any stray zero-width spaces left over
        result = result.replace('\u200B', '')

        return result

    # ========================================================================
    # SPELLCHECK METHODS
    # ========================================================================

    def _update_spellcheck_button_style(self):
        """Update spellcheck button style based on enabled state"""
        if hasattr(self, 'spellcheck_btn'):
            if self.spellcheck_enabled:
                self.spellcheck_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 3px 5px;")
            else:
                self.spellcheck_btn.setStyleSheet("background-color: #9E9E9E; color: white; font-weight: bold; padding: 3px 5px;")

    def _toggle_spellcheck(self, checked=None):
        """Toggle spellcheck on/off"""
        self.spellcheck_enabled = not self.spellcheck_enabled
        TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)
        
        # Update UI
        self._update_spellcheck_button_style()
        if hasattr(self, 'spellcheck_btn'):
            self.spellcheck_btn.setChecked(self.spellcheck_enabled)
        if hasattr(self, 'spellcheck_toggle_action'):
            self.spellcheck_toggle_action.setChecked(self.spellcheck_enabled)
        
        # Use project's target language if available, otherwise fall back to app preference
        spellcheck_lang = self.current_project.target_lang if self.current_project else self.target_language
        
        # Initialize spellcheck for target language if enabling
        if self.spellcheck_enabled:
            if self.spellcheck_manager.set_language(spellcheck_lang):
                self.log(f"✓ Spellcheck enabled for {spellcheck_lang} (backend: {self.spellcheck_manager.get_backend_info()})")
            else:
                self.log(f"⚠ Spellcheck dictionary not available for {spellcheck_lang}")
                # Disable spellcheck since we couldn't initialize
                self.spellcheck_enabled = False
                TagHighlighter.set_spellcheck_enabled(False)
                self._update_spellcheck_button_style()
                if hasattr(self, 'spellcheck_btn'):
                    self.spellcheck_btn.setChecked(False)
                if hasattr(self, 'spellcheck_toggle_action'):
                    self.spellcheck_toggle_action.setChecked(False)
                # Show warning with diagnostics
                diag = self.spellcheck_manager.get_diagnostics()
                QMessageBox.warning(
                    self, "Spellcheck Unavailable",
                    f"Could not initialize spellcheck for '{spellcheck_lang}'.\n\n"
                    f"Backends:\n"
                    f"• Hunspell: {'Available' if diag['hunspell_available'] else 'Not installed'}\n"
                    f"• pyspellchecker: {'Available' if diag['pyspellchecker_available'] else 'Not installed'}\n\n"
                    f"Try opening Spellcheck Info (right-click menu) for more details."
                )
        else:
            self.log("✗ Spellcheck disabled")
        
        # Save setting
        self._save_spellcheck_settings()
        
        # Refresh grid to show/hide underlines
        self._refresh_all_highlighters()

    def _toggle_spellcheck_from_button(self, checked):
        """Handle button click (separate from menu action to avoid double-toggle)"""
        # The button click already toggles the checked state, so we need to sync
        self.spellcheck_enabled = checked
        TagHighlighter.set_spellcheck_enabled(self.spellcheck_enabled)
        
        # Update menu action
        if hasattr(self, 'spellcheck_toggle_action'):
            self.spellcheck_toggle_action.setChecked(self.spellcheck_enabled)
        
        # Same logic as _toggle_spellcheck
        self._update_spellcheck_button_style()
        
        # Use project's target language if available, otherwise fall back to app preference
        spellcheck_lang = self.current_project.target_lang if self.current_project else self.target_language
        
        if self.spellcheck_enabled:
            if self.spellcheck_manager.set_language(spellcheck_lang):
                self.log(f"✓ Spellcheck enabled for {spellcheck_lang} (backend: {self.spellcheck_manager.get_backend_info()})")
            else:
                self.log(f"⚠ Spellcheck could not be initialized for {spellcheck_lang}")
                # Disable spellcheck since we couldn't initialize
                self.spellcheck_enabled = False
                TagHighlighter.set_spellcheck_enabled(False)
                self._update_spellcheck_button_style()
                if hasattr(self, 'spellcheck_btn'):
                    self.spellcheck_btn.setChecked(False)
                if hasattr(self, 'spellcheck_toggle_action'):
                    self.spellcheck_toggle_action.setChecked(False)
                # Show warning
                diag = self.spellcheck_manager.get_diagnostics()
                QMessageBox.warning(
                    self, "Spellcheck Unavailable",
                    f"Could not initialize spellcheck for '{spellcheck_lang}'.\n\n"
                    f"Backends:\n"
                    f"• Hunspell: {'Available' if diag['hunspell_available'] else 'Not installed'}\n"
                    f"• pyspellchecker: {'Available' if diag['pyspellchecker_available'] else 'Not installed'}\n\n"
                    f"Try opening Spellcheck Info for more details."
                )
        else:
            self.log("✗ Spellcheck disabled")
        
        self._save_spellcheck_settings()
        self._refresh_all_highlighters()

    def _save_spellcheck_settings(self):
        """Save spellcheck settings to preferences"""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['spellcheck_settings'] = {
                'enabled': self.spellcheck_enabled
            }
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save spellcheck settings: {e}")

    def _load_spellcheck_settings(self):
        """Load spellcheck settings from preferences"""
        try:
            prefs = self._load_settings_section("ui")
            settings = prefs.get('spellcheck_settings', {})
            return settings.get('enabled', False)
        except:
            return False

    def _refresh_all_highlighters(self):
        """Refresh syntax highlighters in VISIBLE rows only (performance optimization)
        
        Instead of refreshing all 3000+ rows, we only refresh the rows currently
        visible in the viewport. This makes add-to-dictionary/ignore instant.
        Other rows will get updated when they become visible (on navigation).
        """
        if not hasattr(self, 'table') or not self.table:
            return
        
        # Get visible row range from viewport
        viewport = self.table.viewport()
        if not viewport:
            return
            
        # Find first and last visible rows
        first_visible_row = self.table.rowAt(0)
        last_visible_row = self.table.rowAt(viewport.height())
        
        # Handle edge cases
        if first_visible_row < 0:
            first_visible_row = 0
        if last_visible_row < 0 or last_visible_row >= self.table.rowCount():
            last_visible_row = self.table.rowCount() - 1
            
        # Add small buffer for smoother experience
        first_visible_row = max(0, first_visible_row - 2)
        last_visible_row = min(self.table.rowCount() - 1, last_visible_row + 2)
        
        # Only refresh visible rows
        for row in range(first_visible_row, last_visible_row + 1):
            # Target column (3) has editable text editor with spellcheck
            target_widget = self.table.cellWidget(row, 3)
            if target_widget and hasattr(target_widget, 'highlighter'):
                target_widget.highlighter.rehighlight()

    def _open_custom_dictionary_dialog(self):
        """Open dialog to manage custom dictionary words"""
        dialog = QDialog(self)
        dialog.setWindowTitle("Custom Dictionary")
        dialog.setMinimumSize(400, 500)
        
        layout = QVBoxLayout(dialog)
        
        # Info label
        info_label = QLabel(
            "Words added to the custom dictionary will not be marked as spelling errors.\n"
            "One word per line. Changes are saved automatically."
        )
        info_label.setWordWrap(True)
        layout.addWidget(info_label)
        
        # Word list editor
        self.custom_dict_editor = QPlainTextEdit()
        self.custom_dict_editor.setPlaceholderText("Enter custom words here, one per line...")
        
        # Load current words
        words = self.spellcheck_manager.get_custom_words()
        self.custom_dict_editor.setPlainText('\n'.join(words))
        
        layout.addWidget(self.custom_dict_editor)
        
        # Word count label
        self.word_count_label = QLabel(f"{len(words)} words in dictionary")
        layout.addWidget(self.word_count_label)
        
        # Update count when text changes
        def update_word_count():
            text = self.custom_dict_editor.toPlainText()
            count = len([w for w in text.split('\n') if w.strip()])
            self.word_count_label.setText(f"{count} words in dictionary")
        
        self.custom_dict_editor.textChanged.connect(update_word_count)
        
        # Buttons
        button_layout = QHBoxLayout()
        
        save_btn = QPushButton("💾 Save")
        save_btn.clicked.connect(lambda: self._save_custom_dictionary(dialog))
        button_layout.addWidget(save_btn)
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_layout.addWidget(cancel_btn)
        
        layout.addLayout(button_layout)
        
        dialog.exec()

    def _save_custom_dictionary(self, dialog):
        """Save custom dictionary from dialog"""
        text = self.custom_dict_editor.toPlainText()
        words = [w.strip().lower() for w in text.split('\n') if w.strip()]
        
        # Clear and re-add all words
        current_words = self.spellcheck_manager.get_custom_words()
        for word in current_words:
            self.spellcheck_manager.remove_from_dictionary(word)
        
        for word in words:
            self.spellcheck_manager.add_to_dictionary(word)
        
        self.log(f"✓ Custom dictionary saved with {len(words)} words")
        
        # Refresh highlighters
        self._refresh_all_highlighters()
        
        dialog.accept()

    def _show_spellcheck_info(self):
        """Show information about spellcheck configuration with clickable links"""
        backend = self.spellcheck_manager.get_backend_info()
        language = self.spellcheck_manager.get_current_language() or "Not set"
        available_languages = self.spellcheck_manager.get_available_languages()
        available = ', '.join(available_languages) or "None"
        custom_count = len(self.spellcheck_manager.get_custom_words())
        dict_path = str(self.spellcheck_manager.dictionaries_path)
        diag = self.spellcheck_manager.get_diagnostics()
        is_hunspell = "Hunspell" in backend
        
        # Create dialog with two-column horizontal layout
        dialog = QDialog(self)
        dialog.setWindowTitle("Spellcheck Info")
        dialog.setMinimumWidth(700)
        dialog.setMaximumHeight(520)
        
        main_layout = QVBoxLayout(dialog)
        main_layout.setSpacing(10)
        
        # ═══════════════ Top Row: Status + Language + Backend ═══════════════
        top_row = QHBoxLayout()
        
        status_text = "✓ Enabled" if self.spellcheck_enabled else "✗ Disabled"
        status_color = "green" if self.spellcheck_enabled else "red"
        status_label = QLabel(f"<b style='color: {status_color};'>{status_text}</b>")
        top_row.addWidget(status_label)
        top_row.addSpacing(20)
        
        top_row.addWidget(QLabel("Language:"))
        lang_combo = QComboBox()
        lang_combo.setMinimumWidth(100)
        lang_combo.addItems(available_languages)
        if language in available_languages:
            lang_combo.setCurrentText(language)
        top_row.addWidget(lang_combo)
        top_row.addSpacing(20)
        
        backend_icon = "📚" if is_hunspell else "🐍"
        backend_label = QLabel(f"{backend_icon} {backend}")
        top_row.addWidget(backend_label)
        top_row.addStretch()
        main_layout.addLayout(top_row)
        
        def on_language_changed(new_lang):
            if self.spellcheck_manager.set_language(new_lang):
                self.log(f"✓ Spellcheck language changed to: {new_lang}")
                new_backend = self.spellcheck_manager.get_backend_info()
                is_hunspell_new = "Hunspell" in new_backend
                backend_icon_new = "📚" if is_hunspell_new else "🐍"
                backend_label.setText(f"{backend_icon_new} {new_backend}")
                if self.spellcheck_enabled:
                    self._refresh_all_highlighters()
            else:
                self.log(f"⚠ Could not set spellcheck language to: {new_lang}")
        lang_combo.currentTextChanged.connect(on_language_changed)
        
        # ═══════════════ Two-Column Layout ═══════════════
        columns = QHBoxLayout()
        columns.setSpacing(15)
        
        # ─────────── LEFT COLUMN ───────────
        left_col = QVBoxLayout()
        left_col.setSpacing(8)
        
        # How It Works - determine which backend is active
        backend_lower = backend.lower()
        is_spylls = 'spylls' in backend_lower
        is_pyspellchecker = 'pyspellchecker' in backend_lower or 'built-in' in backend_lower
        
        # How It Works
        how_group = QGroupBox("How Spellcheck Works")
        how_layout = QVBoxLayout(how_group)
        how_layout.setSpacing(4)
        
        # Build the explanation with active backend highlighted
        spylls_style = "background-color: #90EE90; padding: 2px;" if is_spylls else ""
        pyspell_style = "background-color: #90EE90; padding: 2px;" if is_pyspellchecker else ""
        hunspell_style = "background-color: #90EE90; padding: 2px;" if is_hunspell and not is_spylls else ""
        
        how_label = QLabel(
            f"<div style='{pyspell_style}'>"
            "🐍 <b>Built-in (pyspellchecker)</b><br>"
            "&nbsp;&nbsp;&nbsp;EN, NL, DE, FR, ES, PT, IT, RU<br>"
            "&nbsp;&nbsp;&nbsp;<i>Works out of the box!</i></div><br>"
            f"<div style='{spylls_style}'>"
            "📗 <b>Spylls (pure Python Hunspell)</b><br>"
            "&nbsp;&nbsp;&nbsp;Bundled: EN, RU, SV<br>"
            "&nbsp;&nbsp;&nbsp;+ Any .dic/.aff files you add</div><br>"
            f"<div style='{hunspell_style}'>"
            "📚 <b>Hunspell (native C library)</b><br>"
            "&nbsp;&nbsp;&nbsp;Any language with .dic/.aff files<br>"
            "&nbsp;&nbsp;&nbsp;<i>Requires cyhunspell (Linux/Mac)</i></div><br>"
            "<b>⚡ Auto-switching:</b> Best available backend<br>"
            "is used automatically based on dictionaries."
        )
        how_label.setWordWrap(True)
        how_layout.addWidget(how_label)
        left_col.addWidget(how_group)
        
        # Available Languages
        lang_group = QGroupBox("Available Languages")
        lang_layout = QVBoxLayout(lang_group)
        lang_list = QLabel(available)
        lang_list.setWordWrap(True)
        lang_layout.addWidget(lang_list)
        left_col.addWidget(lang_group)
        
        # Custom Dictionary
        custom_group = QGroupBox("Custom Dictionary")
        custom_layout = QVBoxLayout(custom_group)
        custom_layout.setSpacing(4)
        custom_layout.addWidget(QLabel(f"<b>{custom_count}</b> words in custom_words.txt"))
        custom_folder = str(self.spellcheck_manager.dictionaries_path)
        custom_btn = QPushButton("📁 Open Folder")
        custom_btn.clicked.connect(lambda: self._open_folder_in_explorer(custom_folder))
        custom_layout.addWidget(custom_btn)
        left_col.addWidget(custom_group)
        
        left_col.addStretch()
        columns.addLayout(left_col)
        
        # ─────────── RIGHT COLUMN ───────────
        right_col = QVBoxLayout()
        right_col.setSpacing(8)
        
        # Add More Dictionaries
        hunspell_group = QGroupBox("Add More Dictionaries")
        hunspell_layout = QVBoxLayout(hunspell_group)
        hunspell_layout.setSpacing(4)
        
        hunspell_layout.addWidget(QLabel("<b>To add more languages:</b>"))
        hunspell_layout.addWidget(QLabel("1. Download .dic + .aff files for your language"))
        hunspell_layout.addWidget(QLabel("2. Place them in the dictionaries folder"))
        hunspell_layout.addWidget(QLabel("3. Restart Supervertaler"))
        
        hunspell_layout.addWidget(QLabel(""))
        hunspell_layout.addWidget(QLabel("<b>Download sources:</b>"))
        
        memoq_label = QLabel('🌐 <a href="https://hunspell.memoq.com/">hunspell.memoq.com</a> – 70+ languages')
        memoq_label.setOpenExternalLinks(True)
        hunspell_layout.addWidget(memoq_label)
        
        github_label = QLabel('🌐 <a href="https://github.com/wooorm/dictionaries/tree/main/dictionaries">GitHub: wooorm/dictionaries</a> – 92+ languages')
        github_label.setOpenExternalLinks(True)
        hunspell_layout.addWidget(github_label)
        
        libreoffice_label = QLabel('🌐 <a href="https://extensions.libreoffice.org/?Tags%5B%5D=50">LibreOffice Extensions</a> – .oxt → .zip')
        libreoffice_label.setOpenExternalLinks(True)
        hunspell_layout.addWidget(libreoffice_label)
        
        dict_btn = QPushButton("📁 Open Dictionaries Folder")
        dict_btn.clicked.connect(lambda: self._open_folder_in_explorer(dict_path))
        hunspell_layout.addWidget(dict_btn)
        
        # Note about Spylls bundled dictionaries location
        spylls_note = QLabel(
            "<small><i>💡 Spylls bundled dicts are in its pip package.<br>"
            "Add your own .dic/.aff files to the folder above.</i></small>"
        )
        spylls_note.setWordWrap(True)
        hunspell_layout.addWidget(spylls_note)
        
        right_col.addWidget(hunspell_group)
        
        # Diagnostics
        diag_group = QGroupBox("🔧 Diagnostics")
        diag_layout = QVBoxLayout(diag_group)
        diag_layout.setSpacing(2)
        
        diag_text = (
            f"<table cellspacing='2'>"
            f"<tr><td>Hunspell:</td><td>{'✓' if diag.get('hunspell_available') else '✗'} avail, {'✓' if diag.get('hunspell_initialized') else '✗'} init</td></tr>"
            f"<tr><td>Spylls:</td><td>{'✓' if diag.get('spylls_available') else '✗'} avail, {'✓' if diag.get('spylls_initialized') else '✗'} init</td></tr>"
            f"<tr><td>pyspellchecker:</td><td>{'✓' if diag.get('pyspellchecker_available') else '✗'} avail, {'✓' if diag.get('pyspellchecker_initialized') else '✗'} init</td></tr>"
            f"<tr><td>Custom words:</td><td>{diag.get('custom_words_count', 0)}</td></tr>"
            f"<tr><td>Session ignored:</td><td>{diag.get('ignored_words_count', 0)}</td></tr>"
            f"<tr><td>Cache size:</td><td>{diag.get('cache_size', 0)}</td></tr>"
        )
        if diag.get('pyspellchecker_word_count'):
            diag_text += f"<tr><td>Dict words:</td><td>{diag['pyspellchecker_word_count']:,}</td></tr>"
        diag_text += "</table>"
        
        diag_label = QLabel(diag_text)
        diag_label.setTextFormat(Qt.TextFormat.RichText)
        diag_layout.addWidget(diag_label)
        
        # Show warning only if NO backend is initialized
        any_initialized = diag.get('hunspell_initialized') or diag.get('spylls_initialized') or diag.get('pyspellchecker_initialized')
        if not any_initialized:
            warn_label = QLabel("<span style='color: orange;'>⚠️ Not initialized - try changing language</span>")
            diag_layout.addWidget(warn_label)
        
        right_col.addWidget(diag_group)
        
        # Project Links (for technical users)
        links_group = QGroupBox("📖 Project Links")
        links_layout = QVBoxLayout(links_group)
        links_layout.setSpacing(2)
        
        pyspell_link = QLabel('<a href="https://github.com/barrust/pyspellchecker">pyspellchecker</a> – Built-in word frequency spellcheck')
        pyspell_link.setOpenExternalLinks(True)
        links_layout.addWidget(pyspell_link)
        
        spylls_link = QLabel('<a href="https://github.com/zverok/spylls">spylls</a> – Pure Python Hunspell implementation')
        spylls_link.setOpenExternalLinks(True)
        links_layout.addWidget(spylls_link)
        
        hunspell_link = QLabel('<a href="http://hunspell.github.io/">Hunspell</a> – Original C/C++ spellcheck library')
        hunspell_link.setOpenExternalLinks(True)
        links_layout.addWidget(hunspell_link)
        
        right_col.addWidget(links_group)
        
        right_col.addStretch()
        columns.addLayout(right_col)
        
        main_layout.addLayout(columns, stretch=1)
        
        # ═══════════════ OK Button ═══════════════
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        ok_btn = QPushButton("OK")
        ok_btn.clicked.connect(dialog.accept)
        ok_btn.setDefault(True)
        button_layout.addWidget(ok_btn)
        main_layout.addLayout(button_layout)
        
        dialog.exec()

    def _open_folder_in_explorer(self, path: str):
        """Open a folder or file location in the system file explorer"""
        from pathlib import Path

        path_obj = Path(path)

        # If it's a file, open the containing folder
        if path_obj.is_file():
            folder = path_obj.parent
        else:
            folder = path_obj

        # Create folder if it doesn't exist
        folder.mkdir(parents=True, exist_ok=True)

        open_file(str(folder))

    def filter_empty_segments(self):
        """Quick filter to show only segments with empty target"""
        if not self.current_project:
            return
        
        # Safety check: ensure table exists
        if not hasattr(self, 'table') or self.table is None:
            return
        
        # Clear filter boxes first
        source_widget = getattr(self, 'source_filter', None)
        target_widget = getattr(self, 'target_filter', None)
        if self._widget_is_alive(source_widget):
            source_widget.blockSignals(True)
            source_widget.clear()
            source_widget.blockSignals(False)
        if self._widget_is_alive(target_widget):
            target_widget.blockSignals(True)
            target_widget.clear()
            target_widget.blockSignals(False)
        
        # OPTIMIZED: Batch UI updates and don't reload grid
        self.table.setUpdatesEnabled(False)
        try:
            # Clear any yellow text filter highlights (but preserve termbase/tag formatting)
            self._clear_all_filter_highlights()
            
            # Hide rows with non-empty target (empty structural segments always hidden)
            visible_count = 0
            for row, segment in enumerate(self.current_project.segments):
                if row >= self.table.rowCount():
                    break

                has_empty_target = not segment.target or not segment.target.strip()
                show_row = has_empty_target and segment.source.strip()
                self.table.setRowHidden(row, not show_row)

                if show_row:
                    visible_count += 1
        finally:
            self.table.setUpdatesEnabled(True)
        
        self.log(f"🔍 Empty segments filter: showing {visible_count} of {len(self.current_project.segments)} segments")
    
    def _clear_all_filter_highlights(self):
        """Clear yellow filter highlights from all cells without reloading grid"""
        if not hasattr(self, 'table') or self.table is None:
            return
        
        for row in range(self.table.rowCount()):
            # Clear source column (2)
            self._clear_filter_highlights_in_widget(row, 2)
            
            # Clear target column (3)
            self._clear_filter_highlights_in_widget(row, 3)

    def apply_quick_filter(self, filter_type: str):
        """Apply quick filter based on type - integrates with pagination system"""
        if not self.current_project:
            return

        if not hasattr(self, 'table') or self.table is None:
            return

        # Clear filter boxes first
        source_widget = getattr(self, 'source_filter', None)
        target_widget = getattr(self, 'target_filter', None)
        if self._widget_is_alive(source_widget):
            source_widget.blockSignals(True)
            source_widget.clear()
            source_widget.blockSignals(False)
        if self._widget_is_alive(target_widget):
            target_widget.blockSignals(True)
            target_widget.clear()
            target_widget.blockSignals(False)

        # Clear any yellow text filter highlights (but preserve termbase/tag formatting)
        self._clear_all_filter_highlights()

        # Calculate matching rows - collect into a set for pagination integration
        matching_rows = set()
        for row, segment in enumerate(self.current_project.segments):
            show_row = False

            if filter_type == "empty":
                show_row = not segment.target or not segment.target.strip()
            elif filter_type == "not_started":
                show_row = segment.status in ["not_started", "draft"]
            elif filter_type == "confirmed":
                show_row = segment.status == "confirmed"
            elif filter_type == "locked":
                show_row = getattr(segment, 'locked', False)
            elif filter_type == "not_locked":
                show_row = not getattr(segment, 'locked', False)
            elif filter_type == "commented":
                show_row = bool(segment.notes and segment.notes.strip())

            if show_row:
                matching_rows.add(row)

        # Integrate with pagination system - this ensures the filter persists
        # when other UI events trigger pagination updates
        self._active_text_filter_rows = matching_rows
        self.filtering_active = True

        # Apply the filter through the pagination system
        if hasattr(self, '_apply_pagination_to_grid'):
            self._apply_pagination_to_grid()

        filter_names = {
            "empty": "Empty segments",
            "not_started": "Not started",
            "confirmed": "Confirmed",
            "locked": "Locked",
            "not_locked": "Not locked",
            "commented": "Commented"
        }
        self.log(f"🔍 {filter_names.get(filter_type, 'Quick')} filter: showing {len(matching_rows)} of {len(self.current_project.segments)} segments")
    
    def _update_bulk_menu_label(self):
        """Update the Bulk Operations menu label to show filter state."""
        if not hasattr(self, 'bulk_menu') or not self.bulk_menu:
            return
        action = self.bulk_menu.menuAction()
        if not self.current_project or not hasattr(self, 'table') or not self.table:
            action.setText("Bulk &Operations")
            return
        visible = sum(1 for r in range(self.table.rowCount())
                      if not self.table.isRowHidden(r))
        total = self.table.rowCount()
        if visible < total and visible > 0:
            action.setText(f"Bulk &Operations ({visible} filtered)")
        else:
            action.setText("Bulk &Operations")

    def show_advanced_filters_dialog(self):
        """Show advanced filters dialog with detailed filtering options"""
        if not self.current_project:
            QMessageBox.information(self, "No Project", "Please open or create a project first.")
            return
        
        dialog = AdvancedFiltersDialog(self)
        if dialog.exec() == QDialog.DialogCode.Accepted:
            filters = dialog.get_filters()
            self.apply_advanced_filters(filters)
    
    def apply_advanced_filters(self, filters: dict):
        """Apply advanced filters to grid - OPTIMIZED for speed"""
        if not self.current_project or not hasattr(self, 'table') or self.table is None:
            return
        
        # Clear text filter boxes
        source_widget = getattr(self, 'source_filter', None)
        target_widget = getattr(self, 'target_filter', None)
        if self._widget_is_alive(source_widget):
            source_widget.blockSignals(True)
            source_widget.clear()
            source_widget.blockSignals(False)
        if self._widget_is_alive(target_widget):
            target_widget.blockSignals(True)
            target_widget.clear()
            target_widget.blockSignals(False)
        
        # OPTIMIZED: Batch UI updates and don't reload grid
        self.table.setUpdatesEnabled(False)
        try:
            # Clear any yellow text filter highlights (but preserve termbase/tag formatting)
            self._clear_all_filter_highlights()
            
            visible_count = 0
            for row, segment in enumerate(self.current_project.segments):
                if row >= self.table.rowCount():
                    break
                
                show_row = True
                
                # Match rate filter
                if filters.get('match_rate_enabled'):
                    match_percent = getattr(segment, 'match_percent', 0) or 0
                    min_rate = filters.get('match_rate_min', 0)
                    max_rate = filters.get('match_rate_max', 102)
                    if not (min_rate <= match_percent <= max_rate):
                        show_row = False
                
                # Row status filters
                status_filters = filters.get('row_status', [])
                if status_filters:
                    if segment.status not in status_filters:
                        show_row = False
                
                # Locked/unlocked filter
                if filters.get('locked_filter'):
                    locked_value = getattr(segment, 'locked', False)
                    if filters['locked_filter'] == 'locked' and not locked_value:
                        show_row = False
                    elif filters['locked_filter'] == 'unlocked' and locked_value:
                        show_row = False
                
                # Other properties
                if filters.get('has_comments'):
                    if not (segment.notes and segment.notes.strip()):
                        show_row = False
                
                if filters.get('has_proofreading'):
                    if not getattr(segment, 'proofreading_notes', None):
                        show_row = False
                
                if filters.get('repetitions_only'):
                    # TODO: Implement repetition detection
                    pass

                # Empty structural segments are always hidden
                if not segment.source.strip():
                    show_row = False

                self.table.setRowHidden(row, not show_row)

                if show_row:
                    visible_count += 1
        finally:
            self.table.setUpdatesEnabled(True)
        
        self.log(f"🔍 Advanced filters: showing {visible_count} of {len(self.current_project.segments)} segments")

    def apply_sort(self, sort_type: str = None):
        """Sort segments by various criteria (similar to memoQ)"""
        if not self.current_project or not hasattr(self, 'table') or self.table is None:
            return

        if not self.current_project.segments:
            return

        # Show progress dialog during sorting
        from PyQt6.QtWidgets import QProgressDialog
        from PyQt6.QtCore import Qt

        progress = QProgressDialog("Sorting segments, please wait...", None, 0, 0, self)
        progress.setWindowTitle("Sorting")
        progress.setWindowModality(Qt.WindowModality.WindowModal)
        progress.setMinimumDuration(0)  # Show immediately
        progress.show()
        QApplication.processEvents()  # Force UI update

        try:
            # Store original document order if not already stored
            if not hasattr(self, '_original_segment_order'):
                self._original_segment_order = self.current_project.segments.copy()

            # Update current sort state
            self.current_sort = sort_type

            # If sort_type is None, restore document order
            if sort_type is None:
                # Restore document order by sorting by segment ID (original position)
                # This works even if the stored original order is wrong
                self.current_project.segments.sort(key=lambda seg: int(seg.id))

                # Update stored original order to this correct order
                self._original_segment_order = self.current_project.segments.copy()

                # Set pagination to "All" to show all segments
                if hasattr(self, 'page_size_combo') and self._widget_is_alive(self.page_size_combo):
                    self.page_size_combo.blockSignals(True)
                    self.page_size_combo.setCurrentText("All")
                    self.page_size_combo.blockSignals(False)
                    # Update the internal page size variable
                    if hasattr(self, 'grid_page_size'):
                        self.grid_page_size = 999999

                self.load_segments_to_grid()
                self.log("↩️ Restored document order (showing all segments)")
                return

            # Helper function to get text without tags for more accurate sorting
            def strip_tags(text: str) -> str:
                """Remove HTML/XML tags from text for sorting"""
                import re
                return re.sub(r'<[^>]+>', '', text).strip()

            # Calculate frequency maps if needed
            frequency_cache = {}
            if 'freq' in sort_type:
                from collections import Counter
                if 'source' in sort_type:
                    counter = Counter(strip_tags(seg.source).lower() for seg in self.current_project.segments)
                    frequency_cache = {strip_tags(seg.source).lower(): counter[strip_tags(seg.source).lower()]
                                     for seg in self.current_project.segments}
                else:  # target frequency
                    counter = Counter(strip_tags(seg.target).lower() for seg in self.current_project.segments if seg.target)
                    frequency_cache = {strip_tags(seg.target).lower(): counter[strip_tags(seg.target).lower()]
                                     for seg in self.current_project.segments if seg.target}

            # Sort based on selected criterion
            if sort_type == 'source_asc':
                self.current_project.segments.sort(key=lambda s: strip_tags(s.source).lower())
                sort_name = "Source A → Z"
            elif sort_type == 'source_desc':
                self.current_project.segments.sort(key=lambda s: strip_tags(s.source).lower(), reverse=True)
                sort_name = "Source Z → A"
            elif sort_type == 'target_asc':
                self.current_project.segments.sort(key=lambda s: strip_tags(s.target).lower() if s.target else "")
                sort_name = "Target A → Z"
            elif sort_type == 'target_desc':
                self.current_project.segments.sort(key=lambda s: strip_tags(s.target).lower() if s.target else "", reverse=True)
                sort_name = "Target Z → A"
            elif sort_type == 'source_length_asc':
                self.current_project.segments.sort(key=lambda s: len(strip_tags(s.source)))
                sort_name = "Source (shorter first)"
            elif sort_type == 'source_length_desc':
                self.current_project.segments.sort(key=lambda s: len(strip_tags(s.source)), reverse=True)
                sort_name = "Source (longer first)"
            elif sort_type == 'target_length_asc':
                self.current_project.segments.sort(key=lambda s: len(strip_tags(s.target)) if s.target else 0)
                sort_name = "Target (shorter first)"
            elif sort_type == 'target_length_desc':
                self.current_project.segments.sort(key=lambda s: len(strip_tags(s.target)) if s.target else 0, reverse=True)
                sort_name = "Target (longer first)"
            elif sort_type == 'match_asc':
                self.current_project.segments.sort(key=lambda s: getattr(s, 'match_percent', 0) or 0)
                sort_name = "Match Rate (lower first)"
            elif sort_type == 'match_desc':
                self.current_project.segments.sort(key=lambda s: getattr(s, 'match_percent', 0) or 0, reverse=True)
                sort_name = "Match Rate (higher first)"
            elif sort_type == 'source_freq_asc':
                self.current_project.segments.sort(key=lambda s: frequency_cache.get(strip_tags(s.source).lower(), 0))
                sort_name = "Source Frequency (lower first)"
            elif sort_type == 'source_freq_desc':
                self.current_project.segments.sort(key=lambda s: frequency_cache.get(strip_tags(s.source).lower(), 0), reverse=True)
                sort_name = "Source Frequency (higher first)"
            elif sort_type == 'target_freq_asc':
                self.current_project.segments.sort(key=lambda s: frequency_cache.get(strip_tags(s.target).lower(), 0) if s.target else 0)
                sort_name = "Target Frequency (lower first)"
            elif sort_type == 'target_freq_desc':
                self.current_project.segments.sort(key=lambda s: frequency_cache.get(strip_tags(s.target).lower(), 0) if s.target else 0, reverse=True)
                sort_name = "Target Frequency (higher first)"
            elif sort_type == 'modified_asc':
                self.current_project.segments.sort(key=lambda s: s.modified_at if s.modified_at else "")
                sort_name = "Last Changed (oldest first)"
            elif sort_type == 'modified_desc':
                self.current_project.segments.sort(key=lambda s: s.modified_at if s.modified_at else "", reverse=True)
                sort_name = "Last Changed (newest first)"
            elif sort_type == 'status':
                # Sort by status in a logical order: not_started, draft, confirmed
                status_order = {'not_started': 0, 'draft': 1, 'confirmed': 2, 'approved': 3}
                self.current_project.segments.sort(key=lambda s: status_order.get(s.status, 99))
                sort_name = "Row Status"
            else:
                self.log(f"⚠️ Unknown sort type: {sort_type}")
                return

            # Set pagination to "All" to show all sorted segments
            if hasattr(self, 'page_size_combo') and self._widget_is_alive(self.page_size_combo):
                self.page_size_combo.blockSignals(True)
                self.page_size_combo.setCurrentText("All")
                self.page_size_combo.blockSignals(False)
                # Update the internal page size variable
                if hasattr(self, 'grid_page_size'):
                    self.grid_page_size = 999999

            # Reload grid to reflect new order
            self.load_segments_to_grid()
            self.log(f"⇅ Sorted by: {sort_name} (showing all segments)")

        except Exception as e:
            self.log(f"❌ Error sorting segments: {e}")
            import traceback
            traceback.print_exc()
        finally:
            # Close progress dialog
            progress.close()

    # ========================================================================
    # TABBED SEGMENT EDITOR METHODS (for Grid view)
    # ========================================================================
    
    def on_tab_target_change(self):
        """Handle target text change in tab editor - updates all panels"""
        if not hasattr(self, 'tab_current_segment_id') or not self.tab_current_segment_id:
            return
        
        # Get the new text from whichever panel triggered this
        new_text = None
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    if hasattr(panel, 'editor_widget') and hasattr(panel.editor_widget, 'target_editor'):
                        new_text = panel.editor_widget.target_editor.toPlainText()
                        break  # Use text from first panel found
                except:
                    continue
        
        if new_text is None:
            return
        
        # Auto-save to current_project data structure
        if self.current_project and hasattr(self.current_project, 'segments'):
            for seg in self.current_project.segments:
                if seg.id == self.tab_current_segment_id:
                    # Check if text actually changed
                    old_target = seg.target
                    old_status = seg.status
                    
                    # IMMEDIATE: Update segment data
                    seg.target = new_text
                    self.project_modified = True
                    
                    # Reset 'confirmed' status to 'draft' when user edits the segment
                    # This prevents auto-saving to TM until user re-confirms the edit
                    if old_status == 'confirmed' and new_text != old_target:
                        seg.status = 'draft'
                        if self.debug_mode_enabled:
                            self.log(f"📝 Tab editor: Status reset confirmed → draft (segment edited)")
                        # Refresh status in all views
                        self._refresh_segment_status(seg)
                    
                    # Update all other panels to keep them in sync
                    if hasattr(self, 'tabbed_panels'):
                        for panel in self.tabbed_panels:
                            try:
                                if hasattr(panel, 'editor_widget') and hasattr(panel.editor_widget, 'target_editor'):
                                    # Temporarily disconnect to avoid infinite loop
                                    panel.editor_widget.target_editor.blockSignals(True)
                                    panel.editor_widget.target_editor.setPlainText(new_text)
                                    panel.editor_widget.target_editor.blockSignals(False)
                            except:
                                pass
                    
                    # DEBOUNCED: Save to TM (expensive operation)
                    # Cancel previous timer
                    if hasattr(self, '_tab_target_debounce_timer'):
                        self._tab_target_debounce_timer.stop()
                    
                    # Schedule TM save after 500ms of inactivity
                    from PyQt6.QtCore import QTimer
                    self._tab_target_debounce_timer = QTimer()
                    self._tab_target_debounce_timer.setSingleShot(True)
                    # CRITICAL: Capture variables by value using default parameters to avoid closure bugs
                    self._tab_target_debounce_timer.timeout.connect(lambda segment=seg, text=new_text: self._save_tab_target_to_tm(segment, text))
                    self._tab_target_debounce_timer.start(500)
                    
                    break
    
    def _save_tab_target_to_tm(self, segment, text):
        """Save tab target text to TM after debounce delay"""
        try:
            # Only save to TM if segment is confirmed (user explicitly approved)
            if segment.status == 'confirmed' and text.strip():
                self.save_segment_to_activated_tms(segment.source, text)
        except Exception as e:
            self.log(f"Warning: Could not save to TM: {e}")
    
    def on_tab_status_combo_changed(self, index: int):
        combo = self.sender()
        if isinstance(combo, QComboBox) and index >= 0:
            status_key = combo.itemData(index)
            if status_key:
                self.on_tab_segment_status_change(status_key)

    def on_tab_segment_status_change(self, status_key: str):
        """Handle status change in tab editor - updates all panels"""
        if not hasattr(self, 'tab_current_segment_id') or not self.tab_current_segment_id:
            return

        status_key = status_key or DEFAULT_STATUS.key
        status_def = get_status(status_key)

        if self.current_project and hasattr(self.current_project, 'segments'):
            for seg in self.current_project.segments:
                if seg.id == self.tab_current_segment_id:
                    seg.status = status_key
                    self.project_modified = True
                    self.log(f"✓ Status changed to: {status_def.label}")

                    # Update all panels to keep them in sync
                    if hasattr(self, 'tabbed_panels'):
                        for panel in self.tabbed_panels:
                            try:
                                if hasattr(panel, 'editor_widget') and hasattr(panel.editor_widget, 'status_combo'):
                                    combo = panel.editor_widget.status_combo
                                    combo.blockSignals(True)
                                    idx = combo.findData(status_key)
                                    if idx >= 0:
                                        combo.setCurrentIndex(idx)
                                    combo.blockSignals(False)
                            except Exception as e:
                                self.log(f"Error syncing status combo: {e}")
                    self._refresh_segment_status(seg)
                    
                    # Save to TM ONLY if status changed to confirmed (user explicitly approved)
                    if status_key == 'confirmed' and seg.target.strip():
                        try:
                            self.save_segment_to_activated_tms(seg.source, seg.target)
                            self.log(f"✓ Saved to TM: {seg.source[:30]}... → {seg.target[:30]}...")
                        except Exception as e:
                            self.log(f"Warning: Could not save to TM: {e}")
                    
                    break
    
    def on_tab_notes_change(self):
        """Handle comments change in tab editor - updates all panels"""
        if not hasattr(self, 'tab_current_segment_id') or not self.tab_current_segment_id:
            return
        
        # Get the new comments from whichever panel triggered this
        new_notes = None
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    if hasattr(panel, 'notes_widget') and hasattr(panel.notes_widget, 'notes_editor'):
                        new_notes = panel.notes_widget.notes_editor.toPlainText()
                        break
                except:
                    continue
        
        if new_notes is None:
            return
        
        if self.current_project and hasattr(self.current_project, 'segments'):
            for seg in self.current_project.segments:
                if seg.id == self.tab_current_segment_id:
                    seg.notes = new_notes
                    self.project_modified = True
                    
                    # Update all other panels to keep them in sync
                    if hasattr(self, 'tabbed_panels'):
                        for panel in self.tabbed_panels:
                            try:
                                if hasattr(panel, 'notes_widget') and hasattr(panel.notes_widget, 'notes_editor'):
                                    panel.notes_widget.notes_editor.blockSignals(True)
                                    panel.notes_widget.notes_editor.setPlainText(new_notes)
                                    panel.notes_widget.notes_editor.blockSignals(False)
                            except:
                                pass
                    self._refresh_segment_status(seg)
                    break
    
    def _on_results_panel_notes_changed(self):
        """Handle notes change in Translation Results panel - saves to current segment.
        NOTE: This function is deprecated - Translation Results panel has been removed.
        """
        # Translation Results panel is deprecated - this should never be called
        if not self.translation_results_panel:
            return
        if not self.current_project:
            return
        
        # Get current segment from grid selection
        current_row = self.table.currentRow()
        if current_row < 0:
            return
        
        id_item = self.table.item(current_row, 0)
        if not id_item:
            return
        
        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            return
        
        # Find segment and update notes
        for seg in self.current_project.segments:
            if seg.id == segment_id:
                new_notes = self.translation_results_panel.notes_edit.toPlainText()
                seg.notes = new_notes
                self.project_modified = True
                # Sync with bottom notes panel
                if hasattr(self, 'bottom_notes_edit') and self.bottom_notes_edit:
                    self.bottom_notes_edit.blockSignals(True)
                    self.bottom_notes_edit.setPlainText(new_notes)
                    self.bottom_notes_edit.blockSignals(False)
                # Refresh the status cell to update the notes indicator
                self._refresh_segment_status(seg)
                break
    
    # ──────────────────────────────────────────────────────────────
    # Comment-anchor visual indicators in editor cells (v1.10.57 Phase C)
    # ──────────────────────────────────────────────────────────────

    # Amber background for ranges anchored by a Comment. Mirrors the
    # default Word-comment highlight colour so the in-app cell matches
    # what users will see in the exported DOCX (Phase E).
    _COMMENT_ANCHOR_BG_QCOLOR = None  # Lazily built — needs Qt module

    def _comment_anchor_bg_color(self):
        if self._COMMENT_ANCHOR_BG_QCOLOR is None:
            # Use a light amber with full alpha — cell renderers don't
            # always honour transparency, and full-alpha picks up cleanly
            # against the editor's default white background.
            self._COMMENT_ANCHOR_BG_QCOLOR = QColor(254, 240, 138)  # amber-200
        return self._COMMENT_ANCHOR_BG_QCOLOR

    def _apply_comment_anchors_to_cell(self, row: int, segment):
        """Apply a background highlight to every anchored character range
        in this row's source and target cell editors.

        Called from load_segments_to_grid (initial paint), after
        Ctrl+Shift+M creates a comment, and after edit/delete.
        Cheap to call repeatedly — at most one cursor pass per
        anchored comment, and visible cells only.

        Defensive against text-length mismatches: anchor offsets are
        clamped to the actual cell text length. If the user has
        edited the target text since the comment was anchored, the
        highlight may not match the original wording, but at least
        it won't crash or wander into invalid territory.
        """
        if not segment or not getattr(segment, 'comments', None):
            return
        if not hasattr(self, 'table') or not self.table:
            return

        from PyQt6.QtGui import QTextCursor as _QTextCursor, QTextCharFormat as _QTextCharFormat

        bg_color = self._comment_anchor_bg_color()
        fmt = _QTextCharFormat()
        fmt.setBackground(bg_color)
        # Force black text on the amber highlight so it stays readable in dark
        # themes (light theme text on light amber is unreadable). The amber is
        # always light, so black works in every theme.
        fmt.setForeground(QColor('#000000'))

        for col, field_name in [(2, 'source'), (3, 'target')]:
            editor = self.table.cellWidget(row, col)
            if editor is None or not hasattr(editor, 'document'):
                continue
            text_len = len(editor.toPlainText())
            for c in segment.comments:
                if c.anchor_field != field_name or not c.is_anchored:
                    continue
                start = max(0, min(c.anchor_start, text_len))
                end = max(start, min(c.anchor_end, text_len))
                if start == end:
                    continue
                cursor = _QTextCursor(editor.document())
                cursor.setPosition(start)
                cursor.setPosition(end, _QTextCursor.MoveMode.KeepAnchor)
                cursor.mergeCharFormat(fmt)

    def _apply_comment_anchors_to_all_cells(self):
        """Apply anchor highlights to every currently-visible row.

        Called once at the end of load_segments_to_grid so the
        highlights show up immediately after a project load or a
        page-switch (with pagination, only the current page's rows
        actually have cell widgets).
        """
        if (not self.current_project
                or not self.current_project.segments
                or not hasattr(self, 'table')
                or not self.table):
            return

        # Build a quick lookup: segment_id → segment, since cells store id only.
        by_id = {s.id: s for s in self.current_project.segments}

        for row in range(self.table.rowCount()):
            id_item = self.table.item(row, 0)
            if not id_item:
                continue
            try:
                seg_id = int(id_item.text())
            except (ValueError, AttributeError):
                continue
            segment = by_id.get(seg_id)
            if segment is None:
                continue
            self._apply_comment_anchors_to_cell(row, segment)

    def _refresh_cell_text_for_anchors(self, row: int, segment):
        """Reset character formatting on this row's source/target cells.

        Used as a "cheap clear" before re-applying anchor highlights,
        since mergeCharFormat is additive (no per-range undo). Calling
        setPlainText() with the cell's current text strips all rich-text
        char formats (Qt documented behaviour) without changing the text
        content. Signals are blocked so the legacy textChanged handlers
        don't interpret this as a user edit.
        """
        if not hasattr(self, 'table') or not self.table:
            return
        for col in (2, 3):
            editor = self.table.cellWidget(row, col)
            if editor is None or not hasattr(editor, 'setPlainText'):
                continue
            current_text = editor.toPlainText()
            editor.blockSignals(True)
            try:
                editor.setPlainText(current_text)
            finally:
                editor.blockSignals(False)

    def _find_row_for_segment_id(self, segment_id: int) -> int:
        """Return the row index for a segment id in the visible table,
        or -1 if the segment isn't on the current page. Helper for the
        targeted anchor-refresh after a single comment is created/edited."""
        if not hasattr(self, 'table') or not self.table:
            return -1
        for row in range(self.table.rowCount()):
            id_item = self.table.item(row, 0)
            if id_item:
                try:
                    if int(id_item.text()) == segment_id:
                        return row
                except (ValueError, AttributeError):
                    continue
        return -1

    def _refresh_segment_comments_list(self):
        """Rebuild the all-comments list at the top of the Comments → Segment sub-tab.

        v1.10.57 Phase D: iterates ``segment.comments`` (one entry per
        Comment) instead of the legacy single-string ``segment.notes``.
        A segment with three comments shows three entries; each entry
        carries its own anchor snippet (if any), author, timestamp,
        and a right-click context menu for edit / delete.

        Idempotent. Cheap to call on every change — number of comments
        per project is typically small.
        """
        if not hasattr(self, '_segment_comments_list_layout'):
            return  # Sub-tab not built yet (early init)

        from PyQt6.QtWidgets import QFrame as _QFrame

        layout = self._segment_comments_list_layout

        # Map of comment id → its header button, so _open_comment_in_panel()
        # can scroll a specific comment into view and flash it. Rebuilt here
        # since the widgets are recreated on every refresh.
        self._comment_list_header_btns = {}

        # Clear existing children. Walk takeAt(0) until the layout is
        # empty so both widgets AND the trailing stretch get cleaned up.
        while layout.count() > 0:
            item = layout.takeAt(0)
            widget = item.widget()
            if widget is not None:
                widget.deleteLater()

        # Collect (segment, comment) pairs in document order, one row
        # per Comment. Skips comments with empty text just in case.
        entries: list = []  # [(seg_id, segment, comment), ...]
        if self.current_project and self.current_project.segments:
            for seg in self.current_project.segments:
                for c in getattr(seg, 'comments', []) or []:
                    if c.text and c.text.strip():
                        entries.append((seg.id, seg, c))

        if not entries:
            empty = QLabel(
                "(No comments in this project yet. Select text in a source or "
                "target cell and press <b>Ctrl+Shift+M</b> to add an anchored "
                "comment, or use the editor below for a segment-level note.)"
            )
            empty.setTextFormat(Qt.TextFormat.RichText)
            empty.setStyleSheet("color: #999; padding: 8px;")
            empty.setWordWrap(True)
            layout.addWidget(empty)
            layout.addStretch()
            return

        for idx, (seg_id, segment, comment) in enumerate(entries):
            # Divider between entries (not above the first).
            if idx > 0:
                divider = _QFrame()
                divider.setFrameShape(_QFrame.Shape.HLine)
                divider.setStyleSheet("color: #e0e0e0; max-height: 1px;")
                layout.addWidget(divider)

            # ── Header: "Segment #N" + optional anchor tag ──
            header_label = f"Segment #{seg_id}"
            if comment.is_anchored:
                header_label += f"  ⚓ {comment.anchor_field}"
            header_btn = QPushButton(header_label)
            header_btn.setFlat(True)
            header_btn.setCursor(Qt.CursorShape.PointingHandCursor)
            header_btn.setStyleSheet(
                "QPushButton { "
                "  color: #2563eb; "
                "  font-weight: bold; "
                "  font-size: 10pt; "
                "  text-align: left; "
                "  padding: 6px 8px 2px 8px; "
                "  border: none; "
                "  background: transparent; "
                "} "
                "QPushButton:hover { "
                "  background-color: rgba(37, 99, 235, 0.08); "
                "  text-decoration: underline; "
                "}"
            )
            header_btn.clicked.connect(
                lambda _checked, sid=seg_id: self._navigate_to_segment_by_id(sid)
            )
            self._comment_list_header_btns[comment.id] = header_btn
            # Right-click context menu for edit/delete.
            header_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
            header_btn.customContextMenuRequested.connect(
                lambda _pos, seg=segment, cid=comment.id, btn=header_btn:
                    self._comment_context_menu(seg, cid, btn)
            )
            layout.addWidget(header_btn)

            # ── Optional anchored-text snippet ──
            if comment.is_anchored:
                source_text = (segment.source if comment.anchor_field == 'source'
                               else segment.target) or ''
                snippet_raw = source_text[comment.anchor_start:comment.anchor_end]
                snippet = snippet_raw[:140] + ('…' if len(snippet_raw) > 140 else '')
                try:
                    import html as _html
                    snippet_escaped = _html.escape(snippet) if snippet else '(range)'
                except Exception:
                    snippet_escaped = snippet or '(range)'
                anchor_label = QLabel(f'<i>"{snippet_escaped}"</i>')
                anchor_label.setTextFormat(Qt.TextFormat.RichText)
                anchor_label.setWordWrap(True)
                anchor_label.setStyleSheet(
                    "color: #555; padding: 0 12px 4px 12px; font-size: 9pt;"
                )
                layout.addWidget(anchor_label)

            # ── Body ──
            body = QLabel(comment.text)
            body.setWordWrap(True)
            body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
            body.setStyleSheet(
                "padding: 0 12px 4px 12px; "
                "font-size: 10pt; "
                "color: #333;"
            )
            layout.addWidget(body)

            # ── Footer: author + timestamp ──
            footer_parts = []
            if comment.author:
                footer_parts.append(comment.author)
            if comment.created:
                # Show just date + HH:MM, not the full ISO blob.
                try:
                    from datetime import datetime as _dt
                    parsed = _dt.fromisoformat(comment.created)
                    footer_parts.append(parsed.strftime('%Y-%m-%d %H:%M'))
                except Exception:
                    footer_parts.append(comment.created[:16])
            if footer_parts:
                footer = QLabel(' · '.join(footer_parts) + '  (right-click for edit/delete)')
                footer.setStyleSheet(
                    "color: #888; padding: 0 12px 8px 12px; font-size: 8pt; "
                    "font-style: italic;"
                )
                layout.addWidget(footer)

        layout.addStretch()

        # Update bottom-editor lock state — when any segment in the project
        # has multiple comments or any anchored comment, the legacy
        # single-string editor would silently clobber them. Lock it
        # whenever the currently-selected segment is in that state.
        self._update_bottom_notes_editor_lock_state()

    def _comment_context_menu(self, segment: 'Segment', comment_id: str, anchor_widget):
        """Right-click context menu on a comments-list entry. Edit / Delete."""
        from PyQt6.QtWidgets import QMenu, QMessageBox as _QMessageBox
        from PyQt6.QtGui import QAction as _QAction

        comment = segment.get_comment(comment_id)
        if not comment:
            return

        menu = QMenu(anchor_widget)
        edit_action = _QAction("✏️ Edit comment…", menu)
        delete_action = _QAction("🗑️ Delete comment", menu)
        menu.addAction(edit_action)
        menu.addAction(delete_action)

        chosen = menu.exec(anchor_widget.mapToGlobal(anchor_widget.rect().bottomLeft()))
        if chosen is edit_action:
            self._edit_comment_dialog(segment, comment_id)
        elif chosen is delete_action:
            reply = _QMessageBox.question(
                self, "Delete comment",
                f"Delete this comment from segment #{segment.id}?\n\n"
                f"\"{comment.text[:100]}{'…' if len(comment.text) > 100 else ''}\"",
                _QMessageBox.StandardButton.Yes | _QMessageBox.StandardButton.No,
                _QMessageBox.StandardButton.No,
            )
            if reply == _QMessageBox.StandardButton.Yes:
                segment.remove_comment(comment_id)
                self.project_modified = True
                try:
                    self.update_window_title()
                except Exception:
                    pass
                try:
                    self._refresh_segment_status(segment)
                except Exception:
                    pass
                self._refresh_segment_comments_list()
                # Re-apply anchor highlights on the affected row (the
                # removed comment's highlight needs to go; remaining
                # anchored comments stay highlighted).
                affected_row = self._find_row_for_segment_id(segment.id)
                if affected_row >= 0:
                    # We need to clear ALL char-format backgrounds first
                    # since mergeCharFormat is additive and we have no
                    # cheap way to identify "our" old highlight to undo.
                    # Easiest: refresh the cell text, which resets formats.
                    self._refresh_cell_text_for_anchors(affected_row, segment)
                    self._apply_comment_anchors_to_cell(affected_row, segment)
                # Keep the bottom editor in sync if the deleted comment
                # belonged to the currently-selected segment.
                if hasattr(self, '_update_bottom_notes_for_segment'):
                    self._update_bottom_notes_for_segment(segment)

    def _edit_comment_dialog(self, segment: 'Segment', comment_id: str):
        """Open a dialog to edit an existing comment's text. Anchor + author
        are kept as-is; only the body is editable here."""
        from PyQt6.QtWidgets import (QDialog as _QDialog, QDialogButtonBox as _QDialogButtonBox,
                                     QPlainTextEdit as _QPlainTextEdit)

        comment = segment.get_comment(comment_id)
        if not comment:
            return

        dialog = _QDialog(self)
        dialog.setWindowTitle("Edit comment")
        dialog.setMinimumWidth(480)
        layout = QVBoxLayout(dialog)

        header = QLabel(
            f"<b>Segment #{segment.id}</b> &mdash; " +
            (f"anchored to {comment.anchor_field}" if comment.is_anchored else "segment-level")
        )
        header.setTextFormat(Qt.TextFormat.RichText)
        layout.addWidget(header)

        if comment.is_anchored:
            source_text = (segment.source if comment.anchor_field == 'source'
                           else segment.target) or ''
            snippet_raw = source_text[comment.anchor_start:comment.anchor_end]
            snippet = snippet_raw[:140] + ('…' if len(snippet_raw) > 140 else '')
            try:
                import html as _html
                snippet_escaped = _html.escape(snippet)
            except Exception:
                snippet_escaped = snippet
            sl = QLabel(f'Re: <i>"{snippet_escaped}"</i>')
            sl.setTextFormat(Qt.TextFormat.RichText)
            sl.setWordWrap(True)
            sl.setStyleSheet(
                "color: #555; padding: 6px 8px; "
                "background-color: rgba(37, 99, 235, 0.08); "
                "border-radius: 4px; margin: 4px 0;"
            )
            layout.addWidget(sl)

        layout.addWidget(QLabel("Comment:"))
        text_edit = _QPlainTextEdit()
        text_edit.setPlainText(comment.text)
        text_edit.setMinimumHeight(120)
        layout.addWidget(text_edit)

        buttons = _QDialogButtonBox(
            _QDialogButtonBox.StandardButton.Ok
            | _QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(dialog.accept)
        buttons.rejected.connect(dialog.reject)
        layout.addWidget(buttons)

        text_edit.setFocus()
        # Place cursor at end so the user can append immediately.
        cur = text_edit.textCursor()
        cur.movePosition(cur.MoveOperation.End)
        text_edit.setTextCursor(cur)

        if dialog.exec() != _QDialog.DialogCode.Accepted:
            return
        new_text = text_edit.toPlainText().strip()
        if new_text == comment.text:
            return  # No change

        if new_text:
            segment.update_comment(comment_id, new_text)
        else:
            segment.remove_comment(comment_id)
        self.project_modified = True
        try:
            self.update_window_title()
        except Exception:
            pass
        try:
            self._refresh_segment_status(segment)
        except Exception:
            pass
        self._refresh_segment_comments_list()
        # Re-apply anchor highlights on the affected row (text content
        # of the anchor is unchanged but a delete-via-empty-edit may
        # have removed a comment whose highlight needs to go).
        affected_row = self._find_row_for_segment_id(segment.id)
        if affected_row >= 0:
            self._refresh_cell_text_for_anchors(affected_row, segment)
            self._apply_comment_anchors_to_cell(affected_row, segment)
        if hasattr(self, '_update_bottom_notes_for_segment'):
            self._update_bottom_notes_for_segment(segment)

    def _update_bottom_notes_editor_lock_state(self):
        """Disable the legacy bottom comment editor when the current segment
        has multiple comments or any anchored comment.

        The bottom editor is a single QPlainTextEdit that writes to
        ``segment.notes`` (via ``replace_all_comments_with_text``).
        That works for the simple one-segment-level-comment case but
        would silently clobber anchored comments or merge multiple
        comments into one. When the current segment is in that "complex"
        state, the editor goes read-only with a hint that the user
        should use the all-comments list above instead.
        """
        if not hasattr(self, 'bottom_notes_edit') or not self.bottom_notes_edit:
            return
        # Determine the currently-selected segment.
        segment = None
        if (self.current_project and self.current_project.segments
                and hasattr(self, 'table') and self.table):
            current_row = self.table.currentRow()
            if current_row >= 0:
                id_item = self.table.item(current_row, 0)
                if id_item:
                    try:
                        seg_id = int(id_item.text())
                        segment = next(
                            (s for s in self.current_project.segments
                             if s.id == seg_id), None
                        )
                    except (ValueError, AttributeError):
                        pass

        is_complex = False
        if segment:
            comments = getattr(segment, 'comments', []) or []
            if len(comments) > 1:
                is_complex = True
            elif any(c.is_anchored for c in comments):
                is_complex = True

        if is_complex:
            self.bottom_notes_edit.setReadOnly(True)
            self.bottom_notes_edit.setPlaceholderText(
                "This segment has multiple comments or an anchored comment. "
                "Use the list above to edit or delete individual comments "
                "(right-click on a Segment #N header)."
            )
            self.bottom_notes_edit.setStyleSheet(
                "font-size: 10pt; background-color: #f5f5f5; color: #888;"
            )
        else:
            self.bottom_notes_edit.setReadOnly(False)
            self.bottom_notes_edit.setPlaceholderText(
                "Add a comment about this segment — context, "
                "translation concerns, anything worth flagging..."
            )
            self.bottom_notes_edit.setStyleSheet("font-size: 10pt;")

    def _navigate_to_segment_by_id(self, segment_id: int):
        """Navigate to a segment by ID, switching pages first if needed.

        Unlike :py:meth:`_navigate_to_segment_in_grid` (which looks the
        segment up by row index in the *currently visible* table), this
        version finds the segment's index in the full project segment
        list, computes which page it's on, switches to that page if
        we're not already there, and only then selects the row. Works
        correctly across pagination boundaries.
        """
        if not self.current_project or not self.current_project.segments:
            return
        if not hasattr(self, 'table') or not self.table:
            return

        # Locate the target segment in the full project list.
        segments = self.current_project.segments
        target_idx = None
        for i, seg in enumerate(segments):
            if seg.id == segment_id:
                target_idx = i
                break
        if target_idx is None:
            self.log(f"⚠ Segment #{segment_id} not found in project")
            return

        # If pagination is active and the segment is on a different page,
        # switch to that page first. The grid rebuilds synchronously
        # inside go_to_page, so by the time it returns the target row
        # is visible.
        if (hasattr(self, 'page_size_combo')
                and self.page_size_combo.currentText() != "All"):
            try:
                page_size = int(self.page_size_combo.currentText())
                target_page = (target_idx // page_size) + 1
                current_page = getattr(self, 'grid_current_page', 0) + 1
                if current_page != target_page and hasattr(self, 'page_number_input'):
                    self.page_number_input.setText(str(target_page))
                    self.go_to_page()
            except (ValueError, AttributeError):
                pass

        # Switch to the Grid (Editor) tab if we're elsewhere.
        if hasattr(self, 'main_tabs'):
            self.main_tabs.setCurrentIndex(0)

        # Find the row in the (possibly newly-rebuilt) table and select it.
        for row in range(self.table.rowCount()):
            id_item = self.table.item(row, 0)
            if id_item:
                try:
                    if int(id_item.text()) == segment_id:
                        self.table.setCurrentCell(row, 3)  # Column 3 = Target
                        self.table.scrollToItem(
                            self.table.item(row, 0),
                            QTableWidget.ScrollHint.PositionAtCenter,
                        )
                        target_widget = self.table.cellWidget(row, 3)
                        if target_widget:
                            target_widget.setFocus()
                        return
                except (ValueError, AttributeError):
                    continue

        # Reaching here means the page-switch logic didn't put the
        # segment on the current page — shouldn't happen but log it.
        self.log(
            f"⚠ Segment #{segment_id} not on current grid page after "
            f"page-switch attempt (unexpected)"
        )

    def _open_comment_in_panel(self, comment_id: str):
        """Reveal a specific comment in the Comments ▸ Segment sub-tab.

        Invoked by the "💬 Open comment" item on a cell's anchored-comment
        highlight. Switches the right panel to the Comments tab, scrolls the
        all-comments list to the matching entry, and briefly flashes it. Handy
        when the Match Panel (or any other tab) is showing and you want to jump
        straight from a yellow-highlighted range to its comment.
        """
        # Ensure the list (and the comment→button map) is current.
        try:
            self._refresh_segment_comments_list()
        except Exception:
            pass

        # Switch the right panel to Comments → Segment sub-tab. Resolve the
        # Comments tab by its LABEL, not the stored _comments_tab_index — the
        # Chat/AI tab is inserted into the row after build time, shifting the
        # Comments tab down, so the cached index goes stale (it pointed at
        # Preview).
        if hasattr(self, 'right_tabs'):
            target_index = None
            for i in range(self.right_tabs.count()):
                if "Comments" in self.right_tabs.tabText(i):
                    target_index = i
                    break
            if target_index is not None:
                self.right_tabs.setCurrentIndex(target_index)
        if hasattr(self, 'comments_sub_tabs'):
            self.comments_sub_tabs.setCurrentIndex(0)

        btn = getattr(self, '_comment_list_header_btns', {}).get(comment_id)
        if btn is None:
            return
        scroll = getattr(self, '_segment_comments_list_scroll', None)

        def _reveal_and_flash():
            try:
                if scroll is not None and self._widget_is_alive(scroll):
                    scroll.ensureWidgetVisible(btn)
                self._flash_comment_header(btn)
            except Exception:
                pass

        # Defer so the tab switch / layout settles before we scroll.
        QTimer.singleShot(0, _reveal_and_flash)

    def _flash_comment_header(self, btn):
        """Briefly tint a comments-list header button so the eye lands on it."""
        if not self._widget_is_alive(btn):
            return
        original = btn.styleSheet()
        btn.setStyleSheet(
            original +
            "\nQPushButton { background-color: rgba(255, 213, 79, 0.55); "
            "border-radius: 3px; }"
        )

        def _restore():
            if self._widget_is_alive(btn):
                btn.setStyleSheet(original)

        QTimer.singleShot(1400, _restore)

    def _on_bottom_notes_changed(self):
        """Handle notes change in bottom Notes tab - saves to current segment and syncs with Translation Results panel"""
        if not self.current_project:
            return
        
        # Get current segment from grid selection
        current_row = self.table.currentRow()
        if current_row < 0:
            return
        
        id_item = self.table.item(current_row, 0)
        if not id_item:
            return
        
        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            return
        
        # Find segment and update notes
        for seg in self.current_project.segments:
            if seg.id == segment_id:
                new_notes = self.bottom_notes_edit.toPlainText()
                # v1.10.57: route the assignment through the legacy
                # bridge helper so segment.comments stays in sync with
                # the legacy segment.notes single-string field. The
                # bridge preserves the existing comment's id when the
                # text is edited in place (no churn), or replaces the
                # comments list with a single new Comment otherwise.
                seg.replace_all_comments_with_text(
                    new_notes,
                    author=self.get_translator_name() if hasattr(self, 'get_translator_name') else '',
                )
                self.project_modified = True
                # Sync with Translation Results panel notes
                if hasattr(self, 'translation_results_panel') and self.translation_results_panel:
                    if hasattr(self.translation_results_panel, 'notes_edit') and self.translation_results_panel.notes_edit:
                        self.translation_results_panel.notes_edit.blockSignals(True)
                        self.translation_results_panel.notes_edit.setPlainText(new_notes)
                        self.translation_results_panel.notes_edit.blockSignals(False)
                # Refresh the status cell to update the notes indicator
                self._refresh_segment_status(seg)
                # v1.10.56: rebuild the all-comments list in the
                # Comments → Segment sub-tab so the new/edited comment
                # appears (or disappears, if cleared) immediately.
                self._refresh_segment_comments_list()
                break
    
    def _on_scratchpad_changed(self):
        """Handle scratchpad change - saves to current project (not segment-level)"""
        if not self.current_project:
            return
        
        new_notes = self.scratchpad_edit.toPlainText()
        self.current_project.scratchpad_notes = new_notes
        self.current_project.modified = datetime.now().isoformat()
        self.project_modified = True
    
    def _update_scratchpad_for_project(self):
        """Update the Scratchpad tab with the current project's scratchpad notes"""
        if not hasattr(self, 'scratchpad_edit') or not self.scratchpad_edit:
            return
        
        self.scratchpad_edit.blockSignals(True)
        if self.current_project and hasattr(self.current_project, 'scratchpad_notes'):
            self.scratchpad_edit.setPlainText(self.current_project.scratchpad_notes or '')
        else:
            self.scratchpad_edit.clear()
        self.scratchpad_edit.blockSignals(False)
    
    def _update_bottom_notes_for_segment(self, segment):
        """Update the bottom Notes tab with the current segment's notes.

        v1.10.57 Phase D: when the segment has multiple comments or any
        anchored comment, the legacy single-string editor would silently
        clobber them on the next textChanged. We detect that case and
        leave the editor empty (it'll be locked read-only by
        _update_bottom_notes_editor_lock_state, which the comments-list
        refresh calls). Otherwise — simple case, one segment-level
        comment (or none) — populate as before.
        """
        if not hasattr(self, 'bottom_notes_edit') or not self.bottom_notes_edit:
            return

        self.bottom_notes_edit.blockSignals(True)
        try:
            if segment and hasattr(segment, 'comments'):
                comments = getattr(segment, 'comments', []) or []
                is_complex = len(comments) > 1 or any(c.is_anchored for c in comments)
                if is_complex:
                    # Leave empty — the read-only state + placeholder
                    # tells the user to use the all-list above.
                    self.bottom_notes_edit.setPlainText('')
                elif comments:
                    # Exactly one unanchored segment-level comment.
                    self.bottom_notes_edit.setPlainText(comments[0].text or '')
                else:
                    self.bottom_notes_edit.setPlainText('')
            elif segment and hasattr(segment, 'notes'):
                # Pre-Phase-A segment (shouldn't happen post-migration,
                # but defensive — old in-memory objects might not have
                # the comments[] field yet).
                self.bottom_notes_edit.setPlainText(segment.notes or '')
            else:
                self.bottom_notes_edit.setPlainText('')
        finally:
            self.bottom_notes_edit.blockSignals(False)
        # Lock/unlock based on the segment's complexity.
        self._update_bottom_notes_editor_lock_state()

    def _update_proofreading_notes_for_segment(self, segment):
        """Update the Proofreading note tab with the current segment's proofreading notes (read-only, keyed by LLM)."""
        if not hasattr(self, 'bottom_proofreading_notes_display') or not self.bottom_proofreading_notes_display:
            return

        import html as html_module

        proofread = getattr(segment, 'proofreading_notes', None) if segment else None
        if not proofread:
            self.bottom_proofreading_notes_display.setHtml(
                '<span style="color: #999; font-style: italic;">No proofreading comments for this segment.</span>'
            )
            return

        # Cycle through a few distinct colours for different LLM entries
        colors = [
            ("#ff9800", "#fff3e0", "#e65100"),  # orange
            ("#2196f3", "#e3f2fd", "#0d47a1"),  # blue
            ("#4caf50", "#e8f5e9", "#1b5e20"),  # green
            ("#9c27b0", "#f3e5f5", "#4a148c"),  # purple
            ("#f44336", "#ffebee", "#b71c1c"),  # red
        ]

        html_parts = []
        for idx, (model_name, issue_text) in enumerate(proofread.items()):
            border_color, bg_color, text_color = colors[idx % len(colors)]
            escaped_model = html_module.escape(model_name)
            escaped_text = html_module.escape(issue_text).replace('\n', '<br/>')
            html_parts.append(
                f'<div style="margin-bottom: 10px; padding: 8px; background: {bg_color}; '
                f'border-left: 3px solid {border_color}; border-radius: 3px;">'
                f'<b style="color: {text_color};">{escaped_model}</b><br/>'
                f'<span style="color: #333;">{escaped_text}</span>'
                f'</div>'
            )

        self.bottom_proofreading_notes_display.setHtml(''.join(html_parts))
    
    def copy_source_to_tab_target(self):
        """Copy source to target in tab editor - works with all panels"""
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    if hasattr(panel, 'editor_widget'):
                        source_text = panel.editor_widget.source_editor.toPlainText()
                        panel.editor_widget.target_editor.setPlainText(source_text)
                except:
                    pass
    
    def clear_tab_target(self):
        """Clear target in tab editor - works with all panels"""
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    if hasattr(panel, 'editor_widget'):
                        panel.editor_widget.target_editor.clear()
                except:
                    pass

    def _get_voice_release_poller(self):
        """Lazy-create the GetAsyncKeyState-based release poller.

        Single instance per app. Returns ``None`` on platforms where
        polling isn't implemented (currently macOS / Linux). The poller
        installs no keyboard hook – press detection stays on
        RegisterHotKey, this just watches the key state while the chord
        is held.
        """
        existing = getattr(self, '_voice_release_poller', None)
        if existing is not None:
            return existing
        try:
            from modules.voice_release_poller import KeyReleasePoller, IS_WINDOWS
        except Exception as e:
            self.log(f"⚠ Voice release poller unavailable: {e}")
            self._voice_release_poller = None
            return None
        if not IS_WINDOWS:
            self._voice_release_poller = None
            return None
        poller = KeyReleasePoller(parent=self)
        poller.released.connect(self.stop_voice_dictation_if_recording)
        self._voice_release_poller = poller
        return poller

    def _register_voice_pushtotalk_deferred(self, qt_shortcut: str):
        """Install the push-to-talk keyboard listener and bind a shortcut.

        Called via QTimer from SuperlookupTab.register_global_hotkey so
        that pynput's blocking hook-install doesn't delay the rest of the
        global-hotkey registration during Workbench startup. Safe to call
        repeatedly (re-registration after a shortcut change is the main
        non-startup caller).
        """
        listener = self._get_voice_hotkey_listener()
        if listener is None:
            return
        listener.unregister('voice_dictate')
        if listener.register('voice_dictate', qt_shortcut):
            self.log(f"🎤 Push-to-talk armed: {qt_shortcut} (hold to dictate, release to transcribe)")
        else:
            self.log(f"⚠ Could not parse push-to-talk shortcut: {qt_shortcut!r}")

    def _get_voice_hotkey_listener(self):
        """Lazy-create the global push-to-talk hotkey listener.

        Returns the running listener instance, or None if pynput / the
        low-level keyboard hook is unavailable. Single instance per app.
        Signals are wired here (not at __init__) so they reach the
        already-existing dictation infrastructure.
        """
        if self._voice_hotkey_listener is not None:
            return self._voice_hotkey_listener
        try:
            from modules.voice_hotkey_listener import GlobalHotkeyListener
        except Exception as e:
            self.log(f"⚠ Voice hotkey listener unavailable: {e}")
            return None
        listener = GlobalHotkeyListener(self)

        def _on_chord_pressed(binding_id):
            if binding_id == 'voice_dictate':
                self.start_voice_dictation()

        def _on_chord_released(binding_id):
            if binding_id == 'voice_dictate':
                self.stop_voice_dictation_if_recording()

        listener.chord_pressed.connect(_on_chord_pressed)
        listener.chord_released.connect(_on_chord_released)
        if not listener.start():
            self.log("⚠ Voice hotkey listener failed to start – push-to-talk will not work")
            return None
        self._voice_hotkey_listener = listener
        return listener

    def stop_voice_dictation_if_recording(self):
        """Stop an active dictation recording on release (hold-to-talk).

        No-op if:
          - no recording is in progress, or
          - the user has explicitly set ``pushtotalk_mode = 'toggle'``
            (in which case release should not stop the recording – the
            user wants press-to-start / press-to-stop semantics).

        Called from the global keyboard listener on release of the
        voice_dictate chord.
        """
        try:
            mode = self.load_dictation_settings().get('pushtotalk_mode', 'hold')
        except Exception:
            mode = 'hold'
        if mode == 'toggle':
            return

        thread = getattr(self, 'dictation_thread', None)
        if thread is None:
            return
        try:
            if thread.isRunning() and getattr(thread, 'is_recording', False):
                thread.stop_recording()
                self.log("⏹️ Hold-to-talk: hotkey released, transcribing…")
                # Dismiss the listening toast immediately on user-initiated
                # stop; on_dictation_finished will also try to dismiss but
                # the visual feedback should disappear right now.
                try:
                    toast = getattr(self, '_dictation_toast', None)
                    if toast is not None:
                        toast.dismiss()
                        self._dictation_toast = None
                except Exception:
                    pass
        except Exception:
            pass

    def start_voice_dictation(self):
        """Start or stop voice dictation (toggle behavior).

        Old behaviour (pre-Vosk): if always-on was active, the dictation
        hotkey would toggle it OFF instead of dictating, because both
        paths used Whisper and shared the mic exclusively.

        New behaviour (Vosk + faster-whisper coexisting): always-on uses
        Vosk for command recognition, push-to-talk uses faster-whisper
        for running-text dictation. They're different engines and SHOULD
        coexist – we just need to mute the always-on listener for the
        duration of the dictation so the dictated text isn't half-
        interpreted as a command. The mic is handed back to Vosk via
        ``_resume_alwayson_after_dictation`` once the dictation thread
        signals ``finished``.
        """
        # Debounce: the in-editor QShortcut and the global keyboard listener
        # can both fire start_voice_dictation for the same physical press
        # (the listener fires on the OS hook ~1 ms before Qt routes the
        # event to the QShortcut). Without this guard the second call sees
        # is_recording=True and aborts the recording milliseconds after it
        # started. 200 ms window comfortably covers the worst-case Qt
        # dispatch lag without affecting genuinely re-issued presses.
        import time as _time
        now_ms = int(_time.monotonic() * 1000)
        if now_ms - self._voice_dictate_last_press_ms < 200:
            return
        self._voice_dictate_last_press_ms = now_ms

        # If always-on is currently running, pause it (don't kill it) so
        # push-to-talk can use the mic alone, then resume after.
        alwayson_was_running = bool(
            self.voice_listener and self.voice_listener.is_listening)
        if alwayson_was_running:
            try:
                self.voice_listener.pause()
                self.log("⏸️ Always-on paused for push-to-talk dictation")
            except Exception:
                pass
        # Stash the flag so the dictation-finished callback knows whether
        # to resume always-on or leave it stopped.
        self._alwayson_was_running_before_dictation = alwayson_was_running

        # Debug: Check thread state
        has_thread = hasattr(self, 'dictation_thread')
        thread_exists = has_thread and self.dictation_thread is not None
        thread_running = thread_exists and self.dictation_thread.isRunning()
        is_recording = thread_exists and getattr(self.dictation_thread, 'is_recording', False)

        self.log(f"🔍 DEBUG: has_thread={has_thread}, thread_exists={thread_exists}, thread_running={thread_running}, is_recording={is_recording}")

        # Check if already recording - if so, stop it
        if thread_running:
            if is_recording:
                # Stop recording early
                self.dictation_thread.stop_recording()
                self.log("⏹️ Stopping recording early...")
                # Dismiss the "🎤 Listening…" toast immediately on the
                # user-initiated stop. on_dictation_finished will also
                # call dismiss() once the thread actually finishes
                # transcribing, but that can be a couple of seconds
                # later – the user pressed the shortcut to STOP, so the
                # visual feedback should disappear right away.
                try:
                    toast = getattr(self, '_dictation_toast', None)
                    if toast is not None:
                        toast.dismiss()
                        self._dictation_toast = None
                except Exception:
                    pass
                return
            else:
                # Thread is running but not recording (probably transcribing/loading model)
                self.log("⚠️ Thread is running but not recording - waiting for it to finish...")
                return

        try:
            # Load dictation settings
            dictation_settings = self.load_dictation_settings()
            model_name = dictation_settings.get('model', 'base')
            max_duration = dictation_settings.get('max_duration', 10)
            lang_setting = dictation_settings.get('language', 'Auto (use project target language)')

            # Push-to-talk dictation engine. Three sources, in order:
            #
            #   1. User's explicit ``pushtotalk_engine`` setting (added
            #      in v1.9.441 as a separate dropdown in the Voice tab)
            #   2. ``'auto'`` → mirror the Always-On engine
            #   3. Always-On engine (with Vosk → faster-whisper since Vosk
            #      is commands-only and push-to-talk needs running text)
            #
            # Legacy ``recognition_engine='local'`` also maps to faster-whisper.
            ptt_engine = dictation_settings.get('pushtotalk_engine', 'auto')
            if ptt_engine == 'auto':
                always_on_engine = dictation_settings.get('recognition_engine', 'vosk')
                if always_on_engine in ('vosk', 'local'):
                    ptt_engine = 'faster_whisper'
                elif always_on_engine == 'api':
                    ptt_engine = 'api'
                else:
                    ptt_engine = 'faster_whisper'
            elif ptt_engine == 'local':  # very old legacy alias
                ptt_engine = 'faster_whisper'
            use_api = ptt_engine == 'api'
            api_key = None
            if use_api:
                api_keys = self.load_api_keys()
                api_key = api_keys.get('openai') or api_keys.get('openai_api_key')
                if not api_key:
                    QMessageBox.warning(
                        self, "OpenAI API Key Required",
                        "To use OpenAI Whisper API, please set your OpenAI API key in:\n\n"
                        "Settings → AI Settings → OpenAI API Key"
                    )
                    return

            # Determine language. Two flavours of "Auto":
            #   - "Auto-detect (Whisper picks per utterance)" – pass
            #     language=None to Whisper so it detects per utterance.
            #     Right for mixed-language workflows.
            #   - "Auto (use project target language)" – read the
            #     Workbench project's target language and pin Whisper
            #     to it. Right for single-language workflows.
            if lang_setting.startswith('Auto-detect'):
                # Sentinel value 'auto' is converted to None by
                # QuickDictationThread before being passed to Whisper.
                target_lang = '__autodetect__'
            elif lang_setting == 'Auto (use project target language)':
                target_lang = getattr(self, 'target_language', 'English')
            else:
                # Use override language from settings
                target_lang = lang_setting

            # Map language names to Whisper codes
            lang_map = {
                'English': 'en',
                'Dutch': 'nl',
                'German': 'de',
                'French': 'fr',
                'Spanish': 'es',
                'Italian': 'it',
                'Portuguese': 'pt',
                'Polish': 'pl',
                'Russian': 'ru',
                'Chinese': 'zh',
                'Japanese': 'ja',
                'Korean': 'ko'
            }
            if target_lang == '__autodetect__':
                # 'auto' is the sentinel that QuickDictationThread maps
                # to language=None at transcribe time, triggering
                # Whisper's per-utterance language detection.
                lang_code = 'auto'
            else:
                lang_code = lang_map.get(target_lang, 'auto')

            # Create dictation thread with user settings. mic_device is
            # the saved device *name* from the Voice tab's Microphone
            # dropdown; the thread resolves it to a sounddevice index at
            # record time via modules.mic_devices (None ⇒ OS default).
            # initial_prompt + replacements bias the Whisper decoder
            # toward known vocabulary (defaults + custom + optional
            # active termbase) – see build_voice_initial_prompt() and
            # modules/voice_vocabulary.py.
            mic_device = dictation_settings.get('mic_device')
            vocab_settings = self.load_voice_vocabulary_settings()
            self.dictation_thread = QuickDictationThread(
                model_name=model_name,
                language=lang_code,
                duration=max_duration,
                use_api=use_api,
                api_key=api_key,
                mic_device=mic_device,
                initial_prompt=self.build_voice_initial_prompt(),
                replacements=vocab_settings.get('replacements', []),
            )

            # Connect signals
            self.dictation_thread.transcription_ready.connect(self.on_dictation_complete)
            self.dictation_thread.status_update.connect(self.on_dictation_status)
            self.dictation_thread.error_occurred.connect(self.on_dictation_error)
            self.dictation_thread.finished.connect(self.on_dictation_finished)
            self.dictation_thread.model_loading_started.connect(self.on_model_loading_started)
            self.dictation_thread.model_loading_finished.connect(self.on_model_loading_finished)

            # Change button appearance
            self._set_dictation_button_recording(True)

            # Start recording
            if use_api:
                self.log(f"▶️ Starting dictation thread (OpenAI Whisper API, language={lang_code}, duration={max_duration}s)...")
            else:
                self.log(f"▶️ Starting dictation thread (local Whisper model={model_name}, language={lang_code}, duration={max_duration}s)...")
            # Visual cue so the user knows the hotkey was received and the
            # mic is now hot. Push-to-talk fires from Ctrl+Shift+Space in any
            # app via the global hotkey; without this there's no feedback
            # that recording started. Replaces the earlier QApplication.beep()
            # which was unreliable (many users mute system sound) and harsh
            # on Windows (the system Ding tone reads as an error).
            try:
                from modules.dictation_toast import show_dictation_toast
                self._dictation_toast = show_dictation_toast(self)
            except Exception as _toast_err:
                print(f"[Dictation toast] Failed to show: {_toast_err}")
                self._dictation_toast = None
            self.dictation_thread.start()

        except Exception as e:
            self._set_dictation_button_recording(False)
            import traceback
            error_details = traceback.format_exc()
            self.log(f"")
            self.log(f"❌ ERROR starting dictation:")
            self.log(f"   {str(e)}")
            self.log(f"")
            self.log(f"Full traceback:")
            self.log(error_details)
            self.log(f"")
            QMessageBox.critical(self, "Dictation Error", f"Failed to start dictation:\n\n{str(e)}\n\nCheck the Log tab for full details.")

    def on_dictation_complete(self, text):
        """Handle completed dictation - check for voice commands first"""
        dictation_settings = self.load_dictation_settings()
        voice_commands_enabled = dictation_settings.get('voice_commands_enabled', True)

        # Voice commands first (if enabled).
        if voice_commands_enabled and hasattr(self, 'voice_command_manager'):
            was_command, result = self.voice_command_manager.process_spoken_text(text)
            if was_command:
                self.log(f"🎤 Voice command: {text} → {result}")
                self.status_bar.showMessage(f"🎤 {result}", 3000)
                return

        # Otherwise route as dictation text – same cross-app path Always-On
        # uses, so push-to-talk works in any app.
        self._insert_dictated_text(text)

    def _insert_dictated_text(self, text: str):
        """Route dictated text to whatever target makes sense.

        Preference order:
        1. Focused Supervertaler grid editor (EditableGridTextEditor) →
           direct insert with cursor handling.
        2. Focused tabbed-panel target editor → direct insert.
        3. Any other focused app → AHK SendText / osascript keystroke /
           pynput type, via CrossPlatformKeySender.type_text.
        4. Last-resort fallback → drop on clipboard so the user can paste
           manually with whatever shortcut their app uses.

        Used by both ``on_dictation_complete`` (F9 push-to-talk and the
        global Voice hotkey) and ``_on_alwayson_dictation``
        (continuous listener) so behaviour is identical across surfaces.
        """
        focused_widget = QApplication.focusWidget()

        if isinstance(focused_widget, EditableGridTextEditor):
            current_text = focused_widget.toPlainText()
            if current_text:
                focused_widget.setPlainText(current_text + " " + text)
            else:
                focused_widget.setPlainText(text)
            cursor = focused_widget.textCursor()
            cursor.movePosition(cursor.MoveOperation.End)
            focused_widget.setTextCursor(cursor)
            self.status_bar.showMessage(f"✅ Dictation: {text[:50]}...", 3000)
            return

        # Tabbed panel editor (legacy list view).
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    if hasattr(panel, 'editor_widget') and panel.editor_widget.target_editor.hasFocus():
                        target = panel.editor_widget.target_editor
                        current_text = target.toPlainText()
                        if current_text:
                            target.setPlainText(current_text + " " + text)
                        else:
                            target.setPlainText(text)
                        cursor = target.textCursor()
                        cursor.movePosition(cursor.MoveOperation.End)
                        target.setTextCursor(cursor)
                        self.status_bar.showMessage(
                            f"✅ Dictation: {text[:50]}...", 3000)
                        return
                except Exception:
                    pass

        # Cross-app: type into whatever has keyboard focus.
        try:
            from modules.platform_helpers import CrossPlatformKeySender
            sender = CrossPlatformKeySender()
            if sender.is_available and sender.type_text(text):
                self.log(f"💬 Dictated: {text}")
                self.status_bar.showMessage(
                    f"💬 Dictated: {text[:50]}...", 3000)
                return
        except Exception as e:
            self.log(f"⚠ Dictation type failed: {e}")

        # Last-resort clipboard fallback.
        try:
            QApplication.clipboard().setText(text)
            self.log(
                f"💬 Dictated → clipboard (auto-typing unavailable): {text}")
            self.status_bar.showMessage(
                f"📋 Copied to clipboard: {text[:50]}...", 4000)
        except Exception:
            self.log(f"💬 Dictation (no target): {text}")
            self.status_bar.showMessage(f"💬 {text[:50]}...", 3000)

    def on_dictation_status(self, message):
        """Show dictation status"""
        self.log(message)  # Also log to Log tab
        self.status_bar.showMessage(message, 2000)

    def on_dictation_error(self, error_msg):
        """Handle dictation error"""
        self._set_dictation_button_recording(False)

        # Log the full error to Log tab
        self.log("")
        self.log("❌ SUPERVOICE ERROR:")
        self.log(error_msg)
        self.log("")

        self.status_bar.showMessage(f"❌ Voice dictation error", 3000)

        # Hand the mic back to always-on if we paused it for this dictation.
        self._resume_alwayson_after_dictation()

        # Show detailed error dialog for FFmpeg issues
        if "FFmpeg" in error_msg or "ffmpeg" in error_msg:
            QMessageBox.warning(self, "FFmpeg Required", error_msg)

    def on_dictation_finished(self):
        """Handle dictation thread finishing"""
        self.log("✓ Dictation thread finished")
        self._set_dictation_button_recording(False)
        # Dismiss the "🎤 Listening…" toast if it's still up.
        try:
            toast = getattr(self, '_dictation_toast', None)
            if toast is not None:
                toast.dismiss()
                self._dictation_toast = None
        except Exception:
            pass
        # Hand the mic back to always-on if we paused it for this dictation.
        self._resume_alwayson_after_dictation()

    def _resume_alwayson_after_dictation(self):
        """Re-enable the always-on listener if push-to-talk had paused it.

        Idempotent – safe to call from both the success and error paths.
        Only resumes if (a) we set the flag in ``start_voice_dictation``
        AND (b) the listener is still around AND in a paused state.
        """
        if not getattr(self, '_alwayson_was_running_before_dictation', False):
            return
        self._alwayson_was_running_before_dictation = False
        try:
            if (self.voice_listener
                    and self.voice_listener.is_listening
                    and getattr(self.voice_listener, 'is_paused', False)):
                self.voice_listener.resume()
                self.log("▶️ Always-on resumed")
        except Exception as e:
            self.log(f"⚠ Could not resume always-on listener: {e}")

    def on_model_loading_started(self, model_name):
        """Handle Whisper model loading/download starting"""
        self.is_loading_model = True
        self.loading_model_name = model_name
        model_sizes = {
            'tiny': '75 MB',
            'base': '142 MB',
            'small': '466 MB',
            'medium': '1.5 GB',
            'large': '2.9 GB'
        }
        size = model_sizes.get(model_name, 'unknown size')

        # Check if model exists
        import os
        cache_dir = self._get_whisper_cache_path()
        model_files = [
            f"{model_name}.pt",
            f"{model_name}.en.pt",
            f"{model_name}-v3.pt"
        ]
        model_exists = any(os.path.exists(os.path.join(cache_dir, f)) for f in model_files)

        if model_exists:
            self.status_bar.showMessage(f"🎤 Voice: Loading '{model_name}' model...", 10000)
            self.log(f"⏳ Loading Whisper model '{model_name}' from cache...")
        else:
            self.status_bar.showMessage(f"📥 Voice: Downloading '{model_name}' model ({size})...", 60000)
            self.log(f"")
            self.log(f"📥 DOWNLOADING Whisper model '{model_name}' ({size})...")
            self.log(f"   This is a one-time download. Please be patient!")
            self.log(f"   ⚠️ DO NOT CLOSE SUPERVERTALER until download completes!")
            self.log(f"   Location: {cache_dir}")
            self.log(f"")

    def on_model_loading_finished(self):
        """Handle Whisper model loading/download finishing"""
        model_name = self.loading_model_name  # Save before clearing
        self.is_loading_model = False
        self.loading_model_name = None
        self.status_bar.showMessage(f"🎤 Voice: '{model_name}' model ready", 3000)
        self.log(f"✅ Model '{model_name}' loaded successfully")

    def _dictation_shortcut_label(self) -> str:
        """Return the current voice-dictation shortcut as a display suffix,
        e.g. " (Ctrl+Shift+Space)", or "" when the shortcut is unbound.

        The dictation key is user-configurable (Settings → Keyboard Shortcuts,
        ``voice_dictate``), so the Dictate/Stop buttons read it live rather than
        hardcoding a key that goes stale on rebind. (The old "(F9)" label was
        wrong — the default is Ctrl+Shift+Space.)
        """
        sm = getattr(self, 'shortcut_manager', None)
        if sm is None:
            return ""
        try:
            key = sm.get_shortcut('voice_dictate')
        except Exception:
            return ""
        if not key:
            return ""
        try:
            return f" ({format_shortcut_for_display(key)})"
        except Exception:
            return f" ({key})"

    def _set_dictation_button_recording(self, is_recording):
        """Change dictate button appearance based on recording state"""
        suffix = self._dictation_shortcut_label()
        # Update grid view dictate button
        if hasattr(self, 'tab_dictate_btn'):
            button = self.tab_dictate_btn
            if is_recording:
                button.setText(f"⏹️ Stop{suffix}")
                button.setStyleSheet("background-color: #D32F2F; color: white; font-weight: bold;")
            else:
                button.setText(f"🎤 Dictate{suffix}")
                button.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")

        # Update list view dictate button (legacy, for backwards compatibility)
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    # Use stored reference to dictate button
                    if hasattr(panel, 'editor_widget') and hasattr(panel.editor_widget, 'dictate_btn'):
                        button = panel.editor_widget.dictate_btn
                        if is_recording:
                            button.setText(f"⏹️ Stop{suffix}")
                            button.setStyleSheet("background-color: #D32F2F; color: white; font-weight: bold;")
                        else:
                            button.setText(f"🎤 Dictate{suffix}")
                            button.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
                except:
                    pass

    def save_tab_segment(self):
        """Save current segment in tab editor"""
        if not hasattr(self, 'tab_current_segment_id') or not self.tab_current_segment_id:
            return
        
        # Find segment and save to TM if appropriate
        if self.current_project and hasattr(self.current_project, 'segments'):
            for seg in self.current_project.segments:
                if seg.id == self.tab_current_segment_id:
                    # Save to TM ONLY if status is confirmed (user explicitly approved)
                    if seg.status == 'confirmed' and seg.target.strip():
                        try:
                            self.save_segment_to_activated_tms(seg.source, seg.target)
                            self.log(f"✓ Saved segment {self.tab_current_segment_id} to TM")
                        except Exception as e:
                            self.log(f"✓ Saved segment {self.tab_current_segment_id} (TM save failed: {e})")
                    else:
                        self.log(f"✓ Saved segment {self.tab_current_segment_id}")
                    break
        else:
            self.log(f"✓ Saved segment {self.tab_current_segment_id}")
    
    def save_tab_segment_and_next(self):
        """Save current segment and move to next in tab editor"""
        self.save_tab_segment()
        
        # Move to next segment in grid view
        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row < self.table.rowCount() - 1:
                self.table.setCurrentCell(current_row + 1, self.table.currentColumn())
    
    # ========================================================================
    # GRID-BASED EDITING HANDLERS (for toolbar below grid)
    # ========================================================================
    
    def filter_on_selected_text(self):
        """Filter based on currently selected text in source or target column.
        
        Toggle behavior: If filters are already active, pressing Ctrl+Shift+F 
        will clear the filters. Otherwise, filter on selected text.
        """
        from PyQt6.QtWidgets import QApplication
        
        # Check if filters are currently active FIRST (for toggle behavior)
        source_filter_text = self._get_line_edit_text('source_filter').strip() if hasattr(self, 'source_filter') else ""
        target_filter_text = self._get_line_edit_text('target_filter').strip() if hasattr(self, 'target_filter') else ""
        filters_active = bool(source_filter_text or target_filter_text)
        
        # Toggle: if filters are active, clear them and return
        if filters_active:
            self.clear_filters()
            self.log(f"🔍 Filters cleared ({format_shortcut_for_display('Ctrl+Shift+F')} toggle)")
            return
        
        # No filters active - try to filter on selected text
        focused_widget = QApplication.focusWidget()
        
        selected_text = ""
        is_source = False
        is_target = False
        
        # Check if it's a text editor widget (source or target)
        if focused_widget and isinstance(focused_widget, (EditableGridTextEditor, ReadOnlyGridTextEditor)):
            cursor = focused_widget.textCursor()
            selected_text = cursor.selectedText().strip()
            
            # Determine if it's source or target by checking which column it's in
            if hasattr(self, 'table') and self.table:
                for row in range(self.table.rowCount()):
                    # Check source column (index 2)
                    source_widget = self.table.cellWidget(row, 2)
                    if source_widget == focused_widget:
                        is_source = True
                        break
                    # Check target column (index 3)
                    target_widget = self.table.cellWidget(row, 3)
                    if target_widget == focused_widget:
                        is_target = True
                        break
        
        # Strip invisible character markers so filtering matches clean segment text
        if selected_text and hasattr(self, 'reverse_invisible_replacements'):
            selected_text = self.reverse_invisible_replacements(selected_text)
            selected_text = selected_text.strip()

        if not selected_text:
            self.log("⚠️ No text selected. Select text in source or target column first.")
            return

        # Put selected text in appropriate filter box and apply filter
        if is_source and hasattr(self, 'source_filter') and self.source_filter:
            self.source_filter.setText(selected_text)
            self.apply_filters()
            self.log(f"🔍 Filtering source on: '{selected_text}'")
        elif is_target and hasattr(self, 'target_filter') and self.target_filter:
            self.target_filter.setText(selected_text)
            self.apply_filters()
            self.log(f"🔍 Filtering target on: '{selected_text}'")
    
    def copy_source_to_grid_target(self):
        """Copy source to target in currently selected grid row"""
        if not hasattr(self, 'table') or not self.table:
            return

        current_row = self.table.currentRow()
        if current_row < 0 or not self.current_project or current_row >= len(self.current_project.segments):
            return

        segment = self.current_project.segments[current_row]
        segment.target = segment.source

        # Update grid cell - target is column 3 (ID=0, Status=1, Source=2, Target=3)
        # v1.9.306: Apply invisible markers for display
        target_widget = self.table.cellWidget(current_row, 3)
        if target_widget and isinstance(target_widget, EditableGridTextEditor):
            display_text = self.apply_invisible_replacements(segment.source) if hasattr(self, 'apply_invisible_replacements') else segment.source
            target_widget.setPlainText(display_text)
        
        self.project_modified = True
        self.log(f"📋 Copied source to target in segment {segment.id}")
    
    def clear_grid_target(self):
        """Clear target in currently selected grid row"""
        if not hasattr(self, 'table') or not self.table:
            return
        
        current_row = self.table.currentRow()
        if current_row < 0 or not self.current_project or current_row >= len(self.current_project.segments):
            return
        
        segment = self.current_project.segments[current_row]
        segment.target = ""
        
        # Update grid cell - target is column 3 (ID=0, Status=1, Source=2, Target=3)
        target_widget = self.table.cellWidget(current_row, 3)
        if target_widget and isinstance(target_widget, EditableGridTextEditor):
            target_widget.clear()
        
        self.project_modified = True
        self.log(f"🗑️ Cleared target in segment {segment.id}")
    
    def save_grid_segment(self):
        """Save current segment from grid"""
        if not hasattr(self, 'table') or not self.table:
            return
        
        current_row = self.table.currentRow()
        if current_row < 0 or current_row >= len(self.current_project.segments):
            return
        
        segment = self.current_project.segments[current_row]
        
        # Save to TM ONLY if status is confirmed (user explicitly approved)
        if segment.status == 'confirmed' and segment.target.strip():
            try:
                self.save_segment_to_activated_tms(segment.source, segment.target)
                self.log(f"💾 Saved segment {segment.id} to TM")
            except Exception as e:
                self.log(f"💾 Saved segment {segment.id} (TM save failed: {e})")
        else:
            self.log(f"💾 Saved segment {segment.id}")
    
    def save_grid_segment_and_next(self):
        """Save current segment and move to next in grid"""
        self.save_grid_segment()
        
        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row < self.table.rowCount() - 1:
                next_row = current_row + 1
                
                # ⚡ INSTANT NAVIGATION
                self._ctrl_enter_navigation = True
                
                self.table.setCurrentCell(next_row, 3)  # Column 3 = Target (widget column)
                # Get the target cell widget and set focus to it
                target_widget = self.table.cellWidget(next_row, 3)
                if target_widget:
                    target_widget.setFocus()
                    # Move cursor to end of text
                    target_widget.moveCursor(QTextCursor.MoveOperation.End)
    
    # ========================================================================
    # KEYBOARD NAVIGATION SHORTCUTS
    # ========================================================================
    
    def select_previous_match(self):
        """Cycle to previous match (Ctrl+Up)

        Context-aware behavior:
        - If Compare Panel tab is active: navigate MT/TM list depending on focused box
        - If Translation Results tab is active: delegate to visible results panel
        - Otherwise: do nothing (avoid acting on hidden panels)
        """
        mode = self._get_active_match_shortcut_mode()
        if mode == 'compare':
            self._compare_panel_nav_active_box(-1)
            return
        if mode == 'results':
            for panel in self._iter_visible_results_panels():
                try:
                    if hasattr(panel, 'select_previous_match'):
                        panel.select_previous_match()
                        break
                except Exception as e:
                    self.log(f"Error selecting previous match: {e}")
            return
    
    def select_next_match(self):
        """Cycle to next match (Ctrl+Down)

        Context-aware behavior:
        - If Compare Panel tab is active: navigate MT/TM list depending on focused box
        - If Translation Results tab is active: delegate to visible results panel
        - Otherwise: do nothing (avoid acting on hidden panels)
        """
        mode = self._get_active_match_shortcut_mode()
        if mode == 'compare':
            self._compare_panel_nav_active_box(1)
            return
        if mode == 'results':
            for panel in self._iter_visible_results_panels():
                try:
                    if hasattr(panel, 'select_next_match'):
                        panel.select_next_match()
                        break
                except Exception as e:
                    self.log(f"Error selecting next match: {e}")
            return
    
    def insert_match_by_number(self, match_number: int):
        """Insert match by number (Ctrl+1-9)

        Context-aware behavior:
        - If Compare Panel tab is active: insert from Compare Panel MT/TM list depending on focused box
        - If Translation Results tab is active: delegate to visible results panel
        - Otherwise: do nothing (avoid acting on hidden panels)
        """
        mode = self._get_active_match_shortcut_mode()
        if mode == 'compare':
            try:
                self._insert_compare_panel_match_by_number(match_number)
            except Exception as e:
                self.log(f"Error inserting Compare Panel match #{match_number}: {e}")
            return
        if mode == 'results':
            for panel in self._iter_visible_results_panels():
                try:
                    if hasattr(panel, 'insert_match_by_number'):
                        if panel.insert_match_by_number(match_number):
                            break
                except Exception as e:
                    self.log(f"Error inserting match #{match_number}: {e}")
            return
    
    def _handle_termlens_shortcut(self, key_num: int):
        """Handle TermLens shortcut with double-tap detection for terms 11-20
        
        TermLens numbering starts at 1 (Alt+1..Alt+9). Alt+0 is reserved for the Compare Panel.
        
        Single tap (Alt+N): Insert term N (1-9)
        Double tap (Alt+N,N within 300ms): Undo first insert, then insert term N+10 (11, 22, ..., 99)
        """
        import time

        # Alt+0 is reserved for the Compare Panel.
        if key_num == 0:
            return

        current_time = time.time()
        double_tap_threshold = 0.3  # 300ms window for double-tap
        
        # Check if this is a double-tap (same key within threshold)
        if (hasattr(self, '_termlens_last_key') and 
            self._termlens_last_key == key_num and 
            hasattr(self, '_termlens_last_time') and
            (current_time - self._termlens_last_time) < double_tap_threshold):
            # Double-tap detected! 
            # First, undo the single-tap insertion
            current_widget = self._get_current_target_widget()
            if current_widget and hasattr(current_widget, 'undo'):
                current_widget.undo()
            
            # Now insert term 10-19 (displayed as 00, 11, ..., 99)
            term_index = key_num + 10
            self._termlens_last_key = None  # Reset to prevent triple-tap
            self._termlens_last_time = 0
            self.insert_termlens_term_by_number(term_index)
        else:
            # Single tap - insert term 1-9
            self._termlens_last_key = key_num
            self._termlens_last_time = current_time
            self.insert_termlens_term_by_number(key_num)
    
    def _get_current_target_widget(self):
        """Get the current target cell text widget"""
        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row >= 0:
                return self.table.cellWidget(current_row, 3)  # Column 3 = Target
        return None
    
    def insert_termlens_term_by_number(self, term_number: int):
        """Insert term from TermLens by number (0-19)"""
        if hasattr(self, 'termlens_widget') and self.termlens_widget:
            try:
                if hasattr(self.termlens_widget, 'insert_term_by_number'):
                    self.termlens_widget.insert_term_by_number(term_number)
            except Exception as e:
                self.log(f"Error inserting TermLens term #{term_number}: {e}")
    
    def insert_selected_match(self):
        """Insert currently selected match (Ctrl+Space)

        Context-aware behavior:
        - If Compare Panel tab is active: insert current MT translation or TM target depending on focused box
        - If Translation Results tab is active: delegate to visible results panel
        - Otherwise: do nothing (avoid acting on hidden panels)
        """
        mode = self._get_active_match_shortcut_mode()
        if mode == 'compare':
            try:
                self._insert_compare_panel_current_match()
            except Exception as e:
                self.log(f"Error inserting Compare Panel selected match: {e}")
            return
        if mode == 'results':
            for panel in self._iter_visible_results_panels():
                try:
                    if hasattr(panel, 'insert_selected_match'):
                        if panel.insert_selected_match():
                            break
                except Exception as e:
                    self.log(f"Error inserting selected match: {e}")
            return

    def _get_active_match_shortcut_mode(self) -> str:
        """Return active shortcut context for match shortcuts.

        Returns:
            'match' if Match Panel tab is active,
            'results' if Translation Results tab is active,
            '' otherwise.
        """
        if hasattr(self, 'right_tabs') and self._widget_is_alive(self.right_tabs):
            try:
                current = self.right_tabs.currentWidget()
            except Exception:
                current = None
            if current is not None:
                if hasattr(self, 'match_panel_widget') and self.match_panel_widget and current is self.match_panel_widget:
                    return 'match'
                if hasattr(self, 'translation_results_panel') and self.translation_results_panel and current is self.translation_results_panel:
                    return 'results'
        return ''

    def _iter_visible_results_panels(self):
        """Yield results panels that are visible to the user (tab-selected)."""
        if not hasattr(self, 'results_panels') or not self.results_panels:
            return
        for panel in self.results_panels:
            try:
                if panel and hasattr(panel, 'isVisible') and panel.isVisible():
                    yield panel
            except Exception:
                continue

    def _compare_panel_nav_active_box(self, direction: int):
        """Navigate Compare Panel matches based on which box is focused."""
        if not hasattr(self, 'compare_panel') or not self.compare_panel:
            return
        if not hasattr(self, 'right_tabs') or not self._widget_is_alive(self.right_tabs):
            return
        if self.right_tabs.currentWidget() is not self.compare_panel:
            return

        focus_widget = QApplication.focusWidget()
        if hasattr(self, 'compare_panel_mt') and focus_widget is self.compare_panel_mt:
            self._compare_panel_nav_mt(direction)
            return
        if hasattr(self, 'compare_panel_tm_source') and focus_widget is self.compare_panel_tm_source:
            self._compare_panel_nav_tm(direction)
            return
        if hasattr(self, 'compare_panel_tm_target') and focus_widget is self.compare_panel_tm_target:
            self._compare_panel_nav_tm(direction)
            return

        # Default: TM navigation (more "match-like" than MT)
        self._compare_panel_nav_tm(direction)

    def _insert_compare_panel_current_match(self):
        """Insert currently shown Compare Panel text based on focused box."""
        if not hasattr(self, 'compare_panel') or not self.compare_panel:
            return
        if not hasattr(self, 'right_tabs') or not self._widget_is_alive(self.right_tabs):
            return
        if self.right_tabs.currentWidget() is not self.compare_panel:
            return

        focus_widget = QApplication.focusWidget()
        if hasattr(self, 'compare_panel_mt') and focus_widget is self.compare_panel_mt:
            text = self.compare_panel_mt.toPlainText().strip()
            if text and not text.startswith('('):
                self.on_match_inserted(text)
            return

        # Default to inserting TM target
        if hasattr(self, 'compare_panel_tm_target'):
            text = self.compare_panel_tm_target.toPlainText().strip()
            if text and not text.startswith('('):
                self.on_match_inserted(text)

    def _handle_compare_panel_alt0_shortcut(self):
        """Handle Alt+0 insertion from Match Panel.

        Alt+0 inserts TM Target from Match Panel (works regardless of which right panel tab is selected).
        """
        # Always try to insert TM Target from Match Panel if available
        # (No longer requires Match Panel tab to be selected)
        if hasattr(self, 'match_panel_tm_target'):
            text = self.match_panel_tm_target.toPlainText().strip()
            if text and not text.startswith('('):
                self._replace_current_target_segment_text(text, source_label='TM Target (Match Panel)')
                return

        # Legacy Compare Panel support (not currently used)
        mode = self._get_active_match_shortcut_mode()
        if mode != 'compare':
            return

        import time
        current_time = time.time()
        double_tap_threshold = 0.3  # 300ms window
        key_num = 0

        if (hasattr(self, '_compare_panel_last_key') and
            self._compare_panel_last_key == key_num and
            hasattr(self, '_compare_panel_last_time') and
            (current_time - self._compare_panel_last_time) < double_tap_threshold):
            # Double-tap: undo MT insert, then insert TM Target
            current_widget = self._get_current_target_widget()
            if current_widget and hasattr(current_widget, 'undo'):
                current_widget.undo()
            self._compare_panel_last_key = None
            self._compare_panel_last_time = 0
            if hasattr(self, 'compare_panel_tm_target'):
                text = self.compare_panel_tm_target.toPlainText().strip()
                if text and not text.startswith('('):
                    self._replace_current_target_segment_text(text, source_label='TM Target (Compare Panel)')
            return

        # Single tap: insert MT
        self._compare_panel_last_key = key_num
        self._compare_panel_last_time = current_time
        if hasattr(self, 'compare_panel_mt'):
            text = self.compare_panel_mt.toPlainText().strip()
            if text and not text.startswith('('):
                self._replace_current_target_segment_text(text, source_label='MT (Compare Panel)')

    def _replace_current_target_segment_text(self, new_text: str, source_label: str = ""):
        """Replace the entire current target segment text (single undo step).

        This is used for Compare Panel quick-insert shortcuts like Alt+0 / Alt+0,0,
        where the user expects a full replacement (not insertion at cursor).
        """
        try:
            if not self.current_project or not self.table:
                return

            row = self.table.currentRow()
            if row < 0:
                return

            id_item = self.table.item(row, 0)
            if not id_item:
                self.log(f"⚠️ No segment ID found at row {row}")
                return

            try:
                segment_id = int(id_item.text())
            except (ValueError, AttributeError):
                self.log(f"⚠️ Could not parse segment ID from row {row}")
                return

            segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
            if not segment:
                self.log(f"⚠️ Could not find segment with ID {segment_id}")
                return

            target_widget = self.table.cellWidget(row, 3)
            if target_widget and isinstance(target_widget, QTextEdit):
                from PyQt6.QtGui import QTextCursor

                # Strip outer wrapping tags for display if setting is enabled
                display_text = new_text
                if self.hide_outer_wrapping_tags:
                    display_text, _ = strip_outer_wrapping_tags(display_text)

                cursor = target_widget.textCursor()
                cursor.beginEditBlock()
                cursor.select(QTextCursor.SelectionType.Document)
                cursor.insertText(display_text)
                cursor.endEditBlock()

                # Store full text (with tags) in segment, but use displayed text if tags are hidden
                segment.target = new_text  # Keep full text with tags
                target_widget.setFocus()
                target_widget.moveCursor(QTextCursor.MoveOperation.End)

                label = f" ({source_label})" if source_label else ""
                self.log(f"✓ Replaced target text in segment {segment.id}{label}")
                self._play_sound_effect('match_inserted')
                return

            # Fallback: no editor widget, update data and (if possible) update cell
            segment.target = new_text
            if target_widget and hasattr(target_widget, 'setPlainText'):
                # Strip outer wrapping tags if setting is enabled
                display_text = new_text
                if self.hide_outer_wrapping_tags:
                    display_text, _ = strip_outer_wrapping_tags(display_text)
                target_widget.setPlainText(display_text)
            label = f" ({source_label})" if source_label else ""
            self.log(f"✓ Replaced target text in segment {segment_id}{label}")
            self._play_sound_effect('match_inserted')

        except Exception as e:
            self.log(f"Error replacing target text: {e}")
            import traceback
            traceback.print_exc()

    def _play_sound_effect(self, event: str):
        """Play a minimalist UI sound effect (currently uses OS beep).

        Event is informational for future expansion.
        """
        try:
            # Prefer cached in-memory value if available (avoids repeated file reads)
            enabled = getattr(self, 'enable_sound_effects', None)
            if enabled is None:
                settings = self.load_general_settings() if hasattr(self, 'load_general_settings') else {}
                enabled = settings.get('enable_sound_effects', False)

            if not enabled:
                return

            # Per-event mapping (defaults provided by general settings defaults)
            sound_map = getattr(self, 'sound_effects_map', None)
            if sound_map is None:
                settings = self.load_general_settings() if hasattr(self, 'load_general_settings') else {}
                sound_map = settings.get('sound_effects_map', {}) if isinstance(settings, dict) else {}

            sound_key = None
            if isinstance(sound_map, dict):
                sound_key = sound_map.get(event)

            # Windows: MessageBeep tends to be more audible than QApplication.beep()
            if os.name == 'nt':
                try:
                    import winsound

                    if sound_key == 'none':
                        return

                    wav_key_to_path = {
                        'windows_proximity_wav': r"C:\\Windows\\Media\\Windows Proximity Notification.wav",
                        'windows_restore_wav': r"C:\\Windows\\Media\\Windows Restore.wav",
                        'windows_navigation_start_wav': r"C:\\Windows\\Media\\Windows Navigation Start.wav",
                        'speech_disambiguation_wav': r"C:\\Windows\\Media\\Speech Disambiguation.wav",
                        'windows_startup_wav': r"C:\\Windows\\Media\\Windows Startup.wav",
                    }
                    if sound_key in wav_key_to_path:
                        wav_path = wav_key_to_path.get(sound_key)
                        try:
                            if wav_path and os.path.exists(wav_path):
                                winsound.PlaySound(wav_path, winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
                                return
                        except Exception:
                            # Fall back to a beep below
                            pass

                    sound_to_mb = {
                        None: winsound.MB_OK,
                        'ok': winsound.MB_OK,
                        'asterisk': winsound.MB_ICONASTERISK,
                        'exclamation': winsound.MB_ICONEXCLAMATION,
                        'hand': winsound.MB_ICONHAND,
                        'question': winsound.MB_ICONQUESTION,
                    }
                    winsound.MessageBeep(sound_to_mb.get(sound_key, winsound.MB_OK))
                    return
                except Exception:
                    pass

            QApplication.beep()
        except Exception:
            # Never allow sound effects to break normal workflows
            return

    def _queue_tm_save_log(self, msg: str):
        """Collapse repeated TM-save log messages into a single '(xN)' line.

        This prevents session log spam when confirming many segments.
        Thread-safe: flushes via a background timer and relies on self.log() being thread-safe.
        """
        import threading

        if not hasattr(self, '_tm_save_log_lock'):
            self._tm_save_log_lock = threading.Lock()
            self._tm_save_log_msg = None
            self._tm_save_log_count = 0
            self._tm_save_log_timer = None

        flush_now_msg = None
        flush_now_count = 0

        with self._tm_save_log_lock:
            # If we're switching to a different message, flush the previous one immediately.
            if self._tm_save_log_msg and self._tm_save_log_msg != msg and self._tm_save_log_count:
                flush_now_msg = self._tm_save_log_msg
                flush_now_count = self._tm_save_log_count
                self._tm_save_log_msg = None
                self._tm_save_log_count = 0

            # Accumulate the new message
            if self._tm_save_log_msg == msg:
                self._tm_save_log_count += 1
            else:
                self._tm_save_log_msg = msg
                self._tm_save_log_count = 1

            # Reset flush timer (debounce)
            if self._tm_save_log_timer is not None:
                try:
                    self._tm_save_log_timer.cancel()
                except Exception:
                    pass
                self._tm_save_log_timer = None

            self._tm_save_log_timer = threading.Timer(0.8, self._flush_tm_save_log_threadsafe)
            self._tm_save_log_timer.daemon = True
            self._tm_save_log_timer.start()

        # Flush outside lock
        if flush_now_msg and flush_now_count:
            suffix = f" (x{flush_now_count})" if flush_now_count > 1 else ""
            self.log(f"{flush_now_msg}{suffix}")

    def _flush_tm_save_log_threadsafe(self):
        import threading

        if not hasattr(self, '_tm_save_log_lock'):
            return

        msg = None
        count = 0
        with self._tm_save_log_lock:
            msg = getattr(self, '_tm_save_log_msg', None)
            count = int(getattr(self, '_tm_save_log_count', 0) or 0)
            self._tm_save_log_msg = None
            self._tm_save_log_count = 0
            self._tm_save_log_timer = None

        if msg and count:
            suffix = f" (x{count})" if count > 1 else ""
            self.log(f"{msg}{suffix}")

    def insert_compare_panel_mt(self):
        """Insert the currently shown Compare Panel MT translation (default: Alt+M)."""
        if self._get_active_match_shortcut_mode() != 'compare':
            return
        self._insert_compare_panel_mt_current()

    def insert_compare_panel_tm_target(self):
        """Insert the currently shown Compare Panel TM target (default: Alt+T)."""
        if self._get_active_match_shortcut_mode() != 'compare':
            return
        self._insert_compare_panel_tm_target_current()

    def _insert_compare_panel_mt_current(self):
        if not hasattr(self, 'compare_panel_mt'):
            return
        text = self.compare_panel_mt.toPlainText().strip()
        if text and not text.startswith('('):
            self.on_match_inserted(text)

    def _insert_compare_panel_tm_target_current(self):
        if not hasattr(self, 'compare_panel_tm_target'):
            return
        text = self.compare_panel_tm_target.toPlainText().strip()
        if text and not text.startswith('('):
            self.on_match_inserted(text)

    def _insert_compare_panel_match_by_number(self, match_number: int):
        """Insert Compare Panel content by number (Ctrl+1-9 while Compare Panel is active).

        Only two of these make sense for the Compare Panel:
        - 1 = MT (current)
        - 2 = TM Target (current)
        """
        if match_number not in (1, 2):
            return
        if not hasattr(self, 'compare_panel') or not self.compare_panel:
            return
        if not hasattr(self, 'right_tabs') or not self._widget_is_alive(self.right_tabs):
            return
        if self.right_tabs.currentWidget() is not self.compare_panel:
            return

        if match_number == 1:
            self._insert_compare_panel_mt_current()
            return
        if match_number == 2:
            self._insert_compare_panel_tm_target_current()
            return
    
    def go_to_previous_segment(self, target_column: int = None, to_last_line: bool = False):
        """Navigate to previous segment (Alt+Up)
        
        Args:
            target_column: If provided, position cursor at this column (for arrow key nav)
            to_last_line: If True with target_column, go to last line at that column
        """
        from PyQt6.QtGui import QTextCursor
        
        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row > 0:
                new_row = current_row - 1
                # Skip hidden rows (empty structural segments, filtered rows)
                while new_row > 0 and self.table.isRowHidden(new_row):
                    new_row -= 1
                if self.table.isRowHidden(new_row):
                    return  # No visible row above
                self.table.clearSelection()
                self.table.setCurrentCell(new_row, 3)  # Column 3 = Target (widget column)
                # Get the target cell widget and set focus to it
                target_widget = self.table.cellWidget(new_row, 3)
                if target_widget:
                    target_widget.setFocus()
                    if target_column is not None and to_last_line:
                        # Position at target column on VISUAL last line (for Up arrow navigation)
                        cursor = target_widget.textCursor()
                        doc = target_widget.document()
                        last_block = doc.lastBlock()
                        layout = last_block.layout()

                        if layout and layout.lineCount() > 0:
                            # Get the last VISUAL line within this block
                            last_visual_line = layout.lineAt(layout.lineCount() - 1)
                            line_start_in_block = int(last_visual_line.textStart())
                            line_length = int(last_visual_line.textLength())
                            target_pos_in_line = min(target_column, max(0, line_length - 1))
                            absolute_pos = last_block.position() + line_start_in_block + target_pos_in_line
                            cursor.setPosition(absolute_pos)
                        else:
                            # Fallback: position at target column from block start
                            cursor.setPosition(last_block.position())
                            line_length = last_block.length() - 1
                            target_pos = min(target_column, max(0, line_length))
                            cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, target_pos)

                        target_widget.setTextCursor(cursor)
                    else:
                        # Default: move cursor to end of text
                        target_widget.moveCursor(QTextCursor.MoveOperation.End)

    def go_to_next_segment(self, target_column: int = None, to_first_line: bool = False):
        """Navigate to next segment (Alt+Down)

        Args:
            target_column: If provided, position cursor at this column (for arrow key nav)
            to_first_line: If True with target_column, go to first line at that column
        """
        from PyQt6.QtGui import QTextCursor

        if hasattr(self, 'table') and self.table:
            current_row = self.table.currentRow()
            if current_row < self.table.rowCount() - 1:
                new_row = current_row + 1
                # Skip hidden rows (empty structural segments, filtered rows)
                while new_row < self.table.rowCount() - 1 and self.table.isRowHidden(new_row):
                    new_row += 1
                if self.table.isRowHidden(new_row):
                    return  # No visible row below
                self.table.clearSelection()
                self.table.setCurrentCell(new_row, 3)  # Column 3 = Target (widget column)
                # Get the target cell widget and set focus to it
                target_widget = self.table.cellWidget(new_row, 3)
                if target_widget:
                    target_widget.setFocus()
                    if target_column is not None and to_first_line:
                        # Position at target column on VISUAL first line (for Down arrow navigation)
                        cursor = target_widget.textCursor()
                        doc = target_widget.document()
                        first_block = doc.firstBlock()
                        layout = first_block.layout()
                        
                        if layout and layout.lineCount() > 0:
                            # Get the first VISUAL line within this block
                            first_visual_line = layout.lineAt(0)
                            line_length = int(first_visual_line.textLength())
                            target_pos_in_line = min(target_column, max(0, line_length - 1))
                            absolute_pos = first_block.position() + target_pos_in_line
                            cursor.setPosition(absolute_pos)
                        else:
                            # Fallback: position at target column from block start
                            cursor.setPosition(first_block.position())
                            line_length = first_block.length() - 1
                            target_pos = min(target_column, max(0, line_length))
                            cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, target_pos)
                        
                        target_widget.setTextCursor(cursor)
                    else:
                        # Default: move cursor to end of text
                        target_widget.moveCursor(QTextCursor.MoveOperation.End)
    
    def go_to_first_segment(self):
        """Navigate to first segment (Ctrl+Home).

        Pagination/filters may hide row 0, so switch to the first page and then
        move to the first visible row.
        """
        if not hasattr(self, 'table') or not self.table or self.table.rowCount() <= 0:
            return

        # If pagination is enabled, ensure we're on the first page so row 0 is visible.
        if hasattr(self, 'go_to_first_page'):
            self.go_to_first_page()

        target_row = 0
        if self.table.isRowHidden(target_row):
            # Filters can also hide rows; pick the first visible row.
            target_row = next((r for r in range(self.table.rowCount()) if not self.table.isRowHidden(r)), 0)

        self.table.setCurrentCell(target_row, 3)  # Column 3 = Target (widget column)
        id_item = self.table.item(target_row, 0)
        if id_item is not None:
            self.table.scrollToItem(id_item)
        self.log("⏫ Moved to first segment")

        target_widget = self.table.cellWidget(target_row, 3)
        if target_widget:
            target_widget.setFocus()
            target_widget.moveCursor(QTextCursor.MoveOperation.Start)
    
    def go_to_last_segment(self):
        """Navigate to last segment (Ctrl+End).

        Pagination/filters may hide the last row, so switch to the last page and then
        move to the last visible row.
        """
        if not hasattr(self, 'table') or not self.table or self.table.rowCount() <= 0:
            return

        # If pagination is enabled, ensure we're on the last page so the last row is visible.
        if hasattr(self, 'go_to_last_page'):
            self.go_to_last_page()

        target_row = self.table.rowCount() - 1
        if self.table.isRowHidden(target_row):
            target_row = next((r for r in range(self.table.rowCount() - 1, -1, -1) if not self.table.isRowHidden(r)), target_row)

        self.table.setCurrentCell(target_row, 3)  # Column 3 = Target (widget column)
        id_item = self.table.item(target_row, 0)
        if id_item is not None:
            self.table.scrollToItem(id_item)
        self.log(f"⏬ Moved to last segment ({target_row + 1})")

        target_widget = self.table.cellWidget(target_row, 3)
        if target_widget:
            target_widget.setFocus()
            target_widget.moveCursor(QTextCursor.MoveOperation.End)
    
    def confirm_and_next_unconfirmed(self):
        """Set current segment to confirmed and move to next unconfirmed segment (Ctrl+Enter)."""
        if not hasattr(self, 'table') or not self.table or not self.current_project:
            self.log(f"⚠️ {format_shortcut_for_display('Ctrl+Enter')}: Missing table or project")
            return
        
        current_row = self.table.currentRow()
        if current_row < 0:
            self.log(f"⚠️ {format_shortcut_for_display('Ctrl+Enter')}: No row selected")
            return

        # If text filters are active, Ctrl+Enter should advance through the filtered/visible rows,
        # not "next unconfirmed". This avoids selecting hidden rows and prevents pagination from
        # accidentally unhiding rows.
        if self._is_text_filter_active():
            self._confirm_current_row_segment()
            # Re-apply filters in case the target text changed and affects matching
            self.apply_filters()
            self._move_to_next_visible_row(current_row)
            return
        
        # CRITICAL: Ensure the target widget has focus before reading from it
        # This fixes the issue where Ctrl+Enter doesn't work without mouse interaction
        target_widget_current = self.table.cellWidget(current_row, 3)
        if target_widget_current and not target_widget_current.hasFocus():
            target_widget_current.setFocus()
            # Give Qt event loop time to process the focus change
            QApplication.processEvents()
        
        # Confirm current row (shared implementation)
        self._confirm_current_row_segment()
        
        # Find next unconfirmed segment
        for row in range(current_row + 1, self.table.rowCount()):
            if row < len(self.current_project.segments):
                seg = self.current_project.segments[row]
                if seg.status not in ['confirmed', 'approved']:
                    # Check if row is hidden due to pagination
                    if self.table.isRowHidden(row):
                        # Calculate which page this row is on and switch to it
                        if hasattr(self, 'grid_page_size') and hasattr(self, 'grid_current_page'):
                            target_page = row // self.grid_page_size
                            if target_page != self.grid_current_page:
                                self.grid_current_page = target_page
                                self._update_pagination_ui()
                                self._apply_pagination_to_grid()
                    
                    # ⚡ INSTANT NAVIGATION: Set flag so on_cell_selected uses fast path
                    # This makes Ctrl+Enter feel instant - cursor moves first, lookups happen after
                    self._ctrl_enter_navigation = True
                    
                    self.table.clearSelection()
                    self.table.setCurrentCell(row, 3)  # Column 3 = Target (widget column)
                    self.table.selectRow(row)  # v1.9.182: Ensure row is visually selected
                    # Ensure the row is visible by scrolling to it
                    self.table.scrollToItem(self.table.item(row, 0), QTableWidget.ScrollHint.PositionAtCenter)
                    self.log(f"⏭️ Moved to next unconfirmed segment {seg.id}")

                    # v1.9.182: Explicitly update termlens (don't rely on deferred signal)
                    self._update_termlens_for_segment(seg)

                    # v1.9.182: Explicitly schedule TM lookup (don't rely on deferred signal)
                    if self.enable_tm_matching:
                        find_replace_active = getattr(self, 'find_replace_active', False)
                        if not find_replace_active:
                            self._schedule_mt_and_llm_matches(seg, [])

                    # Get the target cell widget and set focus to it IMMEDIATELY
                    # (moved BEFORE auto-confirm check for instant responsiveness)
                    target_widget = self.table.cellWidget(row, 3)
                    if target_widget:
                        target_widget.setFocus()
                        # Move cursor to end of text
                        target_widget.moveCursor(QTextCursor.MoveOperation.End)

                    # v1.9.182: Defer auto-confirm check to not block navigation
                    # The TM lookup is slow - do it asynchronously after navigation completes
                    if self.auto_confirm_100_percent_matches:
                        QTimer.singleShot(50, lambda r=row, s=seg: self._check_auto_confirm_100_percent(r, s))
                    return
        
        # No more unconfirmed segments, just go to next
        if current_row < self.table.rowCount() - 1:
            next_row = current_row + 1
            
            # Check if row is hidden due to pagination
            if self.table.isRowHidden(next_row):
                # Calculate which page this row is on and switch to it
                if hasattr(self, 'grid_page_size') and hasattr(self, 'grid_current_page'):
                    target_page = next_row // self.grid_page_size
                    if target_page != self.grid_current_page:
                        self.grid_current_page = target_page
                        self._update_pagination_ui()
                        self._apply_pagination_to_grid()
            
            # ⚡ INSTANT NAVIGATION
            self._ctrl_enter_navigation = True
            
            self.table.clearSelection()
            self.table.setCurrentCell(next_row, 3)  # Column 3 = Target (widget column)
            self.table.selectRow(next_row)  # v1.9.182: Ensure row is visually selected
            # Ensure the row is visible by scrolling to it
            self.table.scrollToItem(self.table.item(next_row, 0), QTableWidget.ScrollHint.PositionAtCenter)
            self.log(f"⏭️ Moved to next segment (all remaining confirmed)")

            # v1.9.182: Explicitly update termlens (don't rely on deferred signal)
            if next_row < len(self.current_project.segments):
                next_seg = self.current_project.segments[next_row]
                self._update_termlens_for_segment(next_seg)

                # v1.9.182: Explicitly schedule TM lookup (don't rely on deferred signal)
                if self.enable_tm_matching:
                    find_replace_active = getattr(self, 'find_replace_active', False)
                    if not find_replace_active:
                        self._schedule_mt_and_llm_matches(next_seg, [])

            # Get the target cell widget and set focus to it
            target_widget = self.table.cellWidget(next_row, 3)
            if target_widget:
                target_widget.setFocus()
                # Move cursor to end of text
                target_widget.moveCursor(QTextCursor.MoveOperation.End)

    def _check_auto_confirm_100_percent(self, row: int, seg):
        """
        v1.9.182: Deferred auto-confirm check for 100% TM matches.

        This is called asynchronously after Ctrl+Enter navigation to avoid blocking
        the UI thread with slow TM database queries.
        """
        try:
            # Verify we're still on the same segment (user may have navigated away)
            current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
            if current_row != row:
                return  # User has moved - don't auto-confirm wrong segment

            if not self.enable_tm_matching or not hasattr(self, 'db_manager') or not self.db_manager:
                return

            # Get activated TM IDs from project settings
            activated_tm_ids = []
            if hasattr(self.current_project, 'tm_settings') and self.current_project.tm_settings:
                activated_tm_ids = self.current_project.tm_settings.get('activated_tm_ids', [])

            if not activated_tm_ids:
                return

            # Use get_exact_match for 100% matches
            source_lang = self.current_project.source_lang if hasattr(self.current_project, 'source_lang') else None
            target_lang = self.current_project.target_lang if hasattr(self.current_project, 'target_lang') else None
            exact_match = self.db_manager.get_exact_match(
                seg.source,
                tm_ids=activated_tm_ids,
                source_lang=source_lang,
                target_lang=target_lang
            )

            if not exact_match:
                return

            # Check if there's a 100% match and (target is empty OR overwrite is enabled)
            target_is_empty = not seg.target.strip()
            can_auto_confirm = target_is_empty or self.auto_confirm_overwrite_existing

            if not can_auto_confirm:
                return

            # Verify AGAIN that we're still on the same segment (TM query may have taken time)
            current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
            if current_row != row:
                return  # User has moved during TM lookup

            match_target = exact_match.get('target_text', '')
            if not match_target:
                return

            overwrite_note = " (overwriting existing)" if not target_is_empty else " (empty target)"
            self.log(f"🎯 Auto-confirm: Found 100% TM match for segment {seg.id}{overwrite_note}")

            # Insert the match into the target cell
            target_widget = self.table.cellWidget(row, 3)
            if target_widget:
                target_widget.setPlainText(match_target)
                seg.target = match_target
                seg.status = 'confirmed'
                self.update_status_icon(row, 'confirmed')
                self.project_modified = True

                # Save to TM
                try:
                    self.save_segment_to_activated_tms(seg.source, seg.target)
                    self.log(f"💾 Auto-confirmed and saved segment {seg.id} to TM")
                except Exception as e:
                    self.log(f"⚠️ Error saving auto-confirmed segment to TM: {e}")

                # Continue to the NEXT unconfirmed segment (skip this one)
                self.confirm_and_next_unconfirmed()
        except Exception as e:
            self.log(f"⚠️ Error in auto-confirm check: {e}")

    def confirm_selected_or_next(self):
        """Smart confirm: if multiple segments selected, confirm all; otherwise confirm and go to next.
        
        This is the handler for Ctrl+Enter:
        - Multiple segments selected: confirm all selected segments
        - Single segment: confirm current and go to next unconfirmed
        """
        selected_segments = self.get_selected_segments_from_grid()
        
        if len(selected_segments) > 1:
            # Multiple segments selected - confirm all
            self.confirm_selected_segments()
        else:
            # Single segment - if text filters are active, advance through the filtered rows
            # (handled inside confirm_and_next_unconfirmed for centralization).
            self.confirm_and_next_unconfirmed()

    def _is_text_filter_active(self) -> bool:
        """Return True if the Filter Source/Target boxes currently contain text."""
        source_text = self._get_line_edit_text('source_filter').strip()
        target_text = self._get_line_edit_text('target_filter').strip()
        return bool(source_text or target_text)

    def _confirm_current_row_segment(self) -> Optional[int]:
        """Confirm the current row's segment and sync target text from the grid.

        Returns the confirmed segment ID on success, else None.
        """
        if not hasattr(self, 'table') or not self.table or not self.current_project:
            return None

        current_row = self.table.currentRow()
        if current_row < 0:
            return None

        # Ensure the target widget has focus before reading from it
        target_widget_current = self.table.cellWidget(current_row, 3)
        if target_widget_current and not target_widget_current.hasFocus():
            target_widget_current.setFocus()
            QApplication.processEvents()

        id_item = self.table.item(current_row, 0)
        if not id_item:
            self.log(f"⚠️ Ctrl+Enter: No ID item at row {current_row}")
            return None

        try:
            segment_id = int(id_item.text())
        except (ValueError, AttributeError):
            self.log(f"⚠️ Ctrl+Enter: Could not parse segment ID from row {current_row}")
            return None

        segment = next((seg for seg in self.current_project.segments if seg.id == segment_id), None)
        if not segment:
            self.log(f"⚠️ Ctrl+Enter: Could not find segment with ID {segment_id}")
            return None

        self.log(f"🔍 Ctrl+Enter: Row {current_row}, Segment ID {segment.id}")

        old_target = segment.target
        old_status = segment.status

        target_widget = self.table.cellWidget(current_row, 3)
        if target_widget:
            current_text = target_widget.toPlainText().strip()
            segment.target = current_text

        segment.status = 'confirmed'

        # Record undo state for Ctrl+Enter confirmation
        self.record_undo_state(segment_id, old_target, segment.target, old_status, 'confirmed')
        self.update_status_icon(current_row, 'confirmed')
        self.project_modified = True
        self.log(f"✅ Segment {segment.id} confirmed")

        self.update_progress_stats()

        # Play sound effect for segment confirmation
        self._play_sound_effect('segment_confirmed')

        if segment.target and segment.target.strip():
            try:
                self.save_segment_to_activated_tms(segment.source, segment.target)
                self.log(f"💾 Saved segment {segment.id} to TM: '{segment.target[:50]}...'")
            except Exception as e:
                self.log(f"⚠️ Error saving to TM: {e}")

        return segment_id

    def _move_to_next_visible_row(self, current_row: int) -> None:
        """Move focus to the next visible row after current_row (respects filters + pagination)."""
        if not hasattr(self, 'table') or not self.table or not self.current_project:
            return

        row_count = self.table.rowCount()
        if row_count <= 0:
            return

        page_size = getattr(self, 'grid_page_size', 50)
        has_pagination = isinstance(page_size, int) and page_size < 999999 and page_size > 0

        last_page_applied = getattr(self, 'grid_current_page', 0)

        for row in range(current_row + 1, row_count):
            if has_pagination:
                target_page = row // page_size
                if target_page != last_page_applied:
                    self.grid_current_page = target_page
                    last_page_applied = target_page
                    self._apply_pagination_to_grid()

            if not self.table.isRowHidden(row):
                self.table.clearSelection()
                self.table.setCurrentCell(row, 3)
                id_item = self.table.item(row, 0)
                if id_item is not None:
                    self.table.scrollToItem(id_item)

                target_widget = self.table.cellWidget(row, 3)
                if target_widget:
                    target_widget.setFocus()
                    target_widget.moveCursor(QTextCursor.MoveOperation.End)
                return

        # No more visible rows
        self.log("✅ No more filtered segments")
    
    def confirm_selected_segments(self, segments=None):
        """Confirm all selected segments in the grid.

        Sets status to 'confirmed' for all selected/provided segments and saves them to TM.

        Args:
            segments: Optional list of segments to confirm. If None, uses grid selection.
        """
        if not self.current_project:
            self.log("⚠️ No project loaded")
            return

        selected_segments = segments or self.get_selected_segments_from_grid()

        if not selected_segments:
            self.log("⚠️ No segments selected")
            return
        
        # Sync all target text from grid widgets first
        self._sync_grid_targets_to_segments(selected_segments)
        
        confirmed_count = 0
        tm_saved_count = 0
        
        for segment in selected_segments:
            # Skip already confirmed segments
            if segment.status == 'confirmed':
                continue
            
            old_status = segment.status
            segment.status = 'confirmed'
            confirmed_count += 1
            
            # Update grid status icon
            row = self._find_row_for_segment(segment.id)
            if row >= 0:
                self.update_status_icon(row, 'confirmed')
            
            # Save to TM if target has content
            if segment.target and segment.target.strip():
                try:
                    self.save_segment_to_activated_tms(segment.source, segment.target)
                    tm_saved_count += 1
                except Exception as e:
                    self.log(f"⚠️ Error saving segment {segment.id} to TM: {e}")
        
        if confirmed_count > 0:
            self.project_modified = True
            self.update_window_title()
            self.log(f"✅ Confirmed {confirmed_count} segment(s), saved {tm_saved_count} to TM")
            # Update status bar progress stats
            self.update_progress_stats()
        else:
            self.log(f"ℹ️ All {len(selected_segments)} selected segment(s) were already confirmed")
    
    def confirm_selected_segments_from_menu(self):
        """Confirm selected/filtered segments (called from Edit > Bulk Operations menu)"""
        if not self.current_project:
            QMessageBox.information(self, "Not Available", "Please load a project first.")
            return

        if not hasattr(self, 'table') or not self.table:
            QMessageBox.information(self, "Not Available", "Grid view is not available.")
            return

        segments = self._get_selected_or_filtered_segments("Confirm Segments")
        if segments:
            self.confirm_selected_segments(segments)

    def change_status_selected(self, new_status: str, from_menu: bool = False):
        """Change status of all selected/filtered segments to the specified status.

        Args:
            new_status: The status key to set (e.g., 'draft', 'pretranslated')
            from_menu: If True, show message boxes for errors (called from menu)
        """
        if not self.current_project:
            if from_menu:
                QMessageBox.information(self, "Not Available", "Please load a project first.")
            else:
                self.log("⚠️ No project loaded")
            return

        if not hasattr(self, 'table') or not self.table:
            if from_menu:
                QMessageBox.information(self, "Not Available", "Grid view is not available.")
            return

        # Get status definition for dialog label
        from modules.statuses import get_status
        status_def = get_status(new_status)

        if from_menu:
            selected_segments = self._get_selected_or_filtered_segments(f"Change Status to '{status_def.label}'")
        else:
            selected_segments = self.get_selected_segments_from_grid()

        if not selected_segments:
            if not from_menu:
                self.log("⚠️ No segments selected")
            return

        # Sync all target text from grid widgets first
        self._sync_grid_targets_to_segments(selected_segments)

        changed_count = 0
        for segment in selected_segments:
            # Skip if already has this status
            if segment.status == new_status:
                continue

            segment.status = new_status
            changed_count += 1

            # Update grid status icon
            row = self._find_row_for_segment(segment.id)
            if row >= 0:
                self.update_status_icon(row, new_status)

        if changed_count > 0:
            self.project_modified = True
            self.update_window_title()
            self.log(f"✅ Changed {changed_count} segment(s) to '{status_def.label}'")
            # Update status bar progress stats
            self.update_progress_stats()
        else:
            self.log(f"ℹ️ All {len(selected_segments)} selected segment(s) already have status '{status_def.label}'")

    def _sync_grid_targets_to_segments(self, segments):
        """Sync target text from grid widgets to segment objects.

        This ensures any edits in the grid are captured before confirming.
        Reverses invisible character display replacements and restores
        stripped outer wrapping tags to get clean text for export.
        """
        for segment in segments:
            row = self._find_row_for_segment(segment.id)
            if row >= 0:
                target_widget = self.table.cellWidget(row, 3)
                if target_widget:
                    text = target_widget.toPlainText().strip()
                    # Reverse invisible character display replacements (·→space, →→tab, etc.)
                    text = self.reverse_invisible_replacements(text)
                    # Re-add stripped outer wrapping tag if applicable
                    stripped_tag = getattr(target_widget, '_stripped_outer_tag', None)
                    if stripped_tag and self.hide_outer_wrapping_tags:
                        if text and not text.strip().startswith(f'<{stripped_tag}'):
                            text = f'<{stripped_tag}>{text}</{stripped_tag}>'
                    segment.target = text
    
    def _find_row_for_segment(self, segment_id: int) -> int:
        """Find the grid row index for a segment by ID."""
        if not hasattr(self, 'table') or not self.table:
            return -1
        
        for row in range(self.table.rowCount()):
            id_item = self.table.item(row, 0)
            if id_item:
                try:
                    if int(id_item.text()) == segment_id:
                        return row
                except (ValueError, AttributeError):
                    continue
        return -1

    def insert_termlens_text(self, text: str):
        """Insert text from TermLens into the currently active target field"""
        try:
            # Find the active target editor
            current_row = self.table.currentRow()
            if current_row >= 0:
                target_widget = self.table.cellWidget(current_row, 3)
                if target_widget and hasattr(target_widget, 'textCursor'):
                    # Insert at current cursor position
                    cursor = target_widget.textCursor()
                    cursor.insertText(text)
                    target_widget.setFocus()
                    self.log(f"✓ Inserted term: {text}")
                    return
            
            # Fallback: try tab editor panels
            if hasattr(self, 'tabbed_panels'):
                for panel in self.tabbed_panels:
                    if hasattr(panel, 'editor_widget'):
                        editor = panel.editor_widget
                        if editor.target_editor.hasFocus() or True:  # Insert into first available
                            cursor = editor.target_editor.textCursor()
                            cursor.insertText(text)
                            editor.target_editor.setFocus()
                            self.log(f"✓ Inserted term: {text}")
                            return
            
            self.log("⚠️ No active target field found for insertion")
        except Exception as e:
            self.log(f"✗ Error inserting termlens text: {e}")
    
    def _on_termlens_font_size_changed(self, new_size: int):
        """Persist new TermLens font size and sync the other TermLens panel.

        Triggered by the inline A-/A+ buttons on either TermLens widget.
        """
        try:
            settings = self.load_general_settings()
            if settings.get('termlens_font_size') == new_size:
                # The widget that fired the signal is already at new_size; sync the other.
                pass
            else:
                settings['termlens_font_size'] = new_size
                self.save_general_settings(settings)

            family = settings.get('termlens_font_family', 'Segoe UI')
            bold = settings.get('termlens_font_bold', False)

            # Sync both TermLens widgets without re-emitting the signal.
            sender = self.sender()
            for widget in (getattr(self, 'termlens_widget', None),
                           getattr(self, 'termlens_widget_match', None)):
                if widget is None or widget is sender:
                    continue
                if widget.current_font_size == new_size:
                    continue
                widget.current_font_size = new_size
                if getattr(widget, 'current_source', '') and hasattr(widget, '_last_termbase_matches'):
                    widget.update_with_matches(
                        widget.current_source,
                        widget._last_termbase_matches or [],
                        getattr(widget, '_last_nt_matches', None),
                        getattr(widget, '_status_hint', None),
                    )
                else:
                    widget.set_font_settings(family, new_size, bold)
        except Exception as e:
            self.log(f"⚠ Could not save TermLens font size: {e}")

    def _on_termlens_edit_entry(self, term_id: int, termbase_id: int):
        """Handle edit glossary entry request from TermLens"""
        try:
            # v1.10.67 diagnostic: record what TermLens actually
            # emitted so an open user report (dialog opens with empty
            # fields or with project-direction captions when the
            # termbase declares the reverse direction) can be checked
            # against the dialog's LOAD line. If termbase_id here
            # disagrees with the real termbases.id for this term, the
            # bug is in the TermLens display pipeline upstream — the
            # dialog itself self-corrects via the term_id JOIN in
            # TermbaseEntryEditor.setup_ui (v1.10.67).
            self.log(
                f"[edit-from-termlens] received term_id={term_id!r} "
                f"termbase_id={termbase_id!r} (types: "
                f"{type(term_id).__name__}, {type(termbase_id).__name__})"
            )

            from modules.termbase_entry_editor import TermbaseEntryEditor

            dialog = TermbaseEntryEditor(
                parent=self,
                db_manager=self.db_manager,
                termbase_id=termbase_id,
                term_id=term_id
            )
            
            if dialog.exec():
                # Entry was edited (or deleted via the dialog's red
                # Delete button — both paths call self.accept()).
                # v1.10.64: use the full post-delete refresh helper
                # so the in-memory termbase_index is rebuilt too.
                # That's necessary for the delete-via-dialog path; it's
                # harmless overhead for the edit-via-dialog path (index
                # rebuild is sub-second on typical term counts).
                self.log(f"✓ Termbase entry {term_id} updated/deleted via dialog")
                self._post_termbase_delete_refresh()
        except Exception as e:
            self.log(f"✗ Error editing termbase entry: {e}")
    
    def _on_termlens_refresh_requested(self):
        """Handle the TermLens 🔄 refresh button (v1.10.68).

        v1.10.72: collapsed into the same handler as F5
        (``force_refresh_matches``). The button is now just a
        discoverable visual entry point for the F5 shortcut — useful
        for users who don't know about F5 but find the button by
        hovering. Behaviour is identical to pressing F5:

         - Snapshot-check whether the termbase tables actually
           changed since the last index rebuild; if yes, rebuild
           the in-memory index from disk (catches cross-process
           edits from the Trados plugin or external tools); if no,
           skip the rebuild (keeps the common case fast).
         - Clear per-segment caches.
         - Re-search termbases / TMs / NT lists.
         - Redraw TermLens widgets + the Translation Results panel.

        Before v1.10.72 this called a separate
        ``_post_termbase_delete_refresh`` that always rebuilt the
        index. Having two near-identical refresh paths confused
        users and caused the v1.10.71 "F5 fixes what the 🔄 button
        leaves under-refreshed" symptom; the merge fixes both.
        """
        try:
            self.force_refresh_matches()
        except Exception as e:
            self.log(f"✗ TermLens refresh failed: {e}")

    def _on_termlens_delete_entry(self, term_id: int, termbase_id: int, source_term: str, target_term: str):
        """Handle delete glossary entry request from TermLens"""
        from PyQt6.QtWidgets import QMessageBox
        
        try:
            # Confirm deletion
            reply = QMessageBox.question(
                self,
                "Confirm Deletion",
                f"Delete termbase entry?\n\nSource: {source_term}\nTarget: {target_term}\n\nThis action cannot be undone.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No
            )
            
            if reply == QMessageBox.StandardButton.Yes:
                if self.termbase_mgr:
                    try:
                        if self.termbase_mgr.delete_term(term_id):
                            self.log(f"✓ Deleted termbase entry: {source_term} → {target_term}")

                            # v1.10.64: previously this only called
                            # _refresh_current_segment_matches, which
                            # clears the per-segment cache and re-runs
                            # find_termbase_matches_in_source. But that
                            # search reads from the in-memory
                            # termbase_index, which still contained the
                            # just-deleted entry → the term kept showing
                            # in TermLens after delete. The post-delete
                            # helper additionally rebuilds the index.
                            self._post_termbase_delete_refresh()
                        else:
                            self.log(f"✗ Failed to delete termbase entry")
                    except Exception as e:
                        self.log(f"✗ Error deleting termbase entry from database: {e}")
        except Exception as e:
            self.log(f"✗ Error deleting termbase entry: {e}")
    
    def _refresh_current_segment_matches(self):
        """Refresh termbase matches for the current segment (after edit/delete)"""
        try:
            # Use the targeted refresh method that doesn't trigger TM search
            self._refresh_termbase_display_for_current_segment()
        except Exception as e:
            self.log(f"✗ Error refreshing segment matches: {e}")
    
    def save_tab_notes(self):
        """Save comments in tab editor"""
        if not hasattr(self, 'tab_current_segment_id') or not self.tab_current_segment_id:
            return
        self.log(f"✓ Saved comments for segment {self.tab_current_segment_id}")

    def toggle_tm_from_editor(self, checked: bool, button: QPushButton):
        """Toggle TM/Termbase lookups from segment editor button"""
        # Call the existing toggle method
        self.toggle_tm_termbase_matching(checked)

        # Update button text and style
        if checked:
            button.setText("🔍 TM/Termbase ON")
            self.log("✓ TM/Termbase lookups ENABLED from segment editor")
        else:
            button.setText("🚫 TM/Termbase OFF")
            self.log("⚠️ TM/Termbase lookups DISABLED from segment editor (faster editing)")

    def _toggle_tag_view_via_shortcut(self):
        """Toggle tag view using keyboard shortcut (Ctrl+Shift+H) – cycles: tags → compact → wysiwyg"""
        mode = getattr(self, 'tag_view_mode', 'tags')
        cycle = {'tags': 'compact', 'compact': 'wysiwyg', 'wysiwyg': 'tags'}
        self._set_tag_view_mode(cycle.get(mode, 'tags'))

    def _enable_tag_view_after_import(self):
        """Auto-enable Tag View after importing a document with formatting tags"""
        if hasattr(self, 'tags_btn'):
            self._set_tag_view_mode('tags')
            self.log("🏷️ Tag View auto-enabled (formatting tags detected in import)")

    def _set_tag_view_mode(self, mode: str):
        """Set the tag view mode: 'tags', 'compact', or 'wysiwyg'"""
        self.tag_view_mode = mode
        self.show_tags = (mode == 'tags' or mode == 'compact')

        # Update segmented control buttons
        if hasattr(self, 'wysiwyg_btn'):
            self.wysiwyg_btn.setChecked(mode == 'wysiwyg')
        if hasattr(self, 'compact_btn'):
            self.compact_btn.setChecked(mode == 'compact')
        if hasattr(self, 'tags_btn'):
            self.tags_btn.setChecked(mode == 'tags')

        labels = {'tags': '🏷️ Tag View – showing raw tags',
                  'compact': '📦 Compact View – verbose tags shortened',
                  'wysiwyg': '✨ WYSIWYG View – showing formatted text'}
        self.log(labels.get(mode, mode))

        if hasattr(self, 'table') and self.current_project:
            self._refresh_grid_display_mode()

    def toggle_tag_view(self, checked: bool, button: QPushButton = None):
        """Legacy toggle – routes to _set_tag_view_mode for backward compatibility"""
        self._set_tag_view_mode('tags' if checked else 'wysiwyg')

    def _refresh_grid_display_mode(self):
        """Refresh all visible cells to reflect current tag view mode"""
        if not hasattr(self, 'table') or not self.current_project:
            return

        mode = getattr(self, 'tag_view_mode', 'tags')

        # Suppress target change handlers during refresh to prevent data corruption
        # (switching display modes changes widget content which could trigger textChanged)
        self._suppress_target_change_handlers = True

        # Get current visible rows
        for row in range(self.table.rowCount()):
            # Get segment for this row
            if row >= len(self.current_project.segments):
                continue

            segment = self.current_project.segments[row]

            # Apply tag stripping if enabled
            source_for_display = segment.source
            target_for_display = segment.target

            if self.hide_outer_wrapping_tags:
                stripped_source, _ = strip_outer_wrapping_tags(segment.source)
                source_for_display = stripped_source
                stripped_target, _ = strip_outer_wrapping_tags(segment.target)
                target_for_display = stripped_target

            # Compact mode: build tag map from source, apply to both columns.
            # The map is stored on the target widget so edits can be reversed.
            if mode == 'compact':
                tag_map = {}
                source_for_display = compact_tags(source_for_display, tag_map)
                target_for_display = compact_tags(target_for_display, tag_map)
            else:
                tag_map = None

            # Apply invisible character replacements (e.g. ↵ for line breaks)
            source_for_display = self.apply_invisible_replacements(source_for_display)
            target_for_display = self.apply_invisible_replacements(target_for_display)

            # For compact mode, use show_tags=True so TagHighlighter can color the placeholders
            show_tags_for_cell = (mode in ('tags', 'compact'))

            # Update source cell (column 2)
            source_widget = self.table.cellWidget(row, 2)
            if source_widget and hasattr(source_widget, 'update_display_mode'):
                source_widget.update_display_mode(source_for_display, show_tags_for_cell)

            # Update target cell (column 3)
            target_widget = self.table.cellWidget(row, 3)
            if target_widget and hasattr(target_widget, 'update_display_mode'):
                target_widget.update_display_mode(target_for_display, show_tags_for_cell)
                # Store tag map for reverse-expanding on save
                target_widget._compact_tag_map = tag_map

        # Re-enable target change handlers
        self._suppress_target_change_handlers = False

        # Auto-resize rows since tag length changes affect row heights
        self.auto_resize_rows()

    def update_tab_segment_editor(self, segment_id: int, source_text: str, target_text: str,
                                   status: str = "untranslated", notes: str = ""):
        """Update the tab segment editor with current segment data"""
        # CRITICAL FIX: Update segment ID BEFORE setting text to prevent cross-contamination
        # When setPlainText() triggers textChanged, it needs to update the CORRECT segment
        self.tab_current_segment_id = segment_id
        
        # Update ALL tabbed panels (grid view and list view)
        if hasattr(self, 'tabbed_panels'):
            for panel in self.tabbed_panels:
                try:
                    # Update segment editor tab
                    if hasattr(panel, 'editor_widget'):
                        editor = panel.editor_widget
                        editor.seg_info_label.setText(f"Segment {segment_id}")
                        editor.source_editor.setPlainText(source_text)
                        editor.target_editor.setPlainText(target_text)
                        idx = editor.status_combo.findData(status)
                        if idx >= 0:
                            editor.status_combo.blockSignals(True)
                            editor.status_combo.setCurrentIndex(idx)
                            editor.status_combo.blockSignals(False)
                    
                    # Update notes tab
                    if hasattr(panel, 'notes_widget'):
                        panel.notes_widget.notes_editor.setPlainText(notes)
                except Exception as e:
                    self.log(f"Error updating tabbed panel: {e}")
        
        # NOTE: TermLens is updated AFTER termbase search completes in _on_cell_selected_full
        # Do NOT update TermLens here - the cache may not be populated yet
    
    # ========================================================================
    # UTILITY
    # ========================================================================
    
    def update_window_title(self):
        """Update window title with project name and modified state"""
        title = f"Supervertaler Workbench v{__version__}"
        if ENABLE_PRIVATE_FEATURES:
            title += " [🛠️ DEV MODE]"
        if self.current_project:
            title += f" - {self.current_project.name}"
            if self.project_modified:
                title += " *"
        self.setWindowTitle(title)
    
    def log(self, message: str):
        """Log message to status bar and session log (thread-safe)"""
        # Always safe to print to console
        print(f"[LOG] {message}")
        
        # Check if we're in the main thread - Qt widgets can only be modified from main thread
        import threading
        
        is_main_thread = threading.current_thread() == threading.main_thread()
        
        if not is_main_thread:
            # If we're in a background thread, emit signal which will be handled on main thread
            # pyqtSignal with default connection is thread-safe and queues to main event loop
            try:
                self._log_signal.emit(message)
            except Exception:
                pass  # Silently fail if signal emission fails
            return
        
        # Main thread - safe to access widgets directly
        self._log_to_ui(message)
    
    def _log_to_ui(self, message: str):
        """Internal method to log to UI widgets - must be called from main thread only"""
        if hasattr(self, 'status_bar'):
            self.status_bar.showMessage(message)
        
        # Add to debug buffer if debug mode is enabled
        if hasattr(self, 'debug_mode_enabled') and self.debug_mode_enabled:
            if not hasattr(self, 'debug_log_buffer'):
                self.debug_log_buffer = []
            from datetime import datetime
            timestamp = datetime.now().strftime("%H:%M:%S")
            self.debug_log_buffer.append(f"[{timestamp}] {message}")
            # Keep buffer size reasonable (max 10,000 entries)
            if len(self.debug_log_buffer) > 10000:
                self.debug_log_buffer = self.debug_log_buffer[-10000:]

        # Also append to session log tab if it exists
        if hasattr(self, 'session_log_text') and self.session_log_text:
            from datetime import datetime
            from PyQt6.QtGui import QTextCursor
            timestamp = datetime.now().strftime("%H:%M:%S")
            formatted_message = f"[{timestamp}] {message}"
            try:
                # Append without newline (appendPlainText adds it automatically)
                self.session_log_text.appendPlainText(formatted_message)
                # Force scroll to bottom by moving cursor to end
                self.session_log_text.moveCursor(QTextCursor.MoveOperation.End)
                self.session_log_text.ensureCursorVisible()
            except Exception:
                pass  # Silently fail if widget not ready

        # Also append to Settings tab session log if it exists (separate widget)
        if hasattr(self, 'session_log') and self.session_log:
            from datetime import datetime
            from PyQt6.QtGui import QTextCursor
            timestamp = datetime.now().strftime("%H:%M:%S")
            formatted_message = f"[{timestamp}] {message}"
            try:
                self.session_log.appendPlainText(formatted_message)
                self.session_log.moveCursor(QTextCursor.MoveOperation.End)
                self.session_log.ensureCursorVisible()
            except Exception:
                pass  # Silently fail if widget not ready

        # Also send to detached log windows
        if hasattr(self, 'detached_log_windows'):
            from datetime import datetime
            from PyQt6.QtGui import QTextCursor
            timestamp = datetime.now().strftime("%H:%M:%S")
            formatted_message = f"[{timestamp}] {message}"
            for window in self.detached_log_windows[:]:  # Copy list to avoid modification during iteration
                try:
                    if window and not window.isHidden():
                        window.log_display.appendPlainText(formatted_message)
                        # Force scroll to bottom by moving cursor to end
                        window.log_display.moveCursor(QTextCursor.MoveOperation.End)
                        window.log_display.ensureCursorVisible()
                    else:
                        # Remove closed windows
                        self.detached_log_windows.remove(window)
                except:
                    # Remove invalid windows
                    try:
                        self.detached_log_windows.remove(window)
                    except:
                        pass
    
    def show_options_dialog(self):
        """Show application options dialog - DEPRECATED: Redirects to Settings tab for backwards compatibility"""
        # Redirect to Settings tab instead of showing dialog
        self._go_to_settings_tab()
    
    def _go_to_settings_tab(self, subtab_name: str = None):
        """Navigate to Settings tab (from menu), optionally to a specific sub-tab
        
        Args:
            subtab_name: Name of the sub-tab to navigate to (e.g., "AI Settings")
        """
        if hasattr(self, 'main_tabs'):
            # Main tabs (post-v1.9.467): Grid=0, TMs=1, Termbases=2, AI=3, Settings=4
            self.main_tabs.setCurrentIndex(4)

            # Navigate to specific sub-tab if requested
            if subtab_name and hasattr(self, 'settings_tabs'):
                for i in range(self.settings_tabs.count()):
                    if subtab_name.lower() in self.settings_tabs.tabText(i).lower():
                        self.settings_tabs.setCurrentIndex(i)
                        
                        # If navigating to AI Settings, scroll to bottom (API keys section)
                        if "ai settings" in subtab_name.lower() and hasattr(self, 'ai_settings_scroll'):
                            # Use QTimer to ensure the tab is fully rendered before scrolling
                            from PyQt6.QtCore import QTimer
                            QTimer.singleShot(100, lambda: self.ai_settings_scroll.verticalScrollBar().setValue(
                                self.ai_settings_scroll.verticalScrollBar().maximum()
                            ))
                        break
    
    def _go_to_superlookup(self, query=None, source_lang=None, target_lang=None,
                           switch_to_vertical=True):
        """Open SuperLookup with an optional pre-filled query.

        Pre-v1.10.4 this routed through Sidekick (the floating
        assistant). Sidekick was retired in v1.10.4 and SuperLookup
        now lives as a Workbench top tab; v1.10.10 finished the
        rewire so this method targets the in-Workbench widget
        directly.
        """
        if hasattr(self, 'open_workbench_to_superlookup'):
            # Bring the SuperLookup tab forward without seeding text
            # via the usual hotkey path (which auto-fires the search);
            # we want to seed using search_with_query so we can pass
            # the language pair too.
            self.open_workbench_to_superlookup("")
        else:
            self.log("⚠ SuperLookup tab not available")
            return

        widget = getattr(self, '_superlookup_top_widget', None)
        if widget is not None and query and hasattr(widget, 'search_with_query'):
            widget.search_with_query(
                query,
                switch_to_vertical=switch_to_vertical,
                source_lang=source_lang,
                target_lang=target_lang,
            )
    
    def load_llm_settings(self) -> Dict[str, str]:
        """Load LLM settings from user preferences"""
        defaults = {
            'provider': 'openai',
            'openai_model': 'gpt-5.5',
            'claude_model': 'claude-sonnet-4-6',
            'gemini_model': 'gemini-3.1-flash-lite',
            'ollama_model': 'translategemma:12b',
            'custom_openai_model': '',
            'custom_openai_endpoint': '',
            'custom_openai_profiles': [],
            'custom_openai_active_profile': ''
        }

        try:
            prefs = self._load_settings_section("ui")
            saved = prefs.get('llm_settings', defaults)
            # Ensure new keys exist for older configs
            for k, v in defaults.items():
                saved.setdefault(k, v)
            # Auto-migrate: old single-field config → profiles
            if not saved.get('custom_openai_profiles') and saved.get('custom_openai_endpoint'):
                api_keys = self.load_api_keys() if hasattr(self, 'load_api_keys') else {}
                saved['custom_openai_profiles'] = [{
                    'name': 'Custom Endpoint',
                    'endpoint': saved['custom_openai_endpoint'],
                    'model': saved.get('custom_openai_model', ''),
                    'api_key': api_keys.get('custom_openai', '')
                }]
                saved['custom_openai_active_profile'] = 'Custom Endpoint'
            return saved
        except:
            return defaults
    
    def save_llm_settings(self, settings: Dict[str, str]):
        """Save LLM settings to user preferences"""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['llm_settings'] = settings
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save LLM settings: {str(e)}")

    # -----------------------------------------------------------------------
    # PROXY SETTINGS
    # -----------------------------------------------------------------------

    def load_proxy_settings(self) -> Dict[str, Any]:
        """Load HTTP proxy settings from unified settings store."""
        defaults = {
            'enabled': False,
            'host': '',
            'port': 8080,
            'username': '',
            'password': '',
        }
        try:
            ui = self._load_settings_section("ui")
            saved = ui.get('proxy_settings', defaults)
            for k, v in defaults.items():
                saved.setdefault(k, v)
            return saved
        except Exception:
            return defaults

    def save_proxy_settings(self, proxy_settings: Dict[str, Any]):
        """Save HTTP proxy settings to unified settings store."""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['proxy_settings'] = proxy_settings
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save proxy settings: {str(e)}")

    def _get_proxy_url(self) -> Optional[str]:
        """
        Return a fully-formed proxy URL string for use with requests/httpx, or None
        if the proxy is disabled or not configured.

        Format:  http://[user:pass@]host:port
        """
        try:
            ps = self.load_proxy_settings()
            if not ps.get('enabled'):
                return None
            host = ps.get('host', '').strip()
            port = ps.get('port', 8080)
            if not host:
                return None
            username = ps.get('username', '').strip()
            password = ps.get('password', '').strip()
            if username:
                from urllib.parse import quote
                creds = f"{quote(username, safe='')}:{quote(password, safe='')}@"
            else:
                creds = ''
            return f"http://{creds}{host}:{port}"
        except Exception:
            return None

    def _get_proxy_dict(self) -> Optional[Dict[str, str]]:
        """
        Return a requests-style proxies dict {"http": ..., "https": ...}, or None.
        Used by MT service calls that use the requests library.
        """
        url = self._get_proxy_url()
        if not url:
            return None
        return {"http": url, "https": url}

    def _apply_gemini_proxy(self):
        """
        Apply (or clear) the proxy for Google Gemini by setting/unsetting the
        HTTPS_PROXY environment variable for this process.
        Called on startup and whenever proxy settings are saved.
        """
        import os
        url = self._get_proxy_url()
        if url:
            os.environ["HTTPS_PROXY"] = url
            os.environ["HTTP_PROXY"] = url
        else:
            os.environ.pop("HTTPS_PROXY", None)
            os.environ.pop("HTTP_PROXY", None)

    def _get_active_custom_profile(self, settings=None):
        """Get the active custom_openai profile dict (endpoint, model, api_key).

        Returns dict with keys: name, endpoint, model, api_key – or None if no profile configured.
        Falls back to legacy single-field config for backward compatibility.
        """
        if settings is None:
            settings = self.load_llm_settings()
        profiles = settings.get('custom_openai_profiles', [])
        active_name = settings.get('custom_openai_active_profile', '')

        # Find active profile by name
        for p in profiles:
            if p.get('name') == active_name:
                return p

        # Fallback to first profile
        if profiles:
            return profiles[0]

        # Legacy fallback: old single-field config (pre-profiles)
        endpoint = settings.get('custom_openai_endpoint', '')
        model = settings.get('custom_openai_model', '')
        if endpoint:
            return {'name': 'Custom Endpoint', 'endpoint': endpoint, 'model': model, 'api_key': ''}

        return None

    def create_llm_client(self, provider, model, api_keys, settings=None):
        """Create an LLMClient with proper base_url and proxy handling."""
        from modules.llm_clients import LLMClient
        api_key = api_keys.get(provider) or (api_keys.get('google') if provider == 'gemini' else None)
        base_url = None
        if provider == 'openrouter':
            base_url = 'https://openrouter.ai/api/v1'
        elif provider == 'custom_openai':
            if settings is None:
                settings = self.load_llm_settings()
            profile = self._get_active_custom_profile(settings)
            if profile:
                base_url = profile.get('endpoint') or None
                # Active profile's model is the source of truth; only fall
                # back to a passed-in model if the profile has none set.
                model = (profile.get('model') or '').strip() or model or 'custom-model'
                # Profile API key takes priority, then api_keys.txt fallback
                profile_key = (profile.get('api_key') or '').strip()
                api_key = profile_key or api_keys.get('custom_openai', '') or 'not-needed'
            elif not api_key:
                api_key = api_keys.get('custom_openai', '') or 'not-needed'
        # Pass proxy URL to client (None if proxy disabled or not configured)
        # Gemini uses env vars instead (set by _apply_gemini_proxy at startup/settings save)
        http_proxy = self._get_proxy_url() if provider != 'gemini' else None
        return LLMClient(api_key=api_key, provider=provider, model=model, base_url=base_url, http_proxy=http_proxy)

    def load_provider_enabled_states(self) -> Dict[str, bool]:
        """Load provider enable/disable states from user preferences"""
        defaults = {
            'llm_openai': True,
            'llm_claude': True,
            'llm_gemini': True,
            'llm_mistral': True,
            'llm_openrouter': True,
            'llm_ollama': True,
            'llm_custom_openai': True,
            'mt_google_translate': True,
            'mt_deepl': True,
            'mt_microsoft': True,
            'mt_amazon': True,
            'mt_modernmt': True,
            'mt_mymemory': True
        }

        try:
            prefs = self._load_settings_section("ui")
            saved = prefs.get('provider_enabled_states', defaults)
            # Ensure new keys exist for older configs
            for k, v in defaults.items():
                saved.setdefault(k, v)
            return saved
        except:
            return defaults

    def save_provider_enabled_states(self, states: Dict[str, bool]):
        """Save provider enable/disable states to user preferences"""
        try:
            all_settings = self._load_unified_settings()
            all_settings.setdefault("ui", {})['provider_enabled_states'] = states
            self._save_unified_settings(all_settings)
        except Exception as e:
            self.log(f"⚠ Could not save provider enabled states: {str(e)}")
    
    def update_warning_banner(self):
        """Show/hide warning banner based on allow_replace_in_source setting"""
        if not hasattr(self, 'warning_banners'):
            return

        stale_keys = []
        for key, banner in self.warning_banners.items():
            if not self._widget_is_alive(banner):
                stale_keys.append(key)
                continue
            if self.allow_replace_in_source:
                banner.show()
            else:
                banner.hide()

        for key in stale_keys:
            self.warning_banners.pop(key, None)
        
        # Update the grid delegate to reflect the new setting
        self.update_grid_delegate()
    
    def update_grid_delegate(self):
        """Update the grid's item delegate to reflect current settings"""
        if hasattr(self, 'table') and self._widget_is_alive(self.table):
            # Recreate the delegate with updated settings
            self.table.setItemDelegate(WordWrapDelegate(None, self.table, self.allow_replace_in_source))
    
    def show_about(self):
        import_layout = QVBoxLayout(import_tab)
        
        import_label = QLabel("<h3>Import TMX File</h3>")
        import_layout.addWidget(import_label)
        
        import_info = QLabel(
            "Import translation memories from TMX files. "
            "Entries will be added to the database for the current language pair."
        )
        import_info.setWordWrap(True)
        import_layout.addWidget(import_info)
        
        # File selection
        file_group = QGroupBox("TMX File")
        file_layout = QHBoxLayout()
        
        file_path_label = QLabel("No file selected")
        file_path_label.setStyleSheet("color: #6b7280;")
        file_layout.addWidget(file_path_label, 1)
        
        selected_file = [None]  # Use list to make it mutable in nested function
        
        def browse_tmx():
            file_path, _ = QFileDialog.getOpenFileName(
                dialog,
                "Select TMX File",
                str(self.user_data_path / "resources"),
                "TMX Files (*.tmx);;All Files (*.*)"
            )
            if file_path:
                selected_file[0] = file_path
                file_path_label.setText(file_path)
                file_path_label.setStyleSheet("color: #22c55e;")
        
        browse_btn = QPushButton("📁 Browse...")
        browse_btn.clicked.connect(browse_tmx)
        file_layout.addWidget(browse_btn)
        
        file_group.setLayout(file_layout)
        import_layout.addWidget(file_group)
        
        # Import button
        def import_tmx():
            if not selected_file[0]:
                QMessageBox.warning(dialog, "No File", "Please select a TMX file first.")
                return
            
            if not self.tm_database:
                QMessageBox.warning(dialog, "No Database", "TM database not initialized.")
                return
            
            try:
                # Import TMX file
                tm_id, count = self.tm_database.load_tmx_file(
                    selected_file[0],
                    self.current_project.source_lang,
                    self.current_project.target_lang
                )
                
                QMessageBox.information(
                    dialog,
                    "Import Successful",
                    f"Imported {count} translation entries from TMX file!"
                )
                self.log(f"Imported {count} entries from {selected_file[0]}")
                
                # Clear selection
                selected_file[0] = None
                file_path_label.setText("No file selected")
                file_path_label.setStyleSheet("color: #6b7280;")
                
            except Exception as e:
                QMessageBox.critical(dialog, "Import Error", f"Failed to import TMX file:\n{e}")
        
        import_btn = QPushButton("📥 Import TMX File")
        import_btn.clicked.connect(import_tmx)
        import_btn.setStyleSheet("background-color: #3b82f6; color: white; padding: 10px; font-weight: bold; border: none; outline: none;")
        import_layout.addWidget(import_btn)
        
        import_layout.addStretch()
        tabs.addTab(import_tab, "📥 Import TMX")
        
        # ===== Tab 4: Browse Entries =====
        browse_tab = QWidget()
        browse_layout = QVBoxLayout(browse_tab)
        
        browse_label = QLabel("<h3>Browse TM Entries</h3>")
        browse_layout.addWidget(browse_label)
        
        # Search box
        search_layout = QHBoxLayout()
        search_layout.addWidget(QLabel("🔍 Search:"))
        search_input = QLineEdit()
        search_input.setPlaceholderText("Enter search term...")
        search_layout.addWidget(search_input, 1)
        browse_layout.addLayout(search_layout)
        
        # Results table
        results_table = QTableWidget()
        results_table.setColumnCount(3)
        results_table.setHorizontalHeaderLabels(["Source", "Target", "Match %"])
        results_table.horizontalHeader().setStretchLastSection(False)
        results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
        results_table.setColumnWidth(2, 80)
        results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        browse_layout.addWidget(results_table)
        
        def search_tm():
            search_term = search_input.text().strip()
            if not search_term or not self.tm_database:
                results_table.setRowCount(0)
                return
            
            # Get activated TM IDs for current project
            tm_ids = None
            if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                if project_id:
                    tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
            
            try:
                matches = self.tm_database.search_all(search_term, tm_ids=tm_ids, max_matches=50)
                
                results_table.setRowCount(len(matches))
                for i, match in enumerate(matches):
                    source_item = QTableWidgetItem(match.get('source', ''))
                    target_item = QTableWidgetItem(match.get('target', ''))
                    match_pct_item = QTableWidgetItem(f"{match.get('match_pct', 0)}%")
                    
                    # Color code by match percentage
                    match_pct = match.get('match_pct', 0)
                    if match_pct == 100:
                        color = "#22c55e"
                    elif match_pct >= 95:
                        color = "#3b82f6"
                    elif match_pct >= 85:
                        color = "#f59e0b"
                    else:
                        color = "#ef4444"
                    
                    match_pct_item.setForeground(QColor(color))
                    match_pct_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                    
                    results_table.setItem(i, 0, source_item)
                    results_table.setItem(i, 1, target_item)
                    results_table.setItem(i, 2, match_pct_item)
                
            except Exception as e:
                QMessageBox.critical(dialog, "Search Error", f"Failed to search TM: {e}")
        
        search_input.returnPressed.connect(search_tm)
        search_btn = QPushButton("🔍 Search")
        search_btn.clicked.connect(search_tm)
        search_layout.addWidget(search_btn)
        
        browse_layout.addWidget(QLabel("<i>Tip: Press Enter to search</i>"))
        
        tabs.addTab(browse_tab, "🔍 Browse")
        
        # Add tabs to dialog
        layout.addWidget(tabs)
        
        # Close button
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        layout.addWidget(close_btn)
        
        dialog.exec()
    
    def _open_url(self, url):
        """Open a URL in system default browser"""
        import webbrowser
        try:
            webbrowser.open(url)
        except Exception as e:
            QMessageBox.warning(self, "Error", f"Could not open URL: {e}")

    def _open_superdocs_tab(self):
        """Open the online Supervertaler Help in the user's browser."""
        try:
            # Prefer opening the published online docs
            self._open_url("https://help.supervertaler.com/workbench/")
        except Exception:
            QMessageBox.information(
                self,
                "Supervertaler Help",
                "Supervertaler Help is available online at https://help.supervertaler.com/workbench/"
            )

    def check_for_updates(self):
        """Check GitHub for the latest Supervertaler release and report whether the user is up to date."""
        # Temporary simplified behavior: QtNetwork HTTPS update checks can fail/hang on some Windows
        # environments (seen as QNetworkReply "Unknown error"). Until this is revisited, open the
        # GitHub releases page directly.
        try:
            self._open_url("https://github.com/Supervertaler/Supervertaler-Workbench/releases/latest")
        except Exception as e:
            QMessageBox.warning(self, "Check for Updates", f"Could not open releases page: {e}")
        return

        if getattr(self, "_update_check_in_progress", False):
            QMessageBox.information(self, "Check for Updates", "An update check is already in progress.")
            return

        self._update_check_in_progress = True
        self._update_check_user_canceled = False
        self._update_check_last_stage = "start"
        self._update_check_any_message_shown = False
        self._update_check_python_fallback_started = False

        def _log_update(msg: str):
            # Always emit something to the terminal in dev mode.
            try:
                print(f"[UPDATE] {msg}", flush=True)
            except Exception:
                pass
            # Also use the app log if available.
            try:
                self.log(f"[Update] {msg}")
            except Exception:
                pass

        _log_update("Checking for updates...")

        progress = QProgressDialog("Checking for updates...", "Cancel", 0, 0, self)
        progress.setWindowTitle("Check for Updates")
        progress.setWindowModality(Qt.WindowModality.ApplicationModal)
        progress.setMinimumDuration(0)
        # Prevent the dialog from auto-disappearing when range is (0, 0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.show()

        try:
            progress.destroyed.connect(lambda *_: _log_update("Progress dialog destroyed"))
        except Exception:
            pass

        # Keep references so Qt objects aren't GC'd mid-request
        self._update_check_progress = progress

        if not hasattr(self, "_update_check_net_mgr") or self._update_check_net_mgr is None:
            self._update_check_net_mgr = QNetworkAccessManager(self)

        api_url = QUrl("https://api.github.com/repos/Supervertaler/Supervertaler-Workbench/releases/latest")
        fallback_url = QUrl("https://github.com/Supervertaler/Supervertaler-Workbench/releases/latest")
        timeout_ms = 15000

        self._update_check_fallback_attempted = False
        # Monotonic request id so stale replies/timeouts can't close/delete the active attempt.
        self._update_check_request_id = int(getattr(self, "_update_check_request_id", 0)) + 1
        active_request_id = self._update_check_request_id

        def is_active_request() -> bool:
            try:
                return bool(getattr(self, "_update_check_in_progress", False)) and int(getattr(self, "_update_check_request_id", 0)) == active_request_id
            except Exception:
                return False

        def abort_current_reply():
            try:
                r = getattr(self, "_update_check_reply", None)
                if r and not r.isFinished():
                    r.abort()
            except Exception:
                pass

        def start_request(kind: str):
            """Start either the API or fallback request."""
            if kind not in {"api", "fallback"}:
                kind = "api"

            self._update_check_last_stage = f"request:{kind}"
            _log_update(f"Starting {kind} request")

            abort_current_reply()

            if kind == "fallback":
                try:
                    progress.setLabelText("Checking for updates (fallback)...")
                except Exception:
                    pass
                url = fallback_url
            else:
                try:
                    progress.setLabelText("Checking for updates...")
                except Exception:
                    pass
                url = api_url

            request = QNetworkRequest(url)
            request.setRawHeader(b"User-Agent", f"Supervertaler/{__version__}".encode("utf-8", errors="ignore"))
            if kind == "api":
                request.setRawHeader(b"Accept", b"application/vnd.github+json")

            r = self._update_check_net_mgr.get(request)
            r.setProperty("sv_update_check_kind", kind)
            r.setProperty("sv_update_check_request_id", active_request_id)
            self._update_check_reply = r

            # Helpful when QtNetwork TLS is misconfigured on Windows.
            try:
                r.sslErrors.connect(
                    lambda errs, rr=r: _log_update(
                        "SSL errors: " + "; ".join(getattr(e, "errorString", lambda: str(e))() for e in errs)
                    )
                )
            except Exception:
                pass

            # Restart timeout for each attempt
            try:
                if hasattr(self, "_update_check_timer") and self._update_check_timer:
                    self._update_check_timer.stop()
            except Exception:
                pass
            timeout_timer.start(timeout_ms)

            r.finished.connect(lambda rr=r: on_finished(rr))

        def finish():
            try:
                try:
                    if hasattr(self, "_update_check_timer") and self._update_check_timer:
                        self._update_check_timer.stop()
                except Exception:
                    pass
                try:
                    progress.close()
                except Exception:
                    pass
            finally:
                # If we got here without showing *any* result/error dialog and the user didn't cancel,
                # surface a minimal explanation so it never feels like a no-op.
                try:
                    unexpected = (not bool(getattr(self, "_update_check_user_canceled", False))) and (not bool(getattr(self, "_update_check_any_message_shown", False)))
                    if unexpected:
                        stage = str(getattr(self, "_update_check_last_stage", "(unknown)"))
                        QMessageBox.warning(
                            self,
                            "Check for Updates",
                            "The update check ended unexpectedly.\n\n"
                            f"Stage: {stage}\n\n"
                            "This is usually caused by a network/SSL issue or a blocked request.",
                        )
                except Exception:
                    pass

                self._update_check_in_progress = False
                try:
                    self._update_check_user_canceled = False
                except Exception:
                    pass
                try:
                    if hasattr(self, "_update_check_reply") and self._update_check_reply:
                        self._update_check_reply.deleteLater()
                except Exception:
                    pass
                self._update_check_reply = None
                self._update_check_timer = None
                self._update_check_progress = None

                try:
                    self._update_check_python_fallback_started = False
                except Exception:
                    pass

                try:
                    self._update_check_fallback_attempted = False
                except Exception:
                    pass

                try:
                    self._update_check_last_stage = "finished"
                except Exception:
                    pass

        def show_offline_error(details: str):
            try:
                self._update_check_any_message_shown = True
                self._update_check_last_stage = "offline_error"
            except Exception:
                pass
            msg = (
                "Could not check for updates (offline or GitHub is unavailable).\n\n"
                f"Details: {details}\n\n"
                "Open the releases page in your browser?"
            )
            choice = QMessageBox.question(
                self,
                "Check for Updates",
                msg,
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes,
            )
            if choice == QMessageBox.StandardButton.Yes:
                self._open_url("https://github.com/Supervertaler/Supervertaler-Workbench/releases")

        def start_python_fallback(reason: str = ""):
            """Fallback that uses Python's HTTPS stack (urllib) instead of QtNetwork."""
            try:
                if bool(getattr(self, "_update_check_python_fallback_started", False)):
                    _log_update("Python fallback already started; ignoring")
                    return
                self._update_check_python_fallback_started = True
            except Exception:
                pass

            # Stop Qt timeout timer now that we're using Python's stack.
            try:
                if hasattr(self, "_update_check_timer") and self._update_check_timer:
                    self._update_check_timer.stop()
            except Exception:
                pass

            # Abort any in-flight Qt request to avoid late signals.
            abort_current_reply()

            try:
                self._update_check_last_stage = "python_fallback"
            except Exception:
                pass

            reason = (reason or "").strip()
            if reason:
                _log_update(f"Trying Python fallback ({reason})")
            else:
                _log_update("Trying Python fallback")

            try:
                progress.setLabelText("Checking for updates (Python fallback)...")
            except Exception:
                pass

            import threading

            def worker():
                try:
                    info = self._fetch_latest_release_info_from_github(timeout_seconds=6)
                    version = (info.get("version") or "") if isinstance(info, dict) else ""
                    url = (info.get("url") or "") if isinstance(info, dict) else ""

                    def on_ok():
                        if not is_active_request():
                            return
                        if bool(getattr(self, "_update_check_user_canceled", False)):
                            finish()
                            return
                        handle_latest(version, url)
                        finish()

                    QTimer.singleShot(0, on_ok)
                except Exception as e:

                    def on_err():
                        if not is_active_request():
                            return
                        if bool(getattr(self, "_update_check_user_canceled", False)):
                            finish()
                            return
                        show_offline_error(str(e))
                        finish()

                    QTimer.singleShot(0, on_err)

            threading.Thread(target=worker, daemon=True).start()

        def on_timeout():
            # Ignore stale timers from previous attempts.
            try:
                if int(getattr(self, "_update_check_request_id", 0)) != active_request_id:
                    return
            except Exception:
                return

            # If Python fallback is already running, don't start anything else.
            try:
                if bool(getattr(self, "_update_check_python_fallback_started", False)):
                    return
            except Exception:
                return

            kind = "api"
            try:
                r = getattr(self, "_update_check_reply", None)
                if r:
                    k = r.property("sv_update_check_kind")
                    kind = str(k) if k else "api"
            except Exception:
                kind = "api"

            if kind == "api" and not getattr(self, "_update_check_fallback_attempted", False):
                self._update_check_fallback_attempted = True
                start_request("fallback")
                return

            try:
                abort_current_reply()
            finally:
                try:
                    self._update_check_last_stage = "timeout"
                except Exception:
                    pass
                # Try Python fallback first; if it fails it will show the offline dialog.
                start_python_fallback(f"timeout after {timeout_ms // 1000}s")

        timeout_timer = QTimer(self)
        timeout_timer.setSingleShot(True)
        timeout_timer.timeout.connect(on_timeout)
        timeout_timer.start(timeout_ms)
        self._update_check_timer = timeout_timer

        def on_cancel():
            try:
                self._update_check_user_canceled = True
            except Exception:
                pass
            try:
                self._update_check_last_stage = "canceled"
            except Exception:
                pass
            _log_update("User canceled update check")
            abort_current_reply()
            finish()

        progress.canceled.connect(on_cancel)

        def handle_latest(latest_version_text: str, latest_url: str):
            try:
                self._update_check_any_message_shown = True
                self._update_check_last_stage = "result"
            except Exception:
                pass
            latest_version_text = (latest_version_text or "").strip()
            latest_url = (latest_url or "").strip() or "https://github.com/Supervertaler/Supervertaler-Workbench/releases"

            current = self._normalize_version_tuple(__version__)
            latest = self._normalize_version_tuple(latest_version_text)

            if not latest:
                QMessageBox.information(
                    self,
                    "Check for Updates",
                    "Could not determine the latest version.\n\nYou can check releases on GitHub.",
                )
                self._open_url(latest_url)
                return

            current_text = self._format_version_for_display(__version__)
            latest_text = self._format_version_for_display(latest_version_text)

            if current < latest:
                msg = (
                    f"You are running {current_text}.\n"
                    f"The latest version is {latest_text}.\n\n"
                    "Open the latest release page?"
                )
                choice = QMessageBox.question(
                    self,
                    "Update Available",
                    msg,
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.Yes,
                )
                if choice == QMessageBox.StandardButton.Yes:
                    self._open_url(latest_url)
            elif current == latest:
                QMessageBox.information(
                    self,
                    "Up to Date",
                    f"You are up to date.\n\nCurrent: {current_text}\nLatest: {latest_text}",
                )
            else:
                QMessageBox.information(
                    self,
                    "Newer Than Latest",
                    f"You are running {current_text}, which is newer than the latest published release ({latest_text}).",
                )

        def on_finished(reply_obj):
            should_finish = True
            # Stale reply (e.g., API reply finishing after we already started fallback).
            try:
                rid = reply_obj.property("sv_update_check_request_id")
                if rid is not None and int(rid) != active_request_id:
                    try:
                        reply_obj.deleteLater()
                    except Exception:
                        pass
                    return
            except Exception:
                # If we can't read the property, play it safe: only proceed if it's the active reply.
                pass

            try:
                if reply_obj is not getattr(self, "_update_check_reply", None):
                    try:
                        reply_obj.deleteLater()
                    except Exception:
                        pass
                    return
            except Exception:
                pass

            try:
                kind = "api"
                try:
                    k = reply_obj.property("sv_update_check_kind")
                    kind = str(k) if k else "api"
                except Exception:
                    kind = "api"

                # If aborted/cancelled/errored, show a useful message
                if reply_obj.error():
                    err = reply_obj.errorString() or "Network error"
                    try:
                        err_code = int(reply_obj.error())
                    except Exception:
                        err_code = -1
                    # Only treat real user cancel/abort as silent. Unexpected aborts should be surfaced.
                    if reply_obj.error() == QNetworkReply.NetworkError.OperationCanceledError:
                        _log_update("Network request aborted (OperationCanceledError)")
                        if not bool(getattr(self, "_update_check_user_canceled", False)):
                            show_offline_error("Update check was aborted unexpectedly.")
                        return

                    _log_update(f"Network error ({kind}) [{err_code}]: {err}")

                    # API can be blocked on some networks; fall back to github.com
                    if kind == "api" and not getattr(self, "_update_check_fallback_attempted", False):
                        self._update_check_fallback_attempted = True
                        # Do not finish here; the fallback attempt becomes the active request.
                        start_request("fallback")
                        should_finish = False
                        return

                    # QtNetwork HTTPS can fail on some systems (often reported as "Unknown error").
                    # Try Python's HTTPS stack before giving up.
                    start_python_fallback(f"qt_network_error {err_code}")
                    should_finish = False
                    return

                if kind == "fallback":
                    # Try to read the redirect location (preferred) because /releases/latest usually 302's.
                    latest_url_text = "https://github.com/Supervertaler/Supervertaler-Workbench/releases"
                    latest_version_text = ""

                    try:
                        status = reply_obj.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
                        status_code = int(status) if status is not None else 0
                    except Exception:
                        status_code = 0

                    try:
                        location = bytes(reply_obj.rawHeader(b"Location")).decode("utf-8", errors="replace").strip()
                    except Exception:
                        location = ""

                    if status_code in {301, 302, 303, 307, 308} and location:
                        if location.startswith("/"):
                            latest_url_text = "https://github.com" + location
                        else:
                            latest_url_text = location

                        m = re.search(r"v?\d+(?:\.\d+)+", location)
                        if m:
                            latest_version_text = m.group(0)
                    else:
                        # No redirect header; fall back to parsing the HTML.
                        raw = bytes(reply_obj.readAll()).decode("utf-8", errors="replace")
                        m = re.search(r"Release\s+v?(\d+(?:\.\d+)+)", raw, re.IGNORECASE)
                        if m:
                            latest_version_text = m.group(1)

                    handle_latest(latest_version_text, latest_url_text)
                    return

                # API JSON path
                raw = bytes(reply_obj.readAll())
                data = json.loads(raw.decode("utf-8", errors="replace"))
                tag = (data.get("tag_name") or "").strip()
                name = (data.get("name") or "").strip()
                latest_version_text = tag or name
                latest_url = (data.get("html_url") or "").strip() or "https://github.com/Supervertaler/Supervertaler-Workbench/releases"

                handle_latest(latest_version_text, latest_url)
            except Exception as e:
                _log_update(f"Exception while parsing update info: {e}")
                show_offline_error(str(e))
            finally:
                if should_finish:
                    finish()

        start_request("api")

    def _fetch_latest_release_info_from_github(self, timeout_seconds: int = 6) -> dict:
        """Return {version, url} for the latest GitHub release, best-effort.

        Uses the public GitHub API. No auth required.
        """
        import urllib.request

        api_url = "https://api.github.com/repos/Supervertaler/Supervertaler-Workbench/releases/latest"
        req = urllib.request.Request(
            api_url,
            headers={
                "Accept": "application/vnd.github+json",
                "User-Agent": f"Supervertaler/{__version__}",
            },
        )

        with urllib.request.urlopen(req, timeout=timeout_seconds) as response:
            data = json.loads(response.read().decode("utf-8", errors="replace"))

        tag = (data.get("tag_name") or "").strip()
        name = (data.get("name") or "").strip()
        version = tag or name
        url = (data.get("html_url") or "").strip() or "https://github.com/Supervertaler/Supervertaler-Workbench/releases"

        return {"version": version, "url": url}

    def _normalize_version_tuple(self, version_text: str) -> tuple:
        """Convert a version like 'v1.9.100' to a comparable tuple of ints."""
        if not version_text:
            return ()
        text = str(version_text).strip()
        if text.lower().startswith("v"):
            text = text[1:]

        parts = [int(p) for p in re.findall(r"\d+", text)]
        return tuple(parts)

    def _format_version_for_display(self, version_text: str) -> str:
        """Return a user-friendly version string like 'v1.9.100'."""
        if not version_text:
            return "(unknown)"
        text = str(version_text).strip()
        if not text.lower().startswith("v"):
            return f"v{text}"
        return text

    def _build_version_info_text(self) -> str:
        """Build a support-friendly version/system info block for clipboard."""
        import platform
        from PyQt6.QtCore import QT_VERSION_STR, PYQT_VERSION_STR

        lines = []
        lines.append(f"Supervertaler {self._format_version_for_display(__version__)}")
        try:
            lines.append(f"Release date: {__release_date__}")
        except Exception:
            pass
        try:
            lines.append(f"Edition: {__edition__} (phase {__phase__})")
        except Exception:
            pass

        lines.append("")
        lines.append(f"OS: {platform.platform()}")
        lines.append(f"Architecture: {platform.machine()}")
        lines.append(f"Python: {platform.python_version()} ({platform.python_implementation()})")
        lines.append(f"Qt: {QT_VERSION_STR}")
        lines.append(f"PyQt: {PYQT_VERSION_STR}")
        lines.append(f"Frozen (PyInstaller): {bool(getattr(sys, 'frozen', False))}")
        lines.append(f"Executable: {sys.executable}")

        # Diagnostic log location – makes support easier: users paste this
        # block, and the path to attach is right there in the output.
        try:
            log_path = self._get_diagnostic_log_path()
            if log_path is not None:
                lines.append("")
                lines.append(f"Diagnostic log: {log_path}")
                lines.append(
                    "  (Help \u2192 Open Diagnostic Log – attach this file when reporting issues)"
                )
        except Exception:
            pass

        return "\n".join(lines)

    def copy_version_info_to_clipboard(self):
        """Copy version info to clipboard for easy sharing in support requests."""
        try:
            text = self._build_version_info_text()
            clipboard = QApplication.clipboard()
            clipboard.setText(text)
            QMessageBox.information(
                self,
                "Version Info Copied",
                "Version info has been copied to your clipboard.\n\n"
                "Tip: Paste it into an email or GitHub issue.",
            )
        except Exception as e:
            QMessageBox.warning(self, "Copy Failed", f"Could not copy version info: {e}")

    def _get_diagnostic_log_path(self):
        """Return the Path to the active diagnostic log, or None if unknown.

        Preferred source is sys._supervertaler_log_path (set by
        _setup_diagnostic_log in main()). If that's missing for any reason
        we compute the expected default location so the menu action still
        works – the folder might be empty but at least the user sees where
        to look.
        """
        try:
            path = getattr(sys, '_supervertaler_log_path', None)
            if path is not None:
                return Path(path)
        except Exception:
            pass
        try:
            return Path(self.user_data_path) / "workbench" / "logs" / "supervertaler.log"
        except Exception:
            return None

    def open_diagnostic_log(self):
        """Open the diagnostic log file in the system's default text editor."""
        log_path = self._get_diagnostic_log_path()
        if log_path is None:
            QMessageBox.warning(
                self, "Log Unavailable",
                "The diagnostic log path could not be determined."
            )
            return
        if not log_path.exists():
            QMessageBox.information(
                self, "No Log Yet",
                f"No diagnostic log has been written yet.\n\nExpected location:\n{log_path}"
            )
            return
        # QDesktopServices with a file:// URL opens the OS default handler
        # for .log (Notepad on Windows, TextEdit on macOS, etc.).
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_path)))

    def open_diagnostic_log_folder(self):
        """Reveal the folder that contains the diagnostic log files."""
        log_path = self._get_diagnostic_log_path()
        log_dir = log_path.parent if log_path is not None else None
        if log_dir is None:
            QMessageBox.warning(
                self, "Log Folder Unavailable",
                "The diagnostic log folder could not be determined."
            )
            return
        try:
            log_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_dir)))
    
    def _show_ahk_setup_from_menu(self):
        """Show AutoHotkey setup dialog from Help menu"""
        if hasattr(self, 'lookup_tab') and self.lookup_tab:
            self.lookup_tab._show_autohotkey_setup_dialog()
        else:
            QMessageBox.warning(
                self,
                "SuperLookup Not Available",
                "SuperLookup tab is not available. Please restart the application."
            )

    def show_about(self):
        """Show about dialog with clickable website link"""
        dialog = QDialog(self)
        dialog.setWindowTitle("About Supervertaler Workbench")
        dialog.setMinimumWidth(400)
        
        layout = QVBoxLayout(dialog)
        layout.setSpacing(10)
        layout.setContentsMargins(20, 20, 20, 20)
        
        # Title
        title = QLabel(f"<h2>Supervertaler Workbench v{__version__}</h2>")
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(title)

        # Description
        desc = QLabel("<p><b>AI-enhanced tool for translators & writers</b></p>")
        desc.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(desc)

        # Links section
        links_label = QLabel(
            '<p style="text-align: center;">'
            '<b>Project:</b> <a href="https://supervertaler.com/" style="color: #1976D2; text-decoration: none;">supervertaler.com</a><br>'
            '<b>Author:</b> <a href="https://beijer.uk/" style="color: #1976D2; text-decoration: none;">beijer.uk</a>'
            '</p>'
        )
        links_label.setOpenExternalLinks(True)
        links_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(links_label)

        # Additional info
        info = QLabel(
            "<p style='text-align: center;'><b>License:</b> MIT</p>"
            "<hr>"
            "<p style='text-align: center; color: #666; font-size: 9pt;'>"
            "Built with PyQt6 • Supports OpenAI, Claude, Gemini & Ollama"
            "</p>"
        )
        info.setWordWrap(True)
        layout.addWidget(info)

        # Buttons
        btn_row = QHBoxLayout()
        copy_btn = QPushButton("📋 Copy Version Info")
        copy_btn.setToolTip("Copy version and system info to clipboard")
        copy_btn.clicked.connect(self.copy_version_info_to_clipboard)
        btn_row.addWidget(copy_btn)

        update_btn = QPushButton("🔄 Check for Updates...")
        update_btn.setToolTip("Check whether you are running the latest Supervertaler release")
        update_btn.clicked.connect(self.check_for_updates)
        btn_row.addWidget(update_btn)

        btn_row.addStretch(1)

        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dialog.accept)
        btn_row.addWidget(close_btn)

        layout.addLayout(btn_row)

        dialog.exec()
    
    def closeEvent(self, event):
        """Handle window close event"""
        # Check if Whisper model is downloading
        if self.is_loading_model:
            model_sizes = {
                'tiny': '75 MB',
                'base': '142 MB',
                'small': '466 MB',
                'medium': '1.5 GB',
                'large': '2.9 GB'
            }
            size = model_sizes.get(self.loading_model_name, 'unknown size')

            reply = QMessageBox.warning(
                self,
                "⚠️ Voice Model Downloading",
                f"Voice is currently downloading the '{self.loading_model_name}' model ({size}).\n\n"
                f"If you close now, the download will be interrupted and the model\n"
                f"file may become corrupted. You would need to delete the incomplete\n"
                f"file manually and re-download.\n\n"
                f"Download location:\n"
                f"{self._get_whisper_cache_path()}\n\n"
                f"Do you want to force quit anyway?",
                QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes,
                QMessageBox.StandardButton.No
            )

            if reply == QMessageBox.StandardButton.No:
                event.ignore()
                return

        # Check for unsaved project changes
        if self.project_modified:
            reply = QMessageBox.question(
                self,
                "Unsaved Changes",
                "You have unsaved changes. Do you want to save before closing?",
                QMessageBox.StandardButton.Save |
                QMessageBox.StandardButton.Discard |
                QMessageBox.StandardButton.Cancel
            )

            if reply == QMessageBox.StandardButton.Save:
                self.save_project()
                self._stop_okapi_sidecar()
                self._cleanup_web_views()
                self._close_detached_log_windows()
                event.accept()
            elif reply == QMessageBox.StandardButton.Discard:
                self._stop_okapi_sidecar()
                self._cleanup_web_views()
                self._close_detached_log_windows()
                event.accept()
            else:
                event.ignore()
        else:
            self._stop_okapi_sidecar()
            self._cleanup_web_views()
            self._close_detached_log_windows()
            event.accept()

    def _cleanup_web_views(self):
        """Clean up WebEngine views - DISABLED to prevent crash"""
        # WebEngine cleanup has been disabled because it was causing Python crashes
        # on program exit. Qt will handle WebEngine cleanup automatically, though
        # you may see a "Release of profile requested" warning which is harmless.
        print("[WebEngine Cleanup] Skipping manual cleanup - letting Qt handle it")
        pass
    
    def _close_detached_log_windows(self):
        """Close all detached log windows when main window closes"""
        try:
            if hasattr(self, 'detached_log_windows'):
                for log_window in list(self.detached_log_windows):
                    try:
                        log_window.close()
                    except:
                        pass
                self.detached_log_windows.clear()
        except:
            pass

    # ── Okapi Sidecar management ─────────────────────────────────

    def _init_okapi_sidecar_deferred(self):
        """Schedule Okapi sidecar startup after the UI is fully loaded.
        This is non-blocking – the sidecar starts in the background."""
        from PyQt6.QtCore import QTimer
        QTimer.singleShot(1500, self._start_okapi_sidecar)

    def _start_okapi_sidecar(self):
        """Start the local Okapi sidecar process (if available).

        We deliberately keep ``self.okapi_sidecar`` set even when the
        sidecar can't run yet (no JAR, no Java, etc.). The lazy-download
        / install flow at import time relies on the OkapiSidecar object
        being present so it can drive the platform-specific download.
        Setting it to None here would short-circuit that flow with a
        misleading "client not initialised" error.
        """
        try:
            from modules.okapi_sidecar import OkapiSidecar
            self.okapi_sidecar = OkapiSidecar()

            if self.okapi_sidecar.is_available:
                if self.okapi_sidecar.start():
                    version = self.okapi_sidecar.get_version() or "?"
                    self.log(f"✅ Okapi sidecar started (v{version}) – "
                             f"enhanced file filters available")
                else:
                    self.log("⚠️ Okapi sidecar failed to start – "
                             "lazy install flow will retry on first use")
            elif not self.okapi_sidecar.is_installed():
                self.log("ℹ️ Okapi sidecar not installed yet – "
                         "will offer to download on first DOCX import")
            else:
                # JAR present but Java missing (Intel Mac / Linux only;
                # Windows and Apple Silicon ship a bundled JRE).
                self.log("⚠️ Okapi sidecar found but Java not detected – "
                         "install Java to enable enhanced filters")

            # Proactive heads-up for Intel Mac / Linux users with no Java.
            # Without it, the user happily uses the app for plain-text work
            # and only discovers the missing dependency when they try to
            # open a Word document (where the lazy-download flow will catch
            # them anyway). Better to warn at startup so they can fix it
            # before hitting the wall.
            if self.okapi_sidecar.needs_system_java_install():
                self._maybe_show_okapi_java_warning()
        except Exception as e:
            self.log(f"⚠️ Okapi sidecar unavailable: {e}")
            self.okapi_sidecar = None

    def _maybe_show_okapi_java_warning(self):
        """Show a one-time-per-session dialog explaining how to install
        Java on Intel Macs / Linux. Respects a 'don't show again' user pref
        stored under settings → general → ``okapi_java_warning_dismissed``.
        """
        try:
            general = self._load_settings_section("general")
            if general.get("okapi_java_warning_dismissed"):
                self.log("ℹ️ Skipping Java-required startup dialog "
                         "(user previously checked 'Don't show again')")
                return
        except Exception:
            general = {}

        self.log("⚠️ Showing 'Java needed for enhanced file filters' "
                 "startup dialog (no system Java detected on this "
                 "platform; sidecar can't run)")

        from PyQt6.QtWidgets import QMessageBox, QCheckBox

        sysname = platform.system()
        if sysname == "Darwin":
            install_cmd = "brew install --cask temurin@17"
            verify_cmd = "java -version"
            platform_note = (
                "Intel Macs need a separate Java install – the bundled "
                "Java runtime in the macOS download is built for Apple "
                "Silicon only."
            )
        else:
            install_cmd = "sudo apt install default-jre   # or your distro's equivalent"
            verify_cmd = "java -version"
            platform_note = (
                "On Linux the sidecar uses your system Java rather than "
                "a bundled one."
            )

        box = QMessageBox(self)
        box.setIcon(QMessageBox.Icon.Information)
        box.setWindowTitle("Java needed for enhanced file filters")
        box.setText(
            "Supervertaler can use a small Java helper (the Okapi "
            "sidecar) to import Word, Excel, HTML and other office "
            "documents. No Java runtime was found on your system."
        )
        box.setInformativeText(
            f"{platform_note}\n\n"
            f"To enable it, install Java 17 (one-time, ~150 MB):\n\n"
            f"    {install_cmd}\n\n"
            f"Then verify with:\n\n"
            f"    {verify_cmd}\n\n"
            "Restart Supervertaler after installing. Plain-text "
            "translation, TMX, etc. all work without Java."
        )
        dont_show = QCheckBox("Don't show this again")
        box.setCheckBox(dont_show)
        box.addButton(QMessageBox.StandardButton.Ok)
        box.exec()
        self.log(f"ℹ️ User dismissed Java-required dialog "
                 f"(don't-show-again={'on' if dont_show.isChecked() else 'off'})")

        if dont_show.isChecked():
            try:
                general["okapi_java_warning_dismissed"] = True
                self._save_settings_section("general", general)
            except Exception as e:
                self.log(f"⚠ Could not persist Java-warning preference: {e}")

    def _stop_okapi_sidecar(self):
        """Stop the Okapi sidecar process on app exit."""
        try:
            if self.okapi_sidecar:
                self.okapi_sidecar.stop()
                self.okapi_sidecar = None
        except Exception:
            pass

    def _get_whisper_cache_path(self):
        """Get the Whisper model cache directory path"""
        import os
        if os.name == 'nt':  # Windows
            return os.path.join(os.environ.get('USERPROFILE', ''), '.cache', 'whisper')
        else:  # Linux/Mac
            return os.path.expanduser('~/.cache/whisper')
    
    # =========================================================================
    # LLM TRANSLATION INTEGRATION
    # =========================================================================
    
    def translate_current_segment(self):
        """Translate currently selected segment using LLM (Ctrl+T)"""
        current_row = self.table.currentRow()
        if current_row < 0 or not self.current_project:
            QMessageBox.warning(self, "No Selection", "Please select a segment to translate.")
            return
        
        segment = self.current_project.segments[current_row]
        
        if not segment.source.strip():
            QMessageBox.warning(self, "Empty Source", "Cannot translate empty source text.")
            return
        
        # Load LLM settings first to check provider
        settings = self.load_llm_settings()
        provider = settings.get('provider', 'openai')
        
        # Get model based on provider (active profile wins for custom_openai)
        model = self._resolve_provider_model(settings, provider, 'gpt-5.5')

        # Ollama and custom_openai don't strictly need API keys
        if provider == 'ollama':
            api_keys = {'ollama': 'not-needed'}  # Placeholder - Ollama doesn't use API keys
        elif provider == 'custom_openai':
            api_keys = self.load_api_keys()  # Key is optional for custom endpoints
        else:
            # Load API keys for cloud providers
            api_keys = self.load_api_keys()
            if not api_keys:
                reply = QMessageBox.question(
                    self, "API Keys Missing",
                    "No API keys found. Would you like to configure them now?\n\n"
                    f"Keys should be in: {self.user_data_path / 'api_keys.txt'}",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if reply == QMessageBox.StandardButton.Yes:
                    self._go_to_settings_tab("AI Settings")
                return

            # Check if API key exists for selected provider
            # Note: 'gemini' and 'google' are aliases for the same API key
            has_api_key = provider in api_keys or (provider == 'gemini' and 'google' in api_keys)
            if not has_api_key:
                reply = QMessageBox.question(
                    self, f"{provider.title()} API Key Missing",
                    f"{provider.title()} API key not found in api_keys.txt\n\n"
                    f"Would you like to configure it now?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if reply == QMessageBox.StandardButton.Yes:
                    self._go_to_settings_tab()
                return
        
        try:
            self.status_bar.showMessage(f"Translating segment #{segment.id} with {provider} ({model})...")
            QApplication.processEvents()  # Update UI
            
            # For Ollama (local LLM), show a progress dialog since it can take a while
            progress_dialog = None
            elapsed_timer = None
            start_time = None
            
            if provider == 'ollama':
                from PyQt6.QtCore import QTimer, QElapsedTimer
                import time
                
                progress_dialog = QDialog(self)
                progress_dialog.setWindowTitle("Local LLM Translation")
                progress_dialog.setModal(False)  # Non-modal so we can update it
                progress_dialog.setMinimumWidth(400)
                progress_layout = QVBoxLayout(progress_dialog)
                
                progress_header = QLabel(f"<h3>🖥️ Translating with Local LLM</h3>")
                progress_layout.addWidget(progress_header)
                
                progress_info = QLabel(
                    f"<b>Model:</b> {model}<br>"
                    f"<b>Segment:</b> #{segment.id}<br><br>"
                    f"<i>Local LLM translation may take 10-60 seconds depending on your hardware...</i>"
                )
                progress_info.setWordWrap(True)
                progress_layout.addWidget(progress_info)
                
                # Animated progress bar (indeterminate)
                progress_bar = QProgressBar()
                progress_bar.setRange(0, 0)  # Indeterminate mode
                progress_bar.setTextVisible(False)
                progress_layout.addWidget(progress_bar)
                
                # Elapsed time label
                elapsed_label = QLabel("⏱️ Elapsed: 0 seconds")
                elapsed_label.setStyleSheet("color: #333; font-weight: bold; padding: 5px 0;")
                progress_layout.addWidget(elapsed_label)
                
                progress_status = QLabel("⏳ Processing... (CPU usage is normal)")
                progress_status.setStyleSheet("color: #666; padding: 5px 0;")
                progress_layout.addWidget(progress_status)
                
                # Store references
                progress_dialog.status_label = progress_status
                progress_dialog.elapsed_label = elapsed_label
                
                # Setup elapsed time counter
                start_time = time.time()
                
                def update_elapsed():
                    if start_time and hasattr(progress_dialog, 'elapsed_label'):
                        elapsed = int(time.time() - start_time)
                        progress_dialog.elapsed_label.setText(f"⏱️ Elapsed: {elapsed} seconds")
                        QApplication.processEvents()
                
                elapsed_timer = QTimer()
                elapsed_timer.timeout.connect(update_elapsed)
                elapsed_timer.start(1000)  # Update every second
                
                progress_dialog.show()
                QApplication.processEvents()
            
            # Use modular LLM client with user's settings
            client = self.create_llm_client(provider, model, api_keys, settings=settings)

            # Check TM before API call if enabled
            general_prefs = self.load_general_settings()
            check_tm_before_api = general_prefs.get('check_tm_before_api', True)
            check_tm_exact_only = general_prefs.get('check_tm_exact_only', False)
            tm_match = None
            
            if check_tm_before_api and self.tm_database:
                # Get activated TM IDs for current project
                tm_ids = None
                if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                    project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                    if project_id:
                        tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
                
                try:
                    # Strip outer structural tags if setting is enabled (for cleaner TM matching)
                    search_source = segment.source
                    if self.hide_outer_wrapping_tags:
                        search_source, _ = strip_outer_wrapping_tags(search_source)

                    if check_tm_exact_only:
                        # Use exact match only (faster - O(1) hash lookup)
                        exact_match = self.tm_database.get_exact_match(search_source, tm_ids=tm_ids)
                        if exact_match:
                            tm_match = exact_match.get('target_text', '')
                            self.log(f"✓ Found 100% TM match (exact) for segment #{segment.id}")
                    else:
                        # Use fuzzy search (includes 100% matches)
                        matches = self.tm_database.search_all(search_source, tm_ids=tm_ids, enabled_only=False, max_matches=1)
                        if matches and matches[0].get('match_pct', 0) == 100:
                            tm_match = matches[0].get('target', '')
                            self.log(f"✓ Found 100% TM match for segment #{segment.id}")
                except Exception as e:
                    self.log(f"⚠ TM check error: {e}")
            
            # If we have a 100% match and auto-insert is enabled, use it
            auto_insert_100 = general_prefs.get('auto_insert_100', False)
            if tm_match and auto_insert_100:
                # Auto-insert the TM match
                segment.target = tm_match
                segment.status = "draft"
                self.project_modified = True
                self.update_window_title()

                # Update grid (using cell widget)
                target_widget = self.table.cellWidget(current_row, 3)
                if target_widget and isinstance(target_widget, EditableGridTextEditor):
                    target_widget.setPlainText(tm_match)
                else:
                    # Fallback if widget doesn't exist
                    self.table.setItem(current_row, 3, QTableWidgetItem(tm_match))
                self.update_status_icon(current_row, "draft")
                
                # Auto-resize the row to fit the new content
                self._auto_resize_single_row(current_row)
                
                # Add to TM if not already there
                if self.tm_database:
                    try:
                        self.tm_database.add_to_project_tm(segment.source, tm_match)
                    except:
                        pass
                
                self.log(f"✓ Auto-inserted 100% TM match for segment #{segment.id}")
                self.status_bar.showMessage(f"✓ Segment #{segment.id} translated from TM (100% match)", 3000)
                return
            
            # Build prompt - use optimized prompt for local LLM (Ollama)
            custom_prompt = None
            
            if provider == 'ollama':
                # Use a focused but context-aware prompt for local LLMs
                source_lang = self.current_project.source_lang
                target_lang = self.current_project.target_lang
                
                # Get 2-3 surrounding segments for context (not 5+ like cloud LLMs)
                context_lines = []
                surrounding_count = 2  # Fewer segments for faster inference
                
                try:
                    start_idx = max(0, current_row - surrounding_count)
                    end_idx = min(len(self.current_project.segments), current_row + surrounding_count + 1)
                    
                    for i in range(start_idx, end_idx):
                        seg = self.current_project.segments[i]
                        if i == current_row:
                            continue  # Skip current segment
                        if seg.target and seg.target.strip():
                            # Show already translated segments as reference
                            context_lines.append(f"• {seg.source[:80]}{'...' if len(seg.source) > 80 else ''}")
                            context_lines.append(f"  → {seg.target[:80]}{'...' if len(seg.target) > 80 else ''}")
                except:
                    pass
                
                # Build optimized prompt
                prompt_parts = [f"You are a professional translator ({source_lang} → {target_lang})."]
                
                
                # Add surrounding segment context
                if context_lines:
                    context_section = "\n".join(context_lines)
                    prompt_parts.append("")
                    prompt_parts.append("Context from surrounding segments:")
                    prompt_parts.append(context_section)
                
                # Add the segment to translate
                prompt_parts.append("")
                prompt_parts.append("Translate this segment accurately. Output ONLY the translation:")
                prompt_parts.append(segment.source)
                prompt_parts.append("")
                prompt_parts.append("Translation:")
                
                custom_prompt = "\n".join(prompt_parts)
                
                self.log(f"  Using optimized Ollama prompt ({len(custom_prompt)} chars, {len(context_lines)//2} context segs)")
            
            elif hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt:
                try:
                    # Get surrounding segments if enabled
                    surrounding_segments = general_prefs.get('surrounding_segments', 5)
                    surrounding_context = ""
                    
                    if surrounding_segments > 0:
                        try:
                            current_idx = current_row
                            start_idx = max(0, current_idx - surrounding_segments)
                            end_idx = min(len(self.current_project.segments), current_idx + surrounding_segments + 1)
                            surrounding = self.current_project.segments[start_idx:end_idx]
                            
                            if len(surrounding) > 1:  # Only add if we have surrounding segments
                                context_parts = []
                                for i, seg in enumerate(surrounding):
                                    actual_idx = start_idx + i
                                    if actual_idx == current_idx:
                                        context_parts.append(f">>> {seg.id}. {seg.source} <<<  [TRANSLATE THIS]")
                                    else:
                                        context_parts.append(f"{seg.id}. {seg.source}")
                                        if seg.target:
                                            context_parts.append(f"    → {seg.target}")
                                
                                surrounding_context = "\n\n**SURROUNDING SEGMENTS FOR CONTEXT:**\n"
                                surrounding_context += "(The segment marked with >>> <<< is the one to translate)\n\n"
                                surrounding_context += "\n".join(context_parts)
                                self.log(f"  Including {len(surrounding)} surrounding segments ({surrounding_segments} before/after)")
                        except Exception as e:
                            self.log(f"⚠ Could not add surrounding segments: {e}")
                    
                    # Get glossary terms for AI injection
                    glossary_terms = self.get_ai_inject_glossary_terms()

                    custom_prompt = self.prompt_manager_qt.build_final_prompt(
                        source_text=segment.source,
                        source_lang=self.current_project.source_lang,
                        target_lang=self.current_project.target_lang,
                        mode="single",
                        glossary_terms=glossary_terms,
                        target_text=segment.target or ""
                    )

                    # Add surrounding context before the translation delimiter
                    if surrounding_context:
                        # Insert before the "YOUR TRANSLATION" delimiter
                        if "**YOUR TRANSLATION" in custom_prompt:
                            custom_prompt = custom_prompt.replace(
                                "**YOUR TRANSLATION",
                                surrounding_context + "\n\n**YOUR TRANSLATION"
                            )
                        else:
                            custom_prompt += surrounding_context
                            
                except Exception as e:
                    self.log(f"⚠ Could not build prompt from manager: {e}")
            
            # Check for figure references and prepare images if available
            images = None
            if self.figure_context and self.figure_context.has_images():
                try:
                    # Detect figure references in source text
                    figure_refs = self.figure_context.detect_figure_references(segment.source)
                    if figure_refs:
                        self.log(f"  🖼️ Detected figure references in segment #{segment.id}: {', '.join(figure_refs)}")
                        # Get images for detected figures
                        images_for_text = self.figure_context.get_images_for_text(segment.source)
                        if images_for_text:
                            # Check if model supports vision
                            if LLMClient.model_supports_vision(provider, model):
                                # Convert PIL images to appropriate format for provider
                                if provider == "gemini":
                                    # Gemini uses PIL.Image directly
                                    images = images_for_text
                                else:
                                    # OpenAI and Claude use base64 PNG
                                    images = [
                                        (ref, self.figure_context.pil_image_to_base64_png(img))
                                        for ref, img in images_for_text
                                    ]
                                self.log(f"  ✅ Including {len(images)} figure images: {', '.join(figure_refs)}")
                            else:
                                self.log(f"  ⚠️ Figures detected ({', '.join(figure_refs)}) but model '{model}' doesn't support vision")
                        else:
                            self.log(f"  ⚠️ Figure references detected but no matching images found in loaded folder")
                except Exception as e:
                    self.log(f"  ⚠️ Could not load figures: {e}")
            
            # Translate using the module
            translation = client.translate(
                text=segment.source,
                source_lang=self.current_project.source_lang,
                target_lang=self.current_project.target_lang,
                custom_prompt=custom_prompt,
                images=images
            )
            
            if translation:
                # Update segment
                segment.target = translation
                segment.status = "draft"

                # Update grid - Column 3 is Target (using cell widget)
                target_widget = self.table.cellWidget(current_row, 3)
                if target_widget and isinstance(target_widget, EditableGridTextEditor):
                    target_widget.setPlainText(translation)
                else:
                    # Fallback: create new widget if none exists
                    self.table.setItem(current_row, 3, QTableWidgetItem(translation))
                self.update_status_icon(current_row, "draft")
                
                # Auto-resize the row to fit the new content
                self._auto_resize_single_row(current_row)
                
                # Add to Translation Memory
                if self.tm_database:
                    try:
                        self.tm_database.add_to_project_tm(segment.source, translation)
                        self.log(f"✓ Added to TM: {segment.source[:30]}... → {translation[:30]}...")
                    except Exception as tm_error:
                        self.log(f"⚠ Could not add to TM: {str(tm_error)}")
                
                # Mark project as modified
                self.project_modified = True
                self.update_window_title()
                
                self.log(f"✓ Segment #{segment.id} translated with {provider}/{model}")
                self.status_bar.showMessage(f"✓ Segment #{segment.id} translated", 3000)
                
                # Stop elapsed timer and close progress dialog
                if elapsed_timer:
                    elapsed_timer.stop()
                if progress_dialog:
                    progress_dialog.close()
            else:
                self.log(f"✗ Translation failed for segment #{segment.id}")
                # Stop elapsed timer and close progress dialog
                if elapsed_timer:
                    elapsed_timer.stop()
                if progress_dialog:
                    progress_dialog.close()
                QMessageBox.warning(self, "Translation Failed", "No translation received from LLM.")
                
        except Exception as e:
            # Stop elapsed timer and close progress dialog
            if 'elapsed_timer' in locals() and elapsed_timer:
                elapsed_timer.stop()
            if 'progress_dialog' in locals() and progress_dialog:
                progress_dialog.close()
            self.log(f"✗ Translation error: {str(e)}")
            QMessageBox.critical(self, "Translation Error", f"Failed to translate segment:\n\n{str(e)}")
            self.status_bar.showMessage("Translation failed", 3000)

    # =========================================================================
    # GRID QUICKLAUNCHER
    # =========================================================================

    def _quicklauncher_get_selection_text(self, widget: QTextEdit) -> str:
        """Get selected text (or full text) from a QTextEdit, normalized for LLMs."""
        cursor = widget.textCursor()
        text = cursor.selectedText() if cursor.hasSelection() else widget.toPlainText()
        text = (text or "").replace('\u2029', '\n')

        # Reverse invisible-character display markers if present
        try:
            if hasattr(self, 'reverse_invisible_replacements'):
                text = self.reverse_invisible_replacements(text)
        except Exception:
            pass

        return text.strip()

    def _quicklauncher_build_custom_prompt(self, prompt_relative_path: str, source_text: str, source_lang: str, target_lang: str, target_text: str = "") -> str:
        """Build a prompt for QuickLauncher using the selected prompt as instructions.

        This is a GENERIC prompt builder (not translation-specific) that allows QuickLauncher prompts
        to do anything: explain, define, search, translate, analyze, etc.

        Supports placeholders:
        - {{SELECTION}} or {{SOURCE_TEXT}} - The selected text
        - {{TARGET_TEXT}} - Current target (translation) of the active segment
        - {{SOURCE_LANGUAGE}} - Source language name
        - {{TARGET_LANGUAGE}} - Target language name
        - {{SOURCE+TARGET_CONTEXT}} - Project segments with both source and target (for proofreading)
        - {{SOURCE_CONTEXT}} - Project segments with source only (for translation questions)
        - {{TARGET_CONTEXT}} - Project segments with target only (for consistency/style analysis)
        """
        if not hasattr(self, 'prompt_manager_qt') or not self.prompt_manager_qt:
            raise RuntimeError("Prompt manager not available")

        pm = self.prompt_manager_qt
        lib = getattr(pm, 'library', None)
        if not lib:
            raise RuntimeError("Prompt library not available")

        prompt_data = lib.prompts.get(prompt_relative_path)
        if not prompt_data:
            raise RuntimeError(f"Prompt not found: {prompt_relative_path}")

        prompt_content = (prompt_data.get('content') or "").strip()
        if not prompt_content:
            raise RuntimeError("Prompt content is empty")

        # Build document context if requested (three variants)
        source_target_context = ""
        source_only_context = ""
        target_only_context = ""
        
        has_source_target = "{{SOURCE+TARGET_CONTEXT}}" in prompt_content
        has_source_only = "{{SOURCE_CONTEXT}}" in prompt_content
        has_target_only = "{{TARGET_CONTEXT}}" in prompt_content
        
        if (has_source_target or has_source_only or has_target_only) and hasattr(self, 'current_project') and self.current_project:
            if has_source_target:
                source_target_context = self._build_quicklauncher_document_context(mode="both")
                self.log(f"🔍 QuickLauncher: Built SOURCE+TARGET context ({len(source_target_context)} chars)")
            if has_source_only:
                source_only_context = self._build_quicklauncher_document_context(mode="source")
                self.log(f"🔍 QuickLauncher: Built SOURCE_ONLY context ({len(source_only_context)} chars)")
            if has_target_only:
                target_only_context = self._build_quicklauncher_document_context(mode="target")
                self.log(f"🔍 QuickLauncher: Built TARGET_ONLY context ({len(target_only_context)} chars)")
        else:
            if has_source_target:
                self.log("⚠️ QuickLauncher: {{SOURCE+TARGET_CONTEXT}} requested but no project loaded")
            if has_source_only:
                self.log("⚠️ QuickLauncher: {{SOURCE_CONTEXT}} requested but no project loaded")
            if has_target_only:
                self.log("⚠️ QuickLauncher: {{TARGET_CONTEXT}} requested but no project loaded")

        # Replace placeholders in the prompt content
        prompt_content = prompt_content.replace("{{SOURCE_LANGUAGE}}", source_lang)
        prompt_content = prompt_content.replace("{{TARGET_LANGUAGE}}", target_lang)
        prompt_content = prompt_content.replace("{{SOURCE_TEXT}}", source_text)
        prompt_content = prompt_content.replace("{{TARGET_TEXT}}", target_text or "")
        prompt_content = prompt_content.replace("{{SELECTION}}", source_text)  # Alternative placeholder
        prompt_content = prompt_content.replace("{{SOURCE+TARGET_CONTEXT}}", source_target_context)
        prompt_content = prompt_content.replace("{{SOURCE_CONTEXT}}", source_only_context)
        prompt_content = prompt_content.replace("{{TARGET_CONTEXT}}", target_only_context)
        
        # Debug: Log the final prompt being sent
        self.log(f"📝 QuickLauncher: Final prompt ({len(prompt_content)} chars):")
        self.log("─" * 80)
        self.log(prompt_content[:500] + ("..." if len(prompt_content) > 500 else ""))
        self.log("─" * 80)
        
        # If the prompt doesn't contain the selection/text, append it
        if "{{SOURCE_TEXT}}" not in prompt_data.get('content', '') and "{{SELECTION}}" not in prompt_data.get('content', ''):
            prompt_content += f"\n\nText:\n{source_text}"
        
        return prompt_content
    
    def _build_quicklauncher_document_context(self, mode: str = "both") -> str:
        """Build document context for QuickLauncher prompts.
        
        Args:
            mode: One of:
                - "both": Include both source and target text (for proofreading)
                - "source": Include only source text (for translation/terminology questions)
                - "target": Include only target text (for consistency/style analysis)
        
        Returns a formatted string with segments from the project for context.
        Uses the 'quicklauncher_context_percent' setting (default: 50% of total segments).
        """
        if not hasattr(self, 'current_project') or not self.current_project or not self.current_project.segments:
            return "(No project context available)"
        
        try:
            # Get settings
            general_prefs = self.load_general_settings()
            context_percent = general_prefs.get('quicklauncher_context_percent',
                                               general_prefs.get('quickmenu_context_percent', 50))  # Default: 50%
            max_context_segments = general_prefs.get('quicklauncher_context_max',
                                                     general_prefs.get('quickmenu_context_max', 100))  # Safety limit
            
            # Calculate how many segments to include
            total_segments = len(self.current_project.segments)
            num_segments = min(
                int(total_segments * context_percent / 100),
                max_context_segments
            )
            
            if num_segments == 0:
                return "(Document context disabled)"
            
            # Get segments (from start of document)
            context_segments = self.current_project.segments[:num_segments]
            
            # Format segments based on mode
            context_parts = []
            mode_labels = {"both": "SOURCE + TARGET", "source": "SOURCE ONLY", "target": "TARGET ONLY"}
            context_parts.append(f"=== DOCUMENT CONTEXT ({mode_labels.get(mode, 'UNKNOWN')}) ===")
            context_parts.append(f"(Showing {num_segments} of {total_segments} segments - {context_percent}%)")
            context_parts.append("")
            
            for seg in context_segments:
                if mode == "both":
                    # Source + Target
                    context_parts.append(f"[{seg.id}] {seg.source}")
                    if seg.target and seg.target.strip():
                        context_parts.append(f"    → {seg.target}")
                elif mode == "source":
                    # Source only
                    context_parts.append(f"[{seg.id}] {seg.source}")
                elif mode == "target":
                    # Target only (skip empty targets)
                    if seg.target and seg.target.strip():
                        context_parts.append(f"[{seg.id}] {seg.target}")
                context_parts.append("")  # Blank line between segments
            
            return "\n".join(context_parts)
            
        except Exception as e:
            return f"(Error building document context: {e})"

    def _quicklauncher_show_result_dialog(self, title: str, output_text: str, apply_callback=None):
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox, QPushButton, QApplication

        dlg = QDialog(self)
        dlg.setWindowTitle(title)
        dlg.setMinimumSize(700, 450)
        layout = QVBoxLayout(dlg)

        out = QTextEdit()
        out.setReadOnly(True)
        out.setMarkdown(output_text or "")
        layout.addWidget(out)

        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        btn_copy = QPushButton("Copy")
        buttons.addButton(btn_copy, QDialogButtonBox.ButtonRole.ActionRole)
        btn_copy.clicked.connect(lambda: QApplication.clipboard().setText(output_text or ""))

        if apply_callback is not None:
            btn_apply = QPushButton("Replace")
            buttons.addButton(btn_apply, QDialogButtonBox.ButtonRole.AcceptRole)
            btn_apply.clicked.connect(lambda: (apply_callback(), dlg.accept()))

        buttons.rejected.connect(dlg.reject)
        layout.addWidget(buttons)
        dlg.exec()

    def run_grid_quicklauncher_prompt(self, prompt_relative_path: str, origin_widget: QTextEdit, behavior: str = "show"):
        """Run a QuickLauncher prompt on the current selection and either show the result or replace selection/target."""
        from PyQt6.QtWidgets import QMessageBox, QApplication
        from modules.llm_clients import LLMClient

        if not origin_widget:
            return

        input_text = self._quicklauncher_get_selection_text(origin_widget)
        if not input_text:
            QMessageBox.information(self, "QuickLauncher", "Please select some text first (or click inside a non-empty cell).")
            return

        # Get current segment's target text for {{TARGET_TEXT}} placeholder
        current_target_text = ""
        if hasattr(self, 'table') and self.table and hasattr(self, 'current_project') and self.current_project:
            current_row = self.table.currentRow()
            if 0 <= current_row < len(self.current_project.segments):
                current_target_text = self.current_project.segments[current_row].target or ""

        # Determine project languages
        source_lang = "English"
        target_lang = "Dutch"
        if hasattr(self, 'current_project') and self.current_project:
            source_lang = getattr(self.current_project, 'source_lang', source_lang) or source_lang
            target_lang = getattr(self.current_project, 'target_lang', target_lang) or target_lang

        # Copy-to-clipboard: expand the prompt against the current segment and
        # copy it, ready to paste into an online LLM (Claude.ai, ChatGPT, etc.).
        # No API call is made and no API key is needed.
        if behavior == "clipboard":
            try:
                expanded = self._quicklauncher_build_custom_prompt(
                    prompt_relative_path=prompt_relative_path,
                    source_text=input_text,
                    source_lang=source_lang,
                    target_lang=target_lang,
                    target_text=current_target_text,
                )
            except Exception as e:
                QMessageBox.critical(self, "QuickLauncher", f"Could not build prompt:\n\n{e}")
                return
            QApplication.clipboard().setText(expanded)
            try:
                self.status_bar.showMessage("⚡ QuickLauncher: prompt copied to clipboard", 4000)
            except Exception:
                pass
            return

        # Determine provider/model (active profile wins for custom_openai)
        settings = self.load_llm_settings()
        provider = settings.get('provider', 'openai')
        model = self._resolve_provider_model(settings, provider, None)

        # Load API keys
        api_keys = self.load_api_keys()
        if provider == 'ollama':
            api_key = api_keys.get('ollama', '') or 'not-needed'
        elif provider == 'custom_openai':
            profile = self._get_active_custom_profile(settings)
            profile_key = (profile.get('api_key') or '').strip() if profile else ''
            api_key = profile_key or api_keys.get('custom_openai', '') or 'not-needed'
        else:
            if not api_keys:
                QMessageBox.warning(self, "QuickLauncher", "No API keys found. Configure them in Settings first.")
                return
            api_key = api_keys.get(provider) or (api_keys.get('google') if provider == 'gemini' else None)
            if not api_key:
                QMessageBox.warning(self, "QuickLauncher", f"No API key found for provider '{provider}'.")
                return

        try:
            self.status_bar.showMessage(f"⚡ QuickLauncher: running '{prompt_relative_path}'…")
            QApplication.processEvents()

            custom_prompt = self._quicklauncher_build_custom_prompt(
                prompt_relative_path=prompt_relative_path,
                source_text=input_text,
                source_lang=source_lang,
                target_lang=target_lang,
                target_text=current_target_text
            )

            base_url = None
            if provider == 'custom_openai':
                profile = self._get_active_custom_profile(settings)
                base_url = profile.get('endpoint') or None if profile else None
            http_proxy = self._get_proxy_url() if provider != 'gemini' else None
            client = LLMClient(api_key=api_key, provider=provider, model=model, base_url=base_url, http_proxy=http_proxy)

            # Use translate() with empty text and custom_prompt for generic AI completion
            # This allows QuickLauncher prompts to do anything (explain, define, search, etc.)
            # not just translation. Same pattern as AI Assistant.
            output_text = client.translate(
                text="",  # Empty - we're using custom_prompt for everything
                source_lang="en",  # Dummy values
                target_lang="en",
                custom_prompt=custom_prompt
            )

            if not output_text:
                QMessageBox.warning(self, "QuickLauncher", "No response received from the LLM.")
                return

        except Exception as e:
            QMessageBox.critical(self, "QuickLauncher", f"QuickLauncher failed:\n\n{e}")
            return
        finally:
            try:
                self.status_bar.clearMessage()
            except Exception:
                pass

        # Decide where to apply replacements
        target_widget = None
        row = getattr(origin_widget, 'row', -1)
        table = getattr(origin_widget, 'table', None) or getattr(origin_widget, 'table_ref', None)

        # If originating from target editor, replace in-place. If from source editor, target is column 3.
        if isinstance(origin_widget, EditableGridTextEditor):
            target_widget = origin_widget
        elif table is not None and row is not None and row >= 0:
            try:
                target_widget = table.cellWidget(row, 3)
            except Exception:
                target_widget = None

        def apply_replacement():
            if not target_widget or not isinstance(target_widget, QTextEdit):
                return
            cursor = target_widget.textCursor()
            cursor.beginEditBlock()
            try:
                if cursor.hasSelection():
                    cursor.insertText(output_text)
                else:
                    cursor.select(cursor.SelectionType.Document)
                    cursor.insertText(output_text)
                target_widget.setTextCursor(cursor)
            finally:
                cursor.endEditBlock()

        if behavior == "replace":
            if not target_widget or not isinstance(target_widget, QTextEdit):
                self._quicklauncher_show_result_dialog("Supervertaler QuickLauncher", output_text)
                return

            apply_replacement()
            return

        # Default: show dialog (with optional Replace button)
        can_apply = target_widget is not None and isinstance(target_widget, QTextEdit)
        title = "Supervertaler QuickLauncher"
        self._quicklauncher_show_result_dialog(title, output_text, apply_callback=apply_replacement if can_apply else None)
    
    def translate_multiple_segments(self, scope: str):
        """Translate segments by scope (selected, status-based, or all)."""
        if not self.current_project:
            QMessageBox.warning(self, "No Project", "Please load or create a project first.")
            return

        segments_with_rows: List[Tuple[int, Segment]] = []
        description = ""

        if scope == "selected_not_started":
            selected = self._get_selected_segments_with_rows()
            if not selected:
                QMessageBox.information(
                    self,
                    "No Selection",
                    "Please select one or more segments in the grid or list view first."
                )
                return
            target_statuses = {"not_started"}
            segments_with_rows = [(idx, seg) for idx, seg in selected if seg.status in target_statuses]
            if not segments_with_rows:
                QMessageBox.information(
                    self,
                    "Nothing to Translate",
                    "The selected segments are not marked as 'Not started'."
                )
                return
            description = "selected not-started segment(s)"
        elif scope == "all_not_started":
            target_statuses = {"not_started"}
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if seg.status in target_statuses
            ]
            description = "not-started segment(s)"
        elif scope == "all_pretranslated":
            target_statuses = {"pretranslated"}
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if seg.status in target_statuses
            ]
            description = "pre-translated segment(s)"
        elif scope == "all_not_started_pretranslated":
            target_statuses = {"not_started", "pretranslated"}
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if seg.status in target_statuses
            ]
            description = "not-started and pre-translated segment(s)"
        elif scope == "all_translatable":
            target_statuses = TRANSLATABLE_STATUSES
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if seg.status in target_statuses
            ]
            labels = ", ".join(sorted({get_status(s).label for s in target_statuses}))
            description = f"{labels} segment(s)"
        elif scope == "all_segments":
            segments_with_rows = list(enumerate(self.current_project.segments))
            description = "segment(s)"
        elif scope == "all_empty":
            # NEW: Translate only segments with empty target (useful after partial batch translation)
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if not seg.target or not seg.target.strip()
            ]
            description = "empty segment(s)"
        elif scope == "filtered_segments":
            # NEW: Translate only currently filtered/visible segments
            segments_with_rows = self._get_filtered_segments_with_rows()
            if not segments_with_rows:
                QMessageBox.information(
                    self,
                    "No Filtered Segments",
                    "No segments are currently visible after filtering.\nClear filters or adjust your filter criteria."
                )
                return
            description = "filtered segment(s)"
        else:
            return

        if not segments_with_rows:
            QMessageBox.information(
                self,
                "Nothing to Translate",
                f"No {description} available for translation."
            )
            return

        segments_with_rows = sorted(segments_with_rows, key=lambda item: item[0])
        self.translate_batch(segments_with_rows=segments_with_rows, scope_description=description)

    def _get_selected_segments_with_rows(self) -> List[Tuple[int, Segment]]:
        """Return selected segments across active views as (row_index, segment)."""
        if not self.current_project:
            return []

        selected_ids = set()

        try:
            for seg in self.get_selected_segments_from_grid():
                if hasattr(seg, 'id'):
                    selected_ids.add(seg.id)
        except Exception:
            pass

        try:
            for seg in self.get_selected_segments_from_list():
                if hasattr(seg, 'id'):
                    selected_ids.add(seg.id)
        except Exception:
            pass

        if not selected_ids:
            return []

        segments_with_rows: List[Tuple[int, Segment]] = []
        for idx, seg in enumerate(self.current_project.segments):
            if seg.id in selected_ids:
                segments_with_rows.append((idx, seg))

        return segments_with_rows

    def _get_filtered_segments_with_rows(self) -> List[Tuple[int, Segment]]:
        """Return currently visible/filtered segments as (row_index, segment)."""
        if not self.current_project:
            return []

        # Check if table is currently filtered
        if not hasattr(self, 'table') or self.table.rowCount() == 0:
            return []

        visible_segments: List[Tuple[int, Segment]] = []
        
        # Iterate through visible rows in the table
        for visual_row in range(self.table.rowCount()):
            if not self.table.isRowHidden(visual_row):
                # Get the segment from this row
                # The visual row index corresponds to the segment index in the project
                if visual_row < len(self.current_project.segments):
                    seg = self.current_project.segments[visual_row]
                    visible_segments.append((visual_row, seg))

        return visible_segments

    def translate_batch(self, segments_with_rows: Optional[List[Tuple[int, Segment]]] = None, scope_description: Optional[str] = None):
        """
        Translate ALL segments in the project using LLM provider.
        
        CRITICAL REQUIREMENTS for memoQ bilingual files:
        - Column 2 (source): Source text to translate
        - Column 3 (target): MUST BE COMPLETELY EMPTY (no pre-translations!)
        - Column 5 (status): Completely ignored by system
        
        The system will translate EVERY segment in the file.
        Users MUST ensure target column is 100% empty before export.
        """
        if not self.current_project:
            QMessageBox.warning(self, "No Project", "Please load or create a project first.")
            return

        if segments_with_rows is None:
            # Translate ALL segments in the project
            # Users must ensure memoQ export has completely empty target column
            segments_with_rows = [
                (idx, seg) for idx, seg in enumerate(self.current_project.segments)
                if seg.source.strip()  # Skip empty structural segments
            ]
            scope_description = scope_description or "segment(s)"
        else:
            valid_segments: List[Tuple[int, Segment]] = []
            for row_index, segment in segments_with_rows:
                if 0 <= row_index < len(self.current_project.segments) and segment.source.strip():
                    valid_segments.append((row_index, segment))
            segments_with_rows = valid_segments
            scope_description = scope_description or "segment(s)"

        if not segments_with_rows:
            QMessageBox.information(
                self,
                "Nothing to Translate",
                f"No {scope_description} available for translation."
            )
            return

        segments_to_translate = sorted(segments_with_rows, key=lambda item: item[0])
        total_segments = len(segments_to_translate)
        
        # Log what we found
        self.log(f"📊 Project has {total_segments} segments → Will translate ALL of them")
        
        # VALIDATION: Check if any segments already have target text
        segments_with_target = [(idx, seg) for idx, seg in segments_to_translate if seg.target and seg.target.strip()]
        
        if segments_with_target:
            # Show examples of problematic segments
            examples = segments_with_target[:3]
            example_text = "\n".join([
                f"• Segment {seg.id}: \"{seg.target[:50]}{'...' if len(seg.target) > 50 else ''}\""
                for idx, seg in examples
            ])
            
            warning_msg = (
                f"⚠️ CRITICAL ERROR: {len(segments_with_target)} of {total_segments} segments have target text!\n\n"
                f"Examples:\n{example_text}\n\n"
                f"❌ This file is NOT ready for batch translation!\n\n"
                f"REQUIRED: Target column (column 3) must be 100% EMPTY:\n"
                f"  • No pre-translations\n"
                f"  • No confirmed segments\n"
                f"  • No partial translations\n"
                f"  • Completely blank target cells\n\n"
                f"The system will translate ALL {total_segments} segments.\n"
                f"Existing translations will cause MAJOR synchronization problems!\n\n"
                f"💡 TIP: You can clear all targets without re-importing:\n"
                f"   1. Right-click in the grid → Select All\n"
                f"   2. Right-click again → Clear Target (Selected)\n\n"
                f"Or: Cancel → Clean target column in memoQ → Re-export\n\n"
                f"Proceed anyway? (STRONGLY NOT recommended - will cause errors!)"
            )
            reply = QMessageBox.warning(
                self,
                "Target Text Detected",
                warning_msg,
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No  # Default to No for safety
            )
            if reply != QMessageBox.StandardButton.Yes:
                self.log("✓ Batch translation cancelled - user chose to clean file first")
                return
            else:
                self.log("⚠️ User proceeded with batch translation despite existing target text")

        # Check if this is a retry pass (skip dialog if so)
        is_retry_pass = hasattr(self, '_batch_retry_pass') and self._batch_retry_pass > 0
        
        if is_retry_pass:
            # Use stored settings from previous pass - skip dialog entirely
            translation_provider_type = getattr(self, '_batch_provider_type', 'LLM')
            translation_provider_name = getattr(self, '_batch_provider_name', 'openai')
            model = getattr(self, '_batch_model', 'gpt-5.5')
            tm_exact_only = getattr(self, '_batch_tm_exact_only', False)  # Retrieve TM exact-only setting
            api_keys = self.load_api_keys()
            settings = self.load_llm_settings()
            llm_provider = settings.get('provider', 'openai')
            llm_model = model
        else:
            # Show provider selection dialog (only on first pass)
            provider_dialog = QDialog(self)
            provider_dialog.setWindowTitle("Select Translation Provider")
            provider_dialog.setModal(True)
            provider_dialog.setMinimumWidth(500)

            dialog_layout = QVBoxLayout(provider_dialog)

            # Header
            header = QLabel(f"<h3>🚀 Batch Translate {total_segments} Segment{'s' if total_segments != 1 else ''}</h3>")
            dialog_layout.addWidget(header)

            info_label = QLabel(
                "Choose which translation provider to use for batch translation.\n"
                "This may take several minutes and consume API credits."
            )
            info_label.setWordWrap(True)
            info_label.setStyleSheet("color: #666; padding: 10px 0;")
            dialog_layout.addWidget(info_label)

            dialog_layout.addSpacing(10)

            # Provider selection checkboxes (mutually exclusive)
            provider_group = QGroupBox("Translation Provider")
            provider_layout = QVBoxLayout()

            # Translation Memory option
            tm_checkbox = CheckmarkCheckBox("📖 TM (Translation Memory) - Pre-translate from activated TMs (exact + fuzzy ≥75%)")
            tm_checkbox.setChecked(False)
            provider_layout.addWidget(tm_checkbox)

            # TM Exact Matches Only sub-option (indented to show it's related to TM)
            tm_exact_only_checkbox = CheckmarkCheckBox("       ⚡ Exact matches only (100% matches only - fastest)")
            tm_exact_only_checkbox.setEnabled(False)  # Disabled until TM is selected
            tm_exact_only_checkbox.setToolTip(
                "Only search for 100% exact matches using fast hash lookup.\n"
                "Skips fuzzy matching (75–99% similar segments).\n"
                "Recommended for large documents when you have a good TM."
            )
            # Add left margin to create visual hierarchy
            tm_exact_only_checkbox.setStyleSheet("QCheckBox { margin-left: 20px; }")
            provider_layout.addWidget(tm_exact_only_checkbox)
            
            # Enable/disable exact-only option based on TM selection
            def on_tm_selection_changed(checked):
                tm_exact_only_checkbox.setEnabled(checked)
                if not checked:
                    tm_exact_only_checkbox.setChecked(False)
            
            tm_checkbox.toggled.connect(on_tm_selection_changed)

            llm_checkbox = CheckmarkCheckBox("🤖 LLM (AI) - Use configured LLM provider (GPT, Claude, Gemini)")
            llm_checkbox.setChecked(True)  # Default to LLM
            provider_layout.addWidget(llm_checkbox)

            # Load API keys to check what's available
            api_keys = self.load_api_keys()
            enabled_providers = self.load_provider_enabled_states()

            # Show available MT providers
            mt_providers_available = []
            if enabled_providers.get('mt_google_translate', True) and api_keys.get('google_translate'):
                mt_providers_available.append('Google Translate')
            if enabled_providers.get('mt_deepl', True) and api_keys.get('deepl'):
                mt_providers_available.append('DeepL')
            if enabled_providers.get('mt_microsoft', True) and (api_keys.get('microsoft_translate') or api_keys.get('azure_translate')):
                mt_providers_available.append('Microsoft Translator')
            if enabled_providers.get('mt_amazon', True) and (api_keys.get('amazon_translate') or api_keys.get('aws_translate')):
                mt_providers_available.append('Amazon Translate')
            if enabled_providers.get('mt_modernmt', True) and api_keys.get('modernmt'):
                mt_providers_available.append('ModernMT')
            if enabled_providers.get('mt_mymemory', True):
                mt_providers_available.append('MyMemory')

            mt_label_text = "🌐 MT (Machine Translation)"
            if mt_providers_available:
                mt_label_text += f" - {', '.join(mt_providers_available)}"
            else:
                mt_label_text += " - No MT providers configured"

            mt_checkbox = CheckmarkCheckBox(mt_label_text)
            mt_checkbox.setEnabled(len(mt_providers_available) > 0)
            provider_layout.addWidget(mt_checkbox)

            # Make checkboxes mutually exclusive
            def on_tm_toggled(checked):
                if checked:
                    llm_checkbox.setChecked(False)
                    mt_checkbox.setChecked(False)
        
            def on_llm_toggled(checked):
                if checked:
                    tm_checkbox.setChecked(False)
                    mt_checkbox.setChecked(False)

            def on_mt_toggled(checked):
                if checked:
                    tm_checkbox.setChecked(False)
                    llm_checkbox.setChecked(False)

            tm_checkbox.toggled.connect(on_tm_toggled)
            llm_checkbox.toggled.connect(on_llm_toggled)
            mt_checkbox.toggled.connect(on_mt_toggled)

            if not mt_providers_available:
                mt_warning = QLabel("⚠️ Configure MT API keys in Settings → MT Settings to enable")
                mt_warning.setStyleSheet("color: #ff6b6b; font-size: 9pt; padding-left: 25px;")
                provider_layout.addWidget(mt_warning)

            provider_group.setLayout(provider_layout)
            dialog_layout.addWidget(provider_group)

            dialog_layout.addSpacing(10)

            # Show current LLM settings
            settings = self.load_llm_settings()
            llm_provider = settings.get('provider', 'openai')
            model_key = f'{llm_provider}_model'
            llm_model = settings.get(model_key, 'gpt-5.5')

            if llm_provider == 'custom_openai':
                profile = self._get_active_custom_profile(settings)
                profile_name = profile.get('name', 'Custom') if profile else 'Custom'
                provider_display = f"Custom: {profile_name}"
            else:
                provider_display = llm_provider.title()
            llm_info = QLabel(f"📊 Current LLM: {provider_display} ({llm_model})")
            llm_info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px 0;")
            dialog_layout.addWidget(llm_info)

            dialog_layout.addSpacing(10)
        
            # Options group
            options_group = QGroupBox("Options")
            options_layout = QVBoxLayout()
        
            # Retry until complete option
            retry_checkbox = CheckmarkCheckBox("🔄 Retry until all segments are translated (recommended)")
            retry_checkbox.setToolTip(
                "If some segments fail or are empty after the first pass,\n"
                "automatically retry translating just those segments.\n"
                "This continues until all segments have translations\n"
                "or a maximum of 5 retries is reached."
            )
            retry_checkbox.setChecked(True)  # Default to enabled
            options_layout.addWidget(retry_checkbox)

            # Auto-confirm TM matches option
            auto_confirm_checkbox = CheckmarkCheckBox("✔ Auto-confirm 100% TM matches")
            auto_confirm_checkbox.setToolTip(
                "Automatically set 100% TM matches to 'Confirmed' status\n"
                "instead of the default 'TM 100%' status.\n\n"
                "This is useful when you trust your TM and want to skip\n"
                "manual review of exact matches."
            )
            auto_confirm_checkbox.setChecked(False)
            auto_confirm_checkbox.setEnabled(False)  # Only enabled when TM is selected
            options_layout.addWidget(auto_confirm_checkbox)

            # Enable/disable auto-confirm based on TM selection
            def on_tm_autoconfirm_toggle(checked):
                auto_confirm_checkbox.setEnabled(checked)
                if not checked:
                    auto_confirm_checkbox.setChecked(False)

            tm_checkbox.toggled.connect(on_tm_autoconfirm_toggle)

            options_group.setLayout(options_layout)
            dialog_layout.addWidget(options_group)

            dialog_layout.addSpacing(20)

            # Buttons
            button_layout = QHBoxLayout()
            button_layout.addStretch()

            cancel_btn = QPushButton("Cancel")
            cancel_btn.clicked.connect(provider_dialog.reject)
            button_layout.addWidget(cancel_btn)

            ok_btn = QPushButton("Start Translation")
            ok_btn.setDefault(True)
            ok_btn.clicked.connect(provider_dialog.accept)
            button_layout.addWidget(ok_btn)

            dialog_layout.addLayout(button_layout)

            # Show dialog
            if provider_dialog.exec() != QDialog.DialogCode.Accepted:
                return

            # Determine which provider was selected
            use_tm = tm_checkbox.isChecked()
            use_mt = mt_checkbox.isChecked()
            retry_until_complete = retry_checkbox.isChecked()
            tm_exact_only = tm_exact_only_checkbox.isChecked()
            auto_confirm_tm = auto_confirm_checkbox.isChecked()
        
            # Store retry setting for recursive calls
            self._batch_retry_enabled = retry_until_complete
            self._batch_tm_exact_only = tm_exact_only  # Store for retry passes
            self._batch_auto_confirm_tm = auto_confirm_tm  # Store for auto-confirm
            
            # Initialize model variable (will be set to actual model if LLM is selected)
            model = None

            if use_tm:
                # Use Translation Memory
                translation_provider_type = 'TM'
                translation_provider_name = 'Translation Memory'
            
                # Check if TM database is available
                if not self.tm_database:
                    QMessageBox.critical(
                        self, "No TM Database",
                        "Translation Memory database is not initialized. Please load a project first."
                    )
                    return
            
                mode_str = " (Exact matches only)" if tm_exact_only else " (with fuzzy matching)"
                confirm_str = ", auto-confirm 100%" if auto_confirm_tm else ""
                self.log(f"📖 Using Translation Memory for batch pre-translation{mode_str}{confirm_str}")
            
            elif use_mt:
                # Use MT provider
                translation_provider_type = 'MT'
                translation_provider_name = mt_providers_available[0] if mt_providers_available else None
                if not translation_provider_name:
                    QMessageBox.critical(
                        self, "No MT Provider",
                        "No Machine Translation provider available. Please configure MT API keys in Settings."
                    )
                    return
            else:
                # Use LLM provider
                translation_provider_type = 'LLM'
            
                # Ollama doesn't need API keys - it's local
                if llm_provider == 'ollama':
                    api_keys = {'ollama': 'not-needed'}  # Placeholder - Ollama doesn't use API keys
                elif llm_provider == 'custom_openai':
                    pass  # Key is optional for custom endpoints
                elif not api_keys:
                    QMessageBox.critical(
                        self, "API Keys Missing",
                        "Please configure your API keys in Settings first."
                    )
                    return
                # Note: 'gemini' and 'google' are aliases for the same API key
                elif llm_provider not in api_keys and not (llm_provider == 'gemini' and 'google' in api_keys):
                    QMessageBox.critical(
                        self, f"{llm_provider.title()} API Key Missing",
                        f"Please configure your {llm_provider.title()} API key in Settings."
                    )
                    return

                translation_provider_name = llm_provider
                model = llm_model
            
                # Store settings for potential retry passes
                self._batch_provider_type = translation_provider_type
                self._batch_provider_name = translation_provider_name
                self._batch_model = model

        # All segments go to translation - TM pre-translation should be done separately
        # via "Edit → Batch Operations → Pre-translate from TM" before AI translation
        segments_needing_translation = segments_to_translate

        # Create provider info string for dialog
        if translation_provider_type == 'MT':
            provider_info = f"Provider: {translation_provider_name} (Machine Translation)"
        elif translation_provider_type == 'TM':
            mode = "Exact matches only" if tm_exact_only else "with fuzzy matching"
            provider_info = f"Provider: Translation Memory ({mode})"
        else:
            provider_info = f"Provider: {translation_provider_name.title()} | Model: {model}"

        # Get retry settings
        retry_pass = getattr(self, '_batch_retry_pass', 0)
        retry_enabled = getattr(self, '_batch_retry_enabled', False)
        
        # =====================================================================
        # TM PRE-TRANSLATION - Run on main thread (no SQLite threading issues)
        # Uses the same database methods as the Compare Panel
        # =====================================================================
        if translation_provider_type == 'TM':
            # Get activated TM IDs for TM pre-translation
            tm_ids = None
            if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                if project_id:
                    tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
            
            if not tm_ids:
                QMessageBox.warning(
                    self, "No TMs Activated",
                    "No Translation Memories are activated for reading.\n\n"
                    "Please go to Project Resources → Translation Memories and enable "
                    "the 'Read' checkbox for the TMs you want to use."
                )
                return
            
            self.log(f"📖 Pre-translate from TM: Using activated TMs: {tm_ids}")

            # ── Batch TM pre-translation (optimized) ──
            import time
            start_time = time.time()
            total_segments = len(segments_needing_translation)

            progress = QProgressDialog(
                f"Pre-translating {total_segments} segments from TM...",
                "Cancel", 0, 100, self
            )
            progress.setWindowTitle("🔍 TM Pre-Translation")
            progress.setWindowModality(Qt.WindowModality.WindowModal)
            progress.setMinimumDuration(0)
            progress.setMinimumWidth(450)
            progress.show()
            QApplication.processEvents()

            # Step 1: Prepare search sources (strip outer tags if needed, deduplicate)
            progress.setLabelText(f"Preparing {total_segments} segments...")
            progress.setValue(5)
            QApplication.processEvents()

            # Build mapping: search_source -> list of (row_index, segment) pairs
            source_to_segments = {}
            for row_index, segment in segments_needing_translation:
                search_source = segment.source
                if self.hide_outer_wrapping_tags:
                    search_source, _ = strip_outer_wrapping_tags(search_source)
                source_to_segments.setdefault(search_source, []).append((row_index, segment))

            unique_sources = list(source_to_segments.keys())
            unique_count = len(unique_sources)
            self.log(f"   {total_segments} segments → {unique_count} unique source texts")

            if progress.wasCanceled():
                progress.close()
                return

            # Step 2: Batch TM lookup
            try:
                if tm_exact_only:
                    progress.setLabelText(
                        f"Batch exact-matching {unique_count} unique segments...\n\n"
                        f"This uses a single optimized database query."
                    )
                    progress.setValue(20)
                    QApplication.processEvents()

                    batch_results = self.tm_database.get_exact_matches_batch(
                        sources=unique_sources,
                        tm_ids=tm_ids
                    )

                    # Convert to common format: source -> {target, match_pct}
                    match_results = {}
                    for source_text, match in batch_results.items():
                        match_results[source_text] = {
                            'target': match.get('target_text', ''),
                            'match_pct': 100
                        }
                else:
                    progress.setLabelText(
                        f"Batch matching {unique_count} unique segments (exact + fuzzy)...\n\n"
                        f"Phase 1: Exact matching..."
                    )
                    progress.setValue(20)
                    QApplication.processEvents()

                    def fuzzy_progress(current, total):
                        """Update progress during fuzzy matching phase (20% to 80%)."""
                        if total > 0:
                            pct = 20 + int(60 * current / total)
                            progress.setValue(pct)
                            progress.setLabelText(
                                f"Fuzzy matching {current}/{total} unmatched segments...\n\n"
                                f"Phase 2: Finding best fuzzy matches (≥75%)."
                            )
                        QApplication.processEvents()
                        if progress.wasCanceled():
                            raise InterruptedError("Canceled by user")

                    batch_results = self.tm_database.search_all_batch(
                        sources=unique_sources,
                        tm_ids=tm_ids,
                        enabled_only=False,
                        progress_callback=fuzzy_progress
                    )

                    match_results = {}
                    for source_text, match in batch_results.items():
                        match_pct = match.get('match_pct', 0)
                        if match_pct >= 75:
                            match_results[source_text] = {
                                'target': match.get('target', ''),
                                'match_pct': match_pct,
                                'source': match.get('source', source_text),
                                'tm_name': match.get('tm_name', ''),
                                'tm_id': match.get('tm_id', '')
                            }

            except Exception as e:
                self.log(f"   ✗ Batch TM search error: {e}")
                match_results = {}

            if progress.wasCanceled():
                progress.close()
                return

            # Step 3: Apply results to segments and update grid
            progress.setLabelText(f"Applying {len(match_results)} matches to segments...")
            progress.setValue(80)
            QApplication.processEvents()

            success_count = 0
            no_match_count = 0
            auto_confirmed_count = 0

            for search_source, seg_list in source_to_segments.items():
                match = match_results.get(search_source)
                for row_index, segment in seg_list:
                    if match:
                        segment.target = match['target']
                        match_pct = match.get('match_pct', 100)
                        segment.match_percent = match_pct
                        if match_pct >= 100 and getattr(self, '_batch_auto_confirm_tm', False):
                            segment.status = "confirmed"
                            auto_confirmed_count += 1
                        elif match_pct >= 100:
                            segment.status = "tm_100"
                        else:
                            segment.status = "tm_fuzzy"
                        # Store batch match info on segment for Match Panel display
                        segment._batch_tm_match = {
                            'source': match.get('source', search_source),
                            'target': match['target'],
                            'match_pct': match_pct,
                            'tm_name': match.get('tm_name', ''),
                            'tm_id': match.get('tm_id', ''),
                            # v1.10.50: flag reverse-direction hits so the
                            # Match Panel can show a "⇄ reversed" chip.
                            'reverse_match': match.get('reverse_match', False),
                        }
                        success_count += 1
                    else:
                        no_match_count += 1

            # Invalidate prefetch cache so Match Panel does fresh lookups
            if hasattr(self, 'translation_matches_cache') and self.translation_matches_cache:
                with self.translation_matches_cache_lock:
                    self.translation_matches_cache.clear()
                self.log(f"   Cache cleared after batch pre-translation")

            elapsed = time.time() - start_time
            elapsed_str = f"{elapsed:.1f}s"

            # Step 4: Reload entire grid from updated segment data.
            # The grid build is the slowest step on large projects (it
            # creates a custom widget per row and sometimes ran for 30s+
            # with the title bar saying "Not Responding"). Switch the
            # dialog to indeterminate, hand the row loop a progress
            # callback so it yields to the Qt event loop every 25 rows,
            # and close only when the rebuild is actually done.
            progress.setRange(0, 0)
            progress.setLabelText(
                f"Reloading grid ({success_count} translated)…")
            QApplication.processEvents()

            def _grid_progress(done, total):
                progress.setLabelText(
                    f"Reloading grid… ({done:,} / {total:,})"
                )
                QApplication.processEvents()

            self.load_segments_to_grid(progress_callback=_grid_progress)
            progress.close()

            # Show completion message
            self.log(f"═══════════════════════════════════════════════════════════")
            self.log(f"✓ TM Pre-Translation Complete ({elapsed_str})")
            confirm_note = f" ({auto_confirmed_count} auto-confirmed)" if auto_confirmed_count > 0 else ""
            self.log(f"   Translated: {success_count}{confirm_note} | No match: {no_match_count}")
            self.log(f"   Unique sources: {unique_count} | Total segments: {total_segments}")
            self.log(f"═══════════════════════════════════════════════════════════")

            confirm_msg = f"\n✔ Auto-confirmed: {auto_confirmed_count}" if auto_confirmed_count > 0 else ""
            QMessageBox.information(
                self, "TM Pre-Translation Complete",
                f"Pre-translation from TM complete in {elapsed_str}.\n\n"
                f"✓ Translated: {success_count}\n"
                f"⊘ No match: {no_match_count}"
                f"{confirm_msg}"
            )

            self.project_modified = True
            self.update_window_title()
            self.update_progress_stats()
            return  # Exit - TM pre-translation is complete
        
        # =====================================================================
        # LLM/MT TRANSLATION - Uses background worker thread
        # =====================================================================
        
        # Create worker and dialog
        # Note: TM pre-translation is now handled on main thread above,
        # so we don't pass tm_ids to the worker anymore
        # Pre-fetch glossary terms on main thread (SQLite is not thread-safe)
        glossary_terms = self.get_ai_inject_glossary_terms()

        # Get base_url and api_key for custom_openai provider (from active profile)
        custom_base_url = None
        custom_api_key = None
        if translation_provider_name == 'custom_openai':
            profile = self._get_active_custom_profile(settings)
            if profile:
                custom_base_url = profile.get('endpoint') or None
                profile_key = (profile.get('api_key') or '').strip()
                if profile_key:
                    custom_api_key = profile_key

        worker = PreTranslationWorker(
            self,
            segments_needing_translation,  # Use filtered segments after TM pre-check
            translation_provider_type,
            translation_provider_name,
            model if translation_provider_type == 'LLM' else None,
            tm_exact_only=tm_exact_only,
            prompt_manager=self.prompt_manager_qt if hasattr(self, 'prompt_manager_qt') else None,
            retry_enabled=retry_enabled,
            retry_pass=retry_pass,
            tm_ids=None,  # TM pre-translation now on main thread
            glossary_terms=glossary_terms,
            base_url=custom_base_url,
            custom_api_key=custom_api_key,
            http_proxy=self._get_proxy_url() if translation_provider_name != 'gemini' else None
        )
        dialog = LiveProgressDialog(self, len(segments_needing_translation), provider_info)
        
        # Track success/error counts for dialog updates
        success_count = 0
        error_count = 0
        
        # Connect signals
        def handle_progress_update(current, total, message, success, elapsed_time):
            nonlocal success_count, error_count
            if success:
                success_count += 1
            else:
                error_count += 1
            dialog.add_console_line(message, success)
            dialog.update_progress(current, total, elapsed_time, success_count, error_count)
            
            # Update grid immediately for successful translations
            if success and current <= len(segments_needing_translation):
                row_index, segment = segments_needing_translation[current - 1]
                if row_index < self.table.rowCount():
                    # Strip outer wrapping tags if setting is enabled
                    display_text = segment.target
                    if self.hide_outer_wrapping_tags:
                        display_text, _ = strip_outer_wrapping_tags(display_text)
                    target_widget = self.table.cellWidget(row_index, 3)
                    if target_widget and isinstance(target_widget, EditableGridTextEditor):
                        target_widget.setPlainText(display_text)
                    else:
                        self.table.setItem(row_index, 3, QTableWidgetItem(display_text))
                    self.update_status_icon(row_index, segment.status)
        
        def handle_translation_complete(final_success_count, final_error_count):
            dialog.show_completion_message(final_success_count, final_error_count)
            self.project_modified = True
            self.update_window_title()
            self.auto_resize_rows()
            self.update_progress_stats()
            
            # Check if this is the final pass (no retry pending)
            if not hasattr(self, '_pending_retry'):
                # Log completion
                self.log(f"═══════════════════════════════════════════════════════════")
                self.log(f"✓ Batch Translation Complete")
                self.log(f"   Translated: {final_success_count} | Failed: {final_error_count}")
                self.log(f"═══════════════════════════════════════════════════════════")
                
                # Clear retry settings
                if hasattr(self, '_batch_retry_pass'):
                    delattr(self, '_batch_retry_pass')
                if hasattr(self, '_batch_retry_enabled'):
                    delattr(self, '_batch_retry_enabled')
        
        def handle_retry_needed(empty_segments_after):
            retry_count = len(empty_segments_after)
            current_pass = worker.retry_pass
            next_pass = current_pass + 2  # +2 because pass 0 is "first pass", so pass 1 is "retry 2"
            
            # Log retry
            self.log(f"🔄 Retry pass {next_pass}: {retry_count} segment(s) still empty, retrying...")
            
            # Update retry pass counter
            self._batch_retry_pass = current_pass + 1
            self._pending_retry = True  # Flag to prevent completion message
            
            # Close current dialog
            dialog.accept()
            
            # Recursively call translate_batch with only empty segments
            # Clear the pending flag before recursion
            if hasattr(self, '_pending_retry'):
                delattr(self, '_pending_retry')
            
            self.translate_batch(
                segments_with_rows=empty_segments_after,
                scope_description=f"empty segment(s) (retry {next_pass})"
            )
        
        worker.progress_update.connect(handle_progress_update)
        worker.translation_complete.connect(handle_translation_complete)
        worker.retry_needed.connect(handle_retry_needed)
        
        # Connect cancel button
        dialog.rejected.connect(worker.cancel)
        
        # Start worker and show dialog
        worker.start()
        dialog.exec()
        
        # Wait for worker to finish
        worker.wait()
        
        return  # Exit - new implementation replaces all the code below

        # ===== OLD BLOCKING IMPLEMENTATION BELOW (DISABLED) =====
        # Create progress dialog
        progress = QDialog(self)
        progress.setWindowTitle("Batch Translation Progress")
        progress.setMinimumWidth(600)
        progress.setMinimumHeight(250)
        progress.setModal(True)

        layout = QVBoxLayout(progress)

        # Header
        header_label = QLabel(f"<h3>🚀 Translating {total_segments} segment{'s' if total_segments != 1 else ''}</h3>")
        layout.addWidget(header_label)

        # Provider info
        if translation_provider_type == 'MT':
            info_label = QLabel(f"<b>Provider:</b> {translation_provider_name} (Machine Translation)")
        elif translation_provider_type == 'TM':
            info_label = QLabel(f"<b>Provider:</b> {translation_provider_name}")
        else:
            info_label = QLabel(f"<b>Provider:</b> {translation_provider_name.title()} | <b>Model:</b> {model}")
        layout.addWidget(info_label)

        # Progress bar
        progress_bar = QProgressBar()
        progress_bar.setMaximum(total_segments)
        progress_bar.setValue(0)
        layout.addWidget(progress_bar)

        # Current segment label
        current_label = QLabel("Starting...")
        layout.addWidget(current_label)

        # Statistics
        stats_label = QLabel(f"Translated: 0 | Failed: 0 | Remaining: {total_segments}")
        layout.addWidget(stats_label)

        # Close button (initially disabled)
        close_btn = QPushButton("Close")
        close_btn.setEnabled(False)
        close_btn.clicked.connect(progress.accept)
        layout.addWidget(close_btn)

        # Show progress dialog
        progress.show()
        QApplication.processEvents()

        translated_count = 0
        failed_count = 0
        segment_idx = 0

        try:
            source_lang = getattr(self.current_project, 'source_lang', 'en')
            target_lang = getattr(self.current_project, 'target_lang', 'nl')

            self.log(f"═══════════════════════════════════════════════════════════")
            self.log(f"🚀 Starting Batch Translation")
            self.log(f"   Provider Type: {translation_provider_type}")
            if translation_provider_type == 'MT':
                self.log(f"   MT Provider: {translation_provider_name}")
            elif translation_provider_type == 'TM':
                self.log(f"   TM Provider: {translation_provider_name}")
            else:
                self.log(f"   LLM Provider: {translation_provider_name} ({model})")
            self.log(f"   Source → Target: {source_lang} → {target_lang}")
            self.log(f"   Total segments: {total_segments}")
            if segments_with_target:
                self.log(f"   ⚠️ Warning: {len(segments_with_target)} segments have existing target text")
            self.log(f"═══════════════════════════════════════════════════════════")

            # Check TM before API calls if enabled (can save significant API costs!)
            general_prefs = self.load_general_settings()
            check_tm_before_api = general_prefs.get('check_tm_before_api', True)
            check_tm_exact_only = general_prefs.get('check_tm_exact_only', False)
            tm_matches_found = 0
            segments_needing_translation = []
            
            if check_tm_before_api and self.tm_database:
                self.log(f"🔍 Pre-checking TM for 100% matches before API calls...")
                if check_tm_exact_only:
                    self.log(f"   Using EXACT match mode (faster - hash lookup)")
                else:
                    self.log(f"   Using FUZZY match mode (includes 100% matches)")
                
                # Get activated TM IDs for current project
                tm_ids = None
                if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                    project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                    if project_id:
                        tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
                        if tm_ids:
                            self.log(f"   Using activated TMs: {tm_ids}")
                        else:
                            self.log(f"   ⚠ No TMs activated - skipping TM pre-check")
                
                # Skip TM pre-check if no TMs are activated
                if tm_ids is None or (isinstance(tm_ids, list) and len(tm_ids) == 0):
                    self.log(f"   Skipping TM pre-check (no activated TMs)")
                    segments_needing_translation = segments_to_translate
                else:
                    # Batch TM pre-check: prepare sources, deduplicate, single query
                    source_to_precheck_segs = {}
                    for row_index, segment in segments_to_translate:
                        search_source = segment.source
                        if self.hide_outer_wrapping_tags:
                            search_source, _ = strip_outer_wrapping_tags(search_source)
                        source_to_precheck_segs.setdefault(search_source, []).append((row_index, segment))

                    unique_precheck = list(source_to_precheck_segs.keys())

                    try:
                        # Batch exact match lookup (single SQL query)
                        precheck_results = self.tm_database.get_exact_matches_batch(
                            sources=unique_precheck,
                            tm_ids=tm_ids
                        )
                    except Exception as e:
                        self.log(f"   ⚠ Batch TM pre-check error: {e}")
                        precheck_results = {}

                    # Apply results
                    for search_source, seg_list in source_to_precheck_segs.items():
                        exact_match = precheck_results.get(search_source)
                        for row_index, segment in seg_list:
                            if exact_match:
                                tm_match = exact_match.get('target_text', '')
                                segment.target = tm_match
                                segment.match_percent = 100
                                segment.status = "tm_100"
                                segment._batch_tm_match = {
                                    'source': exact_match.get('source_text', search_source),
                                    'target': tm_match,
                                    'match_pct': 100,
                                    'tm_name': self.tm_metadata.get(exact_match.get('tm_id', ''), {}).get('name', '') if hasattr(self, 'tm_metadata') else '',
                                    'tm_id': exact_match.get('tm_id', ''),
                                    # v1.10.50: flag reverse-direction hits
                                    'reverse_match': exact_match.get('reverse_match', False),
                                }
                                tm_matches_found += 1
                                translated_count += 1

                                if row_index < self.table.rowCount():
                                    target_widget = self.table.cellWidget(row_index, 3)
                                    if target_widget and isinstance(target_widget, EditableGridTextEditor):
                                        target_widget.setPlainText(tm_match)
                                    else:
                                        self.table.setItem(row_index, 3, QTableWidgetItem(tm_match))
                                    self.update_status_icon(row_index, "tm_100")
                            else:
                                segments_needing_translation.append((row_index, segment))

                    QApplication.processEvents()
                
                self.log(f"✓ TM pre-check complete: {tm_matches_found} 100% matches found, {len(segments_needing_translation)} segments need translation")
                self.log(f"   API calls saved: {tm_matches_found} (cost savings!)")
                
                # Update progress to reflect TM matches
                progress_bar.setValue(tm_matches_found)
                stats_label.setText(f"Translated: {translated_count} | Failed: {failed_count} | Remaining: {len(segments_needing_translation)}")
                QApplication.processEvents()
                
                # Replace segments_to_translate with only those needing translation
                if segments_needing_translation:
                    segments_to_translate = segments_needing_translation
                    total_segments = len(segments_to_translate)
                    segment_idx = tm_matches_found  # Start counting from TM matches
                else:
                    # All segments matched in TM - we're done!
                    self.log(f"🎉 All segments found in TM - no API calls needed!")
                    self.project_modified = True
                    self.update_window_title()
                    progress_bar.setValue(translated_count)
                    current_label.setText(f"✓ Complete! All {translated_count} segments translated from TM")
                    close_btn.setEnabled(True)
                    self.log(f"═══════════════════════════════════════════════════════════")
                    self.log(f"✓ Batch Translation Complete (100% from TM)")
                    self.log(f"   Translated: {translated_count} | Failed: {failed_count}")
                    self.log(f"═══════════════════════════════════════════════════════════")
                    # Auto-resize rows to fit inserted translations
                    self.auto_resize_rows()
                    self.update_progress_stats()
                    return
            else:
                if not check_tm_before_api:
                    self.log(f"ℹ️ TM pre-check disabled in settings")
                elif not self.tm_database:
                    self.log(f"ℹ️ No TM database available")
                segments_needing_translation = segments_to_translate

            # TM-only mode: Skip API initialization and translate from TM only
            if translation_provider_type == 'TM':
                self.log(f"📖 TM-Only mode: Translating {len(segments_needing_translation)} segments from Translation Memory")
                
                # Get activated TM IDs for current project
                tm_ids = None
                if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                    project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                    if project_id:
                        tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)
                        if tm_ids:
                            self.log(f"   Using activated TMs: {tm_ids}")
                
                # Batch TM-only translation: prepare, deduplicate, batch query
                source_to_tm_segs = {}
                for row_index, segment in segments_needing_translation:
                    search_source = segment.source
                    if self.hide_outer_wrapping_tags:
                        search_source, _ = strip_outer_wrapping_tags(search_source)
                    source_to_tm_segs.setdefault(search_source, []).append((row_index, segment))

                unique_tm_sources = list(source_to_tm_segs.keys())
                self.log(f"   {len(segments_needing_translation)} segments → {len(unique_tm_sources)} unique sources")

                def tm_only_fuzzy_progress(current, total):
                    """Update progress during fuzzy matching in TM-only mode."""
                    if total > 0:
                        current_label.setText(f"Fuzzy matching {current}/{total} unmatched segments...")
                    QApplication.processEvents()

                try:
                    batch_tm_results = self.tm_database.search_all_batch(
                        sources=unique_tm_sources,
                        tm_ids=tm_ids,
                        enabled_only=False,
                        progress_callback=tm_only_fuzzy_progress
                    )
                except Exception as e:
                    self.log(f"   ✗ Batch TM search error: {e}")
                    batch_tm_results = {}

                # Apply results to all segments
                for search_source, seg_list in source_to_tm_segs.items():
                    match = batch_tm_results.get(search_source)
                    for row_index, segment in seg_list:
                        if match and match.get('match_pct', 0) >= 75:
                            tm_match = match.get('target', '')
                            match_pct = match.get('match_pct', 100)
                            segment.target = tm_match
                            segment.match_percent = match_pct
                            if match_pct >= 100:
                                segment.status = "tm_100"
                            else:
                                segment.status = "tm_fuzzy"
                            segment._batch_tm_match = {
                                'source': match.get('source', search_source),
                                'target': tm_match,
                                'match_pct': match_pct,
                                'tm_name': match.get('tm_name', ''),
                                'tm_id': match.get('tm_id', ''),
                                # v1.10.50: flag reverse-direction hits
                                'reverse_match': match.get('reverse_match', False),
                            }
                            translated_count += 1

                        else:
                            failed_count += 1

                        segment_idx += 1

                # Invalidate prefetch cache
                if hasattr(self, 'translation_matches_cache') and self.translation_matches_cache:
                    with self.translation_matches_cache_lock:
                        self.translation_matches_cache.clear()

                progress_bar.setValue(segment_idx)
                stats_label.setText(f"Translated: {translated_count} | Skipped: {failed_count}")
                QApplication.processEvents()

                # TM-only translation complete – full grid reload
                self.load_segments_to_grid()

                self.project_modified = True
                self.update_window_title()
                current_label.setText(f"✓ Complete! {translated_count} segments translated from TM, {failed_count} skipped (no match/low match)")
                close_btn.setEnabled(True)
                self.log(f"═══════════════════════════════════════════════════════════")
                self.log(f"✓ TM-Only Batch Translation Complete")
                self.log(f"   Translated: {translated_count} | Skipped: {failed_count}")
                self.log(f"═══════════════════════════════════════════════════════════")
                self.update_progress_stats()
                return
            
            # Initialize client based on provider type (LLM, MT, or TM)
            client = None
            mt_api_key = None
            mt_provider_code = None

            if translation_provider_type == 'LLM':
                client = self.create_llm_client(translation_provider_name, model, api_keys, settings=settings)
            elif translation_provider_type == 'MT':
                # MT provider - get API key
                provider_map = {
                    'Google Translate': 'google_translate',
                    'DeepL': 'deepl',
                    'Microsoft Translator': ('microsoft_translate', 'azure_translate'),
                    'Amazon Translate': ('amazon_translate', 'aws_translate'),
                    'ModernMT': 'modernmt',
                    'MyMemory': 'mymemory'
                }

                key_name = provider_map.get(translation_provider_name)
                if isinstance(key_name, tuple):
                    # Try multiple possible key names
                    for kn in key_name:
                        if api_keys.get(kn):
                            mt_api_key = api_keys[kn]
                            break
                else:
                    mt_api_key = api_keys.get(key_name)

                # Store provider code for MT calls
                mt_provider_code = translation_provider_name.lower().replace(' ', '_')

            batch_size = general_prefs.get('batch_size', 20)
            total_batches = (total_segments + batch_size - 1) // batch_size

            for batch_num in range(total_batches):
                batch_start = batch_num * batch_size
                batch_end = min(batch_start + batch_size, total_segments)
                batch_segments = segments_needing_translation[batch_start:batch_end]

                current_label.setText(f"Translating batch {batch_num + 1}/{total_batches} ({len(batch_segments)} segments)...")
                progress_bar.setValue(segment_idx)
                QApplication.processEvents()

                try:
                    # TM provider - get matches from translation memory
                    if translation_provider_type == 'TM':
                        current_label.setText(f"Translating batch {batch_num + 1}/{total_batches} ({len(batch_segments)} segments)...")
                        progress_bar.setValue(segment_idx)
                        QApplication.processEvents()

                        # Translate each segment using TM
                        for row_index, segment in batch_segments:
                            try:
                                # Query TM for 100% match
                                tm_match = self.tm_database.query_tm(segment.source, threshold=100)

                                if tm_match and len(tm_match) > 0:
                                    translation = tm_match[0]['target']
                                    segment.target = translation
                                    segment.status = 'pretranslated'
                                    translated_count += 1
                                    self.log(f"  ✓ Segment {segment.id}: {segment.source[:40]}... → {translation[:40]}... (TM 100%)")
                                else:
                                    failed_count += 1
                                    self.log(f"  ✗ Segment {segment.id}: No 100% TM match found")

                                segment_idx += 1
                                progress_bar.setValue(segment_idx)
                                stats_label.setText(f"Translated: {translated_count} | Failed: {failed_count} | Remaining: {total_segments - segment_idx}")
                                QApplication.processEvents()

                            except Exception as e:
                                failed_count += 1
                                self.log(f"  ✗ Segment {segment.id}: Error - {e}")
                                segment_idx += 1

                        continue  # Skip to next batch

                    # MT provider - simpler segment-by-segment translation
                    elif translation_provider_type == 'MT':
                        current_label.setText(f"Translating batch {batch_num + 1}/{total_batches} ({len(batch_segments)} segments)...")
                        progress_bar.setValue(segment_idx)
                        QApplication.processEvents()

                        # Translate each segment individually with MT
                        for row_index, segment in batch_segments:
                            try:
                                translation = None

                                # Call appropriate MT service
                                if translation_provider_name == 'Google Translate':
                                    translation = self.call_google_translate(segment.source, source_lang, target_lang, mt_api_key)
                                elif translation_provider_name == 'DeepL':
                                    translation = self.call_deepl(segment.source, source_lang, target_lang, mt_api_key)
                                elif translation_provider_name == 'Microsoft Translator':
                                    translation = self.call_microsoft_translate(segment.source, source_lang, target_lang, mt_api_key)
                                elif translation_provider_name == 'Amazon Translate':
                                    region = api_keys.get('amazon_translate_region', 'us-east-1')
                                    translation = self.call_amazon_translate(segment.source, source_lang, target_lang, mt_api_key, region)
                                elif translation_provider_name == 'ModernMT':
                                    translation = self.call_modernmt(segment.source, source_lang, target_lang, mt_api_key)
                                elif translation_provider_name == 'MyMemory':
                                    translation = self.call_mymemory(segment.source, source_lang, target_lang, mt_api_key)

                                if translation and not translation.startswith('['):  # Skip error messages
                                    segment.target = translation
                                    segment.status = 'pretranslated'
                                    translated_count += 1
                                    self.log(f"  ✓ Segment {segment.id}: {segment.source[:40]}... → {translation[:40]}...")
                                else:
                                    failed_count += 1
                                    self.log(f"  ✗ Segment {segment.id}: Translation failed - {translation}")

                                segment_idx += 1
                                progress_bar.setValue(segment_idx)
                                stats_label.setText(f"Translated: {translated_count} | Failed: {failed_count} | Remaining: {total_segments - segment_idx}")
                                QApplication.processEvents()

                            except Exception as e:
                                failed_count += 1
                                self.log(f"  ✗ Segment {segment.id}: Error - {e}")
                                segment_idx += 1

                        continue  # Skip to next batch

                    # LLM provider - batch translation with context
                    batch_prompt_parts = []

                    base_prompt = None
                    if hasattr(self, 'prompt_manager_qt') and self.prompt_manager_qt and batch_segments:
                        try:
                            first_segment = batch_segments[0][1]
                            # Get glossary terms for AI injection
                            glossary_terms = self.get_ai_inject_glossary_terms()
                            base_prompt = self.prompt_manager_qt.build_final_prompt(
                                source_text=first_segment.source,
                                source_lang=source_lang,
                                target_lang=target_lang,
                                mode="single",
                                glossary_terms=glossary_terms,
                                target_text=first_segment.target or ""
                            )
                            if "**SOURCE TEXT:**" in base_prompt:
                                base_prompt = base_prompt.split("**SOURCE TEXT:**")[0].strip()
                            elif "Translate the following" in base_prompt:
                                base_prompt = base_prompt.split("Translate the following")[0].strip()
                        except Exception:
                            base_prompt = None

                    if base_prompt:
                        batch_prompt_parts.append(base_prompt)
                    else:
                        batch_prompt_parts.append(f"Translate the following text segments from {source_lang} to {target_lang}.")

                    use_full_context = general_prefs.get('use_full_context', True)
                    if use_full_context and self.current_project:
                        try:
                            # Get first and last segment IDs in this batch for context window
                            first_batch_seg_id = batch_segments[0][1].id if batch_segments else 0
                            last_batch_seg_id = batch_segments[-1][1].id if batch_segments else 0
                            
                            # Use user-configurable context window size (default 50)
                            # This prevents massive prompts that cause timeouts with large documents
                            context_window_size = general_prefs.get('context_window_size', 50)
                            context_start_id = max(1, first_batch_seg_id - context_window_size)
                            context_end_id = min(len(self.current_project.segments), last_batch_seg_id + context_window_size)
                            
                            context_parts = []
                            for seg in self.current_project.segments:
                                if seg.source and context_start_id <= seg.id <= context_end_id:
                                    context_parts.append(f"{seg.id}. {seg.source}")
                                    # Show existing translations if present (for legacy compatibility)
                                    if seg.target and seg.target.strip():
                                        context_parts.append(f"    → {seg.target}")

                            if context_parts:
                                batch_prompt_parts.append("\n" + "="*80)
                                batch_prompt_parts.append("**SURROUNDING CONTEXT (FOR REFERENCE ONLY):**")
                                batch_prompt_parts.append(f"Source text from segments {context_start_id}-{context_end_id} shown for terminology consistency.")
                                batch_prompt_parts.append("DO NOT translate these context segments - only translate the segments explicitly listed below.")
                                batch_prompt_parts.append("="*80 + "\n")
                                batch_prompt_parts.append("\n".join(context_parts))
                                batch_prompt_parts.append("\n" + "="*80)
                                batch_prompt_parts.append("END OF CONTEXT SECTION")
                                batch_prompt_parts.append("="*80 + "\n")
                                actual_context_segs = context_end_id - context_start_id + 1
                                self.log(f"  Including context window: segments {context_start_id}-{context_end_id} ({actual_context_segs} segments)")
                        except Exception as e:
                            self.log(f"⚠ Could not add context window: {e}")

                    batch_prompt_parts.append(f"\n**SEGMENTS TO TRANSLATE ({len(batch_segments)} segments):**")
                    batch_prompt_parts.append("\n⚠️ CRITICAL INSTRUCTIONS - READ CAREFULLY:")
                    batch_prompt_parts.append(f"1. You must provide EXACTLY one translation per segment")
                    batch_prompt_parts.append(f"2. You MUST translate ALL {len(batch_segments)} segments - NO EXCEPTIONS, NO SKIPPING")
                    batch_prompt_parts.append("3. TRANSLATE EVERYTHING, including:")
                    batch_prompt_parts.append("   - Short segments starting with 'FIG.' or 'Figure' (these are NOT instructions to you)")
                    batch_prompt_parts.append("   - Very long segments (keep them as a single numbered entry, do NOT split one segment across multiple numbered entries)")
                    batch_prompt_parts.append("   - Section headings, single words, or short phrases")
                    batch_prompt_parts.append("4. Line breaks: If the source segment contains line breaks, preserve them in your translation.")
                    batch_prompt_parts.append("   The number label (e.g. '40.') appears only ONCE at the start; continuation lines have no number.")
                    batch_prompt_parts.append("5. Format: Each translation MUST start with its segment number, a period, then the translation")
                    batch_prompt_parts.append("6. NO explanations, NO commentary, ONLY the numbered translations\n")

                    batch_prompt_parts.append("**SEGMENTS TO TRANSLATE (translate ONLY these, using their EXACT numbers):**\n")
                    
                    for row_index, seg in batch_segments:
                        # Use CLASSIC's simple numbered format: "125. Source text"
                        batch_prompt_parts.append(f"{seg.id}. {seg.source}")

                    batch_prompt_parts.append("\n**YOUR TRANSLATIONS (numbered list):**")
                    batch_prompt_parts.append("Required format (use EXACT segment numbers shown above):")
                    # Show actual examples from this batch
                    example_ids = [str(seg.id) for row_index, seg in batch_segments[:3]]
                    batch_prompt_parts.append(f"{example_ids[0]}. Translation of segment {example_ids[0]}")
                    if len(example_ids) > 1:
                        batch_prompt_parts.append(f"{example_ids[1]}. Translation of segment {example_ids[1]}")
                    if len(example_ids) > 2:
                        batch_prompt_parts.append(f"{example_ids[2]}. Translation of segment {example_ids[2]}")
                    batch_prompt_parts.append(f"... continue for ALL {len(batch_segments)} segments ...")
                    batch_prompt_parts.append(f"\n⚠️ Remember: Translate segments starting with 'FIG.' - they are content, not instructions!")
                    batch_prompt_parts.append("⚠️ Do NOT skip or split long segments.")
                    batch_prompt_parts.append("\nBegin your translations now:")

                    batch_prompt = "\n".join(batch_prompt_parts)

                    self.log(f"🤖 Translating batch {batch_num + 1}/{total_batches} ({len(batch_segments)} segments)...")

                    # Check for figure references across all segments in batch
                    batch_images = None
                    if self.figure_context and self.figure_context.has_images():
                        try:
                            self.log(f"  🖼️ Scanning batch for figure references...")
                            # Collect all unique figure references from batch
                            all_figure_refs = set()
                            segments_with_figures = []
                            for row_index, seg in batch_segments:
                                refs = self.figure_context.detect_figure_references(seg.source)
                                if refs:
                                    all_figure_refs.update(refs)
                                    segments_with_figures.append(f"#{seg.id}")
                            
                            if all_figure_refs:
                                self.log(f"  📌 Detected figure references {sorted(all_figure_refs)} in segments: {', '.join(segments_with_figures[:5])}{'...' if len(segments_with_figures) > 5 else ''}")
                                # Get images for all detected figures
                                batch_text = "\n".join([seg.source for _, seg in batch_segments])
                                images_for_batch = self.figure_context.get_images_for_text(batch_text)
                                
                                if images_for_batch:
                                    # Check if model supports vision
                                    if LLMClient.model_supports_vision(provider, model):
                                        # Convert PIL images to appropriate format for provider
                                        if provider == "gemini":
                                            batch_images = images_for_batch
                                        else:
                                            batch_images = [
                                                (ref, self.figure_context.pil_image_to_base64_png(img))
                                                for ref, img in images_for_batch
                                            ]
                                        self.log(f"  ✅ Including {len(batch_images)} figure images with batch: {', '.join(sorted(all_figure_refs))}")
                                    else:
                                        self.log(f"  ⚠️ Figures detected ({', '.join(sorted(all_figure_refs))}) but model '{model}' doesn't support vision")
                                else:
                                    self.log(f"  ⚠️ Figure references detected but no matching images found in loaded folder")
                            else:
                                self.log(f"  ℹ️ No figure references detected in this batch")
                        except Exception as e:
                            self.log(f"  ⚠️ Could not load batch figures: {e}")

                    first_segment_text = batch_segments[0][1].source if batch_segments else ""
                    batch_response = client.translate(
                        text=first_segment_text,
                        source_lang=source_lang,
                        target_lang=target_lang,
                        custom_prompt=batch_prompt,
                        images=batch_images
                    )

                    import re
                    
                    # Create a dictionary to map segment IDs to translations
                    segment_translations = {}
                    expected_ids = [seg.id for _, seg in batch_segments]
                    
                    # Parse using CLASSIC's proven numbered format: "125. Translation here"
                    # Uses a state-machine to handle multi-line translations (segments with \n).
                    current_id = None
                    for line in batch_response.split('\n'):
                        # Match numbered translations: "125. Translation text"
                        match = re.match(r'^\s*(\d+)\.\s*(.*)', line)
                        if match:
                            seg_id = int(match.group(1))
                            translation = match.group(2)
                            # Only accept translations for segments we requested
                            if seg_id in expected_ids:
                                segment_translations[seg_id] = translation
                                current_id = seg_id
                            else:
                                current_id = None
                        elif current_id is not None:
                            # Continuation line for a multi-line translation – preserve the \n
                            segment_translations[current_id] += '\n' + line
                    
                    self.log(f"  ✓ Parsed {len(segment_translations)} translations from response")
                    self.log(f"  ✓ Expected IDs: {expected_ids[:5]}{'...' if len(expected_ids) > 5 else ''}")
                    self.log(f"  ✓ Received IDs: {sorted(segment_translations.keys())[:5]}{'...' if len(segment_translations) > 5 else ''}")

                    # Validate we got translations for all segments
                    missing_segments = []
                    fig_missing = []
                    long_missing = []
                    
                    for row_index, seg in batch_segments:
                        if seg.id not in segment_translations:
                            missing_segments.append(seg.id)
                            # Check if it's a FIG segment
                            if seg.source.strip().upper().startswith('FIG'):
                                fig_missing.append(seg.id)
                            # Check if it's a very long segment
                            if len(seg.source) > 500:
                                long_missing.append(seg.id)
                    
                    if missing_segments:
                        self.log(f"❌ WARNING: Missing translations for {len(missing_segments)} segments: {missing_segments}")
                        if fig_missing:
                            self.log(f"  ⚠️ {len(fig_missing)} missing segments start with 'FIG': {fig_missing}")
                        if long_missing:
                            self.log(f"  ⚠️ {len(long_missing)} missing segments are very long (>500 chars): {long_missing}")
                        
                        # Check for LLM refusal patterns
                        refusal_patterns = [
                            "I'm sorry", "I can't assist", "I cannot assist", 
                            "I'm not able to", "I cannot help", "I can't help",
                            "against my guidelines", "policy", "unable to"
                        ]
                        response_lower = batch_response.lower()
                        is_refusal = any(pattern.lower() in response_lower for pattern in refusal_patterns)
                        
                        if is_refusal:
                            self.log(f"  🚫 LLM REFUSAL DETECTED: The AI provider refused to translate this content.")
                            self.log(f"  💡 TRY: Switch to Claude or Gemini in Settings - they may be less restrictive.")
                            self.log(f"  💡 TRY: Use a different model - some are less restrictive than others.")
                        self.log(f"  Expected {len(batch_segments)} translations, got {len(segment_translations)}")
                        
                        response_preview = batch_response[:800].replace('\n', ' ')
                        self.log(f"  Response preview: {response_preview}...")

                    for i, (row_index, segment) in enumerate(batch_segments):
                        translation = segment_translations.get(segment.id, "")

                        if translation:
                            # Sanity check: if source is very long and translation is very short, flag it
                            source_len = len(segment.source)
                            trans_len = len(translation)
                            if source_len > 300 and trans_len < source_len * 0.3:
                                self.log(f"⚠ Segment #{segment.id}: Suspiciously short translation ({trans_len} chars for {source_len} char source)")
                            
                            # Check if translation seems to be from wrong segment (contains wrong numbering)
                            source_starts_with_num = re.match(r'^(\d+)\.', segment.source.strip())
                            trans_starts_with_num = re.match(r'^(\d+)\.', translation.strip())
                            if source_starts_with_num and trans_starts_with_num:
                                if source_starts_with_num.group(1) != trans_starts_with_num.group(1):
                                    self.log(f"⚠ Segment #{segment.id}: Number mismatch! Source starts with {source_starts_with_num.group(1)}, translation with {trans_starts_with_num.group(1)}")
                        
                        if translation:
                            segment.target = translation
                            segment.status = "draft"  # Set to 'draft' like single translation mode

                            if row_index < self.table.rowCount():
                                target_widget = self.table.cellWidget(row_index, 3)
                                if target_widget and isinstance(target_widget, EditableGridTextEditor):
                                    target_widget.setPlainText(translation)
                                else:
                                    self.table.setItem(row_index, 3, QTableWidgetItem(translation))
                                    self.update_status_icon(row_index, "draft")

                            if self.tm_database:
                                try:
                                    self.tm_database.add_to_project_tm(segment.source, translation)
                                except Exception:
                                    pass

                            translated_count += 1
                            self.log(f"✓ Batch: Segment #{segment.id} translated")
                        else:
                            failed_count += 1
                            self.log(f"✗ Batch: Segment #{segment.id} - empty translation")

                        segment_idx += 1
                        progress_bar.setValue(segment_idx)
                        remaining = total_segments - segment_idx
                        stats_label.setText(
                            f"Translated: {translated_count} | Failed: {failed_count} | Remaining: {remaining}"
                        )
                        QApplication.processEvents()

                except Exception as e:
                    for row_index, segment in batch_segments:
                        failed_count += 1
                        segment_idx += 1
                        self.log(f"✗ Batch: Segment #{segment.id} - {str(e)}")
                        progress_bar.setValue(segment_idx)
                        remaining = total_segments - segment_idx
                        stats_label.setText(
                            f"Translated: {translated_count} | Failed: {failed_count} | Remaining: {remaining}"
                        )
                        QApplication.processEvents()

            if translated_count > 0:
                self.project_modified = True
                self.update_window_title()

            progress_bar.setValue(total_segments)
            
            # Check if retry is needed (for LLM mode with retry option enabled)
            retry_pass = getattr(self, '_batch_retry_pass', 0)
            max_retries = 5
            retry_enabled = getattr(self, '_batch_retry_enabled', False)
            
            # Count segments that are still empty after this pass
            empty_segments_after = []
            if retry_enabled and translation_provider_type == 'LLM' and retry_pass < max_retries:
                for row_index, seg in segments_to_translate:
                    if not seg.target or not seg.target.strip():
                        empty_segments_after.append((row_index, seg))
            
            if empty_segments_after and retry_pass < max_retries:
                # There are still empty segments - offer to retry
                self._batch_retry_pass = retry_pass + 1
                retry_count = len(empty_segments_after)
                
                current_label.setText(
                    f"<b>Pass {retry_pass + 1} complete</b><br>"
                    f"Translated: {translated_count}<br>"
                    f"Still empty: {retry_count}<br><br>"
                    f"<b>🔄 Starting retry pass {retry_pass + 2} for {retry_count} segments...</b>"
                )
                QApplication.processEvents()
                
                self.log(f"🔄 Retry pass {retry_pass + 2}: {retry_count} segments still empty, retrying...")
                
                # Close current progress dialog
                progress.accept()
                
                # Recursively call translate_batch with only empty segments
                self.translate_batch(
                    segments_with_rows=empty_segments_after,
                    scope_description=f"empty segment(s) (retry {retry_pass + 2})"
                )
                return  # Don't show completion message yet
            
            # Clear retry counter and stored settings
            if hasattr(self, '_batch_retry_pass'):
                delattr(self, '_batch_retry_pass')
            if hasattr(self, '_batch_retry_enabled'):
                delattr(self, '_batch_retry_enabled')
            if hasattr(self, '_batch_provider_type'):
                delattr(self, '_batch_provider_type')
            if hasattr(self, '_batch_provider_name'):
                delattr(self, '_batch_provider_name')
            if hasattr(self, '_batch_model'):
                delattr(self, '_batch_model')
            
            # Show final completion message
            if retry_enabled and translation_provider_type == 'LLM':
                # Check final state
                final_empty = sum(1 for _, seg in segments_to_translate if not seg.target or not seg.target.strip())
                if final_empty > 0:
                    current_label.setText(
                        f"<b>✓ Batch translation complete!</b><br>"
                        f"Successfully translated: {translated_count}<br>"
                        f"Failed: {failed_count}<br>"
                        f"Still empty: {final_empty}<br><br>"
                        f"<i>Max retries ({max_retries}) reached.</i>"
                    )
                else:
                    current_label.setText(
                        f"<b>✓ Batch translation complete!</b><br>"
                        f"Successfully translated: {translated_count}<br>"
                        f"All segments now have translations! 🎉"
                    )
            else:
                current_label.setText(
                    f"<b>✓ Batch translation complete!</b><br>"
                    f"Successfully translated: {translated_count}<br>"
                    f"Failed: {failed_count}"
                )

            close_btn.setEnabled(True)

            self.log(f"✓ Batch translation complete: {translated_count} translated, {failed_count} failed")
            
            # Auto-resize all rows to fit new translations
            self.auto_resize_rows()
            
            # Update status bar progress stats
            self.update_progress_stats()

        except Exception as e:
            QMessageBox.critical(
                progress,
                "Batch Translation Error",
                f"Batch translation failed:\n\n{str(e)}"
            )
            self.log(f"✗ Batch translation error: {str(e)}")
            close_btn.setEnabled(True)

        progress.exec()
    
    def _fetch_llm_translation_async(self, source_text: str, segment, row_index: int):
        """Fetch LLM translation asynchronously and add to results panel"""
        if not source_text or not source_text.strip():
            return
        
        # Check if we have API keys and LLM settings configured
        api_keys = self.load_api_keys()
        if not api_keys:
            return  # No API keys configured, skip LLM

        settings = self.load_llm_settings()
        provider = settings.get('provider', 'openai')
        
        # Check if provider is enabled
        enabled_providers = self.load_provider_enabled_states()
        enabled_key = f"llm_{provider}"
        if not enabled_providers.get(enabled_key, True):
            return  # Provider is disabled, skip LLM

        # Get API key for provider
        api_key_value = api_keys.get(provider) or (api_keys.get('google') if provider == 'gemini' else None)
        if not api_key_value and provider not in ('ollama', 'custom_openai'):
            return  # No API key for selected provider
        # Resolve custom_openai profile for API key and base_url
        _custom_profile = None
        if provider == 'custom_openai':
            _custom_profile = self._get_active_custom_profile(settings)
            profile_key = (_custom_profile.get('api_key') or '').strip() if _custom_profile else ''
            api_key_value = profile_key or api_key_value or 'not-needed'

        # Get model based on provider (active profile wins for custom_openai)
        model = self._resolve_provider_model(settings, provider, 'gpt-5.5')

        # Get languages
        source_lang = getattr(self.current_project, 'source_lang', 'en')
        target_lang = getattr(self.current_project, 'target_lang', 'nl')

        # Use QTimer to run in background (non-blocking)
        def fetch_and_update():
            try:
                from modules.llm_clients import LLMClient
                from modules.translation_results_panel import TranslationMatch

                base_url = _custom_profile.get('endpoint') or None if _custom_profile else None
                http_proxy = self._get_proxy_url() if provider != 'gemini' else None
                client = LLMClient(
                    api_key=api_key_value,
                    provider=provider,
                    model=model,
                    base_url=base_url,
                    http_proxy=http_proxy
                )

                # Build full prompt using prompt manager
                custom_prompt = None
                try:
                    # Access parent through closure
                    parent = self
                    if hasattr(parent, 'prompt_manager_qt') and parent.prompt_manager_qt:
                        # Get glossary terms for AI injection
                        glossary_terms = parent.get_ai_inject_glossary_terms() if hasattr(parent, 'get_ai_inject_glossary_terms') else []
                        custom_prompt = parent.prompt_manager_qt.build_final_prompt(
                            source_text=source_text,
                            source_lang=source_lang,
                            target_lang=target_lang,
                            mode="single",
                            glossary_terms=glossary_terms,
                            target_text=getattr(segment, 'target', '') or ""
                        )
                except Exception as e:
                    self.log(f"⚠ Could not build LLM prompt from manager: {e}")
                
                translation = client.translate(
                    text=source_text,
                    source_lang=source_lang,
                    target_lang=target_lang,
                    custom_prompt=custom_prompt
                )
                
                if translation and hasattr(self, 'assistance_widget') and self.assistance_widget:
                    # Check if this segment is still selected
                    current_row = self.table.currentRow()
                    if current_row == row_index:
                        # Map provider to code
                        provider_code_map = {
                            'openai': 'GPT',
                            'claude': 'CL',
                            'gemini': 'GEM'
                        }
                        provider_code = provider_code_map.get(provider, 'LLM')
                        
                        # Create LLM match
                        llm_match = TranslationMatch(
                            source=source_text,
                            target=translation,
                            relevance=98,  # High relevance for LLM
                            metadata={
                                'model': model,
                                'provider': provider,
                                'timestamp': datetime.now().isoformat()
                            },
                            match_type='LLM',
                            compare_source=source_text,
                            provider_code=provider_code
                        )
                        
                        # Update all results panels with LLM match
                        if hasattr(self, 'results_panels'):
                            current_matches = {"LLM": [llm_match]}
                            for panel in self.results_panels:
                                try:
                                    # Get current matches and add LLM
                                    if hasattr(panel, 'matches_by_type'):
                                        existing_matches = panel.matches_by_type.copy()
                                        existing_matches["LLM"] = [llm_match]
                                        panel.set_matches(existing_matches)
                                    else:
                                        panel.set_matches(current_matches)
                                except Exception as e:
                                    self.log(f"Error updating panel with LLM: {e}")
                        self.log(f"✓ LLM translation added to results pane")
            except Exception as e:
                # Silently fail - don't interrupt workflow
                self.log(f"LLM translation failed (silent): {str(e)}")
        
        # Use QTimer to run async (small delay to avoid blocking)
        QTimer.singleShot(100, fetch_and_update)
    
    def _fetch_mt_translation_async(self, source_text: str, segment, row_index: int):
        """Fetch Machine Translation asynchronously and add to results panel"""
        if not source_text or not source_text.strip():
            return
        
        # Check if we have MT API keys configured
        api_keys = self.load_api_keys()
        if not api_keys:
            return  # No API keys configured, skip MT
        
        # Check enabled providers
        enabled_providers = self.load_provider_enabled_states()
        
        # Try MT providers in priority order (first available and enabled wins)
        # Priority: Google Translate > DeepL > Microsoft > Amazon > ModernMT > MyMemory
        mt_provider = None
        mt_api_key = None
        
        if enabled_providers.get('mt_google_translate', True) and api_keys.get('google_translate'):
            mt_provider = 'google_translate'
            mt_api_key = api_keys['google_translate']
        elif enabled_providers.get('mt_deepl', True) and api_keys.get('deepl'):
            mt_provider = 'deepl'
            mt_api_key = api_keys['deepl']
        elif enabled_providers.get('mt_microsoft', True) and (api_keys.get('microsoft_translate') or api_keys.get('azure_translate')):
            mt_provider = 'microsoft'
            mt_api_key = api_keys.get('microsoft_translate') or api_keys.get('azure_translate')
        elif enabled_providers.get('mt_amazon', True) and (api_keys.get('amazon_translate') or api_keys.get('aws_translate')):
            mt_provider = 'amazon'
            mt_api_key = api_keys.get('amazon_translate') or api_keys.get('aws_translate')
        elif enabled_providers.get('mt_modernmt', True) and api_keys.get('modernmt'):
            mt_provider = 'modernmt'
            mt_api_key = api_keys['modernmt']
        elif enabled_providers.get('mt_mymemory', True) and api_keys.get('mymemory'):
            mt_provider = 'mymemory'
            mt_api_key = api_keys.get('mymemory')  # Optional, MyMemory works without key
        # Note: MyMemory can work without a key, but we only use it if explicitly configured
        else:
            return  # No MT API keys available or all providers disabled
        
        # Get languages
        source_lang = getattr(self.current_project, 'source_lang', 'en')
        target_lang = getattr(self.current_project, 'target_lang', 'nl')
        
        # Use QTimer to run in background (non-blocking)
        def fetch_and_update():
            try:
                from modules.translation_results_panel import TranslationMatch
                
                # Call appropriate MT service
                if mt_provider == 'google_translate':
                    translation = self.call_google_translate(source_text, source_lang, target_lang, mt_api_key)
                    provider_name = 'Google Translate'
                elif mt_provider == 'deepl':
                    translation = self.call_deepl(source_text, source_lang, target_lang, mt_api_key)
                    provider_name = 'DeepL'
                elif mt_provider == 'microsoft':
                    translation = self.call_microsoft_translate(source_text, source_lang, target_lang, mt_api_key)
                    provider_name = 'Microsoft Translator'
                elif mt_provider == 'amazon':
                    # Amazon requires region, check for amazon_translate_region or use default
                    region = api_keys.get('amazon_translate_region', 'us-east-1')
                    translation = self.call_amazon_translate(source_text, source_lang, target_lang, mt_api_key, region)
                    provider_name = 'Amazon Translate'
                elif mt_provider == 'modernmt':
                    translation = self.call_modernmt(source_text, source_lang, target_lang, mt_api_key)
                    provider_name = 'ModernMT'
                elif mt_provider == 'mymemory':
                    translation = self.call_mymemory(source_text, source_lang, target_lang, mt_api_key)
                    provider_name = 'MyMemory'
                else:
                    return
                
                if translation and not translation.startswith('['):  # Skip error messages
                    if hasattr(self, 'assistance_widget') and self.assistance_widget:
                        # Check if this segment is still selected
                        current_row = self.table.currentRow()
                        if current_row == row_index:
                            # Map provider to code
                            provider_code_map = {
                                'Google Translate': 'GT',
                                'DeepL': 'DL',
                                'Microsoft Translator': 'MS',
                                'Amazon Translate': 'AT',
                                'ModernMT': 'MMT',
                                'MyMemory': 'MM'
                            }
                            provider_code = provider_code_map.get(provider_name, 'MT')
                            
                            # Create MT match
                            mt_match = TranslationMatch(
                                source=source_text,
                                target=translation,
                                relevance=95,  # High relevance for MT
                                metadata={
                                    'provider': provider_name,
                                    'timestamp': datetime.now().isoformat()
                                },
                                match_type='MT',
                                compare_source=source_text,
                                provider_code=provider_code
                            )
                            
                            # Update all results panels with MT match
                            if hasattr(self, 'results_panels'):
                                current_matches = {"MT": [mt_match]}
                                for panel in self.results_panels:
                                    try:
                                        # Get current matches and add MT
                                        if hasattr(panel, 'matches_by_type'):
                                            existing_matches = panel.matches_by_type.copy()
                                            existing_matches["MT"] = [mt_match]
                                            panel.set_matches(existing_matches)
                                        else:
                                            panel.set_matches(current_matches)
                                    except Exception as e:
                                        self.log(f"Error updating panel with MT: {e}")
                            self.log(f"✓ MT translation ({provider_name}) added to results pane")
            except Exception as e:
                # Silently fail - don't interrupt workflow
                self.log(f"MT translation failed (silent): {str(e)}")
        
        # Use QTimer to run async (small delay to avoid blocking, slightly after LLM)
        QTimer.singleShot(150, fetch_and_update)
    
    def call_google_translate(self, text: str, source_lang: str, target_lang: str, api_key: str = None) -> str:
        """Call Google Cloud Translation API using REST API"""
        try:
            import requests
            import html
            
            if not api_key:
                api_keys = self.load_api_keys()
                api_key = api_keys.get("google_translate")
            
            if not api_key:
                return "[Google Cloud Translation requires API key]"
            
            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
                'hebrew': 'he', 'thai': 'th', 'vietnamese': 'vi', 'indonesian': 'id',
                'malay': 'ms', 'hindi': 'hi', 'bengali': 'bn', 'tamil': 'ta',
            }
            
            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()
            
            src_code = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt_code = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            # Call REST API directly
            url = "https://translation.googleapis.com/language/translate/v2"
            params = {
                'key': api_key,
                'q': text,
                'source': src_code,
                'target': tgt_code,
                'format': 'text'
            }
            
            response = requests.post(url, params=params, timeout=10, proxies=self._get_proxy_dict())
            response.raise_for_status()

            result = response.json()
            translated_text = result['data']['translations'][0]['translatedText']
            # Unescape HTML entities like &quot; &amp; etc.
            return html.unescape(translated_text)

        except ImportError:
            return "[Google Translate requires: pip install requests]"
        except Exception as e:
            return f"[Google Translate error: {str(e)}]"
    
    def call_deepl(self, text: str, source_lang: str, target_lang: str, api_key: str = None) -> str:
        """Call DeepL API"""
        try:
            import deepl

            if not api_key:
                api_keys = self.load_api_keys()
                api_key = api_keys.get("deepl")

            if not api_key:
                return "[DeepL requires API key]"

            translator = deepl.Translator(api_key, proxy=self._get_proxy_dict())

            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
            }

            # Convert source language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            src_base = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            src_code = src_base.upper()

            # Convert target language - try name mapping first, then handle DeepL variants
            tgt_lower = target_lang.lower().strip()
            tgt_base = lang_name_to_code.get(tgt_lower, tgt_lower)
            tgt_upper = tgt_base.upper().replace('_', '-')

            # DeepL target language mapping - some require specific variants
            deepl_target_map = {
                # English variants (EN alone is deprecated)
                'EN': 'EN-US',      # Default to US English
                'EN-US': 'EN-US',
                'EN-GB': 'EN-GB',
                'EN-AU': 'EN-GB',   # Map Australian to British
                'EN-CA': 'EN-US',   # Map Canadian to US
                # Portuguese variants
                'PT': 'PT-PT',      # Default to European Portuguese
                'PT-PT': 'PT-PT',
                'PT-BR': 'PT-BR',
                # Chinese variants
                'ZH': 'ZH-HANS',    # Default to Simplified
                'ZH-CN': 'ZH-HANS',
                'ZH-TW': 'ZH-HANT',
                'ZH-HANS': 'ZH-HANS',
                'ZH-HANT': 'ZH-HANT',
            }

            # Check if full code matches first, then base code
            if tgt_upper in deepl_target_map:
                tgt_code = deepl_target_map[tgt_upper]
            else:
                # Extract base code and check
                base_code = tgt_upper.split('-')[0]
                if base_code in deepl_target_map:
                    tgt_code = deepl_target_map[base_code]
                else:
                    # Use base code as-is for other languages
                    tgt_code = base_code

            result = translator.translate_text(text, source_lang=src_code, target_lang=tgt_code)
            return result.text

        except ImportError:
            return "[DeepL requires: pip install deepl]"
        except Exception as e:
            return f"[DeepL error: {str(e)}]"
    
    def call_microsoft_translate(self, text: str, source_lang: str, target_lang: str, api_key: str = None, region: str = None) -> str:
        """Call Microsoft Azure Translator API"""
        try:
            import requests

            api_keys = self.load_api_keys()
            if not api_key:
                api_key = api_keys.get("microsoft_translate") or api_keys.get("azure_translate")
            if not region:
                region = api_keys.get("microsoft_translate_region") or api_keys.get("azure_region") or "global"

            if not api_key:
                return "[Microsoft Translator requires API key]"

            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
                'hebrew': 'he', 'thai': 'th', 'vietnamese': 'vi', 'indonesian': 'id',
            }

            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()

            src_code = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt_code = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            # Microsoft Translator API v3.0
            endpoint = f"https://api.cognitive.microsofttranslator.com/translate"
            params = {
                'api-version': '3.0',
                'from': src_code,
                'to': tgt_code
            }
            headers = {
                'Ocp-Apim-Subscription-Key': api_key,
                'Ocp-Apim-Subscription-Region': region,
                'Content-Type': 'application/json'
            }
            body = [{'text': text}]
            
            response = requests.post(endpoint, params=params, headers=headers, json=body, timeout=10, proxies=self._get_proxy_dict())
            response.raise_for_status()

            result = response.json()
            return result[0]['translations'][0]['text']

        except ImportError:
            return "[Microsoft Translator requires: pip install requests]"
        except Exception as e:
            return f"[Microsoft Translator error: {str(e)}]"
    
    def call_amazon_translate(self, text: str, source_lang: str, target_lang: str, api_key: str = None, region: str = 'us-east-1') -> str:
        """Call Amazon Translate API (AWS)"""
        try:
            import boto3
            from botocore.exceptions import ClientError
            
            api_keys = self.load_api_keys()
            if not api_key:
                api_key = api_keys.get("amazon_translate") or api_keys.get("aws_translate")
            
            # AWS also needs secret key
            secret_key = api_keys.get("amazon_translate_secret") or api_keys.get("aws_secret_key")
            
            if not api_key or not secret_key:
                return "[Amazon Translate requires API key and secret key]"
            
            # Get region from settings if not provided
            if not region or region == 'us-east-1':
                region = api_keys.get("amazon_translate_region", "us-east-1")
            
            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
            }
            
            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()
            
            src_code = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt_code = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            translate_client = boto3.client(
                'translate',
                aws_access_key_id=api_key,
                aws_secret_access_key=secret_key,
                region_name=region
            )
            
            result = translate_client.translate_text(
                Text=text,
                SourceLanguageCode=src_code,
                TargetLanguageCode=tgt_code
            )
            
            return result['TranslatedText']
            
        except ImportError:
            return "[Amazon Translate requires: pip install boto3]"
        except Exception as e:
            return f"[Amazon Translate error: {str(e)}]"
    
    def call_modernmt(self, text: str, source_lang: str, target_lang: str, api_key: str = None) -> str:
        """Call ModernMT API"""
        try:
            import requests

            api_keys = self.load_api_keys()
            if not api_key:
                api_key = api_keys.get("modernmt")

            if not api_key:
                return "[ModernMT requires API key]"

            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
            }

            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()

            src_code = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt_code = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            # ModernMT API endpoint
            url = "https://api.modernmt.com/translate"
            headers = {
                'MMT-ApiKey': api_key,
                'Content-Type': 'application/json'
            }
            data = {
                'q': text,
                'source': src_code,
                'target': tgt_code
            }
            
            response = requests.post(url, headers=headers, json=data, timeout=10, proxies=self._get_proxy_dict())
            response.raise_for_status()

            result = response.json()
            return result['data']['translation']

        except ImportError:
            return "[ModernMT requires: pip install requests]"
        except Exception as e:
            return f"[ModernMT error: {str(e)}]"
    
    def call_mymemory(self, text: str, source_lang: str, target_lang: str, api_key: str = None) -> str:
        """Call MyMemory Translation API (free tier available, simple REST API)"""
        try:
            import requests

            # MyMemory is free, but API key provides higher limits
            api_keys = self.load_api_keys()
            api_key = api_key or api_keys.get("mymemory")  # Optional, works without key

            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
                'hebrew': 'he', 'thai': 'th', 'vietnamese': 'vi', 'indonesian': 'id',
                'malay': 'ms', 'hindi': 'hi', 'bengali': 'bn', 'tamil': 'ta',
            }

            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()

            src_code = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt_code = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            # MyMemory API endpoint
            url = "https://api.mymemory.translated.net/get"
            params = {
                'q': text,
                'langpair': f"{src_code}|{tgt_code}"
            }
            if api_key:
                params['key'] = api_key
            
            response = requests.get(url, params=params, timeout=10, proxies=self._get_proxy_dict())
            response.raise_for_status()

            result = response.json()
            if result.get('responseStatus') == 200:
                return result['responseData']['translatedText']
            else:
                return f"[MyMemory error: {result.get('responseDetails', 'Unknown error')}]"
            
        except ImportError:
            return "[MyMemory requires: pip install requests]"
        except Exception as e:
            return f"[MyMemory error: {str(e)}]"
    
    def load_api_keys(self) -> Dict[str, str]:
        """Load API keys from unified settings"""
        api_keys = self._load_settings_section("api_keys")

        # Migrate legacy 'google' key to canonical 'gemini' key
        if api_keys.get('google') and not api_keys.get('gemini'):
            api_keys['gemini'] = api_keys['google']

        return api_keys

    def save_api_keys(self, api_keys: Dict[str, str]):
        """Save API keys to unified settings"""
        self._save_settings_section("api_keys", api_keys)

    def get_ai_inject_glossary_terms(self) -> list:
        """Get glossary terms from AI-inject-enabled termbases for the current project.

        Returns:
            List of term dictionaries with source_term, target_term, forbidden keys
        """
        if not hasattr(self, 'termbase_mgr') or not self.termbase_mgr:
            return []

        project_id = None
        if hasattr(self, 'current_project') and self.current_project:
            project_id = getattr(self.current_project, 'id', None)

        return self.termbase_mgr.get_ai_inject_terms(project_id)

    def show_image_extractor_from_tools(self):
        """Switch the user to the Image Context pane (now under AI in v1.9.395+)."""
        if not hasattr(self, 'main_tabs'):
            return
        # Find the AI main tab by label and select it.
        for i in range(self.main_tabs.count()):
            if "AI" in self.main_tabs.tabText(i):
                self.main_tabs.setCurrentIndex(i)
                break
        # Then select the Image Context sub-tab inside the AI tab.
        if hasattr(self, 'ai_subtabs'):
            for i in range(self.ai_subtabs.count()):
                if "Image Context" in self.ai_subtabs.tabText(i):
                    self.ai_subtabs.setCurrentIndex(i)
                    break
    
    def show_scratchpad(self):
        """Show Scratchpad dialog for private translator notes.
        
        Notes are saved with the project (.svproj) and never exported to CAT tools.
        """
        if not hasattr(self, 'current_project') or self.current_project is None:
            QMessageBox.warning(
                self,
                "No Project Open",
                "Please open or create a project first before using the Scratchpad."
            )
            return
        
        # Get current notes from project
        current_notes = getattr(self.current_project, 'scratchpad_notes', '') or ''
        
        dialog = ScratchpadDialog(self, current_notes)
        if dialog.exec():
            # Save the notes back to the project
            new_notes = dialog.get_notes()
            self.current_project.scratchpad_notes = new_notes
            
            # Mark project as modified so it will be saved
            self.current_project.modified = datetime.now().isoformat()
            
            # Update status bar / log
            if new_notes:
                note_preview = new_notes[:50] + "..." if len(new_notes) > 50 else new_notes
                self.log(f"📝 Scratchpad notes updated: {note_preview.replace(chr(10), ' ')}")
            else:
                self.log("📝 Scratchpad notes cleared")
    
    def show_theme_editor(self):
        """Show Theme Editor dialog"""
        dialog = ThemeEditorDialog(self, self.theme_manager)
        if dialog.exec():
            # Theme may have been changed, reapply
            self.theme_manager.apply_theme(QApplication.instance())
            # Refresh all theme-dependent UI elements
            self.refresh_theme_colors()

    def refresh_theme_colors(self):
        """Refresh all theme-dependent UI elements when theme changes"""
        theme = self.theme_manager.current_theme
        # Clear row color cache to force refresh with new theme colors
        # Remove all theme-specific caches
        for attr in list(vars(self).keys()):
            if attr.startswith('_row_color_cache_'):
                delattr(self, attr)
        
        # Reapply alternating row colors with new theme
        if hasattr(self, 'apply_alternating_row_colors'):
            self.apply_alternating_row_colors()

        # Update navigation arrow colors based on theme
        if hasattr(self, 'theme_aware_arrows'):
            is_dark = theme.name == "Dark"
            arrow_color = "#FFFFFF" if is_dark else "#333333"
            for arrow in self.theme_aware_arrows:
                if hasattr(arrow, 'set_color'):
                    arrow.set_color(arrow_color)

        # Refresh segment numbers color
        if hasattr(self, 'table') and self.table:
            # Determine segment number color based on theme
            is_dark_theme = theme.name == "Dark"
            segment_num_color = theme.text if is_dark_theme else "black"

            for row in range(self.table.rowCount()):
                # Keep UI responsive during large grid updates
                if row % 50 == 0:
                    QApplication.processEvents()
                id_item = self.table.item(row, 0)
                if id_item:
                    # Don't change currently highlighted row (orange background)
                    if id_item.background().color().name() != "#ffa500":
                        id_item.setForeground(QColor(segment_num_color))
        
        # Refresh TranslationResultsPanel
        if hasattr(self, 'translation_results_panel') and self.translation_results_panel:
            if hasattr(self.translation_results_panel, 'apply_theme'):
                self.translation_results_panel.apply_theme()
        
        # Refresh Compare Panel colors
        if hasattr(self, 'compare_panel_text_edits') and self.compare_panel_text_edits:
            try:
                self._refresh_compare_panel_theme()
            except Exception as e:
                print(f"Error refreshing Compare Panel theme: {e}")
                import traceback
                traceback.print_exc()
        
        # Refresh TermLensWidget
        if hasattr(self, 'termlens_widget') and self.termlens_widget:
            if hasattr(self.termlens_widget, 'apply_theme'):
                self.termlens_widget.apply_theme()

        # Also refresh Match Panel TermLens (right panel)
        if hasattr(self, 'termlens_widget_match') and self.termlens_widget_match:
            if hasattr(self.termlens_widget_match, 'apply_theme'):
                self.termlens_widget_match.apply_theme()

        # Refresh sidebar themes (Settings + Tools + Resources)
        self._update_settings_sidebar_theme()
        self._update_tools_sidebar_theme()
        self._update_resources_sidebar_theme()

    def show_file_progress_dialog(self):
        """Show file progress - redirects to Project Info dialog, File Progress tab."""
        self.show_project_info_dialog(initial_tab=1)
    
    def _add_overall_progress_section(self, layout):
        """Add overall progress section to a layout (for File Progress dialog)."""
        from PyQt6.QtWidgets import QGroupBox, QGridLayout, QLabel, QProgressBar
        
        progress_group = QGroupBox("Overall Progress")
        grid = QGridLayout(progress_group)
        
        if not self.current_project or not self.current_project.segments:
            grid.addWidget(QLabel("No segments in project"), 0, 0)
            layout.addWidget(progress_group)
            return
        
        segments = self.current_project.segments
        total_segs = len(segments)
        
        # Calculate stats
        total_words = sum(len(s.source.split()) for s in segments if s.source)
        translated_segs = sum(1 for s in segments if s.target and s.target.strip())
        translated_words = sum(len(s.source.split()) for s in segments if s.target and s.target.strip())
        
        confirmed_statuses = {'confirmed', 'proofread', 'approved'}
        confirmed_segs = sum(1 for s in segments if s.status in confirmed_statuses)
        
        trans_percent = (translated_segs / total_segs * 100) if total_segs > 0 else 0
        conf_percent = (confirmed_segs / total_segs * 100) if total_segs > 0 else 0
        word_percent = (translated_words / total_words * 100) if total_words > 0 else 0
        
        # Row 1: Segments
        grid.addWidget(QLabel("<b>Segments:</b>"), 0, 0)
        grid.addWidget(QLabel(f"{translated_segs} / {total_segs} translated ({trans_percent:.1f}%)"), 0, 1)
        
        seg_progress = QProgressBar()
        seg_progress.setValue(int(trans_percent))
        grid.addWidget(seg_progress, 0, 2)
        
        # Row 2: Words
        grid.addWidget(QLabel("<b>Words:</b>"), 1, 0)
        grid.addWidget(QLabel(f"{translated_words} / {total_words} ({word_percent:.1f}%)"), 1, 1)
        
        word_progress = QProgressBar()
        word_progress.setValue(int(word_percent))
        grid.addWidget(word_progress, 1, 2)
        
        # Row 3: Confirmed
        grid.addWidget(QLabel("<b>Confirmed:</b>"), 2, 0)
        grid.addWidget(QLabel(f"{confirmed_segs} / {total_segs} ({conf_percent:.1f}%)"), 2, 1)
        
        conf_progress = QProgressBar()
        conf_progress.setValue(int(conf_percent))
        conf_progress.setStyleSheet("QProgressBar::chunk { background-color: #4CAF50; }")
        grid.addWidget(conf_progress, 2, 2)
        
        layout.addWidget(progress_group)

    def get_themed_button_style(self, button_type: str, extra_styles: str = "") -> str:
        """
        Get a theme-aware button style.

        Args:
            button_type: Type of button - 'success', 'info', 'warning', 'danger', 'neutral', 'purple'
            extra_styles: Additional CSS styles to append (e.g., "padding: 10px;")

        Returns:
            CSS stylesheet string for the button
        """
        theme = self.theme_manager.current_theme

        # Map button type to theme color
        color_map = {
            'success': theme.button_success,
            'info': theme.button_info,
            'warning': theme.button_warning,
            'danger': theme.button_danger,
            'neutral': theme.button_neutral,
            'purple': theme.button_purple,
        }

        bg_color = color_map.get(button_type, theme.button)
        return f"background-color: {bg_color}; color: white; font-weight: bold; {extra_styles}"

    def get_themed_panel_style(self, panel_type: str, extra_styles: str = "") -> str:
        """
        Get a theme-aware panel/info box style.

        Args:
            panel_type: Type of panel - 'info', 'warning', 'neutral', 'preview', 'accent'
            extra_styles: Additional CSS styles to append

        Returns:
            CSS stylesheet string for the panel
        """
        theme = self.theme_manager.current_theme

        # Map panel type to theme color
        color_map = {
            'info': theme.panel_info,
            'warning': theme.panel_warning,
            'neutral': theme.panel_neutral,
            'preview': theme.panel_preview,
            'accent': theme.panel_accent,
        }

        bg_color = color_map.get(panel_type, theme.window_bg)
        # Use appropriate text color based on panel type
        text_color = theme.text if panel_type != 'warning' else theme.text

        return f"background-color: {bg_color}; color: {text_color}; {extra_styles}"

    def _schedule_mt_and_llm_matches(self, segment, termbase_matches=None):
        """Schedule MT and LLM matches with debouncing - only call APIs when user stops clicking"""
        try:
            # Cancel any previous MT/LLM requests
            if hasattr(self, '_mt_llm_timer'):
                self._mt_llm_timer.stop()
            
            # Store segment and termbase matches for delayed lookup
            self._pending_mt_llm_segment = segment
            self._pending_termbase_matches = termbase_matches or []
            
            # Start debounced timer - only call APIs after user stops navigating
            from PyQt6.QtCore import QTimer
            self._mt_llm_timer = QTimer()
            self._mt_llm_timer.setSingleShot(True)
            self._mt_llm_timer.timeout.connect(lambda: self._execute_mt_llm_lookup())
            self._mt_llm_timer.start(150)  # Wait 150ms of inactivity before external API calls
                
        except Exception as e:
            self.log(f"Error scheduling MT/LLM search: {e}")
    
    def _execute_mt_llm_lookup(self):
        """Execute the actual MT/LLM lookup after debounce delay"""
        try:
            if not hasattr(self, '_pending_mt_llm_segment'):
                return
                
            segment = self._pending_mt_llm_segment

            # Call the actual lookup method (now includes TM, MT, and LLM)
            with self._pcs("_search_mt_and_llm_matches (TM+MT+LLM lookup)"):
                self._search_mt_and_llm_matches(segment)
                
        except Exception as e:
            self.log(f"Error executing MT/LLM lookup: {e}")
            import traceback
            self.log(f"Delayed lookup error traceback: {traceback.format_exc()}")
    
    def _search_mt_and_llm_matches(self, segment):
        """Search for TM, MT and LLM matches - called only after debounce delay"""
        try:
            from modules.translation_results_panel import TranslationMatch

            # v1.9.182: Validate we're still on the same segment before displaying results
            # This prevents stale results from showing when user navigates quickly
            current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
            if current_row >= 0:
                id_item = self.table.item(current_row, 0)
                if id_item:
                    try:
                        current_segment_id = int(id_item.text())
                        if current_segment_id != segment.id:
                            # User has moved to a different segment - abort this lookup
                            return
                    except (ValueError, AttributeError):
                        pass

            # Get current project languages for all translation services
            source_lang = getattr(self.current_project, 'source_lang', None) if self.current_project else None
            target_lang = getattr(self.current_project, 'target_lang', None) if self.current_project else None
            
            # Convert language names to codes if needed
            source_lang_code = None
            target_lang_code = None
            if source_lang:
                source_lang_code = self._convert_language_to_code(source_lang)
            if target_lang:
                target_lang_code = self._convert_language_to_code(target_lang)
            
            # Initialize Compare Panel data
            self._compare_panel_tm_source = ''
            self._compare_panel_tm_target = ''
            self._compare_panel_tm_percent = 0
            self._compare_panel_mt = ''
            self._compare_panel_tm_matches = []  # Reset TM matches list for navigation
            self._compare_panel_mt_matches = []  # Reset MT matches list for navigation
            
            # Update Match Panel with current source immediately (before TM/MT lookup)
            self.set_compare_panel_matches(segment.id, segment.source, [], [])
            
            # Prepare matches dict - use the stored termbase matches from immediate display
            matches_dict = {
                "LLM": [],
                "NT": [],
                "MT": [],
                "TM": [],
                "Termbases": getattr(self, '_pending_termbase_matches', [])
            }
            
            # 🔥 THREADED TM SEARCH: Run expensive fuzzy matching off the main thread
            if self.enable_tm_matching and hasattr(self, 'tm_database') and self.tm_database:
                try:
                    # Get activated TM IDs for current project
                    tm_ids = None
                    if hasattr(self, 'tm_metadata_mgr') and self.tm_metadata_mgr and self.current_project:
                        project_id = self.current_project.id if hasattr(self.current_project, 'id') else None
                        if project_id:
                            tm_ids = self.tm_metadata_mgr.get_active_tm_ids(project_id)

                    # Skip TM search if no TMs are activated
                    if tm_ids is not None and isinstance(tm_ids, list) and len(tm_ids) == 0:
                        pass  # No TMs active, skip search
                    else:
                        # Strip outer structural tags if setting is enabled (for cleaner TM matching)
                        search_source = segment.source
                        if self.hide_outer_wrapping_tags:
                            search_source, _ = strip_outer_wrapping_tags(search_source)

                        # Cancel any previous TM search worker
                        if hasattr(self, '_tm_search_worker') and self._tm_search_worker is not None:
                            self._tm_search_worker.cancel()
                            self._tm_search_worker.wait(100)  # Brief wait, don't block UI

                        # Launch TM search in background thread
                        self._tm_search_worker = TMSearchWorker(
                            db_path=self.tm_database.db.db_path,
                            source_text=search_source,
                            segment_id=segment.id,
                            tm_ids=tm_ids,
                            source_lang=self.tm_database.source_lang,
                            target_lang=self.tm_database.target_lang,
                            fuzzy_threshold=self.tm_database.fuzzy_threshold,
                            max_matches=10,
                            tm_metadata=self.tm_database.tm_metadata
                        )
                        # Store segment reference for result handler
                        self._tm_search_pending_segment = segment
                        self._tm_search_worker.results_ready.connect(self._on_tm_search_results)
                        self._tm_search_worker.start()
                except Exception as e:
                    self.log(f"Error launching TM search worker: {e}")
            
            # Add MT and LLM matches progressively
            self._add_mt_and_llm_matches_progressive(segment, source_lang, target_lang, source_lang_code, target_lang_code)
                
        except Exception as e:
            self.log(f"Error in MT/LLM search: {e}")
    
    def _on_tm_search_results(self, segment_id: int, all_tm_matches: list):
        """Handle TM search results from background TMSearchWorker thread.

        This runs on the main thread (via signal) so it's safe to update UI.
        """
        try:
            from modules.translation_results_panel import TranslationMatch

            # Validate we're still on the same segment
            current_row = self.table.currentRow() if hasattr(self, 'table') and self.table else -1
            current_segment_id = None
            if current_row >= 0:
                id_item = self.table.item(current_row, 0)
                if id_item:
                    try:
                        current_segment_id = int(id_item.text())
                    except (ValueError, AttributeError):
                        pass

            if all_tm_matches:
                self.log(f"🔍 TM: Found {len(all_tm_matches)} matches for segment {segment_id}")

            # Convert to TranslationMatch objects
            tm_match_objects = []
            for match in all_tm_matches:
                # v1.10.51: reverse_match flows through from the db layer's
                # bidirectional fallback. Older code here tried to derive
                # direction from a "Reverse" substring in the TM name, but
                # that heuristic was unreliable; the explicit flag is correct.
                reverse_match = bool(match.get('reverse_match', False))
                match_obj = TranslationMatch(
                    source=match.get('source', ''),
                    target=match.get('target', ''),
                    relevance=match.get('match_pct', 0),
                    metadata={
                        'context': match.get('context', ''),
                        'tm_name': match.get('tm_name', ''),
                        'tm_id': match.get('tm_id', ''),
                        'timestamp': match.get('created_at', ''),
                        'direction': 'reverse' if reverse_match else 'primary',
                        # Explicit flag for the Match Panel chip renderer
                        # (md.get('reverse_match', False) in the renderer).
                        'reverse_match': reverse_match,
                    },
                    match_type='TM',
                    compare_source=match.get('source', ''),
                    provider_code='TM'
                )
                tm_match_objects.append(match_obj)

            # Cache results regardless of whether user moved
            if tm_match_objects:
                with self.translation_matches_cache_lock:
                    if segment_id in self.translation_matches_cache:
                        self.translation_matches_cache[segment_id]["TM"] = tm_match_objects
                    else:
                        self.translation_matches_cache[segment_id] = {
                            "LLM": [], "NT": [], "MT": [], "TM": tm_match_objects,
                            "Termbases": getattr(self, '_pending_termbase_matches', [])
                        }

            # If user moved to a different segment, don't update UI
            if current_segment_id is not None and current_segment_id != segment_id:
                return

            # Show TM matches in results panels
            if tm_match_objects:
                tm_only = {"TM": tm_match_objects}
                if hasattr(self, 'results_panels') and self.results_panels:
                    for panel in self.results_panels:
                        try:
                            panel.add_matches(tm_only)
                        except Exception as e:
                            self.log(f"Error adding TM matches: {e}")

                # Update Match Panel with TM matches
                tm_matches_for_panel = []
                for tm in tm_match_objects:
                    tm_name = tm.metadata.get('tm_name', 'TM') if tm.metadata else 'TM'
                    tm_id = tm.metadata.get('tm_id', '') if tm.metadata else ''
                    # v1.10.51: surface the bidirectional reverse-match flag
                    # so the Match Panel's metadata-line renderer can show
                    # its "⇄ reversed" chip. Previously the panel-bridge dict
                    # was built without this field, so the chip never showed
                    # even after the lower layers were patched.
                    rev = bool(tm.metadata.get('reverse_match', False)) if tm.metadata else False
                    tm_matches_for_panel.append({
                        'source': tm.compare_source or tm.source,
                        'target': tm.target,
                        'tm_name': tm_name,
                        'tm_id': tm_id,
                        'match_pct': int(tm.relevance),
                        'reverse_match': rev,
                    })
                self._compare_panel_tm_matches = tm_matches_for_panel
                self.set_compare_panel_matches(
                    segment_id,
                    self._tm_search_pending_segment.source if hasattr(self, '_tm_search_pending_segment') else '',
                    tm_matches=tm_matches_for_panel,
                    mt_matches=getattr(self, '_compare_panel_mt_matches', [])
                )

                # Auto-insert 100% TM match (if enabled)
                segment = getattr(self, '_tm_search_pending_segment', None)
                if segment and segment.id == segment_id and self.auto_insert_100_percent_matches:
                    target_empty = not segment.target or len(segment.target.strip()) == 0
                    if target_empty:
                        best_match = None
                        for tm_match in tm_match_objects:
                            if float(tm_match.relevance) >= 99.5:
                                best_match = tm_match
                                break
                        if best_match:
                            self._auto_insert_tm_match(segment, best_match.target, None)
                            self._play_sound_effect('tm_100_percent_match')

                # Play fuzzy match sound
                has_100_match = any(float(tm.relevance) >= 99.5 for tm in tm_match_objects)
                has_fuzzy_match = any(float(tm.relevance) < 99.5 and float(tm.relevance) >= 50 for tm in tm_match_objects)
                if has_fuzzy_match and not has_100_match:
                    self._play_sound_effect('tm_fuzzy_match')

        except Exception as e:
            self.log(f"Error handling TM search results: {e}")

    def _auto_insert_tm_match(self, segment, target_text, row=None):
        """
        Auto-insert a 100% TM match into the target field.
        Works for both grid and tab views.
        
        Args:
            segment: The segment object to update
            target_text: The translation text to insert
            row: The grid row number (if known), or None to search
        """
        try:
            self.log(f"🔧 Auto-insert: Starting for segment {segment.id} at row {row}, target='{target_text[:50]}...'")
            self.log(f"🔧 Auto-insert: BEFORE - segment.id={segment.id}, segment object ID={id(segment)}, old_target='{segment.target[:30] if segment.target else 'EMPTY'}'")
            
            # CRITICAL: Update segment data directly
            # The segment parameter IS a reference to the object in self.current_project.segments
            # No need to look it up - just modify it directly!
            segment.target = target_text
            segment.status = 'draft'  # Mark as draft (needs confirmation)
            self.project_modified = True
            self.log(f"🔧 Auto-insert: AFTER - segment.id={segment.id}, segment object ID={id(segment)}, new_target='{segment.target[:30] if segment.target else 'EMPTY'}'")
            self.log(f"🔧 Auto-insert: Updated segment.target, status=draft")
            
            # Update grid view if visible
            if hasattr(self, 'table') and self.table:
                # If row is provided, use it directly
                if row is not None and 0 <= row < self.table.rowCount():
                    self.log(f"🔧 Auto-insert: Using provided row {row}")
                    target_row = row
                else:
                    # Search for the row by segment ID (stored as text in column 0)
                    self.log(f"🔧 Auto-insert: Searching {self.table.rowCount()} rows for segment {segment.id}")
                    target_row = None
                    for r in range(self.table.rowCount()):
                        row_item = self.table.item(r, 0)
                        if row_item and row_item.text() == str(segment.id):
                            target_row = r
                            self.log(f"🔧 Auto-insert: Found segment at row {r}")
                            break
                
                if target_row is not None:
                    # Update target editor widget
                    target_widget = self.table.cellWidget(target_row, 3)
                    if target_widget:
                        self.log(f"🔧 Auto-insert: Got target widget, type={type(target_widget).__name__}")
                        if hasattr(target_widget, 'setPlainText'):
                            # CRITICAL: Do NOT block signals - we need textChanged to update segment.target
                            # The segment was already updated above (line ~13855), but the widget needs to trigger
                            # the handler so they stay in sync
                            target_widget.setPlainText(target_text)
                            self.log(f"🔧 Auto-insert: Set widget text to '{target_text[:50]}...'")
                        else:
                            self.log(f"⚠️ Auto-insert: Widget has no setPlainText method!")
                    else:
                        self.log(f"⚠️ Auto-insert: No target widget found at row {target_row}, col 3")
                    
                    # Update status icon
                    self.update_status_icon(target_row, 'draft')
                    self.log(f"🔧 Auto-insert: Updated status icon to 'draft'")
                    
                    # Auto-resize the row to fit the new content
                    self._auto_resize_single_row(target_row)
                else:
                    self.log(f"⚠️ Auto-insert: Could not find row for segment {segment.id}!")
            else:
                self.log(f"⚠️ Auto-insert: No table attribute or table is None")
            
            self.log(f"✅ Auto-inserted 100% TM match into segment {segment.id}")
            
        except Exception as e:
            self.log(f"⚠ Error auto-inserting TM match: {e}")
    
    def _add_mt_and_llm_matches_progressive(self, segment, source_lang, target_lang, source_lang_code, target_lang_code):
        """DEPRECATED: Translation Results panel has been removed.

        MT/LLM translations are now only available via QuickTrans
        (Ctrl+Alt+Q global hotkey). This function is kept as a stub
        for backwards compatibility.
        """
        pass  # Translation Results panel removed - use QuickTrans (Ctrl+Alt+Q) for MT/LLM

    def _REMOVED_add_mt_and_llm_matches_progressive(self, segment, source_lang, target_lang, source_lang_code, target_lang_code):
        """REMOVED - kept for reference only, will be deleted in future cleanup."""
        return  # Dead code below - keeping temporarily for reference

        from modules.translation_results_panel import TranslationMatch

        # MT matches (usually fast ~0.5s each)
        if self.enable_mt_matching:
            api_keys = self.load_api_keys()
            enabled_providers = self.load_provider_enabled_states()
            
            # 1. Google Translate
            if enabled_providers.get('mt_google_translate', True) and api_keys.get('google_translate'):
                try:
                    from modules.llm_clients import get_google_translation
                    
                    google_result = get_google_translation(
                        segment.source, 
                        source_lang_code or 'auto',
                        target_lang_code or 'en'
                    )
                    
                    if google_result and google_result.get('translation'):
                        match = TranslationMatch(
                            source=segment.source,
                            target=google_result['translation'],
                            relevance=95,
                            metadata=google_result.get('metadata', {}),
                            match_type="MT",
                            compare_source=segment.source,
                            provider_code='GT'
                        )
                        # Show MT match immediately (deduplicated by panel)
                        mt_dict = {"MT": [match]}
                        if hasattr(self, 'results_panels') and self.results_panels:
                            for panel in self.results_panels:
                                try:
                                    panel.add_matches(mt_dict)
                                except Exception as e:
                                    self.log(f"Error adding Google Translate match: {e}")
                        
                        # 🔄 Update Compare Panel with MT result
                        if hasattr(self, 'compare_panel_mt'):
                            # Add to MT matches list for navigation
                            if not hasattr(self, '_compare_panel_mt_matches'):
                                self._compare_panel_mt_matches = []
                            self._compare_panel_mt_matches.append({
                                'translation': google_result['translation'],
                                'provider': 'Google Translate'
                            })
                            self.set_compare_panel_matches(
                                segment.id,
                                segment.source,
                                tm_matches=getattr(self, '_compare_panel_tm_matches', []),
                                mt_matches=self._compare_panel_mt_matches
                            )
                except Exception as e:
                    self.log(f"⚠ Error getting Google Translate: {e}")
            
            # 2. DeepL
            if enabled_providers.get('mt_deepl', True) and api_keys.get('deepl'):
                try:
                    translation = self.call_deepl(segment.source, source_lang, target_lang, api_keys.get('deepl'))
                    if translation and not translation.startswith('['):  # Skip error messages
                        match = TranslationMatch(
                            source=segment.source,
                            target=translation,
                            relevance=94,
                            metadata={'provider': 'DeepL'},
                            match_type="MT",
                            compare_source=segment.source,
                            provider_code='DL'
                        )
                        mt_dict = {"MT": [match]}
                        if hasattr(self, 'results_panels') and self.results_panels:
                            for panel in self.results_panels:
                                try:
                                    panel.add_matches(mt_dict)
                                except Exception as e:
                                    self.log(f"Error adding DeepL match: {e}")
                        
                        # Add to Compare Panel MT matches
                        if hasattr(self, 'compare_panel_mt'):
                            if not hasattr(self, '_compare_panel_mt_matches'):
                                self._compare_panel_mt_matches = []
                            self._compare_panel_mt_matches.append({
                                'translation': translation,
                                'provider': 'DeepL'
                            })
                            self.set_compare_panel_matches(
                                segment.id,
                                segment.source,
                                tm_matches=getattr(self, '_compare_panel_tm_matches', []),
                                mt_matches=self._compare_panel_mt_matches
                            )
                except Exception as e:
                    self.log(f"⚠ Error getting DeepL: {e}")
            
            # 3. Amazon Translate
            if enabled_providers.get('mt_amazon', True) and api_keys.get('amazon_translate'):
                try:
                    region = api_keys.get('amazon_translate_region', 'us-east-1')
                    translation = self.call_amazon_translate(segment.source, source_lang, target_lang, api_keys.get('amazon_translate'), region)
                    if translation and not translation.startswith('['):  # Skip error messages
                        match = TranslationMatch(
                            source=segment.source,
                            target=translation,
                            relevance=93,
                            metadata={'provider': 'Amazon Translate'},
                            match_type="MT",
                            compare_source=segment.source,
                            provider_code='AT'
                        )
                        mt_dict = {"MT": [match]}
                        if hasattr(self, 'results_panels') and self.results_panels:
                            for panel in self.results_panels:
                                try:
                                    panel.add_matches(mt_dict)
                                except Exception as e:
                                    self.log(f"Error adding Amazon Translate match: {e}")
                        
                        # Add to Compare Panel MT matches
                        if hasattr(self, 'compare_panel_mt'):
                            if not hasattr(self, '_compare_panel_mt_matches'):
                                self._compare_panel_mt_matches = []
                            self._compare_panel_mt_matches.append({
                                'translation': translation,
                                'provider': 'Amazon Translate'
                            })
                            self.set_compare_panel_matches(
                                segment.id,
                                segment.source,
                                tm_matches=getattr(self, '_compare_panel_tm_matches', []),
                                mt_matches=self._compare_panel_mt_matches
                            )
                except Exception as e:
                    self.log(f"⚠ Error getting Amazon Translate: {e}")
            
            # 4. MyMemory (free, works without API key or with email as key)
            if enabled_providers.get('mt_mymemory', True):
                try:
                    mymemory_key = api_keys.get('mymemory', '')
                    # MyMemory works without a key, key is optional (email for more requests)
                    translation = self.call_mymemory(segment.source, source_lang, target_lang, mymemory_key if mymemory_key and mymemory_key != 'xxx' else None)
                    if translation and not translation.startswith('['):  # Skip error messages
                        match = TranslationMatch(
                            source=segment.source,
                            target=translation,
                            relevance=85,
                            metadata={'provider': 'MyMemory (Free)'},
                            match_type="MT",
                            compare_source=segment.source,
                            provider_code='MM'
                        )
                        mt_dict = {"MT": [match]}
                        if hasattr(self, 'results_panels') and self.results_panels:
                            for panel in self.results_panels:
                                try:
                                    panel.add_matches(mt_dict)
                                except Exception as e:
                                    self.log(f"Error adding MyMemory match: {e}")
                        
                        # Add to Compare Panel MT matches
                        if hasattr(self, 'compare_panel_mt'):
                            if not hasattr(self, '_compare_panel_mt_matches'):
                                self._compare_panel_mt_matches = []
                            self._compare_panel_mt_matches.append({
                                'translation': translation,
                                'provider': 'MyMemory'
                            })
                            self.set_compare_panel_matches(
                                segment.id,
                                segment.source,
                                tm_matches=getattr(self, '_compare_panel_tm_matches', []),
                                mt_matches=self._compare_panel_mt_matches
                            )
                except Exception as e:
                    self.log(f"⚠ Error getting MyMemory: {e}")
        
        # LLM matches (slower ~1-3s each)
        if self.enable_llm_matching:
            try:
                api_keys = self.load_api_keys()
                if not api_keys:
                    return
                
                settings = self.load_llm_settings()
                
                # OpenAI
                if 'openai' in api_keys:
                    try:
                        self.log("🧠 PROGRESSIVE LLM: Attempting OpenAI translation...")
                        from modules.llm_clients import LLMClient
                        
                        openai_model = settings.get('openai_model', 'gpt-5.5')
                        client = LLMClient(
                            api_key=api_keys['openai'],
                            provider='openai',
                            model=openai_model,
                            http_proxy=self._get_proxy_url()
                        )

                        translation = client.translate(
                            text=segment.source,
                            source_lang=source_lang_code or 'nl',
                            target_lang=target_lang_code or 'en'
                        )

                        if translation and translation.strip():
                            match = TranslationMatch(
                                source=segment.source,
                                target=translation.strip(),
                                relevance=88,
                                metadata={'model': openai_model, 'provider': 'OpenAI'},
                                match_type="LLM",
                                compare_source=segment.source,
                                provider_code='OA'
                            )
                            # Show OpenAI match immediately
                            llm_dict = {"LLM": [match]}
                            self.log(f"🧠 PROGRESSIVE LLM: Showing OpenAI match")
                            if hasattr(self, 'results_panels') and self.results_panels:
                                for panel in self.results_panels:
                                    try:
                                        panel.add_matches(llm_dict)
                                    except Exception as e:
                                        self.log(f"Error adding OpenAI match: {e}")
                    except Exception as e:
                        self.log(f"⚠ Error getting OpenAI translation: {e}")
                
                # Claude
                if 'anthropic' in api_keys:
                    try:
                        self.log("🧠 PROGRESSIVE LLM: Attempting Claude translation...")
                        from modules.llm_clients import LLMClient
                        
                        claude_model = settings.get('anthropic_model', 'claude-3-5-sonnet-20241022')
                        client = LLMClient(
                            api_key=api_keys['anthropic'],
                            provider='anthropic',
                            model=claude_model,
                            http_proxy=self._get_proxy_url()
                        )
                        
                        translation = client.translate(
                            text=segment.source,
                            source_lang=source_lang_code or 'nl',
                            target_lang=target_lang_code or 'en'
                        )
                        
                        if translation and translation.strip():
                            match = TranslationMatch(
                                source=segment.source,
                                target=translation.strip(),
                                relevance=88,
                                metadata={'model': claude_model, 'provider': 'Claude'},
                                match_type="LLM",
                                compare_source=segment.source,
                                provider_code='CL'
                            )
                            # Show Claude match immediately
                            llm_dict = {"LLM": [match]}
                            self.log(f"🧠 PROGRESSIVE LLM: Showing Claude match")
                            if hasattr(self, 'results_panels') and self.results_panels:
                                for panel in self.results_panels:
                                    try:
                                        panel.add_matches(llm_dict)
                                    except Exception as e:
                                        self.log(f"Error adding Claude match: {e}")
                    except Exception as e:
                        self.log(f"⚠ Error getting Claude translation: {e}")
                        
            except Exception as e:
                self.log(f"⚠ Error in LLM matching: {e}")
    
    def _add_mt_and_llm_matches(self, segment, matches_dict, source_lang, target_lang, source_lang_code, target_lang_code):
        """Add MT and LLM matches to the matches dictionary"""
        from modules.translation_results_panel import TranslationMatch
        
        # 🤖 DIRECT MT SEARCH: Machine Translation matches
        if self.enable_mt_matching:
            try:
                self.log(f"🤖 DIRECT MT SEARCH: Getting machine translation for '{segment.source[:50]}...'")
                
                # Use Google Translate via the wrapper function
                from modules.llm_clients import get_google_translation
                
                self.log(f"🤖 DIRECT MT SEARCH: Calling get_google_translation with source_lang='{source_lang_code or 'auto'}', target_lang='{target_lang_code or 'en'}'")
                
                google_result = get_google_translation(
                    segment.source, 
                    source_lang_code or 'auto',  # Auto-detect if no source lang 
                    target_lang_code or 'en'     # Default to English if no target lang
                )
                
                self.log(f"🤖 DIRECT MT SEARCH: Google Translate result: {google_result}")
                
                if google_result and google_result.get('translation'):
                    match = TranslationMatch(
                        source=segment.source,
                        target=google_result['translation'],
                        relevance=95,  # High confidence for MT
                        metadata=google_result.get('metadata', {}),
                        match_type="MT",
                        compare_source=segment.source,
                        provider_code='GT'
                    )
                    matches_dict["MT"].append(match)
                    self.log(f"✓ Added Google Translate match: {google_result['translation'][:50]}...")
                elif google_result and google_result.get('error'):
                    self.log(f"⚠ Google Translate error: {google_result.get('error')}")
                else:
                    self.log(f"⚠ Google Translate returned no translation")
                    
            except Exception as e:
                self.log(f"⚠ Error getting Google Translate: {e}")
                import traceback
                self.log(f"⚠ Traceback: {traceback.format_exc()}")
        
        # 🧠 DIRECT LLM SEARCH: Large Language Model matches  
        if self.enable_llm_matching:
            try:
                self.log(f"🧠 DIRECT LLM SEARCH: Getting LLM translations for '{segment.source[:50]}...'")
                self.log(f"🧠 DIRECT LLM SEARCH: LLM matching is enabled: {self.enable_llm_matching}")
                
                # Import LLM wrapper functions
                from modules.llm_clients import get_openai_translation, get_claude_translation
                self.log("🧠 DIRECT LLM SEARCH: Successfully imported LLM wrapper functions")
                
                # Use existing LLM infrastructure from Prompt Manager
                api_keys = self.load_api_keys()
                self.log(f"🧠 DIRECT LLM SEARCH: Loaded API keys: {list(api_keys.keys()) if api_keys else 'None'}")
                if not api_keys:
                    self.log("⚠ No API keys found for LLM translation")
                    return
                
                settings = self.load_llm_settings()
                
                # Check if we should use prompts for grid translations (configurable)
                use_prompts_in_grid = settings.get('use_prompts_in_grid', False)  # Default: simple translations
                self.log(f"🧠 LLM Grid Translations - Use Prompts: {use_prompts_in_grid}")
                
                # Get OpenAI translation using existing infrastructure
                if 'openai' in api_keys:
                    try:
                        self.log("🧠 DIRECT LLM SEARCH: Attempting OpenAI translation...")
                        from modules.llm_clients import LLMClient
                        self.log("🧠 DIRECT LLM SEARCH: LLMClient imported for OpenAI")
                        
                        openai_model = settings.get('openai_model', 'gpt-5.5')
                        self.log(f"🧠 DIRECT LLM SEARCH: Using OpenAI model: {openai_model}")
                        client = LLMClient(
                            api_key=api_keys['openai'],
                            provider='openai',
                            model=openai_model,
                            http_proxy=self._get_proxy_url()
                        )
                        self.log("🧠 DIRECT LLM SEARCH: LLMClient initialized for OpenAI")
                        
                        translation = client.translate(
                            text=segment.source,
                            source_lang=source_lang_code or 'nl',
                            target_lang=target_lang_code or 'en'
                        )
                        self.log(f"🧠 DIRECT LLM SEARCH: OpenAI translation result: {translation[:100] if translation else 'None'}...")
                        
                        if translation and translation.strip():
                            match = TranslationMatch(
                                source=segment.source,
                                target=translation.strip(),
                                relevance=88,  # Good confidence for LLM
                                metadata={'model': openai_model, 'provider': 'OpenAI'},
                                match_type="LLM",
                                compare_source=segment.source,
                                provider_code='OA'
                            )
                            matches_dict["LLM"].append(match)
                            self.log(f"✓ Added OpenAI match: {translation[:50]}...")
                        else:
                            self.log("⚠ OpenAI translation was empty or None")
                            
                    except Exception as e:
                        self.log(f"⚠ Error getting OpenAI translation: {e}")
                        import traceback
                        self.log(f"⚠ OpenAI error traceback: {traceback.format_exc()}")
                
                # Get Claude translation using existing infrastructure
                if 'claude' in api_keys:
                    try:
                        from modules.llm_clients import LLMClient
                        
                        claude_model = settings.get('claude_model', 'claude-sonnet-4-6')
                        client = LLMClient(
                            api_key=api_keys['claude'],
                            provider='claude',
                            model=claude_model,
                            http_proxy=self._get_proxy_url()
                        )
                        
                        translation = client.translate(
                            text=segment.source,
                            source_lang=source_lang_code or 'nl',
                            target_lang=target_lang_code or 'en'
                        )
                        
                        if translation and translation.strip():
                            match = TranslationMatch(
                                source=segment.source,
                                target=translation.strip(),
                                relevance=90,  # High confidence for Claude
                                metadata={'model': claude_model, 'provider': 'Claude'},
                                match_type="LLM",
                                compare_source=segment.source,
                                provider_code='CL'
                            )
                            matches_dict["LLM"].append(match)
                            self.log(f"✓ Added Claude match: {translation[:50]}...")
                            
                    except Exception as e:
                        self.log(f"⚠ Error getting Claude translation: {e}")
                
                # Get Gemini translation using existing infrastructure
                # Check for both 'gemini' and 'google' keys for backward compatibility
                gemini_api_key = api_keys.get('gemini') or api_keys.get('google')
                if gemini_api_key:
                    try:
                        self.log("🧠 DIRECT LLM SEARCH: Attempting Gemini translation...")
                        from modules.llm_clients import LLMClient
                        self.log("🧠 DIRECT LLM SEARCH: LLMClient imported for Gemini")
                        
                        gemini_model = settings.get('gemini_model', 'gemini-3.1-flash-lite')
                        self.log(f"🧠 DIRECT LLM SEARCH: Using Gemini model: {gemini_model}")
                        client = LLMClient(
                            api_key=gemini_api_key,
                            provider='gemini',
                            model=gemini_model
                        )
                        self.log("🧠 DIRECT LLM SEARCH: LLMClient initialized for Gemini")
                        
                        translation = client.translate(
                            text=segment.source,
                            source_lang=source_lang_code or 'nl',
                            target_lang=target_lang_code or 'en'
                        )
                        
                        if translation and translation.strip():
                            match = TranslationMatch(
                                source=segment.source,
                                target=translation.strip(),
                                relevance=85,  # Good confidence for Gemini
                                metadata={'model': gemini_model, 'provider': 'Gemini'},
                                match_type="LLM",
                                compare_source=segment.source,
                                provider_code='GM'
                            )
                            matches_dict["LLM"].append(match)
                            self.log(f"✓ Added Gemini match: {translation[:50]}...")
                            
                    except Exception as e:
                        self.log(f"⚠ Error getting Gemini translation: {e}")
                    
            except Exception as e:
                self.log(f"⚠ Error in LLM translation process: {e}")
    
    def _clean_provider_prefix(self, translation):
        """Remove provider prefixes like [OpenAI], [Claude], etc. from translations"""
        import re
        # Remove patterns like [OpenAI], [Claude], [ChatGPT], [Anthropic] at the start
        cleaned = re.sub(r'^\[(?:OpenAI|Claude|ChatGPT|Anthropic|GPT-\d+)\]\s*', '', translation.strip())
        return cleaned


# ============================================================================
# SUPERLOOKUP TAB
# ============================================================================


class _SearchTermHighlighter(QSyntaxHighlighter):
    """Highlights occurrences of a search term with a yellow background.

    Uses the same pattern as the main grid's TagHighlighter – operates on
    plain text in the QTextEdit, so native text selection and copy work
    perfectly without any HTML parsing overhead.
    """

    def __init__(self, document, search_term=""):
        super().__init__(document)
        self._search_term = search_term or ""
        from PyQt6.QtGui import QTextCharFormat, QColor
        self._format = QTextCharFormat()
        self._format.setBackground(QColor("#FFFF00"))  # Yellow
        self._format.setForeground(QColor("#000000"))  # Black

    def set_search_term(self, term):
        self._search_term = term or ""
        self.rehighlight()

    def highlightBlock(self, text):
        if not self._search_term:
            return
        # Case-insensitive search
        needle = self._search_term.lower()
        haystack = text.lower()
        start = 0
        while True:
            idx = haystack.find(needle, start)
            if idx < 0:
                break
            self.setFormat(idx, len(needle), self._format)
            start = idx + len(needle)


class _ReadOnlyHtmlCell(QTextEdit):
    """Read-only QTextEdit for Superlookup results table cells.

    Uses plain text + a QSyntaxHighlighter for search term highlighting –
    the same pattern the main Supervertaler grid uses. This gives us full
    native text selection, right-click copy, and mouse word selection,
    without the rendering issues that QLabel cell widgets have.
    """

    def __init__(self, text="", search_term="", parent=None, bold=False):
        super().__init__(parent)
        self.setReadOnly(True)
        self.setAcceptRichText(False)
        self.setPlainText(text)
        self.setFrameStyle(0)  # No border
        if bold:
            f = self.font()
            f.setBold(True)
            self.setFont(f)

        # Allow text selection but not tab-taking focus
        self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
        self.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
            | Qt.TextInteractionFlag.TextSelectableByKeyboard
        )

        # Make inactive selections stay visible (same as main grid)
        from PyQt6.QtGui import QPalette
        palette = self.palette()
        palette.setColor(QPalette.ColorGroup.Active,
                         QPalette.ColorRole.Highlight, QColor("#D0E7FF"))
        palette.setColor(QPalette.ColorGroup.Active,
                         QPalette.ColorRole.HighlightedText, QColor("black"))
        palette.setColor(QPalette.ColorGroup.Inactive,
                         QPalette.ColorRole.Highlight, QColor("#D0E7FF"))
        palette.setColor(QPalette.ColorGroup.Inactive,
                         QPalette.ColorRole.HighlightedText, QColor("black"))
        self.setPalette(palette)

        # Compact styling – no border, no scroll bars
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setStyleSheet("""
            QTextEdit {
                border: none; padding: 1px 4px; background: transparent;
            }
            QTextEdit::selection {
                background-color: #D0E7FF; color: black;
            }
        """)

        # Zero document margin for compact layout
        self.document().setDocumentMargin(1)

        # Apply search term highlighting
        if search_term:
            self._highlighter = _SearchTermHighlighter(self.document(), search_term)
        else:
            self._highlighter = None


class _SuperLookupSearchSignals(QObject):
    """Signals emitted by the SuperLookup background search worker.

    A separate QObject because QRunnable is not a QObject and so cannot have
    signals of its own.
    """
    tm_ready = pyqtSignal(list)        # list of TM result dicts
    termbase_ready = pyqtSignal(list)  # list of LookupResult
    error = pyqtSignal(str)
    finished = pyqtSignal()


class _SuperLookupSearchWorker(QRunnable):
    """Run TM concordance + termbase search off the Qt main thread.

    Both searches share a single thread-local sqlite3 connection obtained via
    DatabaseManager.get_reader_connection(). The connection is released at the
    end of run() so QThreadPool worker reuse doesn't accumulate connections.

    Cancellation: callers set self.cancel_event when a newer search begins.
    The flag is checked at coarse-grained checkpoints between phases — we
    don't try to interrupt a SQLite query mid-execution. If the flag is set
    after a phase completes, we just don't emit that phase's signal so the UI
    keeps showing whatever the newer worker produces.
    """

    def __init__(self, tab, text, search_direction, from_lang, to_lang,
                 selected_tm_ids, tm_search_disabled):
        super().__init__()
        self.setAutoDelete(False)  # we manage lifetime via tab._active_search_worker
        self.tab = tab
        self.text = text
        self.search_direction = search_direction
        self.from_lang = from_lang
        self.to_lang = to_lang
        self.selected_tm_ids = selected_tm_ids
        self.tm_search_disabled = tm_search_disabled
        self.signals = _SuperLookupSearchSignals()
        self.cancel_event = threading.Event()

    def cancel(self):
        self.cancel_event.set()

    @pyqtSlot()
    def run(self):
        db_manager = self.tab.db_manager
        conn = None
        try:
            if db_manager is None:
                return
            if self.cancel_event.is_set():
                return

            try:
                conn = db_manager.get_reader_connection()
            except Exception as e:
                print(f"[SuperLookupWorker] Failed to open reader connection: {e}")
                self.signals.error.emit(str(e))
                return

            # --- TM search ---
            tm_results = []
            if not self.tm_search_disabled and self.tab.tm_database and self.tab.engine:
                try:
                    tm_results = self.tab.engine.search_tm(
                        self.text,
                        direction=self.search_direction,
                        source_lang=self.from_lang,
                        target_lang=self.to_lang,
                        connection=conn,
                    )
                except Exception as e:
                    print(f"[SuperLookupWorker] TM search error: {e}")

            if self.cancel_event.is_set():
                return
            self.signals.tm_ready.emit(tm_results)

            # --- Termbase search ---
            tb_results = []
            try:
                tb_results = self.tab.search_termbases(
                    self.text,
                    source_lang=self.from_lang,
                    target_lang=self.to_lang,
                    connection=conn,
                )
            except Exception as e:
                print(f"[SuperLookupWorker] Termbase search error: {e}")

            if self.cancel_event.is_set():
                return
            self.signals.termbase_ready.emit(tb_results)
        finally:
            try:
                if conn is not None and db_manager is not None:
                    db_manager.close_reader_connection()
            except Exception:
                pass
            self.signals.finished.emit()


class SuperlookupTab(QWidget):
    """
    Superlookup - System-wide translation lookup
    Works anywhere on your computer: in CAT tools, browsers, Word, any text box
    """
    
    def __init__(self, parent=None, user_data_path=None):
        super().__init__(parent)
        self.main_window = parent  # Store reference to main window for database access
        self.user_data_path = user_data_path  # Store user data path for web cache
        # The `is_sidekick` flag was removed in v1.10.10 along with the
        # Sidekick window itself. It used to gate mounting of the
        # QuickTrans settings widget as a sub-sub-tab inside Sidekick's
        # SuperLookup pane; QuickTrans settings now live directly in
        # Workbench Settings → ⚡ QuickTrans.

        print("[Superlookup] SuperlookupTab.__init__ called")
        
        # Get theme manager from main window (try parent first, then parent's parent for dialogs)
        self.theme_manager = getattr(parent, 'theme_manager', None)
        if self.theme_manager is None and parent is not None:
            # Try parent's parent (for when parent is a dialog window)
            parent_parent = getattr(parent, 'parent', lambda: None)()
            if parent_parent:
                self.theme_manager = getattr(parent_parent, 'theme_manager', None)
        print(f"[Superlookup] theme_manager: {self.theme_manager is not None}")
        
        # Import lookup engine
        try:
            from modules.superlookup import SuperlookupEngine, LookupResult
            self.SuperlookupEngine = SuperlookupEngine
            self.LookupResult = LookupResult
            print("[Superlookup] Successfully imported SuperlookupEngine")
        except ImportError as e:
            print(f"[Superlookup] IMPORT ERROR: {e}")
            QMessageBox.critical(
                self,
                "Missing Module",
                "Could not import superlookup module.\nPlease ensure modules/superlookup.py exists."
            )
            self.SuperlookupEngine = None
            self.LookupResult = None
            # Don't return - still set up hotkey even if engine import fails
        
        # Initialize engine
        self.engine = None
        self.tm_database = None
        self.termbase_mgr = None
        self.db_manager = None
        self.hotkey_registered = False
        
        # Resource selection tracking (which resources to search)
        self.enabled_tms = []  # List of TM IDs to search
        self.enabled_termbases = []  # List of termbase IDs to search
        self.search_tm_enabled = True  # Search TMs by default
        self.search_termbase_enabled = True  # Search termbases by default
        self.search_mt_enabled = False  # MT not implemented yet
        self.search_web_enabled = False  # Web resources not implemented yet
        
        # Initialize checkbox lists (even if Settings tab is removed)
        self.tm_checkboxes = []
        self.tb_checkboxes = []
        
        # Track if languages have been populated
        self._languages_populated = False
        
        # Initialize search history
        self._init_search_history()
        
        # UI setup
        self.init_ui()
        
        # Register global hotkey
        self.register_global_hotkey()
    
    def keyPressEvent(self, event):
        """Handle key presses - Escape returns to Grid"""
        if event.key() == Qt.Key.Key_Escape:
            # Navigate back to Grid (tab index 0)
            if self.main_window and hasattr(self.main_window, 'main_tabs'):
                self.main_window.main_tabs.setCurrentIndex(0)  # Grid
                # Focus the target cell if a segment is selected
                if hasattr(self.main_window, 'table') and self.main_window.table:
                    current_row = self.main_window.table.currentRow()
                    if current_row >= 0:
                        target_widget = self.main_window.table.cellWidget(current_row, 3)
                        if target_widget:
                            target_widget.setFocus()
            event.accept()
        else:
            super().keyPressEvent(event)
    
    def showEvent(self, event):
        """Called when the widget becomes visible - populate languages on first show"""
        super().showEvent(event)
        if not self._languages_populated:
            # Use a short delay to ensure main window databases are ready
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(100, self._delayed_language_population)
    
    def _delayed_language_population(self):
        """Populate language dropdowns after a short delay"""
        if self._languages_populated:
            return

        # Get database connections from main window
        if self.main_window:
            if hasattr(self.main_window, 'db_manager') and self.main_window.db_manager:
                self.db_manager = self.main_window.db_manager
            if hasattr(self.main_window, 'termbase_mgr') and self.main_window.termbase_mgr:
                self.termbase_mgr = self.main_window.termbase_mgr

        # Populate if we have database access
        if self.db_manager or self.termbase_mgr:
            self.populate_language_dropdowns()
            # Also populate the Settings → Translation Memories / Termbases
            # lists so they're not empty the first time the user opens them.
            # Previously these only populated when the user clicked "Refresh
            # List" or (in the main Workbench) switched to the Settings tab,
            # which confused users into thinking no resources were being
            # searched even though the fallback logic (empty selection =
            # search all) meant everything WAS being searched.
            try:
                self.refresh_tm_list()
            except Exception as e:
                print(f"[Superlookup] refresh_tm_list failed on first show: {e}")
            try:
                self.refresh_termbase_list()
            except Exception as e:
                print(f"[Superlookup] refresh_termbase_list failed on first show: {e}")
            self._languages_populated = True
            print("[Superlookup] Languages + resource lists populated on first show")
    
    def init_ui(self):
        """Initialize the UI"""
        layout = QVBoxLayout(self)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(5)  # Reduced from 10 to 5 for consistency
        
        # Header and description – shown only when embedded in the main
        # window (hidden when inside the floating assistant, where the tab
        # label already identifies the feature and vertical space is precious).
        self._header_label = QLabel("🔍 SuperLookup")
        self._header_label.setStyleSheet("font-size: 11pt; font-weight: bold; color: #1976D2;")
        layout.addWidget(self._header_label, 0)

        if os.name == 'nt':
            description_text = (
                f"Press {format_shortcut_for_display('Ctrl+Alt+L')} or paste text to search your TMs and Termbases."
            )
        elif IS_MACOS:
            description_text = (
                f"Press {format_shortcut_for_display('Meta+Ctrl+L')} or paste text to search your TMs and Termbases.\n"
                "Requires Accessibility permission in System Settings → Privacy & Security."
            )
        else:
            description_text = (
                f"Press {format_shortcut_for_display('Ctrl+Alt+L')} or paste text to search your TMs and Termbases.\n"
                "Requires pynput installed (pip install pynput)."
            )

        self._description_label = QLabel(description_text)
        self._description_label.setWordWrap(True)
        self._description_label.setStyleSheet("color: #666; padding: 3px 5px; background-color: #E3F2FD; border-radius: 3px;")
        layout.addWidget(self._description_label, 0)

        # Search input area - compact single-line layout with search box and controls
        search_row = QHBoxLayout()
        
        # Search label and input
        search_label = QLabel("🔍")
        search_label.setStyleSheet("font-size: 12pt;")
        search_row.addWidget(search_label)
        
        # Use HistoryComboBox for search with history dropdown
        self.source_text = HistoryComboBox()
        self.source_text.setEditable(True)
        self.source_text.lineEdit().setPlaceholderText("Enter search term or paste text...")
        self.source_text.setMinimumHeight(30)
        self.source_text.setMaximumHeight(35)
        self.source_text.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        # Load history into dropdown
        if hasattr(self, 'search_history') and self.search_history:
            self.source_text.set_history(self.search_history)
        # Handle Enter key to trigger search
        self.source_text.lineEdit().returnPressed.connect(self.perform_lookup)
        search_row.addWidget(self.source_text, stretch=1)
        
        # Search button
        self.search_btn = QPushButton("🔍 Search")
        self.search_btn.setStyleSheet("font-weight: bold; background-color: #2196F3; color: white; padding: 6px 12px; outline: none;")
        self.search_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.search_btn.clicked.connect(self.perform_lookup)
        search_row.addWidget(self.search_btn)
        
        # Clear button
        clear_btn = QPushButton("🗑️ Clear")
        clear_btn.setStyleSheet("padding: 6px 8px;")
        clear_btn.clicked.connect(self.clear_all)
        search_row.addWidget(clear_btn)

        # Language filters live on the same row as the search controls
        # (to the right of Search / Clear) to save vertical space. The
        # search box keeps stretch=1, so it absorbs the extra width and
        # pushes the From/To controls to the right edge of the row.
        from_label = QLabel("From:")
        from_label.setStyleSheet("font-weight: bold; font-size: 9pt;")
        search_row.addWidget(from_label)

        self.lang_from_combo = QComboBox()
        self.lang_from_combo.setMinimumWidth(90)
        self.lang_from_combo.setToolTip("Filter by source language (leave as 'Any' for all)")
        self.lang_from_combo.currentIndexChanged.connect(self._on_language_changed)
        search_row.addWidget(self.lang_from_combo)

        # Swap button
        swap_btn = QPushButton("↔")
        swap_btn.setFixedWidth(28)
        swap_btn.setToolTip("Swap source and target languages")
        swap_btn.clicked.connect(self.swap_language_filters)
        search_row.addWidget(swap_btn)

        to_label = QLabel("To:")
        to_label.setStyleSheet("font-weight: bold; font-size: 9pt;")
        search_row.addWidget(to_label)

        self.lang_to_combo = QComboBox()
        self.lang_to_combo.setMinimumWidth(90)
        self.lang_to_combo.setToolTip("Filter by target language (leave as 'Any' for all)")
        self.lang_to_combo.currentIndexChanged.connect(self._on_language_changed)
        search_row.addWidget(self.lang_to_combo)

        layout.addLayout(search_row)
        
        # Initialize language dropdowns with "Any" option
        self.lang_from_combo.addItem("Any", None)
        self.lang_to_combo.addItem("Any", None)
        
        # Results area (with tabs for TM, termbase, MT)
        self.results_tabs = QTabWidget()
        self.results_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.results_tabs.tabBar().setDrawBase(False)
        self.results_tabs.tabBar().setExpanding(False)
        # Force tabs to fit their full text. Without this macOS clips the
        # SuperLookup sub-tabs ("QuickTrans" → "QuickT…", "Web Resources"
        # → "Web Reso…", etc.) even when there's plenty of horizontal
        # space — Qt's default ElideRight is too aggressive on Mac.
        self.results_tabs.tabBar().setElideMode(Qt.TextElideMode.ElideNone)
        self.results_tabs.tabBar().setUsesScrollButtons(False)
        self.results_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
        
        # TM Results tab
        tm_tab = self.create_tm_results_tab()
        self.results_tabs.addTab(tm_tab, "📖 TMs")
        
        # Termbase Results tab
        termbase_tab = self.create_termbase_results_tab()
        self.results_tabs.addTab(termbase_tab, "📚 Termbases")
        
        # MT tab removed – handled by QuickTrans.

        # Web Resources tab
        web_tab = self.create_web_resources_tab()
        self.results_tabs.addTab(web_tab, "🌐 Web Resources")
        
        # Settings tab
        settings_tab = self.create_settings_tab()
        self.results_tabs.addTab(settings_tab, "⚙️ SuperLookup Settings")
        
        # Connect tab change for Settings refresh
        self.results_tabs.currentChanged.connect(self.on_results_tab_changed)
        
        layout.addWidget(self.results_tabs, stretch=1)
        
        # Status bar
        self.status_label = QLabel("Ready. Select a mode and capture text to begin.")
        self.status_label.setStyleSheet("padding: 5px; border-radius: 3px;")
        layout.addWidget(self.status_label, 0)  # 0 = no stretch, stays compact
    
    def create_tm_results_tab(self):
        """Create the TM results tab with horizontal/vertical view toggle"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        
        # View mode toggle at the top
        view_toggle_layout = QHBoxLayout()
        view_toggle_layout.addWidget(QLabel("View:"))
        
        self.tm_view_horizontal_radio = CheckmarkRadioButton("Horizontal (Table)")
        self.tm_view_horizontal_radio.setChecked(True)  # Default to horizontal
        self.tm_view_horizontal_radio.setToolTip("Source and Target side-by-side in columns")
        self.tm_view_horizontal_radio.toggled.connect(self.toggle_tm_view_mode)
        view_toggle_layout.addWidget(self.tm_view_horizontal_radio)
        
        self.tm_view_vertical_radio = CheckmarkRadioButton("Vertical (List)")
        self.tm_view_vertical_radio.setToolTip("Source above Target, like traditional concordance")
        view_toggle_layout.addWidget(self.tm_view_vertical_radio)
        
        view_toggle_layout.addStretch()
        layout.addLayout(view_toggle_layout)
        
        # Stacked widget to hold both views
        self.tm_view_stack = QStackedWidget()
        
        # === Horizontal View (Table) ===
        horizontal_widget = QWidget()
        horizontal_layout = QVBoxLayout(horizontal_widget)
        horizontal_layout.setContentsMargins(0, 0, 0, 0)
        
        self.tm_results_table = QTableWidget()
        self.tm_results_table.setColumnCount(3)
        self.tm_results_table.setHorizontalHeaderLabels(["Source", "Target", "TM"])
        # Make column headers bold (body cells are rendered non-bold by _ReadOnlyHtmlCell).
        # Use both setFont and a stylesheet so bold is honoured on every Qt style.
        _tm_hdr_font = self.tm_results_table.horizontalHeader().font()
        _tm_hdr_font.setBold(True)
        self.tm_results_table.horizontalHeader().setFont(_tm_hdr_font)
        self.tm_results_table.setStyleSheet(
            "QHeaderView::section { font-weight: bold; }"
        )
        self.tm_results_table.horizontalHeader().setStretchLastSection(False)
        self.tm_results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        self.tm_results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        self.tm_results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
        self.tm_results_table.setColumnWidth(2, 150)  # TM name column
        self.tm_results_table.verticalHeader().setVisible(False)  # Hide row numbers
        self.tm_results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.tm_results_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        self.tm_results_table.doubleClicked.connect(self.on_tm_result_double_click)
        self.tm_results_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tm_results_table.customContextMenuRequested.connect(self._tm_table_context_menu)
        horizontal_layout.addWidget(self.tm_results_table)
        
        self.tm_view_stack.addWidget(horizontal_widget)
        
        # === Vertical View (List - like Concordance dialog) ===
        vertical_widget = QWidget()
        vertical_layout = QVBoxLayout(vertical_widget)
        vertical_layout.setContentsMargins(0, 0, 0, 0)
        
        self.tm_results_vertical = QTextEdit()
        self.tm_results_vertical.setReadOnly(True)
        self.tm_results_vertical.setFont(QFont("Segoe UI", 10))
        self.tm_results_vertical.setStyleSheet("border: 1px solid #ddd; border-radius: 4px;")
        vertical_layout.addWidget(self.tm_results_vertical)
        
        self.tm_view_stack.addWidget(vertical_widget)
        
        layout.addWidget(self.tm_view_stack)
        
        # Action buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        copy_btn = QPushButton("📋 Copy Target")
        copy_btn.clicked.connect(self.copy_selected_tm_target)
        button_layout.addWidget(copy_btn)
        
        insert_btn = QPushButton("📥 Insert Target")
        insert_btn.setToolTip(f"Insert selected translation ({format_shortcut_for_display('Ctrl+V')})")
        insert_btn.clicked.connect(self.insert_selected_tm_target)
        button_layout.addWidget(insert_btn)
        
        layout.addLayout(button_layout)
        
        return tab
    
    def create_termbase_results_tab(self):
        """Create the termbase results tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        
        # Results table with expanded columns
        self.termbase_results_table = QTableWidget()
        self.termbase_results_table.setColumnCount(5)
        self.termbase_results_table.setHorizontalHeaderLabels(["Source", "Target", "Termbase", "Domain", "Notes"])
        # Make all columns resizable (Interactive) with stretch for Source/Target
        header = self.termbase_results_table.horizontalHeader()
        # Make column headers bold (body cells are rendered non-bold by _ReadOnlyHtmlCell).
        # Use both setFont and a stylesheet so bold is honoured on every Qt style.
        _tb_hdr_font = header.font()
        _tb_hdr_font.setBold(True)
        header.setFont(_tb_hdr_font)
        self.termbase_results_table.setStyleSheet(
            "QHeaderView::section { font-weight: bold; }"
        )
        header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)  # All columns resizable
        header.setStretchLastSection(False)  # Don't auto-stretch last column
        # Set initial column widths
        self.termbase_results_table.setColumnWidth(0, 200)  # Source
        self.termbase_results_table.setColumnWidth(1, 200)  # Target
        self.termbase_results_table.setColumnWidth(2, 100)  # Termbase column
        self.termbase_results_table.setColumnWidth(3, 80)   # Domain column
        self.termbase_results_table.setColumnWidth(4, 100)  # Notes column
        self.termbase_results_table.verticalHeader().setVisible(False)  # Hide row numbers
        self.termbase_results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.termbase_results_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        self.termbase_results_table.doubleClicked.connect(lambda: self.copy_selected_termbase_target())
        
        # Right-click context menu for termbase results
        self.termbase_results_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.termbase_results_table.customContextMenuRequested.connect(self._show_termbase_result_context_menu)
        
        layout.addWidget(self.termbase_results_table)
        
        # Action buttons
        button_layout = QHBoxLayout()
        
        add_term_btn = QPushButton("➕ Add to Termbase")
        add_term_btn.setToolTip("Add source text and selected translation as new term")
        add_term_btn.setStyleSheet("background-color: #4CAF50; color: white; padding: 5px; border: none; outline: none;")
        add_term_btn.clicked.connect(self.add_to_termbase)
        button_layout.addWidget(add_term_btn)
        
        button_layout.addStretch()
        
        copy_target_btn = QPushButton("📋 Copy Translation")
        copy_target_btn.clicked.connect(self.copy_selected_termbase_target)
        button_layout.addWidget(copy_target_btn)
        
        layout.addLayout(button_layout)
        
        # Info label
        info = QLabel("💡 Tip: Double-click a term to copy translation to clipboard")
        info.setStyleSheet("color: #666; font-size: 9pt; padding: 5px;")
        layout.addWidget(info)
        
        return tab
    
    def search_supermemory(self, query: str):
        """Search Supermemory for semantic matches. Returns count of results.
        
        NOTE: Supermemory was removed in v1.9.105. This method now returns 0
        to maintain backward compatibility with existing code.
        """
        # Supermemory functionality removed - return 0 to indicate no results
        return 0
    
    def create_mt_results_tab(self):
        """Create the MT results tab with provider status and results"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(5, 5, 5, 5)
        
        # === Provider Status Summary ===
        status_frame = QFrame()
        status_frame.setFrameStyle(QFrame.Shape.StyledPanel)
        status_frame.setStyleSheet("QFrame { background-color: #f5f5f5; border-radius: 4px; padding: 5px; }")
        status_layout = QVBoxLayout(status_frame)
        status_layout.setContentsMargins(10, 10, 10, 10)
        
        # Header row with title and settings link
        header_row = QHBoxLayout()
        
        status_title = QLabel("🤖 MT Providers")
        status_title.setStyleSheet("font-weight: bold; font-size: 11pt;")
        header_row.addWidget(status_title)
        
        header_row.addStretch()
        
        # Link to Settings
        settings_link = QPushButton("⚙️ Configure in Settings")
        settings_link.setStyleSheet("""
            QPushButton {
                background: transparent;
                color: #1976D2;
                border: none;
                font-size: 10pt;
                text-decoration: underline;
            }
            QPushButton:hover {
                color: #1565C0;
            }
        """)
        settings_link.setCursor(Qt.CursorShape.PointingHandCursor)
        settings_link.clicked.connect(self._open_mt_settings)
        header_row.addWidget(settings_link)
        
        status_layout.addLayout(header_row)
        
        # Provider status label (shows which are active)
        self.mt_provider_status_label = QLabel()
        self.mt_provider_status_label.setWordWrap(True)
        self.mt_provider_status_label.setStyleSheet("color: #666; font-size: 9pt; padding: 5px 0;")
        status_layout.addWidget(self.mt_provider_status_label)
        
        # Update the status display
        self._update_mt_provider_status()
        
        layout.addWidget(status_frame)
        
        # === MT Results Section ===
        results_group = QGroupBox("📝 Translation Results")
        results_layout = QVBoxLayout(results_group)
        
        # Results table
        self.mt_results_table = QTableWidget()
        self.mt_results_table.setColumnCount(3)
        self.mt_results_table.setHorizontalHeaderLabels(["Provider", "Translation", ""])
        self.mt_results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        self.mt_results_table.setColumnWidth(0, 120)
        self.mt_results_table.setColumnWidth(2, 60)
        self.mt_results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
        self.mt_results_table.verticalHeader().setVisible(False)
        self.mt_results_table.doubleClicked.connect(self.on_mt_result_double_click)
        
        results_layout.addWidget(self.mt_results_table)
        
        # Status
        self.mt_status_label = QLabel("Enter text and click Search to get MT translations")
        self.mt_status_label.setStyleSheet("color: #666; font-style: italic; padding: 5px;")
        results_layout.addWidget(self.mt_status_label)
        
        layout.addWidget(results_group, stretch=1)
        
        return tab
    
    def _open_mt_settings(self):
        """Navigate to Settings → MT Settings tab"""
        if self.main_window:
            # Go to main Settings tab (Settings is index 4 in the
            # post-v1.9.467 layout: Editor/TMs/Termbases/AI/Settings)
            if hasattr(self.main_window, 'main_tabs'):
                self.main_window.main_tabs.setCurrentIndex(4)
            # Go to MT Settings sub-tab (index 3: General=0, AI=1, Language=2, MT=3)
            if hasattr(self.main_window, 'settings_tabs'):
                self.main_window.settings_tabs.setCurrentIndex(3)
    
    def _update_mt_provider_status(self):
        """Update the MT provider status display"""
        # Get API keys and enabled states from main window
        api_keys = {}
        enabled_providers = {}
        
        if self.main_window:
            if hasattr(self.main_window, 'load_api_keys'):
                api_keys = self.main_window.load_api_keys()
            if hasattr(self.main_window, 'load_provider_enabled_states'):
                enabled_providers = self.main_window.load_provider_enabled_states()
        
        # Define all MT providers
        providers = [
            ("DeepL", "mt_deepl", "deepl"),
            ("Google Translate", "mt_google_translate", "google_translate"),
            ("Microsoft Translator", "mt_microsoft", "microsoft_translate"),
            ("Amazon Translate", "mt_amazon", "amazon_translate"),
            ("ModernMT", "mt_modernmt", "modernmt"),
            ("MyMemory", "mt_mymemory", None),
        ]
        
        ready_providers = []
        disabled_providers = []
        missing_key_providers = []
        
        for name, enabled_key, api_key_name in providers:
            is_enabled = enabled_providers.get(enabled_key, True)
            has_key = api_key_name is None or bool(api_keys.get(api_key_name))
            
            if has_key and is_enabled:
                ready_providers.append(name)
            elif has_key and not is_enabled:
                disabled_providers.append(name)
            else:
                missing_key_providers.append(name)
        
        # Build status text
        status_parts = []
        if ready_providers:
            status_parts.append(f"✅ Active: {', '.join(ready_providers)}")
        if disabled_providers:
            status_parts.append(f"⏸️ Disabled: {', '.join(disabled_providers)}")
        if missing_key_providers:
            status_parts.append(f"❌ No API key: {', '.join(missing_key_providers)}")
        
        if not status_parts:
            status_text = "No MT providers configured"
        else:
            status_text = "\n".join(status_parts)
        
        self.mt_provider_status_label.setText(status_text)
    
    def _perform_mt_lookup(self, text: str, source_lang: str = None, target_lang: str = None):
        """Call enabled MT providers in parallel and return results.

        Skips entirely when this SuperlookupTab instance has no MT results table
        (e.g. inside the Floating Assistant / Sidekick, where MT is handled by
        QuickTrans's own MTFetchWorker). In that case the previous implementation
        was making blocking HTTP calls and discarding the results, freezing the
        UI for up to ~30 s on every Ctrl+Alt+L press.
        """
        # Sidekick / Floating Assistant has no MT table — don't burn HTTP calls.
        if not hasattr(self, 'mt_results_table'):
            return []

        if not self.main_window:
            return []

        # Get languages from main window if not provided
        if not source_lang:
            source_lang = getattr(self.main_window, 'source_language', 'en')
        if not target_lang:
            target_lang = getattr(self.main_window, 'target_language', 'nl')

        # Get API keys and enabled providers from Settings
        api_keys = {}
        enabled_providers = {}
        if hasattr(self.main_window, 'load_api_keys'):
            api_keys = self.main_window.load_api_keys()
        if hasattr(self.main_window, 'load_provider_enabled_states'):
            enabled_providers = self.main_window.load_provider_enabled_states()

        # Define MT providers with their settings keys and API key names
        providers = [
            ("DeepL", "mt_deepl", "deepl"),
            ("Google Translate", "mt_google_translate", "google_translate"),
            ("Microsoft Translator", "mt_microsoft", "microsoft_translate"),
            ("Amazon Translate", "mt_amazon", "amazon_translate"),
            ("ModernMT", "mt_modernmt", "modernmt"),
            ("MyMemory", "mt_mymemory", None),
        ]

        # Build the list of provider call closures to dispatch in parallel.
        active_calls = []
        for provider_name, enabled_key, api_key_name in providers:
            is_enabled = enabled_providers.get(enabled_key, True)
            has_key = api_key_name is None or bool(api_keys.get(api_key_name))
            if not is_enabled or not has_key:
                continue

            mw = self.main_window
            if provider_name == "DeepL" and hasattr(mw, 'call_deepl'):
                call = lambda mw=mw, k=api_keys.get('deepl'): mw.call_deepl(text, source_lang, target_lang, k)
            elif provider_name == "Google Translate" and hasattr(mw, 'call_google_translate'):
                call = lambda mw=mw, k=api_keys.get('google_translate'): mw.call_google_translate(text, source_lang, target_lang, k)
            elif provider_name == "Microsoft Translator" and hasattr(mw, 'call_microsoft_translate'):
                call = lambda mw=mw, k=api_keys.get('microsoft_translate'): mw.call_microsoft_translate(text, source_lang, target_lang, k)
            elif provider_name == "Amazon Translate" and hasattr(mw, 'call_amazon_translate'):
                region = api_keys.get('amazon_translate_region', 'us-east-1')
                call = lambda mw=mw, k=api_keys.get('amazon_translate'), r=region: mw.call_amazon_translate(text, source_lang, target_lang, k, r)
            elif provider_name == "ModernMT" and hasattr(mw, 'call_modernmt'):
                call = lambda mw=mw, k=api_keys.get('modernmt'): mw.call_modernmt(text, source_lang, target_lang, k)
            elif provider_name == "MyMemory":
                call = lambda: self._call_mymemory(text, source_lang, target_lang)
            else:
                continue

            active_calls.append((provider_name, call))

        if not active_calls:
            return []

        # Fan out: every provider runs concurrently. Total wall-clock is now
        # max(provider_times) instead of sum(provider_times). Per-future timeout
        # caps a single misbehaving provider; the overall as_completed wait
        # caps the whole batch so the UI can never stall longer than that.
        from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
        import time

        per_provider_timeout = 5.0
        overall_timeout = 6.0  # small slack over per-provider so timeouts surface as errors
        results = []
        deadline = time.monotonic() + overall_timeout

        with ThreadPoolExecutor(max_workers=len(active_calls), thread_name_prefix="sv-mt") as executor:
            future_to_name = {executor.submit(call): name for name, call in active_calls}
            for future, provider_name in list(future_to_name.items()):
                remaining = max(0.0, min(per_provider_timeout, deadline - time.monotonic()))
                try:
                    translation = future.result(timeout=remaining)
                except FuturesTimeout:
                    future.cancel()
                    results.append({
                        'provider': provider_name,
                        'translation': "[Error: timed out]",
                        'is_error': True,
                    })
                    continue
                except Exception as e:
                    print(f"[Superlookup] MT error ({provider_name}): {e}")
                    results.append({
                        'provider': provider_name,
                        'translation': f"[Error: {str(e)}]",
                        'is_error': True,
                    })
                    continue

                if translation:
                    results.append({
                        'provider': provider_name,
                        'translation': translation,
                        'is_error': translation.startswith('['),
                    })

        return results
    
    def _call_mymemory(self, text: str, source_lang: str, target_lang: str) -> str:
        """Call MyMemory free translation API"""
        try:
            import requests
            
            # Map full language names to ISO codes
            lang_name_to_code = {
                'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
                'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
                'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
                'polish': 'pl', 'swedish': 'sv', 'norwegian': 'no', 'danish': 'da',
                'finnish': 'fi', 'greek': 'el', 'turkish': 'tr', 'czech': 'cs',
                'hungarian': 'hu', 'romanian': 'ro', 'bulgarian': 'bg', 'ukrainian': 'uk',
            }
            
            # Convert language - try name mapping first, then code extraction
            src_lower = source_lang.lower().strip()
            tgt_lower = target_lang.lower().strip()
            
            src = lang_name_to_code.get(src_lower, src_lower.split('-')[0].split('_')[0])
            tgt = lang_name_to_code.get(tgt_lower, tgt_lower.split('-')[0].split('_')[0])
            
            url = "https://api.mymemory.translated.net/get"
            params = {
                'q': text,
                'langpair': f"{src}|{tgt}"
            }
            
            response = requests.get(url, params=params, timeout=10)
            response.raise_for_status()
            
            data = response.json()
            if data.get('responseStatus') == 200:
                return data['responseData']['translatedText']
            else:
                return f"[MyMemory error: {data.get('responseStatus')}]"
                
        except Exception as e:
            return f"[MyMemory error: {e}]"

    def create_web_resources_tab(self):
        """Create the Web Resources tab with vertical sidebar for different web resources"""
        tab = QWidget()
        layout = QHBoxLayout(tab)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        
        # Try to import QWebEngineView for embedded browser with persistent storage
        self.web_engine_available = False
        self.web_profile = None
        self.SilentWebPage = None  # Custom page class that suppresses JS console spam
        try:
            from PyQt6.QtWebEngineWidgets import QWebEngineView
            from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile

            # Create a custom page class that silences JavaScript console messages
            class SilentWebEnginePage(QWebEnginePage):
                """Custom QWebEnginePage that suppresses JavaScript console output.

                External websites (IATE, Juremy, Google Patents, etc.) emit tons of
                noisy console messages about CSP violations, mixed content, etc.
                These are not relevant to Supervertaler and clutter the terminal.
                """
                def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
                    # Silently ignore all JS console messages from web pages
                    pass

            # Subclassed view that adds default-browser items to the right-click
            # menu: "Open link in default browser" when the click target is a
            # hyperlink, plus an always-available "Open page in default browser".
            class SidekickWebEngineView(QWebEngineView):
                def contextMenuEvent(self, event):
                    menu = self.createStandardContextMenu()
                    try:
                        request = self.lastContextMenuRequest()
                    except AttributeError:
                        request = None
                    link_url = None
                    if request is not None:
                        try:
                            url = request.linkUrl()
                            if url is not None and not url.isEmpty():
                                link_url = QUrl(url)
                        except Exception:
                            link_url = None

                    menu.addSeparator()
                    if link_url is not None:
                        link_action = menu.addAction("Open link in default browser")
                        link_action.triggered.connect(
                            lambda _checked=False, u=link_url: QDesktopServices.openUrl(u)
                        )
                    page_url = self.url()
                    if page_url is not None and not page_url.isEmpty() and page_url.toString() != "about:blank":
                        page_action = menu.addAction("Open page in default browser")
                        page_action.triggered.connect(
                            lambda _checked=False, u=QUrl(page_url): QDesktopServices.openUrl(u)
                        )
                    menu.exec(event.globalPos())

            self.SilentWebPage = SilentWebEnginePage
            self.QWebEngineView = SidekickWebEngineView
            self.QWebEngineProfile = QWebEngineProfile
            self.web_engine_available = True
            
            # Create persistent profile for login/cookie storage
            if self.user_data_path:
                storage_path = os.path.join(str(self.user_data_path), 'workbench', 'web_cache')
            else:
                # Fallback to script directory if user_data_path not provided
                storage_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'user_data', 'workbench', 'web_cache')
            os.makedirs(storage_path, exist_ok=True)
            self.web_profile = QWebEngineProfile("SuperlookupProfile", self)
            self.web_profile.setPersistentStoragePath(storage_path)
            self.web_profile.setPersistentCookiesPolicy(QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies)
            print(f"[Superlookup] QWebEngineView available - embedded browser enabled with persistent storage at {storage_path}")
        except ImportError:
            print("[Superlookup] QWebEngineView not available - external browser only")
            self.QWebEngineView = None
            self.QWebEngineProfile = None
        
        # Setting for browser mode (embedded vs external)
        self.web_browser_mode = 'embedded' if self.web_engine_available else 'external'
        
        # === Web Resources Definitions ===
        # Each resource has: name, icon, url_template with {query}, {sl} (source lang), {tl} (target lang)
        # Language codes vary per service - we'll map them in _get_web_lang_code()
        self.web_resources = [
            {
                'id': 'iate',
                'name': 'IATE',
                'icon': '🇪🇺',
                'description': 'EU terminology database',
                'url_template': 'https://iate.europa.eu/search/byUrl?term={query}&sl={sl}&tl={tl}',
                'lang_format': 'iso2',  # en, nl, de, fr
                'bidirectional': False,  # respects source/target direction
            },
            {
                'id': 'linguee',
                'name': 'Linguee',
                'icon': '📗',
                'description': 'Bilingual dictionary with context',
                'url_template': 'https://www.linguee.com/{sl_full}-{tl_full}/search?source=auto&query={query}',
                'lang_format': 'full_lower',  # dutch, english, german
                'bidirectional': True,  # searches both directions
            },
            {
                'id': 'proz',
                'name': 'ProZ.com',
                'icon': '💬',
                'description': 'Translator terminology database',
                'url_template': 'https://www.proz.com/search/?term={query}&from={sl}&to={tl}&results_per_page=25&es=1',
                'lang_format': 'iso3',  # dut, eng, ger
                'bidirectional': False,
            },
            {
                'id': 'reverso',
                'name': 'Reverso Context',
                'icon': '🔄',
                'description': 'Context-based translations',
                'url_template': 'https://context.reverso.net/translation/{sl_full}-{tl_full}/{query}',
                'lang_format': 'full_lower',  # dutch, english
                'bidirectional': False,
            },
            {
                'id': 'google',
                'name': 'Google Search',
                'icon': '🔍',
                'description': 'General web search',
                'url_template': 'https://www.google.com/search?q={query}',
                'lang_format': None,  # No language needed
                'bidirectional': True,
            },
            {
                'id': 'google_patents',
                'name': 'Google Patents',
                'icon': '📜',
                'description': 'Patent search',
                'url_template': 'https://patents.google.com/?q="{query}"',
                'lang_format': None,
                'bidirectional': True,
            },
            {
                'id': 'wikipedia_source',
                'name': 'Wikipedia (Source)',
                'icon': '📖',
                'description': 'Wikipedia in source language',
                'url_template': 'https://{sl}.wikipedia.org/w/index.php?search={query}',
                'lang_format': 'iso2',  # nl, en, de
                'bidirectional': True,
            },
            {
                'id': 'wikipedia_target',
                'name': 'Wikipedia (Target)',
                'icon': '📖',
                'description': 'Wikipedia in target language',
                'url_template': 'https://{tl}.wikipedia.org/w/index.php?search={query}',
                'lang_format': 'iso2',
                'bidirectional': True,
            },
            {
                'id': 'juremy',
                'name': 'Juremy',
                'icon': '⚖️',
                'description': 'Legal terminology database',
                'url_template': 'https://juremy.com/search?src={sl}&dst={tl}&q={query}&opts=ia&tool=iws',
                'lang_format': 'iso639_3',  # nld, eng, deu (ISO 639-3 codes)
                'bidirectional': False,
            },
            {
                'id': 'beijerterm',
                'name': 'Beijerterm',
                'icon': '📚',
                'description': 'Dutch-English terminology database (500k+ terms)',
                'url_template': 'https://beijerterm.com/w/index.php?search={query}',
                'lang_format': None,
                'bidirectional': True,
            },
            {
                'id': 'acronymfinder',
                'name': 'AcronymFinder',
                'icon': '🔤',
                'description': 'Acronym and abbreviation dictionary',
                'url_template': 'https://www.acronymfinder.com/~/search/af.aspx?string=exact&Acronym={query}',
                'lang_format': None,
                'bidirectional': True,
            },
            {
                'id': 'babelnet',
                'name': 'BabelNet',
                'icon': '🌐',
                'description': 'Multilingual encyclopedic dictionary',
                'url_template': 'https://babelnet.org/search?word={query}&lang={sl_upper}&transLang={tl_upper}',
                'lang_format': 'iso2_upper',  # NL, EN, DE
                'bidirectional': False,
            },
            {
                'id': 'wiktionary_source',
                'name': 'Wiktionary (Source)',
                'icon': '📓',
                'description': 'Wiktionary in source language',
                'url_template': 'https://{sl}.wiktionary.org/wiki/{query}',
                'lang_format': 'iso2',
                'bidirectional': True,
            },
            {
                'id': 'wiktionary_target',
                'name': 'Wiktionary (Target)',
                'icon': '📓',
                'description': 'Wiktionary in target language',
                'url_template': 'https://{tl}.wiktionary.org/wiki/{query}',
                'lang_format': 'iso2',
                'bidirectional': True,
            },
            {
                'id': 'github_code',
                'name': 'GitHub Code (all)',
                'icon': '💻',
                'description': 'Search code across all GitHub repositories',
                'url_template': 'https://github.com/search?q={query}&type=code',
                'lang_format': None,
                'bidirectional': True,
            },
            {
                'id': 'opus_corpus',
                'name': 'OPUS Corpus',
                'icon': '📚',
                'description': 'Search 58B parallel sentences from OPUS parallel corpora',
                'url_template': 'https://opus.nlpl.eu/bin/opuscqp.pl?corpus={opus_corpus};lang={sl};cqp={query};align={tl}',
                'lang_format': 'iso2',
                'bidirectional': False,
                'has_corpus_selector': True,  # Special flag for OPUS
            },
        ]
        
        # OPUS corpus options for the selector
        self.opus_corpora = [
            ('DGT', 'DGT - EU Translation Memory (1.1B pairs)'),
            ('Europarl', 'Europarl - EU Parliament (186M pairs)'),
            ('OpenSubtitles', 'OpenSubtitles - Movies/TV (20B pairs)'),
            ('EMEA', 'EMEA - Medicines Agency (243M pairs)'),
            ('EUbookshop', 'EUbookshop - EU Publications (279M pairs)'),
            ('JRC-Acquis', 'JRC-Acquis - EU Legislation (147M pairs)'),
            ('ECB', 'ECB - Central Bank (15M pairs)'),
            ('TED2020', 'TED2020 - TED Talks (143M pairs)'),
            ('WikiMatrix', 'WikiMatrix - Wikipedia (127M pairs)'),
            ('GlobalVoices', 'GlobalVoices - News (7.3M pairs)'),
            ('Tatoeba', 'Tatoeba - Example Sentences (8.7M pairs)'),
        ]
        self.current_opus_corpus = 'DGT'  # Default corpus
        
        # === Use QSplitter for resizable sidebar ===
        splitter = QSplitter(Qt.Orientation.Horizontal)
        
        # === Left sidebar with vertical tabs ===
        sidebar = QWidget()
        sidebar.setMinimumWidth(120)
        sidebar.setMaximumWidth(250)
        sidebar.setStyleSheet("""
            QWidget {
                background-color: #f5f5f5;
                border-right: 1px solid #ddd;
            }
        """)
        sidebar_layout = QVBoxLayout(sidebar)
        sidebar_layout.setContentsMargins(5, 5, 5, 5)
        sidebar_layout.setSpacing(2)

        # Sidebar header
        sidebar_header = QLabel("🌐 Resources")
        sidebar_header.setStyleSheet(
            f"font-weight: bold; font-size: {scaled_pt(10):.1f}pt; padding: 5px; color: #1976D2;"
        )
        sidebar_layout.addWidget(sidebar_header)

        # Scrollable area for resource buttons – ensures all resources are
        # accessible even on smaller screens / when the panel is short.
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        scroll_area.setFrameShape(QFrame.Shape.NoFrame)
        scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")

        scroll_content = QWidget()
        scroll_content.setStyleSheet("background: transparent;")
        scroll_layout = QVBoxLayout(scroll_content)
        scroll_layout.setContentsMargins(0, 0, 0, 0)
        scroll_layout.setSpacing(2)

        # Resource buttons list
        self.web_resource_buttons = []
        self.web_resource_button_group = QButtonGroup(self)
        self.web_resource_button_group.setExclusive(True)

        for i, resource in enumerate(self.web_resources):
            btn = QPushButton(f"{resource['icon']} {resource['name']}")
            btn.setCheckable(True)
            btn.setToolTip(resource['description'])
            btn.setStyleSheet(f"""
                QPushButton {{
                    text-align: left;
                    padding: 5px 8px;
                    border: none;
                    border-radius: 4px;
                    background-color: transparent;
                    font-size: {scaled_pt(9):.1f}pt;
                    outline: none;
                }}
                QPushButton:hover {{
                    background-color: #e3e3e3;
                }}
                QPushButton:checked {{
                    background-color: #2196F3;
                    color: white;
                    font-weight: bold;
                }}
                QPushButton:focus {{
                    outline: none;
                    border: none;
                }}
            """)
            btn.clicked.connect(lambda checked, idx=i: self._on_web_resource_selected(idx))
            self.web_resource_button_group.addButton(btn, i)
            self.web_resource_buttons.append(btn)
            scroll_layout.addWidget(btn)

        scroll_layout.addStretch()
        scroll_area.setWidget(scroll_content)
        sidebar_layout.addWidget(scroll_area, stretch=1)
        
        # "Search All" button - pre-loads all resources
        search_all_btn = QPushButton("🔎 Search All")
        search_all_btn.setToolTip("Search all web resources at once (embedded mode)")
        search_all_btn.setStyleSheet(f"""
            QPushButton {{
                padding: 8px;
                background-color: #FF9800;
                color: white;
                border: none;
                border-radius: 4px;
                font-size: {scaled_pt(9):.1f}pt;
                outline: none;
            }}
            QPushButton:hover {{
                background-color: #F57C00;
            }}
            QPushButton:focus {{
                outline: none;
                border: none;
            }}
        """)
        search_all_btn.clicked.connect(lambda: self._perform_web_search(search_all=True))
        sidebar_layout.addWidget(search_all_btn)
        
        # Browser mode toggle
        mode_label = QLabel("Mode:")
        mode_label.setStyleSheet(f"font-size: {scaled_pt(8):.1f}pt; color: #666; padding-top: 5px;")
        sidebar_layout.addWidget(mode_label)
        
        self.web_mode_embedded_radio = CheckmarkRadioButton("Embedded")
        self.web_mode_embedded_radio.setToolTip("Show results inside Supervertaler")
        self.web_mode_embedded_radio.setChecked(self.web_browser_mode == 'embedded')
        self.web_mode_embedded_radio.setEnabled(self.web_engine_available)
        self.web_mode_embedded_radio.toggled.connect(self._on_web_mode_changed)
        sidebar_layout.addWidget(self.web_mode_embedded_radio)
        
        self.web_mode_external_radio = CheckmarkRadioButton("External")
        self.web_mode_external_radio.setToolTip("Open results in default browser")
        self.web_mode_external_radio.setChecked(self.web_browser_mode == 'external')
        self.web_mode_external_radio.toggled.connect(self._on_web_mode_changed)
        sidebar_layout.addWidget(self.web_mode_external_radio)
        
        # "Open in Browser" button at bottom of sidebar
        self.web_open_external_btn = QPushButton("🌍 Open in Browser")
        self.web_open_external_btn.setToolTip("Open current page in your default web browser")
        self.web_open_external_btn.setStyleSheet(f"""
            QPushButton {{
                padding: 8px;
                min-height: 20px;
                background-color: #4CAF50;
                color: white;
                border: none;
                border-radius: 4px;
                font-size: {scaled_pt(9):.1f}pt;
                margin-top: 5px;
                outline: none;
            }}
            QPushButton:hover {{
                background-color: #45a049;
            }}
            QPushButton:focus {{
                outline: none;
                border: none;
            }}
        """)
        self.web_open_external_btn.clicked.connect(self._open_web_resource_external)
        sidebar_layout.addWidget(self.web_open_external_btn)
        
        splitter.addWidget(sidebar)
        
        # === Main content area ===
        content_widget = QWidget()
        content_layout = QVBoxLayout(content_widget)
        content_layout.setContentsMargins(10, 10, 10, 10)
        content_layout.setSpacing(5)
        
        # Info label showing current language direction (no separate search bar - uses main Superlookup search)
        self.web_lang_info_label = QLabel("Languages: Any → Any  •  Click Search above or select a resource")
        self.web_lang_info_label.setStyleSheet(
            f"color: #666; font-size: {scaled_pt(9):.1f}pt; padding: 3px 0;"
        )
        content_layout.addWidget(self.web_lang_info_label)
        
        # OPUS corpus selector (hidden by default, shown when OPUS is selected)
        self.opus_selector_widget = QWidget()
        opus_selector_layout = QHBoxLayout(self.opus_selector_widget)
        opus_selector_layout.setContentsMargins(0, 5, 0, 5)
        opus_selector_layout.setSpacing(8)
        
        opus_label = QLabel("📚 Corpus:")
        opus_label.setStyleSheet("font-weight: bold; color: #1976D2;")
        opus_selector_layout.addWidget(opus_label)
        
        self.opus_corpus_combo = QComboBox()
        self.opus_corpus_combo.setMinimumWidth(280)
        self.opus_corpus_combo.setStyleSheet("""
            QComboBox {
                padding: 5px 10px;
                border: 1px solid #1976D2;
                border-radius: 4px;
                background: white;
            }
            QComboBox:hover {
                border-color: #1565C0;
            }
            QComboBox::drop-down {
                border: none;
                padding-right: 8px;
            }
        """)
        for corpus_id, corpus_name in self.opus_corpora:
            self.opus_corpus_combo.addItem(corpus_name, corpus_id)
        self.opus_corpus_combo.currentIndexChanged.connect(self._on_opus_corpus_changed)
        opus_selector_layout.addWidget(self.opus_corpus_combo)
        
        opus_selector_layout.addStretch()
        self.opus_selector_widget.hide()  # Hidden by default
        content_layout.addWidget(self.opus_selector_widget)
        
        # Web view container - stacked widget for embedded views per resource
        self.web_view_stack = QStackedWidget()
        
        # LAZY LOADING: Don't create all web views upfront - create them on demand
        # This dramatically improves startup performance and memory usage
        # Each QWebEngineView spawns a Chromium process, so 14 views = 14 processes
        self.web_views = {}  # Will be populated lazily when resources are selected
        # Each web view is wrapped in a container widget that adds a find-in-page bar.
        # The stack stores containers, but self.web_views still holds the raw views
        # so existing setUrl() / page() callers keep working unchanged.
        self.web_view_containers = {}
        
        # Fallback view for external mode or when web engine not available
        self.web_results_view = QTextEdit()
        self.web_results_view.setReadOnly(True)
        self.web_results_view.setStyleSheet("""
            QTextEdit {
                border: 1px solid #ddd;
                border-radius: 4px;
                padding: 10px;
                font-size: 11pt;
            }
        """)
        self._show_web_welcome_message()
        self.web_view_stack.addWidget(self.web_results_view)
        
        content_layout.addWidget(self.web_view_stack, stretch=1)
        
        splitter.addWidget(content_widget)
        splitter.setStretchFactor(0, 0)  # Sidebar doesn't stretch
        splitter.setStretchFactor(1, 1)  # Content area stretches
        splitter.setSizes([150, 600])    # Initial sizes
        
        layout.addWidget(splitter, stretch=1)
        
        # Track current resource and last search URL
        self.current_web_resource_index = 0
        self.last_web_search_url = None
        self.last_web_search_query = None
        
        # Select first resource by default
        if self.web_resource_buttons:
            self.web_resource_buttons[0].setChecked(True)
        
        # Show correct view based on mode
        self._update_web_view_for_mode()
        
        # Update enabled state based on settings
        self.search_web_enabled = True  # Now implemented!
        
        return tab
    
    def _show_web_welcome_message(self):
        """Show welcome message in the web results view"""
        mode_info = "embedded browser" if self.web_browser_mode == 'embedded' else "your default web browser"
        self.web_results_view.setHtml(f"""
            <div style="text-align: center; padding: 40px; color: #999;">
                <h2>🌐 Web Resources</h2>
                <p>Select a resource from the sidebar and enter a search term.</p>
                <p>Results will open in <b>{mode_info}</b>.</p>
                <p style="font-size: 10pt; margin-top: 20px;">
                    <b>Tip:</b> The search respects the language direction from the From/To dropdowns above.
                </p>
                <p style="font-size: 9pt; color: #aaa; margin-top: 10px;">
                    Use the mode toggle in the sidebar to switch between embedded and external browser.
                </p>
            </div>
        """)
    
    def _create_web_view_for_resource(self, resource):
        """Lazily create a web view for a specific resource (on-demand creation)
        
        This improves performance by not creating all 14+ QWebEngineView instances at startup.
        Each QWebEngineView spawns its own Chromium process, so lazy loading saves significant
        memory and startup time.
        """
        if not self.web_engine_available:
            return
            
        if resource['id'] in self.web_views:
            return  # Already created
            
        web_view = self.QWebEngineView()

        # Use persistent profile with silent page (suppresses JS console spam)
        if self.web_profile and self.SilentWebPage:
            page = self.SilentWebPage(self.web_profile, web_view)
            web_view.setPage(page)

        web_view.setUrl(QUrl("about:blank"))

        # Wrap the view in a container that adds a Ctrl+F find-in-page toolbar.
        # The container is what gets added to the stack; self.web_views still
        # maps to the raw view so existing setUrl()/page() callers are unchanged.
        container = self._create_web_view_container(web_view)

        self.web_view_stack.addWidget(container)
        self.web_views[resource['id']] = web_view
        self.web_view_containers[resource['id']] = container

        print(f"[Superlookup] Created web view for {resource['name']} (lazy load)")

    def _create_web_view_container(self, web_view):
        """Wrap a QWebEngineView in a container with a hidden find-in-page bar.

        The bar toggles via Ctrl+F (when the container has focus) and Esc.
        Enter / Shift+Enter cycle next/previous match. Match count and a
        case-sensitive toggle live on the bar itself.
        """
        from PyQt6.QtGui import QShortcut, QKeySequence
        from PyQt6.QtWebEngineCore import QWebEnginePage

        container = QWidget()
        v = QVBoxLayout(container)
        v.setContentsMargins(0, 0, 0, 0)
        v.setSpacing(0)
        v.addWidget(web_view, stretch=1)

        # --- Find bar ---
        find_bar = QWidget()
        find_bar.setStyleSheet("""
            QWidget#sl_find_bar { background-color: #f5f5f5; border-top: 1px solid #ddd; }
            QLineEdit { border: 1px solid #bbb; border-radius: 3px; padding: 3px 6px; background: white; }
            QPushButton { padding: 2px 8px; border: 1px solid transparent; border-radius: 3px; background: transparent; }
            QPushButton:hover { background: #e3e3e3; border: 1px solid #ccc; }
            QLabel { color: #666; font-size: 9pt; }
        """)
        find_bar.setObjectName("sl_find_bar")
        h = QHBoxLayout(find_bar)
        h.setContentsMargins(8, 4, 8, 4)
        h.setSpacing(4)

        h.addWidget(QLabel("Find:"))
        find_input = QLineEdit()
        find_input.setPlaceholderText("Find on page…")
        find_input.setMinimumWidth(220)
        h.addWidget(find_input, stretch=1)

        status = QLabel("")
        status.setMinimumWidth(80)
        h.addWidget(status)

        prev_btn = QPushButton("▲")
        prev_btn.setToolTip("Previous match (Shift+Enter)")
        prev_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        h.addWidget(prev_btn)

        next_btn = QPushButton("▼")
        next_btn.setToolTip("Next match (Enter)")
        next_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        h.addWidget(next_btn)

        case_check = QCheckBox("Match case")
        case_check.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        h.addWidget(case_check)

        clear_btn = QPushButton("✕")
        clear_btn.setToolTip("Clear (Esc)")
        clear_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        h.addWidget(clear_btn)

        # Bar is permanently visible so users discover it without needing Ctrl+F.
        v.addWidget(find_bar)

        # --- Behaviour ---
        def _do_find(backward=False):
            text = find_input.text()
            flags = QWebEnginePage.FindFlag(0)
            if backward:
                flags |= QWebEnginePage.FindFlag.FindBackward
            if case_check.isChecked():
                flags |= QWebEnginePage.FindFlag.FindCaseSensitively

            if not text:
                web_view.findText("")
                status.setText("")
                find_input.setStyleSheet("")
                return

            def _cb(result):
                try:
                    total = result.numberOfMatches()
                    active = result.activeMatch()
                except Exception:
                    return
                if total == 0:
                    status.setText("No matches")
                    find_input.setStyleSheet("background: #fde7e9;")
                else:
                    status.setText(f"{active} of {total}")
                    find_input.setStyleSheet("")

            try:
                web_view.findText(text, flags, _cb)
            except TypeError:
                # Older Qt without callback overload
                web_view.findText(text, flags)
                status.setText("")

        def focus_find_bar():
            """Focus the find input (bar is always visible)."""
            find_input.setFocus()
            find_input.selectAll()
            if find_input.text():
                _do_find()

        def clear_find():
            """Clear the search and return focus to the page."""
            find_input.clear()
            web_view.findText("")
            status.setText("")
            find_input.setStyleSheet("")
            web_view.setFocus()

        find_input.textChanged.connect(lambda _t: _do_find(backward=False))
        find_input.returnPressed.connect(lambda: _do_find(backward=False))
        prev_btn.clicked.connect(lambda: _do_find(backward=True))
        next_btn.clicked.connect(lambda: _do_find(backward=False))
        case_check.toggled.connect(lambda _c: _do_find(backward=False))
        clear_btn.clicked.connect(clear_find)

        # Ctrl+F focuses the input (bar is always visible).
        find_shortcut = QShortcut(QKeySequence(QKeySequence.StandardKey.Find), container)
        find_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
        find_shortcut.activated.connect(focus_find_bar)

        # Esc clears the search and returns focus to the page.
        esc_shortcut = QShortcut(QKeySequence("Escape"), find_input)
        esc_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
        esc_shortcut.activated.connect(clear_find)

        # Shift+Enter for previous match while the input has focus.
        shift_enter = QShortcut(QKeySequence("Shift+Return"), find_input)
        shift_enter.setContext(Qt.ShortcutContext.WidgetShortcut)
        shift_enter.activated.connect(lambda: _do_find(backward=True))
        shift_enter_pad = QShortcut(QKeySequence("Shift+Enter"), find_input)
        shift_enter_pad.setContext(Qt.ShortcutContext.WidgetShortcut)
        shift_enter_pad.activated.connect(lambda: _do_find(backward=True))

        # Stash references on the container so they aren't garbage-collected
        # and so callers can introspect (e.g. tests).
        container.web_view = web_view
        container.find_bar = find_bar
        container.find_input = find_input
        container.focus_find_bar = focus_find_bar
        container.clear_find = clear_find

        return container

    def _get_web_view_index(self, resource_id):
        """Get the stack index for a web view container by resource ID"""
        if resource_id in self.web_view_containers:
            return self.web_view_stack.indexOf(self.web_view_containers[resource_id])
        return -1
    
    def _on_web_mode_changed(self, checked):
        """Handle browser mode toggle"""
        if self.web_mode_embedded_radio.isChecked():
            self.web_browser_mode = 'embedded'
        else:
            self.web_browser_mode = 'external'
        
        self._update_web_view_for_mode()
        self._show_web_welcome_message()
        print(f"[Superlookup] Web browser mode changed to: {self.web_browser_mode}")
    
    def _update_web_view_for_mode(self):
        """Update the view stack based on current mode"""
        if self.web_browser_mode == 'embedded' and self.web_engine_available:
            # Show embedded view for current resource (create lazily if needed)
            resource = self.web_resources[self.current_web_resource_index]
            if resource['id'] not in self.web_views:
                self._create_web_view_for_resource(resource)
            idx = self._get_web_view_index(resource['id'])
            if idx >= 0:
                self.web_view_stack.setCurrentIndex(idx)
        else:
            # Show text view (for external mode or fallback)
            self.web_view_stack.setCurrentIndex(self.web_view_stack.indexOf(self.web_results_view))
    
    def _on_web_resource_selected(self, index):
        """Handle web resource selection from sidebar"""
        self.current_web_resource_index = index
        resource = self.web_resources[index]
        
        # Show/hide OPUS corpus selector based on selected resource
        if hasattr(self, 'opus_selector_widget'):
            if resource.get('has_corpus_selector'):
                self.opus_selector_widget.show()
            else:
                self.opus_selector_widget.hide()
        
        # Update view based on mode
        if self.web_browser_mode == 'embedded' and self.web_engine_available:
            # LAZY LOADING: Create web view for this resource if it doesn't exist yet
            if resource['id'] not in self.web_views:
                self._create_web_view_for_resource(resource)
            
            # Switch to this resource's embedded web view
            idx = self._get_web_view_index(resource['id'])
            if idx >= 0:
                self.web_view_stack.setCurrentIndex(idx)
                
                # If we have a cached search query, perform search for this resource
                if self.last_web_search_query:
                    url = self._build_web_search_url(resource, self.last_web_search_query)
                    if url:
                        self.web_views[resource['id']].setUrl(QUrl(url))
        else:
            # External mode - show info
            self.web_results_view.setHtml(f"""
                <div style="text-align: center; padding: 40px;">
                    <h2>{resource['icon']} {resource['name']}</h2>
                    <p style="color: #666;">{resource['description']}</p>
                    <p style="margin-top: 20px;">Enter a search term above and press Search.</p>
                    <p style="font-size: 9pt; color: #aaa;">Results will open in your default browser.</p>
                </div>
            """)
        
        # Update language info
        self._update_web_lang_info()
    
    def _update_web_lang_info(self):
        """Update the language direction info label for web resources"""
        from_lang = self.lang_from_combo.currentData() if hasattr(self, 'lang_from_combo') else None
        to_lang = self.lang_to_combo.currentData() if hasattr(self, 'lang_to_combo') else None
        
        from_display = self.lang_from_combo.currentText() if from_lang else "Any"
        to_display = self.lang_to_combo.currentText() if to_lang else "Any"
        
        if hasattr(self, 'web_lang_info_label'):
            self.web_lang_info_label.setText(f"Languages: {from_display} → {to_display}")
    
    def _schedule_lazy_web_views(self, query: str, resources, idx: int):
        """Defer-create / setUrl one QWebEngineView per event-loop tick.

        Recursively schedules itself for the next index until all resources
        are processed. Yielding to the event loop between each web-view
        creation keeps the main thread responsive and stops Windows DWM
        from ghosting the Sidekick window during the lookup.

        Cancellation: if a newer search has fired (self._web_search_query
        differs from the query this chain was started with), abort.
        """
        if getattr(self, '_web_search_query', None) != query:
            return
        if idx >= len(resources):
            return
        resource = resources[idx]
        try:
            url = self._build_web_search_url(resource, query)
            if url:
                if resource['id'] not in self.web_views:
                    self._create_web_view_for_resource(resource)
                if resource['id'] in self.web_views:
                    self.web_views[resource['id']].setUrl(QUrl(url))
        except Exception as e:
            print(f"[SuperLookup] Lazy web view error for {resource.get('name', '?')}: {e}")
        QTimer.singleShot(0, lambda: self._schedule_lazy_web_views(query, resources, idx + 1))

    def _perform_web_search(self, search_all: bool = False, silent: bool = False):
        """Perform web search with the selected resource(s)

        Args:
            search_all: If True, search all enabled resources (for embedded mode)
            silent: If True, do NOT touch self.status_label. Used when called from
                perform_lookup, where the central status line is owned by the
                background search worker (so its "✓ Found N results" message
                isn't clobbered by a "Searching all web resources for X" set
                from this synchronous code path).
        """
        # Use the main Superlookup source text field
        query = self.source_text.currentText().strip()
        if not query:
            if not silent:
                self.status_label.setText("Please enter a search term in the Source Text field above")
            return
        
        # Cache the query for when user switches between resources
        self.last_web_search_query = query
        
        if self.web_browser_mode == 'embedded' and self.web_engine_available:
            # Embedded mode - load in web views
            if search_all:
                # IMPORTANT: each QWebEngineView creation is 100-500 ms on Windows.
                # Doing 16+ of them in a synchronous loop blocks the main thread
                # for 3-8 s, which exceeds Windows DWM's "Not Responding"
                # threshold (~5 s). DWM then ghosts the Sidekick window – it
                # loses Z-order and apps like Trados Studio (aggressive about
                # foreground reclaim) immediately pop in front, making Sidekick
                # appear to "disappear" while the search is running.
                #
                # Mitigation: do the currently-visible resource synchronously
                # (so the user sees results in the active sub-tab right away),
                # then schedule the rest one-per-event-loop-tick via QTimer.
                # Total wall-clock is similar but the main thread keeps yielding
                # to the event loop, so the UI stays responsive and DWM doesn't
                # mark the app as Not Responding.
                self._web_search_query = query  # used by deferred ticks for cancellation

                current_idx = self.current_web_resource_index
                current_resource = (self.web_resources[current_idx]
                                    if 0 <= current_idx < len(self.web_resources)
                                    else None)

                # 1. Currently-visible resource: create + setUrl immediately so
                #    the active sub-tab has results without delay.
                if current_resource:
                    try:
                        url = self._build_web_search_url(current_resource, query)
                        if url:
                            if current_resource['id'] not in self.web_views:
                                self._create_web_view_for_resource(current_resource)
                            if current_resource['id'] in self.web_views:
                                self.web_views[current_resource['id']].setUrl(QUrl(url))
                                view_idx = self._get_web_view_index(current_resource['id'])
                                if view_idx >= 0:
                                    self.web_view_stack.setCurrentIndex(view_idx)
                    except Exception as e:
                        print(f"[SuperLookup] Web search (current resource) error: {e}")

                # 2. Other resources: schedule lazily, one per event-loop tick.
                other_resources = [r for i, r in enumerate(self.web_resources) if i != current_idx]
                if other_resources:
                    QTimer.singleShot(0, lambda: self._schedule_lazy_web_views(query, other_resources, 0))

                if not silent:
                    self.status_label.setText(f"Searching all web resources for '{query}'")
            else:
                # Search current resource only
                resource = self.web_resources[self.current_web_resource_index]
                url = self._build_web_search_url(resource, query)
                if url:
                    self.last_web_search_url = url
                    # Create view lazily if needed
                    if resource['id'] not in self.web_views:
                        self._create_web_view_for_resource(resource)
                    if resource['id'] in self.web_views:
                        self.web_views[resource['id']].setUrl(QUrl(url))
                        # Show this resource's view
                        idx = self._get_web_view_index(resource['id'])
                        if idx >= 0:
                            self.web_view_stack.setCurrentIndex(idx)
                    if not silent:
                        self.status_label.setText(f"Loaded {resource['name']} search")
        else:
            # External mode - open in browser
            resource = self.web_resources[self.current_web_resource_index]
            url = self._build_web_search_url(resource, query)
            
            if url:
                self.last_web_search_url = url
                
                # Show the URL that will be opened
                self.web_results_view.setHtml(f"""
                    <div style="padding: 20px;">
                        <h3>{resource['icon']} {resource['name']}</h3>
                        <p><b>Search term:</b> {query}</p>
                        <p><b>Opening URL:</b></p>
                        <p style="word-wrap: break-word; color: #1976D2;">
                            <a href="{url}">{url}</a>
                        </p>
                        <hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
                        <p style="color: #666; font-size: 10pt;">
                            Click "Open in Browser" to open again, or select another resource.
                        </p>
                    </div>
                """)
                
                # Open in default browser
                QDesktopServices.openUrl(QUrl(url))
                if not silent:
                    self.status_label.setText(f"Opened {resource['name']} search in browser")
    
    def search_all_web_resources(self, query: str):
        """Search all web resources with the given query (called from Superlookup integration)
        
        Note: This method now uses the source_text field directly, so the query param
        should match what's already in source_text.
        """
        if query:
            # Source text should already be set, just trigger the search
            self._perform_web_search(search_all=True)
    
    def _build_web_search_url(self, resource, query):
        """Build the search URL for a web resource with proper language codes"""
        import urllib.parse

        # Get language settings (may be a list of variants)
        from_lang = self.lang_from_combo.currentData() if hasattr(self, 'lang_from_combo') else None
        to_lang = self.lang_to_combo.currentData() if hasattr(self, 'lang_to_combo') else None

        # If we got a list of variants, use the first one for web URLs
        if isinstance(from_lang, list) and from_lang:
            from_lang = from_lang[0]
        if isinstance(to_lang, list) and to_lang:
            to_lang = to_lang[0]

        # If the From/To combos are set to "Any" (or not yet populated), fall
        # back to the main window's project language pair – the same pair
        # QuickTrans uses. This is what the user configured as their default
        # in Settings → Language Pair (e.g. Dutch → English), so resources
        # like IATE / Linguee / ProZ / BabelNet fire with the right codes
        # instead of the hard-coded English → Dutch fallback.
        if not from_lang or not to_lang:
            mw = self.main_window
            if mw is not None:
                # Prefer current_project pair if a project is loaded; otherwise
                # use the app-wide default.
                proj = getattr(mw, 'current_project', None)
                if proj is not None:
                    if not from_lang:
                        from_lang = getattr(proj, 'source_lang', None) or getattr(mw, 'source_language', None)
                    if not to_lang:
                        to_lang = getattr(proj, 'target_lang', None) or getattr(mw, 'target_language', None)
                else:
                    if not from_lang:
                        from_lang = getattr(mw, 'source_language', None)
                    if not to_lang:
                        to_lang = getattr(mw, 'target_language', None)

        # Final safety net – if everything above failed, default to English-Dutch
        if not from_lang:
            from_lang = 'en'
        if not to_lang:
            to_lang = 'nl'
        
        # URL-encode the query
        encoded_query = urllib.parse.quote(query)
        
        # Get language codes in the format needed by this resource
        lang_format = resource.get('lang_format')
        sl = self._get_web_lang_code(from_lang, lang_format)
        tl = self._get_web_lang_code(to_lang, lang_format)
        sl_full = self._get_web_lang_code(from_lang, 'full_lower')
        tl_full = self._get_web_lang_code(to_lang, 'full_lower')
        sl_upper = self._get_web_lang_code(from_lang, 'iso2').upper()
        tl_upper = self._get_web_lang_code(to_lang, 'iso2').upper()

        # Linguee uses a canonical language-pair slug: English is always
        # first in any English↔X pair (e.g. /english-dutch/, never
        # /dutch-english/), and source=auto handles the query direction.
        # Non-canonical URLs redirect/error – particularly badly inside the
        # embedded QWebEngineView. Swap sl_full/tl_full for this resource
        # when one side is English and it isn't already first. For non-
        # English pairs (e.g. French↔German), fall back to alphabetical
        # ordering – this mirrors Linguee's actual slug scheme.
        if resource.get('id') == 'linguee' and sl_full and tl_full:
            if 'english' in (sl_full, tl_full):
                if sl_full != 'english':
                    sl_full, tl_full = tl_full, sl_full
            elif sl_full > tl_full:
                sl_full, tl_full = tl_full, sl_full
        
        # Build URL from template
        url = resource['url_template']
        url = url.replace('{query}', encoded_query)
        url = url.replace('{sl}', sl)
        url = url.replace('{tl}', tl)
        url = url.replace('{sl_full}', sl_full)
        url = url.replace('{tl_full}', tl_full)
        url = url.replace('{sl_upper}', sl_upper)
        url = url.replace('{tl_upper}', tl_upper)
        
        # Handle OPUS corpus placeholder
        if '{opus_corpus}' in url and hasattr(self, 'current_opus_corpus'):
            url = url.replace('{opus_corpus}', self.current_opus_corpus)
        
        return url
    
    def _on_opus_corpus_changed(self, index):
        """Handle OPUS corpus selection change"""
        if hasattr(self, 'opus_corpus_combo'):
            self.current_opus_corpus = self.opus_corpus_combo.currentData()
            # If we have a search query, refresh the OPUS view
            if self.last_web_search_query and hasattr(self, 'web_views'):
                resource = self.web_resources[self.current_web_resource_index]
                if resource.get('has_corpus_selector') and resource['id'] in self.web_views:
                    url = self._build_web_search_url(resource, self.last_web_search_query)
                    self.web_views[resource['id']].setUrl(QUrl(url))
    
    def _get_web_lang_code(self, lang_code, format_type):
        """Convert language code to the format needed by different web services"""
        if not lang_code or not format_type:
            return ''
        
        # Normalize input - handle full language names or codes
        lang_lower = lang_code.lower()
        
        # Language code mappings
        # ISO-2 codes (2-letter): en, nl, de, fr, es, etc.
        iso2_map = {
            'english': 'en', 'en': 'en', 'en-us': 'en', 'en-gb': 'en', 'en-au': 'en',
            'dutch': 'nl', 'nl': 'nl', 'nl-nl': 'nl', 'nl-be': 'nl', 'nederlands': 'nl',
            'german': 'de', 'de': 'de', 'de-de': 'de', 'de-at': 'de', 'de-ch': 'de', 'deutsch': 'de',
            'french': 'fr', 'fr': 'fr', 'fr-fr': 'fr', 'fr-be': 'fr', 'fr-ca': 'fr', 'français': 'fr',
            'spanish': 'es', 'es': 'es', 'es-es': 'es', 'es-mx': 'es', 'español': 'es',
            'italian': 'it', 'it': 'it', 'it-it': 'it', 'italiano': 'it',
            'portuguese': 'pt', 'pt': 'pt', 'pt-pt': 'pt', 'pt-br': 'pt', 'português': 'pt',
            'polish': 'pl', 'pl': 'pl', 'polski': 'pl',
            'russian': 'ru', 'ru': 'ru', 'русский': 'ru',
            'chinese': 'zh', 'zh': 'zh', 'zh-cn': 'zh', 'zh-tw': 'zh', '中文': 'zh',
            'japanese': 'ja', 'ja': 'ja', '日本語': 'ja',
            'korean': 'ko', 'ko': 'ko', '한국어': 'ko',
            'arabic': 'ar', 'ar': 'ar', 'العربية': 'ar',
            'swedish': 'sv', 'sv': 'sv', 'svenska': 'sv',
            'danish': 'da', 'da': 'da', 'dansk': 'da',
            'norwegian': 'no', 'no': 'no', 'nb': 'no', 'nn': 'no', 'norsk': 'no',
            'finnish': 'fi', 'fi': 'fi', 'suomi': 'fi',
            'czech': 'cs', 'cs': 'cs', 'čeština': 'cs',
            'hungarian': 'hu', 'hu': 'hu', 'magyar': 'hu',
            'romanian': 'ro', 'ro': 'ro', 'română': 'ro',
            'greek': 'el', 'el': 'el', 'ελληνικά': 'el',
            'turkish': 'tr', 'tr': 'tr', 'türkçe': 'tr',
            'ukrainian': 'uk', 'uk': 'uk', 'українська': 'uk',
            'bulgarian': 'bg', 'bg': 'bg', 'български': 'bg',
        }
        
        # ISO-3 codes (3-letter for ProZ): dut, eng, ger, fre, spa, etc. (ISO 639-2/B bibliographic)
        iso3_map = {
            'english': 'eng', 'en': 'eng', 'en-us': 'eng', 'en-gb': 'eng',
            'dutch': 'dut', 'nl': 'dut', 'nl-nl': 'dut', 'nl-be': 'dut', 'nederlands': 'dut',
            'german': 'ger', 'de': 'ger', 'de-de': 'ger', 'deutsch': 'ger',
            'french': 'fre', 'fr': 'fre', 'fr-fr': 'fre', 'français': 'fre',
            'spanish': 'spa', 'es': 'spa', 'es-es': 'spa', 'español': 'spa',
            'italian': 'ita', 'it': 'ita', 'it-it': 'ita', 'italiano': 'ita',
            'portuguese': 'por', 'pt': 'por', 'pt-pt': 'por', 'português': 'por',
            'polish': 'pol', 'pl': 'pol', 'polski': 'pol',
            'russian': 'rus', 'ru': 'rus', 'русский': 'rus',
            'chinese': 'chi', 'zh': 'chi', 'zh-cn': 'chi', '中文': 'chi',
            'japanese': 'jpn', 'ja': 'jpn', '日本語': 'jpn',
            'korean': 'kor', 'ko': 'kor', '한국어': 'kor',
            'arabic': 'ara', 'ar': 'ara', 'العربية': 'ara',
            'swedish': 'swe', 'sv': 'swe', 'svenska': 'swe',
            'danish': 'dan', 'da': 'dan', 'dansk': 'dan',
            'norwegian': 'nor', 'no': 'nor', 'norsk': 'nor',
            'finnish': 'fin', 'fi': 'fin', 'suomi': 'fin',
            'czech': 'cze', 'cs': 'cze', 'čeština': 'cze',
            'hungarian': 'hun', 'hu': 'hun', 'magyar': 'hun',
            'romanian': 'rum', 'ro': 'rum', 'română': 'rum',
            'greek': 'gre', 'el': 'gre', 'ελληνικά': 'gre',
            'turkish': 'tur', 'tr': 'tur', 'türkçe': 'tur',
            'ukrainian': 'ukr', 'uk': 'ukr', 'українська': 'ukr',
            'bulgarian': 'bul', 'bg': 'bul', 'български': 'bul',
        }
        
        # ISO 639-3 codes (for Juremy): nld, eng, deu, fra, spa, etc.
        iso639_3_map = {
            'english': 'eng', 'en': 'eng', 'en-us': 'eng', 'en-gb': 'eng',
            'dutch': 'nld', 'nl': 'nld', 'nl-nl': 'nld', 'nl-be': 'nld', 'nederlands': 'nld',
            'german': 'deu', 'de': 'deu', 'de-de': 'deu', 'deutsch': 'deu',
            'french': 'fra', 'fr': 'fra', 'fr-fr': 'fra', 'français': 'fra',
            'spanish': 'spa', 'es': 'spa', 'es-es': 'spa', 'español': 'spa',
            'italian': 'ita', 'it': 'ita', 'it-it': 'ita', 'italiano': 'ita',
            'portuguese': 'por', 'pt': 'por', 'pt-pt': 'por', 'português': 'por',
            'polish': 'pol', 'pl': 'pol', 'polski': 'pol',
            'russian': 'rus', 'ru': 'rus', 'русский': 'rus',
        }
        
        # Full language names (lowercase for URL slugs)
        full_lower_map = {
            'english': 'english', 'en': 'english', 'en-us': 'english', 'en-gb': 'english',
            'dutch': 'dutch', 'nl': 'dutch', 'nl-nl': 'dutch', 'nl-be': 'dutch', 'nederlands': 'dutch',
            'german': 'german', 'de': 'german', 'de-de': 'german', 'deutsch': 'german',
            'french': 'french', 'fr': 'french', 'fr-fr': 'french', 'français': 'french',
            'spanish': 'spanish', 'es': 'spanish', 'es-es': 'spanish', 'español': 'spanish',
            'italian': 'italian', 'it': 'italian', 'it-it': 'italian', 'italiano': 'italian',
            'portuguese': 'portuguese', 'pt': 'portuguese', 'pt-pt': 'portuguese', 'português': 'portuguese',
            'polish': 'polish', 'pl': 'polish', 'polski': 'polish',
            'russian': 'russian', 'ru': 'russian', 'русский': 'russian',
            'chinese': 'chinese', 'zh': 'chinese', 'zh-cn': 'chinese', '中文': 'chinese',
            'japanese': 'japanese', 'ja': 'japanese', '日本語': 'japanese',
            'korean': 'korean', 'ko': 'korean', '한국어': 'korean',
            'arabic': 'arabic', 'ar': 'arabic', 'العربية': 'arabic',
            'swedish': 'swedish', 'sv': 'swedish', 'svenska': 'swedish',
            'danish': 'danish', 'da': 'danish', 'dansk': 'danish',
            'norwegian': 'norwegian', 'no': 'norwegian', 'norsk': 'norwegian',
            'finnish': 'finnish', 'fi': 'finnish', 'suomi': 'finnish',
            'czech': 'czech', 'cs': 'czech', 'čeština': 'czech',
            'hungarian': 'hungarian', 'hu': 'hungarian', 'magyar': 'hungarian',
            'romanian': 'romanian', 'ro': 'romanian', 'română': 'romanian',
            'greek': 'greek', 'el': 'greek', 'ελληνικά': 'greek',
            'turkish': 'turkish', 'tr': 'turkish', 'türkçe': 'turkish',
            'ukrainian': 'ukrainian', 'uk': 'ukrainian', 'українська': 'ukrainian',
            'bulgarian': 'bulgarian', 'bg': 'bulgarian', 'български': 'bulgarian',
        }
        
        # Select the appropriate mapping
        if format_type == 'iso2':
            return iso2_map.get(lang_lower, lang_lower[:2] if len(lang_lower) >= 2 else 'en')
        elif format_type == 'iso3':
            return iso3_map.get(lang_lower, 'eng')
        elif format_type == 'iso639_3':
            return iso639_3_map.get(lang_lower, 'eng')
        elif format_type == 'full_lower':
            return full_lower_map.get(lang_lower, 'english')
        else:
            return lang_lower
    
    def _open_web_resource_external(self):
        """Open the last search URL in the default browser"""
        if self.last_web_search_url:
            QDesktopServices.openUrl(QUrl(self.last_web_search_url))
        else:
            # If no search has been performed, build URL with current input from source_text
            query = self.source_text.currentText().strip()
            if query:
                self._perform_web_search()
            else:
                self.status_label.setText("Enter a search term in the Source Text field first")
    
    # Setting key + valid values for the Ctrl+Alt+L landing sub-tab.
    SUPERLOOKUP_LANDING_TAB_KEY = 'superlookup_landing_tab'
    SUPERLOOKUP_LANDING_TAB_DEFAULT = 'termbases'
    SUPERLOOKUP_LANDING_TAB_LABELS = {
        'quicktrans': 'QuickTrans',
        'tms': 'TMs',
        'termbases': 'Termbases',
        'webresources': 'Web Resources',
    }

    def create_settings_tab(self):
        """Create the Settings tab with sub-tabs for each resource type"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(5)

        # Header
        header = QLabel("⚙️ Search Resources Configuration")
        header.setStyleSheet("font-size: 14pt; font-weight: bold; color: #1976D2;")
        layout.addWidget(header, 0)

        # Description
        desc = QLabel(
            "Configure which resources to search. Each resource type has its own tab below."
        )
        desc.setWordWrap(True)
        desc.setStyleSheet("color: #666; padding: 5px 0;")
        layout.addWidget(desc, 0)

        # Ctrl+Alt+L landing-tab preference. Lives above the sub-sub-tabs so
        # it's visible regardless of which resource sub-tab is selected.
        # We use a QFrame + QLabel rather than a QGroupBox to dodge the
        # theme_manager's QGroupBox styling, and CheckmarkRadioButton rather
        # than plain QRadioButton because the theme strips QRadioButton's
        # native checked-state indicator (see theme_manager.py:475-486).
        landing_frame = QFrame()
        landing_frame.setFrameShape(QFrame.Shape.NoFrame)
        landing_outer = QVBoxLayout(landing_frame)
        landing_outer.setContentsMargins(0, 5, 0, 5)
        landing_outer.setSpacing(2)

        landing_label = QLabel("Ctrl+Alt+L lands on:")
        landing_label.setStyleSheet("font-weight: bold;")
        landing_outer.addWidget(landing_label)

        landing_row = QHBoxLayout()
        landing_row.setContentsMargins(0, 0, 0, 0)
        landing_row.setSpacing(15)

        self._landing_tab_buttons = {}
        self._landing_tab_button_group = QButtonGroup(landing_frame)
        self._landing_tab_button_group.setExclusive(True)
        current_pref = self._load_superlookup_landing_pref()
        for value, label in self.SUPERLOOKUP_LANDING_TAB_LABELS.items():
            rb = CheckmarkRadioButton(label)
            rb.setChecked(value == current_pref)
            rb.toggled.connect(
                lambda checked, v=value: self._on_landing_pref_changed(v, checked)
            )
            self._landing_tab_buttons[value] = rb
            self._landing_tab_button_group.addButton(rb)
            landing_row.addWidget(rb)
        landing_row.addStretch()
        landing_outer.addLayout(landing_row)
        layout.addWidget(landing_frame, 0)

        # Create sub-tabs for each resource type
        self.settings_subtabs = QTabWidget()
        self.settings_subtabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.settings_subtabs.tabBar().setDrawBase(False)
        self.settings_subtabs.tabBar().setExpanding(False)
        
        # TM Settings sub-tab
        tm_settings_tab = self.create_tm_settings_subtab()
        self.settings_subtabs.addTab(tm_settings_tab, "📖 Translation Memories")
        
        # Termbase Settings sub-tab
        tb_settings_tab = self.create_termbase_settings_subtab()
        self.settings_subtabs.addTab(tb_settings_tab, "📚 Termbases")
        
        # Note: MT Settings removed - now in main Settings → MT Settings
        
        # Web Settings sub-tab
        web_settings_tab = self.create_web_settings_subtab()
        self.settings_subtabs.addTab(web_settings_tab, "🌐 Web Resources")

        # QuickTrans Settings used to be mounted here as a sub-sub-tab
        # when this SuperlookupTab lived inside Sidekick (is_sidekick=
        # True). With Sidekick retired in v1.10.4 / v1.10.10, the
        # QuickTrans settings widget moved to Workbench Settings →
        # ⚡ QuickTrans where it's the sole, canonical mount point.
        # Keeping it out of the SuperLookup tab also keeps
        # _mtql_checkboxes / _mtql_llm_combos on the main window
        # unambiguous (single source of truth).

        layout.addWidget(self.settings_subtabs, stretch=1)

        return tab

    def _load_superlookup_landing_pref(self) -> str:
        """Read the user's preferred Ctrl+Alt+L landing sub-tab from settings."""
        try:
            mw = self.main_window
            if mw and hasattr(mw, 'load_general_settings'):
                settings = mw.load_general_settings() or {}
                value = settings.get(self.SUPERLOOKUP_LANDING_TAB_KEY)
                if value in self.SUPERLOOKUP_LANDING_TAB_LABELS:
                    return value
        except Exception as e:
            print(f"[Superlookup] Could not load landing tab pref: {e}")
        return self.SUPERLOOKUP_LANDING_TAB_DEFAULT

    def _on_landing_pref_changed(self, value: str, checked: bool):
        """Persist the new Ctrl+Alt+L landing sub-tab preference."""
        if not checked:
            return  # only react to the newly-selected button
        try:
            mw = self.main_window
            if mw and hasattr(mw, 'load_general_settings') and hasattr(mw, 'save_general_settings'):
                settings = mw.load_general_settings() or {}
                settings[self.SUPERLOOKUP_LANDING_TAB_KEY] = value
                mw.save_general_settings(settings)
        except Exception as e:
            print(f"[Superlookup] Could not save landing tab pref: {e}")

    def create_tm_settings_subtab(self):
        """Create TM settings sub-tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)
        
        # Info section
        info = QLabel(
            "All Translation Memories are searched by default. "
            "Uncheck any you want to exclude. "
            "Uncheck every TM to disable TM search entirely."
        )
        info.setWordWrap(True)
        info.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
        layout.addWidget(info, 0)

        # TM selection label
        list_label = QLabel("Select Translation Memories:")
        list_label.setStyleSheet("font-weight: bold; padding-top: 10px;")
        layout.addWidget(list_label, 0)
        
        # TM selection scroll area with checkboxes
        self.tm_scroll_area = QScrollArea()
        self.tm_scroll_area.setWidgetResizable(True)
        self.tm_scroll_area.setFrameShape(QFrame.Shape.StyledPanel)
        
        self.tm_scroll_widget = QWidget()
        self.tm_scroll_layout = QVBoxLayout(self.tm_scroll_widget)
        self.tm_scroll_layout.setContentsMargins(5, 5, 5, 5)
        self.tm_scroll_layout.setSpacing(3)
        self.tm_scroll_layout.addStretch()
        
        self.tm_scroll_area.setWidget(self.tm_scroll_widget)
        layout.addWidget(self.tm_scroll_area, stretch=1)  # Takes all available space
        
        # Store checkboxes for easy access
        self.tm_checkboxes = []
        
        # Info label
        tm_info = QLabel("💡 Tip: Unchecking every TM disables TM search for SuperLookup.")
        tm_info.setStyleSheet("color: #666; font-size: 9pt; font-style: italic; padding: 5px 0;")
        layout.addWidget(tm_info, 0)
        
        # TM buttons
        tm_btn_layout = QHBoxLayout()
        select_all_tm_btn = QPushButton("Select All")
        select_all_tm_btn.clicked.connect(self.check_all_tms)
        tm_btn_layout.addWidget(select_all_tm_btn)
        
        clear_all_tm_btn = QPushButton("Clear All")
        clear_all_tm_btn.clicked.connect(self.uncheck_all_tms)
        tm_btn_layout.addWidget(clear_all_tm_btn)
        
        refresh_tm_btn = QPushButton("🔄 Refresh List")
        refresh_tm_btn.clicked.connect(self.refresh_tm_list)
        tm_btn_layout.addWidget(refresh_tm_btn)
        tm_btn_layout.addStretch()
        
        layout.addLayout(tm_btn_layout, 0)
        
        return tab
    
    def create_termbase_settings_subtab(self):
        """Create Termbase settings sub-tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)
        
        # Info section
        info = QLabel(
            "All termbases are searched by default. "
            "Uncheck any you want to exclude. "
            "Uncheck every termbase to disable termbase search entirely."
        )
        info.setWordWrap(True)
        info.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
        layout.addWidget(info, 0)

        # Termbase selection label
        list_label = QLabel("Select Termbases:")
        list_label.setStyleSheet("font-weight: bold; padding-top: 10px;")
        layout.addWidget(list_label, 0)
        
        # Termbase selection scroll area with checkboxes
        self.tb_scroll_area = QScrollArea()
        self.tb_scroll_area.setWidgetResizable(True)
        self.tb_scroll_area.setFrameShape(QFrame.Shape.StyledPanel)
        
        self.tb_scroll_widget = QWidget()
        self.tb_scroll_layout = QVBoxLayout(self.tb_scroll_widget)
        self.tb_scroll_layout.setContentsMargins(5, 5, 5, 5)
        self.tb_scroll_layout.setSpacing(3)
        self.tb_scroll_layout.addStretch()
        
        self.tb_scroll_area.setWidget(self.tb_scroll_widget)
        layout.addWidget(self.tb_scroll_area, stretch=1)  # Takes all available space
        
        # Store checkboxes for easy access
        self.tb_checkboxes = []
        
        # Info label
        tb_info = QLabel("💡 Tip: Unchecking every termbase disables termbase search for SuperLookup.")
        tb_info.setStyleSheet("color: #666; font-size: 9pt; font-style: italic; padding: 5px 0;")
        layout.addWidget(tb_info, 0)
        
        # Termbase buttons
        tb_btn_layout = QHBoxLayout()
        select_all_tb_btn = QPushButton("Select All")
        select_all_tb_btn.clicked.connect(self.check_all_tbs)
        tb_btn_layout.addWidget(select_all_tb_btn)
        
        clear_all_tb_btn = QPushButton("Clear All")
        clear_all_tb_btn.clicked.connect(self.uncheck_all_tbs)
        tb_btn_layout.addWidget(clear_all_tb_btn)
        
        refresh_tb_btn = QPushButton("🔄 Refresh List")
        refresh_tb_btn.clicked.connect(self.refresh_termbase_list)
        tb_btn_layout.addWidget(refresh_tb_btn)
        tb_btn_layout.addStretch()
        
        layout.addLayout(tb_btn_layout, 0)
        
        return tab
    
    def create_mt_settings_subtab(self):
        """Create MT settings sub-tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)
        
        # Info section
        info = QLabel(
            "Machine Translation integration is coming soon.\n"
            "This will allow SuperLookup to query DeepL, Google Translate, and other MT services."
        )
        info.setWordWrap(True)
        info.setStyleSheet("padding: 5px; border-radius: 3px;")
        layout.addWidget(info, 0)
        
        # Enable checkbox (disabled for now)
        self.mt_search_checkbox = CheckmarkCheckBox("✓ Enable Machine Translation (Coming Soon)")
        self.mt_search_checkbox.setChecked(False)
        self.mt_search_checkbox.setEnabled(False)
        self.mt_search_checkbox.setStyleSheet("font-weight: bold; font-size: 11pt; color: #999; padding: 10px 0;")
        layout.addWidget(self.mt_search_checkbox, 0)
        
        # Placeholder content
        placeholder = QLabel("🚧 Under Construction\n\nComing soon:\n• DeepL integration\n• Google Translate\n• Microsoft Translator\n• Amazon Translate\n• ModernMT")
        placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
        placeholder.setStyleSheet("color: #999; padding: 40px; font-size: 11pt;")
        layout.addWidget(placeholder, stretch=1)
        
        return tab
    
    def create_web_settings_subtab(self):
        """Create Web Resources settings sub-tab"""
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(15, 15, 15, 15)
        layout.setSpacing(10)
        
        # Info section
        info = QLabel(
            "All web resources appear in the Web Resources tab by default. "
            "Uncheck any you want to hide. "
            "Uncheck every resource to disable web search entirely."
        )
        info.setWordWrap(True)
        info.setStyleSheet("color: #666; padding: 5px; background-color: #E8F5E9; border-radius: 3px;")
        layout.addWidget(info, 0)

        # Available resources list
        resources_label = QLabel("Available Web Resources:")
        resources_label.setStyleSheet("font-weight: bold; padding-top: 10px;")
        layout.addWidget(resources_label, 0)
        
        # Scroll area for resource checkboxes
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.StyledPanel)
        
        scroll_widget = QWidget()
        scroll_layout = QVBoxLayout(scroll_widget)
        scroll_layout.setContentsMargins(10, 10, 10, 10)
        scroll_layout.setSpacing(5)
        
        # Add checkboxes for each resource (all enabled by default)
        self.web_resource_checkboxes = []
        default_resources = [
            ('🇪🇺 IATE', 'EU terminology database - supports language direction'),
            ('📗 Linguee', 'Bilingual dictionary with context sentences'),
            ('💬 ProZ.com', 'Translator terminology database - supports language direction'),
            ('🔄 Reverso Context', 'Context-based translations with examples'),
            ('🔍 Google Search', 'General web search'),
            ('📜 Google Patents', 'Search patents for terminology'),
            ('📖 Wikipedia (Source)', 'Wikipedia in source language'),
            ('📖 Wikipedia (Target)', 'Wikipedia in target language'),
            ('⚖️ Juremy', 'Legal terminology database'),
            ('📚 Beijerterm', 'Dutch-English terminology database (500k+ terms)'),
            ('🔤 AcronymFinder', 'Acronym and abbreviation dictionary'),
            ('🌐 BabelNet', 'Multilingual encyclopedic dictionary'),
            ('📓 Wiktionary (Source)', 'Wiktionary in source language'),
            ('📓 Wiktionary (Target)', 'Wiktionary in target language'),
        ]
        
        for i, (name, desc) in enumerate(default_resources):
            cb = CheckmarkCheckBox(name)
            cb.setChecked(True)
            cb.setToolTip(desc)
            # Connect to update sidebar visibility when checkbox changes
            cb.stateChanged.connect(lambda state, idx=i: self._on_web_resource_checkbox_changed(idx, state))
            scroll_layout.addWidget(cb)
            self.web_resource_checkboxes.append(cb)
        
        scroll_layout.addStretch()
        scroll.setWidget(scroll_widget)
        layout.addWidget(scroll, stretch=1)
        
        # Future: Add custom URL section
        future_label = QLabel("💡 Tip: More web resources can be added in future updates")
        future_label.setStyleSheet("color: #666; font-size: 9pt; font-style: italic; padding: 5px 0;")
        layout.addWidget(future_label, 0)
        
        return tab
    
    def on_results_tab_changed(self, index):
        """Handle results tab change.

        Lazy-activation: each sub-tab's work is fired here when (and only
        when) the user navigates to it.

        - Settings tab: refresh resource lists.
        - Web Resources tab: fire any pending web search (deferred from
          perform_lookup so we don't pay the QWebEngineView creation cost
          when the user never opens this tab).
        - QuickTrans tab: fire any pending MT fan-out (deferred so we don't
          spawn N provider HTTP calls when the user never opens this tab,
          and don't momentarily create the hidden WindowStaysOnTopHint
          dialog that the MTQuickPopup constructor would otherwise create —
          which disturbs Z-order and pushes Sidekick behind Trados).

        Look up tabs dynamically by title so this keeps working if more
        tabs are added / removed later.
        """
        settings_index = -1
        web_index = -1
        quicktrans_index = -1
        for i in range(self.results_tabs.count()):
            label = self.results_tabs.tabText(i)
            if settings_index < 0 and "Settings" in label:
                settings_index = i
            if web_index < 0 and "Web Resources" in label:
                web_index = i
            if quicktrans_index < 0 and "QuickTrans" in label:
                quicktrans_index = i

        if index == settings_index and settings_index >= 0:
            print("[Superlookup] Settings tab viewed - refreshing resource lists")
            self.refresh_tm_list()
            self.refresh_termbase_list()
            self.populate_language_dropdowns()

        if index == web_index and web_index >= 0:
            if getattr(self, '_web_search_pending', False):
                self._web_search_pending = False
                if hasattr(self, 'web_browser_mode') and self.web_browser_mode == 'embedded' \
                   and hasattr(self, 'web_engine_available') and self.web_engine_available:
                    try:
                        self._perform_web_search(search_all=True, silent=True)
                    except Exception as e:
                        print(f"[SuperLookup] Deferred web search error: {e}")
                elif hasattr(self, 'web_browser_mode') and self.web_browser_mode == 'external':
                    try:
                        self._perform_web_search(search_all=False, silent=True)
                    except Exception as e:
                        print(f"[SuperLookup] Deferred web search (external) error: {e}")

        # NOTE (v1.10.10): the QuickTrans-sub-tab deferred dispatch
        # path was removed when Sidekick was retired. It only fired
        # for SuperLookup widgets hosted inside Sidekick (is_sidekick
        # =True), which mounted QuickTrans as a sub-sub-tab. The
        # in-Workbench SuperLookup tab doesn't host QuickTrans
        # internally – QuickTrans now lives directly in Workbench
        # Settings → ⚡ QuickTrans – so there's nothing to defer.
    
    def on_tm_search_toggled(self, state):
        """Handle TM search checkbox toggle"""
        self.search_tm_enabled = (state == Qt.CheckState.Checked.value)
        print(f"[Superlookup] TM search {'enabled' if self.search_tm_enabled else 'disabled'}")
    
    def on_termbase_search_toggled(self, state):
        """Handle termbase search checkbox toggle"""
        self.search_termbase_enabled = (state == Qt.CheckState.Checked.value)
        print(f"[Superlookup] Termbase search {'enabled' if self.search_termbase_enabled else 'disabled'}")
    
    def _on_web_resource_checkbox_changed(self, index: int, state: int):
        """Handle web resource checkbox change - show/hide corresponding sidebar button"""
        is_checked = (state == Qt.CheckState.Checked.value)
        if hasattr(self, 'web_resource_buttons') and index < len(self.web_resource_buttons):
            self.web_resource_buttons[index].setVisible(is_checked)
            print(f"[Superlookup] Web resource {index} {'shown' if is_checked else 'hidden'}")
            
            # If the hidden resource was selected, select the first visible one
            if not is_checked and hasattr(self, 'current_web_resource_index') and self.current_web_resource_index == index:
                for i, btn in enumerate(self.web_resource_buttons):
                    if btn.isVisible():
                        self._on_web_resource_selected(i)
                        btn.setChecked(True)
                        break
    
    def refresh_tm_list(self):
        """Refresh the list of available TMs"""
        # Clear existing checkboxes
        for checkbox in self.tm_checkboxes:
            self.tm_scroll_layout.removeWidget(checkbox)
            checkbox.deleteLater()
        self.tm_checkboxes.clear()
        
        print(f"[Superlookup] refresh_tm_list called")
        print(f"[Superlookup]   main_window exists: {self.main_window is not None}")
        
        # Get TMs from main window's database
        if self.main_window and hasattr(self.main_window, 'db_manager') and self.main_window.db_manager:
            try:
                print(f"[Superlookup]   db_manager found, querying TMs...")
                cursor = self.main_window.db_manager.cursor
                cursor.execute("SELECT id, name, tm_id FROM translation_memories ORDER BY name")
                tms = cursor.fetchall()
                
                print(f"[Superlookup]   Query returned {len(tms)} TMs")
                
                for db_id, tm_name, tm_id_str in tms:
                    checkbox = CheckmarkCheckBox(f"{tm_name} (ID: {db_id})")
                    checkbox.setChecked(True)  # Start checked; user deselects to exclude.
                    # Unchecking every TM disables TM search entirely.
                    checkbox.setProperty("tm_id", tm_id_str)  # Store tm_id string for search
                    checkbox.setProperty("db_id", db_id)  # Store db_id for reference
                    self.tm_checkboxes.append(checkbox)
                    # Insert before the stretch at the end
                    self.tm_scroll_layout.insertWidget(len(self.tm_checkboxes) - 1, checkbox)
                
                print(f"[Superlookup] ✓ Loaded {len(tms)} TMs")
            except Exception as e:
                print(f"[Superlookup] ✗ Error loading TMs: {e}")
                import traceback
                traceback.print_exc()
        else:
            print(f"[Superlookup]   db_manager not available")
            # Add placeholder label
            placeholder = QLabel("No database connection - TMs unavailable")
            placeholder.setStyleSheet("color: #999; font-style: italic;")
            self.tm_scroll_layout.insertWidget(0, placeholder)
    
    def refresh_termbase_list(self):
        """Refresh the list of available termbases"""
        # Clear existing checkboxes
        for checkbox in self.tb_checkboxes:
            self.tb_scroll_layout.removeWidget(checkbox)
            checkbox.deleteLater()
        self.tb_checkboxes.clear()
        
        print(f"[Superlookup] refresh_termbase_list called")
        print(f"[Superlookup]   main_window exists: {self.main_window is not None}")
        
        # Try termbase_mgr first (preferred method)
        if self.main_window and hasattr(self.main_window, 'termbase_mgr') and self.main_window.termbase_mgr:
            try:
                print(f"[Superlookup]   termbase_mgr found, querying termbases...")
                termbases = self.main_window.termbase_mgr.get_all_termbases()
                
                print(f"[Superlookup]   get_all_termbases() returned {len(termbases)} termbases")
                
                for tb in termbases:
                    tb_id = tb.get('id')
                    tb_name = tb.get('name', 'Unnamed')
                    checkbox = CheckmarkCheckBox(f"{tb_name} (ID: {tb_id})")
                    checkbox.setChecked(True)  # Start checked; user deselects to exclude.
                    # Unchecking every termbase disables termbase search entirely.
                    checkbox.setProperty("tb_id", tb_id)
                    self.tb_checkboxes.append(checkbox)
                    # Insert before the stretch at the end
                    self.tb_scroll_layout.insertWidget(len(self.tb_checkboxes) - 1, checkbox)

                print(f"[Superlookup] ✓ Loaded {len(termbases)} termbases via termbase_mgr")
                return
            except Exception as e:
                print(f"[Superlookup] ✗ Error loading termbases via termbase_mgr: {e}")
                import traceback
                traceback.print_exc()
        
        # Fallback to direct database query
        if self.main_window and hasattr(self.main_window, 'db_manager') and self.main_window.db_manager:
            try:
                print(f"[Superlookup]   db_manager found, querying termbases...")
                cursor = self.main_window.db_manager.cursor
                cursor.execute("SELECT id, name FROM termbases ORDER BY name")
                termbases = cursor.fetchall()
                
                print(f"[Superlookup]   Query returned {len(termbases)} termbases")
                
                for tb_id, tb_name in termbases:
                    checkbox = CheckmarkCheckBox(f"{tb_name} (ID: {tb_id})")
                    checkbox.setChecked(True)  # Start checked; user deselects to exclude.
                    # Unchecking every termbase disables termbase search entirely.
                    checkbox.setProperty("tb_id", tb_id)
                    self.tb_checkboxes.append(checkbox)
                    # Insert before the stretch at the end
                    self.tb_scroll_layout.insertWidget(len(self.tb_checkboxes) - 1, checkbox)
                
                print(f"[Superlookup] ✓ Loaded {len(termbases)} termbases via db_manager")
            except Exception as e:
                print(f"[Superlookup] ✗ Error loading termbases via db_manager: {e}")
                import traceback
                traceback.print_exc()
        else:
            print(f"[Superlookup]   Neither termbase_mgr nor db_manager available")
            # Add placeholder label
            placeholder = QLabel("No database connection - Termbases unavailable")
            placeholder.setStyleSheet("color: #999; font-style: italic;")
            self.tb_scroll_layout.insertWidget(0, placeholder)
    
    def get_selected_tm_ids(self):
        """Get list of checked TM IDs"""
        selected_ids = []
        for checkbox in self.tm_checkboxes:
            if checkbox.isChecked():
                tm_id = checkbox.property("tm_id")
                if tm_id is not None:
                    selected_ids.append(tm_id)
        return selected_ids
    
    def get_search_direction(self):
        """Get the current search direction setting.
        
        Always returns 'both' for maximum search coverage.
        Smart language swapping ensures results are always shown in correct order.
        """
        return 'both'
    
    def _on_language_changed(self):
        """Handle language dropdown change - update Web Resources info"""
        # Update the web resources language info label
        self._update_web_lang_info()
    
    def get_language_filters(self):
        """Get the current language filter settings.
        
        Returns:
            tuple: (from_lang, to_lang) - None means 'Any'
        """
        from_lang = None
        to_lang = None
        
        if hasattr(self, 'lang_from_combo'):
            from_lang = self.lang_from_combo.currentData()
        if hasattr(self, 'lang_to_combo'):
            to_lang = self.lang_to_combo.currentData()
        
        return (from_lang, to_lang)
    
    def swap_language_filters(self):
        """Swap the From and To language selections"""
        if hasattr(self, 'lang_from_combo') and hasattr(self, 'lang_to_combo'):
            from_idx = self.lang_from_combo.currentIndex()
            to_idx = self.lang_to_combo.currentIndex()
            self.lang_from_combo.setCurrentIndex(to_idx)
            self.lang_to_combo.setCurrentIndex(from_idx)
    
    def populate_language_dropdowns(self):
        """Populate language dropdowns with BASE languages only (not variants).
        
        Groups all variants (en, en-US, en-GB, English, etc.) into a single "English" entry.
        When searching, all variants will be matched.
        """
        all_languages = set()  # All language codes/names found in database
        
        # Ensure we have db_manager from main window
        db_manager = self.db_manager
        if not db_manager and self.main_window and hasattr(self.main_window, 'db_manager'):
            db_manager = self.main_window.db_manager
        
        # Ensure we have termbase_mgr from main window
        termbase_mgr = self.termbase_mgr
        if not termbase_mgr and self.main_window and hasattr(self.main_window, 'termbase_mgr'):
            termbase_mgr = self.main_window.termbase_mgr
        
        # Get languages from TMs via database
        if db_manager:
            try:
                db_manager.cursor.execute("""
                    SELECT DISTINCT source_lang FROM translation_units WHERE source_lang IS NOT NULL AND source_lang != ''
                    UNION
                    SELECT DISTINCT target_lang FROM translation_units WHERE target_lang IS NOT NULL AND target_lang != ''
                """)
                for row in db_manager.cursor.fetchall():
                    if row[0]:
                        all_languages.add(row[0])
            except Exception:
                pass  # Silent failure for language population
        
        # Get languages from termbases
        if termbase_mgr:
            try:
                all_termbases = termbase_mgr.get_all_termbases()
                for tb in all_termbases:
                    if tb.get('source_lang'):
                        all_languages.add(tb['source_lang'])
                    if tb.get('target_lang'):
                        all_languages.add(tb['target_lang'])
            except Exception:
                pass  # Silent failure for language population
        
        # Group languages by their base language name
        # E.g., "en", "en-US", "en-GB", "English" all map to "English"
        base_lang_variants = {}  # base_name -> set of all variant codes
        
        for lang in all_languages:
            base_name = self._get_base_language_name(lang)
            if base_name not in base_lang_variants:
                base_lang_variants[base_name] = set()
            base_lang_variants[base_name].add(lang)
        
        # Store the mapping for use in searches
        self._language_variants_map = base_lang_variants
        
        # Sort base languages alphabetically
        sorted_base_langs = sorted(base_lang_variants.keys(), key=str.lower)
        
        # Clear existing items except "Any"
        while self.lang_from_combo.count() > 1:
            self.lang_from_combo.removeItem(1)
        while self.lang_to_combo.count() > 1:
            self.lang_to_combo.removeItem(1)
        
        # Add base languages only (store list of all variants as data)
        for base_name in sorted_base_langs:
            variants = list(base_lang_variants[base_name])
            # Store variants list as the data for this item
            self.lang_from_combo.addItem(base_name, variants)
            self.lang_to_combo.addItem(base_name, variants)
    
    def _get_base_language_name(self, lang_code):
        """Extract the base language name from any language code or name.
        
        Maps all variants to a single base name:
        - "en", "en-US", "en-GB", "English", "english" → "English"
        - "nl", "nl-NL", "nl-BE", "Dutch", "Nederlands" → "Dutch"
        """
        # Base language names mapping
        base_names = {
            'en': 'English', 'english': 'English',
            'nl': 'Dutch', 'dutch': 'Dutch', 'nederlands': 'Dutch',
            'de': 'German', 'german': 'German', 'deutsch': 'German',
            'fr': 'French', 'french': 'French', 'français': 'French', 'francais': 'French',
            'es': 'Spanish', 'spanish': 'Spanish', 'español': 'Spanish', 'espanol': 'Spanish',
            'it': 'Italian', 'italian': 'Italian', 'italiano': 'Italian',
            'pt': 'Portuguese', 'portuguese': 'Portuguese', 'português': 'Portuguese', 'portugues': 'Portuguese',
            'zh': 'Chinese', 'chinese': 'Chinese',
            'ja': 'Japanese', 'japanese': 'Japanese',
            'ko': 'Korean', 'korean': 'Korean',
            'ru': 'Russian', 'russian': 'Russian',
            'pl': 'Polish', 'polish': 'Polish', 'polski': 'Polish',
            'cs': 'Czech', 'czech': 'Czech',
            'da': 'Danish', 'danish': 'Danish', 'dansk': 'Danish',
            'sv': 'Swedish', 'swedish': 'Swedish', 'svenska': 'Swedish',
            'no': 'Norwegian', 'nb': 'Norwegian', 'nn': 'Norwegian', 'norwegian': 'Norwegian', 'norsk': 'Norwegian',
            'fi': 'Finnish', 'finnish': 'Finnish', 'suomi': 'Finnish',
            'ar': 'Arabic', 'arabic': 'Arabic',
            'he': 'Hebrew', 'hebrew': 'Hebrew',
            'tr': 'Turkish', 'turkish': 'Turkish',
            'el': 'Greek', 'greek': 'Greek',
            'hu': 'Hungarian', 'hungarian': 'Hungarian', 'magyar': 'Hungarian',
            'ro': 'Romanian', 'romanian': 'Romanian',
            'bg': 'Bulgarian', 'bulgarian': 'Bulgarian',
            'uk': 'Ukrainian', 'ukrainian': 'Ukrainian',
            'vi': 'Vietnamese', 'vietnamese': 'Vietnamese',
            'th': 'Thai', 'thai': 'Thai',
            'id': 'Indonesian', 'indonesian': 'Indonesian',
            'ms': 'Malay', 'malay': 'Malay',
            'sk': 'Slovak', 'slovak': 'Slovak',
            'sl': 'Slovenian', 'slovenian': 'Slovenian',
            'hr': 'Croatian', 'croatian': 'Croatian',
            'sr': 'Serbian', 'serbian': 'Serbian',
            'et': 'Estonian', 'estonian': 'Estonian',
            'lv': 'Latvian', 'latvian': 'Latvian',
            'lt': 'Lithuanian', 'lithuanian': 'Lithuanian',
            'ca': 'Catalan', 'catalan': 'Catalan',
            'ga': 'Irish', 'irish': 'Irish',
            'mt': 'Maltese', 'maltese': 'Maltese',
        }
        
        if not lang_code:
            return 'Unknown'
        
        # Normalize: lowercase for lookup
        lang_lower = lang_code.lower().strip()
        
        # Get base code (before hyphen or underscore)
        # Handles: en-US, en_GB, nl-NL, etc.
        base_code = lang_lower.split('-')[0].split('_')[0]
        
        # First try exact match
        if lang_lower in base_names:
            return base_names[lang_lower]
        
        # Then try base code
        if base_code in base_names:
            return base_names[base_code]
        
        # Unknown language - capitalize first letter
        return lang_code.capitalize() if lang_code else 'Unknown'
    
    def get_selected_termbase_ids(self):
        """Get list of checked termbase IDs"""
        selected_ids = []
        for checkbox in self.tb_checkboxes:
            if checkbox.isChecked():
                tb_id = checkbox.property("tb_id")
                if tb_id is not None:
                    selected_ids.append(tb_id)
        return selected_ids
    
    def check_all_tms(self):
        """Check all TM checkboxes"""
        for checkbox in self.tm_checkboxes:
            checkbox.setChecked(True)
    
    def uncheck_all_tms(self):
        """Uncheck all TM checkboxes"""
        for checkbox in self.tm_checkboxes:
            checkbox.setChecked(False)
    
    def check_all_tbs(self):
        """Check all termbase checkboxes"""
        for checkbox in self.tb_checkboxes:
            checkbox.setChecked(True)
    
    def uncheck_all_tbs(self):
        """Uncheck all termbase checkboxes"""
        for checkbox in self.tb_checkboxes:
            checkbox.setChecked(False)
    
    def __del__(self):
        """Destructor - cleanup AHK process when widget is destroyed"""
        try:
            self.unregister_global_hotkey()
        except:
            pass
    
    def capture_text(self):
        """
        Capture text from the active application (triggered by global hotkey).
        """
        if not self.SuperlookupEngine:
            return
        
        # Initialize engine if needed
        if not self.engine:
            self.engine = self.SuperlookupEngine(mode='universal')
            
            # Set TM database if available
            if self.tm_database:
                self.engine.set_tm_database(self.tm_database)
        
        self.status_label.setText("⏳ Capturing text...")
        QApplication.processEvents()
        
        # Capture text
        text = self.engine.capture_text()
        
        if text:
            self.source_text.setCurrentText(text)
            self.status_label.setText(f"✓ Captured {len(text)} characters. Searching...")
            # Auto-search after capture
            self.perform_lookup()
        else:
            self.status_label.setText("✗ No text captured. Try again.")
    
    def perform_lookup(self):
        """Perform lookup on the source text.

        TM and termbase searches run on a background QRunnable so the UI stays
        responsive even with very large termbases. Results stream back via
        signals (tm_ready / termbase_ready / finished). MT and web-search
        stay on the main thread for now — MT is fast (early-outs in Sidekick,
        parallel via ThreadPoolExecutor in main window), web-search is slow
        but its cost is QWebEngineView creation, not SQLite.
        """
        text = self.source_text.currentText().strip()

        if not text:
            self.status_label.setText("⚠️ No text to search. Enter or capture text first.")
            return

        # Add to search history
        self._add_to_search_history(text)

        # Always ensure we have the latest databases from main window
        if self.main_window:
            if hasattr(self.main_window, 'tm_database') and self.main_window.tm_database:
                self.tm_database = self.main_window.tm_database
            if hasattr(self.main_window, 'termbase_mgr') and self.main_window.termbase_mgr:
                self.termbase_mgr = self.main_window.termbase_mgr
            if hasattr(self.main_window, 'db_manager') and self.main_window.db_manager:
                self.db_manager = self.main_window.db_manager

        # Populate language dropdowns if not yet populated (fallback)
        if not self._languages_populated and self.db_manager:
            self.populate_language_dropdowns()
            self._languages_populated = True

        if not self.engine:
            # Initialize engine
            self.engine = SuperlookupEngine(mode='universal')

        # Always update the engine with current tm_database
        if self.tm_database and self.engine:
            self.engine.set_tm_database(self.tm_database)

        self.status_label.setText("🔍 Searching...")

        # Cancel any in-flight search so its results don't overwrite ours when
        # they arrive late. Disconnect signals first so even if the worker
        # emits after we've moved on, the slots aren't called.
        prev = getattr(self, '_active_search_worker', None)
        if prev is not None:
            try:
                prev.cancel()
            except Exception:
                pass
            for sig in (prev.signals.tm_ready, prev.signals.termbase_ready,
                        prev.signals.finished, prev.signals.error):
                try:
                    sig.disconnect()
                except Exception:
                    pass

        # Set enabled TM IDs from Superlookup's own checkboxes (independent selection).
        selected_tm_ids = self.get_selected_tm_ids()
        search_direction = self.get_search_direction()
        from_lang, to_lang = self.get_language_filters()
        tm_search_disabled = bool(self.tm_checkboxes) and not selected_tm_ids

        if self.engine:
            self.engine.set_enabled_tm_ids(selected_tm_ids if selected_tm_ids else None)

        # Clear panels immediately so the user doesn't see stale data while
        # the new search is in flight.
        try:
            self.display_tm_results([])
        except Exception:
            pass
        try:
            self.display_termbase_results([])
        except Exception:
            pass

        # Track the latest counts so _on_search_finished can update the status.
        self._search_in_flight_tm = []
        self._search_in_flight_termbase = []

        # Dispatch the heavy DB work to a worker thread.
        worker = _SuperLookupSearchWorker(
            tab=self,
            text=text,
            search_direction=search_direction,
            from_lang=from_lang,
            to_lang=to_lang,
            selected_tm_ids=selected_tm_ids,
            tm_search_disabled=tm_search_disabled,
        )
        worker.signals.tm_ready.connect(self._on_search_tm_ready)
        worker.signals.termbase_ready.connect(self._on_search_termbase_ready)
        worker.signals.finished.connect(self._on_search_finished)
        self._active_search_worker = worker
        QThreadPool.globalInstance().start(worker)

        # MT and supermemory still run synchronously on the main thread:
        # _perform_mt_lookup early-outs in the Sidekick context (no
        # mt_results_table) and is parallelised via ThreadPoolExecutor
        # otherwise, so it doesn't block significantly. Supermemory is a no-op
        # currently. Both are cheap compared to TM/termbase SQL.
        self._search_in_flight_supermemory = self.search_supermemory(text) or 0
        try:
            mt_results = self._perform_mt_lookup(text, from_lang, to_lang)
            self.display_mt_results(mt_results)
        except Exception as e:
            print(f"[SuperLookup] MT lookup error: {e}")

        # Web Resources – DEFERRED. We do NOT fire _perform_web_search here.
        # Creating 16+ QWebEngineViews per search is heavy (each spawns a
        # Chromium subprocess, touches OS-level windowing, and the cumulative
        # effect on slower machines disrupts Sidekick's Z-order — Trados
        # Studio aggressively reclaims foreground when our process is busy
        # creating subprocesses, and Sidekick ends up behind Trados even
        # though it was never hidden in Qt's view of the world).
        #
        # Instead, we just remember that a web search is pending. When (and
        # only when) the user actually navigates to the Web Resources sub-tab,
        # on_results_tab_changed fires the search. If the user never opens
        # the Web Resources tab — and most users using SuperLookup as a
        # termbase/TM lookup don't — we never pay the cost at all.
        any_web_checked = True
        if hasattr(self, 'web_resource_checkboxes') and self.web_resource_checkboxes:
            any_web_checked = any(cb.isChecked() for cb in self.web_resource_checkboxes)

        if hasattr(self, 'web_browser_mode') and any_web_checked:
            self._web_search_pending = True
            # If the user is already viewing the Web Resources sub-tab, fire
            # the web search now. on_results_tab_changed only fires the
            # deferred search on a tab *change*, so a re-search from the Web
            # Resources tab would otherwise leave the web views stuck on the
            # previous query.
            web_index = -1
            if hasattr(self, 'results_tabs'):
                for i in range(self.results_tabs.count()):
                    if "Web Resources" in self.results_tabs.tabText(i):
                        web_index = i
                        break
            if web_index >= 0 and self.results_tabs.currentIndex() == web_index:
                self._web_search_pending = False
                try:
                    if self.web_browser_mode == 'embedded' and getattr(self, 'web_engine_available', False):
                        self._perform_web_search(search_all=True, silent=True)
                    elif self.web_browser_mode == 'external':
                        self._perform_web_search(search_all=False, silent=True)
                except Exception as e:
                    print(f"[SuperLookup] Active-tab web search error: {e}")
        else:
            self._web_search_pending = False

    def _on_search_tm_ready(self, results):
        """Slot: TM concordance results have arrived from the worker."""
        self._search_in_flight_tm = results or []
        try:
            self.display_tm_results(self._search_in_flight_tm)
        except Exception as e:
            print(f"[SuperLookup] display_tm_results error: {e}")

    def _on_search_termbase_ready(self, results):
        """Slot: termbase results have arrived from the worker."""
        self._search_in_flight_termbase = results or []
        try:
            self.display_termbase_results(self._search_in_flight_termbase)
        except Exception as e:
            print(f"[SuperLookup] display_termbase_results error: {e}")

    def _on_search_finished(self):
        """Slot: worker done – update the status line with final counts."""
        tm_count = len(getattr(self, '_search_in_flight_tm', []) or [])
        tb_count = len(getattr(self, '_search_in_flight_termbase', []) or [])
        sm_count = getattr(self, '_search_in_flight_supermemory', 0) or 0

        parts = []
        if tm_count:
            parts.append(f"TM: {tm_count}")
        if tb_count:
            parts.append(f"Termbase: {tb_count}")
        if sm_count:
            parts.append(f"Supermemory: {sm_count}")

        total = tm_count + tb_count + sm_count
        if parts:
            self.status_label.setText(f"✓ Found {total} results ({', '.join(parts)})")
        else:
            self.status_label.setText("No results found")

        # Drop the worker reference so it can be garbage-collected.
        self._active_search_worker = None
    
    def search_with_query(self, query: str, switch_to_vertical: bool = True, 
                          source_lang: str = None, target_lang: str = None):
        """
        Public method to trigger a search from outside (e.g., from Ctrl+K).
        
        Args:
            query: Text to search for
            switch_to_vertical: If True, switches to vertical (list) view mode
            source_lang: Optional source language code to set
            target_lang: Optional target language code to set
        """
        # Set the query text (this is used by all tabs including Web Resources)
        self.source_text.setCurrentText(query)
        
        # Reset language dropdowns to "Any" (index 0) for unrestricted search by default
        # This ensures we always start with "From Any to Any"
        if hasattr(self, 'lang_from_combo'):
            self.lang_from_combo.setCurrentIndex(0)  # "Any"
        if hasattr(self, 'lang_to_combo'):
            self.lang_to_combo.setCurrentIndex(0)  # "Any"
        
        # Optionally switch to vertical view (traditional concordance layout)
        if switch_to_vertical and hasattr(self, 'tm_view_vertical_radio'):
            self.tm_view_vertical_radio.setChecked(True)
        
        # Switch to TM Matches tab
        if hasattr(self, 'results_tabs'):
            self.results_tabs.setCurrentIndex(0)  # TM Matches is first tab
        
        # Perform the lookup (TM, Termbase, Supermemory, MT)
        self.perform_lookup()
        
        # Pre-load all web resources in embedded mode
        if hasattr(self, 'web_browser_mode') and self.web_browser_mode == 'embedded':
            if hasattr(self, 'search_all_web_resources'):
                self.search_all_web_resources(query)
    
    def set_project_languages(self, source_lang: str = None, target_lang: str = None):
        """
        Set the language dropdowns to match the project's source and target languages.
        Called when a project is loaded to auto-configure Superlookup.
        
        Args:
            source_lang: Source language name or code (e.g., "Dutch", "nl")
            target_lang: Target language name or code (e.g., "English", "en")
        """
        # Make sure language dropdowns are populated first
        if not self._languages_populated:
            self.populate_language_dropdowns()
            self._languages_populated = True
        
        # Set the source language (From dropdown)
        if source_lang and hasattr(self, 'lang_from_combo'):
            self._set_language_combo(self.lang_from_combo, source_lang)
        
        # Set the target language (To dropdown)
        if target_lang and hasattr(self, 'lang_to_combo'):
            self._set_language_combo(self.lang_to_combo, target_lang)
        
        # Update web resources language info label if available
        if hasattr(self, '_on_language_changed'):
            self._on_language_changed()
    
    def _set_language_combo(self, combo, lang_code):
        """Set a language combo box to a specific language code"""
        if not combo or not lang_code:
            return
        
        # Try to find the language in the combo
        lang_lower = lang_code.lower()
        for i in range(combo.count()):
            item_data = combo.itemData(i)  # This is now a list of variants
            item_text = combo.itemText(i).lower()
            
            # Match by data (list of language variants) or text (display name)
            if item_data and isinstance(item_data, list):
                # Check if the lang_code matches any variant in the list
                if any(variant.lower() == lang_lower for variant in item_data):
                    combo.setCurrentIndex(i)
                    return
            elif item_data and isinstance(item_data, str) and item_data.lower() == lang_lower:
                # Legacy support for string data
                combo.setCurrentIndex(i)
                return
            if lang_lower in item_text:
                combo.setCurrentIndex(i)
                return
    
    def toggle_tm_view_mode(self, checked):
        """Toggle between horizontal (table) and vertical (list) view modes"""
        if self.tm_view_horizontal_radio.isChecked():
            self.tm_view_stack.setCurrentIndex(0)  # Horizontal/Table view
        else:
            self.tm_view_stack.setCurrentIndex(1)  # Vertical/List view
        
        # Re-display results if we have any stored
        if hasattr(self, '_current_tm_results') and self._current_tm_results:
            self.display_tm_results(self._current_tm_results)
    
    def display_tm_results(self, results):
        """Display TM results in both horizontal (table) and vertical (list) views"""
        # Store results for view toggling
        self._current_tm_results = results

        # Get search term for highlighting
        search_text = self.source_text.currentText().strip().lower()

        # Get language names from main window, normalised to base language
        # names (e.g. 'en-US' → 'English') and stripped of any "(variant)"
        # suffix so the column headers stay compact.
        source_lang_name = "Source"
        target_lang_name = "Target"
        if self.main_window:
            raw_src = getattr(self.main_window, 'source_language', None)
            raw_tgt = getattr(self.main_window, 'target_language', None)
            source_lang_name = self._lang_base_name(raw_src) or "Source"
            target_lang_name = self._lang_base_name(raw_tgt) or "Target"

        # Update column headers with the actual language names
        self.tm_results_table.setHorizontalHeaderLabels(
            [source_lang_name, target_lang_name, "TM"])

        # === Update Horizontal View (Table) ===
        self.tm_results_table.setRowCount(0)
        
        for result in results:
            row = self.tm_results_table.rowCount()
            self.tm_results_table.insertRow(row)

            # Source – QTextEdit cell widget with highlighting + text selection
            source_item = QTableWidgetItem(result.source)
            source_item.setFlags(Qt.ItemFlag.NoItemFlags)
            self.tm_results_table.setItem(row, 0, source_item)
            source_editor = _ReadOnlyHtmlCell(
                result.source, search_term=search_text,
                parent=self.tm_results_table, bold=False)
            self.tm_results_table.setCellWidget(row, 0, source_editor)

            # Target – same pattern
            target_item = QTableWidgetItem(result.target)
            target_item.setFlags(Qt.ItemFlag.NoItemFlags)
            self.tm_results_table.setItem(row, 1, target_item)
            target_editor = _ReadOnlyHtmlCell(
                result.target, search_term=search_text,
                parent=self.tm_results_table, bold=False)
            self.tm_results_table.setCellWidget(row, 1, target_editor)

            # TM name
            tm_name = result.metadata.get('tm_name', 'Unknown')
            tm_item = QTableWidgetItem(tm_name)
            tm_item.setToolTip(tm_name)  # Full name on hover
            # Store metadata for right-click "Open in TM browser"
            tm_item.setData(Qt.ItemDataRole.UserRole, {
                'tm_id': result.metadata.get('tm_id'),
                'tm_name': tm_name,
                'source': result.source,
                'target': result.target,
            })
            self.tm_results_table.setItem(row, 2, tm_item)

        # Resize rows to fit the actual rendered QTextEdit content
        self._resize_html_cell_rows(self.tm_results_table,
                                    cols_with_html=(0, 1),
                                    min_height=24, max_height=120)
        
        # === Update Vertical View (List) ===
        # Use theme colors for HTML content (with fallback for missing theme_manager)
        if self.theme_manager:
            theme = self.theme_manager.current_theme
            text_color = theme.text
            # Use darker backgrounds for TM results to improve readability
            bg_even = theme.window_bg  # Darker than base
            bg_odd = theme.alternate_bg
            border_color = theme.border
            source_label_color = theme.tm_source_label
            target_label_color = theme.tm_target_label
        else:
            # Fallback colors for light theme
            text_color = "#000000"
            bg_even = "#FFFFFF"
            bg_odd = "#EBEBEB"
            border_color = "#CCCCCC"
            source_label_color = "#0066CC"
            target_label_color = "#006600"

        html = f"<h3 style='color: {text_color}; margin-bottom: 15px;'>Found {len(results)} TM matches for '<span style='color: {source_label_color};'>{search_text}</span>'</h3>"

        for idx, result in enumerate(results, 1):
            source = result.source
            target = result.target
            tm_name = result.metadata.get('tm_name', 'Unknown')

            # Highlight search term
            highlighted_source = self._highlight_search_term(source, search_text)
            highlighted_target = self._highlight_search_term(target, search_text)

            # Alternating background colors from theme (using darker colors)
            bg_color = bg_even if idx % 2 == 0 else bg_odd

            html += f"""
            <div style='background-color: {bg_color}; padding: 10px 8px; margin: 0; color: {text_color};'>
                <div style='color: {text_color}; font-size: 11px; margin-bottom: 6px;'>
                    #{idx} - TM: <b>{tm_name}</b>
                </div>
                <div style='margin-bottom: 4px; color: {text_color};'>
                    <b style='color: {source_label_color};'>{source_lang_name}:</b> {highlighted_source}
                </div>
                <div style='color: {text_color};'>
                    <b style='color: {target_label_color};'>{target_lang_name}:</b> {highlighted_target}
                </div>
            </div>
            <hr style='border: none; border-top: 1px solid {border_color}; margin: 0;'>
            """
        
        self.tm_results_vertical.setHtml(html)

    def _lang_base_name(self, lang):
        """Return the base-language display name for a code or display string.

        Handles ISO codes ('en', 'en-US') via the main window's
        _normalize_language_code mapping, and strips any trailing
        "(variant)" suffix from display strings like "English (US)".
        Returns None when the input is empty.
        """
        if not lang:
            return None
        name = lang
        # Prefer the main window's normalisation if available – it maps
        # 'en'/'en-US'/'en-GB' all to 'English', etc.
        try:
            if self.main_window and hasattr(self.main_window, '_normalize_language_code'):
                name = self.main_window._normalize_language_code(lang)
        except Exception:
            pass
        # Strip trailing " (…)" if present (e.g. "English (US)" → "English")
        if name and '(' in name:
            name = name.split('(', 1)[0].strip()
        return name or None

    def _highlight_search_term(self, text, search_term):
        """Highlight search term in text with theme-aware background.

        Case-insensitive highlighting that preserves original case.
        """
        import re
        if not search_term or not text:
            return text

        # Escape HTML special characters first
        html_text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

        # Use theme colors for highlight (with fallback for missing theme_manager)
        if self.theme_manager:
            theme = self.theme_manager.current_theme
            highlight_bg = theme.tm_highlight_bg
            highlight_text = theme.tm_highlight_text
        else:
            # Fallback colors - yellow highlight
            highlight_bg = "#FFFF00"
            highlight_text = "#000000"

        # Case-insensitive replacement with theme-aware highlight
        # Use regex to preserve original case
        pattern = re.compile(re.escape(search_term), re.IGNORECASE)
        highlighted = pattern.sub(
            lambda m: f'<span style="background-color: {highlight_bg}; color: {highlight_text}; padding: 1px 2px;">{m.group(0)}</span>',
            html_text
        )
        
        return highlighted
    
    def _resize_html_cell_rows(self, table, cols_with_html=(), min_height=24, max_height=120):
        """Resize table rows based on the actual rendered height of the
        QTextEdit cell widgets in the specified columns.

        Qt's built-in resizeRowsToContents() measures QTableWidgetItem text,
        not cell widgets, so it gives wrong results when cells use
        setCellWidget(). This helper walks every row, asks each QTextEdit
        cell widget for the height its content needs at the current column
        width, and sets the row to the tallest value within the bounds.
        """
        for row in range(table.rowCount()):
            needed = min_height
            for col in cols_with_html:
                widget = table.cellWidget(row, col)
                if widget is None:
                    continue
                # Force layout at the current cell width so wrapping is applied
                width = table.columnWidth(col) - 8  # account for padding
                if width < 40:
                    width = 40
                doc = widget.document()
                doc.setTextWidth(width)
                # Add a few px of vertical padding so descenders aren't clipped
                h = int(doc.size().height()) + 4
                if h > needed:
                    needed = h
            if needed < min_height:
                needed = min_height
            if needed > max_height:
                needed = max_height
            table.setRowHeight(row, needed)

    def display_termbase_results(self, results):
        """Display termbase results with search term highlighting and metadata"""
        self.termbase_results_table.setRowCount(0)

        # Get search term for highlighting
        search_text = self.source_text.currentText().strip().lower()

        # Update column headers with the current project's base language names
        # (e.g. 'en-US' → 'English'). Falls back to "Source"/"Target" if no
        # project is loaded or the language is unknown.
        src_header = "Source"
        tgt_header = "Target"
        if self.main_window:
            raw_src = getattr(self.main_window, 'source_language', None)
            raw_tgt = getattr(self.main_window, 'target_language', None)
            src_header = self._lang_base_name(raw_src) or "Source"
            tgt_header = self._lang_base_name(raw_tgt) or "Target"
        self.termbase_results_table.setHorizontalHeaderLabels(
            [src_header, tgt_header, "Termbase", "Domain", "Notes"])
        
        # Filter duplicates: if same source→target exists in multiple glossaries,
        # only keep the one from the project glossary (ranking=1) or first seen
        filtered_results = []
        seen_pairs = {}  # (source_lower, target_lower) -> (result, ranking)

        for result in results:
            source_lower = result.source.lower()
            target_lower = result.target.lower()
            pair_key = (source_lower, target_lower)

            metadata = result.metadata or {}
            ranking = metadata.get('ranking') or 99

            if pair_key in seen_pairs:
                existing_result, existing_ranking = seen_pairs[pair_key]
                if ranking < existing_ranking:
                    seen_pairs[pair_key] = (result, ranking)
            else:
                seen_pairs[pair_key] = (result, ranking)

        # Convert filtered dict back to list
        filtered_results = [result for result, _ in seen_pairs.values()]
        
        for result in filtered_results:
            row = self.termbase_results_table.rowCount()
            self.termbase_results_table.insertRow(row)
            
            # Get metadata
            metadata = result.metadata or {}
            termbase_name = metadata.get('termbase', '')
            domain = metadata.get('domain', '')
            notes = metadata.get('notes', '')
            
            # Source term – QTextEdit with syntax highlighter for native text selection
            source_item = QTableWidgetItem(result.source)
            source_item.setFlags(Qt.ItemFlag.NoItemFlags)
            self.termbase_results_table.setItem(row, 0, source_item)
            source_editor = _ReadOnlyHtmlCell(
                result.source, search_term=search_text,
                parent=self.termbase_results_table, bold=False)
            self.termbase_results_table.setCellWidget(row, 0, source_editor)

            # Target term – same pattern
            target_item = QTableWidgetItem(result.target)
            target_item.setFlags(Qt.ItemFlag.NoItemFlags)
            self.termbase_results_table.setItem(row, 1, target_item)
            target_editor = _ReadOnlyHtmlCell(
                result.target, search_term=search_text,
                parent=self.termbase_results_table, bold=False)
            self.termbase_results_table.setCellWidget(row, 1, target_editor)
            
            # Termbase name
            tb_item = QTableWidgetItem(termbase_name)
            tb_item.setToolTip(termbase_name)
            self.termbase_results_table.setItem(row, 2, tb_item)
            
            # Domain
            domain_item = QTableWidgetItem(domain)
            domain_item.setToolTip(domain)
            self.termbase_results_table.setItem(row, 3, domain_item)
            
            # Notes (truncated display)
            notes_display = notes[:30] + '...' if len(notes) > 30 else notes
            notes_item = QTableWidgetItem(notes_display)
            notes_item.setToolTip(notes)  # Full notes on hover
            self.termbase_results_table.setItem(row, 4, notes_item)
            
            # Store metadata in first column item for right-click navigation
            source_editor.setProperty('termbase_id', metadata.get('termbase_id'))
            source_editor.setProperty('source_term', result.source)
            source_editor.setProperty('termbase_name', termbase_name)

        # Resize rows to fit the actual rendered QTextEdit content – short
        # entries get short rows, long entries wrap to taller rows.
        self._resize_html_cell_rows(self.termbase_results_table,
                                    cols_with_html=(0, 1),
                                    min_height=24, max_height=120)
    
    def _show_termbase_result_context_menu(self, position):
        """Show context menu for termbase results with option to navigate to term"""
        # Get the row at click position
        row = self.termbase_results_table.rowAt(position.y())
        if row < 0:
            return
        
        # Get the source label widget which has the metadata
        source_widget = self.termbase_results_table.cellWidget(row, 0)
        if not source_widget:
            return
        
        termbase_id = source_widget.property('termbase_id')
        source_term = source_widget.property('source_term')
        termbase_name = source_widget.property('termbase_name')
        
        # Create context menu
        menu = QMenu(self.termbase_results_table)
        
        # Copy actions
        copy_target_action = menu.addAction("📋 Copy Translation")
        copy_source_action = menu.addAction("📋 Copy Source Term")
        
        menu.addSeparator()
        
        # Navigate to termbase action
        if termbase_id and termbase_name:
            edit_action = menu.addAction(f"✏️ Edit in Termbase: {termbase_name}")
            edit_action.setData({'termbase_id': termbase_id, 'source_term': source_term, 'termbase_name': termbase_name})
        else:
            edit_action = None
        
        # Show menu and handle selection
        action = menu.exec(self.termbase_results_table.mapToGlobal(position))
        
        if action == copy_target_action:
            self.copy_selected_termbase_target()
        elif action == copy_source_action:
            if source_term:
                QApplication.clipboard().setText(source_term)
                self.status_label.setText(f"Copied source term: {source_term}")
        elif action == edit_action and edit_action:
            data = action.data()
            self._navigate_to_termbase_entry(data['termbase_id'], data['source_term'])
    
    def _navigate_to_termbase_entry(self, termbase_id, source_term):
        """Navigate to a specific termbase entry in Project resources > Termbases tab"""
        try:
            # Navigate to Project resources > Termbases
            if hasattr(self, 'main_window') and self.main_window:
                main = self.main_window

                # Bring the main Workbench window to the foreground
                if main.isMinimized():
                    main.showNormal()
                main.show()
                main.raise_()
                main.activateWindow()
                import sys
                if sys.platform == 'win32':
                    try:
                        import ctypes
                        hwnd = int(main.winId())
                        fg = ctypes.windll.user32.GetForegroundWindow()
                        fg_thread = ctypes.windll.user32.GetWindowThreadProcessId(fg, None)
                        our_thread = ctypes.windll.kernel32.GetCurrentThreadId()
                        if fg_thread != our_thread:
                            ctypes.windll.user32.AttachThreadInput(fg_thread, our_thread, True)
                            ctypes.windll.user32.SetForegroundWindow(hwnd)
                            ctypes.windll.user32.AttachThreadInput(fg_thread, our_thread, False)
                        else:
                            ctypes.windll.user32.SetForegroundWindow(hwnd)
                    except Exception:
                        pass

                # Switch to the Termbases tab (index 2 in the post-v1.9.422
                # top-level layout: Editor/TMs/Termbases/AI/Tools/Settings).
                # Termbases used to be a sub-tab under "Resources" – it was
                # promoted to a peer of Editor/AI/Tools/Settings in v1.9.422,
                # so this nav doesn't need to drill through resources_tabs
                # any more.
                if hasattr(main, 'main_tabs'):
                    for i in range(main.main_tabs.count()):
                        if 'Termbases' in main.main_tabs.tabText(i):
                            main.main_tabs.setCurrentIndex(i)
                            break
                
                # Find and select the termbase in the list
                if hasattr(main, 'termbase_table'):
                    for row in range(main.termbase_table.rowCount()):
                        name_item = main.termbase_table.item(row, 1)  # Name column stores ID in UserRole
                        if name_item and name_item.data(Qt.ItemDataRole.UserRole) == termbase_id:
                            main.termbase_table.selectRow(row)
                            # Trigger loading of terms for this termbase
                            if hasattr(main, '_on_termbase_selected'):
                                main._on_termbase_selected()
                            break
                
                # Use the filter box to find the specific term (works with pagination)
                if hasattr(main, 'terms_search_box') and source_term:
                    from PyQt6.QtCore import QTimer
                    def set_filter_and_select():
                        # Set the filter to the source term - this will trigger server-side search
                        main.terms_search_box.setText(source_term)
                        # After filter is applied, select the first matching row
                        QTimer.singleShot(300, lambda: self._select_first_term_in_table(main))
                    QTimer.singleShot(200, set_filter_and_select)
                
                self.status_label.setText(f"Navigated to termbase entry: {source_term}")

        except Exception as e:
            print(f"[Superlookup] Error navigating to termbase: {e}")
            self.status_label.setText(f"Error navigating to termbase: {e}")
    
    def _select_first_term_in_table(self, main):
        """Select the first term in the filtered termbase terms table"""
        try:
            if hasattr(main, 'termbase_terms_table'):
                table = main.termbase_terms_table
                if table.rowCount() > 0:
                    table.selectRow(0)
                    item = table.item(0, 0)
                    if item:
                        table.scrollToItem(item)
        except Exception as e:
            print(f"[Superlookup] Error selecting term: {e}")

    def display_mt_results(self, results):
        """Display MT results in the table"""
        # The MT tab was removed from the Superlookup UI (MT is now handled by
        # QuickTrans). When this SuperlookupTab instance was built without the
        # MT tab (e.g. inside the Floating Assistant), mt_results_table does
        # not exist – silently skip so perform_lookup() can continue to the
        # web-resource search that follows.
        if not hasattr(self, 'mt_results_table'):
            return
        self.mt_results_table.setRowCount(0)
        
        if results:
            success_count = 0
            error_count = 0
            
            for result in results:
                row = self.mt_results_table.rowCount()
                self.mt_results_table.insertRow(row)
                
                # Check if this is an error result
                is_error = result.get('is_error', False) if isinstance(result, dict) else False
                
                # Provider name
                provider = result.get('provider', 'MT') if isinstance(result, dict) else getattr(result, 'provider', 'MT')
                provider_item = QTableWidgetItem(provider)
                if is_error:
                    provider_item.setForeground(QColor("#F44336"))  # Red for errors
                    error_count += 1
                else:
                    provider_item.setForeground(QColor("#1976d2"))  # Blue for success
                    success_count += 1
                self.mt_results_table.setItem(row, 0, provider_item)
                
                # Translation text (or error message)
                translation = result.get('translation', '') if isinstance(result, dict) else getattr(result, 'target', '')
                trans_item = QTableWidgetItem(translation)
                trans_item.setToolTip(translation)
                if is_error:
                    trans_item.setForeground(QColor("#F44336"))  # Red text for errors
                    trans_item.setFont(QFont(trans_item.font().family(), -1, -1, True))  # Italic
                self.mt_results_table.setItem(row, 1, trans_item)
                
                # Copy button (only for successful translations)
                if not is_error:
                    copy_btn = QPushButton("📋")
                    copy_btn.setFixedSize(30, 24)
                    copy_btn.setToolTip("Copy translation")
                    copy_btn.clicked.connect(lambda checked, t=translation: self._copy_mt_result(t))
                    self.mt_results_table.setCellWidget(row, 2, copy_btn)
            
            # Update status with breakdown
            if error_count > 0:
                self.mt_status_label.setText(f"✓ Got {success_count} translation(s), ⚠ {error_count} error(s)")
                self.mt_status_label.setStyleSheet("color: #FF9800; font-weight: bold;")
            else:
                self.mt_status_label.setText(f"✓ Got {success_count} translation(s)")
                self.mt_status_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
        else:
            self.mt_status_label.setText("No MT results. Check provider settings.")
            self.mt_status_label.setStyleSheet("color: #666; font-style: italic;")
    
    def _copy_mt_result(self, text):
        """Copy MT result to clipboard"""
        import pyperclip
        pyperclip.copy(text)
        self.status_label.setText(f"✓ Copied: {text[:50]}...")
    
    def on_mt_result_double_click(self, index):
        """Handle double-click on MT result to copy"""
        row = index.row()
        trans_item = self.mt_results_table.item(row, 1)
        if trans_item:
            self._copy_mt_result(trans_item.text())
    
    def _tm_table_context_menu(self, pos):
        """Right-click context menu for TM results table."""
        row = self.tm_results_table.rowAt(pos.y())
        if row < 0:
            return

        source_item = self.tm_results_table.item(row, 0)
        target_item = self.tm_results_table.item(row, 1)
        tm_item = self.tm_results_table.item(row, 2)
        source_text = source_item.text() if source_item else ""
        target_text = target_item.text() if target_item else ""

        from PyQt6.QtWidgets import QMenu
        menu = QMenu(self.tm_results_table)

        if source_text:
            copy_source = menu.addAction("Copy source")
            copy_source.triggered.connect(
                lambda: (pyperclip.copy(source_text),
                         self.status_label.setText(f"\u2713 Copied source: {source_text[:50]}...")))

        if target_text:
            copy_target = menu.addAction("Copy target")
            copy_target.triggered.connect(
                lambda: (pyperclip.copy(target_text),
                         self.status_label.setText(f"\u2713 Copied target: {target_text[:50]}...")))

        if source_text and target_text:
            copy_both = menu.addAction("Copy source \u2192 target")
            copy_both.triggered.connect(
                lambda: (pyperclip.copy(f"{source_text}\t{target_text}"),
                         self.status_label.setText("\u2713 Copied source \u2192 target")))

        # Open in TM Browser for editing
        tm_meta = tm_item.data(Qt.ItemDataRole.UserRole) if tm_item else None
        if tm_meta and tm_meta.get('tm_id'):
            menu.addSeparator()
            open_action = menu.addAction("\u270F Open in TM browser")
            open_action.triggered.connect(
                lambda: self._open_tm_entry_in_browser(tm_meta))

        menu.exec(self.tm_results_table.viewport().mapToGlobal(pos))

    def _open_tm_entry_in_browser(self, tm_meta):
        """Open the TM Manager's Concordance tab pre-filled with this entry's
        source text, so the user can quickly find and edit the matching segment.
        """
        try:
            mw = self.main_window or self.window()
            if not mw or not hasattr(mw, 'show_tm_manager_tab'):
                return
            # Bring the main Workbench window to the foreground
            if mw.isMinimized():
                mw.showNormal()
            mw.show()
            mw.raise_()
            mw.activateWindow()
            import sys
            if sys.platform == 'win32':
                try:
                    import ctypes
                    hwnd = int(mw.winId())
                    fg = ctypes.windll.user32.GetForegroundWindow()
                    fg_thread = ctypes.windll.user32.GetWindowThreadProcessId(fg, None)
                    our_thread = ctypes.windll.kernel32.GetCurrentThreadId()
                    if fg_thread != our_thread:
                        ctypes.windll.user32.AttachThreadInput(fg_thread, our_thread, True)
                        ctypes.windll.user32.SetForegroundWindow(hwnd)
                        ctypes.windll.user32.AttachThreadInput(fg_thread, our_thread, False)
                    else:
                        ctypes.windll.user32.SetForegroundWindow(hwnd)
                except Exception:
                    pass

            # Open TM Manager's Concordance tab pre-filled with the source text
            source_text = tm_meta.get('source', '')
            mw.show_tm_manager_tab(1, initial_concordance_query=source_text)
            self.status_label.setText(
                f"\u2713 Opened TM Manager concordance for \"{source_text[:50]}\""
            )
        except Exception as e:
            self.status_label.setText(f"\u26A0 Could not open TM browser: {e}")

    def on_tm_result_double_click(self, index):
        """Handle double-click on TM result"""
        self.copy_selected_tm_target()

    def copy_selected_tm_target(self):
        """Copy selected TM target to clipboard"""
        selected = self.tm_results_table.selectedItems()
        if selected:
            row = selected[0].row()
            target_item = self.tm_results_table.item(row, 1)
            if target_item:
                pyperclip.copy(target_item.text())
                self.status_label.setText(f"✓ Copied to clipboard: {target_item.text()[:50]}...")

    def insert_selected_tm_target(self):
        """Insert selected TM target into active application"""
        selected = self.tm_results_table.selectedItems()
        if selected:
            row = selected[0].row()
            target_item = self.tm_results_table.item(row, 1)
            if target_item:
                pyperclip.copy(target_item.text())
                self.status_label.setText(
                    f"✓ Copied to clipboard. Press {format_shortcut_for_display('Ctrl+V')} to paste."
                )
                # Could auto-paste here if we wanted to be aggressive
                # pyautogui.hotkey('ctrl', 'v')
    
    def _init_search_history(self):
        """Initialize search history from file."""
        self.search_history = []
        self._search_history_file = None
        
        # Get user_data path from main window
        if self.main_window and hasattr(self.main_window, 'user_data_path'):
            from pathlib import Path
            user_data = Path(self.main_window.user_data_path)
            self._search_history_file = user_data / "settings" / "superlookup_history.json"
            
            # Load existing history
            if self._search_history_file.exists():
                try:
                    import json
                    with open(self._search_history_file, 'r', encoding='utf-8') as f:
                        data = json.load(f)
                        self.search_history = data.get('searches', [])[:20]
                except Exception:
                    pass
    
    def _add_to_search_history(self, text: str):
        """Add a search term to history."""
        if not text or not text.strip():
            return
        
        text = text.strip()
        # Remove if exists, add to front
        if text in self.search_history:
            self.search_history.remove(text)
        self.search_history.insert(0, text)
        self.search_history = self.search_history[:20]  # Keep max 20
        
        # Update dropdown
        if hasattr(self, 'source_text') and hasattr(self.source_text, 'set_history'):
            self.source_text.set_history(self.search_history)
        
        # Save to file
        self._save_search_history()
    
    def _save_search_history(self):
        """Save search history to file."""
        if not self._search_history_file:
            return
        
        try:
            import json
            self._search_history_file.parent.mkdir(parents=True, exist_ok=True)
            with open(self._search_history_file, 'w', encoding='utf-8') as f:
                json.dump({'searches': self.search_history[:20]}, f, ensure_ascii=False, indent=2)
        except Exception:
            pass
    
    def clear_all(self):
        """Clear all text and results"""
        self.source_text.clear()
        self.tm_results_table.setRowCount(0)
        self.termbase_results_table.setRowCount(0)
        self.status_label.setText("Cleared. Ready for new lookup.")
    
    def _normalize_language_code(self, lang: str) -> str:
        """Normalize language code/name to a standard format for comparison.
        
        Converts both full names (English, Dutch) and ISO codes (en, nl)
        to lowercase ISO 2-letter codes for consistent matching.
        """
        if not lang:
            return ''
        
        lang_lower = lang.lower().strip()
        
        # Map full names to ISO codes
        name_to_code = {
            'english': 'en', 'dutch': 'nl', 'german': 'de', 'french': 'fr',
            'spanish': 'es', 'italian': 'it', 'portuguese': 'pt', 'russian': 'ru',
            'chinese': 'zh', 'japanese': 'ja', 'korean': 'ko', 'arabic': 'ar',
            'polish': 'pl', 'czech': 'cs', 'hungarian': 'hu', 'romanian': 'ro',
            'bulgarian': 'bg', 'greek': 'el', 'turkish': 'tr', 'swedish': 'sv',
            'danish': 'da', 'norwegian': 'no', 'finnish': 'fi', 'ukrainian': 'uk',
            'hebrew': 'he', 'thai': 'th', 'vietnamese': 'vi', 'indonesian': 'id',
            'malay': 'ms', 'hindi': 'hi', 'bengali': 'bn', 'tamil': 'ta',
            'catalan': 'ca', 'basque': 'eu', 'galician': 'gl', 'croatian': 'hr',
            'serbian': 'sr', 'slovenian': 'sl', 'slovak': 'sk', 'estonian': 'et',
            'latvian': 'lv', 'lithuanian': 'lt',
        }
        
        # If it's a full name, convert to code
        if lang_lower in name_to_code:
            return name_to_code[lang_lower]
        
        # If it's already an ISO code (2-3 chars), normalize it
        # Handle variants like en-US, nl-BE by taking the base code
        if '-' in lang_lower or '_' in lang_lower:
            base_code = lang_lower.replace('_', '-').split('-')[0]
            return base_code
        
        # Return as-is (already a code like 'en', 'nl')
        return lang_lower
    
    # Hard cap on termbase results to keep the UI responsive when very large
    # termbases are loaded (otherwise we'd build thousands of QTableWidgetItems
    # synchronously and freeze on render).
    SUPERLOOKUP_TERMBASE_RESULT_CAP = 500

    def search_termbases(self, text, source_lang=None, target_lang=None, connection=None):
        """Search Supervertaler termbases for matching terms.

        Uses Superlookup's OWN checkbox selections (independent from Resources > Termbases).
        Respects the search direction setting (source only, target only, or both).

        Args:
            text: Text to search for
            source_lang: Filter by source language - can be a string OR a list of variants (None = any)
            target_lang: Filter by target language - can be a string OR a list of variants (None = any)
            connection: Optional sqlite3.Connection for thread-safe access (used by the
                SuperLookup background search worker). When None, the main-thread
                cursor is used via termbase_mgr.
        """
        results = []
        
        # Normalize language filters to lists of normalized codes for consistent matching
        # Handle both single strings and lists of variants
        if source_lang:
            if isinstance(source_lang, list):
                source_langs_norm = [self._normalize_language_code(lang) for lang in source_lang if lang]
            else:
                source_langs_norm = [self._normalize_language_code(source_lang)]
        else:
            source_langs_norm = None
        
        if target_lang:
            if isinstance(target_lang, list):
                target_langs_norm = [self._normalize_language_code(lang) for lang in target_lang if lang]
            else:
                target_langs_norm = [self._normalize_language_code(target_lang)]
        else:
            target_langs_norm = None
        
        print(f"[DEBUG search_termbases] Called with text='{text[:30]}...', source_lang='{source_lang}' (norm: {source_langs_norm}), target_lang='{target_lang}' (norm: {target_langs_norm})", flush=True)
        print(f"[DEBUG search_termbases] termbase_mgr: {self.termbase_mgr is not None}, db_manager: {self.db_manager is not None}, main_window: {self.main_window is not None}", flush=True)
        
        if not self.termbase_mgr or not self.db_manager or not self.main_window:
            print(f"[DEBUG search_termbases] Early return - missing managers!", flush=True)
            return results
        
        try:
            # Get search direction
            direction = self.get_search_direction()
            
            # Get termbases selected in Superlookup's Settings tab (independent selection).
            # NEW BEHAVIOUR (all-checked-by-default model): unchecking every
            # termbase disables termbase search entirely. If the checkbox
            # list hasn't been built yet (no checkboxes at all), fall back to
            # searching all termbases so first-use returns results.
            selected_tb_ids = self.get_selected_termbase_ids()
            all_termbases = self.termbase_mgr.get_all_termbases(connection=connection)

            if self.tb_checkboxes and not selected_tb_ids:
                # User has explicitly unchecked every termbase → disabled.
                print("[DEBUG search_termbases] All termbases deselected – skipping termbase search.")
                return results

            if selected_tb_ids:
                # Filter to only selected termbases
                termbases_to_search = [tb for tb in all_termbases if tb['id'] in selected_tb_ids]
            else:
                # Checkbox list not yet built – search everything.
                termbases_to_search = all_termbases
            
            text_lower = text.lower()
            
            # Search each selected termbase
            for termbase in termbases_to_search:
                termbase_id = termbase['id']
                
                # Check termbase language filters - normalize for comparison
                tb_source_lang = termbase.get('source_lang', '')
                tb_target_lang = termbase.get('target_lang', '')
                tb_source_norm = self._normalize_language_code(tb_source_lang)
                tb_target_norm = self._normalize_language_code(tb_target_lang)
                
                print(f"[DEBUG search_termbases] Checking TB '{termbase['name']}': source='{tb_source_lang}' (norm: '{tb_source_norm}'), target='{tb_target_lang}' (norm: '{tb_target_norm}')")
                
                # Skip termbases that don't match language filters
                # Compare normalized codes - match if TB's language is in the list of variants
                if source_langs_norm and tb_source_norm:
                    if tb_source_norm not in source_langs_norm:
                        print(f"[DEBUG search_termbases] Skipping '{termbase['name']}' - source lang mismatch: '{tb_source_norm}' not in {source_langs_norm}")
                        continue
                if target_langs_norm and tb_target_norm:
                    if tb_target_norm not in target_langs_norm:
                        print(f"[DEBUG search_termbases] Skipping '{termbase['name']}' - target lang mismatch: '{tb_target_norm}' not in {target_langs_norm}")
                        continue
                
                print(f"[DEBUG search_termbases] Searching in '{termbase['name']}'")
                terms = self.termbase_mgr.get_terms(termbase_id, connection=connection)
                
                for term in terms:
                    source_term = term.get('source_term', '').lower()
                    target_term = term.get('target_term', '').lower()
                    target_term_original = term.get('target_term', '')
                    source_term_original = term.get('source_term', '')
                    
                    # Also check term-level language if available (normalize for comparison)
                    term_source_lang = term.get('source_lang', '')
                    term_target_lang = term.get('target_lang', '')
                    term_source_norm = self._normalize_language_code(term_source_lang) if term_source_lang else ''
                    term_target_norm = self._normalize_language_code(term_target_lang) if term_target_lang else ''
                    
                    # Check if term-level languages match any of the filter variants
                    if source_langs_norm and term_source_norm and term_source_norm not in source_langs_norm:
                        continue
                    if target_langs_norm and term_target_norm and term_target_norm not in target_langs_norm:
                        continue
                    
                    match_found = False
                    
                    # Minimum length for substring matching to avoid spurious single-char matches
                    # If search text is longer than 3 chars, require term to be at least 3 chars for substring match
                    min_term_len = 3 if len(text_lower) > 3 else 1
                    
                    # Check based on search direction
                    if direction == 'source':
                        # Search source only
                        if source_term and len(source_term) >= min_term_len:
                            if source_term in text_lower or text_lower in source_term:
                                match_found = True
                    elif direction == 'target':
                        # Search target only
                        if target_term and len(target_term) >= min_term_len:
                            if target_term in text_lower or text_lower in target_term:
                                match_found = True
                    else:
                        # Both directions (bidirectional)
                        if source_term and len(source_term) >= min_term_len:
                            if source_term in text_lower or text_lower in source_term:
                                match_found = True
                        if target_term and len(target_term) >= min_term_len:
                            if target_term in text_lower or text_lower in target_term:
                                match_found = True
                    
                    if match_found:
                        # Create LookupResult with full metadata
                        from modules.superlookup import LookupResult
                        results.append(LookupResult(
                            source=source_term_original,
                            target=target_term_original,
                            match_percent=100,
                            source_type='termbase',
                            metadata={
                                'termbase': termbase['name'],
                                'termbase_id': termbase_id,
                                'domain': term.get('domain', ''),
                                'notes': term.get('notes', ''),
                                'project': term.get('project', ''),
                                'client': term.get('client', ''),
                                'forbidden': term.get('forbidden', False)
                            }
                        ))
                        if len(results) >= self.SUPERLOOKUP_TERMBASE_RESULT_CAP:
                            break
                if len(results) >= self.SUPERLOOKUP_TERMBASE_RESULT_CAP:
                    print(f"[search_termbases] Hit result cap of {self.SUPERLOOKUP_TERMBASE_RESULT_CAP}; stopping early.")
                    break

            # Remove duplicates
            seen = set()
            unique_results = []
            for result in results:
                key = (result.source.lower(), result.target.lower())
                if key not in seen:
                    seen.add(key)
                    unique_results.append(result)
            
            return unique_results
            
        except Exception as e:
            if hasattr(self.main_window, 'log'):
                self.main_window.log(f"✗ Error searching termbases: {e}")
            return results
    
    def copy_selected_termbase_target(self):
        """Copy selected termbase translation to clipboard"""
        selected = self.termbase_results_table.selectedItems()
        if selected:
            row = selected[0].row()
            target_item = self.termbase_results_table.item(row, 1)
            if target_item:
                pyperclip.copy(target_item.text())
                self.status_label.setText(f"✓ Copied to clipboard: {target_item.text()[:50]}...")
    
    def add_to_termbase(self):
        """Add source text and selected translation as new term to termbase"""
        # Get source text
        source_text = self.source_text.currentText().strip()
        if not source_text:
            QMessageBox.warning(self, "No Source Text", "Please enter or capture source text first.")
            return
        
        # Get selected termbase result if any
        selected = self.termbase_results_table.selectedItems()
        target_text = ""
        if selected:
            row = selected[0].row()
            target_item = self.termbase_results_table.item(row, 1)
            if target_item:
                target_text = target_item.text()
        
        if not self.termbase_mgr or not self.db_manager or not self.main_window:
            QMessageBox.warning(self, "Database Not Available", "Termbase database is not initialized.")
            return
        
        # Show dialog to add term
        self.show_add_term_dialog(source_text, target_text)
    
    def show_add_term_dialog(self, source_text, target_text=""):
        """Show dialog to add new term to glossary"""
        dialog = QDialog(self)
        dialog.setWindowTitle("Add Term to Termbase")
        dialog.setMinimumWidth(500)
        
        layout = QVBoxLayout(dialog)
        
        # Source term
        layout.addWidget(QLabel("Source Term:"))
        source_edit = QLineEdit(source_text)
        layout.addWidget(source_edit)
        
        # Target term
        layout.addWidget(QLabel("Target Translation:"))
        target_edit = QLineEdit(target_text)
        layout.addWidget(target_edit)
        
        # Termbase selection
        layout.addWidget(QLabel("Select Termbase:"))
        termbase_combo = QComboBox()
        
        # Get writable termbases
        try:
            project_id = None
            if hasattr(self.main_window, 'current_project') and self.main_window.current_project:
                project_id = self.main_window.current_project.id if hasattr(self.main_window.current_project, 'id') else None
            
            all_termbases = self.termbase_mgr.get_all_termbases()
            writable_termbases = [tb for tb in all_termbases if not tb.get('read_only', True)]
            
            if not writable_termbases:
                QMessageBox.warning(self, "No Writable Termbases",
                    "No writable termbases found. Please enable Write for at least one termbase in Resources → Termbases.")
                return
            
            for tb in writable_termbases:
                termbase_combo.addItem(tb['name'], tb['id'])
        except Exception as e:
            QMessageBox.warning(self, "Error", f"Could not load termbases: {e}")
            return
        
        layout.addWidget(termbase_combo)
        
        # Buttons
        button_layout = QHBoxLayout()
        button_layout.addStretch()
        
        cancel_btn = QPushButton("Cancel")
        cancel_btn.clicked.connect(dialog.reject)
        button_layout.addWidget(cancel_btn)
        
        add_btn = QPushButton("Add Term")
        add_btn.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold; padding: 8px; border: none; outline: none;")
        add_btn.clicked.connect(dialog.accept)
        button_layout.addWidget(add_btn)
        
        layout.addLayout(button_layout)
        
        # Show dialog
        if dialog.exec() == QDialog.DialogCode.Accepted:
            source = source_edit.text().strip()
            target = target_edit.text().strip()
            termbase_id = termbase_combo.currentData()
            
            if not source or not target:
                QMessageBox.warning(self, "Missing Information", "Both source and target terms are required.")
                return
            
            # Add term to termbase
            try:
                term_id = self.termbase_mgr.add_term(
                    termbase_id=termbase_id,
                    source_term=source,
                    target_term=target,
                    domain="",
                    notes=f"Added via Superlookup",
                    project="",
                    client=""
                )
                
                if term_id is None:
                    # Duplicate detected
                    if hasattr(self.main_window, '_play_sound_effect'):
                        self.main_window._play_sound_effect('glossary_term_duplicate')

                    try:
                        if hasattr(self.main_window, 'statusBar') and self.main_window.statusBar():
                            self.main_window.statusBar().showMessage("⚠ Duplicate termbase entry (not added)", 3500)
                    except Exception:
                        pass

                    QMessageBox.warning(self, "Duplicate Term", 
                        f"This term already exists in {termbase_combo.currentText()}:\n\n{source} → {target}\n\nDuplicate terms are not allowed.")
                    return
                
                if hasattr(self.main_window, 'log'):
                    self.main_window.log(f"✓ Added term to {termbase_combo.currentText()}: {source} → {target}")

                if hasattr(self.main_window, '_play_sound_effect'):
                    self.main_window._play_sound_effect('glossary_term_added')

                try:
                    if hasattr(self.main_window, 'statusBar') and self.main_window.statusBar():
                        self.main_window.statusBar().showMessage(
                            f"✓ Added termbase entry: {source} → {target} (to {termbase_combo.currentText()})",
                            3500,
                        )
                except Exception:
                    pass
                
                QMessageBox.information(self, "Term Added", 
                    f"Successfully added term:\n\n{source} → {target}\n\nto termbase: {termbase_combo.currentText()}")
                
                # Refresh termbase results
                self.perform_lookup()
                
            except Exception as e:
                if hasattr(self.main_window, '_play_sound_effect'):
                    self.main_window._play_sound_effect('glossary_term_error')

                try:
                    if hasattr(self.main_window, 'statusBar') and self.main_window.statusBar():
                        self.main_window.statusBar().showMessage("❌ Error adding termbase entry (see log)", 3500)
                except Exception:
                    pass

                QMessageBox.critical(self, "Error", f"Failed to add term:\n{str(e)}")
    
    def set_tm_database(self, tm_db):
        """Set TM database (called from main window)"""
        self.tm_database = tm_db
        if self.engine:
            self.engine.set_tm_database(tm_db)
    
    def _find_autohotkey_executable(self):
        """Find AutoHotkey executable, checking saved path first
        
        Returns tuple: (exe_path, source) where source is 'saved', 'detected', or None
        """
        # First, check if user has a saved custom path
        if self.main_window and hasattr(self.main_window, 'general_settings'):
            saved_path = self.main_window.general_settings.get('autohotkey_path', '')
            if saved_path and os.path.exists(saved_path):
                print(f"[Superlookup] Using saved AutoHotkey path: {saved_path}")
                return saved_path, 'saved'
        
        # Standard installation paths
        username = os.environ.get('USERNAME', '')
        ahk_paths = [
            r"C:\Program Files\AutoHotkey\v2\AutoHotkey.exe",
            r"C:\Program Files\AutoHotkey\v2\AutoHotkey64.exe",
            r"C:\Program Files\AutoHotkey\AutoHotkey.exe",
            r"C:\Program Files (x86)\AutoHotkey\AutoHotkey.exe",
            fr"C:\Users\{username}\AppData\Local\Programs\AutoHotkey\AutoHotkey.exe",
            # v1 paths
            r"C:\Program Files\AutoHotkey\v1.1\AutoHotkeyU64.exe",
            r"C:\Program Files\AutoHotkey\v1.1\AutoHotkeyU32.exe",
        ]
        
        for path in ahk_paths:
            if os.path.exists(path):
                print(f"[Superlookup] Detected AutoHotkey at: {path}")
                return path, 'detected'
        
        return None, None
    
    def _show_autohotkey_setup_dialog(self):
        """Show dialog to help user install or locate AutoHotkey"""
        dialog = QDialog(self)
        dialog.setWindowTitle("AutoHotkey Setup - SuperLookup")
        dialog.setMinimumWidth(500)
        
        layout = QVBoxLayout(dialog)
        
        # Header
        header = QLabel("⌨️ AutoHotkey Required for Global Hotkey")
        header.setStyleSheet("font-size: 14px; font-weight: bold; margin-bottom: 10px;")
        layout.addWidget(header)
        
        # Explanation
        explanation = QLabel(
            f"SuperLookup uses AutoHotkey to provide a global hotkey ({format_shortcut_for_display('Ctrl+Alt+L')}) "
            "that works from any application.\n\n"
            "Without AutoHotkey, SuperLookup will still work, but only when "
            "Supervertaler is the active window."
        )
        explanation.setWordWrap(True)
        explanation.setStyleSheet("margin-bottom: 15px;")
        layout.addWidget(explanation)
        
        # Options group
        options_group = QGroupBox("Choose an option:")
        options_layout = QVBoxLayout(options_group)
        
        # Option 1: Download AutoHotkey
        download_btn = QPushButton("📥 Download AutoHotkey (Recommended)")
        download_btn.setStyleSheet("padding: 8px; font-size: 11px;")
        download_btn.setToolTip("Opens the AutoHotkey download page in your browser")
        download_btn.clicked.connect(lambda: self._open_ahk_download())
        options_layout.addWidget(download_btn)
        
        # Option 2: Browse for existing installation
        browse_layout = QHBoxLayout()
        browse_btn = QPushButton("📁 Browse for AutoHotkey.exe")
        browse_btn.setStyleSheet("padding: 8px; font-size: 11px;")
        browse_btn.setToolTip("Locate AutoHotkey.exe if you have it installed in a custom location")
        browse_btn.clicked.connect(lambda: self._browse_for_autohotkey(dialog))
        browse_layout.addWidget(browse_btn)
        options_layout.addLayout(browse_layout)
        
        # Option 3: Skip
        skip_btn = QPushButton("⏭️ Skip (Use SuperLookup without global hotkey)")
        skip_btn.setStyleSheet("padding: 8px; font-size: 11px; color: #666;")
        skip_btn.clicked.connect(dialog.reject)
        options_layout.addWidget(skip_btn)
        
        layout.addWidget(options_group)
        
        # Status label (for showing selected path)
        self._ahk_setup_status = QLabel("")
        self._ahk_setup_status.setStyleSheet("color: green; margin-top: 10px;")
        self._ahk_setup_status.setWordWrap(True)
        layout.addWidget(self._ahk_setup_status)
        
        # Note about restart
        note = QLabel(
            "💡 Note: After installing AutoHotkey, restart Supervertaler to enable the global hotkey."
        )
        note.setStyleSheet("color: #666; font-size: 10px; margin-top: 15px;")
        note.setWordWrap(True)
        layout.addWidget(note)
        
        # Don't show again checkbox
        dont_show_cb = CheckmarkCheckBox("Do not show this dialog again")
        dont_show_cb.setStyleSheet("margin-top: 10px;")
        layout.addWidget(dont_show_cb)
        
        # Close button
        close_btn = QPushButton("Close")
        def on_close():
            # Save preference if checkbox is checked
            if dont_show_cb.isChecked():
                if self.main_window and hasattr(self.main_window, 'general_settings'):
                    self.main_window.general_settings['hide_autohotkey_dialog'] = True
                    self.main_window.save_general_settings()
            dialog.accept()
        close_btn.clicked.connect(on_close)
        layout.addWidget(close_btn)
        
        dialog.exec()
    
    def _open_ahk_download(self):
        """Open AutoHotkey download page"""
        import webbrowser
        webbrowser.open("https://www.autohotkey.com/download/")
        QMessageBox.information(
            self,
            "Download AutoHotkey",
            "The AutoHotkey download page has been opened in your browser.\n\n"
            "Download and install AutoHotkey v2.x, then restart Supervertaler."
        )
    
    def _browse_for_autohotkey(self, parent_dialog):
        """Let user browse for AutoHotkey executable"""
        file_path, _ = QFileDialog.getOpenFileName(
            parent_dialog,
            "Locate AutoHotkey Executable",
            "C:\\Program Files",
            "Executable Files (*.exe);;All Files (*.*)"
        )
        
        if file_path:
            # Verify it looks like AutoHotkey
            if 'autohotkey' not in file_path.lower():
                result = QMessageBox.question(
                    parent_dialog,
                    "Confirm Selection",
                    f"The selected file doesn't appear to be AutoHotkey:\n{file_path}\n\n"
                    "Are you sure you want to use this file?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
                )
                if result != QMessageBox.StandardButton.Yes:
                    return
            
            # Save the path
            if self.main_window and hasattr(self.main_window, 'general_settings'):
                self.main_window.general_settings['autohotkey_path'] = file_path
                self.main_window.save_general_settings()
                print(f"[Superlookup] Saved AutoHotkey path: {file_path}")
            
            self._ahk_setup_status.setText(f"✓ Saved: {file_path}\n\nRestart Supervertaler to use this path.")
            
            QMessageBox.information(
                parent_dialog,
                "Path Saved",
                f"AutoHotkey path saved:\n{file_path}\n\n"
                "Please restart Supervertaler to enable the global hotkey."
            )
    
    def register_global_hotkey(self):
        """Register global hotkeys for Superlookup, QuickTrans, Sidekick,
        Clipboard, Voice push-to-talk, and Voice Always-On
        toggle.

        Idempotent: if a manager from a previous registration is still
        running, it is stopped first. That makes this safe to call from
        ``reload_global_hotkeys`` after the user changes a binding in
        Settings \u2192 Keyboard Shortcuts.

        Strategy:
        1. Try WinAPI RegisterHotKey / pynput GlobalHotKeys (cross-platform)
        2. On Windows, fall back to AHK external script if that fails
        3. On macOS/Linux, if pynput fails, hotkeys are unavailable

        Shortcut keys are read from the ShortcutManager (customizable in Settings).
        """
        global _ahk_process, _hotkey_manager

        # Stop any previously-running manager so re-registration applies a
        # clean set of bindings instead of stacking new keys on top of old.
        prev_manager = getattr(self, '_hotkey_manager', None)
        if prev_manager is not None:
            try:
                prev_manager.stop()
            except Exception:
                pass
        self._using_pynput = False
        self._hotkey_manager = None

        # Read customizable shortcuts from ShortcutManager. Each action used
        # to have a separate `global_*` ID; that's been merged into a single
        # entry per action with `global: True`. Legacy IDs still resolve via
        # ShortcutManager._LEGACY_IDS, so old saved settings keep working.
        sm = getattr(self.main_window, 'shortcut_manager', None) if self.main_window else None
        if sm:
            sl_shortcut = sm.get_shortcut('tools_universal_lookup').lower()
            qt_shortcut = sm.get_shortcut('mt_quick_lookup').lower()
            sk_shortcut = sm.get_shortcut('sidekick_open').lower()
            cb_shortcut = sm.get_shortcut('sidekick_open_clipboard').lower()
            pt_shortcut = sm.get_shortcut('voice_dictate').lower()
            ao_shortcut = sm.get_shortcut('voice_alwayson_toggle').lower()

            # Honour the per-shortcut enabled flag and the `global` field:
            # if disabled, or if the entry is no longer flagged global, skip
            # OS-level registration. (In-app QShortcut binding is still set
            # up by the regular create_shortcut path.)
            def _skip(sid):
                return (not sm.is_enabled(sid)) or (not sm.is_global(sid))
            if _skip('tools_universal_lookup'):       sl_shortcut = ''
            if _skip('mt_quick_lookup'):              qt_shortcut = ''
            if _skip('sidekick_open_clipboard'):      cb_shortcut = ''
            if _skip('voice_dictate'):                pt_shortcut = ''
            if _skip('voice_alwayson_toggle'):  ao_shortcut = ''
        else:
            sl_shortcut = 'ctrl+alt+l'
            qt_shortcut = 'ctrl+alt+q'
            cb_shortcut = 'ctrl+shift+c'
            pt_shortcut = 'ctrl+shift+space'
            ao_shortcut = 'ctrl+alt+a'

        # No platform-specific rewrite needed: GlobalHotkeyManager's macOS
        # NSEvent backend now follows Qt's Mac convention internally
        # (stored "Ctrl" → ⌘ Cmd, "Alt" → ⌥ Option, "Meta" → ⌃ Control),
        # so the same shortcut string fires on the SAME physical keystroke
        # whether triggered via QShortcut in-app or via the global hotkey
        # outside the app. The previous alt→cmd rewrite was a workaround
        # for an older NSEvent parser that took modifier names literally
        # and produced different physical chords for local vs global; that
        # parser was fixed in modules/platform_helpers.py:_MacNSEventHotkey._parse.

        # Logging helper. Defined here (above the push-to-talk listener
        # setup) so both the listener block and the RegisterHotKey block
        # below can use the same mirror-to-main-window-log behaviour.
        def _log(m):
            print(m, flush=True)
            if self.main_window and hasattr(self.main_window, 'log'):
                self.main_window.log(m)

        # --- Push-to-talk routing ---
        # Press detection: RegisterHotKey (in the _bindings list below).
        # Release detection: GetAsyncKeyState polling triggered when the
        # press handler fires; no keyboard hook is installed, so this
        # doesn't interfere with AHK's hook chain (which was the problem
        # the earlier pynput.keyboard.Listener-based attempt ran into).
        # See modules/voice_release_poller.py.
        _log(f"[Voice] Push-to-talk: press via RegisterHotKey, release via GetAsyncKeyState polling ({pt_shortcut or 'unset'})")

        # --- Attempt 1: WinAPI / pynput (cross-platform) ---
        import sys as _sys
        try:
            from modules.platform_helpers import GlobalHotkeyManager, CrossPlatformKeySender
            manager = GlobalHotkeyManager()
            _log(f"[Global Hotkeys] Manager available: {manager.is_available}")
            if manager.is_available:
                # Skip empty shortcuts (action disabled or `global` flag off)
                _bindings = [
                    (sl_shortcut, self._on_pynput_superlookup),
                    (qt_shortcut, self._on_pynput_quicktrans),
                    (cb_shortcut, self._on_pynput_clipboard),
                    (pt_shortcut, self._on_pynput_pushtotalk),
                    (ao_shortcut, self._on_pynput_alwayson_toggle),
                ]
                _to_register = [(s, cb) for s, cb in _bindings if s]
                if not _to_register:
                    _log("[Global Hotkeys] Nothing to register (all global shortcuts disabled or unset).")
                    self.hotkey_registered = False
                    return
                for s, cb in _to_register:
                    manager.register(s, cb)
                _log(f"[Global Hotkeys] Registering: {', '.join(s for s, _ in _to_register)}")
                started = manager.start()
                _log(f"[Global Hotkeys] Started: {started}")
                if started:
                    self._hotkey_manager = manager
                    _hotkey_manager = manager  # global for atexit cleanup
                    self._using_pynput = True
                    self.hotkey_registered = True
                    failed = getattr(manager, 'failed_hotkeys', [])
                    if failed:
                        _log(f"\u26A0 [Global Hotkeys] Failed to register: {', '.join(failed)} (claimed by another app)")
                    ok_keys = [s for s, _ in _to_register if s not in failed]
                    _log(f"\u2328 [Global Hotkeys] Registered via {manager._backend}: {', '.join(ok_keys)}")
                    return
                else:
                    _log("[Global Hotkeys] manager.start() returned False")
        except Exception as e:
            _log(f"[Global Hotkeys] Registration failed: {e}")
            import traceback
            traceback.print_exc()
            if IS_MACOS:
                _log("[Global Hotkeys] macOS: grant Accessibility permission "
                     "to whichever binary launched Python (Terminal.app / "
                     "iTerm2.app for terminal launches, Supervertaler.app "
                     "for the bundled app) in System Settings → Privacy & "
                     "Security → Accessibility, then restart Supervertaler.")

        # --- Attempt 2: AHK external script (Windows only) ---
        if IS_WINDOWS:
            self._register_hotkey_external_script()
        else:
            self.hotkey_registered = False

    def _on_pynput_superlookup(self):
        """Called from pynput background thread when Ctrl+Alt+L is pressed.

        IMPORTANT: Do NO work here – pynput callbacks on macOS crash if they
        touch pyperclip, pynput Controller, or Qt objects.  Just signal the
        Qt main thread and return immediately.
        """
        try:
            from PyQt6.QtCore import QMetaObject, Qt as QtConst
            QMetaObject.invokeMethod(
                self, "_handle_superlookup_hotkey",
                QtConst.ConnectionType.QueuedConnection,
            )
        except Exception as e:
            print(f"[Superlookup] Error signaling main thread: {e}")

    def _on_pynput_quicktrans(self):
        """Called from pynput background thread when Ctrl+Alt+Q is pressed.

        IMPORTANT: Do NO work here – see _on_pynput_superlookup docstring.
        """
        try:
            from PyQt6.QtCore import QMetaObject, Qt as QtConst
            QMetaObject.invokeMethod(
                self, "_handle_quicktrans_hotkey",
                QtConst.ConnectionType.QueuedConnection,
            )
        except Exception as e:
            print(f"[QuickTrans] Error signaling main thread: {e}")

    def _on_pynput_clipboard(self):
        """Called from pynput background thread when Ctrl+Shift+C is pressed.

        IMPORTANT: Do NO work here – see _on_pynput_superlookup docstring.
        """
        try:
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(0, self._handle_clipboard_hotkey)
        except Exception as e:
            print(f"[Clipboard] Error signaling main thread: {e}")

    @pyqtSlot()
    def _handle_clipboard_hotkey(self):
        """Runs on Qt main thread.

        v1.10.1 (Phase 2 of issue #199): the hotkey now brings Workbench
        forward and switches to its Clipboard top tab, instead of
        opening Sidekick. Sidekick still works for users who summon it
        manually via Ctrl+Alt+K.

        v1.10.2: the foreground-window handle is captured BEFORE we
        bring Workbench forward so snippet / conversion activations
        in the Clipboard tab can paste-and-return to it (Sidekick's
        well-loved flow). Without this capture the activations would
        just leave the result on the clipboard, forcing the user to
        Alt+Tab back manually.

        v1.10.6: auto-send Ctrl+C to the source app *before* opening
        the Clipboard tab. The old flow forced users to do two
        keystrokes (Ctrl+C to copy, then Ctrl+Alt+C to open the
        Clipboard manager). Now Ctrl+Alt+C alone does both: it
        synthesises Ctrl+C in the foreground app, waits 250ms for
        the clipboard to update, then opens the tab with the
        just-copied text at the top of the history. Users who had
        already copied something can still press Ctrl+Alt+C – the
        synthetic Ctrl+C is a no-op if nothing is selected, and a
        harmless re-copy if something is. Mirrors how Ctrl+Alt+L
        already worked for SuperLookup.
        """
        try:
            mw = self.main_window or self.window()

            # Step 1: capture source window before anything else –
            # send_copy() below doesn't change foreground (AHK just
            # synthesises input events), but we want a clean snapshot.
            source_win = None
            try:
                from modules.platform_helpers import get_foreground_window
                source_win = get_foreground_window()
            except Exception:
                pass

            # Step 2: synthesise Ctrl+C so the user's current
            # selection (if any) lands on the clipboard. The clipboard
            # widget's QClipboard.dataChanged signal will pick it up
            # and prepend it to the history just before we navigate.
            try:
                from modules.platform_helpers import CrossPlatformKeySender
                CrossPlatformKeySender().send_copy()
            except Exception as copy_err:
                print(f"[Clipboard] send_copy failed (non-fatal): {copy_err}")

            # Step 3: after a 250ms breathing room (same delay
            # SuperLookup uses) the OS has dispatched the synthetic
            # Ctrl+C, the source app has populated the clipboard,
            # and QClipboard.dataChanged has fired in our process.
            # *Now* open the Clipboard tab.
            QTimer.singleShot(
                250,
                lambda sw=source_win: self._open_clipboard_after_copy(sw),
            )
        except Exception as e:
            print(f"[Clipboard] Error in clipboard hotkey handler: {e}")

    def _open_clipboard_after_copy(self, source_win):
        """Continuation of _handle_clipboard_hotkey, called 250ms
        after send_copy() so the synthetic Ctrl+C has had time to
        populate the clipboard. Kept as a separate method (rather
        than a closure) so tracebacks are readable."""
        try:
            mw = self.main_window or self.window()
            if mw and hasattr(mw, 'open_workbench_to_clipboard'):
                try:
                    mw.open_workbench_to_clipboard(source_window=source_win)
                    return
                except Exception as e:
                    print(f"[Clipboard] Workbench top-tab route failed: {e}")

            else:
                print("[Clipboard] open_workbench_to_clipboard unavailable")
        except Exception as e:
            print(f"[Clipboard] Error in _open_clipboard_after_copy: {e}")

    def _on_pynput_pushtotalk(self):
        """Voice global push-to-talk hotkey – fires on the pynput
        background thread.

        IMPORTANT: Do NO work here – see _on_pynput_superlookup docstring.
        """
        try:
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(0, self._handle_pushtotalk_hotkey)
        except Exception as e:
            print(f"[Voice] Error signaling main thread: {e}")

    @pyqtSlot()
    def _handle_pushtotalk_hotkey(self):
        """Runs on Qt main thread – start the push-to-talk dictation
        recording on the main Workbench, exactly as F9 does. The dictation
        result is then routed by ``_insert_dictated_text``, which falls
        back to AHK SendText / osascript / pynput typing when focus is
        outside Supervertaler – so this works in any app.

        Hold-to-talk: after starting the recording, kick off a
        ``KeyReleasePoller`` (GetAsyncKeyState polling, no keyboard hook)
        that watches the bound chord and fires
        ``stop_voice_dictation_if_recording`` the moment any required
        key is released. Respects the user's ``pushtotalk_mode``
        setting: ``hold`` → release stops; ``toggle`` → release is
        ignored (next press stops).
        """
        try:
            mw = self.main_window or self.window()
            if mw and hasattr(mw, 'start_voice_dictation'):
                mw.start_voice_dictation()
                # Arm release-detection. Cheap to call repeatedly:
                # set_chord re-parses the user's current binding so a
                # shortcut change picked up between presses is honoured.
                if hasattr(mw, '_get_voice_release_poller'):
                    poller = mw._get_voice_release_poller()
                    if poller is not None:
                        sm = getattr(mw, 'shortcut_manager', None)
                        chord_str = sm.get_shortcut('voice_dictate') if sm else ''
                        if chord_str and poller.set_chord(chord_str):
                            poller.start()
            else:
                print("[Voice] Workbench unavailable for push-to-talk")
        except Exception as e:
            print(f"[Voice] Error in push-to-talk hotkey handler: {e}")

    def _on_pynput_alwayson_toggle(self):
        """Voice Always-On toggle hotkey – fires on the pynput
        background thread.

        IMPORTANT: Do NO work here – see _on_pynput_superlookup docstring.
        """
        try:
            from PyQt6.QtCore import QTimer
            QTimer.singleShot(0, self._handle_alwayson_toggle_hotkey)
        except Exception as e:
            print(f"[Voice] Error signaling main thread: {e}")

    @pyqtSlot()
    def _handle_alwayson_toggle_hotkey(self):
        """Runs on Qt main thread – toggle Always-On listening on/off.

        Unlike push-to-talk (which records a single utterance), this
        starts continuous listening that stays on until pressed again.
        Visual feedback: the system tray icon appears when Always-On is
        active, plus the grid toolbar button + Sidekick Voice tab
        update via _update_alwayson_ui."""
        try:
            mw = self.main_window or self.window()
            if mw and hasattr(mw, '_toggle_alwayson_listening'):
                mw._toggle_alwayson_listening()
            else:
                print("[Voice] Workbench unavailable for Always-On toggle")
        except Exception as e:
            print(f"[Voice] Error in Always-On toggle handler: {e}")

    def _try_ahk_library_method(self):
        """Try to register hotkey using ahk Python library
        
        Returns True if successful, False otherwise
        """
        try:
            from ahk import AHK
            print("[Superlookup] ahk library available, attempting to use it...")
            
            # Find AutoHotkey executable using shared function
            ahk_exe, source = self._find_autohotkey_executable()
            
            # Create AHK instance (with executable path if found)
            if ahk_exe:
                print(f"[Superlookup] Using AutoHotkey at: {ahk_exe} (source: {source})")
                self._ahk = AHK(executable_path=ahk_exe)
            else:
                # Let it try to find AHK on PATH (may fail)
                self._ahk = AHK()
            print(f"[Superlookup] AHK instance created: {self._ahk}")
            
            # Define hotkey callback
            def on_hotkey():
                """Called when Ctrl+Alt+L is pressed"""
                print("[Superlookup] Hotkey triggered via ahk library!")
                try:
                    # Copy selection to clipboard
                    self._ahk.send('^c')  # Ctrl+C
                    time.sleep(0.2)  # Give clipboard time to update
                    
                    # Get clipboard text
                    text = pyperclip.paste()
                    
                    # Activate Supervertaler window
                    try:
                        self._ahk.win_activate('Supervertaler')
                    except Exception as e:
                        print(f"[Superlookup] win_activate error (non-critical): {e}")
                    
                    # Trigger lookup in main thread
                    if text:
                        # Use QTimer to call from main thread
                        QTimer.singleShot(0, lambda: self.on_ahk_capture(text))
                        
                except Exception as e:
                    print(f"[Superlookup] Error in hotkey callback: {e}")
            
            # Register the hotkey
            self._ahk.add_hotkey('^!l', callback=on_hotkey)  # Ctrl+Alt+L
            self._ahk.start_hotkeys()
            
            print("[Superlookup] ✓ Hotkey registered via ahk library: Ctrl+Alt+L")
            self.hotkey_registered = True
            self._using_ahk_library = True
            return True
            
        except ImportError:
            print("[Superlookup] ahk library not installed (pip install ahk)")
            return False
        except Exception as e:
            print(f"[Superlookup] ahk library method failed: {e}")
            # Clean up on failure
            if hasattr(self, '_ahk'):
                try:
                    self._ahk.stop_hotkeys()
                except:
                    pass
                self._ahk = None
            return False
    
    def _register_hotkey_external_script(self):
        """Register hotkey using external AHK script (fallback method)"""
        global _ahk_process
        self._using_ahk_library = False
        try:
            # Kill any existing instances of the AHK script first
            if os.name == 'nt':
                try:
                    # Use multiple methods to ensure cleanup
                    # Method 1: Kill by window title
                    subprocess.run(['taskkill', '/F', '/FI', 'WINDOWTITLE eq supervertaler_hotkeys.ahk*'],
                                 capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
                    
                    # Method 2: Kill AutoHotkey processes more aggressively
                    subprocess.run(['taskkill', '/F', '/IM', 'AutoHotkey.exe'],
                                 capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
                    
                    # Method 3: Kill by process name pattern
                    import psutil
                    try:
                        for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
                            try:
                                if 'supervertaler_hotkeys' in ' '.join(proc.cmdline() or []):
                                    proc.kill()
                            except:
                                pass
                    except:
                        pass
                except:
                    pass
            
            # Find AutoHotkey executable using shared function
            ahk_exe, source = self._find_autohotkey_executable()
            
            if not ahk_exe:
                print("[Hotkeys] AutoHotkey not found.")
                print("[Hotkeys] Global hotkeys (Ctrl+Alt+L, Shift+Shift) will not be available.")
                self.hotkey_registered = False
                # Show setup dialog (deferred to avoid blocking startup) - unless user opted out
                if self.main_window and hasattr(self.main_window, 'general_settings'):
                    if not self.main_window.general_settings.get('hide_autohotkey_dialog', False):
                        QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
                else:
                    QTimer.singleShot(2000, self._show_autohotkey_setup_dialog)
                return
            
            print(f"[Hotkeys] Found AutoHotkey at: {ahk_exe} (source: {source})")
            
            ahk_script = Path(__file__).parent / "supervertaler_hotkeys.ahk"
            print(f"[Hotkeys] Looking for script at: {ahk_script}")
            print(f"[Hotkeys] Script exists: {ahk_script.exists()}")
            
            if ahk_script.exists():
                # Start AHK script in background (hidden)
                self.ahk_process = subprocess.Popen([ahk_exe, str(ahk_script)],
                                                   creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
                # Store in global variable for atexit cleanup
                _ahk_process = self.ahk_process
                print(f"[Hotkeys] AHK hotkeys registered (Ctrl+Alt+L, Shift+Shift)")
                
                # Start file watcher
                self.start_file_watcher()
                self.hotkey_registered = True
            else:
                print(f"[Hotkeys] AHK script not found: {ahk_script}")
                self.hotkey_registered = False
        except Exception as e:
            print(f"[Hotkeys] Could not start AHK hotkeys: {e}")
            self.hotkey_registered = False
    
    def start_file_watcher(self):
        """Watch for signal files from AHK (Superlookup and MT Quick Lookup)"""
        self.signal_file = Path(__file__).parent / "lookup_signal.txt"
        self.capture_file = Path(__file__).parent / "temp_capture.txt"
        self.mt_lookup_signal_file = Path(__file__).parent / "mt_lookup_signal.txt"

        print(f"[Superlookup] File watcher started, watching: {self.signal_file}")
        print(f"[QuickTrans] File watcher started, watching: {self.mt_lookup_signal_file}")
        
        # Create timer to check for signal file
        self.file_check_timer = QTimer()
        self.file_check_timer.timeout.connect(self.check_for_signal)
        self.file_check_timer.start(100)  # Check every 100ms
    
    def check_for_signal(self):
        """Check if AHK wrote a signal file"""
        # Check for Superlookup signal
        if self.signal_file.exists():
            print(f"[Superlookup] Signal file detected!")
            try:
                # Delete signal file
                self.signal_file.unlink()
                print(f"[Superlookup] Signal file deleted")

                # Get text from clipboard (AHK already copied it)
                time.sleep(0.1)  # Give clipboard a moment
                text = pyperclip.paste()

                # Trigger lookup
                if text:
                    self.on_ahk_capture(text)
            except Exception as e:
                print(f"[Superlookup] Error reading capture: {e}")

        # Check for MT Quick Lookup signal
        if hasattr(self, 'mt_lookup_signal_file') and self.mt_lookup_signal_file.exists():
            print(f"[QuickTrans] Signal file detected!")
            try:
                # Small delay to let AHK finish writing/close the file
                time.sleep(0.05)

                # Delete signal file with retry for file lock
                for attempt in range(3):
                    try:
                        self.mt_lookup_signal_file.unlink()
                        print(f"[QuickTrans] Signal file deleted")
                        break
                    except PermissionError:
                        if attempt < 2:
                            time.sleep(0.05)
                        else:
                            raise

                # Get text from clipboard (AHK already copied it)
                time.sleep(0.1)  # Give clipboard a moment
                text = pyperclip.paste()

                # Trigger MT Quick Lookup
                if text:
                    self.on_ahk_mt_lookup_capture(text)
            except Exception as e:
                print(f"[QuickTrans] Error reading capture: {e}")
    
    @pyqtSlot()
    def _handle_superlookup_hotkey(self):
        """Runs on Qt main thread after the global hotkey fires.

        Sends Cmd+C / Ctrl+C to the foreground app, waits for the clipboard
        to update, then dispatches to Superlookup.  Everything here runs on
        the main thread, avoiding the macOS crash caused by calling pyperclip
        or pynput from a background thread.
        """
        try:
            from modules.platform_helpers import CrossPlatformKeySender
            sender = CrossPlatformKeySender()
            sender.send_copy()
            # Read clipboard after a short delay (give OS time to process Cmd+C)
            QTimer.singleShot(250, self._read_clipboard_for_superlookup)
        except Exception as e:
            print(f"[Superlookup] Error in hotkey handler: {e}")

    def _read_clipboard_for_superlookup(self):
        """Read clipboard and dispatch to SuperLookup.

        v1.10.1 (Phase 2 of issue #199): the hotkey now bring Workbench
        forward and switches to its SuperLookup top tab, instead of
        opening Sidekick. Sidekick still works for users who summon it
        manually via Ctrl+Alt+K. Empty clipboard still triggers the
        navigation – without text we skip the auto-search and let the
        user type into the search field manually, which is the same
        visible-feedback contract Sidekick had.
        """
        text = pyperclip.paste() or ""

        mw = self.main_window
        # New path: route to Workbench's SuperLookup top tab.
        if mw and hasattr(mw, 'open_workbench_to_superlookup'):
            try:
                mw.open_workbench_to_superlookup(text)
                return
            except Exception as e:
                print(f"[Superlookup] Workbench top-tab route failed: {e}")
                # Last-ditch fallback: main-window AHK-capture handler.
                if text:
                    self.on_ahk_capture(text)

    @pyqtSlot()
    def _handle_quicktrans_hotkey(self):
        """Runs on Qt main thread after the QuickTrans global hotkey fires."""
        try:
            # If the user is working inside Workbench itself (e.g. the cursor
            # is in a target cell), skip the clipboard round-trip entirely and
            # use the in-app popup. show_mt_quick_popup() already does the
            # right thing: translate the current selection if there is one,
            # otherwise fall back to the whole source segment. The clipboard
            # flow below would instead copy nothing and reuse stale clipboard
            # text when nothing is selected – which is the bug the user hit.
            #
            # NOTE: this handler lives on SuperlookupTab, not the main window,
            # so show_mt_quick_popup() must be called on self.main_window.
            from PyQt6.QtWidgets import QApplication
            main_window = getattr(self, 'main_window', None) or self.window()
            if (QApplication.activeWindow() is not None
                    and main_window is not None
                    and hasattr(main_window, 'show_mt_quick_popup')):
                main_window.show_mt_quick_popup()
                return

            # Otherwise the hotkey came from another application (browser,
            # Trados, etc.). Capture that foreground window BEFORE we do
            # anything, then copy the user's selection to the clipboard.
            from modules.platform_helpers import CrossPlatformKeySender, get_foreground_window
            self._quicktrans_source_window = get_foreground_window()
            print(f"[QuickTrans] Captured source window: {self._quicktrans_source_window}")

            sender = CrossPlatformKeySender()
            sender.send_copy()
            QTimer.singleShot(250, self._read_clipboard_for_quicktrans)
        except Exception as e:
            print(f"[QuickTrans] Error in hotkey handler: {e}")

    def _read_clipboard_for_quicktrans(self):
        """Read clipboard and show the QuickTrans popup.

        Pre-v1.10.4 this opened Sidekick's QuickTrans sub-tab. With
        Sidekick retired, QuickTrans uses the standalone always-on-
        top popup (the same one the editor's right-click → QuickTrans
        triggers). `_quicktrans_source_window` is still captured by
        the hotkey handler so the popup's paste-back flow has the
        original app to return to.
        """
        text = pyperclip.paste() or ""
        if text:
            self.on_ahk_mt_lookup_capture(text)

    def on_ahk_capture(self, text):
        """Handle text captured by AHK"""
        try:
            print(f"[Superlookup] on_ahk_capture called with text: {text[:50]}...")

            # Bring Supervertaler to foreground using platform-native method
            # On Windows: AttachThreadInput + SetForegroundWindow (reliable)
            # On macOS: osascript
            # On Linux: wmctrl / xdotool
            from modules.platform_helpers import activate_window_by_title

            main_window = self.window()
            if main_window:
                # Restore if minimized first (Qt side)
                if main_window.isMinimized():
                    main_window.showNormal()
                elif main_window.isHidden():
                    main_window.show()

                # Use native API to bring window to foreground
                # This uses the window title, which always contains "Supervertaler"
                activate_window_by_title("Supervertaler")

                # Qt side: raise + activate as fallback
                main_window.raise_()
                main_window.activateWindow()

            # Longer delay to allow all window focus events to settle
            # Window activation triggers focus restoration which takes time
            QTimer.singleShot(250, lambda: self.show_superlookup(text))

        except Exception as e:
            print(f"[Superlookup] Error handling capture: {e}")

    def on_ahk_mt_lookup_capture(self, text):
        """Handle MT Quick Lookup text captured by AHK (Ctrl+Alt+Q)"""
        try:
            print(f"[QuickTrans] on_ahk_mt_lookup_capture called with text: {text[:50]}...")

            # Show popup directly without bringing Supervertaler to foreground
            # The popup has WindowStaysOnTopHint so it will appear over any app
            QTimer.singleShot(100, lambda: self.show_mt_quick_lookup_from_ahk(text))

        except Exception as e:
            print(f"[QuickTrans] Error handling capture: {e}")

    def show_mt_quick_lookup_from_ahk(self, text):
        """Show MT Quick Lookup popup with text from AHK capture"""
        try:
            print(f"[QuickTrans] show_mt_quick_lookup_from_ahk called with text: {text[:50]}...")

            # Get main window reference for settings access
            main_window = self.main_window
            if not main_window:
                main_window = self.window()

            if not main_window:
                print("[QuickTrans] ERROR: Could not find main window")
                return

            # Get project languages - prefer project settings over app defaults
            if hasattr(main_window, 'current_project') and main_window.current_project:
                source_lang = main_window.current_project.source_lang or 'en'
                target_lang = main_window.current_project.target_lang or 'nl'
            else:
                source_lang = getattr(main_window, 'source_language', 'English')
                target_lang = getattr(main_window, 'target_language', 'Dutch')

            # Import and show MT Quick Lookup popup
            from modules.quicktrans import MTQuickPopup

            # Create popup without Qt parent so it can appear independently over any app
            # Pass main_window as parent_app for API access
            popup = MTQuickPopup(
                parent_app=main_window,
                source_text=text,
                source_lang=source_lang,
                target_lang=target_lang,
                parent=None,  # No Qt parent - allows independent window
                external_mode=True  # Tool window: no taskbar flash
            )

            # Connect signal: when user selects a translation, copy to clipboard
            # and paste at cursor in the external app (browser, Trados, etc.)
            popup.translation_selected.connect(
                lambda translation: self._paste_translation_to_external_app(translation)
            )

            # Store reference to prevent garbage collection
            self._ahk_mt_popup = popup

            # Show popup on top – Tool window type prevents the main
            # Supervertaler icon from flashing in the taskbar
            popup.show()
            popup.raise_()
            popup.activateWindow()
            print(f"[QuickTrans] Popup shown for text: {text[:30]}...")

        except Exception as e:
            print(f"[QuickTrans] Error showing popup: {e}")
            import traceback
            traceback.print_exc()

    def show_supervertaler_assistant(self, initial_text=None, external_mode=False):
        """Navigate to the Supervertaler Sidekick (AI Assistant tab) and optionally insert text.

        Instead of a separate floating window, this reuses the existing AI Assistant
        inside the AI tab. For external mode, it also brings Supervertaler
        to the foreground first.
        """
        try:
            # Resolve main window
            main_window = self.main_window if hasattr(self, 'main_window') and self.main_window else self
            if hasattr(main_window, 'window') and main_window is not main_window.window():
                main_window = main_window.window()

            # If called from external app, bring Supervertaler to front
            if external_mode:
                main_window.show()
                main_window.raise_()
                main_window.activateWindow()

            # Switch to AI tab (index 3 in the post-v1.9.422 layout:
            # Editor/TMs/Termbases/AI/Tools/Settings)
            if hasattr(main_window, 'main_tabs'):
                main_window.main_tabs.setCurrentIndex(3)

            # Switch to Supervertaler Sidekick sub-tab and insert text
            if hasattr(main_window, 'prompt_manager_qt') and main_window.prompt_manager_qt:
                main_window.prompt_manager_qt.receive_text_for_assistant(
                    initial_text or "", from_external=external_mode
                )

            print(f"[Assistant] Navigated to Supervertaler Sidekick (external={external_mode})")

        except Exception as e:
            print(f"[Assistant] Error navigating to assistant: {e}")
            import traceback
            traceback.print_exc()

    def _paste_translation_to_external_app(self, translation: str):
        """Copy translation to clipboard and paste at cursor in the external app.

        Called when the user selects a translation in the QuickTrans popup that
        was invoked via the global hotkey (Ctrl+Alt+Q) from another application.
        """
        try:
            print(f"[QuickTrans] Pasting translation to external app: {translation[:50]}...")

            # 1. Copy translation to clipboard
            pyperclip.copy(translation)
            print(f"[QuickTrans] Translation copied to clipboard")

            # 2. Re-activate the original app window (browser, Trados, etc.)
            #    that was captured when the hotkey was pressed
            source_window = getattr(self, '_quicktrans_source_window', None)
            if source_window:
                from modules.platform_helpers import activate_foreground_window
                activate_foreground_window(source_window)
                print(f"[QuickTrans] Re-activated source window: {source_window}")

            # 3. After a short delay (let OS process the focus switch),
            #    send Ctrl+V / Cmd+V to paste at cursor
            def _do_paste():
                try:
                    from modules.platform_helpers import CrossPlatformKeySender
                    sender = CrossPlatformKeySender()
                    sender.send_paste()
                    print(f"[QuickTrans] Ctrl+V sent to external app")
                except Exception as e:
                    print(f"[QuickTrans] Error sending paste: {e}")

            # 500ms delay: let the OS complete the focus switch before
            # sending Ctrl+V via AHK to the now-foreground app
            QTimer.singleShot(500, _do_paste)

        except Exception as e:
            print(f"[QuickTrans] Error pasting translation: {e}")

    # show_quicklauncher_external, _show_quicklauncher_external_legacy,
    # and _run_external_quicklauncher_prompt were removed in v1.10.10.
    # They were the Sidekick-era hotkey landing methods plus a QMenu
    # fallback popup. With Sidekick retired, the Ctrl+Alt+K external
    # QuickLauncher hotkey is unregistered and these handlers are
    # unreachable. The right-click QuickLauncher menu inside the
    # editor (which surfaces the same prompts) remains, served by
    # run_grid_quicklauncher_prompt on the main window.

    def set_compact_mode(self, compact=True):
        """Hide header and description to save vertical space (used in floating assistant)."""
        if hasattr(self, '_header_label'):
            self._header_label.setVisible(not compact)
        if hasattr(self, '_description_label'):
            self._description_label.setVisible(not compact)

    def show_superlookup(self, text):
        """Show Superlookup with pre-filled text"""
        try:
            print(f"[Superlookup] show_superlookup called with text: {text[:50]}...")
            
            # Get main window reference
            main_window = self.main_window
            if not main_window:
                main_window = self.window()
            
            print(f"[Superlookup] Main window found: {main_window is not None}")
            print(f"[Superlookup] Main window type: {type(main_window).__name__}")
            print(f"[Superlookup] Has main_tabs: {hasattr(main_window, 'main_tabs')}")
            
            # Switch to Tools tab (main_tabs index 4 in the post-v1.9.422 layout:
            # Grid=0, TMs=1, Termbases=2, AI=3, Tools=4, Settings=5)
            if hasattr(main_window, 'main_tabs'):
                print(f"[Superlookup] Current main_tab index: {main_window.main_tabs.currentIndex()}")
                main_window.main_tabs.setCurrentIndex(4)  # Tools tab
                print(f"[Superlookup] Switched to Tools tab (index 4)")
                QApplication.processEvents()  # Force GUI update
            else:
                print(f"[Superlookup] WARNING: Main window has no main_tabs attribute!")
            
            # Switch to Superlookup within modules_tabs
            if hasattr(main_window, 'modules_tabs'):
                print(f"[Superlookup] Current modules_tab index: {main_window.modules_tabs.currentIndex()}")
                for i in range(main_window.modules_tabs.count()):
                    if "superlookup" in main_window.modules_tabs.tabText(i).lower():
                        main_window.modules_tabs.setCurrentIndex(i)
                        print(f"[Superlookup] Switched to Superlookup tab (index {i})")
                        QApplication.processEvents()  # Force GUI update
                        break
            
            # Delay text input and lookup to ensure tab is fully loaded
            QTimer.singleShot(100, lambda: self._fill_and_search(text))
                    
        except Exception as e:
            print(f"[Superlookup] Error showing lookup: {e}")
    
    def _fill_and_search(self, text):
        """Fill in text and trigger search (called after tab switching completes)"""
        try:
            print(f"[Superlookup] _fill_and_search called")
            # Fill in text and trigger lookup
            if hasattr(self, 'source_text'):
                self.source_text.setCurrentText(text)
                print(f"[Superlookup] Text filled in source_text field")
                # Trigger lookup by calling perform_lookup directly
                self.perform_lookup()
                print(f"[Superlookup] perform_lookup() called")
            else:
                print(f"[Superlookup] ERROR: source_text widget not found!")
        except Exception as e:
            print(f"[Superlookup] Error in _fill_and_search: {e}")
            import traceback
            traceback.print_exc()




# ============================================================================
# AUTOFINGERS DIALOG
# ============================================================================



class PinkCheckmarkCheckBox(QCheckBox):
    """Custom checkbox with pink background and white checkmark when checked (for project termbases)"""
    
    def __init__(self, text="", parent=None):
        super().__init__(text, parent)
        self.setCheckable(True)
        self.setEnabled(True)
        self.setStyleSheet("""
            QCheckBox {
                font-size: 9pt;
                spacing: 6px;
            }
            QCheckBox::indicator {
                width: 16px;
                height: 16px;
                border: 2px solid #999;
                border-radius: 3px;
                background-color: white;
            }
            QCheckBox::indicator:checked {
                background-color: #FFB6C1;
                border-color: #FFB6C1;
            }
            QCheckBox::indicator:hover {
                border-color: #666;
            }
            QCheckBox::indicator:checked:hover {
                background-color: #FF99AD;
                border-color: #FF99AD;
            }
        """)
    
    def paintEvent(self, event):
        """Override paint event to draw white checkmark when checked"""
        super().paintEvent(event)
        
        if self.isChecked():
            from PyQt6.QtWidgets import QStyleOptionButton
            from PyQt6.QtGui import QPainter, QPen, QColor
            from PyQt6.QtCore import QPointF
            
            opt = QStyleOptionButton()
            self.initStyleOption(opt)
            indicator_rect = self.style().subElementRect(
                self.style().SubElement.SE_CheckBoxIndicator,
                opt,
                self
            )
            
            if indicator_rect.isValid():
                painter = QPainter(self)
                painter.setRenderHint(QPainter.RenderHint.Antialiasing)
                pen_width = max(2.0, min(indicator_rect.width(), indicator_rect.height()) * 0.12)
                painter.setPen(QPen(QColor(255, 255, 255), pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
                painter.setBrush(QColor(255, 255, 255))
                
                x = indicator_rect.x()
                y = indicator_rect.y()
                w = indicator_rect.width()
                h = indicator_rect.height()
                
                padding = min(w, h) * 0.15
                x += padding
                y += padding
                w -= padding * 2
                h -= padding * 2
                
                check_x1 = x + w * 0.10
                check_y1 = y + h * 0.50
                check_x2 = x + w * 0.35
                check_y2 = y + h * 0.70
                check_x3 = x + w * 0.90
                check_y3 = y + h * 0.25
                
                painter.drawLine(QPointF(check_x2, check_y2), QPointF(check_x3, check_y3))
                painter.drawLine(QPointF(check_x1, check_y1), QPointF(check_x2, check_y2))
                
                painter.end()


class CheckmarkRadioButton(QRadioButton):
    """Custom radio button with green background when checked"""
    
    def __init__(self, text="", parent=None):
        super().__init__(text, parent)
        self.setStyleSheet("""
            QRadioButton {
                font-size: 9pt;
                spacing: 6px;
            }
            QRadioButton::indicator {
                width: 16px;
                height: 16px;
                border: 2px solid #999;
                border-radius: 9px;
                background-color: white;
            }
            QRadioButton::indicator:checked {
                background-color: #4CAF50;
                border-color: #4CAF50;
            }
            QRadioButton::indicator:hover {
                border-color: #666;
            }
            QRadioButton::indicator:checked:hover {
                background-color: #45a049;
                border-color: #45a049;
            }
        """)
    
    def paintEvent(self, event):
        """Override paint event to draw white dot when checked"""
        super().paintEvent(event)
        
        if self.isChecked():
            from PyQt6.QtWidgets import QStyleOptionButton
            from PyQt6.QtGui import QPainter, QBrush, QColor
            from PyQt6.QtCore import Qt, QPointF
            
            opt = QStyleOptionButton()
            self.initStyleOption(opt)
            indicator_rect = self.style().subElementRect(
                self.style().SubElement.SE_RadioButtonIndicator,
                opt,
                self
            )
            
            if indicator_rect.isValid():
                painter = QPainter(self)
                painter.setRenderHint(QPainter.RenderHint.Antialiasing)
                painter.setBrush(QBrush(QColor("white")))
                painter.setPen(Qt.PenStyle.NoPen)
                
                # Draw white dot in center
                center = indicator_rect.center()
                center_pt = QPointF(center.x(), center.y())
                
                # Dot size: 25% of width radius (50% diameter)
                radius = indicator_rect.width() * 0.25
                
                painter.drawEllipse(center_pt, radius, radius)
                painter.end()


class BlueCheckmarkCheckBox(QCheckBox):
    """Custom checkbox with blue background and white checkmark when checked (for global/background termbases)"""
    
    def __init__(self, text="", parent=None):
        super().__init__(text, parent)
        self.setCheckable(True)
        self.setEnabled(True)
        self.setStyleSheet("""
            QCheckBox {
                font-size: 9pt;
                spacing: 6px;
            }
            QCheckBox::indicator {
                width: 16px;
                height: 16px;
                border: 2px solid #999;
                border-radius: 3px;
                background-color: white;
            }
            QCheckBox::indicator:checked {
                background-color: #4d94ff;
                border-color: #4d94ff;
            }
            QCheckBox::indicator:hover {
                border-color: #666;
            }
            QCheckBox::indicator:checked:hover {
                background-color: #3d7dd9;
                border-color: #3d7dd9;
            }
        """)
    
    def paintEvent(self, event):
        """Override paint event to draw white checkmark when checked"""
        super().paintEvent(event)
        
        if self.isChecked():
            from PyQt6.QtWidgets import QStyleOptionButton
            from PyQt6.QtGui import QPainter, QPen, QColor
            from PyQt6.QtCore import QPointF
            
            opt = QStyleOptionButton()
            self.initStyleOption(opt)
            indicator_rect = self.style().subElementRect(
                self.style().SubElement.SE_CheckBoxIndicator,
                opt,
                self
            )
            
            if indicator_rect.isValid():
                painter = QPainter(self)
                painter.setRenderHint(QPainter.RenderHint.Antialiasing)
                pen_width = max(2.0, min(indicator_rect.width(), indicator_rect.height()) * 0.12)
                painter.setPen(QPen(QColor(255, 255, 255), pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
                painter.setBrush(QColor(255, 255, 255))
                
                x = indicator_rect.x()
                y = indicator_rect.y()
                w = indicator_rect.width()
                h = indicator_rect.height()
                
                padding = min(w, h) * 0.15
                x += padding
                y += padding
                w -= padding * 2
                h -= padding * 2
                
                check_x1 = x + w * 0.10
                check_y1 = y + h * 0.50
                check_x2 = x + w * 0.35
                check_y2 = y + h * 0.70
                check_x3 = x + w * 0.90
                check_y3 = y + h * 0.25
                
                painter.drawLine(QPointF(check_x2, check_y2), QPointF(check_x3, check_y3))
                painter.drawLine(QPointF(check_x1, check_y1), QPointF(check_x2, check_y2))

                painter.end()


class OrangeCheckmarkCheckBox(QCheckBox):
    """Custom checkbox with orange background and white checkmark when checked (for AI injection)"""

    def __init__(self, text="", parent=None):
        super().__init__(text, parent)
        self.setCheckable(True)
        self.setEnabled(True)
        self.setStyleSheet("""
            QCheckBox {
                font-size: 9pt;
                spacing: 6px;
            }
            QCheckBox::indicator {
                width: 16px;
                height: 16px;
                border: 2px solid #999;
                border-radius: 3px;
                background-color: white;
            }
            QCheckBox::indicator:checked {
                background-color: #FF9800;
                border-color: #FF9800;
            }
            QCheckBox::indicator:hover {
                border-color: #666;
            }
            QCheckBox::indicator:checked:hover {
                background-color: #F57C00;
                border-color: #F57C00;
            }
        """)

    def paintEvent(self, event):
        """Override paint event to draw white checkmark when checked"""
        super().paintEvent(event)

        if self.isChecked():
            from PyQt6.QtWidgets import QStyleOptionButton
            from PyQt6.QtGui import QPainter, QPen, QColor
            from PyQt6.QtCore import QPointF

            opt = QStyleOptionButton()
            self.initStyleOption(opt)
            indicator_rect = self.style().subElementRect(
                self.style().SubElement.SE_CheckBoxIndicator,
                opt,
                self
            )

            if indicator_rect.isValid():
                painter = QPainter(self)
                painter.setRenderHint(QPainter.RenderHint.Antialiasing)
                pen_width = max(2.0, min(indicator_rect.width(), indicator_rect.height()) * 0.12)
                painter.setPen(QPen(QColor(255, 255, 255), pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin))
                painter.setBrush(QColor(255, 255, 255))

                x = indicator_rect.x()
                y = indicator_rect.y()
                w = indicator_rect.width()
                h = indicator_rect.height()

                padding = min(w, h) * 0.15
                x += padding
                y += padding
                w -= padding * 2
                h -= padding * 2

                check_x1 = x + w * 0.10
                check_y1 = y + h * 0.50
                check_x2 = x + w * 0.35
                check_y2 = y + h * 0.70
                check_x3 = x + w * 0.90
                check_y3 = y + h * 0.25

                painter.drawLine(QPointF(check_x2, check_y2), QPointF(check_x3, check_y3))
                painter.drawLine(QPointF(check_x1, check_y1), QPointF(check_x2, check_y2))

                painter.end()


class CustomRadioButton(QRadioButton):
    """Custom radio button with circular indicator, green ring + green dot when checked.

    Earlier versions of this class drew a square indicator with a white checkmark
    on a green fill – which looked exactly like a checkbox and gave at least one
    user (forum feedback, May 2026) the impression that the LLM provider list
    was multi-select when it's actually mutually exclusive. The visual now
    matches the universal radio-button signal: hollow circle when off, filled
    centre dot when on.
    """

    def __init__(self, text="", parent=None):
        super().__init__(text, parent)
        self.setCheckable(True)
        self.setEnabled(True)
        # 18 px diameter → border-radius 9 px = a perfect circle.
        self.setStyleSheet("""
            QRadioButton {
                font-size: 9pt;
                spacing: 6px;
            }
            QRadioButton::indicator {
                width: 18px;
                height: 18px;
                border: 2px solid #999;
                border-radius: 9px;
                background-color: white;
            }
            QRadioButton::indicator:checked {
                border-color: #4CAF50;
                background-color: white;
            }
            QRadioButton::indicator:hover {
                border-color: #666;
            }
            QRadioButton::indicator:checked:hover {
                border-color: #45a049;
            }
        """)

    def paintEvent(self, event):
        """Draw the centre dot when checked – the visual signal for "this radio
        is the active selection". Drawn manually because Qt's stylesheet engine
        doesn't expose a nice way to put a coloured dot inside a styled
        ::indicator without losing the ring + hover behaviour we just defined.
        """
        super().paintEvent(event)
        if not self.isChecked():
            return

        from PyQt6.QtWidgets import QStyleOptionButton
        from PyQt6.QtGui import QPainter, QColor
        from PyQt6.QtCore import QRectF

        opt = QStyleOptionButton()
        self.initStyleOption(opt)
        indicator_rect = self.style().subElementRect(
            self.style().SubElement.SE_RadioButtonIndicator, opt, self
        )
        if not indicator_rect.isValid():
            return

        # Centre dot: ~50% of the indicator diameter, green to match the ring.
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setPen(Qt.PenStyle.NoPen)
        painter.setBrush(QColor("#4CAF50"))
        cx = indicator_rect.x() + indicator_rect.width() / 2
        cy = indicator_rect.y() + indicator_rect.height() / 2
        r = min(indicator_rect.width(), indicator_rect.height()) * 0.25
        painter.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2))
        painter.end()


# ============================================================================
# APPLICATION ENTRY POINT
# ============================================================================

def _setup_diagnostic_log():
    """Tee stdout + stderr into a rolling diagnostic log file.

    Runs at the very start of main() so all startup output is captured.
    Critical for silent launches (pythonw / Windows GUI subsystem) where
    stdout/stderr would otherwise go to a null device – users have no
    console to copy from, but they can point us at a file.

    Path: <user_data_path>/workbench/logs/supervertaler.log
    Rotation: once the log exceeds ~2 MB it is renamed to
    supervertaler-previous.log (overwriting any older copy). Keeps at most
    2 files, ~4 MB total, so the disk footprint is negligible.

    The tee preserves the original stdout/stderr, so developers running
    from `run.cmd` or `supervertaler-debug` still see everything in the
    console AND get a copy in the file.

    Returns the Path of the active log file, or None on failure.
    """
    import io
    from datetime import datetime

    try:
        log_dir = get_user_data_path() / "workbench" / "logs"
        log_dir.mkdir(parents=True, exist_ok=True)
        log_path = log_dir / "supervertaler.log"
        prev_path = log_dir / "supervertaler-previous.log"

        # Rotate if the existing log is too large (~2 MB).
        try:
            if log_path.exists() and log_path.stat().st_size > 2 * 1024 * 1024:
                if prev_path.exists():
                    prev_path.unlink()
                log_path.rename(prev_path)
        except Exception:
            pass  # Non-fatal – keep going with append mode

        # Open in line-buffered append mode so a hard crash still leaves a
        # usable log on disk (nothing stuck in a Python-side buffer).
        log_file = open(log_path, 'a', encoding='utf-8', errors='replace', buffering=1)
        log_file.write(
            f"\n{'=' * 70}\n"
            f"Supervertaler session started {datetime.now().isoformat(timespec='seconds')}\n"
            f"Python: {sys.version.split()[0]} on {sys.platform}\n"
            f"{'=' * 70}\n"
        )
        log_file.flush()

        class _Tee:
            """Duplicate writes to the original stream + the log file.

            `original` may be None when running under pythonw (the Windows
            GUI-subsystem Python has no stdout/stderr attached at all);
            we guard every forward so we never hit AttributeError in that
            case.
            """

            def __init__(self, original, log_handle):
                self._orig = original
                self._log = log_handle

            def write(self, text):
                if self._orig is not None:
                    try:
                        self._orig.write(text)
                    except Exception:
                        pass
                try:
                    self._log.write(text)
                except Exception:
                    pass

            def flush(self):
                if self._orig is not None:
                    try:
                        self._orig.flush()
                    except Exception:
                        pass
                try:
                    self._log.flush()
                except Exception:
                    pass

            def isatty(self):
                if self._orig is None:
                    return False
                try:
                    return self._orig.isatty()
                except Exception:
                    return False

            def fileno(self):
                # Some libraries expect a real fd; expose the log file's fd
                # as a best-effort fallback when the original is detached.
                if self._orig is not None and hasattr(self._orig, 'fileno'):
                    try:
                        return self._orig.fileno()
                    except (OSError, io.UnsupportedOperation):
                        pass
                return self._log.fileno()

        sys.stdout = _Tee(sys.stdout, log_file)
        sys.stderr = _Tee(sys.stderr, log_file)
        # Stash the path so UI actions ("Open Diagnostic Log") can find it.
        sys._supervertaler_log_path = log_path
        return log_path
    except Exception as e:
        # Never prevent the app from launching because logging failed.
        try:
            sys.__stderr__.write(f"[Diagnostic log setup failed: {e}]\n")
        except Exception:
            pass
        return None


def _install_log_hooks():
    """Route extra output channels into the (already teed) stdout/stderr.

    Without this, four sources of debug info disappear under pythonw /
    the built .exe (no console attached):

      * threading.excepthook  – uncaught exceptions in worker threads
      * Qt message handler    – qWarning / qCritical, layout warnings, GL errors
      * stdlib logging        – modules/* use ``logging.getLogger(__name__)``
      * Python warnings       – DeprecationWarning, ResourceWarning, ...

    Everything they emit is written via ``print`` / ``sys.stderr``, which
    _setup_diagnostic_log has already teed to supervertaler.log. So after
    this runs, the in-app diagnostic log is a true superset of what the
    dev terminal would have shown.
    """
    import logging
    import threading
    import traceback

    # 1. Thread exception hook (Python 3.8+). Without it, a crashing
    #    worker thread vanishes silently – only the main thread is
    #    covered by sys.excepthook.
    def _thread_excepthook(args):
        if args.exc_type is SystemExit:
            return
        thread_name = args.thread.name if args.thread else "<unknown>"
        try:
            sys.stderr.write(f"\n[THREAD-EXCEPTION] in thread {thread_name!r}:\n")
            traceback.print_exception(
                args.exc_type, args.exc_value, args.exc_traceback,
                file=sys.stderr,
            )
        except Exception:
            pass
    try:
        threading.excepthook = _thread_excepthook
    except Exception:
        pass

    # 2. Qt message handler – surface qWarning / qCritical / layout
    #    warnings that Qt would otherwise print to a console we don't have.
    try:
        from PyQt6.QtCore import qInstallMessageHandler, QtMsgType
        _qt_severity = {
            QtMsgType.QtDebugMsg:    "QT-DEBUG",
            QtMsgType.QtInfoMsg:     "QT-INFO",
            QtMsgType.QtWarningMsg:  "QT-WARN",
            QtMsgType.QtCriticalMsg: "QT-CRIT",
            QtMsgType.QtFatalMsg:    "QT-FATAL",
        }
        def _qt_message_handler(mode, context, message):
            tag = _qt_severity.get(mode, "QT-???")
            try:
                print(f"[{tag}] {message}")
            except Exception:
                pass
        qInstallMessageHandler(_qt_message_handler)
    except Exception:
        pass

    # 3. Stdlib logging → stdout (teed). Modules under modules/* use
    #    logging.getLogger(__name__); without a configured handler those
    #    messages are dropped silently. Bump root to INFO only if the
    #    user/library hasn't already configured something stricter.
    try:
        root = logging.getLogger()
        already_attached = any(
            isinstance(h, logging.StreamHandler)
            and getattr(h, "stream", None) is sys.stdout
            for h in root.handlers
        )
        if not already_attached:
            handler = logging.StreamHandler(sys.stdout)
            handler.setFormatter(logging.Formatter(
                "[%(levelname)s] %(name)s: %(message)s"
            ))
            root.addHandler(handler)
        if root.level == logging.WARNING:
            root.setLevel(logging.INFO)
        # Route warnings.warn(...) through the logging system too.
        logging.captureWarnings(True)
    except Exception:
        pass


def main():
    """Application entry point"""
    # Redirect stdout/stderr to a rolling log file FIRST – so even crashes
    # during QApplication import or QWebEngine init get captured.
    _setup_diagnostic_log()
    # Then route Qt / threads / stdlib-logging / warnings into the same tee.
    _install_log_hooks()

    # Install global exception handler to catch unhandled exceptions
    import traceback

    def global_exception_handler(exc_type, exc_value, exc_traceback):
        """Global exception handler - prints full traceback before crashing"""
        print("\n" + "="*60)
        print("🚨 UNHANDLED EXCEPTION - FULL TRACEBACK:")
        print("="*60)
        traceback.print_exception(exc_type, exc_value, exc_traceback)
        print("="*60 + "\n")
        # Call the default handler
        sys.__excepthook__(exc_type, exc_value, exc_traceback)

    sys.excepthook = global_exception_handler
    
    # Suppress Chromium/QtWebEngine verbose error output (cache errors, GPU warnings, JS console)
    # These are harmless but alarming to users - websites emit tons of CSP/permissions noise
    os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-logging --log-level=3 --enable-logging=stderr --v=-1"
    
    # Suppress Qt WebEngine's "js:" console output (CSP violations, permissions policy warnings)
    # These come from embedded web pages and are not relevant to Supervertaler
    os.environ["QT_LOGGING_RULES"] = "*.debug=false;qt.webenginecontext.debug=false;js=false"
    
    # Filter stderr to suppress noisy Chromium JS console messages
    # These "js:" prefixed messages come from embedded web pages and alarm users
    class StderrFilter:
        """Filter that suppresses Chromium's JS console output from stderr"""
        def __init__(self, original_stderr):
            self.original = original_stderr
            # Patterns to suppress (from embedded web pages)
            self.suppress_prefixes = (
                'js:', 'js: Error with Permissions-Policy', 'js: Refused to load',
                'js: Document-Policy', 'js: Listener added', 'js: No ID or name',
                'js: [Report Only]', 'js: Unrecognized feature'
            )
        
        def write(self, text):
            # Suppress lines starting with JS console prefixes
            if text.strip() and any(text.strip().startswith(prefix) for prefix in self.suppress_prefixes):
                return  # Silently discard
            self.original.write(text)
        
        def flush(self):
            self.original.flush()
        
        def fileno(self):
            return self.original.fileno()
        
        def isatty(self):
            return self.original.isatty() if hasattr(self.original, 'isatty') else False
    
    sys.stderr = StderrFilter(sys.stderr)
    
    # Linux-specific: Avoid memory access violations from native libraries
    # ChromaDB and Hunspell can crash on some Linux configurations
    if sys.platform == 'linux':
        # Disable threading optimizations that can cause crashes in native code
        os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
        # Use safer malloc implementation if available
        os.environ.setdefault("MALLOC_CHECK_", "0")
    
    # Set OpenGL context sharing before creating QApplication (required for QtWebEngine)
    QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
    
    app = QApplication(sys.argv)
    app.setApplicationName("Supervertaler")
    app.setOrganizationName("Supervertaler")
    
    # Global stylesheet to remove ugly focus rectangles (dotted outlines)
    # from buttons, tool buttons, tabs, and tree/list views.
    # Note: QToolTip styling is handled by theme_manager.apply_theme()
    app.setStyleSheet("""
        QPushButton:focus {
            outline: none;
            border: none;
        }
        QToolButton:focus {
            outline: none;
            border: none;
        }
        QTabBar::tab {
            outline: 0;
        }
        QTabBar::tab:focus {
            outline: none;
        }
        /* Left-align every QTabWidget's tab bar.
           Belt-and-braces with the QProxyStyle SH_TabBar_Alignment
           override below — the proxy-style path is the load-bearing
           one because theme_manager.apply_theme() resets the app
           stylesheet (wiping any per-stylesheet rule), and per-widget
           setStyleSheet calls shadow the app stylesheet for that
           widget. Style hints are CSS-independent and survive both. */
        QTabWidget::tab-bar {
            alignment: left;
        }
        QTreeView::item:focus, QListView::item:focus, QTableView::item:focus {
            outline: none;
        }
        QAbstractItemView {
            outline: 0;
        }
    """)

    # macOS: use Fusion style instead of native QMacStyle.
    # QMacStyle ignores QTabBar.setExpanding(False) (tabs always centered/expanded)
    # and renders QComboBox popup items blank. Fusion respects all Qt properties
    # and the app's custom stylesheets still apply on top.
    if sys.platform == 'darwin':
        app.setStyle("Fusion")

    # Suppress dotted focus rectangles app-wide. Native styles (notably the
    # Windows Vista style) draw PE_FrameFocusRect at the QStyle level, which
    # bypasses CSS – `outline: none` does nothing against it. A QProxyStyle
    # that intercepts that single primitive removes the rectangle from every
    # tab, button, list item, and tree item without touching individual
    # widgets.
    from PyQt6.QtWidgets import QProxyStyle, QStyle

    class _NoFocusRectStyle(QProxyStyle):
        def drawPrimitive(self, element, option, painter, widget=None):
            if element == QStyle.PrimitiveElement.PE_FrameFocusRect:
                return
            super().drawPrimitive(element, option, painter, widget)

        def styleHint(self, hint, option=None, widget=None, returnData=None):
            # Force left-alignment on every QTabBar in the app.
            # QMacStyle returns AlignCenter for SH_TabBar_Alignment, and
            # even on Fusion we can't rely on the app stylesheet rule
            # (`QTabWidget::tab-bar { alignment: left }`) because
            # theme_manager.apply_theme() replaces the app stylesheet
            # wholesale, and individual QTabWidgets routinely set their
            # own per-widget stylesheet which shadows the app rule for
            # that widget. Style hints are queried by the widget at paint
            # time and are not affected by stylesheets, so this override
            # is the only path that reliably reaches every QTabBar
            # regardless of theme / per-widget overrides.
            if hint == QStyle.StyleHint.SH_TabBar_Alignment:
                return int(Qt.AlignmentFlag.AlignLeft)
            return super().styleHint(hint, option, widget, returnData)

    app.setStyle(_NoFocusRectStyle(app.style()))

    # Set Windows AppUserModelID for taskbar icon grouping (Windows 7+)
    if sys.platform == 'win32':
        try:
            import ctypes
            myappid = 'com.michaelbeijer.supervertaler.1.9.27'
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
        except Exception:
            pass  # Fail silently if not on Windows or ctypes unavailable

    # Suppress Windows' modal "There is no disk in drive X:" / "The system
    # cannot find the drive specified" pop-ups that appear when *anything*
    # in-process probes a removed/unmounted drive (recent files, plugin
    # discovery, file dialogs). They block the UI thread on a system-modal
    # dialog and there's nothing useful the user can do about them.
    if sys.platform == 'win32':
        try:
            import ctypes
            SEM_FAILCRITICALERRORS = 0x0001
            SEM_NOGPFAULTERRORBOX  = 0x0002
            SEM_NOOPENFILEERRORBOX = 0x8000
            ctypes.windll.kernel32.SetErrorMode(
                SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX
            )
        except Exception:
            pass

    window = SupervertalerQt()

    # Decide visibility BEFORE creating the tray icon, so the main window
    # is fully realised on screen first – some Windows configs render a
    # cold-started QMainWindow incorrectly when a tray icon is installed
    # ahead of the first show().
    ui_prefs = window._load_settings_section("ui")
    start_minimized = bool(ui_prefs.get("start_minimized_to_tray"))
    if not start_minimized:
        window.show()

    # System-tray icon + close-to-tray / start-minimized handling.
    # _setup_tray_icon also flips QApplication.setQuitOnLastWindowClosed(False)
    # if a tray is available, so hiding the main window won't terminate the app.
    window._setup_tray_icon()

    sys.exit(app.exec())


if __name__ == '__main__':
    # Wrap main() in crash handler for macOS Finder launches (stdout/stderr go nowhere)
    if getattr(sys, 'frozen', False) and sys.platform == 'darwin':
        try:
            main()
        except Exception:
            import traceback
            _crash_log = os.path.expanduser('~/Desktop/supervertaler_crash.log')
            try:
                with open(_crash_log, 'a') as _f:
                    _f.write('\n--- CRASH ---\n')
                    traceback.print_exc(file=_f)
            except Exception:
                pass
            raise
    else:
        main()
