"""
Settings Dialog for Vocalinux.

Allows users to configure speech recognition engine, model size,
and other relevant parameters.

UX Design Notes:
- Follows GNOME Human Interface Guidelines (HIG) for modern desktop look
- Uses preference-page style layout with clearly grouped sections
- Settings apply immediately when changed (instant-apply pattern)
- Close button provided for WM compatibility (some WMs hide title bar close)
- Provides real-time progress feedback for recognition state
- Multi-modal feedback (text + icon + audio level) for accessibility
- Modal dialog for model downloads (explicit confirmation for large downloads)
"""

import logging
import os
import re
import threading
import time
from typing import TYPE_CHECKING, Optional

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
# Need GLib for idle_add
from gi.repository import Gdk, GLib, Gtk, Pango  # noqa: E402

from ..common_types import RecognitionState  # noqa: E402
from ..speech_recognition.silero_vad import is_silero_available  # noqa: E402
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO  # noqa: E402
from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES
from ..utils.whispercpp_model_info import (
    WHISPERCPP_MODEL_INFO,
    detect_compute_backend,
    get_backend_display_name,
)
from ..utils.whispercpp_model_info import get_model_size as get_whispercpp_model_size
from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_model_variants
from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model
from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model
from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded
from .config_manager import DEFAULT_CONFIG  # noqa: E402
from .keyboard_backends import (  # noqa: E402
    SHORTCUT_DISPLAY_NAMES,
    SHORTCUT_GROUPS,
    SHORTCUT_MODES,
    SUPPORTED_SHORTCUTS,
    get_shortcut_display_name,
    is_valid_shortcut,
    parse_shortcut_spec,
)

# Avoid circular imports for type checking
if TYPE_CHECKING:
    from ..speech_recognition.recognition_manager import SpeechRecognitionManager  # noqa: E402
    from .config_manager import ConfigManager  # noqa: E402

logger = logging.getLogger(__name__)

# Define available models for each engine
ENGINE_MODELS = {
    "vosk": [
        "small",
        "medium",
        "large",
    ],  # Note: 'large' maps to medium internally, as higher version wasn't available
    "whisper": [
        "tiny",
        "base",
        "small",
        "medium",
        "large",
    ],  # Add more whisper sizes if needed
    "whisper_cpp": [
        *WHISPERCPP_MODEL_SIZES,
    ],  # whisper.cpp top-level size buckets; variants are selected separately
    "remote_api": [],  # Remote API does not need local models
}

# Engine display name mapping
ENGINE_DISPLAY_NAMES = {
    "vosk": "Vosk",
    "whisper": "Whisper",
    "whisper_cpp": "Whisper_cpp",
    "remote_api": "Remote API",
}


def _engine_display_name(engine: str) -> str:
    """Get the display name of the engine."""
    return ENGINE_DISPLAY_NAMES.get(engine, engine.capitalize())


def _engine_from_display(display_name: str) -> str:
    """Reverse lookup engine ID from display name."""
    for engine_id, name in ENGINE_DISPLAY_NAMES.items():
        if name == display_name:
            return engine_id
    return display_name.lower()


def _model_display_name(model_name: str) -> str:
    """Get a user-friendly model display name."""
    if model_name == "large":
        return "Large v3"

    display_parts = []
    for part in model_name.split("-"):
        if part.endswith(".en"):
            display_parts.append(part[:-3].capitalize())
            display_parts.append("EN")
        elif part.startswith("q"):
            display_parts.append(part.upper())
        elif part == "turbo":
            display_parts.append("Turbo")
        elif part.startswith("v") and part[1:].isdigit():
            display_parts.append(part)
        else:
            display_parts.append(part.capitalize())

    return " ".join(display_parts)


def _model_specialization_display_name(model_name: str) -> str:
    """Get a concise label for a whisper.cpp model variant."""
    if model_name == "large":
        return "Standard v3"

    quantization = None
    for part in model_name.split("-"):
        if part.startswith("q"):
            quantization = part.upper()
            break

    if model_name.startswith("large-v") and "turbo" not in model_name:
        version = next(
            (part for part in model_name.split("-") if part.startswith("v")),
            "large",
        )
        if quantization:
            return f"{version} {quantization}"
        return version

    if is_english_only_whispercpp_model(model_name):
        return f"English-only {quantization}" if quantization else "English-only"

    if "turbo" in model_name:
        if quantization:
            return f"Turbo {quantization}"
        return "Turbo"

    if quantization:
        return f"Quantized {quantization}"

    return "Standard multilingual"


def _language_is_english(language_id: str) -> bool:
    """Return whether a language ID maps to English for Whisper."""
    return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"


def _recommended_whispercpp_variant_for_language(
    recommended_model: str,
    reason: str,
    language_id: str,
) -> tuple[str, str]:
    """Adjust a hardware recommendation to the selected language."""
    recommended_size = get_whispercpp_model_size(recommended_model)
    english_variant = f"{recommended_size}.en"

    if _language_is_english(language_id) and english_variant in WHISPERCPP_MODEL_INFO:
        return english_variant, f"{reason}; English language selected"

    return recommended_model, reason


def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> Optional[str]:
    """Return the default specialization for a user-selected size and language."""
    variants = get_whispercpp_model_variants(model_size)
    if not variants:
        return None

    english_variant = f"{model_size}.en"
    if _language_is_english(language_id) and english_variant in variants:
        return english_variant

    standard_variant = "large" if model_size == "large" else model_size
    if standard_variant in variants:
        return standard_variant

    return variants[0]


MODEL_SIZE_TOOLTIP = (
    "Choose the largest model your computer can run comfortably. Tiny/Base are fastest, "
    "Small is balanced, and Medium/Large can be more accurate but need more memory."
)
MODEL_SPECIALIZATION_TOOLTIP = (
    "Choose Standard multilingual unless you specifically need English-only accuracy, "
    "lower-memory quantized models, Turbo speed, or a legacy large model."
)
LANGUAGE_TOOLTIP = (
    "Choose the language you dictate in. English-only model specializations limit this "
    "list to English."
)


def _model_specialization_tooltip(model_name: str) -> str:
    """Return hover guidance for a whisper.cpp specialization."""
    is_english_only = is_english_only_whispercpp_model(model_name)
    is_quantized = any(part.startswith("q") for part in model_name.split("-"))

    if "turbo" in model_name:
        if is_quantized:
            return (
                "Choose this for a faster large-v3 Turbo model with lower disk and memory use; "
                "expect a small accuracy tradeoff from quantization."
            )
        return (
            "Choose Turbo when you want high accuracy from a large model with less memory use "
            "and faster inference than full large v3."
        )

    if model_name.startswith("large-v") and model_name != "large":
        return (
            "Choose this only if you specifically want that legacy large model version; "
            "Standard v3 or Turbo is the better default for most users."
        )

    if is_english_only and is_quantized:
        return (
            "Choose this for English-only dictation on lower-memory systems; choose "
            "multilingual if you use auto-detect or any non-English language."
        )

    if is_english_only:
        return (
            "Choose this when you dictate only in English. It can be better for English, "
            "but it will not work for other languages."
        )

    if is_quantized:
        return (
            "Choose this on lower-memory systems or when download size matters; it uses "
            "less disk and RAM with a possible accuracy tradeoff."
        )

    if model_name == "large":
        return (
            "Choose Standard v3 when you want the highest default accuracy and have enough "
            "memory for a large model."
        )

    return (
        "Choose Standard multilingual for most users, auto-detect, or any supported "
        "non-English language."
    )


# Whisper model metadata for display
WHISPER_MODEL_INFO = {
    "tiny": {"size_mb": 75, "desc": "Fastest, lowest accuracy", "params": "39M"},
    "base": {"size_mb": 142, "desc": "Fast, good for basic use", "params": "74M"},
    "small": {"size_mb": 466, "desc": "Balanced speed/accuracy", "params": "244M"},
    "medium": {"size_mb": 1500, "desc": "High accuracy, slower", "params": "769M"},
    "large": {"size_mb": 2900, "desc": "Highest accuracy, slowest", "params": "1550M"},
}


def get_available_engines():
    """
    Detect which speech recognition engines are available/installed.
    Returns a dictionary of engine_name -> availability (bool).
    """
    engines = {"vosk": False, "whisper": False, "whisper_cpp": False, "remote_api": False}

    # Check VOSK
    try:
        import vosk

        engines["vosk"] = True
    except ImportError:
        pass

    # Check OpenAI Whisper
    try:
        import whisper

        engines["whisper"] = True
    except ImportError:
        pass

    # Check whisper.cpp (pywhispercpp)
    try:
        from pywhispercpp.model import Model

        engines["whisper_cpp"] = True
    except ImportError:
        pass

    # Remote API is always available (only requires requests package)
    try:
        import requests  # noqa: F401

        engines["remote_api"] = True
    except ImportError:
        pass

    logger.debug(f"Available engines: {engines}")
    return engines


# Models directory
MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models")
SYSTEM_MODELS_DIRS = [
    "/usr/local/share/vocalinux/models",
    "/usr/share/vocalinux/models",
]

# CSS for modern styling
SETTINGS_CSS = """
/* Notebook (tab) styling */
.notebook {
    background-color: transparent;
    border: none;
}

.notebook tab {
    background-color: transparent;
    border: none;
    padding: 10px 16px;
    color: @theme_unfocused_fg_color;
}

.notebook tab:hover {
    background-color: alpha(@theme_selected_bg_color, 0.1);
}

.notebook tab:active {
    background-color: alpha(@theme_selected_bg_color, 0.2);
}

.notebook tab:selected {
    background-color: transparent;
    color: @theme_fg_color;
}

.notebook tab label {
    font-weight: 500;
    font-size: 0.95em;
}

.notebook tab:selected label {
    font-weight: 600;
}

.notebook header {
    background-color: alpha(@theme_bg_color, 0.5);
    border-bottom: 1px solid alpha(@borders, 0.3);
    box-shadow: 0 1px 3px alpha(@theme_bg_color, 0.2);
}

/* Tab content area */
.notebook stack {
    background-color: transparent;
}

/* Modern GNOME-style settings dialog */
.settings-dialog {
    background-color: @theme_bg_color;
}

/* Preference group styling - card-like appearance */
.preferences-group {
    background-color: @theme_base_color;
    border-radius: 12px;
    padding: 0;
    margin: 6px 0;
    border: 1px solid alpha(@borders, 0.5);
}

.preferences-group-title {
    font-weight: bold;
    font-size: 0.9em;
    color: @theme_unfocused_fg_color;
    padding: 12px 16px 6px 16px;
    margin: 0;
}

/* Row styling */
.preference-row {
    padding: 12px 16px;
    min-height: 32px;
    border-bottom: 1px solid alpha(@borders, 0.3);
}

.preference-row:last-child {
    border-bottom: none;
}

.preference-row:hover {
    background-color: alpha(@theme_selected_bg_color, 0.1);
}

.preference-row-title {
    font-weight: 500;
}

.preference-row-subtitle {
    font-size: 0.85em;
    color: @theme_unfocused_fg_color;
}

/* Status indicators */
.status-success {
    color: #26a269;
}

.status-warning {
    color: #e5a50a;
}

.status-error {
    color: #c01c28;
}

.status-info {
    color: @theme_unfocused_fg_color;
}

/* Test area styling */
.test-area {
    background-color: @theme_base_color;
    border-radius: 8px;
    padding: 12px;
    border: 1px solid alpha(@borders, 0.5);
}

.test-textview {
    font-family: monospace;
    font-size: 0.95em;
    padding: 8px;
    background-color: alpha(@theme_bg_color, 0.5);
    border-radius: 6px;
}

/* Level bars */
levelbar block.filled {
    background-color: @theme_selected_bg_color;
    border-radius: 3px;
}

levelbar block.empty {
    background-color: alpha(@theme_fg_color, 0.1);
    border-radius: 3px;
}

/* Combo boxes and spin buttons */
combobox button,
spinbutton {
    min-height: 32px;
    border-radius: 6px;
}

/* Section headers */
.section-header {
    font-size: 1.1em;
    font-weight: bold;
    margin-top: 12px;
    margin-bottom: 6px;
}

/* Info box styling */
.info-box {
    background-color: alpha(@theme_selected_bg_color, 0.1);
    border-radius: 8px;
    padding: 12px;
    border-left: 4px solid @theme_selected_bg_color;
}

.info-box-warning {
    background-color: alpha(#e5a50a, 0.1);
    border-left-color: #e5a50a;
}

/* Recognition status */
.recognition-idle {
    color: @theme_unfocused_fg_color;
}

.recognition-listening {
    color: #26a269;
}

.recognition-processing {
    color: #e5a50a;
}

.recognition-error {
    color: #c01c28;
}

/* Buttons */
.suggested-action {
    background-color: @theme_selected_bg_color;
    color: @theme_selected_fg_color;
}

.flat-button {
    background: transparent;
    border: none;
    padding: 8px;
    border-radius: 6px;
}

.flat-button:hover {
    background-color: alpha(@theme_fg_color, 0.1);
}

/* Scrolled content */
.scrolled-content {
    background-color: transparent;
}

/* Model info card */
.model-info-card {
    background-color: alpha(@theme_base_color, 0.8);
    border-radius: 8px;
    padding: 12px 16px;
    margin: 8px 0;
}

.model-info-title {
    font-weight: bold;
    font-size: 1.0em;
}

.model-info-subtitle {
    font-size: 0.9em;
    color: @theme_unfocused_fg_color;
}

/* Tip styling */
.tip-label {
    font-size: 0.85em;
    color: @theme_unfocused_fg_color;
    font-style: italic;
}

.tip-highlight {
    font-weight: bold;
    color: @theme_selected_bg_color;
}
"""


def _setup_css():
    """Set up CSS styling for the settings dialog."""
    css_provider = Gtk.CssProvider()
    css_provider.load_from_data(SETTINGS_CSS.encode())
    Gtk.StyleContext.add_provider_for_screen(
        Gdk.Screen.get_default(),
        css_provider,
        Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
    )


def _prevent_scroll_on_hover(widget: Gtk.Widget):
    """
    Prevent scroll events from modifying widget values when hovering.

    This fixes a common GTK UX issue where scrolling through a settings dialog
    accidentally changes ComboBox or SpinButton values when the mouse happens
    to be over them. The widget will only respond to scroll events after being
    explicitly clicked/focused.

    Args:
        widget: The widget to prevent scroll events on (ComboBox, SpinButton, etc.)
    """

    def on_scroll(widget, event):
        # Only process scroll if widget has explicit focus
        if not widget.has_focus():
            # Stop propagation to prevent value changes
            return True
        return False

    widget.connect("scroll-event", on_scroll)

    # Also prevent focus-on-hover behavior
    widget.set_can_focus(True)


def _get_whisper_cache_dir() -> str:
    """Get the Whisper model cache directory."""
    return os.path.expanduser("~/.local/share/vocalinux/models/whisper")


def _is_whisper_model_downloaded(model_name: str) -> bool:
    """Check if a Whisper model is downloaded."""
    cache_dir = _get_whisper_cache_dir()
    model_file = os.path.join(cache_dir, f"{model_name}.pt")
    if os.path.exists(model_file):
        return True
    # Also check default whisper cache
    default_cache = os.path.expanduser("~/.cache/whisper")
    return os.path.exists(os.path.join(default_cache, f"{model_name}.pt"))


def _format_size(size_mb: int) -> str:
    """Format size in MB to human readable string."""
    if size_mb >= 1000:
        return f"{size_mb / 1000:.1f} GB"
    return f"{size_mb} MB"


