"""
Main view — unified settings page for BigLinux TTS.

Layout:
1. Hero section (status indicator + test button)
2. Quick settings (voice, speed, pitch, volume)
3. Expanders (backend, text processing, shortcut, advanced)
"""

from __future__ import annotations

import logging
import subprocess
import threading
from collections.abc import Callable
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Iterator

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk

from config import (
    PITCH_MAX,
    PITCH_MIN,
    PITCH_STEP,
    RATE_MAX,
    RATE_MIN,
    RATE_STEP,
    TTSBackend,
    TTSState,
    VOLUME_MAX,
    VOLUME_MIN,
    VOLUME_STEP,
)
from services.desktop_integration_service import DesktopIntegrationService
from services.kokoro_voice_service import is_kokoro_installed
from services.text_processor import get_system_language
from services.voice_manager import (
    VoiceCatalog,
    VoiceInfo,
    discover_voices,
)
from ui.components import (
    create_action_row_with_scale,
    create_action_row_with_switch,
    create_button_row,
    create_combo_row,
    create_expander_row,
    create_icon_button,
    create_preferences_group,
)
from utils.async_utils import run_in_thread
from utils.i18n import _

if TYPE_CHECKING:
    from config import AppSettings
    from services.settings_service import SettingsService
    from services.tts_service import TTSService

logger = logging.getLogger(__name__)


