# Orca
#
# Copyright 2005-2009 Sun Microsystems Inc.
# Copyright 2011-2026 Igalia, S.L.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

# pylint: disable=no-member
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-lines

"""Displays a GUI for the user to set Orca preferences."""

from __future__ import annotations

import os
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import gi

gi.require_version("Gtk", "3.0")
from gi.repository import (
    Gdk,  # pylint: disable=no-name-in-module
    Gio,
    GLib,
    GObject,
    Gtk,  # pylint: disable=no-name-in-module
)

from . import (
    braille_presenter,
    chat_presenter,
    command_manager,
    debug,
    document_presenter,
    event_manager,
    focus_manager,
    gsettings_registry,
    guilabels,
    learn_mode_presenter,
    messages,
    mouse_review,
    orca,
    preferences_grid_base,
    presentation_manager,
    profile_manager,
    pronunciation_dictionary_manager,
    say_all_presenter,
    sound_presenter,
    speech_presenter,
    spellcheck_presenter,
    system_information_presenter,
    text_attribute_manager,
    typing_echo_presenter,
)
from .ax_object import AXObject

if TYPE_CHECKING:
    from .scripts import default


@dataclass
class _AppearanceProviders:
    """CSS providers for conditional appearance settings."""

    hc: Gtk.CssProvider
    dark: Gtk.CssProvider
    shapes: Gtk.CssProvider | None = None


class NavigationRow(Gtk.ListBoxRow):
    """ListBoxRow with a panel_id attribute for navigation."""

    def __init__(self, panel_id: str | None = None) -> None:
        super().__init__()
        self.panel_id = panel_id