def _get_recommended_whisper_model() -> tuple:
    """Get recommended model based on system configuration."""
    import warnings

    try:
        import psutil

        ram_gb = psutil.virtual_memory().total // (1024**3)

        # Check for CUDA - suppress warnings during detection
        has_cuda = False
        cuda_memory_gb = 0
        try:
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                import torch

                if torch.cuda.is_available():
                    has_cuda = True
                    cuda_memory_gb = torch.cuda.get_device_properties(0).total_memory // (1024**3)
        except Exception:
            pass

        if has_cuda and cuda_memory_gb >= 8:
            return "medium", f"GPU with {cuda_memory_gb}GB VRAM"
        elif has_cuda and cuda_memory_gb >= 4:
            return "small", f"GPU with {cuda_memory_gb}GB VRAM"
        elif ram_gb >= 8:
            return "small", f"{ram_gb}GB RAM - good balance"
        elif ram_gb >= 4:
            return "base", f"{ram_gb}GB RAM"
        else:
            return "tiny", f"Limited RAM ({ram_gb}GB)"
    except Exception:
        return "base", "Default recommendation"


def _is_vosk_model_downloaded(size: str, language: str) -> bool:
    """Check if a VOSK model is downloaded."""
    if size not in VOSK_MODEL_INFO:
        return False

    # Auto-detect is not supported by VOSK, fall back to en-us
    if language == "auto" or language not in VOSK_MODEL_INFO[size]["languages"]:
        language = "en-us"

    model_name = VOSK_MODEL_INFO[size]["languages"][language]

    # Check user's local models directory
    user_model_path = os.path.join(MODELS_DIR, model_name)
    if os.path.exists(user_model_path):
        return True

    # Check system-wide installation directories
    for system_dir in SYSTEM_MODELS_DIRS:
        system_model_path = os.path.join(system_dir, model_name)
        if os.path.exists(system_model_path):
            return True

    return False


def _get_recommended_vosk_model() -> tuple:
    """Get recommended VOSK model based on system configuration."""
    try:
        import psutil

        ram_gb = psutil.virtual_memory().total // (1024**3)

        # VOSK models are CPU-based, so we recommend based on RAM and disk space
        if ram_gb >= 4:
            return "medium", f"{ram_gb}GB RAM - better accuracy"
        else:
            return "small", f"Limited RAM ({ram_gb}GB) - optimized for speed"
    except Exception:
        return "small", "Default recommendation"


# GDK key-symbol names that are themselves modifiers (skipped while recording).
_GDK_MODIFIER_KEYNAMES = {
    "Control_L",
    "Control_R",
    "Alt_L",
    "Alt_R",
    "Shift_L",
    "Shift_R",
    "Super_L",
    "Super_R",
    "Meta_L",
    "Meta_R",
    "Hyper_L",
    "Hyper_R",
    "ISO_Level3_Shift",
}

# GDK key-symbol names -> canonical main-key tokens (irregular names only;
# single letters/digits and function keys are handled by rule).
_GDK_KEYNAME_TOKENS = {
    "space": "space",
    "Return": "enter",
    "KP_Enter": "enter",
    "Tab": "tab",
    "Escape": "esc",
    "BackSpace": "backspace",
    "Delete": "delete",
    "Insert": "insert",
    "Home": "home",
    "End": "end",
    "Page_Up": "pageup",
    "Page_Down": "pagedown",
    "Up": "up",
    "Down": "down",
    "Left": "left",
    "Right": "right",
    "comma": "comma",
    "period": "period",
    "slash": "slash",
    "semicolon": "semicolon",
    "apostrophe": "apostrophe",
    "grave": "grave",
    "minus": "minus",
    "equal": "equal",
    "bracketleft": "leftbracket",
    "bracketright": "rightbracket",
    "backslash": "backslash",
}


def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]:
    """Map a GDK keyval name to a canonical main-key token, or None."""
    if not name:
        return None
    if name in _GDK_KEYNAME_TOKENS:
        return _GDK_KEYNAME_TOKENS[name]
    if len(name) == 1 and name.isalnum():
        return name.lower()
    if re.fullmatch(r"[Ff]([1-9]|1[0-9]|2[0-4])", name):
        return name.lower()
    return None


class PreferencesGroup(Gtk.Box):
    """A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup."""

    def __init__(self, title: str = "", description: str = ""):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.get_style_context().add_class("preferences-group")

        # Header with title
        if title:
            header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
            header_box.set_margin_top(12)
            header_box.set_margin_bottom(4)
            header_box.set_margin_start(16)
            header_box.set_margin_end(16)

            title_label = Gtk.Label(label=title, xalign=0)
            title_label.get_style_context().add_class("preferences-group-title")
            header_box.pack_start(title_label, False, False, 0)

            if description:
                desc_label = Gtk.Label(label=description, xalign=0, wrap=True)
                desc_label.get_style_context().add_class("preference-row-subtitle")
                header_box.pack_start(desc_label, False, False, 0)

            self.pack_start(header_box, False, False, 0)

        # Content area with listbox for rows
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self.listbox.set_activate_on_single_click(False)
        self.pack_start(self.listbox, False, False, 0)

    def add_row(self, widget):
        """Add a widget as a row in the preferences group."""
        self.listbox.add(widget)


class PreferenceRow(Gtk.ListBoxRow):
    """A single preference row with title, subtitle, and a control widget."""

    def __init__(
        self,
        title: str,
        subtitle: str = "",
        widget: Gtk.Widget = None,
        activatable: bool = False,
    ):
        super().__init__()
        self.set_activatable(activatable)
        self.get_style_context().add_class("preference-row")

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        hbox.set_margin_top(12)
        hbox.set_margin_bottom(12)
        hbox.set_margin_start(16)
        hbox.set_margin_end(16)

        # Text container (title + subtitle)
        text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        text_box.set_valign(Gtk.Align.CENTER)

        title_label = Gtk.Label(label=title, xalign=0)
        title_label.get_style_context().add_class("preference-row-title")
        text_box.pack_start(title_label, False, False, 0)

        # Store subtitle label reference for later updates
        self.subtitle_label = None
        if subtitle:
            self.subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
            self.subtitle_label.get_style_context().add_class("preference-row-subtitle")
            self.subtitle_label.set_max_width_chars(40)
            self.subtitle_label.set_line_wrap(True)
            self.subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
            text_box.pack_start(self.subtitle_label, False, False, 0)

        hbox.pack_start(text_box, True, True, 0)

        # Control widget on the right
        if widget:
            widget.set_valign(Gtk.Align.CENTER)
            hbox.pack_end(widget, False, False, 0)

        self.add(hbox)

    def set_subtitle(self, subtitle: str):
        """Update the subtitle text."""
        if self.subtitle_label:
            self.subtitle_label.set_text(subtitle)


class ModelDownloadDialog(Gtk.Dialog):
    """Dialog showing model download progress with cancel support."""

    def __init__(
        self,
        parent,
        model_name: str,
        model_size_mb: int,
        engine: str = "whisper",
        language: str = "en-us",
    ):
        super().__init__(
            title=f"Downloading {_model_display_name(model_name)} Model",
            transient_for=parent,
            flags=Gtk.DialogFlags.MODAL,
        )
        self.set_default_size(450, 200)
        self.set_deletable(False)  # Prevent closing during download

        self.cancelled = False
        self.engine = engine
        self.model_name = model_name

        engine_display = engine.upper() if engine == "vosk" else engine.capitalize()

        box = self.get_content_area()
        box.set_spacing(16)
        box.set_margin_start(24)
        box.set_margin_end(24)
        box.set_margin_top(24)
        box.set_margin_bottom(20)

        # Info label
        self.info_label = Gtk.Label(
            label=(
                f"Downloading {engine_display} {_model_display_name(model_name)} model "
                f"(~{_format_size(model_size_mb)})..."
            ),
            wrap=True,
            justify=Gtk.Justification.CENTER,
        )
        box.pack_start(self.info_label, False, False, 0)

        # Progress bar
        self.progress_bar = Gtk.ProgressBar()
        self.progress_bar.set_show_text(True)
        self.progress_bar.set_text("Connecting...")
        box.pack_start(self.progress_bar, False, False, 8)

        # Status label (shows speed and ETA)
        self.status_label = Gtk.Label(label="")
        self.status_label.set_markup("<i>Please wait...</i>")
        self.status_label.get_style_context().add_class("status-info")
        box.pack_start(self.status_label, False, False, 0)

        # Cancel button
        self.cancel_button = Gtk.Button(label="Cancel")
        self.cancel_button.connect("clicked", self._on_cancel_clicked)
        self.cancel_button.set_halign(Gtk.Align.CENTER)
        self.cancel_button.set_margin_top(12)
        box.pack_start(self.cancel_button, False, False, 0)

        self.show_all()

        # For Whisper, we can't track progress, so pulse
        if engine == "whisper":
            self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
        else:
            self._pulse_timeout = None

    def _pulse_progress(self):
        """Pulse the progress bar while downloading (for Whisper)."""
        if self.cancelled:
            return False
        self.progress_bar.pulse()
        return True  # Continue pulsing

    def _on_cancel_clicked(self, widget):
        """Handle cancel button click."""
        self.cancelled = True
        self.cancel_button.set_sensitive(False)
        self.cancel_button.set_label("Cancelling...")
        self.status_label.set_markup("<i>Cancelling download...</i>")

    def update_progress(self, fraction: float, speed_mbps: float, status_text: str):
        """Update the progress bar with actual download progress."""
        if self.cancelled:
            return

        # Stop pulsing if we were pulsing
        if self._pulse_timeout:
            GLib.source_remove(self._pulse_timeout)
            self._pulse_timeout = None

        self.progress_bar.set_fraction(fraction)
        self.progress_bar.set_text(f"{fraction * 100:.0f}%")
        self.status_label.set_markup(f"<i>{status_text}</i>")

    def set_complete(self, success: bool, message: str = ""):
        """Mark download as complete."""
        if self._pulse_timeout:
            GLib.source_remove(self._pulse_timeout)
            self._pulse_timeout = None

        # Hide cancel button
        self.cancel_button.hide()

        if success:
            self.progress_bar.set_fraction(1.0)
            self.progress_bar.set_text("Complete!")
            self.status_label.set_markup(
                "<span foreground='#26a269'><b>✓ Model ready to use</b></span>"
            )
        else:
            self.progress_bar.set_fraction(0)
            self.progress_bar.set_text("Failed")
            if "cancelled" in message.lower():
                self.status_label.set_markup(
                    "<span foreground='#e5a50a'>✗ Download cancelled</span>"
                )
            else:
                self.status_label.set_markup(f"<span foreground='#c01c28'>✗ {message}</span>")

        # Allow closing now
        self.set_deletable(True)
        self.add_button("OK", Gtk.ResponseType.OK)