class MainView(Adw.NavigationPage):
    """
    Main settings view with hero, quick settings, and expanders.

    Follows progressive disclosure: basic on top, advanced in expanders.
    """

    def __init__(
        self,
        tts_service: TTSService,
        settings_service: SettingsService,
        on_toast: Callable[[str, int], None],
    ) -> None:
        super().__init__()
        self.set_title(_("BigLinux TTS"))

        self._tts = tts_service
        self._settings_service = settings_service
        self._settings: AppSettings = settings_service.get()
        self._on_toast = on_toast
        self._catalog: VoiceCatalog | None = None
        self._voice_list: list[VoiceInfo] = []
        self._updating_ui = False  # Prevent feedback loops

        # Connect TTS state changes
        self._tts.set_on_state_changed(self._on_tts_state_changed)

        # Build UI
        self._build_ui()

        # Apply saved playback mode
        from ui.audio_player import AudioPlayerWidget
        AudioPlayerWidget.set_playback_mode(self._settings.history.playback_mode)

        # Discover voices in background
        run_in_thread(discover_voices, on_done=self._on_voices_discovered)

    @contextmanager
    def _guard_ui(self) -> Iterator[None]:
        """Block callback handlers during programmatic widget updates.

        Exception-safe and nesting-safe: restores previous flag value
        even if an exception occurs during the guarded block.
        """
        prev = self._updating_ui
        self._updating_ui = True
        try:
            yield
        finally:
            self._updating_ui = prev

    # ── UI Construction ──────────────────────────────────────────────

    def _build_ui(self) -> None:
        """Build the complete main view."""
        # Scrollable content
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scrolled.set_vexpand(True)

        # Clamp for responsive width
        clamp = Adw.Clamp()
        clamp.set_maximum_size(600)
        clamp.set_tightening_threshold(400)

        # Main vertical box
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        content.set_margin_top(12)
        content.set_margin_bottom(24)
        content.set_margin_start(12)
        content.set_margin_end(12)

        # 1. Hero section
        self._hero = self._build_hero_section()
        content.append(self._hero)

        # 2. Quick settings
        quick = self._build_quick_settings()
        content.append(quick)

        # 3. Text processing expander
        text_group = self._build_text_processing_section()
        content.append(text_group)

        # 4. Backend expander
        backend_group = self._build_backend_section()
        content.append(backend_group)

        # 5. Advanced expander
        advanced_group = self._build_advanced_section()
        content.append(advanced_group)

        clamp.set_child(content)
        scrolled.set_child(clamp)
        self.set_child(scrolled)

    # ── Hero Section ─────────────────────────────────────────────────

    def _build_hero_section(self) -> Gtk.Box:
        """Build the hero status section with indicator and test button."""
        hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        hero.add_css_class("hero-section")
        hero.set_halign(Gtk.Align.FILL)

        # Status icon
        self._hero_icon = Gtk.Image.new_from_icon_name("audio-speakers-symbolic")
        self._hero_icon.set_pixel_size(48)
        self._hero_icon.add_css_class("hero-status-icon")
        self._hero_icon.set_halign(Gtk.Align.CENTER)
        hero.append(self._hero_icon)

        # Status title
        self._hero_title = Gtk.Label()
        self._hero_title.set_markup(f"<b>{_('Ready to speak')}</b>")
        self._hero_title.add_css_class("hero-title")
        self._hero_title.add_css_class("title-2")
        self._hero_title.set_halign(Gtk.Align.CENTER)
        hero.append(self._hero_title)

        # Status subtitle (instructions)
        self._hero_subtitle = Gtk.Label()
        self._hero_subtitle.add_css_class("hero-subtitle")
        self._hero_subtitle.add_css_class("dim-label")
        self._hero_subtitle.set_halign(Gtk.Align.CENTER)
        self._hero_subtitle.set_wrap(True)
        self._hero_subtitle.set_justify(Gtk.Justification.CENTER)
        self._update_hero_labels(TTSState.IDLE)
        hero.append(self._hero_subtitle)

        # Test text entry
        self._test_entry = Gtk.Entry()
        self._test_entry.set_text(_("This is a test, welcome to BigLinux!"))
        self._test_entry.set_placeholder_text(_("Type text to test…"))
        self._test_entry.set_halign(Gtk.Align.FILL)
        self._test_entry.set_hexpand(True)
        self._test_entry.set_margin_start(24)
        self._test_entry.set_margin_end(24)
        self._test_entry.set_margin_top(8)
        self._test_entry.set_max_length(500)
        hero.append(self._test_entry)

        # Test button
        self._test_button = create_button_row(
            label=_("Test voice"),
            style_class="suggested-action",
            on_clicked=self._on_test_voice,
            accessible_name=_("Test the selected voice"),
        )
        self._test_button.add_css_class("test-button")
        self._test_button.set_halign(Gtk.Align.CENTER)
        self._test_button.set_margin_top(6)
        hero.append(self._test_button)

        return hero

    # ── Quick Settings ───────────────────────────────────────────────

    def _build_quick_settings(self) -> Adw.PreferencesGroup:
        """Build the voice and speech parameter controls."""
        group = create_preferences_group(
            title=_("Voice settings"),
            description=_("Choose voice and adjust speech parameters"),
        )

        # TTS Backend selection (above voice combo)
        backends = [
            _("RHVoice (Native)"),
            _("espeak-ng"),
            _("Piper (Neural TTS)"),
            _("Kokoro (Neural TTS)"),
        ]
        self._backend_map = {
            0: TTSBackend.RHVOICE.value,
            1: TTSBackend.ESPEAK_NG.value,
            2: TTSBackend.PIPER.value,
            3: TTSBackend.KOKORO.value,
        }
        current_backend = self._settings.speech.backend
        current_idx = 0
        for idx, val in self._backend_map.items():
            if val == current_backend:
                current_idx = idx
                break

        self._backend_combo = create_combo_row(
            title=_("TTS Backend"),
            subtitle=_("Engine used for speech synthesis"),
            options=backends,
            selected_index=current_idx,
            on_selected=self._on_backend_selected,
            accessible_name=_("Select TTS engine"),
        )
        group.add(self._backend_combo)

        # Voice selection combo
        self._voice_combo = create_combo_row(
            title=_("Voice"),
            subtitle=_("Loading voices..."),
            options=[_("Detecting installed voices...")],
            on_selected=self._on_voice_selected,
            accessible_name=_("Select TTS voice"),
        )
        # Add refresh button as suffix
        refresh_btn = create_icon_button(
            icon_name="view-refresh-symbolic",
            tooltip=_("Refresh voice list"),
            on_clicked=self._on_refresh_voices,
        )
        self._voice_combo.add_suffix(refresh_btn)

        # Add manage voices button as suffix
        manage_btn = create_icon_button(
            icon_name="list-add-symbolic",
            tooltip=_("Manage voices for current engine"),
            on_clicked=self._on_manage_current_engine,
        )
        self._voice_combo.add_suffix(manage_btn)
        group.add(self._voice_combo)

        # SizeGroup for scale title alignment
        title_sg = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL)

        # Speed
        self._speed_row, self._speed_scale = create_action_row_with_scale(
            title=_("Speed"),
            subtitle=_("Speech rate"),
            min_value=RATE_MIN,
            max_value=RATE_MAX,
            value=self._settings.speech.rate,
            step=RATE_STEP,
            digits=0,
            on_changed=self._on_rate_changed,
            marks=[(RATE_MIN, _("Slow")), (0, _("Normal")), (RATE_MAX, _("Fast"))],
            accessible_name=_("Speech speed"),
            title_size_group=title_sg,
        )
        group.add(self._speed_row)

        # Pitch
        self._pitch_row, self._pitch_scale = create_action_row_with_scale(
            title=_("Pitch"),
            subtitle=_("Voice tone"),
            min_value=PITCH_MIN,
            max_value=PITCH_MAX,
            value=self._settings.speech.pitch,
            step=PITCH_STEP,
            digits=0,
            on_changed=self._on_pitch_changed,
            marks=[(PITCH_MIN, _("Low")), (0, _("Normal")), (PITCH_MAX, _("High"))],
            accessible_name=_("Voice pitch"),
            title_size_group=title_sg,
        )
        group.add(self._pitch_row)

        # Volume
        self._volume_row, self._volume_scale = create_action_row_with_scale(
            title=_("Volume"),
            subtitle=_("Speech volume"),
            min_value=VOLUME_MIN,
            max_value=VOLUME_MAX,
            value=self._settings.speech.volume,
            step=VOLUME_STEP,
            digits=0,
            on_changed=self._on_volume_changed,
            marks=[(VOLUME_MIN, _("Mute")), (50, "50%"), (VOLUME_MAX, _("Max"))],
            accessible_name=_("Speech volume"),
            title_size_group=title_sg,
        )
        group.add(self._volume_row)

        # ── Kokoro-specific controls (visible only when Kokoro is active) ──
        is_kokoro = self._settings.speech.backend == TTSBackend.KOKORO.value

        # Expression style combo
        emotion_presets = [
            _("Neutral"),
            _("Happy"),
            _("Calm"),
            _("Urgent"),
            _("Narrative"),
        ]
        self._kokoro_emotion_values = [
            "neutral", "happy", "calm", "urgent", "narrative",
        ]
        current_emotion = self._settings.speech.kokoro.emotion_preset
        emotion_idx = 0
        for i, v in enumerate(self._kokoro_emotion_values):
            if v == current_emotion:
                emotion_idx = i
                break

        self._kokoro_emotion_combo = create_combo_row(
            title=_("Expression style"),
            subtitle=_("Changes speed and intonation to match the mood"),
            options=emotion_presets,
            selected_index=emotion_idx,
            on_selected=self._on_kokoro_emotion_selected,
            accessible_name=_("Select expression style"),
        )
        self._kokoro_emotion_combo.set_visible(is_kokoro)
        group.add(self._kokoro_emotion_combo)

        # Voice blend combo — select a second voice to mix with the primary
        self._kokoro_blend_voices = [""]  # "" = no blend (populated later)
        self._kokoro_blend_labels = [_("None (single voice)")]

        self._kokoro_blend_combo = create_combo_row(
            title=_("Voice blend"),
            subtitle=_("Mix a second voice with the primary voice"),
            options=self._kokoro_blend_labels,
            selected_index=0,
            on_selected=self._on_kokoro_blend_selected,
            accessible_name=_("Select blend voice"),
        )
        self._kokoro_blend_combo.set_visible(is_kokoro)
        group.add(self._kokoro_blend_combo)

        return group

    # ── Backend Section ──────────────────────────────────────────────

    def _build_backend_section(self) -> Adw.PreferencesGroup:
        """Build voice management section."""
        group = create_preferences_group(
            title=_("Voice Management"),
            description=_("Install or manage voice packages"),
        )

        # Voice Manager row
        voice_mgr_row = Adw.ActionRow()
        voice_mgr_row.set_title(_("Voice Manager"))
        voice_mgr_row.set_subtitle(_("Install or remove voice packages"))
        voice_mgr_row.set_icon_name("audio-speakers-symbolic")
        voice_mgr_row.set_activatable(True)
        voice_mgr_row.connect("activated", lambda *_: self._on_open_voice_manager())

        arrow = Gtk.Image.new_from_icon_name("go-next-symbolic")
        arrow.set_valign(Gtk.Align.CENTER)
        arrow.add_css_class("dim-label")
        voice_mgr_row.add_suffix(arrow)
        group.add(voice_mgr_row)

        return group

    # ── Text Processing Section ──────────────────────────────────────

    def _build_text_processing_section(self) -> Adw.PreferencesGroup:
        """Build text processing options."""
        group = create_preferences_group(
            title=_("Text Processing"),
            description=_("Configure how text is processed before reading"),
        )

        expander = create_expander_row(
            title=_("Processing options"),
            subtitle=_("Abbreviations, formatting, limits"),
            icon_name="document-edit-symbolic",
        )

        # Expand abbreviations
        abbr_row, self._abbr_switch = create_action_row_with_switch(
            title=_("Expand abbreviations"),
            subtitle=_("Replace common abbreviations with full words"),
            active=self._settings.text.expand_abbreviations,
            on_toggled=self._on_abbreviations_toggled,
            accessible_name=_("Expand text abbreviations"),
        )
        expander.add_row(abbr_row)

        # Process special characters
        chars_row, self._chars_switch = create_action_row_with_switch(
            title=_("Read special characters"),
            subtitle=_("Speak symbols like # @ % by name"),
            active=self._settings.text.process_special_chars,
            on_toggled=self._on_special_chars_toggled,
            accessible_name=_("Read special characters aloud"),
        )
        expander.add_row(chars_row)

        # Strip formatting
        fmt_row, self._fmt_switch = create_action_row_with_switch(
            title=_("Remove formatting"),
            subtitle=_("Strip Markdown and HTML before reading"),
            active=self._settings.text.strip_formatting,
            on_toggled=self._on_strip_formatting_toggled,
            accessible_name=_("Remove text formatting"),
        )
        expander.add_row(fmt_row)

        # Process URLs
        url_row, self._url_switch = create_action_row_with_switch(
            title=_("Read URLs"),
            subtitle=_("Read web addresses aloud instead of skipping them"),
            active=self._settings.text.process_urls,
            on_toggled=self._on_urls_toggled,
            accessible_name=_("Read URLs aloud"),
        )
        expander.add_row(url_row)

        # Max characters — combo with presets
        char_options = [
            _("Unlimited"),
            _("1 000"),
            _("5 000"),
            _("10 000"),
            _("50 000"),
            _("100 000"),
        ]
        self._char_limit_values = [0, 1000, 5000, 10000, 50000, 100000]

        current_limit = self._settings.text.max_chars
        current_char_idx = 0  # default: Unlimited
        for i, val in enumerate(self._char_limit_values):
            if val == current_limit:
                current_char_idx = i
                break

        self._max_chars_combo = create_combo_row(
            title=_("Character limit"),
            subtitle=_("Maximum number of characters to read at once"),
            options=char_options,
            selected_index=current_char_idx,
            on_selected=self._on_max_chars_selected,
            accessible_name=_("Maximum character limit"),
        )
        expander.add_row(self._max_chars_combo)

        group.add(expander)
        return group

    # ── Advanced Section ─────────────────────────────────────────────

    def _build_advanced_section(self) -> Adw.PreferencesGroup:
        """Build advanced settings with functional shortcut editor."""
        group = create_preferences_group(
            title=_("Advanced"),
        )

        expander = create_expander_row(
            title=_("Advanced options"),
            subtitle=_("Shortcut, behavior settings"),
            icon_name="preferences-other-symbolic",
        )

        # ── Shortcut editor ──
        shortcut_row = Adw.ActionRow()
        shortcut_row.set_title(_("Keyboard shortcut"))
        shortcut_row.set_subtitle(
            _(
                "Select text anywhere and press the shortcut to read aloud. Press again to stop."
            )
        )
        shortcut_row.set_icon_name("preferences-desktop-keyboard-shortcuts-symbolic")

        # Shortcut label showing current keybinding
        self._shortcut_label = Gtk.ShortcutLabel()
        self._shortcut_label.set_accelerator(self._settings.shortcut.keybinding)
        self._shortcut_label.set_valign(Gtk.Align.CENTER)

        # "Change" button to start recording
        self._shortcut_button = Gtk.Button(label=_("Change"))
        self._shortcut_button.set_valign(Gtk.Align.CENTER)
        self._shortcut_button.add_css_class("flat")
        self._shortcut_button.connect("clicked", self._on_shortcut_change_clicked)

        shortcut_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        shortcut_box.append(self._shortcut_label)
        shortcut_box.append(self._shortcut_button)
        shortcut_row.add_suffix(shortcut_box)

        expander.add_row(shortcut_row)

        # ── Show speak action in app launcher ──
        launcher_row, self._launcher_switch_widget = create_action_row_with_switch(
            title=_("Tray icon"),
            subtitle=_("Fixes quick access and allows running in the background"),
            active=self._settings.shortcut.show_in_launcher,
            on_toggled=self._on_launcher_toggle,
            accessible_name=_("Show system tray icon"),
        )
        launcher_row.set_icon_name("view-pin-symbolic")
        expander.add_row(launcher_row)

        # ── History save ──
        history_row, self._history_switch_widget = create_action_row_with_switch(
            title=_("Save history"),
            subtitle=_("Save spoken text and audio to ~/Music/tts-biglinux"),
            active=self._settings.history.enabled,
            on_toggled=self._on_history_toggle,
            accessible_name=_("Save speech history"),
        )
        history_row.set_icon_name("document-save-symbolic")
        expander.add_row(history_row)

        # ── Playback mode for history player ──
        playback_row = Adw.ComboRow()
        playback_row.set_title(_("Audio playback mode"))
        playback_row.set_subtitle(_("Behavior when playing multiple audios or speech"))
        playback_row.set_icon_name("media-playback-start-symbolic")
        playback_modes = Gtk.StringList.new(
            [_("Interrupt previous"), _("Queue"), _("Simultaneous")]
        )
        playback_row.set_model(playback_modes)
        mode_map = {"interrupt": 0, "queue": 1, "simultaneous": 2}
        current = self._settings.history.playback_mode
        playback_row.set_selected(mode_map.get(current, 0))
        playback_row.connect("notify::selected", self._on_playback_mode_changed)
        expander.add_row(playback_row)

        group.add(expander)
        return group

    def _on_shortcut_change_clicked(self, button: Gtk.Button) -> None:
        """Open a small key-capture window for shortcut recording."""
        parent = self.get_root()

        # Block global shortcuts so the current binding does not fire
        DesktopIntegrationService.block_global_shortcuts(True)

        # Create a capture window
        win = Adw.Window()
        win.set_title(_("Set new shortcut"))
        win.set_default_size(500, 320)
        win.set_modal(True)
        if parent:
            win.set_transient_for(parent)
        win.set_resizable(False)

        # Custom smaller icon
        icon = Gtk.Image.new_from_icon_name("input-keyboard-symbolic")
        icon.set_pixel_size(48)
        icon.set_margin_top(24)
        icon.add_css_class("dim-label")

        desc_label = Gtk.Label()
        desc_label.set_markup(
            _(
                "<b>Hold modifier keys</b> (Alt, Ctrl, Shift, Super)\n"
                "and press a letter or key.\n\n"
                "<small>Press Escape to cancel</small>"
            )
        )
        desc_label.set_justify(Gtk.Justification.CENTER)
        desc_label.set_margin_top(16)
        desc_label.set_margin_bottom(16)

        title_label = Gtk.Label(label=_("Press the new shortcut"))
        title_label.add_css_class("title-3")
        title_label.set_margin_top(12)

        content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        content_box.set_valign(Gtk.Align.CENTER)
        content_box.set_halign(Gtk.Align.CENTER)
        content_box.append(icon)
        content_box.append(title_label)
        content_box.append(desc_label)

        win.set_content(content_box)

        # Restore global shortcuts when the capture window is closed
        win.connect(
            "close-request",
            lambda w: DesktopIntegrationService.block_global_shortcuts(False) or False,
        )

        # Key controller on the capture window
        key_ctrl = Gtk.EventControllerKey()
        key_ctrl.connect("key-pressed", self._on_capture_key_pressed, win)
        win.add_controller(key_ctrl)

        win.present()

    def _on_capture_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: int,
        win: Adw.Window,
    ) -> bool:
        """Handle key press in the capture window."""
        import threading

        from gi.repository import Gdk

        # Ignore modifier-only presses
        modifier_keys = {
            Gdk.KEY_Shift_L,
            Gdk.KEY_Shift_R,
            Gdk.KEY_Control_L,
            Gdk.KEY_Control_R,
            Gdk.KEY_Alt_L,
            Gdk.KEY_Alt_R,
            Gdk.KEY_Super_L,
            Gdk.KEY_Super_R,
            Gdk.KEY_Meta_L,
            Gdk.KEY_Meta_R,
        }
        if keyval in modifier_keys:
            return False

        # Escape = cancel
        if keyval == Gdk.KEY_Escape and not (
            state
            & (
                Gdk.ModifierType.CONTROL_MASK
                | Gdk.ModifierType.ALT_MASK
                | Gdk.ModifierType.SUPER_MASK
            )
        ):
            win.close()
            return True

        # Build accelerator string from modifiers + key
        mods = state & (
            Gdk.ModifierType.CONTROL_MASK
            | Gdk.ModifierType.SHIFT_MASK
            | Gdk.ModifierType.ALT_MASK
            | Gdk.ModifierType.SUPER_MASK
        )

        accel = Gtk.accelerator_name(keyval, mods)
        if not accel:
            return False

        # Save the new shortcut
        self._settings.shortcut.keybinding = accel
        self._settings_service.save_now()

        # Update UI
        self._shortcut_label.set_accelerator("" if accel == "none" else accel)
        self._update_hero_labels(self._tts.state)

        # Unblock global shortcuts before updating KDE bindings
        DesktopIntegrationService.block_global_shortcuts(False)

        # Close the capture window immediately
        win.close()

        display_name = Gtk.accelerator_get_label(keyval, mods)
        self._on_toast(_("Shortcut changed to {keys}").format(keys=display_name), 3)
        logger.info("Shortcut changed to: %s (%s)", accel, display_name)

        # Update shortcut for the current desktop environment in background
        threading.Thread(
            target=DesktopIntegrationService.register_shortcut_for_current_de,
            args=(accel,),
            daemon=True,
        ).start()

        return True

    # ── Launcher toggle ────────────────────────────────────────────

    def _on_launcher_toggle(self, active: bool) -> None:
        """Enable/disable system tray icon."""
        if self._updating_ui:
            return

        self._settings.shortcut.show_in_launcher = active
        self._settings_service.save()

        # Toggle system tray icon
        app = self.get_root().get_application()
        if hasattr(app, "enable_tray") and active:
            app.enable_tray()
            self._on_toast(_("Tray icon enabled"), 2)
        elif hasattr(app, "disable_tray") and not active:
            app.disable_tray()
            self._on_toast(_("Tray icon disabled"), 2)

    def _on_history_toggle(self, active: bool) -> None:
        """Enable/disable speech history saving."""
        if self._updating_ui:
            return
        self._settings.history.enabled = active
        self._settings_service.save()

        if active:
            # Ensure directory exists
            from services.history_service import ensure_history_dir
            ensure_history_dir()
            self._on_toast(_("History saving enabled"), 2)
        else:
            self._on_toast(_("History saving disabled"), 2)

        # Notify window to show/hide history tab
        window = self.get_root()
        if hasattr(window, "update_history_tab_visibility"):
            window.update_history_tab_visibility(active)

    def _on_playback_mode_changed(self, combo: Adw.ComboRow, _pspec: object) -> None:
        """Handle audio playback mode selection."""
        if self._updating_ui:
            return
        modes = ["interrupt", "queue", "simultaneous"]
        idx = combo.get_selected()
        if 0 <= idx < len(modes):
            self._settings.history.playback_mode = modes[idx]
            self._settings_service.save()
            # Update AudioPlayerWidget class setting
            from ui.audio_player import AudioPlayerWidget
            AudioPlayerWidget.set_playback_mode(modes[idx])

    def _on_kokoro_emotion_selected(self, index: int) -> None:
        """Handle Kokoro emotion preset selection."""
        if self._updating_ui:
            return
        preset = self._kokoro_emotion_values[index]
        self._settings.speech.kokoro.emotion_preset = preset
        self._settings_service.save()

    def _on_kokoro_blend_selected(self, index: int) -> None:
        """Handle Kokoro voice blend combo selection."""
        if self._updating_ui:
            return
        voice_id = self._kokoro_blend_voices[index] if index < len(self._kokoro_blend_voices) else ""
        self._settings.speech.kokoro.voice_blend = voice_id
        self._settings_service.save()

    def _populate_kokoro_blend_combo(self, current_blend: str = "") -> None:
        """Populate voice blend combo with available Kokoro voices."""
        voices = [""]
        labels = [_("None (single voice)")]

        # Get available Kokoro voices from catalog
        if self._catalog:
            for v in self._catalog.get_by_backend(TTSBackend.KOKORO.value):
                voices.append(v.voice_id)
                labels.append(v.name or v.voice_id)

        self._kokoro_blend_voices = voices
        self._kokoro_blend_labels = labels

        # Rebuild combo model — guard to prevent callback from clearing selection
        with self._guard_ui():
            model = Gtk.StringList()
            for label in labels:
                model.append(label)
            self._kokoro_blend_combo.set_model(model)

            # Restore selection
            if not current_blend:
                current_blend = self._settings.speech.kokoro.voice_blend
            sel_idx = 0
            for i, vid in enumerate(voices):
                if vid == current_blend:
                    sel_idx = i
                    break
            self._kokoro_blend_combo.set_selected(sel_idx)

    def _on_refresh_voices(self) -> None:
        """Manually trigger voice discovery."""
        self._voice_combo.set_subtitle(_("Refreshing voices..."))
        run_in_thread(discover_voices, on_done=self._on_voices_discovered)
        self._on_toast(_("Refreshing voice list…"), 2)

    def _on_manage_current_engine(self) -> None:
        """Open Voice Manager filtered by current backend."""
        backend = self._settings.speech.backend
        # Map internal backend IDs to Voice Manager engine names
        engine_map = {
            "rhvoice": "RHVoice",
            "piper": "Piper",
            "espeak-ng": "espeak-ng",
            "kokoro": "Kokoro",
            "speech-dispatcher": self._settings.speech.output_module.lower()
        }
        engine_filter = engine_map.get(backend.lower())
        
        # If still not found, check if backend string contains engine name
        if not engine_filter:
            for key in ["rhvoice", "piper", "espeak-ng"]:
                if key in backend.lower():
                    engine_filter = engine_map[key]
                    break

        self._on_open_voice_manager(engine_filter)

    def _on_open_voice_manager(self, engine_filter: str | None = None) -> None:
        """Open the Voice Manager dialog, optionally filtered by engine."""
        from ui.voice_manager_dialog import VoiceManagerDialog

        dialog = VoiceManagerDialog(
            on_voices_changed=self._on_refresh_voices,
            engine_filter=engine_filter,
        )
        parent = self.get_root()
        dialog.present(parent)


    # ── Voice Discovery Callback ─────────────────────────────────────

    def _on_voices_discovered(self, catalog: VoiceCatalog) -> None:
        """Handle voice discovery completion (called on main thread)."""
        self._catalog = catalog
        logger.info("Voices discovered: %d voices in catalog", len(catalog.voices))

        if not catalog.voices:
            self._voice_combo.set_subtitle(
                _("No voices found — install rhvoice or espeak-ng")
            )
            self._voice_combo.set_model(Gtk.StringList.new([_("No voices available")]))
            self._voice_list = []
            return

        # Filter voices by selected backend
        current_backend = self._settings.speech.backend
        filtered = catalog.get_by_backend(current_backend)

        # If selected backend has no voices, auto-switch to one that does
        if not filtered and catalog.backends_available:
            # Prefer backends in this order
            preferred = [
                TTSBackend.RHVOICE.value,
                TTSBackend.PIPER.value,
                TTSBackend.KOKORO.value,
                TTSBackend.ESPEAK_NG.value,
            ]
            for pref in preferred:
                if pref in catalog.backends_available:
                    current_backend = pref
                    break
            else:
                current_backend = catalog.backends_available[0]

            filtered = catalog.get_by_backend(current_backend)
            self._settings.speech.backend = current_backend
            self._settings_service.save(self._settings)

            # Update backend combo to reflect the auto-switch
            backend_idx_map = {
                TTSBackend.RHVOICE.value: 0,
                TTSBackend.ESPEAK_NG.value: 1,
                TTSBackend.PIPER.value: 2,
                TTSBackend.KOKORO.value: 3,
            }
            with self._guard_ui():
                self._backend_combo.set_selected(
                    backend_idx_map.get(current_backend, 0)
                )

            logger.info(
                "Auto-switched backend from %s to %s (no voices for original)",
                self._settings.speech.backend, current_backend,
            )

        # For speech-dispatcher, show ALL output modules (rhvoice + espeak-ng)
        # so the user always sees every installed voice regardless of the
        # previously saved output_module.  The correct module is set when the
        # user picks a voice (see _on_voice_selected).

        if not filtered:
            # No voices for selected backend
            self._voice_list = []
            with self._guard_ui():
                self._voice_combo.set_model(
                    Gtk.StringList.new([_("No voices available for this engine")])
                )
                self._voice_combo.set_subtitle(
                    _("Install {engine} voices first").format(engine=current_backend)
                )
                self._test_button.set_sensitive(False)
            return

        self._voice_list = filtered
        logger.info("Filtered voices for backend %s: %s", current_backend, [v.name for v in filtered])
        self._test_button.set_sensitive(True)

        # Sort voices alphabetically by language name, then by name
        self._voice_list.sort(key=lambda v: (v.language_name, v.name))

        # Build display names
        display_names: list[str] = []
        for v in self._voice_list:
            quality_tag = ""
            if v.quality == "neural":
                quality_tag = " [Neural]"
            elif v.quality == "high":
                quality_tag = " [HQ]"

            display_names.append(f"{v.name} — {v.language_name}{quality_tag}")

        # Update combo
        with self._guard_ui():
            model = Gtk.StringList.new(display_names)
            self._voice_combo.set_model(model)
            status_msg = _("{count} voices available").format(count=len(display_names))
            self._voice_combo.set_subtitle(status_msg)
            # Show a toast for the refresh result
            self._on_toast(status_msg, 2)

            # Select current voice (accent-insensitive match)
            current_voice_id = self._settings.speech.voice_id
            selected_idx = 0

            if current_voice_id:
                import unicodedata

                def _norm(s: str) -> str:
                    s = unicodedata.normalize("NFD", s)
                    return "".join(c for c in s if unicodedata.category(c) != "Mn").lower()

                norm_current = _norm(current_voice_id)
                for i, v in enumerate(self._voice_list):
                    if _norm(v.voice_id) == norm_current:
                        selected_idx = i
                        break

            # If current voice not in filtered list, auto-select best
            if selected_idx == 0 and current_voice_id:
                # Voice not found in current backend — pick best for language
                sys_lang = get_system_language()
                for i, v in enumerate(self._voice_list):
                    if v.language.startswith(sys_lang):
                        selected_idx = i
                        break

            self._voice_combo.set_selected(selected_idx)

        # Update settings with selected voice
        if self._voice_list:
            voice = self._voice_list[selected_idx]
            self._settings.speech.voice_id = voice.voice_id
            self._settings.speech.backend = voice.backend
            self._settings_service.save(self._settings)

        logger.info(
            "Voice catalog loaded: %d voices (filtered from %d)",
            len(self._voice_list),
            len(catalog.voices),
        )

        # Refresh Kokoro blend combo if Kokoro is active
        if current_backend == TTSBackend.KOKORO.value:
            self._populate_kokoro_blend_combo()

    # ── Event Handlers ───────────────────────────────────────────────

    def _on_voice_selected(self, index: int) -> None:
        """Handle voice selection change."""
        if self._updating_ui or not self._voice_list:
            return
        if 0 <= index < len(self._voice_list):
            voice = self._voice_list[index]
            self._settings.speech.voice_id = voice.voice_id
            self._settings.speech.backend = voice.backend
            self._settings.speech.output_module = voice.output_module
            self._settings_service.save(self._settings)
            logger.debug("Voice selected: %s (%s)", voice.name, voice.voice_id)

    def _on_rate_changed(self, value: float) -> None:
        """Handle speed change."""
        self._settings.speech.rate = int(value)
        self._settings_service.save(self._settings)

    def _on_pitch_changed(self, value: float) -> None:
        """Handle pitch change."""
        self._settings.speech.pitch = int(value)
        self._settings_service.save(self._settings)

    def _on_volume_changed(self, value: float) -> None:
        """Handle volume change."""
        self._settings.speech.volume = int(value)
        self._settings_service.save(self._settings)

    def _on_backend_selected(self, index: int) -> None:
        """Handle backend selection."""
        if self._updating_ui:
            return
        backend_map = {
            0: TTSBackend.RHVOICE.value,
            1: TTSBackend.ESPEAK_NG.value,
            2: TTSBackend.PIPER.value,
            3: TTSBackend.KOKORO.value,
        }
        backend = backend_map.get(index, TTSBackend.RHVOICE.value)
        logger.debug("Backend selected: index=%d → %s", index, backend)

        # Show Kokoro-specific controls only for Kokoro engine
        is_kokoro = backend == TTSBackend.KOKORO.value
        self._kokoro_emotion_combo.set_visible(is_kokoro)
        self._kokoro_blend_combo.set_visible(is_kokoro)
        if is_kokoro:
            self._populate_kokoro_blend_combo()

        # Stop any active speech before switching
        self._tts.stop()

        self._settings.speech.backend = backend
        self._settings_service.save(self._settings)

        # Use existing catalog for immediate update
        if self._catalog:
            filtered = self._catalog.get_by_backend(backend)
            if filtered:
                self._on_voices_discovered(self._catalog)
                return

        # If no voices in catalog, or switching to native RHVoice for first time
        if backend == TTSBackend.RHVOICE.value:
            self._voice_combo.set_subtitle(_("Searching for voices..."))
            run_in_thread(
                discover_voices,
                on_done=self._on_voices_discovered,
            )
            return

        # Kokoro: check if engine is installed before anything else
        if backend == TTSBackend.KOKORO.value and not is_kokoro_installed():
            self._ask_install_kokoro()
            return

        # Refresh voices for new backend
        if self._catalog:
            filtered = self._catalog.get_by_backend(backend)
            if filtered:
                self._on_voices_discovered(self._catalog)
                return
            elif backend == TTSBackend.PIPER.value:
                # If Piper is selected but no voices are found, try discovering again
                # before asking to install (it might have been just installed)
                self._voice_combo.set_subtitle(_("Checking for Piper voices..."))
                run_in_thread(
                    discover_voices,
                    on_done=lambda cat: self._on_piper_discovery_retry(cat),
                )
                return
            elif backend == TTSBackend.KOKORO.value:
                # Kokoro installed but no voices — re-discover
                self._voice_combo.set_subtitle(_("Checking for Kokoro voices..."))
                run_in_thread(
                    discover_voices,
                    on_done=self._on_voices_discovered,
                )
                return
            else:
                self._on_toast(
                    _("No voices found for {engine} — install it first").format(
                        engine=backend,
                    ),
                    4,
                )
            self._on_voices_discovered(self._catalog)

    def _ask_install_piper(self) -> None:
        """Show dialog to install Piper TTS and voices."""
        dialog = Adw.AlertDialog.new(
            _("Install Piper Neural TTS?"),
            _(
                "Piper is not installed. It provides high-quality neural "
                "voices for reading text.\n\n"
                "The following packages will be installed:\n"
                "• piper-tts-bin (TTS engine)\n"
                "• piper-voices-pt-BR (Portuguese voices)\n\n"
                "This requires administrator permissions."
            ),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("install", _("Install"))
        dialog.set_response_appearance("install", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_default_response("install")
        dialog.set_close_response("cancel")
        dialog.connect("response", self._on_install_piper_response)

        window = self.get_root()
        dialog.present(window)

    def _on_install_piper_response(
        self, dialog: Adw.AlertDialog, response: str
    ) -> None:
        """Handle install dialog response."""
        if response != "install":
            # Revert to previous backend
            with self._guard_ui():
                prev_backend = TTSBackend.RHVOICE.value
                self._settings.speech.backend = prev_backend
                self._settings_service.save(self._settings)
                self._backend_combo.set_selected(0)
                if self._catalog:
                    self._on_voices_discovered(self._catalog)
            return

        self._run_install_with_progress(
            title=_("Installing Piper TTS"),
            status_text=_("Downloading and installing packages…"),
            worker=self._install_piper_packages,
            on_done=self._on_piper_installed,
        )

    def _ask_install_kokoro(self) -> None:
        """Show dialog to install Kokoro Neural TTS."""
        dialog = Adw.AlertDialog.new(
            _("Install Kokoro Neural TTS?"),
            _(
                "Kokoro is not installed. It provides high-quality neural "
                "voices with emotion presets and voice blending.\n\n"
                "System dependencies (PyTorch, scipy, espeak-ng) will be "
                "installed via pacman.\n"
                "The Kokoro library will be installed via pip."
            ),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("install", _("Install"))
        dialog.set_response_appearance("install", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_default_response("install")
        dialog.set_close_response("cancel")
        dialog.connect("response", self._on_install_kokoro_response)

        window = self.get_root()
        dialog.present(window)

    def _on_install_kokoro_response(
        self, dialog: Adw.AlertDialog, response: str
    ) -> None:
        """Handle Kokoro install dialog response."""
        if response != "install":
            with self._guard_ui():
                prev_backend = TTSBackend.RHVOICE.value
                self._settings.speech.backend = prev_backend
                self._settings_service.save(self._settings)
                self._backend_combo.set_selected(0)
                if self._catalog:
                    self._on_voices_discovered(self._catalog)
            return

        self._run_install_with_progress(
            title=_("Installing Kokoro TTS"),
            status_text=_("Downloading and installing packages…"),
            worker=self._install_kokoro_packages,
            on_done=self._on_kokoro_installed,
        )

    def _run_install_with_progress(
        self,
        title: str,
        status_text: str,
        worker: Callable[[], tuple[bool, str]],
        on_done: Callable[[tuple[bool, str]], None],
    ) -> None:
        """Show a progress dialog and run a package install in background."""
        progress_dialog = Adw.AlertDialog.new(title, status_text)
        progress_dialog.set_can_close(False)

        # Add a progress bar as extra child
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_top(12)

        progress_bar = Gtk.ProgressBar()
        progress_bar.set_show_text(False)
        progress_bar.pulse()
        box.append(progress_bar)

        progress_dialog.set_extra_child(box)

        window = self.get_root()
        progress_dialog.present(window)

        # Pulse the progress bar while installing
        def _pulse() -> bool:
            if not hasattr(progress_dialog, "_install_running"):
                return False
            progress_bar.pulse()
            return True

        progress_dialog._install_running = True
        GLib.timeout_add(200, _pulse)

        def _threaded() -> None:
            result = worker()

            def _finish() -> bool:
                del progress_dialog._install_running
                progress_dialog.set_can_close(True)
                progress_dialog.force_close()
                on_done(result)
                return False

            GLib.idle_add(_finish)

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

    def _install_kokoro_packages(self) -> tuple[bool, str]:
        """Install Kokoro TTS: system deps via pacman + kokoro via pip."""
        try:
            lock_file = Path("/var/lib/pacman/db.lck")
            if lock_file.exists():
                return False, _("Database is locked by another process")

            # Step 1: system deps via pacman (uses pkexec for graphical auth)
            sys_deps = [
                "python-pytorch", "python-numpy", "python-scipy",
                "python-transformers", "python-huggingface-hub",
                "python-loguru", "espeak-ng",
            ]
            result = subprocess.run(
                ["pkexec", "pacman", "-S", "--noconfirm", "--needed", *sys_deps],
                capture_output=True,
                text=True,
                timeout=600,
            )
            if result.returncode != 0:
                stderr = result.stderr.strip()
                error_msg = stderr.splitlines()[-1] if stderr else _("pacman failed")
                return False, error_msg

            # Step 2: kokoro + soundfile via pip (user-local, no root needed)
            result = subprocess.run(
                [
                    "pip3", "install", "--user",
                    "--break-system-packages",
                    "kokoro", "soundfile",
                ],
                capture_output=True,
                text=True,
                timeout=300,
            )
            if result.returncode != 0:
                stderr = result.stderr.strip()
                error_msg = stderr.splitlines()[-1] if stderr else _("pip install failed")
                return False, error_msg

            return True, ""
        except FileNotFoundError as e:
            return False, _("Command not found: {cmd}").format(cmd=str(e))
        except subprocess.TimeoutExpired:
            return False, _("Installation timed out")
        except Exception as e:
            return False, str(e)

    def _on_kokoro_installed(self, result: tuple[bool, str]) -> None:
        """Called after Kokoro install completes."""
        success, error_msg = result
        if success:
            self._on_toast(_("Kokoro installed successfully! Discovering voices…"), 3)

            def _on_done(catalog: VoiceCatalog) -> None:
                self._on_voices_discovered(catalog)
                with self._guard_ui():
                    self._backend_combo.set_selected(3)
                    self._on_backend_selected(3)

            run_in_thread(discover_voices, on_done=_on_done)
        else:
            if error_msg:
                self._on_toast(
                    _("Failed to install: {error}").format(error=error_msg), 5
                )
            else:
                self._on_toast(_("Failed to install Kokoro — check permissions"), 5)
            with self._guard_ui():
                self._settings.speech.backend = TTSBackend.RHVOICE.value
                self._settings_service.save(self._settings)
                self._backend_combo.set_selected(0)
                if self._catalog:
                    self._on_voices_discovered(self._catalog)

    def _on_piper_discovery_retry(self, catalog: VoiceCatalog) -> None:
        """Second attempt at discovery after switching to Piper."""
        self._catalog = catalog
        filtered = catalog.get_by_backend(TTSBackend.PIPER.value)
        if not filtered:
            self._ask_install_piper()
        else:
            self._on_voices_discovered(catalog)

    def _install_piper_packages(self) -> tuple[bool, str]:
        """Install piper-tts-bin and piper-voices-pt-BR via pkexec + pacman."""
        # Detect system language for voice package
        lang = get_system_language()
        voice_pkgs = []
        lang_map = {
            "pt": "piper-voices-pt-BR",
            "en": "piper-voices-en-US",
            "es": "piper-voices-es-ES",
            "fr": "piper-voices-fr-FR",
            "de": "piper-voices-de-DE",
            "it": "piper-voices-it-IT",
            "ru": "piper-voices-ru-RU",
            "ja": "piper-voices-ja-JP",
            "ko": "piper-voices-ko-KR",
            "zh": "piper-voices-zh-CN",
        }
        voice_pkg = lang_map.get(lang, "piper-voices-en-US")
        voice_pkgs.append(voice_pkg)

        # Always include pt-BR for BigLinux
        if voice_pkg != "piper-voices-pt-BR":
            voice_pkgs.append("piper-voices-pt-BR")

        # List of packages to install
        pkgs = ["piper-tts-bin"] + voice_pkgs

        try:
            # Detect if a pacman lock exists before even trying
            lock_file = Path("/var/lib/pacman/db.lck")
            if lock_file.exists():
                logger.warning("Pacman database is locked")
                return False, "Database is locked by another process"

            result = subprocess.run(
                ["pkexec", "pacman", "-Sy", "--noconfirm", "--needed"] + pkgs,
                capture_output=True,
                text=True,
                timeout=600,  # Increased timeout for large voice packages
            )
            stdout = result.stdout[-500:] if result.stdout else ""
            stderr = result.stderr[-500:] if result.stderr else ""
            
            logger.info("Piper install stdout: %s", stdout)
            if result.returncode != 0:
                logger.error(
                    "Piper install failed (code %d): %s", result.returncode, stderr
                )
                if result.returncode == 126 or result.returncode == 1:
                    if "authorization" in stderr.lower() or not stderr:
                        return False, "Authorization denied"
                
                # Try to extract the most meaningful error from pacman output
                error_msg = stderr.strip().splitlines()[-1] if stderr.strip() else "Unknown error"
                return False, error_msg
            return True, ""
        except FileNotFoundError:
            return False, "pkexec not found"
        except subprocess.TimeoutExpired:
            return False, "Installation timed out"
        except Exception as e:
            return False, str(e)
    def _on_piper_installed(self, result: tuple[bool, str]) -> None:
        """Called after Piper install completes."""
        success, error_msg = result
        if success:
            self._on_toast(_("Piper installed successfully! Discovering voices…"), 3)

            def _on_done(catalog: VoiceCatalog) -> None:
                self._on_voices_discovered(catalog)
                # After discovery, force selecting the Piper backend index (2)
                with self._guard_ui():
                    self._backend_combo.set_selected(2)
                    self._on_backend_selected(2)

            # Re-discover voices
            run_in_thread(discover_voices, on_done=_on_done)
        else:
            # If error_msg is descriptive, show it, otherwise fallback to generic
            if error_msg:
                detailed_error = _("Failed to install: {error}").format(error=error_msg)
                self._on_toast(detailed_error, 5)
            else:
                self._on_toast(_("Failed to install Piper — check permissions"), 5)

            # Revert to native RHVoice
            with self._guard_ui():
                self._settings.speech.backend = TTSBackend.RHVOICE.value
                self._settings_service.save(self._settings)
                self._backend_combo.set_selected(0)
                if self._catalog:
                    self._on_voices_discovered(self._catalog)

    def _on_abbreviations_toggled(self, active: bool) -> None:
        if self._updating_ui:
            return
        self._settings.text.expand_abbreviations = active
        self._settings_service.save()

    def _on_special_chars_toggled(self, active: bool) -> None:
        if self._updating_ui:
            return
        self._settings.text.process_special_chars = active
        self._settings_service.save()

    def _on_strip_formatting_toggled(self, active: bool) -> None:
        if self._updating_ui:
            return
        self._settings.text.strip_formatting = active
        self._settings_service.save()

    def _on_urls_toggled(self, active: bool) -> None:
        if self._updating_ui:
            return
        self._settings.text.process_urls = active
        self._settings_service.save()

    def _on_max_chars_changed(self, value: float) -> None:
        self._settings.text.max_chars = int(value)
        self._settings_service.save(self._settings)

    def _on_max_chars_selected(self, idx: int) -> None:
        """Handle character limit combo selection."""
        if self._updating_ui:
            return
        if 0 <= idx < len(self._char_limit_values):
            self._settings.text.max_chars = self._char_limit_values[idx]
            self._settings_service.save()

    # ── Test Voice ───────────────────────────────────────────────────

    def _on_test_voice(self) -> None:
        """Test the selected voice / stop if already speaking."""
        # Toggle: if speaking, stop
        if self._tts.is_speaking:
            self._tts.stop()
            return

        # Read text from the test entry field
        phrase = self._test_entry.get_text().strip()
        if not phrase:
            self._on_toast(_("Type some text to test"), 2)
            return

        # Get selected voice
        voice = None
        idx = self._voice_combo.get_selected()
        if self._voice_list and 0 <= idx < len(self._voice_list):
            voice = self._voice_list[idx]

        settings = self._settings.speech

        # Warn if volume is at zero
        test_volume = settings.volume
        if test_volume <= 0:
            self._on_toast(_("Volume is at zero — increasing to minimum for test"), 3)
            test_volume = 10

        logger.info(
            "Test voice: backend=%s, voice_id=%s, volume=%d, text=%r",
            voice.backend if voice else settings.backend,
            voice.voice_id if voice else settings.voice_id,
            test_volume,
            phrase[:60],
        )

        success = self._tts.speak(
            phrase,
            voice=voice,
            rate=settings.rate,
            pitch=settings.pitch,
            volume=test_volume,
            backend=settings.backend,
            output_module=settings.output_module,
            voice_id=settings.voice_id,
            expand_abbreviations=self._settings.text.expand_abbreviations,
            process_special_chars=self._settings.text.process_special_chars,
            process_urls=self._settings.text.process_urls,
            strip_formatting=self._settings.text.strip_formatting,
        )

        if not success:
            self._on_toast(
                _("Could not play test — check if a TTS engine is installed"), 4
            )

    # ── TTS State Callback ───────────────────────────────────────────

    def _on_tts_state_changed(self, state: TTSState) -> None:
        """Update hero section when TTS state changes."""
        GLib.idle_add(self._update_hero_state, state)

    def _get_shortcut_display(self) -> str:
        accel = self._settings.shortcut.keybinding
        return DesktopIntegrationService.gtk_accel_to_kde(accel)

    def _update_hero_labels(self, state: TTSState) -> None:
        """Update hero subtitle texts dynamically."""
        sc = self._get_shortcut_display()
        if state == TTSState.SPEAKING:
            lbl = _("Press Alt+V to stop")
            self._hero_subtitle.set_label(lbl.replace("Alt+V", sc))
        elif state == TTSState.ERROR:
            self._hero_subtitle.set_label(_("Could not play speech — check TTS engine"))
        else:
            lbl = _("Select text and press Alt+V to read aloud")
            self._hero_subtitle.set_label(lbl.replace("Alt+V", sc))

    def _update_hero_state(self, state: TTSState) -> bool:
        """Update hero UI for current TTS state (main thread)."""
        # Neural engine indicator
        is_neural = self._settings.speech.backend in (
            TTSBackend.PIPER.value, TTSBackend.KOKORO.value,
        )

        if state == TTSState.SPEAKING:
            self._hero_icon.set_from_icon_name("audio-volume-high-symbolic")
            self._hero_icon.add_css_class("speaking-indicator")
            self._hero_title.set_markup(f"<b>{_('Speaking...')}</b>")
            self._update_hero_labels(state)
            self._test_button.set_label(_("Stop"))
            self._test_button.update_property(
                [Gtk.AccessibleProperty.LABEL], [_("Stop")]
            )
            self._test_button.remove_css_class("suggested-action")
            self._test_button.add_css_class("destructive-action")
            if is_neural:
                self.add_css_class("status-neural")
            else:
                self.remove_css_class("status-neural")

        elif state == TTSState.ERROR:
            self._hero_icon.set_from_icon_name("dialog-warning-symbolic")
            self._hero_icon.remove_css_class("speaking-indicator")
            self._hero_title.set_markup(f"<b>{_('Error')}</b>")
            self._update_hero_labels(state)
            self._test_button.set_label(_("Test voice"))
            self._test_button.update_property(
                [Gtk.AccessibleProperty.LABEL], [_("Test voice")]
            )
            self._test_button.remove_css_class("destructive-action")
            self._test_button.add_css_class("suggested-action")
            self.remove_css_class("status-neural")

        else:  # IDLE
            self._hero_icon.set_from_icon_name("audio-speakers-symbolic")
            self._hero_icon.remove_css_class("speaking-indicator")
            self._hero_title.set_markup(f"<b>{_('Ready to speak')}</b>")
            self._update_hero_labels(state)
            self._test_button.set_label(_("Test voice"))
            self._test_button.update_property(
                [Gtk.AccessibleProperty.LABEL], [_("Test voice")]
            )
            self._test_button.remove_css_class("destructive-action")
            self._test_button.add_css_class("suggested-action")
            self.remove_css_class("status-neural")

        return GLib.SOURCE_REMOVE

    # ── Restore Defaults ─────────────────────────────────────────────

    def restore_defaults(self) -> None:
        """Reset all settings to defaults and update UI."""
        self._settings = self._settings_service.reset_to_defaults()
        self._update_ui_from_settings()

        # Explicitly sync tray state since _update_ui_from_settings blocks callbacks
        # via the _updating_ui guard. Default is show_in_launcher=True (tray enabled).
        app = self.get_root().get_application()
        if self._settings.shortcut.show_in_launcher:
            if hasattr(app, "enable_tray"):
                app.enable_tray()
        else:
            if hasattr(app, "disable_tray"):
                app.disable_tray()

        self._on_toast(_("Settings restored to defaults"), 3)

    def _update_ui_from_settings(self) -> None:
        """Sync all UI widgets with current settings."""
        with self._guard_ui():
            # Voice settings sliders
            self._speed_scale.set_value(self._settings.speech.rate)
            self._pitch_scale.set_value(self._settings.speech.pitch)
            self._volume_scale.set_value(self._settings.speech.volume)

            # Text processing switches
            self._abbr_switch.set_active(self._settings.text.expand_abbreviations)
            self._chars_switch.set_active(self._settings.text.process_special_chars)
            self._fmt_switch.set_active(self._settings.text.strip_formatting)
            self._url_switch.set_active(self._settings.text.process_urls)

            # Character limit combo
            current_limit = self._settings.text.max_chars
            for i, val in enumerate(self._char_limit_values):
                if val == current_limit:
                    self._max_chars_combo.set_selected(i)
                    break

            # Backend combo
            backend_map = {
                TTSBackend.RHVOICE.value: 0,
                TTSBackend.ESPEAK_NG.value: 1,
                TTSBackend.PIPER.value: 2,
                TTSBackend.KOKORO.value: 3,
            }
            current_backend = self._settings.speech.backend
            if current_backend not in backend_map:
                # Handle legacy/removed speech-dispatcher engine
                current_backend = TTSBackend.RHVOICE.value
                self._settings.speech.backend = current_backend
                self._settings_service.save_now()

            backend_idx = backend_map.get(current_backend, 0)
            self._backend_combo.set_selected(backend_idx)

            # Keyboard shortcut
            accel = self._settings.shortcut.keybinding
            self._shortcut_label.set_accelerator("" if accel == "none" else accel)

            # Launcher toggle
            self._launcher_switch_widget.set_active(
                self._settings.shortcut.show_in_launcher
            )

            # History toggle
            self._history_switch_widget.set_active(self._settings.history.enabled)

            # Kokoro controls — visible only when Kokoro is selected
            is_kokoro = current_backend == TTSBackend.KOKORO.value
            self._kokoro_emotion_combo.set_visible(is_kokoro)
            self._kokoro_blend_combo.set_visible(is_kokoro)
            kokoro = self._settings.speech.kokoro
            for i, v in enumerate(self._kokoro_emotion_values):
                if v == kokoro.emotion_preset:
                    self._kokoro_emotion_combo.set_selected(i)
                    break
            if is_kokoro:
                self._populate_kokoro_blend_combo(kokoro.voice_blend)

            # Update KDE shortcut to default
            DesktopIntegrationService.update_khotkeys(accel)

            # Update hero labels
            self._update_hero_labels(self._tts.state)

    def set_launcher_enabled(self, enabled: bool) -> None:
        """Expose launcher toggle state change to other components."""
        if self._launcher_switch_widget:
            self._launcher_switch_widget.set_active(enabled)
            # This will trigger the _on_launcher_toggle callback automatically