class OrcaSetupGUI(Gtk.ApplicationWindow):  # pylint: disable=too-many-instance-attributes
    """Preferences window for configuring Orca screen reader settings."""

    WINDOW: OrcaSetupGUI | None = None

    _EVENTS_TO_SUSPEND: tuple[str, ...] = (
        "object:state-changed:checked",
        "object:state-changed:sensitive",
        "object:state-changed:showing",
        "object:children-changed:add",
        "object:children-changed:remove",
        "object:selection-changed",
        "object:property-change:accessible-name",
        "object:property-change:accessible-description",
    )

    def __init__(self, script: default.Script) -> None:
        if OrcaSetupGUI.WINDOW is not None:
            return

        msg = "PREFERENCES: Initializing UI"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        appearance_refs = self._sync_appearance()
        super().__init__(title=guilabels.DIALOG_SCREEN_READER_PREFERENCES)
        self._appearance_refs = appearance_refs

        self.connect("destroy", self.window_destroyed)
        self.connect("delete-event", self.window_closed)

        self._profile_name: str = profile_manager.get_manager().get_active_profile()
        self.script = script
        self._app_name: str | None = None
        if script.app is not None:
            self._app_name = AXObject.get_name(script.app) or None
        self._current_page_title: str = ""
        self._current_panel_id: str | None = None
        self._original_profile: str = profile_manager.get_manager().get_active_profile()

        titlebar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)

        self.left_headerbar = Gtk.HeaderBar()
        self.left_headerbar.set_show_close_button(True)
        self.left_headerbar.set_title(guilabels.DIALOG_SCREEN_READER_PREFERENCES)
        self.left_headerbar.get_style_context().add_class("orca-left-headerbar")

        self._app_icon = Gtk.Image.new_from_icon_name("orca", Gtk.IconSize.MENU)
        self._app_icon.set_margin_start(2)

        self.menu_button = Gtk.MenuButton()
        menu_image = Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)
        self.menu_button.set_image(menu_image)
        self.menu_button.get_accessible().set_name(guilabels.MENU_BUTTON_OPTIONS)

        popover = Gtk.Popover()
        menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

        help_item = Gtk.ModelButton()
        help_item.set_property("text", guilabels.DIALOG_HELP)
        help_item.connect("clicked", lambda _: self.help_button_clicked(None))
        menu_box.pack_start(help_item, False, False, 0)

        self.apply_item = Gtk.ModelButton()
        self.apply_item.set_property("text", guilabels.DIALOG_APPLY)
        self.apply_item.connect("clicked", lambda _: self.apply_button_clicked(None))
        menu_box.pack_start(self.apply_item, False, False, 0)

        self.save_item = Gtk.ModelButton()
        self.save_item.set_property("text", guilabels.BTN_SAVE)
        self.save_item.connect("clicked", lambda _: self.ok_button_clicked(None))
        menu_box.pack_start(self.save_item, False, False, 0)

        # Save Profile As is only for global preferences (profiles are global)
        if not self._app_name:
            save_as_item = Gtk.ModelButton()
            save_as_item.set_property("text", guilabels.PROFILE_SAVE_AS_TITLE)
            save_as_item.connect("clicked", lambda _: self._on_save_profile_as())
            menu_box.pack_start(save_as_item, False, False, 0)

        cancel_item = Gtk.ModelButton()
        cancel_item.set_property("text", guilabels.DIALOG_CANCEL)
        cancel_item.connect("clicked", lambda _: self.cancel_button_clicked(None))
        menu_box.pack_start(cancel_item, False, False, 0)

        menu_box.show_all()
        popover.add(menu_box)
        self.menu_button.set_popover(popover)

        self.left_headerbar.pack_end(self.menu_button)

        titlebar_box.pack_start(self.left_headerbar, False, False, 0)

        titlebar_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
        titlebar_box.pack_start(titlebar_separator, False, False, 0)

        self.panel_headerbar = Gtk.HeaderBar()
        self.panel_headerbar.set_show_close_button(True)
        self.panel_headerbar.set_title("")
        self.panel_headerbar.get_style_context().add_class("orca-panel-headerbar")

        titlebar_box.pack_start(self.panel_headerbar, True, True, 0)

        self._apply_decoration_layout()
        gtk_settings = Gtk.Settings.get_default()  # pylint: disable=no-value-for-parameter
        if gtk_settings is not None:
            gtk_settings.connect(
                "notify::gtk-decoration-layout",
                lambda *_: self._apply_decoration_layout(),
            )

        self.set_titlebar(titlebar_box)

        main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

        self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)

        self.sidebar_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.sidebar_vbox.get_style_context().add_class("orca-sidebar")

        self.sidebar_scrolled = Gtk.ScrolledWindow()
        self.sidebar_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
        self.listbox.get_accessible().set_name(guilabels.PREFERENCES_CATEGORIES)
        self.listbox.connect("row-selected", self._on_row_selected)
        self.sidebar_scrolled.add(self.listbox)
        self.sidebar_vbox.pack_start(self.sidebar_scrolled, True, True, 0)

        self.listbox.connect("key-press-event", self._on_listbox_key_press)

        self.hbox.pack_start(self.sidebar_vbox, False, False, 0)

        separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
        self.hbox.pack_start(separator, False, False, 0)

        self.content_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

        self.stack = Gtk.Stack()
        self.stack.set_hexpand(True)
        self.stack.set_vhomogeneous(False)
        stack_scrolled = Gtk.ScrolledWindow()
        stack_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        stack_scrolled.add(self.stack)
        self.content_vbox.pack_start(stack_scrolled, True, True, 0)

        self.hbox.pack_start(self.content_vbox, True, True, 0)

        main_vbox.pack_start(self.hbox, True, True, 0)

        self.add(main_vbox)

        # Ignore runtime overrides while creating preference grids so that
        # on-the-fly changes (e.g. speech rate) don't influence the values shown.
        registry = gsettings_registry.get_registry()
        registry.set_ignore_runtime(True)

        # Create all preference grids
        # Profiles grid controls are insensitive for app-specific prefs
        prof_manager = profile_manager.get_manager()
        self.profiles_grid = prof_manager.create_preferences_grid(
            profile_loaded_callback=self._on_profile_loaded,
            is_app_specific=bool(self._app_name),
            labels_update_callback=self.update_menu_labels,
            unsaved_changes_checker=self._has_unsaved_changes,
        )
        self.stack.add_named(self.profiles_grid, "profiles")
        self._add_navigation_row("profiles", self.profiles_grid.get_label().get_text())
        self.update_menu_labels()

        presenter = speech_presenter.get_presenter()

        def update_title(title: str) -> None:
            """Update the panel header title and window accessible name."""

            self._set_page_title(title)

        self.speech_grid = presenter.create_speech_preferences_grid(
            title_change_callback=update_title,
            app_name=self._app_name or "",
        )
        self.stack.add_named(self.speech_grid, "speech")
        self._add_navigation_row("speech", self.speech_grid.get_label().get_text())

        braille_pres = braille_presenter.get_presenter()
        self.braille_grid = braille_pres.create_preferences_grid(title_change_callback=update_title)
        self.stack.add_named(self.braille_grid, "braille")
        self._add_navigation_row("braille", self.braille_grid.get_label().get_text())

        sound_pres = sound_presenter.get_presenter()
        self.sound_grid = sound_pres.create_preferences_grid(title_change_callback=update_title)
        self.stack.add_named(self.sound_grid, "sound")
        self._add_navigation_row("sound", self.sound_grid.get_label().get_text())

        cmd_manager = command_manager.get_manager()
        self.keybindings_grid = cmd_manager.create_preferences_grid(
            self.script,
            title_change_callback=update_title,
        )
        self.stack.add_named(self.keybindings_grid, "keybindings")
        self._add_navigation_row("keybindings", self.keybindings_grid.get_label().get_text())

        typing_pres = typing_echo_presenter.get_presenter()
        self.typing_echo_grid = typing_pres.create_preferences_grid()
        self.stack.add_named(self.typing_echo_grid, "typing_echo")
        self._add_navigation_row("typing_echo", self.typing_echo_grid.get_label().get_text())

        mouse_reviewer = mouse_review.get_reviewer()
        self.mouse_grid = mouse_reviewer.create_preferences_grid()
        self.stack.add_named(self.mouse_grid, "mouse")
        self._add_navigation_row("mouse", self.mouse_grid.get_label().get_text())

        doc_presenter = document_presenter.get_presenter()
        self.document_grid = doc_presenter.create_preferences_grid(update_title)
        self.stack.add_named(self.document_grid, "documents")
        self._add_navigation_row("documents", self.document_grid.get_label().get_text())

        say_all_pres = say_all_presenter.get_presenter()
        self.say_all_grid = say_all_pres.create_preferences_grid()
        self.stack.add_named(self.say_all_grid, "say_all")
        self._add_navigation_row("say_all", self.say_all_grid.get_label().get_text())

        pronunciation_manager = pronunciation_dictionary_manager.get_manager()
        self.pronunciation_grid = pronunciation_manager.create_preferences_grid(self.script)
        self.stack.add_named(self.pronunciation_grid, "pronunciation")
        self._add_navigation_row("pronunciation", self.pronunciation_grid.get_label().get_text())

        spellcheck_pres = spellcheck_presenter.get_presenter()
        self.spellcheck_grid = spellcheck_pres.create_preferences_grid()
        self.stack.add_named(self.spellcheck_grid, "spellcheck")
        self._add_navigation_row("spellcheck", self.spellcheck_grid.get_label().get_text())

        chat_pres = chat_presenter.get_presenter()
        self.chat_grid = chat_pres.create_preferences_grid()
        self.stack.add_named(self.chat_grid, "chat")
        self._add_navigation_row("chat", self.chat_grid.get_label().get_text())

        text_attr_mgr = text_attribute_manager.get_manager()
        self.text_attributes_grid = text_attr_mgr.create_preferences_grid()
        self.stack.add_named(self.text_attributes_grid, "text_attributes")
        self._add_navigation_row(
            "text_attributes",
            self.text_attributes_grid.get_label().get_text(),
        )

        system_info_presenter = system_information_presenter.get_presenter()
        self.time_and_date_grid = system_info_presenter.create_time_and_date_preferences_grid()
        self.stack.add_named(self.time_and_date_grid, "time_and_date")
        self._add_navigation_row("time_and_date", self.time_and_date_grid.get_label().get_text())

        registry.set_ignore_runtime(False)

        self._page_to_grid = {
            "speech": self.speech_grid,
            "braille": self.braille_grid,
            "keybindings": self.keybindings_grid,
            "typing_echo": self.typing_echo_grid,
            "say_all": self.say_all_grid,
            "spellcheck": self.spellcheck_grid,
            "chat": self.chat_grid,
            "mouse": self.mouse_grid,
            "documents": self.document_grid,
            "pronunciation": self.pronunciation_grid,
            "sound": self.sound_grid,
            "time_and_date": self.time_and_date_grid,
            "text_attributes": self.text_attributes_grid,
            "profiles": self.profiles_grid,
        }

        for grid in self._page_to_grid.values():
            grid.set_focus_sidebar_callback(self._focus_sidebar)

        first_row = self.listbox.get_row_at_index(0)
        if first_row:
            self.listbox.select_row(first_row)

        msg = "PREFERENCES: Initializing UI complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

    def _apply_decoration_layout(self) -> None:
        """Syncs headerbar button placement and app icon with the system layout."""

        gtk_settings = Gtk.Settings.get_default()  # pylint: disable=no-value-for-parameter
        if gtk_settings is None:
            return

        layout = gtk_settings.get_property("gtk-decoration-layout") or ":minimize,maximize,close"
        left_layout, _, right_layout = layout.partition(":")

        self.left_headerbar.set_decoration_layout(f"{left_layout}:")
        self.panel_headerbar.set_decoration_layout(f":{right_layout}")

        parent = self._app_icon.get_parent()
        if parent:
            parent.remove(self._app_icon)

        if "close" in left_layout:
            self.panel_headerbar.pack_end(self._app_icon)
        else:
            self.left_headerbar.pack_start(self._app_icon)
        self._app_icon.show()

        if self.get_visible():
            GLib.idle_add(self._sync_headerbar_widths)

    def _sync_headerbar_widths(self) -> None:
        headerbar_width = self.left_headerbar.get_allocated_width()
        sidebar_width = self.sidebar_vbox.get_allocated_width()
        max_width = max(headerbar_width, sidebar_width)

        self.left_headerbar.set_size_request(max_width, -1)
        self.sidebar_vbox.set_size_request(max_width, -1)

    def _set_height_from_sidebar(self) -> None:
        """Set window height to fit the sidebar items, capped at 800px."""

        sidebar_height = self.listbox.get_preferred_height()[1]
        headerbar_height = self.left_headerbar.get_allocated_height()
        height = min(sidebar_height + headerbar_height, 800)
        self.resize(self.get_allocated_width(), height)

    def _on_listbox_key_press(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> bool:
        if event.keyval == Gdk.KEY_Tab:
            self.stack.child_focus(Gtk.DirectionType.TAB_FORWARD)
            return True
        if event.keyval == Gdk.KEY_ISO_Left_Tab:
            # Shift+Tab from listbox should go back to menu button
            self.menu_button.grab_focus()
            return True
        return False

    def _focus_sidebar(self) -> None:
        """Move focus to the sidebar navigation listbox."""

        selected_row = self.listbox.get_selected_row()
        if selected_row:
            selected_row.grab_focus()
        else:
            self.listbox.grab_focus()

    def _add_navigation_row(self, panel_id: str, label_text: str) -> NavigationRow:
        """Add a navigation row to the sidebar listbox and return it."""

        row = NavigationRow(panel_id=panel_id)

        label = Gtk.Label(label=label_text, xalign=0)
        label.set_margin_start(12)
        label.set_margin_end(12)
        label.set_margin_top(6)
        label.set_margin_bottom(6)
        row.add(label)

        self.listbox.add(row)
        return row

    def _on_row_selected(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None:
        """Handle listbox row selection and switch to the selected panel."""

        if row is None or not isinstance(row, NavigationRow):
            return

        # Skip if this is a non-selectable header row
        if row.panel_id is None:
            return

        panel_id = row.panel_id

        # Update the panel title in headerbar and accessible name
        if panel_id == "application":
            title = AXObject.get_name(self.script.app)
        else:
            child = self.stack.get_child_by_name(panel_id)
            title = child.get_label().get_text()

        self._set_page_title(title)

        self.stack.set_visible_child_name(panel_id)

        # Only reset multi-page grids when switching to a different panel.
        # This preserves sub-category state when focus returns to the
        # sidebar via Shift-Tab without changing the selected row.
        child = self.stack.get_child_by_name(panel_id)
        if isinstance(child, preferences_grid_base.PreferencesGridBase):
            if panel_id != self._current_panel_id:
                child.on_becoming_visible()

        self._current_panel_id = panel_id

    def _init_gui_state(self, include_profiles: bool = False) -> None:
        """Adjust the settings of the various widgets based on user settings."""

        self._reload_all_grids(include_profiles=include_profiles)

    def show_gui(self) -> None:
        """Show the Orca configuration GUI window."""

        def enable_configuring_mode():
            focus_manager.get_manager().set_in_preferences_window(True)
            return False

        if OrcaSetupGUI.WINDOW is not None:
            OrcaSetupGUI.WINDOW.present()
            return

        OrcaSetupGUI.WINDOW = self

        accel_group = Gtk.AccelGroup()
        OrcaSetupGUI.WINDOW.add_accel_group(accel_group)

        # Select first row and set visible child before showing dialog
        first_row = self.listbox.get_row_at_index(0)
        if first_row and isinstance(first_row, NavigationRow) and first_row.panel_id:
            self.listbox.select_row(first_row)
            panel_id = first_row.panel_id
            # Set initial panel title in headerbar
            if panel_id == "application":
                title = AXObject.get_name(self.script.app)
            else:
                child = self.stack.get_child_by_name(panel_id)
                title = child.get_label().get_text()
            self._set_page_title(title)
            self.stack.set_visible_child_name(panel_id)

        self.suspend_events("Window being shown.")
        OrcaSetupGUI.WINDOW.show_all()
        self._sync_headerbar_widths()
        self._set_height_from_sidebar()
        self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
        self.stack.set_transition_duration(150)
        OrcaSetupGUI.WINDOW.present_with_time(time.time())
        GLib.timeout_add(500, self.resume_events)

        # Set accessible name after window is realized
        def set_accessible_name() -> bool:
            self.update_menu_labels()
            return False

        GLib.idle_add(set_accessible_name)

        # Enable configuring mode after a brief delay to allow initial setup to complete
        GLib.timeout_add(100, enable_configuring_mode)

    def help_button_clicked(self, _widget: Gtk.Button) -> None:
        """Handle Help button click to show preferences help."""

        learn_mode_presenter.get_presenter().show_help(page="preferences")

    def _reload_all_grids(self, include_profiles: bool = False) -> None:
        """Reload all preference grids from settings."""

        registry = gsettings_registry.get_registry()
        registry.set_ignore_runtime(True)
        for grid in self._page_to_grid.values():
            if grid is self.profiles_grid and not include_profiles:
                continue
            grid.reload()
        registry.set_ignore_runtime(False)

    def apply_button_clicked(self, _widget: Gtk.Button) -> None:
        """Handle Apply button click to save and apply preferences."""

        msg = "PREFERENCES: Apply button clicked"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        save_app = self._app_name or ""

        # Save profiles first so any renames happen before saving other settings
        # (profiles are global, not saved for app-specific preferences)
        if not self._app_name:
            self.profiles_grid.save_settings()
            self._profile_name = profile_manager.get_manager().get_active_profile()

        for grid in self._page_to_grid.values():
            if grid is not self.profiles_grid:
                grid.save_settings(self._profile_name, save_app)

        orca.load_user_settings(self.script, skip_reload_message=True)

        # Speak the settings reloaded message after a delay to ensure speech has fully started.
        def speak_settings_reloaded() -> bool:
            presentation_manager.get_manager().speak_message(messages.SETTINGS_RELOADED)
            return False

        GLib.timeout_add(100, speak_settings_reloaded)

        self._reload_all_grids()

        # Re-apply user keybinding overrides and refresh grabs so changes work immediately.
        command_manager.get_manager().activate_commands("Applied preferences")

        msg = "PREFERENCES: Handling Apply button click complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

    def cancel_button_clicked(self, _widget: Gtk.Button) -> None:
        """Handle Cancel button click to close window without saving."""

        msg = "PREFERENCES: Cancel button clicked"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        for grid in self._page_to_grid.values():
            grid.revert_changes()
        self.destroy()

        msg = "PREFERENCES: Handling Cancel button click complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

    def ok_button_clicked(self, widget: Gtk.Button | None = None) -> None:
        """Handle OK button click to save preferences and close window."""

        msg = "PREFERENCES: OK button clicked"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        self.apply_button_clicked(widget)  # type: ignore[arg-type]
        self.destroy()

        msg = "PREFERENCES: Handling OK button click complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

    def _on_save_profile_as(self) -> None:
        """Handle Save Profile As menu item."""

        new_profile = self.profiles_grid.get_new_profile_name()
        if new_profile is None:
            return

        self._profile_name = new_profile[1]
        self.apply_button_clicked(None)
        self._on_profile_loaded(new_profile)

    def window_closed(self, _widget: Gtk.Widget, _event: Any) -> bool:
        """Handle window close signal by suspending events temporarily."""

        msg = "PREFERENCES: Window is being closed"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        has_unsaved_changes = self._has_unsaved_changes(
            include_profiles=not self._app_name,
        )

        # Check if profile was switched during this session
        current_profile = profile_manager.get_manager().get_active_profile()
        profile_was_switched = current_profile != self._original_profile

        if has_unsaved_changes:
            dialog = Gtk.MessageDialog(
                transient_for=self,
                modal=True,
                message_type=Gtk.MessageType.QUESTION,
                buttons=Gtk.ButtonsType.NONE,
                text=guilabels.PREFERENCES_CLOSE_WITHOUT_SAVE,
            )
            dialog.format_secondary_text(guilabels.PREFERENCES_CHANGES_WILL_BE_LOST)

            profile_label = self._get_current_profile_label()
            save_button = dialog.add_button(
                guilabels.MENU_SAVE_PROFILE % profile_label,
                Gtk.ResponseType.YES,
            )
            save_button.get_style_context().add_class("suggested-action")

            # Only show "New Profile" for global preferences (profiles are global)
            if not self._app_name:
                dialog.add_button(guilabels.PROFILE_CREATE_NEW, Gtk.ResponseType.APPLY)

            dialog.add_button(guilabels.BTN_CLOSE_WITHOUT_SAVING, Gtk.ResponseType.NO)
            dialog.add_button(guilabels.DIALOG_CANCEL, Gtk.ResponseType.CANCEL)

            dialog.show_all()
            dialog.present()
            save_button.grab_focus()
            response = dialog.run()
            dialog.destroy()

            if response == Gtk.ResponseType.YES:
                self.apply_button_clicked(None)
            elif response == Gtk.ResponseType.APPLY:
                self._on_save_profile_as()
            elif response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
                return True
            else:
                for grid in self._page_to_grid.values():
                    grid.revert_changes()

        elif profile_was_switched:
            # Profile was switched but no other changes - show simple dialog
            current_label = self._get_current_profile_label()
            original_label = self.profiles_grid.get_profile_label(self._original_profile)
            dialog = Gtk.MessageDialog(
                transient_for=self,
                modal=True,
                message_type=Gtk.MessageType.QUESTION,
                buttons=Gtk.ButtonsType.NONE,
                text=guilabels.PREFERENCES_PROFILE_SWITCHED,
            )

            use_button = dialog.add_button(
                guilabels.PROFILE_USE % current_label,
                Gtk.ResponseType.YES,
            )
            use_button.get_style_context().add_class("suggested-action")
            dialog.add_button(
                guilabels.PROFILE_SWITCH_BACK_TO % original_label,
                Gtk.ResponseType.NO,
            )

            dialog.show_all()
            dialog.present()
            use_button.grab_focus()
            response = dialog.run()
            dialog.destroy()

            if response == Gtk.ResponseType.NO:
                profile_manager.get_manager().load_profile(self._original_profile)

        self.suspend_events("Window being closed.")
        GObject.timeout_add(1000, self.resume_events)

        msg = "PREFERENCES: Window closure complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        return False

    _BASE_CSS = b"""
        decoration {
            border-radius: 15px;
        }
        @define-color orca_sidebar_bg shade(@theme_bg_color, 0.98);
        list.frame {
            border-color: alpha(@theme_fg_color, 0.15);
        }
        .orca-left-headerbar {
            background-color: @orca_sidebar_bg;
            background-image: none;
            border-bottom-width: 0;
        }
        .orca-panel-headerbar {
            background-color: @theme_bg_color;
            background-image: none;
            border-bottom-width: 0;
        }
        .orca-sidebar {
            background-color: @orca_sidebar_bg;
        }
        .orca-sidebar scrolledwindow,
        .orca-sidebar list {
            background-color: transparent;
        }
        .orca-sidebar list {
            padding: 6px 0;
        }
        .orca-sidebar list row {
            border-radius: 9px;
            min-height: 36px;
            padding: 0 8px;
            margin: 0 6px 2px;
        }
        .orca-sidebar list row:selected {
            background-color: alpha(@theme_fg_color, 0.10);
        }
        .orca-sidebar list row:selected,
        .orca-sidebar list row:selected label {
            color: @theme_fg_color;
        }
        .orca-sidebar list row:hover {
            background-color: alpha(@theme_fg_color, 0.07);
        }
        .orca-sidebar list row:selected:hover {
            background-color: alpha(@theme_fg_color, 0.13);
        }
        .orca-sidebar list row:active,
        .orca-sidebar list row:selected:active {
            background-color: alpha(@theme_fg_color, 0.19);
        }
        list.frame row:focus {
            box-shadow: inset 0 0 0 2px alpha(@theme_selected_bg_color, 0.5);
        }
    """

    _HIGH_CONTRAST_CSS = b"""
        list.frame {
            border-color: alpha(@theme_fg_color, 0.4);
        }
        list separator {
            background-color: alpha(@theme_fg_color, 0.4);
        }
        .dim-label {
            opacity: 1.0;
        }
    """

    _DARK_MODE_CSS = b"""
        @define-color orca_sidebar_bg @theme_bg_color;
        window.background {
            background-color: @theme_base_color;
        }
        .orca-panel-headerbar {
            background-color: @theme_base_color;
        }
        switch slider {
            background-image: image(white);
        }
    """

    _GNOME_DARK_CSS = b"""
        @define-color theme_bg_color #303030;
        @define-color theme_base_color #242424;
        @define-color orca_sidebar_bg #2e2e32;
    """

    _STATUS_SHAPES_CSS = b"""
        switch image {
            color: @theme_fg_color;
        }
        switch:checked image {
            color: white;
        }
    """

    @staticmethod
    def _sync_appearance() -> tuple | None:
        """Bridges GNOME's color-scheme and high-contrast gsettings to GTK3."""

        gtk_settings = Gtk.Settings.get_default()  # pylint: disable=no-value-for-parameter
        screen = Gdk.Screen.get_default()  # pylint: disable=no-value-for-parameter
        if gtk_settings is None or screen is None:
            return None

        base_provider = Gtk.CssProvider()
        base_provider.load_from_data(OrcaSetupGUI._BASE_CSS)
        Gtk.StyleContext.add_provider_for_screen(
            screen,
            base_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
        )

        providers = _AppearanceProviders(
            hc=Gtk.CssProvider(),
            dark=Gtk.CssProvider(),
        )
        providers.hc.load_from_data(OrcaSetupGUI._HIGH_CONTRAST_CSS)
        dark_css = OrcaSetupGUI._DARK_MODE_CSS
        if "GNOME" in os.environ.get("XDG_CURRENT_DESKTOP", ""):
            dark_css += OrcaSetupGUI._GNOME_DARK_CSS
        providers.dark.load_from_data(dark_css)

        try:
            interface_settings = Gio.Settings(schema_id="org.gnome.desktop.interface")
            a11y_settings = Gio.Settings(schema_id="org.gnome.desktop.a11y.interface")
            if "show-status-shapes" in a11y_settings.list_keys():
                shapes_provider = Gtk.CssProvider()
                shapes_provider.load_from_data(OrcaSetupGUI._STATUS_SHAPES_CSS)
                providers.shapes = shapes_provider
            OrcaSetupGUI._apply_appearance(
                interface_settings,
                a11y_settings,
                gtk_settings,
                screen,
                providers,
            )

            def on_setting_changed(*_args):
                OrcaSetupGUI._apply_appearance(
                    interface_settings,
                    a11y_settings,
                    gtk_settings,
                    screen,
                    providers,
                )

            interface_settings.connect("changed::color-scheme", on_setting_changed)
            a11y_settings.connect("changed::high-contrast", on_setting_changed)
            if providers.shapes is not None:
                a11y_settings.connect("changed::show-status-shapes", on_setting_changed)
            gtk_settings.connect("notify::gtk-theme-name", on_setting_changed)
            return interface_settings, a11y_settings, base_provider, providers
        except GLib.Error as error:
            msg = f"PREFERENCES WINDOW: Exception syncing appearance: {error}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return None

    @staticmethod
    def _apply_appearance(
        interface_settings: Gio.Settings,
        a11y_settings: Gio.Settings,
        gtk_settings: Gtk.Settings,
        screen: Gdk.Screen,
        providers: _AppearanceProviders,
    ) -> None:
        """Applies color-scheme, high-contrast, and status-shapes settings."""

        prefer_dark = interface_settings.get_string("color-scheme") == "prefer-dark"
        gtk_settings.set_property("gtk-application-prefer-dark-theme", prefer_dark)

        theme = gtk_settings.get_property("gtk-theme-name")
        if prefer_dark and theme == "HighContrast":
            gtk_settings.set_property("gtk-theme-name", "HighContrastInverse")
        elif not prefer_dark and theme == "HighContrastInverse":
            gtk_settings.set_property("gtk-theme-name", "HighContrast")

        priority = Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1
        conditional_providers: list[tuple[Gtk.CssProvider, bool]] = [
            (providers.hc, a11y_settings.get_boolean("high-contrast")),
            (providers.dark, prefer_dark),
        ]
        if providers.shapes is not None:
            conditional_providers.append(
                (providers.shapes, a11y_settings.get_boolean("show-status-shapes")),
            )
        for provider, enabled in conditional_providers:
            if enabled:
                Gtk.StyleContext.add_provider_for_screen(screen, provider, priority)
            else:
                Gtk.StyleContext.remove_provider_for_screen(screen, provider)

    def window_destroyed(self, _widget: Gtk.Widget) -> None:
        """Handle window destroyed signal by clearing window reference."""

        msg = "PREFERENCES WINDOW: Window is being destroyed"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        OrcaSetupGUI.WINDOW = None

        msg = "PREFERENCES WINDOW: Window destruction complete"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        focus_manager.get_manager().set_in_preferences_window(False)

    def _has_unsaved_changes(self, include_profiles: bool = False) -> bool:
        """Returns True if any preference grid has unsaved changes."""

        for name, grid in self._page_to_grid.items():
            if grid is self.profiles_grid and not include_profiles:
                continue
            if grid.has_changes():
                msg = f"PREFERENCES: Grid '{name}' has unsaved changes"
                debug.print_message(debug.LEVEL_INFO, msg, True)
                return True
        return False

    def resume_events(self, reason: str = "") -> bool:
        """Re-register event listeners suspended during UI creation and teardown."""

        msg = f"PREFERENCES: Re-registering events. {reason}"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        manager = event_manager.get_manager()
        for event in self._EVENTS_TO_SUSPEND:
            manager.register_listener(event)
        return False

    def suspend_events(self, reason: str = "") -> None:
        """Deregister event listeners that flood Orca during UI creation and teardown."""

        msg = f"PREFERENCES: Suspending events. {reason}"
        debug.print_message(debug.LEVEL_ALL, msg, True)

        manager = event_manager.get_manager()
        for event in self._EVENTS_TO_SUSPEND:
            manager.deregister_listener(event)

    def _get_current_profile_label(self) -> str:
        """Get the display label for the current profile, including pending renames."""

        return self.profiles_grid.get_current_profile_label()

    def _get_panel_title(self, page_title: str) -> str:
        """Build the panel header title with profile in parentheses."""

        profile_label = self._get_current_profile_label()
        return f"{page_title} ({profile_label})"

    def _set_page_title(self, title: str) -> None:
        """Set the current page title, updating panel header and accessible name."""

        self._current_page_title = title
        self.panel_headerbar.set_title(self._get_panel_title(title))
        self.get_accessible().set_name(self._get_accessible_name(title))

    def _get_accessible_name(self, page_title: str = "") -> str:
        """Build the accessible name for the window."""

        if self._app_name:
            base_title = guilabels.PREFERENCES_APPLICATION_TITLE % self._app_name
        else:
            base_title = guilabels.DIALOG_SCREEN_READER_PREFERENCES_ACCESSIBLE

        profile_label = self._get_current_profile_label()

        if page_title:
            return f"{base_title}, {page_title}, {profile_label}"
        return f"{base_title}, {profile_label}"

    def update_menu_labels(self) -> None:
        """Update Apply and Save menu items and panel title to show the current profile."""

        profile_label = self._get_current_profile_label()
        self.apply_item.set_property("text", guilabels.MENU_APPLY_PROFILE % profile_label)
        self.save_item.set_property("text", guilabels.MENU_SAVE_PROFILE % profile_label)

        if self._app_name:
            self.left_headerbar.set_subtitle(self._app_name)
        else:
            self.left_headerbar.set_subtitle(None)

        if self._current_page_title:
            self._set_page_title(self._current_page_title)

    def _on_profile_loaded(self, profile: list[str]) -> None:
        """Handle profile loaded callback and reload all preference grids."""

        if not self.get_realized():
            return

        self._profile_name = profile[1]
        self._init_gui_state(include_profiles=False)
        self.update_menu_labels()