class SettingsDialog(Gtk.Dialog):
    """Modern GTK Dialog for configuring Vocalinux settings."""

    def __init__(
        self,
        parent: Gtk.Window,
        config_manager: "ConfigManager",
        speech_engine: "SpeechRecognitionManager",
        shortcut_update_callback: callable = None,
    ):
        super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
        self.set_decorated(True)  # Force window decorations (close button) on all WMs

        # Add a Close action button so the dialog always has a visible way to
        # dismiss it, even on window managers that hide the title-bar close
        # button for Gtk.Dialog windows without action buttons (fixes #323).
        self.add_button("Close", Gtk.ResponseType.CLOSE)
        action_area = self.get_action_area()
        action_area.set_margin_top(8)
        action_area.set_margin_bottom(12)
        action_area.set_margin_start(16)
        action_area.set_margin_end(16)
        action_area.set_spacing(8)
        self.advanced_reset_button = Gtk.Button(label="Reset to Defaults")
        self.advanced_reset_button.set_tooltip_text(
            "Restore the whisper.cpp advanced parameters to their default values"
        )
        self.advanced_reset_button.connect("clicked", self._on_reset_advanced_clicked)
        action_area.pack_start(self.advanced_reset_button, False, False, 0)
        action_area.set_child_secondary(self.advanced_reset_button, True)
        self.config_manager = config_manager
        self.speech_engine = speech_engine
        self.shortcut_update_callback = shortcut_update_callback
        self._test_active = False
        self._test_result = ""
        self._initializing = True  # Flag to prevent auto-apply during initialization
        self._populating_models = False  # Flag to prevent model change handler during population
        self._processing_language_change = (
            False  # Flag to prevent recursive language change handling
        )
        self._applying_settings = False  # Flag to prevent recursive settings application
        self._advanced_prompt_dirty = False

        # Setup CSS styling
        _setup_css()

        # Dialog configuration - Close button added above for WM compatibility
        # Calculate dialog size
        display = Gdk.Display.get_default()
        if display:
            monitor = display.get_primary_monitor()
            if not monitor and display.get_n_monitors() > 0:
                monitor = display.get_monitor(0)
            if monitor:
                geometry = monitor.get_geometry()
                screen_height = geometry.height
                screen_width = geometry.width
            else:
                screen_height = 1080  # Default fallback
                screen_width = 1920
        else:
            screen_height = 1080  # Default fallback
            screen_width = 1920
        dialog_height = min(600, int(screen_height * 0.4))
        dialog_width = min(700, int(screen_width * 0.8))
        self.set_default_size(dialog_width, dialog_height)
        self.get_style_context().add_class("settings-dialog")

        # Create notebook for tabbed interface
        notebook = Gtk.Notebook()
        self.settings_notebook = notebook
        notebook.set_show_tabs(True)
        notebook.set_show_border(False)
        notebook.get_style_context().add_class("notebook")

        # Create tab content boxes
        # Speech Engine tab (Engine, Model, Language)
        self.speech_engine_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.speech_engine_tab.set_margin_top(16)
        self.speech_engine_tab.set_margin_bottom(16)
        self.speech_engine_tab.set_margin_start(16)
        self.speech_engine_tab.set_margin_end(16)

        # Recognition Settings tab (VAD, Silence, Test)
        self.recognition_settings_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.recognition_settings_tab.set_margin_top(16)
        self.recognition_settings_tab.set_margin_bottom(16)
        self.recognition_settings_tab.set_margin_start(16)
        self.recognition_settings_tab.set_margin_end(16)

        # Audio tab
        self.audio_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.audio_tab.set_margin_top(16)
        self.audio_tab.set_margin_bottom(16)
        self.audio_tab.set_margin_start(16)
        self.audio_tab.set_margin_end(16)

        self.shortcuts_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.shortcuts_tab.set_margin_top(16)
        self.shortcuts_tab.set_margin_bottom(16)
        self.shortcuts_tab.set_margin_start(16)
        self.shortcuts_tab.set_margin_end(16)

        self.general_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.general_tab.set_margin_top(16)
        self.general_tab.set_margin_bottom(16)
        self.general_tab.set_margin_start(16)
        self.general_tab.set_margin_end(16)

        self.advanced_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.advanced_tab.set_margin_top(16)
        self.advanced_tab.set_margin_bottom(16)
        self.advanced_tab.set_margin_start(16)
        self.advanced_tab.set_margin_end(16)

        # Add tabs to notebook (ordered by importance)
        # Speech Engine tab - most important (what model/language to use)
        speech_engine_label = Gtk.Label(label="Speech Engine")
        speech_engine_label.set_tooltip_text("Speech recognition engine and model settings")
        notebook.append_page(self.speech_engine_tab, speech_engine_label)

        # Recognition Settings tab - second most important (how to recognize)
        recognition_label = Gtk.Label(label="Recognition")
        recognition_label.set_tooltip_text("Recognition behavior and test settings")
        notebook.append_page(self.recognition_settings_tab, recognition_label)

        # Audio tab - third (hardware configuration)
        audio_label = Gtk.Label(label="Audio")
        audio_label.set_tooltip_text("Microphone and audio settings")
        notebook.append_page(self.audio_tab, audio_label)

        # Shortcuts tab
        shortcuts_label = Gtk.Label(label="Shortcuts")
        shortcuts_label.set_tooltip_text("Keyboard shortcuts")
        notebook.append_page(self.shortcuts_tab, shortcuts_label)

        # General tab - least important (application behavior)
        general_label = Gtk.Label(label="General")
        general_label.set_tooltip_text("General settings")
        notebook.append_page(self.general_tab, general_label)

        # Advanced tab - whisper.cpp parameters and power-user features (remote API, etc.)
        advanced_label = Gtk.Label(label="Advanced")
        advanced_label.set_tooltip_text(
            "Advanced whisper.cpp parameters and settings for power users"
        )
        self.advanced_page_num = notebook.append_page(self.advanced_tab, advanced_label)
        notebook.connect("switch-page", self._on_settings_page_switched)

        self.get_content_area().pack_start(notebook, True, True, 0)

        # Set content_box to speech_engine_tab for backward compatibility
        self.content_box = self.speech_engine_tab

        # Build UI sections into appropriate tabs
        self._build_general_section()
        self._build_audio_section()
        self._build_engine_section()
        self._build_remote_server_section()
        self._build_recognition_section()
        self._build_shortcuts_section()
        self._build_advanced_section()
        self._build_test_section()

        # Load settings and populate UI
        self._load_and_apply_settings()

        self.connect("response", self._on_settings_dialog_response)

        # Show everything first
        self.show_all()

        # Then update visibility of engine-specific elements
        self._update_advanced_reset_button_visibility()
        self._update_engine_specific_ui()

        # Initialize recognition progress UI
        self.update_recognition_progress("Idle")

        # Connect to recognition manager for progress updates
        self.connect_to_recognition_manager()

        # Initialization complete - enable auto-apply
        self._initializing = False

    def _build_audio_section(self):
        """Build the Audio Input section."""
        group = PreferencesGroup(title="Audio Input")

        # Device selection row
        device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.audio_device_combo = Gtk.ComboBoxText()
        self.audio_device_combo.set_tooltip_text(
            "Select the microphone to use for voice recognition"
        )
        self.audio_device_combo.set_size_request(250, -1)
        _prevent_scroll_on_hover(self.audio_device_combo)
        device_box.pack_start(self.audio_device_combo, True, True, 0)

        refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON)
        refresh_btn.set_tooltip_text("Refresh device list")
        refresh_btn.get_style_context().add_class("flat-button")
        refresh_btn.connect("clicked", self._on_refresh_audio_devices)
        device_box.pack_start(refresh_btn, False, False, 0)

        device_row = PreferenceRow(
            title="Input Device",
            subtitle="Select the microphone for voice recognition",
            widget=device_box,
        )
        group.add_row(device_row)

        # Audio level test row
        level_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        self.audio_level_bar = Gtk.LevelBar()
        self.audio_level_bar.set_min_value(0)
        self.audio_level_bar.set_max_value(100)
        self.audio_level_bar.set_value(0)
        self.audio_level_bar.set_size_request(150, -1)
        level_box.pack_start(self.audio_level_bar, True, True, 0)

        self.test_audio_btn = Gtk.Button(label="Test")
        self.test_audio_btn.set_tooltip_text("Test the microphone for 2 seconds")
        self.test_audio_btn.connect("clicked", self._on_test_audio_clicked)
        level_box.pack_start(self.test_audio_btn, False, False, 0)

        level_row = PreferenceRow(
            title="Audio Level",
            subtitle="Test your microphone",
            widget=level_box,
        )
        group.add_row(level_row)

        # Status label for audio testing (added below the group)
        self.audio_test_status = Gtk.Label(label="", use_markup=True, xalign=0)
        self.audio_test_status.set_margin_start(16)
        self.audio_test_status.set_margin_top(4)
        self.audio_test_status.get_style_context().add_class("status-info")

        self.audio_tab.pack_start(group, False, False, 0)
        self.audio_tab.pack_start(self.audio_test_status, False, False, 0)

        # Sound Effects section
        sound_group = PreferencesGroup(title="Sound Effects")
        self.sound_effects_switch = Gtk.Switch()
        self.sound_effects_switch.set_tooltip_text(
            "Play sounds when recording starts, stops, or encounters errors"
        )
        sound_row = PreferenceRow(
            title="Enable Sound Effects",
            subtitle="Play audio feedback for recording events",
            widget=self.sound_effects_switch,
        )
        sound_group.add_row(sound_row)
        self.audio_tab.pack_start(sound_group, False, False, 0)
        self.sound_effects_switch.connect("state-set", self._on_sound_effects_toggled)

        # Populate devices
        self._populate_audio_devices()
        self.audio_device_combo.connect("changed", self._on_audio_device_changed)

    def _build_general_section(self):
        """Build the General section with autostart and UI settings."""
        group = PreferencesGroup(title="General")

        self.autostart_switch = Gtk.Switch()
        self.autostart_switch.set_tooltip_text("Start Vocalinux automatically when you log in")
        autostart_row = PreferenceRow(
            title="Start on Login",
            subtitle="Automatically start Vocalinux when you log in",
            widget=self.autostart_switch,
        )
        group.add_row(autostart_row)

        self.start_minimized_switch = Gtk.Switch()
        self.start_minimized_switch.set_tooltip_text("Start minimized to system tray")
        start_minimized_row = PreferenceRow(
            title="Start Minimized",
            subtitle="Start minimized to system tray instead of showing window",
            widget=self.start_minimized_switch,
        )
        group.add_row(start_minimized_row)

        self.copy_to_clipboard_switch = Gtk.Switch()
        self.copy_to_clipboard_switch.set_tooltip_text(
            "Copy recognized text to clipboard after each transcription. "
            "Useful if injection fails or you want to paste elsewhere."
        )
        copy_to_clipboard_row = PreferenceRow(
            title="Copy to Clipboard",
            subtitle="Always copy recognized text to clipboard for easy pasting",
            widget=self.copy_to_clipboard_switch,
        )
        group.add_row(copy_to_clipboard_row)

        self.general_tab.pack_start(group, False, False, 0)

        self.autostart_switch.connect("state-set", self._on_autostart_toggled)
        self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled)
        self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)

    def _on_autostart_toggled(self, widget, state):
        """Handle toggle of the autostart switch."""
        if self._initializing or self._applying_settings:
            return False

        enabled = bool(state)
        logger.info(f"Autostart toggled: {enabled}")

        from . import autostart_manager

        if autostart_manager.set_autostart(enabled):
            self.config_manager.set("general", "autostart", enabled)
            self.config_manager.save_settings()
            logger.info(f"Autostart {'enabled' if enabled else 'disabled'}")
            return False

        return True

    def _on_start_minimized_toggled(self, widget, state):
        """Handle toggle of the start minimized switch."""
        if self._initializing or self._applying_settings:
            return False

        enabled = bool(state)
        logger.info(f"Start minimized toggled: {enabled}")
        self.config_manager.set("ui", "start_minimized", enabled)
        self.config_manager.save_settings()
        logger.info(f"Start minimized {'enabled' if enabled else 'disabled'}")
        return False

    def _on_copy_to_clipboard_toggled(self, widget, state):
        """Handle toggle of the copy to clipboard switch."""
        if self._initializing or self._applying_settings:
            return False

        enabled = bool(state)
        logger.info(f"Copy to clipboard toggled: {enabled}")
        self.config_manager.set("text_injection", "copy_to_clipboard", enabled)
        self.config_manager.save_settings()
        logger.info(f"Copy to clipboard {'enabled' if enabled else 'disabled'}")
        return False

    def _on_sound_effects_toggled(self, widget, state):
        if self._initializing or self._applying_settings:
            return False

        enabled = bool(state)
        logger.info(f"Sound effects toggled: {enabled}")
        self.config_manager.set_sound_effects_enabled(enabled)
        self.config_manager.save_settings()
        logger.info(f"Sound effects {'enabled' if enabled else 'disabled'}")
        return False

    def _build_engine_section(self):
        """Build the Speech Engine section."""
        group = PreferencesGroup(title="Speech Engine")

        # Engine selection
        self.engine_combo = Gtk.ComboBoxText()
        self.engine_combo.set_size_request(180, -1)
        _prevent_scroll_on_hover(self.engine_combo)
        engine_row = PreferenceRow(
            title="Engine",
            subtitle="Speech recognition backend",
            widget=self.engine_combo,
        )
        group.add_row(engine_row)

        # Model size selection
        self.model_combo = Gtk.ComboBoxText()
        self.model_combo.set_size_request(180, -1)
        self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)
        _prevent_scroll_on_hover(self.model_combo)
        self.model_row = PreferenceRow(
            title="Model Size",
            subtitle="Larger models are more accurate but slower",
            widget=self.model_combo,
        )
        self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)
        group.add_row(self.model_row)

        # whisper.cpp specialization selection
        self.model_variant_combo = Gtk.ComboBoxText()
        self.model_variant_combo.set_size_request(220, -1)
        self.model_variant_combo.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP)
        _prevent_scroll_on_hover(self.model_variant_combo)
        self.model_variant_row = PreferenceRow(
            title="Specialization",
            subtitle="Variant for language, speed, or memory use",
            widget=self.model_variant_combo,
        )
        self.model_variant_row.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP)
        group.add_row(self.model_variant_row)

        # Language selection
        self.language_combo = Gtk.ComboBoxText()
        self.language_combo.set_size_request(180, -1)
        self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP)
        _prevent_scroll_on_hover(self.language_combo)
        self.language_row = PreferenceRow(
            title="Language",
            subtitle="Primary language for recognition",
            widget=self.language_combo,
        )
        self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
        group.add_row(self.language_row)

        self.content_box.pack_start(group, False, False, 0)

        # Model info card (shown below the group)
        self.model_info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.model_info_card.get_style_context().add_class("model-info-card")
        self.model_info_card.set_margin_start(4)
        self.model_info_card.set_margin_end(4)

        self.model_info_title = Gtk.Label(xalign=0)
        self.model_info_title.get_style_context().add_class("model-info-title")
        self.model_info_card.pack_start(self.model_info_title, False, False, 0)

        self.model_info_subtitle = Gtk.Label(xalign=0, wrap=True)
        self.model_info_subtitle.get_style_context().add_class("model-info-subtitle")
        self.model_info_card.pack_start(self.model_info_subtitle, False, False, 0)

        self.model_recommendation = Gtk.Label(xalign=0, wrap=True)
        self.model_recommendation.get_style_context().add_class("tip-label")
        self.model_info_card.pack_start(self.model_recommendation, False, False, 0)

        self.content_box.pack_start(self.model_info_card, False, False, 0)

        # Language warning (for auto-detect)
        self.language_warning = Gtk.Label(label="", use_markup=True, xalign=0)
        self.language_warning.set_margin_start(16)
        self.language_warning.get_style_context().add_class("status-warning")
        self.content_box.pack_start(self.language_warning, False, False, 0)

        # Legend
        self.legend_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
        self.legend_box.set_halign(Gtk.Align.CENTER)
        self.legend_box.set_margin_top(4)
        self.legend_box.set_margin_bottom(4)

        for symbol, text in [
            ("✓", "Downloaded"),
            ("↓", "Will download"),
            ("★", "Recommended"),
        ]:
            item = Gtk.Label(label=f"{symbol} {text}")
            item.get_style_context().add_class("status-info")
            self.legend_box.pack_start(item, False, False, 0)

        self.content_box.pack_start(self.legend_box, False, False, 0)

        # Connect signals
        self.engine_combo.connect("changed", self._on_engine_changed)
        self.model_combo.connect("changed", self._on_model_changed)
        self.model_variant_combo.connect("changed", self._on_model_variant_changed)
        self.language_combo.connect("changed", self._on_language_changed)

    def _on_remote_api_settings_changed(self, widget):
        """Handle remote API URL/Key/endpoint changes."""
        if self._initializing or self._applying_settings:
            return

        url = self.remote_api_url_entry.get_text().strip()
        key = self.remote_api_key_entry.get_text().strip()
        endpoint = self.remote_api_endpoint_combo.get_active_id() or "/inference"
        model = self.remote_api_model_entry.get_text().strip() or "whisper-1"

        self.config_manager.set("speech_recognition", "remote_api_url", url)
        self.config_manager.set("speech_recognition", "remote_api_key", key)
        self.config_manager.set("speech_recognition", "remote_api_endpoint", endpoint)
        self.config_manager.set("speech_recognition", "remote_api_model", model)
        self.config_manager.save_settings()

        # Auto apply only if remote engine is currently active
        engine_text = self.engine_combo.get_active_text()
        engine = _engine_from_display(engine_text) if engine_text else "vosk"
        if engine == "remote_api":
            self._auto_apply_settings()

    def _on_test_remote_connection(self, widget):
        """Test remote server connection."""
        url = self.remote_api_url_entry.get_text().strip()
        if not url:
            self.remote_status_label.set_markup(
                "<span foreground='#c01c28'>✗ Please enter a server URL</span>"
            )
            return

        self.remote_test_btn.set_sensitive(False)
        self.remote_test_btn.set_label("Testing...")
        self.remote_status_label.set_markup("<i>Connecting...</i>")

        key = self.remote_api_key_entry.get_text().strip()
        endpoint = self.remote_api_endpoint_combo.get_active_id() or "/inference"

        def test_connection(url=url, key=key, endpoint=endpoint):
            try:
                import requests

                headers = {}
                if key:
                    headers["Authorization"] = f"Bearer {key}"

                clean_url = url.rstrip("/")

                session = requests.Session()
                try:
                    response = session.get(clean_url, headers=headers, timeout=5)

                    server_info = ""

                    try:
                        if endpoint == "/v1/audio/transcriptions":
                            openai_resp = session.get(
                                f"{clean_url}/v1/models", headers=headers, timeout=5
                            )
                            if openai_resp.status_code == 200:
                                server_info = " (OpenAI compatible)"
                        elif endpoint == "/inference":
                            whispercpp_resp = session.get(
                                f"{clean_url}/inference", headers=headers, timeout=5
                            )
                            if whispercpp_resp.status_code != 404:
                                server_info = " (whisper.cpp server)"
                    except Exception:
                        pass

                    GLib.idle_add(
                        self.remote_status_label.set_markup,
                        f"<span foreground='#26a269'>✓ Connected! "
                        f"(status={response.status_code}){server_info}</span>",
                    )
                finally:
                    session.close()

            except Exception as e:
                error_msg = str(e)[:80]
                GLib.idle_add(
                    self.remote_status_label.set_markup,
                    f"<span foreground='#c01c28'>✗ Connection failed: {error_msg}</span>",
                )

            GLib.idle_add(self.remote_test_btn.set_sensitive, True)
            GLib.idle_add(self.remote_test_btn.set_label, "Test Connection")

        threading.Thread(target=test_connection, daemon=True).start()

    def _build_recognition_section(self):
        """Build the Recognition Settings section."""
        group = PreferencesGroup(title="Recognition Settings")

        # VAD Sensitivity
        self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
        self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech")
        _prevent_scroll_on_hover(self.vad_spin)
        silero_active = is_silero_available()
        vad_subtitle = (
            "Sensitivity (1-5) -- backend: Silero neural VAD"
            if silero_active
            else "Sensitivity (1-5) -- backend: amplitude (install vocalinux[vad] for neural)"
        )
        self.vad_row = PreferenceRow(
            title="VAD Sensitivity",
            subtitle=vad_subtitle,
            widget=self.vad_spin,
        )
        group.add_row(self.vad_row)

        # Silence Timeout
        self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1)
        self.silence_spin.set_digits(1)
        self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
        _prevent_scroll_on_hover(self.silence_spin)
        silence_row = PreferenceRow(
            title="Silence Timeout",
            subtitle="Seconds of silence before processing",
            widget=self.silence_spin,
        )
        group.add_row(silence_row)

        # Voice Commands Toggle
        self.voice_commands_switch = Gtk.Switch()
        self.voice_commands_switch.set_tooltip_text(
            "Enable voice commands like 'new line', 'period', 'undo', etc.\n"
            "Useful for VOSK engine. Whisper engines handle punctuation automatically."
        )
        voice_commands_row = PreferenceRow(
            title="Voice Commands",
            subtitle="Enable voice commands for punctuation and editing",
            widget=self.voice_commands_switch,
        )
        group.add_row(voice_commands_row)

        self.recognition_settings_tab.pack_start(group, False, False, 0)

        if not silero_active:
            vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            vad_info_box.get_style_context().add_class("info-box")
            vad_info_box.set_margin_start(4)
            vad_info_box.set_margin_end(4)
            vad_info_box.set_margin_top(4)

            info_icon = Gtk.Image.new_from_icon_name(
                "dialog-information-symbolic", Gtk.IconSize.MENU
            )
            vad_info_box.pack_start(info_icon, False, False, 0)

            vad_info_label = Gtk.Label(
                label=(
                    "Neural VAD is more accurate at separating speech from background "
                    "noise. To enable it, install onnxruntime and restart Vocalinux:\n"
                    '    pip install "vocalinux[vad]"'
                ),
                xalign=0,
                wrap=True,
                selectable=True,
            )
            vad_info_label.get_style_context().add_class("tip-label")
            vad_info_box.pack_start(vad_info_label, True, True, 0)

            self.recognition_settings_tab.pack_start(vad_info_box, False, False, 0)

        # Connect signals
        self.vad_spin.connect("value-changed", self._on_vad_changed)
        self.silence_spin.connect("value-changed", self._on_silence_changed)
        self.voice_commands_switch.connect("state-set", self._on_voice_commands_toggled)

    def _build_shortcuts_section(self):
        """Build the Keyboard Shortcuts section."""
        group = PreferencesGroup(
            title="Keyboard Shortcuts",
            description="Configure the shortcut to control voice recognition",
        )

        # Mode selection (Toggle vs Push-to-Talk)
        self.shortcut_mode_combo = Gtk.ComboBoxText()
        self.shortcut_mode_combo.set_size_request(200, -1)
        self.shortcut_mode_combo.set_tooltip_text(
            "Choose between toggle (double-tap) or push-to-talk mode"
        )
        _prevent_scroll_on_hover(self.shortcut_mode_combo)

        # Populate mode options
        for mode_id, display_name in SHORTCUT_MODES.items():
            self.shortcut_mode_combo.append(mode_id, display_name)

        # Load current mode from config
        current_mode = self.config_manager.get_str("shortcuts", "mode", "toggle")
        if not self.shortcut_mode_combo.set_active_id(current_mode):
            self.shortcut_mode_combo.set_active_id("toggle")

        mode_row = PreferenceRow(
            title="Shortcut Mode",
            subtitle="How the shortcut behaves",
            widget=self.shortcut_mode_combo,
        )
        group.add_row(mode_row)

        # Shortcut selection combo
        self.shortcut_combo = Gtk.ComboBoxText()
        self.shortcut_combo.set_size_request(200, -1)
        self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut for voice typing")
        _prevent_scroll_on_hover(self.shortcut_combo)

        # Populate shortcut options grouped by side, then a Custom sentinel.
        # Preset and custom are mutually exclusive: only one is active at a time.
        for group_label, shortcut_ids in SHORTCUT_GROUPS.items():
            # Add group separator as a disabled label entry
            separator_id = f"__separator_{group_label}__"
            self.shortcut_combo.append(separator_id, f"── {group_label} ──")
            for shortcut_id in shortcut_ids:
                display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
                self.shortcut_combo.append(shortcut_id, display_name)
        self.shortcut_combo.append("__custom__", "Custom Shortcut")

        # Load current shortcut from config
        current_shortcut = self.config_manager.get_str(
            "shortcuts", "toggle_recognition", "ctrl+ctrl"
        )

        self.shortcut_row = PreferenceRow(
            title="Shortcut Key",
            subtitle="Press this key to control voice typing",
            widget=self.shortcut_combo,
        )
        group.add_row(self.shortcut_row)

        # Custom shortcut: modifier+key combos (e.g. Alt+R) for users who want
        # something the preset modifiers can't express (split keyboards, etc.).
        custom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.custom_shortcut_entry = Gtk.Entry()
        self.custom_shortcut_entry.set_placeholder_text("e.g. alt+r")
        self.custom_shortcut_entry.set_width_chars(12)
        self.custom_shortcut_entry.set_tooltip_text(
            "A modifier plus a key, e.g. alt+r, ctrl+alt+r, super+space"
        )
        self.custom_shortcut_entry.connect("activate", self._on_custom_shortcut_apply)
        custom_box.pack_start(self.custom_shortcut_entry, False, False, 0)

        self.record_shortcut_button = Gtk.Button(label="Record")
        self.record_shortcut_button.set_tooltip_text("Click, then press your desired key combo")
        self.record_shortcut_button.connect("clicked", self._on_record_shortcut_clicked)
        custom_box.pack_start(self.record_shortcut_button, False, False, 0)

        self.set_custom_shortcut_button = Gtk.Button(label="Set")
        self.set_custom_shortcut_button.set_tooltip_text("Apply the typed shortcut")
        self.set_custom_shortcut_button.connect("clicked", self._on_custom_shortcut_apply)
        custom_box.pack_start(self.set_custom_shortcut_button, False, False, 0)

        self.custom_shortcut_row = PreferenceRow(
            title="Custom Shortcut",
            subtitle="Modifier + key combo (great for split keyboards)",
            widget=custom_box,
        )
        group.add_row(self.custom_shortcut_row)

        # Key-capture state for the Record button.
        self._recording_shortcut = False
        self.connect("key-press-event", self._on_shortcut_key_press)

        self.shortcuts_tab.pack_start(group, False, False, 0)

        # Info box about the shortcut
        info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        info_box.get_style_context().add_class("info-box")
        info_box.set_margin_start(4)
        info_box.set_margin_end(4)
        info_box.set_margin_top(4)

        info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
        info_box.pack_start(info_icon, False, False, 0)

        self.shortcut_info_label = Gtk.Label(
            label="Changes take effect immediately.",
            xalign=0,
            wrap=True,
        )
        self.shortcut_info_label.get_style_context().add_class("tip-label")
        info_box.pack_start(self.shortcut_info_label, True, True, 0)

        self.shortcuts_tab.pack_start(info_box, False, False, 0)

        # Reflect active shortcut in combo + custom entry (preset vs custom).
        self._sync_shortcut_selection_ui(current_shortcut)

        # Connect signals
        self.shortcut_combo.connect("changed", self._on_shortcut_changed)
        self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)

        # Update UI based on initial mode
        self._update_shortcut_ui_for_mode(current_mode)

    def _is_preset_shortcut(self, shortcut: str) -> bool:
        """Return True if shortcut is one of the built-in double-tap presets."""
        return shortcut in SUPPORTED_SHORTCUTS

    def _set_shortcut_combo_active_id(self, active_id: str) -> None:
        """Select a combo item without firing the changed handler."""
        # Handler may not be connected yet (during section build).
        try:
            self.shortcut_combo.handler_block_by_func(self._on_shortcut_changed)
            blocked = True
        except TypeError:
            blocked = False
        try:
            if not self.shortcut_combo.set_active_id(active_id):
                self.shortcut_combo.set_active_id("ctrl+ctrl")
        finally:
            if blocked:
                self.shortcut_combo.handler_unblock_by_func(self._on_shortcut_changed)

    def _sync_shortcut_selection_ui(self, shortcut: str) -> None:
        """Keep preset combo and custom entry mutually exclusive.

        - Preset active: combo shows that preset; custom entry is cleared.
        - Custom active: combo shows "Custom Shortcut"; entry holds the combo string.
        """
        if self._is_preset_shortcut(shortcut):
            self._set_shortcut_combo_active_id(shortcut)
            self.custom_shortcut_entry.set_text("")
        else:
            self._set_shortcut_combo_active_id("__custom__")
            self.custom_shortcut_entry.set_text(shortcut)

    def _report_shortcut_apply_result(self, display_name: str, applied: bool) -> None:
        """Show success/restart feedback after a shortcut change."""
        if applied:
            self.shortcut_info_label.set_markup(
                f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
                "Active now!</span>"
            )
        else:
            self.shortcut_info_label.set_markup(
                f"<i>Shortcut updated to <b>{display_name}</b>. "
                "Restart the app for the change to take full effect.</i>"
            )

    def _apply_custom_shortcut(self, shortcut: str) -> None:
        """Validate, persist, and live-apply a custom shortcut string."""
        shortcut = shortcut.strip().lower()
        if not is_valid_shortcut(shortcut):
            self.shortcut_info_label.set_markup(
                f"<span foreground='#e01b24'>Invalid shortcut: "
                f"<b>{GLib.markup_escape_text(shortcut or '(empty)')}</b>. "
                "Try a modifier + key, e.g. alt+r.</span>"
            )
            return

        # If the user typed/recorded a preset id, treat it as selecting that preset.
        if self._is_preset_shortcut(shortcut):
            self.config_manager.set("shortcuts", "toggle_recognition", shortcut)
            self.config_manager.save_settings()
            self._sync_shortcut_selection_ui(shortcut)
            mode_id = self.shortcut_mode_combo.get_active_id()
            display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
            logger.info(f"Keyboard shortcut changed to preset via custom entry: {display_name}")
            applied = False
            if self.shortcut_update_callback:
                applied = bool(self.shortcut_update_callback(shortcut, mode_id))
            self._report_shortcut_apply_result(display_name, applied)
            return

        self.config_manager.set("shortcuts", "toggle_recognition", shortcut)
        self.config_manager.save_settings()
        # Dropdown should show Custom, not a leftover preset.
        self._sync_shortcut_selection_ui(shortcut)

        mode_id = self.shortcut_mode_combo.get_active_id()
        display_name = get_shortcut_display_name(shortcut, mode_id)
        logger.info(f"Keyboard shortcut changed to custom: {display_name}")

        applied = False
        if self.shortcut_update_callback:
            applied = bool(self.shortcut_update_callback(shortcut, mode_id))
        self._report_shortcut_apply_result(display_name, applied)

    def _on_custom_shortcut_apply(self, widget):
        """Handle Set button / Entry activation for a typed custom shortcut."""
        if self._initializing:
            return
        self._apply_custom_shortcut(self.custom_shortcut_entry.get_text())

    def _on_record_shortcut_clicked(self, widget):
        """Begin capturing the next key combo pressed in the dialog."""
        self._recording_shortcut = True
        self.record_shortcut_button.set_label("Press keys…")
        self.shortcut_info_label.set_markup(
            "<i>Press a modifier + key (e.g. Alt+R). Press Esc to cancel.</i>"
        )

    def _stop_recording_shortcut(self):
        """Exit key-capture mode and restore the Record button."""
        self._recording_shortcut = False
        self.record_shortcut_button.set_label("Record")

    def _gdk_event_to_shortcut(self, event) -> Optional[str]:
        """Build a canonical shortcut string from a GDK key-press event."""
        modifiers = []
        state = event.state
        if state & Gdk.ModifierType.CONTROL_MASK:
            modifiers.append("ctrl")
        if state & Gdk.ModifierType.MOD1_MASK:
            modifiers.append("alt")
        if state & Gdk.ModifierType.SHIFT_MASK:
            modifiers.append("shift")
        if state & Gdk.ModifierType.SUPER_MASK:
            modifiers.append("super")
        token = _gdk_keyname_to_token(Gdk.keyval_name(event.keyval))
        if not modifiers or token is None:
            return None
        return "+".join(modifiers + [token])

    def _on_shortcut_key_press(self, widget, event):
        """Capture a pressed combo while recording; otherwise pass through."""
        if not getattr(self, "_recording_shortcut", False):
            return False  # not recording: let normal event handling proceed

        keyname = Gdk.keyval_name(event.keyval) or ""
        if keyname in _GDK_MODIFIER_KEYNAMES:
            return True  # wait for the non-modifier key

        shortcut = self._gdk_event_to_shortcut(event)
        if keyname == "Escape" and not shortcut:
            self._stop_recording_shortcut()
            self.shortcut_info_label.set_text("Recording cancelled.")
            return True

        if shortcut and is_valid_shortcut(shortcut):
            self.custom_shortcut_entry.set_text(shortcut)
            self._stop_recording_shortcut()
            self._apply_custom_shortcut(shortcut)
        else:
            self.shortcut_info_label.set_markup(
                "<span foreground='#e01b24'>Need a modifier + key. "
                "Try again or press Esc to cancel.</span>"
            )
        return True

    def _update_shortcut_ui_for_mode(self, mode: str):
        """Update the shortcut UI text to match both the mode and the shortcut.

        A modifier+key combo (e.g. "alt+r") toggles on a single press, not a
        double-tap, so the wording must reflect the active shortcut instead of
        assuming a bare-modifier gesture. Reads the saved shortcut from config
        (the single source of truth for both preset and custom shortcuts).
        """
        shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
        is_combo = is_valid_shortcut(shortcut) and parse_shortcut_spec(shortcut).is_combo
        # e.g. "Press Alt+R" (combo toggle), "Double-tap Ctrl", "Hold Alt+R".
        action = get_shortcut_display_name(shortcut, mode)

        if mode == "toggle":
            if is_combo:
                self.shortcut_row.set_subtitle(f"{action} to start/stop voice typing")
                self.shortcut_info_label.set_text(
                    f"In Toggle mode: {action} to start voice typing, {action.lower()} "
                    "again to stop."
                )
            else:
                self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing")
                self.shortcut_info_label.set_text(
                    "In Toggle mode: Double-tap the key to start voice typing, "
                    "double-tap again to stop."
                )
        elif mode == "push_to_talk":
            self.shortcut_row.set_subtitle("Hold this shortcut to speak, release to stop")
            self.shortcut_info_label.set_text(
                "In Push-to-Talk mode: Hold the shortcut down to speak, release to stop recording."
            )

    def _on_shortcut_mode_changed(self, widget):
        """Handle shortcut mode selection change."""
        if self._initializing:
            return

        mode_id = self.shortcut_mode_combo.get_active_id()
        if not mode_id:
            return

        # Save to config
        self.config_manager.set("shortcuts", "mode", mode_id)
        self.config_manager.save_settings()

        mode_name = SHORTCUT_MODES.get(mode_id, mode_id)
        logger.info(f"Keyboard shortcut mode changed to: {mode_name}")

        # Update UI to reflect new mode
        self._update_shortcut_ui_for_mode(mode_id)

        # Apply from saved config (source of truth for both preset and custom).
        if self.shortcut_update_callback:
            shortcut_id = self.config_manager.get_str(
                "shortcuts", "toggle_recognition", "ctrl+ctrl"
            )
            success = self.shortcut_update_callback(shortcut_id, mode_id)
            if success:
                self.shortcut_info_label.set_markup(
                    f"<span foreground='#26a269'>Mode updated to <b>{mode_name}</b>. "
                    f"Active now!</span>"
                )
            else:
                self.shortcut_info_label.set_markup(
                    f"<i>Mode updated to <b>{mode_name}</b>. "
                    f"Restart the app for the change to take full effect.</i>"
                )
        else:
            self.shortcut_info_label.set_markup(
                f"<i>Mode updated to <b>{mode_name}</b>. "
                f"Restart the app for the change to take full effect.</i>"
            )

    def _revert_shortcut_combo_to_saved(self) -> None:
        """Restore the combo selection to match the saved active shortcut."""
        current = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
        if self._is_preset_shortcut(current):
            self._set_shortcut_combo_active_id(current)
        else:
            self._set_shortcut_combo_active_id("__custom__")

    def _on_shortcut_changed(self, widget):
        """Handle shortcut selection change."""
        if self._initializing:
            return

        shortcut_id = self.shortcut_combo.get_active_id()
        if not shortcut_id:
            return

        # Ignore separator entries (used for grouped display)
        if shortcut_id.startswith("__separator_"):
            self._revert_shortcut_combo_to_saved()
            return

        # Selecting "Custom Shortcut" does not change the active binding until
        # the user records/sets one; just focus the custom entry for convenience.
        if shortcut_id == "__custom__":
            current = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
            if not self._is_preset_shortcut(current):
                self.custom_shortcut_entry.set_text(current)
            self.custom_shortcut_entry.grab_focus()
            self.shortcut_info_label.set_markup(
                "<i>Record or type a custom shortcut (e.g. alt+r), then click Set.</i>"
            )
            return

        # Preset selected: clear any leftover custom entry so UI matches config.
        self.custom_shortcut_entry.set_text("")
        self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
        self.config_manager.save_settings()

        display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
        logger.info(f"Keyboard shortcut changed to: {display_name}")

        applied = False
        if self.shortcut_update_callback:
            mode_id = self.shortcut_mode_combo.get_active_id()
            applied = bool(self.shortcut_update_callback(shortcut_id, mode_id))
        self._report_shortcut_apply_result(display_name, applied)

    def _build_test_section(self):
        """Build the Test Recognition section."""
        group = PreferencesGroup(title="Test Recognition")

        # Test area inside the group's listbox as a custom row
        test_container = Gtk.ListBoxRow()
        test_container.set_activatable(False)
        test_container.get_style_context().add_class("preference-row")

        test_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        test_box.set_margin_top(12)
        test_box.set_margin_bottom(12)
        test_box.set_margin_start(16)
        test_box.set_margin_end(16)

        # Text view for test results
        scrolled_window = Gtk.ScrolledWindow()
        scrolled_window.set_min_content_height(80)
        scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        scrolled_window.get_style_context().add_class("test-area")

        self.test_textview = Gtk.TextView()
        self.test_textview.set_editable(False)
        self.test_textview.set_cursor_visible(False)
        self.test_textview.set_wrap_mode(Gtk.WrapMode.WORD)
        self.test_textview.get_style_context().add_class("test-textview")
        self.test_buffer = self.test_textview.get_buffer()
        scrolled_window.add(self.test_textview)
        test_box.pack_start(scrolled_window, True, True, 0)

        # Test button
        self.test_button = Gtk.Button(label="Start Test (3 seconds)")
        self.test_button.get_style_context().add_class("suggested-action")
        self.test_button.connect("clicked", self._on_test_clicked)
        test_box.pack_start(self.test_button, False, False, 0)

        test_container.add(test_box)
        group.listbox.add(test_container)

        self.recognition_settings_tab.pack_start(group, False, False, 0)

        # Recognition Progress section
        progress_group = PreferencesGroup(title="Recognition Status")

        # Status row
        status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.recognition_status_label = Gtk.Label(label="Idle", xalign=0)
        status_box.pack_start(self.recognition_status_label, True, True, 0)

        self.recognition_indicator = Gtk.Image.new_from_icon_name(
            "media-record-symbolic", Gtk.IconSize.MENU
        )
        self.recognition_indicator.set_opacity(0.3)
        status_box.pack_end(self.recognition_indicator, False, False, 0)

        status_row = PreferenceRow(
            title="Status",
            widget=status_box,
        )
        progress_group.add_row(status_row)

        # Audio level row
        self.recognition_audio_level = Gtk.LevelBar()
        self.recognition_audio_level.set_min_value(0)
        self.recognition_audio_level.set_max_value(100)
        self.recognition_audio_level.set_value(0)
        self.recognition_audio_level.set_size_request(150, -1)

        level_row = PreferenceRow(
            title="Audio Level",
            widget=self.recognition_audio_level,
        )
        progress_group.add_row(level_row)

        self.recognition_settings_tab.pack_start(progress_group, False, False, 0)

        # Progress info label
        self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0)
        self.progress_info_label.set_margin_start(16)
        self.progress_info_label.set_margin_bottom(8)
        self.recognition_settings_tab.pack_start(self.progress_info_label, False, False, 0)

    def _build_advanced_section(self):
        """Build the Advanced section with whisper.cpp parameters."""

        # Opt-in toggle at the top of the tab
        opt_in_group = PreferencesGroup(title="Advanced Access")
        self.power_user_switch = Gtk.Switch()
        self.power_user_switch.set_tooltip_text("Reveal advanced whisper.cpp tuning parameters")
        power_user_row = PreferenceRow(
            title="Unlock Advanced Settings",
            subtitle="I know what I'm doing — show me the whisper.cpp tuning knobs",
            widget=self.power_user_switch,
        )
        opt_in_group.add_row(power_user_row)
        self.advanced_tab.pack_start(opt_in_group, False, False, 0)

        # Revealer that hides/shows the actual advanced controls
        self.advanced_revealer = Gtk.Revealer()
        self.advanced_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)

        # Scrollable container for the controls
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scrolled.set_min_content_height(350)

        controls_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)

        group = PreferencesGroup(title="Whisper.cpp Decoding")

        self.advanced_no_timestamps_switch = Gtk.Switch()
        self.advanced_no_timestamps_switch.set_tooltip_text(
            "Disable timestamp generation to reduce hallucinations"
        )
        no_timestamps_row = PreferenceRow(
            title="No Timestamps",
            subtitle="Disable timestamp tokens (reduces hallucinations)",
            widget=self.advanced_no_timestamps_switch,
        )
        group.add_row(no_timestamps_row)

        self.advanced_no_context_switch = Gtk.Switch()
        self.advanced_no_context_switch.set_tooltip_text(
            "Do not condition on previously transcribed text"
        )
        no_context_row = PreferenceRow(
            title="No Context",
            subtitle="Prevent error loops from past text",
            widget=self.advanced_no_context_switch,
        )
        group.add_row(no_context_row)

        self.advanced_temperature_spin = Gtk.SpinButton.new_with_range(0.0, 1.0, 0.1)
        self.advanced_temperature_spin.set_digits(1)
        self.advanced_temperature_spin.set_tooltip_text(
            "0.0 = greedy decoding, higher = more random"
        )
        _prevent_scroll_on_hover(self.advanced_temperature_spin)
        temperature_row = PreferenceRow(
            title="Temperature",
            subtitle="Decoding randomness (0.0 = deterministic)",
            widget=self.advanced_temperature_spin,
        )
        group.add_row(temperature_row)

        self.advanced_temperature_inc_spin = Gtk.SpinButton.new_with_range(-1.0, 1.0, 0.1)
        self.advanced_temperature_inc_spin.set_digits(1)
        self.advanced_temperature_inc_spin.set_tooltip_text(
            "-1.0 disables temperature fallback entirely"
        )
        _prevent_scroll_on_hover(self.advanced_temperature_inc_spin)
        temperature_inc_row = PreferenceRow(
            title="Temperature Increment",
            subtitle="Fallback step (-1.0 = disabled)",
            widget=self.advanced_temperature_inc_spin,
        )
        group.add_row(temperature_inc_row)

        self.advanced_entropy_thold_spin = Gtk.SpinButton.new_with_range(0.0, 5.0, 0.1)
        self.advanced_entropy_thold_spin.set_digits(1)
        self.advanced_entropy_thold_spin.set_tooltip_text(
            "Higher values catch more repetition loops"
        )
        _prevent_scroll_on_hover(self.advanced_entropy_thold_spin)
        entropy_row = PreferenceRow(
            title="Entropy Threshold",
            subtitle="Repetition loop detection",
            widget=self.advanced_entropy_thold_spin,
        )
        group.add_row(entropy_row)

        self.advanced_logprob_thold_spin = Gtk.SpinButton.new_with_range(-5.0, 0.0, 0.1)
        self.advanced_logprob_thold_spin.set_digits(1)
        self.advanced_logprob_thold_spin.set_tooltip_text(
            "Average log-probability threshold for fallback"
        )
        _prevent_scroll_on_hover(self.advanced_logprob_thold_spin)
        logprob_row = PreferenceRow(
            title="Logprob Threshold",
            subtitle="Fallback trigger for low confidence",
            widget=self.advanced_logprob_thold_spin,
        )
        group.add_row(logprob_row)

        self.advanced_no_speech_thold_spin = Gtk.SpinButton.new_with_range(0.0, 1.0, 0.05)
        self.advanced_no_speech_thold_spin.set_digits(2)
        self.advanced_no_speech_thold_spin.set_tooltip_text(
            "Probability threshold for treating audio as silence"
        )
        _prevent_scroll_on_hover(self.advanced_no_speech_thold_spin)
        no_speech_row = PreferenceRow(
            title="No-Speech Threshold",
            subtitle="Silence detection confidence",
            widget=self.advanced_no_speech_thold_spin,
        )
        group.add_row(no_speech_row)

        # Initial Prompt -- moved to the end and made multiline
        initial_prompt_help = (
            "Optional. Add names, jargon, punctuation style, or other context to bias "
            "whisper.cpp transcription. Leave blank for normal dictation."
        )
        self.advanced_initial_prompt_textview = Gtk.TextView()
        self.advanced_initial_prompt_textview.set_wrap_mode(Gtk.WrapMode.WORD)
        self.advanced_initial_prompt_textview.set_tooltip_text(initial_prompt_help)
        self.advanced_initial_prompt_textview.set_size_request(250, 80)

        prompt_scrolled = Gtk.ScrolledWindow()
        prompt_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        prompt_scrolled.set_min_content_height(80)
        prompt_scrolled.set_tooltip_text(initial_prompt_help)
        prompt_scrolled.add(self.advanced_initial_prompt_textview)

        initial_prompt_row = PreferenceRow(
            title="Initial Prompt",
            subtitle="Context to steer transcription style",
            widget=prompt_scrolled,
        )
        initial_prompt_row.set_tooltip_text(initial_prompt_help)
        group.add_row(initial_prompt_row)

        info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        info_box.get_style_context().add_class("info-box")
        info_box.set_margin_start(4)
        info_box.set_margin_end(4)
        info_box.set_margin_bottom(4)

        info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
        info_box.pack_start(info_icon, False, False, 0)

        self.advanced_info_label = Gtk.Label(
            label="These settings only apply when the whisper.cpp engine is selected.",
            xalign=0,
            wrap=True,
        )
        self.advanced_info_label.get_style_context().add_class("tip-label")
        info_box.pack_start(self.advanced_info_label, True, True, 0)

        controls_box.pack_start(info_box, False, False, 0)
        controls_box.pack_start(group, False, False, 0)

        separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        separator.set_margin_top(8)
        separator.set_margin_bottom(8)
        controls_box.pack_start(separator, False, False, 0)

        scrolled.add(controls_box)
        self.advanced_revealer.add(scrolled)
        self.advanced_tab.pack_start(self.advanced_revealer, True, True, 0)

        self.advanced_no_timestamps_switch.connect("state-set", self._on_advanced_param_changed)
        self.advanced_no_context_switch.connect("state-set", self._on_advanced_param_changed)
        self.advanced_temperature_spin.connect("value-changed", self._on_advanced_param_changed)
        self.advanced_temperature_inc_spin.connect("value-changed", self._on_advanced_param_changed)
        self.advanced_entropy_thold_spin.connect("value-changed", self._on_advanced_param_changed)
        self.advanced_logprob_thold_spin.connect("value-changed", self._on_advanced_param_changed)
        self.advanced_no_speech_thold_spin.connect("value-changed", self._on_advanced_param_changed)

        self.advanced_initial_prompt_buffer = self.advanced_initial_prompt_textview.get_buffer()
        self.advanced_initial_prompt_buffer.connect("changed", self._on_advanced_prompt_changed)

        self.power_user_switch.connect("state-set", self._on_power_user_toggled)

    def _build_remote_server_section(self):
        """Build the Remote Server configuration section (shown when Remote API engine is selected)."""
        self.remote_server_group = PreferencesGroup(
            title="Remote Server",
            description=(
                "Offload speech recognition to a server on your network. "
                "Supports whisper.cpp and OpenAI-compatible APIs."
            ),
        )

        # Server URL
        self.remote_api_url_entry = Gtk.Entry()
        self.remote_api_url_entry.set_placeholder_text("http://192.168.1.100:8080")
        self.remote_api_url_entry.set_tooltip_text(
            "URL of the remote speech recognition server\n"
            "Supports OpenAI compatible API and whisper.cpp server"
        )
        self.remote_api_url_entry.set_size_request(280, -1)
        remote_url_row = PreferenceRow(
            title="Server URL",
            subtitle="Remote speech recognition server address",
            widget=self.remote_api_url_entry,
        )
        self.remote_server_group.add_row(remote_url_row)

        # API Key
        self.remote_api_key_entry = Gtk.Entry()
        self.remote_api_key_entry.set_placeholder_text("(optional)")
        self.remote_api_key_entry.set_visibility(False)
        self.remote_api_key_entry.set_tooltip_text("API Key for authentication (optional)")
        self.remote_api_key_entry.set_size_request(280, -1)
        remote_key_row = PreferenceRow(
            title="API Key",
            subtitle="Authentication key (optional)",
            widget=self.remote_api_key_entry,
        )
        self.remote_server_group.add_row(remote_key_row)

        # API Endpoint
        self.remote_api_endpoint_combo = Gtk.ComboBoxText()
        self.remote_api_endpoint_combo.set_size_request(280, -1)
        self.remote_api_endpoint_combo.set_tooltip_text(
            "Select the API format of the remote server (API Endpoint Format)"
        )
        self.remote_api_endpoint_combo.append(
            "/v1/audio/transcriptions", "OpenAI/FunASR (/v1/audio/transcriptions)"
        )
        self.remote_api_endpoint_combo.append("/inference", "Whisper.cpp (/inference)")
        _prevent_scroll_on_hover(self.remote_api_endpoint_combo)
        remote_endpoint_row = PreferenceRow(
            title="API Endpoint",
            subtitle="API format for the remote server",
            widget=self.remote_api_endpoint_combo,
        )
        self.remote_server_group.add_row(remote_endpoint_row)

        # Model name
        self.remote_api_model_entry = Gtk.Entry()
        self.remote_api_model_entry.set_placeholder_text("whisper-1")
        self.remote_api_model_entry.set_tooltip_text(
            "Model identifier sent to OpenAI-compatible and FunASR servers"
        )
        self.remote_api_model_entry.set_size_request(280, -1)
        remote_model_row = PreferenceRow(
            title="Model",
            subtitle="Remote model name, for example whisper-1 or sensevoice",
            widget=self.remote_api_model_entry,
        )
        self.remote_server_group.add_row(remote_model_row)

        # Connection test
        self.remote_test_btn = Gtk.Button(label="Test Connection")
        self.remote_test_btn.set_tooltip_text("Test connection to remote server")
        self.remote_test_btn.connect("clicked", self._on_test_remote_connection)
        remote_test_row = PreferenceRow(
            title="Connection Test",
            subtitle="Verify remote server is reachable",
            widget=self.remote_test_btn,
        )
        self.remote_server_group.add_row(remote_test_row)

        self.content_box.pack_start(self.remote_server_group, False, False, 0)

        # Status label below the group
        self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0)
        self.remote_status_label.set_margin_start(16)
        self.remote_status_label.set_margin_top(4)
        self.remote_status_label.get_style_context().add_class("status-info")
        self.content_box.pack_start(self.remote_status_label, False, False, 0)

        # Load saved values into the widgets
        saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "")
        saved_key = self.config_manager.get("speech_recognition", "remote_api_key", "")
        saved_endpoint = self.config_manager.get(
            "speech_recognition", "remote_api_endpoint", "/inference"
        )
        saved_model = self.config_manager.get("speech_recognition", "remote_api_model", "whisper-1")
        if saved_url:
            self.remote_api_url_entry.set_text(saved_url)
        if saved_key:
            self.remote_api_key_entry.set_text(saved_key)
        self.remote_api_endpoint_combo.set_active_id(saved_endpoint)
        self.remote_api_model_entry.set_text(saved_model or "whisper-1")

        self.remote_api_url_entry.connect("changed", self._on_remote_api_settings_changed)
        self.remote_api_key_entry.connect("changed", self._on_remote_api_settings_changed)
        self.remote_api_endpoint_combo.connect("changed", self._on_remote_api_settings_changed)
        self.remote_api_model_entry.connect("changed", self._on_remote_api_settings_changed)

        self.remote_server_group.hide()
        self.remote_status_label.hide()

    def _on_power_user_toggled(self, widget, state):
        """Handle the power-user opt-in toggle."""
        if self._initializing or self._applying_settings:
            return False
        if getattr(self, "_power_user_dialog_open", False):
            return True
        if state:
            self._power_user_dialog_open = True
            try:
                dialog = Gtk.MessageDialog(
                    transient_for=self,
                    flags=Gtk.DialogFlags.MODAL,
                    message_type=Gtk.MessageType.WARNING,
                    buttons=Gtk.ButtonsType.NONE,
                    text="Advanced Settings",
                )
                dialog.format_secondary_text(
                    "These settings control whisper.cpp's internal decoding parameters. "
                    "Changing them can affect transcription quality and performance. "
                    "Only proceed if you understand the impact."
                )
                dialog.add_button("_Keep it Simple", Gtk.ResponseType.CANCEL)
                confirm_btn = dialog.add_button("_I Know What I'm Doing", Gtk.ResponseType.YES)
                confirm_btn.get_style_context().add_class("suggested-action")
                response = dialog.run()
                dialog.destroy()
            finally:
                self._power_user_dialog_open = False
            if response != Gtk.ResponseType.YES:
                return True
        self.config_manager.set("advanced", "power_user_mode", state)
        self.config_manager.save_settings()
        self.advanced_revealer.set_reveal_child(state)
        return False

    def _on_settings_dialog_response(self, dialog, response_id):
        """Persist deferred text edits before the settings dialog closes."""
        if response_id in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT):
            self._flush_advanced_prompt_if_dirty()

    def _on_settings_page_switched(self, notebook, page, page_num):
        """Update contextual footer actions when the active settings page changes."""
        if page_num != self.advanced_page_num:
            self._flush_advanced_prompt_if_dirty()
        self._update_advanced_reset_button_visibility(page_num)

    def _update_advanced_reset_button_visibility(self, page_num: int = None):
        """Show the reset action only on the Advanced settings page."""
        if page_num is None:
            page_num = self.settings_notebook.get_current_page()
        self.advanced_reset_button.set_visible(page_num == self.advanced_page_num)

    def _on_advanced_prompt_changed(self, buffer):
        """Track prompt edits without applying settings on every keystroke."""
        if self._initializing or self._applying_settings:
            return
        self._advanced_prompt_dirty = True

    def _flush_advanced_prompt_if_dirty(self):
        """Apply deferred initial prompt edits."""
        if not self._advanced_prompt_dirty or self._initializing or self._applying_settings:
            return
        self._auto_apply_settings()
        self._advanced_prompt_dirty = False

    def _on_advanced_param_changed(self, widget, *args):
        """Handle any advanced parameter change."""
        if self._initializing or self._applying_settings:
            return False
        self._auto_apply_settings()
        self._advanced_prompt_dirty = False
        return False

    def _on_reset_advanced_clicked(self, widget):
        """Reset whisper.cpp advanced parameters to defaults."""
        if self._initializing or self._applying_settings:
            return

        defaults = DEFAULT_CONFIG["advanced"]
        self._applying_settings = True
        try:
            self.advanced_no_timestamps_switch.set_active(defaults["whispercpp_no_timestamps"])
            self.advanced_no_context_switch.set_active(defaults["whispercpp_no_context"])
            self.advanced_initial_prompt_buffer.set_text(defaults["whispercpp_initial_prompt"], -1)
            self.advanced_temperature_spin.set_value(defaults["whispercpp_temperature"])
            self.advanced_temperature_inc_spin.set_value(defaults["whispercpp_temperature_inc"])
            self.advanced_entropy_thold_spin.set_value(defaults["whispercpp_entropy_thold"])
            self.advanced_logprob_thold_spin.set_value(defaults["whispercpp_logprob_thold"])
            self.advanced_no_speech_thold_spin.set_value(defaults["whispercpp_no_speech_thold"])
        finally:
            self._applying_settings = False

        self._auto_apply_settings()

    def _load_and_apply_settings(self):
        """Load current settings and populate the UI."""
        settings = self._get_current_settings()
        self.current_engine = settings["engine"]
        self.language = settings["language"]
        self.current_model_size = settings["model_size"]
        self.current_vad = settings.get("vad_sensitivity", 3)
        self.current_silence = settings.get("silence_timeout", 2.0)

        logger.info(
            f"Starting dialog with settings: engine={self.current_engine}, model={self.current_model_size}"
        )

        general_settings = self.config_manager.get_settings().get("general", {})
        ui_settings = self.config_manager.get_settings().get("ui", {})
        text_injection_settings = self.config_manager.get_settings().get("text_injection", {})

        autostart_enabled = general_settings.get("autostart", False)
        start_minimized = ui_settings.get("start_minimized", False)
        copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False)

        self.autostart_switch.set_active(autostart_enabled)
        self.start_minimized_switch.set_active(start_minimized)
        self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
        self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled())

        available_engines = get_available_engines()
        available_count = 0

        for engine in ENGINE_MODELS.keys():
            if available_engines.get(engine, False):
                display_name = _engine_display_name(engine)
                self.engine_combo.append(display_name, display_name)
                available_count += 1

        if available_count == 0:
            logger.error("No speech recognition engines available!")
            # Still add them so the UI works, but log the error
            for engine in ENGINE_MODELS.keys():
                display_name = _engine_display_name(engine)
                self.engine_combo.append(display_name, display_name)
        else:
            logger.info(f"Populated {available_count} available engines: {available_engines}")

        if not available_engines.get(self.current_engine, False):
            logger.warning(
                f"Current engine '{self.current_engine}' is not available, "
                "selecting first available"
            )
            for engine in ENGINE_MODELS.keys():
                if available_engines.get(engine, False):
                    self.current_engine = engine
                    break

        engine_text = _engine_display_name(self.current_engine)
        logger.info(f"Setting active engine to: {engine_text}")
        if not self.engine_combo.set_active_id(engine_text):
            logger.warning("Could not set engine by ID, trying by index")
            # Find index of current engine
            model = self.engine_combo.get_model()
            for i, row in enumerate(model):
                if _engine_from_display(row[0]) == self.current_engine:
                    self.engine_combo.set_active(i)
                    break
            else:
                # Fallback to first available
                if self.engine_combo.get_model():
                    self.engine_combo.set_active(0)

        # Populate model and language options for the selected engine
        self._populate_model_options()
        self._sync_language_options_for_selected_model(self.language)

        # Set spin button values
        self.vad_spin.set_value(self.current_vad)
        self.silence_spin.set_value(self.current_silence)

        # Set voice commands switch based on config
        voice_commands_enabled = self.config_manager.is_voice_commands_enabled()
        self.voice_commands_switch.set_active(voice_commands_enabled)

        advanced_settings = self.config_manager.get_settings().get("advanced", {})
        power_user_mode = advanced_settings.get("power_user_mode", False)
        self.power_user_switch.set_active(power_user_mode)
        self.advanced_revealer.set_reveal_child(power_user_mode)
        self.advanced_no_timestamps_switch.set_active(
            advanced_settings.get("whispercpp_no_timestamps", True)
        )
        self.advanced_no_context_switch.set_active(
            advanced_settings.get("whispercpp_no_context", True)
        )
        self.advanced_initial_prompt_buffer.set_text(
            advanced_settings.get("whispercpp_initial_prompt", ""), -1
        )
        self.advanced_temperature_spin.set_value(
            advanced_settings.get("whispercpp_temperature", 0.0)
        )
        self.advanced_temperature_inc_spin.set_value(
            advanced_settings.get("whispercpp_temperature_inc", -1.0)
        )
        self.advanced_entropy_thold_spin.set_value(
            advanced_settings.get("whispercpp_entropy_thold", 2.4)
        )
        self.advanced_logprob_thold_spin.set_value(
            advanced_settings.get("whispercpp_logprob_thold", -1.0)
        )
        self.advanced_no_speech_thold_spin.set_value(
            advanced_settings.get("whispercpp_no_speech_thold", 0.6)
        )

    def _get_current_settings(self):
        """Get current settings from config manager."""
        self.config_manager.load_config()
        settings = self.config_manager.get_settings()

        sr_settings = settings.get("speech_recognition", {})
        engine = sr_settings.get("engine", "vosk")
        language = sr_settings.get("language", "en-us")
        model_size = self.config_manager.get_model_size_for_engine(engine)
        vad_sensitivity = sr_settings.get("vad_sensitivity", 3)
        silence_timeout = sr_settings.get("silence_timeout", 2.0)

        logger.info(
            f"Loaded current settings: engine={engine}, language={language}, model_size={model_size}, "
            f"vad={vad_sensitivity}, silence={silence_timeout}"
        )

        return {
            "engine": engine,
            "language": language,
            "model_size": model_size,
            "vad_sensitivity": vad_sensitivity,
            "silence_timeout": silence_timeout,
        }

    def _get_selected_engine(self) -> str:
        """Return the currently selected engine ID."""
        engine_text = self.engine_combo.get_active_text()
        return _engine_from_display(engine_text) if engine_text else "vosk"

    def _get_selected_whispercpp_model(self) -> str:
        """Return the selected whisper.cpp model variant."""
        variant_id = self.model_variant_combo.get_active_id()
        if variant_id:
            return variant_id

        size_id = self.model_combo.get_active_id()
        model_size = size_id.lower() if size_id else "small"
        variants = get_whispercpp_model_variants(model_size)
        return variants[0] if variants else "small"

    def _get_recommended_whispercpp_model_for_language(self) -> tuple[str, str]:
        """Return the recommended whisper.cpp variant for the selected language."""
        recommended_model, reason = get_recommended_whispercpp_model()
        language_id = self.language_combo.get_active_id() or self.language
        return _recommended_whispercpp_variant_for_language(
            recommended_model,
            reason,
            language_id,
        )

    def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]:
        """Return the default specialization for a user-selected size."""
        language_id = self.language_combo.get_active_id() or self.language
        return _default_whispercpp_variant_for_size(model_size, language_id)

    def _is_selected_whispercpp_model_english_only(self) -> bool:
        """Return whether the selected model is a whisper.cpp English-only variant."""
        if self._get_selected_engine() != "whisper_cpp":
            return False
        return is_english_only_whispercpp_model(self._get_selected_whispercpp_model())

    def _set_combo_active_id_or_first(self, combo, active_id: Optional[str]) -> bool:
        """Set a combo to an ID, falling back to the first row."""
        if active_id and combo.set_active_id(active_id):
            return True

        model = combo.get_model()
        if model:
            combo.set_active(0)
            return True
        return False

    def _default_language_for_engine(self, engine: str) -> str:
        """Return a safe default language for the selected engine and model."""
        if engine == "vosk" or self._is_selected_whispercpp_model_english_only():
            return "en-us"
        return "auto"

    def _update_model_picker_tooltips(self):
        """Refresh model picker hover guidance for the current selection."""
        self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)
        self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)
        self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP)
        self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)

        if self._get_selected_engine() == "whisper_cpp":
            specialization_tooltip = _model_specialization_tooltip(
                self._get_selected_whispercpp_model()
            )
        else:
            specialization_tooltip = MODEL_SPECIALIZATION_TOOLTIP

        self.model_variant_combo.set_tooltip_text(specialization_tooltip)
        self.model_variant_row.set_tooltip_text(specialization_tooltip)

    def _sync_language_options_for_selected_model(self, preferred_language: Optional[str] = None):
        """Refresh language options and keep the current model/language pair valid."""
        engine = self._get_selected_engine()
        language_to_keep = (
            preferred_language or self.language_combo.get_active_id() or self.language
        )

        self._processing_language_change = True
        try:
            self._populate_language_options()
            if not self._set_combo_active_id_or_first(self.language_combo, language_to_keep):
                return

            active_language = self.language_combo.get_active_id()
            if active_language != language_to_keep:
                fallback_language = self._default_language_for_engine(engine)
                self._set_combo_active_id_or_first(self.language_combo, fallback_language)

            self.language = (
                self.language_combo.get_active_id() or self._default_language_for_engine(engine)
            )
        finally:
            self._processing_language_change = False

        self._update_language_warning()
        self._update_model_picker_tooltips()

    def _populate_model_options(self):
        """Populate model options based on the current engine selection."""
        self._populating_models = True
        try:
            self.model_combo.remove_all()
            self.model_variant_combo.remove_all()

            engine_text = self.engine_combo.get_active_text()
            if not engine_text:
                logger.warning("No engine selected during model options population")
                return

            engine = _engine_from_display(engine_text)
            logger.info(f"Populating model options for engine: {engine}")

            # Remote API does not need model options
            if engine == "remote_api":
                logger.info("Remote API engine selected, no model options needed")
                return

            saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine)
            logger.info(f"Saved model for {engine}: {saved_model_for_engine}")

            if engine == "whisper_cpp":
                self._populate_whispercpp_model_options(saved_model_for_engine)
                return

            downloaded_models = []
            smallest_model = None
            if engine == "whisper":
                recommended_model, _ = _get_recommended_whisper_model()
            else:
                recommended_model, _ = _get_recommended_vosk_model()

            if engine in ENGINE_MODELS:
                for size in ENGINE_MODELS[engine]:
                    if engine == "whisper" and size in WHISPER_MODEL_INFO:
                        info = WHISPER_MODEL_INFO[size]
                        is_downloaded = _is_whisper_model_downloaded(size)
                    elif engine == "vosk" and size in VOSK_MODEL_INFO:
                        info = VOSK_MODEL_INFO[size]
                        is_downloaded = _is_vosk_model_downloaded(size, self.language)
                    else:
                        is_downloaded = False
                        info = {"size_mb": 0}

                    model_display_name = _model_display_name(size)
                    status = "✓" if is_downloaded else "↓"
                    star = " ★" if size == recommended_model else ""
                    display_text = (
                        f"{model_display_name} ({_format_size(info.get('size_mb', 0))}) "
                        f"{status}{star}"
                    )

                    if is_downloaded:
                        downloaded_models.append(size)
                    if smallest_model is None:
                        smallest_model = size

                    self.model_combo.append(size.capitalize(), display_text)

            # Determine which model to select
            saved_model = saved_model_for_engine.lower()
            valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])]

            if saved_model in valid_models:
                model_to_set = saved_model.capitalize()
            elif downloaded_models:
                model_to_set = downloaded_models[0].capitalize()
            else:
                model_to_set = smallest_model.capitalize() if smallest_model else "Small"

            logger.info(f"Setting active model to: {model_to_set}")

            if not self.model_combo.set_active_id(model_to_set):
                logger.warning(f"Could not set model by ID '{model_to_set}'")
                model = self.model_combo.get_model()
                for i, row in enumerate(model):
                    if row[0].lower() == model_to_set.lower():
                        self.model_combo.set_active(i)
                        break
                else:
                    if len(ENGINE_MODELS.get(engine, [])) > 0:
                        self.model_combo.set_active(0)

            logger.info(f"Final selected model: {self.model_combo.get_active_text()}")
        finally:
            self._populating_models = False

    def _populate_whispercpp_model_options(self, saved_model_for_engine: str):
        """Populate whisper.cpp size and specialization selectors."""
        recommended_model, _ = self._get_recommended_whispercpp_model_for_language()
        recommended_size = get_whispercpp_model_size(recommended_model)

        saved_model = saved_model_for_engine.lower()
        if saved_model not in WHISPERCPP_MODEL_INFO:
            saved_model = (
                recommended_model if recommended_model in WHISPERCPP_MODEL_INFO else "tiny"
            )

        saved_size = get_whispercpp_model_size(saved_model)

        for model_size in ENGINE_MODELS["whisper_cpp"]:
            display_text = _model_display_name(model_size)
            if model_size == recommended_size:
                display_text += " ★"
            self.model_combo.append(model_size, display_text)

        self._set_combo_active_id_or_first(self.model_combo, saved_size)
        active_size = self.model_combo.get_active_id() or saved_size
        self._populate_whispercpp_variant_options(active_size, saved_model)

    def _populate_whispercpp_variant_options(
        self,
        model_size: str,
        selected_model: Optional[str] = None,
    ):
        """Populate the whisper.cpp specialization selector for a size."""
        self.model_variant_combo.remove_all()

        variants = get_whispercpp_model_variants(model_size.lower())
        recommended_model, _ = self._get_recommended_whispercpp_model_for_language()

        for model_name in variants:
            info = WHISPERCPP_MODEL_INFO[model_name]
            is_downloaded = is_whispercpp_model_downloaded(model_name)
            status = "✓" if is_downloaded else "↓"
            star = " ★" if model_name == recommended_model else ""
            display_text = (
                f"{_model_specialization_display_name(model_name)} "
                f"({_format_size(info['size_mb'])}) {status}{star}"
            )
            self.model_variant_combo.append(model_name, display_text)

        model_to_set = selected_model if selected_model in variants else None
        if not model_to_set and recommended_model in variants:
            model_to_set = recommended_model
        if not model_to_set:
            model_to_set = self._get_default_whispercpp_variant_for_size(model_size.lower())
        if not model_to_set and variants:
            model_to_set = variants[0]

        self._set_combo_active_id_or_first(self.model_variant_combo, model_to_set)
        self._update_model_picker_tooltips()

    def _on_engine_changed(self, widget):
        """Handle changes in the selected engine."""
        engine_text = self.engine_combo.get_active_text()
        if not engine_text:
            return

        engine = _engine_from_display(engine_text)

        current_lang = self.language_combo.get_active_id()
        if current_lang:
            if engine == "vosk" and (
                current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk")
            ):
                self.language = "en-us"
            elif engine in ["whisper", "whisper_cpp", "remote_api"] and not current_lang:
                self.language = "auto"

        self._populate_model_options()
        self._sync_language_options_for_selected_model(self.language)
        self._update_engine_specific_ui()
        self._update_model_info()
        self._update_voice_commands_for_engine()

    def _update_voice_commands_for_engine(self):
        """Update voice commands switch based on current engine."""
        sr_config = self.config_manager.get_settings().get("speech_recognition", {})
        voice_commands_enabled = sr_config.get("voice_commands_enabled")

        if voice_commands_enabled is None:
            engine_text = self.engine_combo.get_active_text()
            engine = _engine_from_display(engine_text) if engine_text else "whisper_cpp"
            auto_enabled = engine == "vosk"
            self.voice_commands_switch.set_active(auto_enabled)

    def _on_model_changed(self, widget):
        """Handle changes in the selected model."""
        if self._populating_models:
            return

        if self._get_selected_engine() == "whisper_cpp":
            model_size = self.model_combo.get_active_id()
            if model_size:
                self._populating_models = True
                try:
                    self._populate_whispercpp_variant_options(model_size)
                finally:
                    self._populating_models = False
                self._sync_language_options_for_selected_model()

        self._update_model_info()
        self._auto_apply_settings()

    def _on_model_variant_changed(self, widget):
        """Handle changes in the selected whisper.cpp specialization."""
        if self._populating_models:
            return

        self._sync_language_options_for_selected_model()
        self._update_model_info()
        self._auto_apply_settings()

    def _on_vad_changed(self, widget):
        """Handle changes in VAD sensitivity."""
        self._auto_apply_settings()

    def _on_silence_changed(self, widget):
        """Handle changes in silence timeout."""
        self._auto_apply_settings()

    def _on_voice_commands_toggled(self, widget, state):
        """Handle toggle of the voice commands switch."""
        if self._initializing or self._applying_settings:
            return False

        enabled = bool(state)
        logger.info(f"Voice commands toggled: {enabled}")

        self.config_manager.set("speech_recognition", "voice_commands_enabled", enabled)
        self.config_manager.save_settings()
        try:
            self.speech_engine.reconfigure(voice_commands_enabled=enabled, force_download=False)
        except Exception as e:
            logger.warning(f"Failed to apply voice commands toggle immediately: {e}")
        logger.info(f"Voice commands {'enabled' if enabled else 'disabled'}")
        return False

    def _populate_language_options(self):
        """Populate language dropdown with supported languages."""
        self.language_combo.remove_all()
        engine = self.engine_combo.get_active_text()
        if not engine:
            return

        engine = _engine_from_display(engine)
        english_only_whispercpp = self._is_selected_whispercpp_model_english_only()

        for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
            display_text = lang_info["name"]

            if engine == "vosk":
                has_model = lang_info["vosk"] is not None
                if not has_model or lang_code == "auto":
                    continue
                is_downloaded = _is_vosk_model_downloaded("small", lang_code)
                display_text += " ✓" if is_downloaded else " ↓"
            elif engine in ["whisper", "whisper_cpp", "remote_api"]:
                if english_only_whispercpp and lang_info.get("whisper") != "en":
                    continue
                # Both Whisper and whisper.cpp support auto-detect
                if lang_code == "auto":
                    display_text += " ⚠"
            else:
                continue

            self.language_combo.append(lang_code, display_text)

    def _update_language_warning(self):
        """Update language help text for the selected engine/model/language."""
        lang_code = self.language_combo.get_active_id()
        lang_info = SUPPORTED_LANGUAGES.get(lang_code, {})

        if self._is_selected_whispercpp_model_english_only():
            self.language_warning.set_markup(
                "<span foreground='#e5a50a'>⚠ English-only model selected. "
                "Language choices are limited to English.</span>"
            )
            self.language_warning.show()
        elif lang_info.get("warning"):
            self.language_warning.set_markup(
                f"<span foreground='#e5a50a'>⚠ {lang_info['warning']}</span>"
            )
            self.language_warning.show()
        else:
            self.language_warning.set_markup("")
            self.language_warning.hide()

    def _on_language_changed(self, widget):
        """Handle language selection change."""
        if self._processing_language_change:
            return

        lang_code = self.language_combo.get_active_id()
        if not lang_code:
            return

        engine = self.engine_combo.get_active_text()
        if not engine:
            return

        self._processing_language_change = True
        try:
            self.language = lang_code
            self._populate_model_options()
            self._update_language_warning()
            self._auto_apply_settings()
        finally:
            self._processing_language_change = False

    def _update_engine_specific_ui(self):
        """Show/hide UI elements driven by the active engine."""
        engine_text = self.engine_combo.get_active_text()
        engine = _engine_from_display(engine_text) if engine_text else "vosk"
        is_remote = engine == "remote_api"

        if is_remote:
            self.model_row.hide()
            self.model_variant_row.hide()
            self.model_info_card.hide()
            self.legend_box.hide()
            self.remote_server_group.show_all()
            self.remote_status_label.show()
        else:
            self.model_row.show_all()
            if engine == "whisper_cpp":
                self.model_variant_row.show_all()
            else:
                self.model_variant_row.hide()
            self.legend_box.show_all()
            self.remote_server_group.hide()
            self.remote_status_label.hide()

        self._update_model_info()
        self._update_language_warning()
        self._update_model_picker_tooltips()
        self._update_advanced_tab_sensitivity()

    def _update_advanced_tab_sensitivity(self):
        """Enable or disable advanced settings based on selected engine."""
        engine_text = self.engine_combo.get_active_text()
        is_whispercpp = engine_text and engine_text.lower() == "whisper_cpp"

        widgets = [
            self.advanced_no_timestamps_switch,
            self.advanced_no_context_switch,
            self.advanced_initial_prompt_textview,
            self.advanced_temperature_spin,
            self.advanced_temperature_inc_spin,
            self.advanced_entropy_thold_spin,
            self.advanced_logprob_thold_spin,
            self.advanced_no_speech_thold_spin,
            self.advanced_reset_button,
        ]
        for widget in widgets:
            widget.set_sensitive(is_whispercpp)

        if is_whispercpp:
            self.advanced_info_label.set_text("These settings apply to the whisper.cpp engine.")
        else:
            self.advanced_info_label.set_text(
                "These settings only apply when the whisper.cpp engine is selected."
            )

    def _update_model_info(self):
        """Update the model info card display."""
        engine_text = self.engine_combo.get_active_text()
        if not engine_text:
            self.model_info_card.hide()
            return

        engine = _engine_from_display(engine_text)

        if engine == "whisper_cpp":
            model_name = self._get_selected_whispercpp_model()
        else:
            model_id = self.model_combo.get_active_id()
            if not model_id:
                self.model_info_card.hide()
                return
            model_name = model_id.lower()

        if engine == "whisper":
            if model_name not in WHISPER_MODEL_INFO:
                self.model_info_card.hide()
                return
            info = WHISPER_MODEL_INFO[model_name]
            is_downloaded = _is_whisper_model_downloaded(model_name)
            recommended, reason = _get_recommended_whisper_model()
            extra_info = f"Parameters: {info['params']}"
        elif engine == "whisper_cpp":
            if model_name not in WHISPERCPP_MODEL_INFO:
                self.model_info_card.hide()
                return
            info = WHISPERCPP_MODEL_INFO[model_name]
            is_downloaded = is_whispercpp_model_downloaded(model_name)
            recommended, reason = self._get_recommended_whispercpp_model_for_language()
            backend, backend_info = detect_compute_backend()
            extra_info = (
                f"Parameters: {info['params']} • Backend: {get_backend_display_name(backend)}"
            )
        elif engine == "vosk":
            if model_name not in VOSK_MODEL_INFO:
                self.model_info_card.hide()
                return
            info = VOSK_MODEL_INFO[model_name]
            is_downloaded = _is_vosk_model_downloaded(model_name, self.language)
            recommended, reason = _get_recommended_vosk_model()
            extra_info = f"Size: {_format_size(info['size_mb'])}"
        else:
            self.model_info_card.hide()
            return

        model_display_name = _model_display_name(model_name)
        recommended_display_name = _model_display_name(recommended)

        # Update title
        self.model_info_title.set_markup(f"<b>{model_display_name}</b>: {info['desc']}")

        # Update subtitle with status
        if is_downloaded:
            status = "<span foreground='#26a269'>✓ Downloaded and ready</span>"
        else:
            status = f"<span foreground='#e5a50a'>↓ Will download ~{_format_size(info['size_mb'])}</span>"
        self.model_info_subtitle.set_markup(f"{extra_info} • {status}")

        # Update recommendation
        if model_name == recommended:
            self.model_recommendation.set_markup(
                f"<span foreground='#26a269'>★ Recommended for your system ({reason})</span>"
            )
        else:
            self.model_recommendation.set_markup(
                f"Tip: <b>{recommended_display_name}</b> is recommended for your system ({reason})"
            )

        self._update_model_picker_tooltips()
        self.model_info_card.show_all()

    def _auto_apply_settings(self):
        """Automatically apply settings when changed."""
        if self._applying_settings:
            return

        if self._initializing:
            return

        if self._test_active:
            return

        if self._populating_models:
            return

        self._applying_settings = True
        try:
            settings = self.get_selected_settings()
            engine = settings.get("engine", "vosk")
            model_name = settings.get("model_size", "small")

            # Check if model needs to be downloaded
            needs_download = False
            model_info = {"size_mb": 100}  # Default
            if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
                needs_download = True
                model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
            elif engine == "whisper_cpp" and not is_whispercpp_model_downloaded(model_name):
                needs_download = True
                model_info = WHISPERCPP_MODEL_INFO.get(model_name, {"size_mb": 39})
            elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
                needs_download = True
                model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})

            if needs_download:
                logger.info(f"Model {model_name} needs download, showing progress dialog")
                download_dialog = ModelDownloadDialog(
                    self,
                    model_name,
                    model_info["size_mb"],
                    engine=engine,
                    language=self.language,
                )

                def progress_callback(fraction, speed, status):
                    GLib.idle_add(download_dialog.update_progress, fraction, speed, status)

                def download_and_apply():
                    try:
                        self.speech_engine.set_download_progress_callback(progress_callback)

                        def check_cancelled():
                            if download_dialog.cancelled:
                                self.speech_engine.cancel_download()
                            return not download_dialog.cancelled

                        cancel_check_id = GLib.timeout_add(100, check_cancelled)

                        try:
                            self._apply_settings_internal(settings)
                            GLib.idle_add(download_dialog.set_complete, True, "")
                            GLib.idle_add(self._populate_model_options)
                        finally:
                            GLib.source_remove(cancel_check_id)
                            self.speech_engine.set_download_progress_callback(None)

                    except Exception as e:
                        error_msg = str(e)
                        if "cancelled" in error_msg.lower():
                            GLib.idle_add(
                                download_dialog.set_complete,
                                False,
                                "Download cancelled",
                            )
                        elif engine == "whisper" and "no module named" in error_msg.lower():
                            GLib.idle_add(
                                download_dialog.set_complete,
                                False,
                                "Whisper not installed",
                            )
                            GLib.idle_add(self._show_whisper_install_dialog)
                        else:
                            GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])

                threading.Thread(target=download_and_apply, daemon=True).start()
                download_dialog.run()
                download_dialog.destroy()
                return

            logger.info(f"Auto-applying settings: {settings}")

            self._save_selected_settings(settings)

            was_running = self.speech_engine.state != RecognitionState.IDLE
            if was_running:
                self.speech_engine.stop_recognition()

            self.speech_engine.reconfigure(**settings)
            logger.info("Settings auto-applied successfully")
        except Exception as e:
            logger.error(f"Failed to auto-apply settings: {e}")
        finally:
            self._applying_settings = False

    def _save_selected_settings(self, settings: dict):
        """Persist selected settings to their appropriate config sections."""
        sr_settings = {k: v for k, v in settings.items() if not k.startswith("whispercpp_")}
        advanced_settings = {k: v for k, v in settings.items() if k.startswith("whispercpp_")}

        self.config_manager.update_speech_recognition_settings(sr_settings)
        for key, value in advanced_settings.items():
            self.config_manager.set("advanced", key, value)
        self.config_manager.save_settings()

    def get_selected_settings(self) -> dict:
        """Return the currently selected settings from the UI."""
        engine_text = self.engine_combo.get_active_text()
        model_id = self.model_combo.get_active_id()
        language_id = self.language_combo.get_active_id()

        engine = _engine_from_display(engine_text) if engine_text else "vosk"
        if engine == "whisper_cpp":
            model_size = self._get_selected_whispercpp_model()
        else:
            model_size = model_id.lower() if model_id else "small"
        language = language_id if language_id else self._default_language_for_engine(engine)

        vad = int(self.vad_spin.get_value())
        silence = self.silence_spin.get_value()

        settings = {
            "engine": engine,
            "model_size": model_size,
            "language": language,
            "vad_sensitivity": vad,
            "silence_timeout": silence,
            "whispercpp_no_timestamps": self.advanced_no_timestamps_switch.get_active(),
            "whispercpp_no_context": self.advanced_no_context_switch.get_active(),
            "whispercpp_initial_prompt": self.advanced_initial_prompt_buffer.get_text(
                self.advanced_initial_prompt_buffer.get_start_iter(),
                self.advanced_initial_prompt_buffer.get_end_iter(),
                False,
            ),
            "whispercpp_temperature": self.advanced_temperature_spin.get_value(),
            "whispercpp_temperature_inc": self.advanced_temperature_inc_spin.get_value(),
            "whispercpp_entropy_thold": self.advanced_entropy_thold_spin.get_value(),
            "whispercpp_logprob_thold": self.advanced_logprob_thold_spin.get_value(),
            "whispercpp_no_speech_thold": self.advanced_no_speech_thold_spin.get_value(),
        }

        # Remote API additional settings
        if engine == "remote_api":
            settings["remote_api_url"] = self.remote_api_url_entry.get_text().strip()
            settings["remote_api_key"] = self.remote_api_key_entry.get_text().strip()
            settings["remote_api_endpoint"] = (
                self.remote_api_endpoint_combo.get_active_id() or "/inference"
            )
            settings["remote_api_model"] = (
                self.remote_api_model_entry.get_text().strip() or "whisper-1"
            )

        return settings

    def _on_test_clicked(self, widget):
        """Handle click on the test button."""
        if self._test_active:
            logger.warning("Test already in progress.")
            return

        current_config = self.config_manager.get_settings().get("speech_recognition", {})
        selected_settings = self.get_selected_settings()

        settings_differ = False
        if current_config.get("engine") != selected_settings.get("engine") or current_config.get(
            "model_size"
        ) != selected_settings.get("model_size"):
            settings_differ = True
        elif selected_settings.get("engine") == "vosk":
            if current_config.get("vad_sensitivity") != selected_settings.get(
                "vad_sensitivity"
            ) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"):
                settings_differ = True

        if settings_differ:
            self.test_buffer.set_text("Applying settings...")
            if not self.apply_settings():
                self.test_buffer.set_text("Failed to apply settings. Please try again.")
                return
            self.test_buffer.set_text("Settings applied. Starting test...")

        self._test_active = True
        self.test_button.set_sensitive(False)
        self.test_button.set_label("Testing... Speak Now!")
        self.test_buffer.set_text("")
        self._test_result = ""

        self.connect_to_recognition_manager()
        self.update_recognition_progress("Listening", info="Starting recognition test...")

        self._saved_text_callbacks = self.speech_engine.get_text_callbacks()
        self.speech_engine.set_text_callbacks([self._test_text_callback])

        self.speech_engine.start_recognition()
        threading.Thread(target=self._stop_test_after_delay, args=(3,)).start()

    def _test_text_callback(self, text: str):
        """Callback specifically for the test recognition."""
        GLib.idle_add(self._append_test_result, text)

    def _append_test_result(self, text: str):
        current_text = self.test_buffer.get_text(
            self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
        )
        separator = " " if current_text.strip() else ""
        self.test_buffer.insert(self.test_buffer.get_end_iter(), separator + text)
        mark = self.test_buffer.get_insert()
        self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
        return False

    def _stop_test_after_delay(self, delay: int):
        """Stops the recognition test after a specified delay."""
        time.sleep(delay)
        GLib.idle_add(self._finalize_test)

    def _finalize_test(self):
        """Finalize the test state and UI updates."""
        if not self._test_active:
            return False

        self.speech_engine.stop_recognition()

        # Wait a bit for any pending transcription to complete before restoring callbacks
        # This ensures the test callback receives the transcription result
        GLib.timeout_add(500, self._restore_callbacks_and_check_result)

        self._test_active = False
        self.test_button.set_sensitive(True)
        self.test_button.set_label("Start Test (3 seconds)")

        self.update_recognition_progress("Idle")

        return False

    def _restore_callbacks_and_check_result(self):
        """Restore callbacks and check test result after delay."""
        # Restore original text callbacks
        if hasattr(self, "_saved_text_callbacks"):
            self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
            del self._saved_text_callbacks

        # Check result after giving time for final callbacks to complete
        GLib.timeout_add(300, self._check_test_result)
        return False

    def _check_test_result(self):
        """Check if any text was captured after all callbacks have run."""
        final_text = self.test_buffer.get_text(
            self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
        )
        if not final_text.strip():
            self.test_buffer.set_text("(No speech detected during test)")
        return False

    def _show_whisper_install_dialog(self):
        """Show a dialog with instructions for installing Whisper."""
        dialog = Gtk.MessageDialog(
            transient_for=self,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.OK,
            text="Whisper Not Installed",
        )

        install_text = """Whisper AI is not installed. To use Whisper for speech recognition, you need to install it first.

Installation Options:

1. Using the installation script:
   ./install.sh --with-whisper

2. Manual installation in virtual environment:
   source venv/bin/activate
   pip install openai-whisper torch torchaudio

3. If you have SSL issues, try:
   pip install openai-whisper torch torchaudio --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org

Note: Whisper requires significant disk space (~1-3GB) and may take time to download.

For now, the engine has been reverted to VOSK."""

        dialog.format_secondary_text(install_text)
        dialog.run()
        dialog.destroy()

        self.engine_combo.set_active_id("Vosk")
        self._populate_model_options()
        self._update_engine_specific_ui()

    def apply_settings(self):
        """Apply the selected settings."""
        settings = self.get_selected_settings()
        logger.info(f"Applying settings: {settings}")

        engine = settings.get("engine", "vosk")
        model_name = settings.get("model_size", "small")

        needs_download = False
        model_info = {"size_mb": 100}  # Default
        if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
            needs_download = True
            model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
        elif engine == "whisper_cpp" and not is_whispercpp_model_downloaded(model_name):
            needs_download = True
            model_info = WHISPERCPP_MODEL_INFO.get(model_name, {"size_mb": 39})
        elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language):
            needs_download = True
            model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})

        if needs_download:
            download_dialog = ModelDownloadDialog(
                self,
                model_name,
                model_info["size_mb"],
                engine=engine,
                language=self.language,
            )

            def progress_callback(fraction, speed, status):
                GLib.idle_add(download_dialog.update_progress, fraction, speed, status)

            def download_and_apply():
                try:
                    self.speech_engine.set_download_progress_callback(progress_callback)

                    def check_cancelled():
                        if download_dialog.cancelled:
                            self.speech_engine.cancel_download()
                        return not download_dialog.cancelled

                    cancel_check_id = GLib.timeout_add(100, check_cancelled)

                    try:
                        self._apply_settings_internal(settings)
                        GLib.idle_add(download_dialog.set_complete, True, "")
                    finally:
                        GLib.source_remove(cancel_check_id)
                        self.speech_engine.set_download_progress_callback(None)

                except Exception as e:
                    error_msg = str(e)
                    if "cancelled" in error_msg.lower():
                        GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
                    elif engine == "whisper" and "no module named" in error_msg.lower():
                        GLib.idle_add(download_dialog.set_complete, False, "Whisper not installed")
                        GLib.idle_add(self._show_whisper_install_dialog)
                    else:
                        GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])

            threading.Thread(target=download_and_apply, daemon=True).start()
            download_dialog.run()
            download_dialog.destroy()

            self._populate_model_options()
            return True

        return self._apply_settings_internal(settings)

    def _apply_settings_internal(self, settings: dict) -> bool:
        """Internal method to apply settings."""
        try:
            self._save_selected_settings(settings)

            was_running = self.speech_engine.state != RecognitionState.IDLE
            if was_running:
                self.speech_engine.stop_recognition()
                time.sleep(0.5)

            self.speech_engine.reconfigure(**settings)

            logger.info("Settings applied successfully.")
            return True
        except Exception as e:
            logger.error(f"Failed to apply settings: {e}", exc_info=True)

            if "whisper" in str(e).lower() and "no module named" in str(e).lower():
                self._show_whisper_install_dialog()
            else:
                error_dialog = Gtk.MessageDialog(
                    transient_for=self,
                    flags=0,
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.OK,
                    text="Error Applying Settings",
                )
                error_dialog.format_secondary_text(f"Could not apply settings: {e}")
                error_dialog.run()
                error_dialog.destroy()
            return False

    def _populate_audio_devices(self):
        """Populate the audio device dropdown with available input devices."""
        from ..speech_recognition.recognition_manager import get_audio_input_devices

        self.audio_device_combo.remove_all()

        self.audio_device_combo.append("-1", "System Default")

        devices = get_audio_input_devices()

        for device_index, device_name, is_default in devices:
            label = device_name
            if is_default:
                label += " (default)"
            self.audio_device_combo.append(str(device_index), label)

        saved_device = self.config_manager.get_optional_int("audio", "device_index", None)
        saved_device_name = self.config_manager.get("audio", "device_name", None)

        if saved_device is None:
            self.audio_device_combo.set_active_id("-1")
        else:
            # Try to match by saved device name first (more stable across reboots)
            matched = False
            if saved_device_name:
                for idx, name, _ in devices:
                    if name == saved_device_name:
                        self.audio_device_combo.set_active_id(str(idx))
                        matched = True
                        break
            if not matched:
                # Fall back to stored index
                if not self.audio_device_combo.set_active_id(str(saved_device)):
                    logger.warning(
                        f"Saved audio device {saved_device} "
                        f"(name: {saved_device_name}) no longer available"
                    )
                    self.audio_device_combo.set_active_id("-1")

        logger.info(f"Found {len(devices)} audio input devices")

    def _on_refresh_audio_devices(self, widget):
        """Handle refresh button click for audio devices."""
        self._populate_audio_devices()
        self.audio_test_status.set_markup("<i>Device list refreshed</i>")

    def _on_audio_device_changed(self, widget):
        """Handle changes in the selected audio device."""
        if self._initializing:
            return

        device_id = self.audio_device_combo.get_active_id()
        if device_id is None:
            return

        device_index = int(device_id)
        device_name = self.audio_device_combo.get_active_text()

        if device_index == -1:
            self.config_manager.set("audio", "device_index", None)
            self.config_manager.set("audio", "device_name", None)
        else:
            self.config_manager.set("audio", "device_index", device_index)
            self.config_manager.set("audio", "device_name", device_name)

        self.config_manager.save_settings()

        if device_index == -1:
            self.speech_engine.set_audio_device(None, None)
        else:
            self.speech_engine.set_audio_device(device_index, device_name)

        logger.info(f"Audio device changed to: [{device_index}] {device_name}")
        self.audio_test_status.set_markup(f"<i>Selected: {device_name}</i>")

    def _on_test_audio_clicked(self, widget):
        """Handle test audio button click."""
        self.test_audio_btn.set_sensitive(False)
        self.test_audio_btn.set_label("Testing...")
        self.audio_test_status.set_markup("<i>Recording... speak into your microphone</i>")
        self.audio_level_bar.set_value(0)

        device_id = self.audio_device_combo.get_active_id()
        device_index = None if device_id == "-1" else int(device_id)

        def run_test():
            from ..speech_recognition.recognition_manager import test_audio_input

            result = test_audio_input(device_index=device_index, duration=2.0)
            GLib.idle_add(self._handle_audio_test_result, result)

        threading.Thread(target=run_test, daemon=True).start()

    def _handle_audio_test_result(self, result: dict):
        """Handle the result of an audio test."""
        self.test_audio_btn.set_sensitive(True)
        self.test_audio_btn.set_label("Test")

        if result.get("success"):
            max_level = result.get("max_amplitude", 0)
            has_signal = result.get("has_signal", False)
            sample_rate = result.get("sample_rate", 16000)

            level_percent = min(100, (max_level / 327.68))
            self.audio_level_bar.set_value(level_percent)

            # Build sample rate info string
            if sample_rate == 16000:
                rate_info = "(16kHz native)"
            else:
                rate_info = f"({sample_rate // 1000}kHz → 16kHz auto)"

            if has_signal:
                self.audio_test_status.set_markup(
                    f"<span foreground='#26a269'>✓ Audio detected!</span> "
                    f"Peak: {level_percent:.0f}% {rate_info}"
                )
            else:
                self.audio_test_status.set_markup(
                    f"<span foreground='#e5a50a'>⚠ Very low audio level</span> "
                    f"(peak: {level_percent:.1f}%)\n"
                    "<small>Check if microphone is muted or try a different device</small>"
                )
        else:
            error_msg = result.get("error", "Unknown error")
            self.audio_test_status.set_markup(
                f"<span foreground='#c01c28'>✗ Test failed:</span> {error_msg}"
            )

        return False

    def update_recognition_progress(self, state: str, audio_level: float = 0.0, info: str = ""):
        """Update the recognition progress feedback UI."""
        self.recognition_status_label.set_text(state)

        # Remove existing state classes
        for css_class in [
            "recognition-idle",
            "recognition-listening",
            "recognition-processing",
            "recognition-error",
        ]:
            self.recognition_status_label.get_style_context().remove_class(css_class)

        if state == "Listening":
            self.recognition_indicator.set_opacity(1.0)
            self.recognition_status_label.get_style_context().add_class("recognition-listening")
            self.progress_info_label.set_markup("<span foreground='#26a269'>● Listening...</span>")
        elif state == "Processing":
            self.recognition_indicator.set_opacity(1.0)
            self.recognition_status_label.get_style_context().add_class("recognition-processing")
            self.progress_info_label.set_markup(
                "<span foreground='#e5a50a'>● Processing speech...</span>"
            )
        elif state == "Idle":
            self.recognition_indicator.set_opacity(0.3)
            self.recognition_status_label.get_style_context().add_class("recognition-idle")
            self.progress_info_label.set_text("")
        elif state == "Error":
            self.recognition_indicator.set_opacity(0.3)
            self.recognition_status_label.get_style_context().add_class("recognition-error")
            self.progress_info_label.set_markup(
                f"<span foreground='#c01c28'>✗ Error: {info}</span>"
            )
        else:
            self.recognition_indicator.set_opacity(0.3)
            if info:
                self.progress_info_label.set_text(info)

        if audio_level > 0:
            normalized_level = min(100, max(0, audio_level))
            self.recognition_audio_level.set_value(normalized_level)
        elif state == "Idle":
            self.recognition_audio_level.set_value(0)

    def connect_to_recognition_manager(self):
        """Connect to speech recognition manager for progress updates."""
        if hasattr(self, "speech_engine") and self.speech_engine:
            if not hasattr(self, "_callbacks_registered"):
                self.speech_engine.state_callbacks.append(self._on_recognition_state_changed)
                self.speech_engine.register_audio_level_callback(self._on_audio_level_changed)
                self._callbacks_registered = True
                self.connect("destroy", self._on_dialog_destroy)

    def _on_dialog_destroy(self, widget):
        """Clean up callbacks when dialog is destroyed."""
        if hasattr(self, "speech_engine") and self.speech_engine:
            if self._on_recognition_state_changed in self.speech_engine.state_callbacks:
                self.speech_engine.state_callbacks.remove(self._on_recognition_state_changed)
            self.speech_engine.unregister_audio_level_callback(self._on_audio_level_changed)

    def _on_recognition_state_changed(self, state):
        """Handle recognition state changes."""
        state_map = {
            RecognitionState.IDLE: "Idle",
            RecognitionState.LISTENING: "Listening",
            RecognitionState.PROCESSING: "Processing",
            RecognitionState.ERROR: "Error",
        }

        state_str = state_map.get(state, "Unknown")
        GLib.idle_add(self.update_recognition_progress, state_str)

    def _on_audio_level_changed(self, level: float):
        """Handle audio level changes."""
        GLib.idle_add(self.update_recognition_progress, "Listening", level)
