"""
sshPilot - SSH connection manager with integrated terminal
Main application entry point
"""

import sys
import os
import gc
import faulthandler
import resource
import logging
import threading


def _clamp_thirdparty_loggers() -> None:
    """Pin chatty third-party loggers to WARNING.

    Called BEFORE any other module is imported so that keyring/etc.
    don't dump DEBUG noise to the root logger at import-time, which happens
    before :meth:`SshPilotApplication.setup_logging` has had a chance to run.
    Re-applied (idempotently) inside setup_logging so direct callers of that
    method are also covered.
    """
    for noisy in (
        'keyring', 'keyring.backend',
        'gi', 'PIL', 'urllib3', 'asyncio',
    ):
        logging.getLogger(noisy).setLevel(logging.WARNING)


_clamp_thirdparty_loggers()

# Module logger — used for any non-class-attached logging below.
logger = logging.getLogger(__name__)

import gi
gi.require_version('Adw', '1')
gi.require_version('Gtk', '4.0')
gi.require_version('Vte', '3.91')

from gi.repository import Adw, Gtk, Gio, GLib, Gdk
from .shortcut_utils import (
    DOUBLE_SHIFT_SHORTCUT,
    TOGGLE_FULLSCREEN_ACTION,
    default_fullscreen_shortcuts,
)

# Build-time paths written by Meson at install time. Absent in a setuptools /
# editable checkout, where we fall back to package-relative discovery.
try:
    from .build_config import PKGDATADIR as _PKGDATADIR, LOCALEDIR as _LOCALEDIR
except Exception:  # pragma: no cover - only present in a Meson install
    _PKGDATADIR = None
    _LOCALEDIR = None


def _init_gettext():
    """Bind the ``sshpilot`` text domain so the existing ``_()`` calls resolve
    translations. Uses the Meson-configured localedir when available, else the
    system default. Harmless when no catalogs are installed (``_()`` is identity)."""
    import gettext as _gettext
    # Preferences ▸ Interface ▸ Language, exported before the first lookup:
    # gettext caches the catalogue it resolves, so this cannot be moved later.
    localedir = None
    try:
        from .i18n import apply_language, get_localedir
        apply_language()
        # get_localedir() picks the first candidate that actually holds a
        # catalogue -- Meson's localedir, else the copy bundled in the package.
        # Testing _LOCALEDIR for existence instead would bind an empty tree
        # whenever Meson installed the directory but no .mo into it, leaving the
        # UI English while the picker still offered the language.
        localedir = get_localedir()
    except Exception as exc:  # pragma: no cover - defensive
        logger.debug("language preference not applied: %s", exc)
        localedir = _LOCALEDIR if (_LOCALEDIR and os.path.isdir(_LOCALEDIR)) else None
    try:
        _gettext.bindtextdomain('sshpilot', localedir)
        _gettext.textdomain('sshpilot')
        # Mirror to the C library so GTK/Adw (.ui, .desktop) translations resolve too.
        try:
            import locale
            # setlocale first: until the process leaves the default "C" locale,
            # the C library's gettext returns every string untranslated, so the
            # GtkBuilder strings from the .ui files stay English while the
            # Python _() ones (pure-Python gettext, env-driven) come out
            # translated. Binding the domain without this is a no-op for GTK.
            try:
                locale.setlocale(locale.LC_ALL, '')
            except locale.Error:
                pass  # unset/ungenerated locale — leave C, Python side still works
            locale.bindtextdomain('sshpilot', localedir)
            locale.textdomain('sshpilot')
        except Exception:
            pass
    except Exception as exc:  # pragma: no cover - defensive
        logger.debug("gettext init skipped: %s", exc)


_init_gettext()


# Register resources before importing any UI modules
def load_resources():
    # Prefer the Meson-installed gresource in $pkgdatadir, then the bundled copy
    # inside the package (setuptools/editable), then the legacy system path.
    current_dir = os.path.dirname(os.path.abspath(__file__))
    possible_paths = []
    if _PKGDATADIR:
        possible_paths.append(
            os.path.join(_PKGDATADIR, 'io.github.mfat.sshpilot.gresource'))
    possible_paths += [
        os.path.join(current_dir, 'resources', 'sshpilot.gresource'),
        '/usr/share/io.github.mfat.sshpilot/io.github.mfat.sshpilot.gresource',
    ]

    for path in possible_paths:
        if os.path.exists(path):
            try:
                resource = Gio.Resource.load(path)
                Gio.resources_register(resource)
                logger.debug("Loaded resources from: %s", path)

                # Add resource path to icon theme EARLY, before any UI is created
                # Following GNOME docs: https://developer.gnome.org/documentation/tutorials/themed-icons.html
                # and GTK4 API: https://docs.gtk.org/gtk4/class.IconTheme.html
                # We use set_resource_path() to prepend our base path so bundled icons are checked first
                try:
                    display = Gdk.Display.get_default()
                    if display:
                        theme = Gtk.IconTheme.get_for_display(display)
                        # Get existing paths using get_resource_path() API
                        existing_paths = list(theme.get_resource_path())
                        base_path = "/io/github/mfat/sshpilot/icons"
                        if base_path not in existing_paths:
                            # Prepend our path using set_resource_path() API (replaces all paths)
                            new_paths = [base_path] + existing_paths
                            theme.set_resource_path(new_paths)
                            logger.debug(
                                "Set icon theme resource paths (bundled first): %s...",
                                new_paths[:2],
                            )
                        elif existing_paths[0] != base_path:
                            # Already added, but ensure it's first using set_resource_path()
                            new_paths = [base_path] + [p for p in existing_paths if p != base_path]
                            theme.set_resource_path(new_paths)
                            logger.debug("Reordered resource paths to prioritize bundled icons")
                        else:
                            logger.debug("Bundled icon path already first: %s", base_path)
                except Exception as e:
                    logger.warning("Could not configure icon theme for bundled icons: %s", e)

                return True
            except GLib.Error as e:
                logger.error("Failed to load resources from %s: %s", path, e)
    logger.error("Could not load GResource bundle")
    return False

if not load_resources():
    sys.exit(1)

# Patch Gtk.Image to automatically prefer bundled icons
from .icon_utils import patch_gtk_image
patch_gtk_image()

from .platform_utils import is_macos, get_data_dir, get_state_dir
from .file_manager_integration import should_hide_file_manager_options
from .startup_info import print_startup_info

class SshPilotApplication(Adw.Application):
    """Main application class for sshPilot"""

    def __init__(self, verbose: bool = False, quiet: bool = False,
                 isolated: bool = False, log_gtk_warnings: bool = False,
                 fatal_warnings: bool = False,
                 application_id: str | None = None):
        super().__init__(
            application_id=application_id or 'io.github.mfat.sshpilot',
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
        )

        # GTK is not thread-safe: Python's *cyclic* garbage collector can run on
        # any thread (e.g. an SFTP worker thread during repeated saves) and would
        # then dispose GObject reference-cycles — including GTK widgets — off the
        # main thread, which segfaults (observed on macOS). Disable the cyclic
        # collector and drive it from a main-thread timer so GObject finalization
        # only ever happens on the main thread. (Refcount-based finalization is
        # unaffected, so non-cyclic resources still free promptly.)
        gc.disable()
        self._gc_pending = False
        self._gc_timer_id = GLib.timeout_add_seconds(5, self._collect_garbage)

        # Command line verbosity overrides — mutually exclusive at argparse,
        # but defensively normalise here too.
        self.verbose_override = verbose and not quiet
        self.quiet_override = quiet and not verbose
        # ``--isolated`` is a semantic startup request.  It is submitted to
        # the daemon after the typed client is ready; GTK must not use it (or
        # config.json) to select an SSH filesystem path.
        self.isolated_mode = isolated
        # App-launched daemons inherit this so ``--verbose`` reaches sshpilotd
        # (stdout/stderr are redirected to DEVNULL; file logging uses the flag).
        if self.verbose_override:
            os.environ["SSHPILOT_DAEMON_VERBOSE"] = "1"
        else:
            os.environ.pop("SSHPILOT_DAEMON_VERBOSE", None)
        if self.quiet_override:
            os.environ["SSHPILOT_DAEMON_QUIET"] = "1"
        else:
            os.environ.pop("SSHPILOT_DAEMON_QUIET", None)

        # Session-scoped dismissals for the post-connect save prompt
        from .unsaved_host import SavePromptDismissals
        self.save_prompt_dismissals = SavePromptDismissals()

        # Set up logging
        self.setup_logging()

        # Arm fatal-signal diagnostics now that logging is configured but before
        # any window/GTK activity, so a future shutdown segfault leaves an
        # all-thread Python traceback in crash.log. Returns a preserved report
        # from a previous crash (or None) for the window to surface on startup.
        self._previous_crash_report = _enable_crash_diagnostics()

        # Log uncaught Python exceptions (main thread, worker threads, and GLib
        # callbacks) into the log files so they land in bug reports.
        _install_exception_hooks()

        # Capture GTK/GLib warnings & criticals into the log files by default —
        # they name the exact bad widget/render operation. --log-gtk-warnings /
        # --diagnostics additionally capture lower-severity (info/debug) GTK
        # messages; --fatal-warnings makes them fatal (for pinpointing crashes).
        _install_gtk_log_capture(verbose=log_gtk_warnings)
        if fatal_warnings:
            _enable_fatal_gtk_warnings()

        # Startup diagnostics are deferred to idle (scheduled at the end of
        # __init__) so their probing — ssh/sshpass version spawns and a Secret
        # Service D-Bus connect — never blocks the pre-window main thread.

        # Apply saved application theme (light/dark/system)
        self.config = None
        self._default_shortcuts = {}
        self._action_order = []
        # Names that appear in the shortcut editor and respect config overrides
        # but are NOT applied via set_accels_for_action — they are triggered by a
        # dedicated handler (e.g. SplitViewTab's CAPTURE-phase key controller).
        # Claiming their accelerators globally would stop the keys reaching a
        # normal terminal outside that context. See register_custom_shortcut.
        self._custom_shortcut_names = set()
        self._accelerators_enabled = True
        self.accelerators_enabled = True
        self._config_handler = None

        try:
            from .config import Config
            cfg = Config()
            self.config = cfg
            try:
                self._accelerators_enabled = not bool(cfg.get_setting('terminal.pass_through_mode', False))
            except Exception:
                self._accelerators_enabled = True
            self._update_accelerators_enabled_flag()
            if hasattr(cfg, 'connect'):
                try:
                    self._config_handler = cfg.connect('setting-changed', self._on_config_setting_changed)
                except Exception:
                    self._config_handler = None
            saved_theme = str(cfg.get_setting('app-theme', 'default'))
            style_manager = Adw.StyleManager.get_default()
            if saved_theme == 'light':
                style_manager.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT)
            elif saved_theme == 'dark':
                style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK)
            else:
                style_manager.set_color_scheme(Adw.ColorScheme.DEFAULT)

            # Apply color overrides
            self.apply_color_overrides(cfg)

        except Exception:
            pass

        # Create actions with keyboard shortcuts
        # Use platform-specific shortcuts for better macOS compatibility
        mac = is_macos()

        if mac:
            # macOS-specific shortcuts using Meta key (Command key)
            self.create_action('quit', self.on_quit_action, ['<Meta><Shift>q'])
            self.create_action('new-connection', self.on_new_connection, ['<Meta>n'])
            self.create_action('open-new-connection-tab', self.on_open_new_connection_tab, ['<Meta><Alt>n'])
            self.create_action('toggle-list', self.on_toggle_list, ['<Meta><Shift>l'])
            self.create_action('search', self.on_search, ['<Meta>f'])
            self.create_action('terminal-search', self.on_terminal_search, ['<Meta><Shift>f'])
            self.create_action('new-key', self.on_new_key, ['<Meta><Shift>k'])
            self.create_action('edit-ssh-config', self.on_edit_ssh_config, ['<Meta><Shift>e'])
            if not should_hide_file_manager_options():
                self.create_action('manage-files', self.on_manage_files, ['<Meta><Shift>o'])
            logger.debug("Using macOS-specific shortcuts (Meta key = Command key)")
        else:
            # Linux/Windows shortcuts using Primary key.
            # Several defaults are disabled ([]) or rebound to avoid clashing with
            # CLI tools / keyboard layouts; "safe" = combos GNOME Terminal/Ptyxis
            # also grab (so a terminal never forwards them to the shell). Disabled
            # actions stay listed/assignable in the shortcut editor.
            self.create_action('quit', self.on_quit_action, ['<primary><shift>q'])
            # Ctrl+N clashes with readline/apps → rebind to Ctrl+Shift+N.
            self.create_action('new-connection', self.on_new_connection, ['<primary><shift>n'])
            # Ctrl+Alt+N clashes with AltGr/WM → disabled by default.
            self.create_action('open-new-connection-tab', self.on_open_new_connection_tab, [])
            # Not a terminal-standard combo → disabled by default.
            self.create_action('toggle-list', self.on_toggle_list, [])
            self.create_action('search', self.on_search, ['<primary>f'])
            self.create_action('terminal-search', self.on_terminal_search, ['<primary><shift>f'])
            self.create_action('new-key', self.on_new_key, ['<primary><shift>k'])
            # Not a terminal-standard combo → disabled by default.
            self.create_action('edit-ssh-config', self.on_edit_ssh_config, [])
            if not should_hide_file_manager_options():
                self.create_action('manage-files', self.on_manage_files, ['<primary><shift>o'])
            logger.debug("Using Linux/Windows shortcuts (Primary key = Ctrl key)")

        # Double Shift is handled by MainWindow's key controller because GTK
        # accelerators represent key combinations, not multi-press gestures.
        # A normal accelerator assigned in the shortcut editor is applied to
        # this action by _apply_shortcut_for_action.
        self.create_action(
            'omnisearch', self.on_omnisearch, [DOUBLE_SHIFT_SHORTCUT]
        )

        # Sidebar toggle is a window action; register its default accels so
        # it shows up in the shortcut editor and respects overrides.
        self.register_window_shortcut(
            'toggle_sidebar', ['F9', '<Meta>b'] if mac else ['F9']
        )

        # Fullscreen is likewise a window action. Registering it here is what
        # puts it in the overview and the customizer and makes the registry —
        # not a hard-coded key handler — the single owner of the accelerator.
        self.register_window_shortcut(
            TOGGLE_FULLSCREEN_ACTION, default_fullscreen_shortcuts(mac)
        )

        # Detailed shortcut list is verbose noise at INFO. Help → Keyboard
        # Shortcuts already shows this to users.
        if logger.isEnabledFor(logging.DEBUG):
            mod_label = "Cmd" if mac else "Ctrl"
            logger.debug("Registered keyboard shortcuts:")
            logger.debug("  %s+N: new-connection", mod_label)
            logger.debug("  %s+Shift+K: new-key", mod_label)
        if mac:
            self.create_action('local-terminal', self.on_local_terminal, ['<Meta><Shift>t'])
            self.create_action('preferences', self.on_preferences, ['<Meta>comma'])
            self.create_action('tab-close', self.on_tab_close, ['<Meta><Shift>w'])
            self.create_action('broadcast-command', self.on_broadcast_command, ['<Meta><Shift>b'])
            self.create_action('new-split-view-tab', self.on_new_split_view_tab, ['<Meta><Shift>s'])
        else:
            self.create_action('local-terminal', self.on_local_terminal, ['<primary><shift>t'])
            self.create_action('preferences', self.on_preferences, ['<primary>comma'])
            self.create_action('tab-close', self.on_tab_close, ['<primary><shift>w'])
            self.create_action('broadcast-command', self.on_broadcast_command, ['<primary><shift>b'])
            # App-specific combo, not terminal-standard → disabled by default.
            self.create_action('new-split-view-tab', self.on_new_split_view_tab, [])
        
        self.create_action('about', self.on_about)
        self.create_action('help', self.on_help, ['F1'])
        # Use the "question" keyval, not "<Shift>slash": holding Shift turns
        # "/" into "?", so the shifted-slash accelerator never matches the
        # actual key event. "<primary>question" is the standard binding that
        # fires on Ctrl+Shift+/ (Cmd+Shift+/ on macOS).
        shortcuts_accel = ['<Meta>question'] if mac else ['<primary>question']
        self.create_action('shortcuts', self.on_shortcuts, shortcuts_accel)
        # Tab navigation accelerators
        self.create_action('tab-next', self.on_tab_next, ['<primary>Page_Down'])
        self.create_action('tab-prev', self.on_tab_prev, ['<primary>Page_Up'])
        # Move the current tab within the tab bar
        self.create_action('tab-move-left', self.on_tab_move_left, ['<primary><shift>Page_Up'])
        self.create_action('tab-move-right', self.on_tab_move_right, ['<primary><shift>Page_Down'])

        # Tab overview accelerator
        if mac:
            self.create_action('tab-overview', self.on_tab_overview, ['<Meta><Shift>Tab'])
        else:
            self.create_action('tab-overview', self.on_tab_overview, ['<primary><shift>Tab'])

        # Split-view shortcuts: editor-listed and override-able, triggered by
        # SplitViewTab's CAPTURE-phase handler (not global accelerators).
        # Disabled ([]) by default — the original Ctrl+Alt+HJKL / Ctrl+Alt+Shift
        # / Ctrl+Shift combos clashed with CLI tools and keyboard layouts. They
        # remain listed in the editor so users can assign their own. See
        # SplitViewTab._on_key_pressed.
        for _name in (
            'split-focus-left', 'split-focus-down', 'split-focus-up', 'split-focus-right',
            'split-resize-left', 'split-resize-down', 'split-resize-up', 'split-resize-right',
            'split-layout-horizontal', 'split-layout-vertical', 'split-add-pane',
        ):
            self.register_custom_shortcut(_name, [])

        # Connect to signals
        self.connect('shutdown', self.on_shutdown)
        self.connect('activate', self.on_activate)

        # Ensure Ctrl (⌘ on macOS)+C (SIGINT) follows the SAME path as clicking the window close button
        try:
            import signal

            def _handle_sigint(signum, frame):
                def _close_active_window():
                    win = self.props.active_window
                    if win:
                        try:
                            win.close()  # triggers MainWindow.on_close_request
                        except Exception:
                            pass
                    else:
                        try:
                            self.quit()
                        except Exception:
                            pass
                    return False
                GLib.idle_add(_close_active_window)
            signal.signal(signal.SIGINT, _handle_sigint)
        except Exception:
            pass

        # Initialize window reference
        self.window = None

        # Emit startup diagnostics once the daemon client selection has settled
        # (see _schedule_startup_diagnostics). The secrets diagnostic reads the
        # daemon through the window's secrets_controller/client — the app
        # Config object has neither, so the window is passed, not self.config.
        GLib.idle_add(lambda: (self._schedule_startup_diagnostics(), False)[1])

        logger.info("sshPilot application initialized")

    def do_startup(self):
        """Install the native macOS menubar once the application is registered.

        ``Gtk.Application.set_menubar`` requires the application to be
        registered (``gtk_application_set_menubar`` asserts on
        ``g_application_get_is_registered``), which only happens during
        startup — calling it from ``__init__`` made real macOS builds emit
        that assertion at launch. ``Adw.Application.do_startup`` runs the
        normal GApplication startup sequence first (the ``super()`` proxy
        does not bind ``self`` for vfuncs, so the parent is chained up
        explicitly), then the menubar is installed exactly once. Non-macOS
        builds never call ``install_menubar``.
        """
        Adw.Application.do_startup(self)

        if getattr(self, "_macos_menubar_installed", False):
            return
        if not is_macos():
            return
        try:
            from .macos_menubar import install_menubar

            install_menubar(self)
            self._macos_menubar_installed = True
        except Exception as exc:
            logger.error("Failed to install macOS menubar: %s", exc)

    def _schedule_startup_diagnostics(self, retries_left: int = 25) -> None:
        """Print startup diagnostics once the daemon client selection settles.

        Client selection (``MainWindow._begin_daemon_client_selection``) runs
        asynchronously, so ``window.secrets_controller``/``window.client`` are
        not populated on the first main-loop iteration. Printing immediately
        used to make the Secure Storage section fabricate "not available" for
        a daemon that was simply still connecting. Poll briefly (~5s) for the
        selection to finish; startup_info reports an explicit "unknown" state
        rather than a false negative if it never does.
        """
        window = getattr(self, "window", None)
        pending = (
            True if window is None
            else bool(getattr(window, "_api_client_selection_pending", False))
        )
        if pending and retries_left > 0:
            GLib.timeout_add(
                200,
                lambda: (
                    self._schedule_startup_diagnostics(retries_left - 1),
                    False,
                )[1],
            )
            return
        # print_startup_info() reads the secret backend registry through a
        # blocking daemon RPC (SecretBackendsController.load_registry());
        # building that registry can shell out to e.g. `bw login --check`,
        # which alone has been observed to take 3+ seconds. Called directly
        # from this GLib timeout callback that froze the whole GTK main loop
        # for the duration — nothing here touches widgets, so run it off the
        # main thread instead.
        confirmed_mode = getattr(window, "_confirmed_operation_mode", None)
        threading.Thread(
            target=print_startup_info,
            kwargs=dict(
                isolated=self.isolated_mode,
                verbose=self.verbose_override,
                config=window,
                confirmed_mode=confirmed_mode,
            ),
            daemon=True,
        ).start()

    def _load_app_style(self):
        """Load the bundled application stylesheet once, at APPLICATION priority."""
        display = Gdk.Display.get_default()
        if display is None or getattr(display, '_sshpilot_app_style_loaded', False):
            return
        try:
            provider = Gtk.CssProvider()
            provider.load_from_resource('/io/github/mfat/sshpilot/style.css')
            Gtk.StyleContext.add_provider_for_display(
                display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
            display._sshpilot_app_style_loaded = True
        except Exception as exc:
            logger.warning("Failed to load application stylesheet: %s", exc)

    def on_activate(self, app):
        """Handle application activation"""
        self._load_app_style()
        # Create a new window if one doesn't exist
        if not self.window or not self.window.get_visible():
            from .window import MainWindow
            self.window = MainWindow(application=app, isolated=self.isolated_mode)
            self.window.present()

            # The window UI is built and presented and plugins are bound, so
            # it is now safe for plugins to do live UI/terminal work.
            try:
                host = getattr(self.window, 'plugin_host', None)
                if host is not None:
                    host.dispatch_app_started()
            except Exception:
                logger.exception("Plugin app_started dispatch failed")
        else:
            # Secondary activation / CLI handoff: surface the existing window.
            try:
                self.window.present()
            except Exception:
                pass

        # If a session-backed secret backend (Bitwarden/Vaultwarden) is selected and
        # locked, prompt to unlock it now (password dialog + spinner) so the vault is
        # ready before the first connection. Scheduled on idle so it runs after the
        # connection manager's deferred backend-selection init.
        try:
            win = self.window or self.props.active_window
            if win is not None:
                from . import secret_unlock_dialog
                GLib.idle_add(secret_unlock_dialog.unlock_at_startup, win)
        except Exception as exc:
            logger.debug(f"Failed to schedule startup vault unlock: {exc}")

        try:
            from .xterm_prewarm import schedule_xterm_prewarm
            schedule_xterm_prewarm(self.config)
        except Exception as exc:
            logger.debug(f"Failed to schedule PyXterm prewarm: {exc}")

    def install_api_event_subscription(self, client) -> bool:
        """Subscribe once for the application-scoped daemon client.

        Returns ``False`` when the candidate client cannot be subscribed, so
        reconnect can abort before publishing a mixed client graph.
        """

        self.clear_api_event_subscription()

        set_lost = getattr(client, "set_on_transport_lost", None)
        if callable(set_lost):
            set_lost(
                lambda error, source_client=client: GLib.idle_add(
                    self._on_daemon_transport_lost,
                    error,
                    source_client,
                )
            )

        def _on_event(event):
            from .api.events import EventType

            if event.type in {
                EventType.CONNECTION_CREATED,
                EventType.CONNECTION_UPDATED,
                EventType.CONNECTION_DELETED,
                EventType.ERROR_OCCURRED,
            }:
                GLib.idle_add(self._handle_api_client_event, event.type)
            elif event.type in {
                EventType.SESSION_CREATED,
                EventType.SESSION_STATE_CHANGED,
                EventType.SESSION_EXITED,
                EventType.SESSION_CLOSED,
            }:
                GLib.idle_add(self._handle_api_session_event, event)

        try:
            self._api_event_subscription = client.subscribe_events(_on_event)
        except Exception as error:
            self._api_event_subscription = None
            logger.warning(
                "Application API event subscription failed type=%s code=%s detail=%s",
                type(error).__name__,
                getattr(getattr(error, "code", None), "value", None),
                getattr(error, "message", str(error)),
            )
            return False
        return True

    def _daemon_reconnect_suppressed(self) -> bool:
        """True when reconnect must not run (intentional terminate / quit)."""
        if getattr(self, "_daemon_shutdown_intent", None) == "terminate":
            return True
        from .daemon_quit_policy import DaemonQuitDecision

        if getattr(self, "_daemon_quit_decision", None) is DaemonQuitDecision.TERMINATE_ALL:
            return True
        window = self.window
        if window is None:
            return False
        if getattr(window, "_is_quitting", False):
            return True
        if getattr(window, "_daemon_shutdown_intent", None) == "terminate":
            return True
        if getattr(window, "_daemon_quit_decision", None) is DaemonQuitDecision.TERMINATE_ALL:
            return True
        return False

    def cancel_daemon_reconnect(self, *, reason: str = "shutdown") -> None:
        """Abort in-flight / pending reconnect (Terminate everything / quit)."""
        self._daemon_reconnect_generation = (
            getattr(self, "_daemon_reconnect_generation", 0) + 1
        )
        self._daemon_reconnect_in_progress = False
        logger.debug("daemon reconnect cancelled reason=%s", reason)

    def _discard_accidental_daemon_reconnect(self, result) -> None:
        """Stop a daemon that reconnect started during intentional terminate."""
        if result is None:
            return
        client = getattr(result, "client", None)
        if client is None:
            return
        try:
            from .api.models.daemon import StopDaemonRequest

            stop = getattr(client, "stop_daemon", None)
            if callable(stop):
                stop(StopDaemonRequest(force=True))
        except Exception:
            logger.debug(
                "Failed to stop accidental reconnect daemon during terminate-all",
                exc_info=True,
            )
        try:
            client.close()
        except Exception:
            logger.debug(
                "Failed to close accidental reconnect client during terminate-all",
                exc_info=True,
            )

    def _on_daemon_transport_lost(self, error, source_client=None) -> bool:
        """Schedule a bounded daemon reconnect after unexpected transport loss."""

        if source_client is not None:
            window = self.window
            selected = getattr(self, "_api_client_selection", None)
            current_clients = (
                getattr(selected, "client", None),
                getattr(window, "client", None) if window is not None else None,
            )
            if not any(source_client is current for current in current_clients):
                logger.debug("Ignoring transport loss from stale daemon client")
                return False

        if self._daemon_reconnect_suppressed():
            code = getattr(getattr(error, "code", None), "value", None)
            logger.debug(
                "Ignoring expected daemon transport loss during intentional "
                "shutdown code=%s",
                code or type(error).__name__,
            )
            return False
        code = getattr(getattr(error, "code", None), "value", None)
        detail = getattr(error, "message", None)
        logger.warning(
            "Daemon transport lost code=%s detail=%s; scheduling reconnect",
            code or type(error).__name__,
            detail or "unspecified",
        )
        window = self.window
        mark_unavailable = getattr(window, "_mark_daemon_unavailable", None)
        if callable(mark_unavailable):
            mark_unavailable()
        runtime_status = (
            getattr(window, "connection_runtime_status", None)
            if window is not None
            else None
        )
        close_runtime_status = getattr(runtime_status, "close", None)
        if callable(close_runtime_status):
            close_runtime_status()
        self.request_daemon_reconnect(
            reason="transport_loss",
            immediate=False,
        )
        return False

    def _ensure_daemon_reconnect_helper(self):
        helper = getattr(self, "_api_daemon_reconnect_helper", None)
        if helper is not None:
            return helper
        from .api.daemon_reconnect import DaemonReconnectHelper
        from .daemon.launcher import DaemonLauncher

        launcher = getattr(self, "_api_daemon_launcher", None)
        if launcher is None:
            launcher = DaemonLauncher(
                verbose=bool(getattr(self, "verbose_override", False)),
                quiet=bool(getattr(self, "quiet_override", False)),
            )
            self._api_daemon_launcher = launcher
        helper = DaemonReconnectHelper(launcher=launcher)
        self._api_daemon_reconnect_helper = helper
        return helper

    def request_daemon_reconnect(
        self,
        *,
        reason: str = "transport_loss",
        immediate: bool = False,
    ) -> None:
        """Reconnect or start a compatible daemon without restoring live resources.

        ``immediate`` skips backoff (used after an explicit in-app restart).
        Concurrent requests are coalesced.
        """

        if self._daemon_reconnect_suppressed():
            logger.debug(
                "daemon reconnect suppressed reason=%s (intentional shutdown)",
                reason,
            )
            return
        if getattr(self, "_daemon_reconnect_in_progress", False):
            logger.debug(
                "daemon reconnect already in progress; ignoring reason=%s",
                reason,
            )
            return
        self._daemon_reconnect_in_progress = True
        generation = getattr(self, "_daemon_reconnect_generation", 0)
        helper = self._ensure_daemon_reconnect_helper()
        helper.note_transport_loss()
        bridge = getattr(self, "_api_client_bridge", None)

        def _run():
            if (
                self._daemon_reconnect_suppressed()
                or getattr(self, "_daemon_reconnect_generation", 0) != generation
            ):
                logger.debug(
                    "daemon reconnect aborted before attempt reason=%s",
                    reason,
                )
                GLib.idle_add(self._finish_daemon_reconnect, None, generation)
                return None
            try:
                result = helper.reconnect(wait_for_backoff=not immediate)
            except Exception as error:
                logger.error(
                    "daemon reconnect worker failed type=%s",
                    type(error).__name__,
                    exc_info=True,
                )
                GLib.idle_add(self._finish_daemon_reconnect, None, generation)
                return None
            if (
                self._daemon_reconnect_suppressed()
                or getattr(self, "_daemon_reconnect_generation", 0) != generation
            ):
                logger.info(
                    "Discarding daemon reconnect during intentional terminate-all"
                )
                self._discard_accidental_daemon_reconnect(result)
                GLib.idle_add(self._finish_daemon_reconnect, None, generation)
                return None
            GLib.idle_add(self._finish_daemon_reconnect, result, generation)
            return None

        if bridge is not None and hasattr(bridge, "submit"):
            try:
                bridge.submit(
                    _run,
                    on_success=lambda _value: None,
                    on_error=lambda error: GLib.idle_add(
                        self._finish_daemon_reconnect,
                        None,
                        generation,
                    ),
                )
                return
            except Exception:
                logger.debug(
                    "bridge submit for daemon reconnect failed; using thread",
                    exc_info=True,
                )
        thread = threading.Thread(
            target=_run,
            name="sshpilot-daemon-reconnect",
            daemon=True,
        )
        thread.start()

    def _finish_daemon_reconnect(self, result, generation: int = -1) -> bool:
        if (
            generation >= 0
            and getattr(self, "_daemon_reconnect_generation", 0) != generation
        ):
            logger.debug("Ignoring stale daemon reconnect finish")
            return False
        self._daemon_reconnect_in_progress = False
        if self._daemon_reconnect_suppressed():
            if result is not None and getattr(result, "client", None) is not None:
                logger.info(
                    "Discarding daemon reconnect during intentional terminate-all"
                )
                self._discard_accidental_daemon_reconnect(result)
            return False
        if result is None or getattr(result, "client", None) is None:
            decision = getattr(result, "decision", None) if result is not None else None
            message = getattr(decision, "message", None) if decision is not None else None
            logger.warning(
                "daemon reconnect did not restore a client message=%s",
                message or "unavailable",
            )
            # A single attempt right after we ourselves triggered a restart
            # routinely races the old process exiting/unlinking its socket
            # (observed as a spurious handshake_failed). The policy already
            # computed a backoff decision for exactly this case; nothing
            # previously consumed it, so a transient failure just stranded
            # the frontend disconnected until some unrelated action happened
            # to retry. Follow up once more per failure; the policy itself
            # bounds the whole cycle (failure count / deadline) and reports
            # GIVE_UP when retrying is no longer worthwhile.
            outcome = getattr(decision, "outcome", None)
            if outcome is not None and outcome == "backoff":
                self.request_daemon_reconnect(
                    reason="reconnect_retry", immediate=False
                )
            return False

        from .api.client_factory import ClientSelection

        launched = getattr(result, "launched", None)
        process = getattr(launched, "process", None) if launched is not None else None
        selection = ClientSelection(
            client=result.client,
            daemon_process=process,
        )
        previous = None
        existing = getattr(self, "_api_client_selection", None)
        if existing is not None:
            previous = getattr(existing, "client", None)
        try:
            subscribed = self.install_api_event_subscription(result.client)
            if subscribed is False:
                raise RuntimeError("candidate daemon event subscription failed")
        except Exception:
            logger.warning(
                "Failed to reinstall API events after daemon reconnect",
                exc_info=True,
            )
            try:
                result.client.close()
            except Exception:
                pass
            return False
        window = self.window
        if window is not None and not getattr(window, "_is_quitting", False):
            replace_client = getattr(window, "_replace_daemon_client", None)
            if not callable(replace_client):
                logger.error("Window cannot replace its daemon client safely")
                try:
                    result.client.close()
                except Exception:
                    pass
                return False
            try:
                replace_client(result.client)
            except Exception:
                logger.warning(
                    "Failed to refresh daemon-backed services after reconnect",
                    exc_info=True,
                )
                try:
                    result.client.close()
                except Exception:
                    pass
                return False
            welcome = getattr(window, "welcome_view", None)
            if welcome is not None and hasattr(welcome, "set_client"):
                try:
                    welcome.set_client(
                        result.client,
                        bridge=getattr(self, "_api_client_bridge", None),
                    )
                except Exception:
                    logger.debug(
                        "Failed to refresh welcome client after reconnect",
                        exc_info=True,
                    )

        # Publish only after every dependent frontend service accepted the
        # replacement. This prevents a mixed old/new client selection.
        self._api_client_selection = selection

        if previous is not None and previous is not result.client:
            try:
                previous.close()
            except Exception:
                pass
        logger.info(
            "daemon reconnect applied instance=%s",
            getattr(result.client, "server_instance_id", ""),
        )
        return False

    def _handle_api_session_event(self, event) -> bool:
        """Record daemon session state on GTK's main context for diagnostics."""

        window = self.window
        if window is not None and getattr(window, '_is_quitting', False):
            return False
        self._last_api_session_event = event
        callback = getattr(self, '_api_session_event_callback', None)
        if callable(callback):
            callback(event)
        return False

    def open_daemon_session_for_diagnostics(
        self,
        connection_id,
        *,
        on_success=None,
        on_error=None,
    ):
        """Submit a non-terminal session open without blocking GTK.

        This remains a development/test hook. Normal VTE and PyXtermJS launch
        paths do not call it in Phase 6.
        """

        from .api.errors import ErrorCode, SshPilotError
        from .api.models.sessions import OpenSessionRequest

        selection = getattr(self, '_api_client_selection', None)
        client = getattr(selection, 'client', None)
        bridge = getattr(self, '_api_client_bridge', None)
        if client is None or bridge is None:
            error = SshPilotError(
                ErrorCode.UNSUPPORTED_CAPABILITY,
                "Daemon session diagnostics are unavailable",
            )
            self._last_api_session_error_code = error.code
            if callable(on_error):
                on_error(error)
            return None

        def _success(summary):
            if self.window is not None and getattr(
                self.window,
                '_is_quitting',
                False,
            ):
                return
            self._last_api_session_summary = summary
            if callable(on_success):
                on_success(summary)

        def _error(error):
            if self.window is not None and getattr(
                self.window,
                '_is_quitting',
                False,
            ):
                return
            self._last_api_session_error_code = getattr(
                error,
                'code',
                ErrorCode.INTERNAL_ERROR,
            )
            if callable(on_error):
                on_error(error)

        return bridge.submit(
            lambda: client.open_session(
                OpenSessionRequest(connection_id=connection_id)
            ),
            on_success=_success,
            on_error=_error,
        )

    def clear_api_event_subscription(self) -> None:
        subscription = getattr(self, '_api_event_subscription', None)
        self._api_event_subscription = None
        if subscription is not None:
            try:
                subscription.unsubscribe()
            except Exception:
                logger.warning("Application API event unsubscription failed")

    def _handle_api_client_event(self, event_type) -> bool:
        from .api.events import EventType

        window = self.window
        welcome = getattr(window, 'welcome_view', None) if window else None
        if welcome is None or getattr(window, '_is_quitting', False):
            return False
        if event_type is EventType.ERROR_OCCURRED:
            welcome.mark_connection_data_unavailable()
        else:
            welcome.schedule_connection_refresh()
        return False

    def do_command_line(self, command_line):
        """Handle argv for first launch and single-instance handoff.

        Remaining tokens after sshPilot options are treated as an OpenSSH-style
        destination. On failure: print to the invoking CLI's stderr, do **not**
        start a connection / open a tab, return 255 (and quit if this was a
        fresh launch for that failed connect — like ``ssh`` exiting).
        """
        from .cli_connect import parse_sshpilot_cli, validate_cli_tokens

        argv = command_line.get_arguments()
        try:
            opts = parse_sshpilot_cli(list(argv)[1:])
        except SystemExit as exc:
            code = exc.code
            if code is None:
                return 0
            try:
                return int(code)
            except Exception:
                return 1

        if not opts.ssh_tokens:
            self.activate()
            return 0

        def _cli_fail(message: str) -> int:
            text = f'sshpilot: {message}\n'
            try:
                command_line.printerr_literal(text)
            except Exception:
                print(text, end='', file=sys.stderr)
            # Fresh launch solely for a failed connect: exit like ssh.
            # Remote (secondary) invocations only return status to the caller.
            if not command_line.get_is_remote():
                GLib.idle_add(self.quit)
            return 255

        early = validate_cli_tokens(opts.ssh_tokens)
        if early:
            return _cli_fail(early)

        # Alias lookup needs ConnectionManager (created with the window).
        self.activate()
        window = self.window or self.props.active_window
        if window is None or not hasattr(window, 'open_cli_connect'):
            return _cli_fail('Application window is not ready')

        try:
            window.present()
        except Exception:
            pass

        from .cli_connect import describe_cli_error, resolve_cli_connect
        try:
            resolved = resolve_cli_connect(opts.ssh_tokens, window.connection_manager)
        except ValueError as exc:
            return _cli_fail(describe_cli_error(exc))
        except Exception as exc:
            logger.exception('CLI resolve failed')
            return _cli_fail(str(exc) or 'failed to resolve destination')

        # Only now start a connection / open a tab.
        try:
            window.open_cli_connect_resolved(resolved, sftp=opts.sftp)
        except Exception as exc:
            logger.exception('CLI connect open failed')
            return _cli_fail(str(exc) or 'failed to open connection')
        return 0

    def on_shutdown(self, app):
        """Clean up all resources when application is shutting down"""
        logger.info("Application shutdown initiated, cleaning up...")
        try:
            from .logging_support import stop_daemon_log_forwarder

            stop_daemon_log_forwarder()
        except Exception:
            logger.debug("Failed to stop daemon log forwarder", exc_info=True)

        # Mark every window quitting before any teardown. This path (direct app
        # shutdown) bypasses cleanup_and_quit, which is the only other place
        # _is_quitting is set — without this the shutdown-race guards
        # (terminal spawn/colors, Docker Console pulse & refresh timers) never
        # engage, and a live GLib timer can queue a draw against the disposing
        # GSK renderer (Gsk-CRITICAL: gsk_renderer_render_texture).
        try:
            for window in app.get_windows():
                window._is_quitting = True
            if self.window is not None:
                self.window._is_quitting = True
        except Exception:
            logger.debug("Failed to mark windows quitting on shutdown", exc_info=True)

        # Notify plugins first (before any teardown), then best-effort
        # deactivate them.
        try:
            host = getattr(self, 'plugin_host', None)
            if host is None and self.window is not None:
                host = getattr(self.window, 'plugin_host', None)
            if host is not None:
                host.dispatch_app_shutdown()
        except Exception:
            logger.exception("Plugin app_shutdown dispatch failed")
        try:
            for lp in (getattr(self, 'loaded_plugins', None)
                       or (getattr(self.window, 'loaded_plugins', None)
                           if self.window is not None else None)
                       or []):
                pid = getattr(lp, 'plugin_id', None)
                try:
                    lp.instance.deactivate()
                except Exception:
                    logger.exception("Plugin %r deactivate failed", pid or '?')
                # Unwind everything the plugin registered so deactivate is
                # actually a teardown (events, protocols, UI pages).
                if pid:
                    try:
                        if host is not None:
                            host.events.unsubscribe_plugin(pid)
                            host.ui.remove_plugin_pages(pid)
                            host.ui.remove_plugin_actions(pid)
                    except Exception:
                        logger.exception("Plugin %r host unwind failed", pid)
                    try:
                        from .plugins.registry import protocol_registry
                        protocol_registry().unregister_plugin(pid)
                    except Exception:
                        logger.exception("Plugin %r protocol unwind failed", pid)
        except Exception:
            pass

        # Tear down any open embedded file-manager tabs synchronously, before
        # the window/interpreter are finalized. Their widgets carry Python
        # 'destroy' handlers that segfault if invoked during GC finalization.
        try:
            if self.window is not None and hasattr(self.window, '_teardown_all_file_manager_tabs'):
                self.window._teardown_all_file_manager_tabs()
        except Exception:
            logger.debug("Failed to tear down file manager tabs on shutdown", exc_info=True)

        # Close all file manager windows
        try:
            def _cleanup_window(window):
                cleanup = getattr(window, "_cleanup_manager", None)
                if callable(cleanup):
                    cleanup()
                elif hasattr(window, "_manager") and window._manager is not None:
                    window._manager.close()
                    window._manager = None
            
            windows = list(app.get_windows())
            logger.info(f"Found {len(windows)} windows from application during shutdown")
            for window in windows:
                try:
                    _cleanup_window(window)
                except Exception as exc:
                    logger.error(f"Error cleaning up window {window}: {exc}", exc_info=True)
            
            # Also check the global registry as a fallback
            try:
                from .file_manager_window import _file_manager_windows_registry
                registry_windows = list(_file_manager_windows_registry)
                logger.info(f"Found {len(registry_windows)} file manager windows in registry")
                for window in registry_windows:
                    if window in windows:
                        continue
                    try:
                        _cleanup_window(window)
                    except Exception as exc:
                        logger.error(f"Error cleaning up registry window {window}: {exc}", exc_info=True)
            except ImportError:
                pass  # Module might not be loaded
            except Exception as exc:
                logger.debug(f"Error accessing file manager registry: {exc}")
        except Exception as exc:
            logger.error(f"Error closing file manager windows: {exc}", exc_info=True)

        # The frontend-neutral client and its GTK command bridge are
        # application-scoped. Closing a window only suppresses that window's
        # callbacks; final application shutdown owns transport teardown.
        self.clear_api_event_subscription()
        selection = getattr(self, '_api_client_selection', None)
        client = getattr(selection, 'client', None)
        if client is None and self.window is not None:
            client = getattr(self.window, 'client', None)

        # Quitting the application stops the daemon — always, and whether or
        # not this process is the one that launched it. A daemon we merely
        # connected to is still *this user's* daemon serving *this* app; the
        # only reason it exists is to serve the window that is now closing,
        # and leaving it resident is what makes the next launch collide with a
        # previous build. Idle-shutdown remains the safety net for crashes, not
        # the mechanism for ordinary quit.
        already_force_stopped = (
            getattr(self, "_daemon_shutdown_intent", None) == "terminate"
            or (
                self.window is not None
                and getattr(self.window, "_daemon_shutdown_intent", None)
                == "terminate"
            )
        )
        if already_force_stopped:
            logger.debug(
                "Terminate-all already force-stopped the daemon; skipping on_shutdown stop"
            )
        else:
            self._stop_daemon_for_quit(client, selection)

        if client is not None:
            try:
                client.close()
            except Exception:
                logger.warning(
                    "Application API client cleanup failed type=%s",
                    type(client).__name__,
                )
        bridge = getattr(self, '_api_client_bridge', None)
        if bridge is not None:
            try:
                bridge.shutdown()
            except Exception:
                logger.warning("Application API bridge cleanup failed")
        self._api_client_selection = None
        self._api_client_bridge = None
        
        if self._config_handler is not None and self.config is not None:
            try:
                self.config.disconnect(self._config_handler)
            except Exception:
                pass
            finally:
                self._config_handler = None
        self._update_accelerators_enabled_flag()
        from .terminal import process_manager
        process_manager.cleanup_all()
        logger.info("Cleanup completed")

    def _stop_daemon_for_quit(self, client, selection) -> None:
        """Stop the daemon and confirm it is gone before the app exits.

        Graceful force-stop first so the daemon closes its own sessions, SFTP
        services, transfers and ControlMasters; then a bounded wait; then
        signals. Quit does not depend on the daemon cooperating.
        """

        from .daemon_quit_policy import (
            capture_daemon_identity,
            force_daemon_exit,
            resolve_daemon_socket_path,
            wait_for_daemon_termination,
        )

        socket_path = resolve_daemon_socket_path(client)
        daemon_identity = capture_daemon_identity(socket_path)
        daemon_process = getattr(selection, 'daemon_process', None)

        if client is not None:
            try:
                from .api.models.daemon import StopDaemonRequest

                client.stop_daemon(StopDaemonRequest(force=True))
                logger.info("Requested stop of the background service on quit")
            except Exception:
                logger.debug(
                    "Daemon stop request failed on quit; escalating",
                    exc_info=True,
                )

        errors = wait_for_daemon_termination(
            client=None,
            daemon_process=daemon_process,
            daemon_identity=daemon_identity,
            socket_path=socket_path,
            timeout=5.0,
        )
        if errors:
            for message in errors:
                logger.warning("quit shutdown: %s; escalating", message)
            for message in force_daemon_exit(
                socket_path, daemon_identity=daemon_identity
            ):
                logger.error("quit could not fully tear down: %s", message)

    def setup_logging(self):
        """Set up logging configuration.

        Logs live in ``$XDG_STATE_HOME/sshpilot/`` per the XDG Base Directory
        spec (state data includes "actions history (logs, …)"). We previously
        stored them in ``$XDG_DATA_HOME/sshpilot/``; existing log files there
        are migrated on first launch so users don't lose history.

        Inside a Flatpak sandbox the path resolves to
        ``~/.var/app/io.github.mfat.sshpilot/.local/state/sshpilot/`` which
        the sandbox grants the app write access to — verified by simulation
        and matches the convention used by other Flatpak GNOME apps on this
        system (Bitwarden, Authenticator, Cambalache, …).
        """
        log_dir = get_state_dir()
        # Defensive fallback: if the state dir can't be created for any
        # reason (filesystem mount-point quirks, exotic Flatpak versions,
        # corrupted XDG env vars), fall back to the old data-dir path so
        # the app still logs *somewhere* instead of crashing at startup.
        try:
            os.makedirs(log_dir, exist_ok=True)
        except OSError as exc:
            fallback = get_data_dir()
            try:
                os.makedirs(fallback, exist_ok=True)
            except OSError:
                # Last-ditch: tmpdir. Logging to a transient location is
                # better than failing setup_logging entirely.
                import tempfile
                fallback = os.path.join(tempfile.gettempdir(), 'sshpilot')
                os.makedirs(fallback, exist_ok=True)
            print(
                f"sshpilot: state dir {log_dir!r} unusable ({exc}); "
                f"logging to {fallback!r} instead",
                flush=True,
            )
            log_dir = fallback

        # One-shot migration of any pre-existing log files from the old
        # data-dir location. Runs only when the new dir has no main log yet
        # — i.e. on the very first launch after the path change. Idempotent
        # in practice because we move (not copy), so a subsequent launch
        # finds the file already in place.
        try:
            old_dir = get_data_dir()
            new_main = os.path.join(log_dir, 'sshpilot.log')
            if old_dir != log_dir and not os.path.exists(new_main) and os.path.isdir(old_dir):
                import glob, shutil as _sh
                moved = 0
                for old_path in glob.glob(os.path.join(old_dir, 'sshpilot.log*')):
                    try:
                        _sh.move(old_path, os.path.join(log_dir, os.path.basename(old_path)))
                        moved += 1
                    except Exception:
                        # Silent — we'll just leave the old file in place.
                        pass
                if moved:
                    # Defer to a print since logging isn't set up yet.
                    print(
                        f"sshpilot: migrated {moved} log file(s) "
                        f"from {old_dir} to {log_dir}",
                        flush=True,
                    )
        except Exception:
            # Migration is best-effort; never block startup over it.
            pass

        # The process-specific policy is shared with the daemon.  It removes
        # only handlers owned by sshPilot, preserving embedding applications'
        # handlers and closing every replaced rotating file.
        from .logging_support import configure_frontend_logging, normalize_log_level

        # Determine verbosity. Explicit CLI flags take precedence over config.
        quiet = bool(getattr(self, 'quiet_override', False))
        verbose = bool(getattr(self, 'verbose_override', False))
        if not (quiet or verbose):
            try:
                from .config import Config
                cfg = Config()
                level_setting = cfg.get_setting('logging.level', 'info')
                configured_level = str(level_setting).lower()
            except Exception:
                configured_level = 'info'
        else:
            configured_level = 'warning' if quiet else 'debug'

        if quiet:
            effective_level = 'warning'
        elif verbose:
            effective_level = 'debug'
        else:
            effective_level = configured_level
        self._category_handlers = configure_frontend_logging(log_dir, effective_level)
        numeric_level = normalize_log_level(effective_level)

        # Reapply third-party clamp. At default level they stay at WARNING
        # (so we don't drown the user in keyring/PIL chatter). In verbose mode
        # 3rd-party libraries get one notch up to INFO because their DEBUG
        # levels are rarely useful for sshPilot bug reports.
        _clamp_thirdparty_loggers()
        if verbose:
            for noisy in ('keyring', 'gi', 'PIL', 'urllib3', 'asyncio'):
                logging.getLogger(noisy).setLevel(logging.INFO)

        app_level = numeric_level
        logging.getLogger('sshpilot').setLevel(app_level)
        logging.getLogger(__name__).setLevel(app_level)

    def _on_config_setting_changed(self, _config, key, value):
        if key != 'terminal.pass_through_mode':
            return

        self._accelerators_enabled = not bool(value)
        self._update_accelerators_enabled_flag()
        self.apply_shortcut_overrides()
        self._refresh_window_accelerators()

    def create_action(self, name, callback, shortcuts=None):
        """Create a GAction with optional keyboard shortcuts"""
        action = Gio.SimpleAction.new(name, None)
        action.connect("activate", callback)
        self.add_action(action)
        if name not in self._action_order:
            self._action_order.append(name)
        # ``[]`` (disabled but still listed/assignable in the editor) must be
        # preserved distinctly from ``None`` (no shortcut, hidden from editor).
        self._default_shortcuts[name] = list(shortcuts) if shortcuts is not None else None
        self._apply_shortcut_for_action(name)

    def register_window_shortcut(self, name, shortcuts):
        """Register default accels for a win.* action (created on the window,
        not the app) so it appears in the shortcut editor and respects
        config overrides. ``set_accels_for_action`` does not require the
        action to exist yet."""
        if name not in self._action_order:
            self._action_order.append(name)
        self._default_shortcuts[name] = list(shortcuts) if shortcuts is not None else None
        self._apply_shortcut_for_action(name)

    def register_custom_shortcut(self, name, shortcuts):
        """Register a shortcut that appears in the editor and respects config
        overrides but is NOT applied via ``set_accels_for_action``.

        Used for shortcuts handled by a dedicated controller (e.g. split-view
        pane navigation, intercepted in the CAPTURE phase before VTE). They must
        not claim a global accelerator, or the keys would stop reaching a normal
        terminal outside that context. The owning handler reads the effective
        accelerators via ``get_effective_shortcuts``."""
        if name not in self._action_order:
            self._action_order.append(name)
        self._default_shortcuts[name] = list(shortcuts) if shortcuts is not None else None
        self._custom_shortcut_names.add(name)

    def get_effective_shortcuts(self, name):
        """Return the effective accelerators for an action (override or default)."""
        default = self._default_shortcuts.get(name)
        override = None
        if self.config is not None:
            try:
                override = self.config.get_shortcut_override(name)
            except Exception:
                override = None
        return override if override is not None else default

    def _apply_shortcut_for_action(self, name: str):
        # Custom shortcuts (handled by a dedicated controller) are never applied
        # as global accelerators; the owning handler reads them from config.
        if name in getattr(self, '_custom_shortcut_names', set()):
            return
        default = self._default_shortcuts.get(name)
        override = None
        if self.config is not None:
            try:
                override = self.config.get_shortcut_override(name)
            except Exception:
                override = None

        effective = default
        source = 'default'
        if override is not None:
            effective = override
            source = 'override'

        action_name = f"win.{name}" if self.lookup_action(name) is None else f"app.{name}"
        accelerators_blocked = not getattr(self, '_accelerators_enabled', True)
        if accelerators_blocked:
            self.set_accels_for_action(action_name, [])
            logger.debug(
                "Skipping accelerators for action '%s' because accelerators are suspended",
                name,
            )
            return
        if effective is None:
            self.set_accels_for_action(action_name, [])
            logger.debug(f"Registered action '{name}' without shortcuts")
        elif len(effective) == 0:
            self.set_accels_for_action(action_name, [])
            logger.debug(f"Action '{name}' shortcuts disabled via {source}")
        else:
            gtk_accelerators = [
                accel for accel in effective
                if accel != DOUBLE_SHIFT_SHORTCUT
            ]
            self.set_accels_for_action(action_name, gtk_accelerators)
            logger.debug(
                "Registered action '%s' with shortcuts from %s: %s",
                name,
                source,
                effective,
            )

    def apply_shortcut_overrides(self):
        """Reapply all shortcut overrides to the registered actions."""
        for name in self._action_order:
            self._apply_shortcut_for_action(name)

    def _refresh_window_accelerators(self):
        """Notify windows to refresh accelerator state."""
        try:
            windows = list(self.get_windows())
        except Exception:
            windows = []

        for win in windows:
            if hasattr(win, '_update_sidebar_accelerators'):
                try:
                    win._update_sidebar_accelerators()
                except Exception:
                    logger.debug("Failed to update window accelerators for current state")
            # Keep the header-bar fullscreen tooltip showing the binding that
            # is actually in force after a rebind or reset.
            if hasattr(win, '_update_fullscreen_button_tooltip'):
                try:
                    win._update_fullscreen_button_tooltip()
                except Exception:
                    logger.debug("Failed to refresh the fullscreen button tooltip")

    def _update_accelerators_enabled_flag(self):
        """Update the exposed accelerator enabled flag considering focus state."""
        self.accelerators_enabled = bool(getattr(self, '_accelerators_enabled', True))

    def get_registered_shortcut_defaults(self):
        """Return a mapping of action names to their default accelerators."""
        return {
            name: (shortcuts.copy() if isinstance(shortcuts, list) else shortcuts)
            for name, shortcuts in self._default_shortcuts.items()
        }

    def get_registered_action_order(self):
        """Return the order in which actions were registered."""
        return list(self._action_order)

    def quit(self):
        """Request application shutdown, showing confirmation if needed."""
        win = self.props.active_window
        if win and not getattr(win, "_is_quitting", False):
            if win.on_close_request(win):
                return  # dialog will handle quitting or cancellation
        super().quit()

    def on_quit_action(self, action=None, param=None):
        """Handle Ctrl (⌘ on macOS)+Q by routing through the application quit path."""
        self.quit()

    def on_edit_ssh_config(self, action=None, param=None):
        """Handle SSH config editor action."""
        win = self.props.active_window
        if win and hasattr(win, '_open_ssh_config_editor'):
            win._open_ssh_config_editor()

    def _collect_garbage(self) -> bool:
        """Periodic trigger: queue a low-priority idle that runs the cyclic GC on
        the main thread, but only once it's idle (no UI jank). See gc.disable()
        in __init__. Guarded so collections never stack under sustained load."""
        if not self._gc_pending:
            self._gc_pending = True
            GLib.idle_add(self._run_gc_collect, priority=GLib.PRIORITY_LOW)
        return True  # GLib.SOURCE_CONTINUE — keep the periodic timer

    def _run_gc_collect(self) -> bool:
        """Run the cyclic collector on the main thread at idle priority."""
        self._gc_pending = False
        gc.collect()
        return False  # one-shot idle

    def on_new_connection(self, action, param):
        """Handle new connection action"""
        logger.debug("New connection action triggered")
        if self.props.active_window:
            try:
                self.props.active_window.show_connection_dialog()
                logger.debug("Connection dialog shown successfully")
            except Exception as e:
                logger.error(f"Failed to show connection dialog: {e}")
        else:
            logger.warning("No active window found for new connection action")

    def on_open_new_connection_tab(self, action, param):
        """Handle open new connection tab action (Ctrl/⌘+Alt+N)"""
        logger.debug("Open new connection tab action triggered")
        if self.props.active_window:
            # Forward to the window's action
            self.props.active_window.open_new_connection_tab_action.activate(None)

    def on_toggle_list(self, action, param):
        """Handle toggle list focus action"""
        logger.debug("Toggle list focus action triggered")
        if self.props.active_window:
            self.props.active_window.toggle_list_focus()

    def on_search(self, action, param):
        """Show the original connection-list search."""
        logger.debug("Search action triggered")
        if self.props.active_window:
            self.props.active_window.focus_search_entry()

    def on_omnisearch(self, action, param):
        """Show the global Omnisearch."""
        logger.debug("Omnisearch action triggered")
        if self.props.active_window:
            self.props.active_window.activate_omni_search()

    def on_new_key(self, action, param):
        """Handle new SSH key action"""
        logger.debug("New SSH key action triggered")
        if self.props.active_window:
            try:
                # Check if there's a selected connection
                selected_row = self.props.active_window.connection_list.get_selected_row()
                if not selected_row or not getattr(selected_row, "connection", None):
                    # No connection selected, show a dialog to select one
                    logger.info("No connection selected, showing connection selection dialog")
                    self.props.active_window.show_connection_selection_for_ssh_copy()
                else:
                    # Use the selected connection
                    self.props.active_window.on_copy_key_to_server_clicked(None)
                logger.debug("SSH key copy dialog shown successfully")
            except Exception as e:
                logger.error(f"Failed to show SSH key copy dialog: {e}")
        else:
            logger.warning("No active window found for new SSH key action")

    def on_manage_files(self, action, param):
        """Handle manage files shortcut."""
        if should_hide_file_manager_options():
            return

        win = self.props.active_window
        if not win:
            return

        try:
            handler = getattr(win, 'on_manage_files_button_clicked', None)
            if callable(handler):
                handler(None)
                return

            connection_list = getattr(win, 'connection_list', None)
            if not connection_list:
                return

            row = connection_list.get_selected_row()
            connection = getattr(row, 'connection', None) if row else None
            if connection and hasattr(win, '_open_manage_files_for_connection'):
                win._open_manage_files_for_connection(connection)
        except Exception as exc:
            logger.error(f"Failed to open file manager via shortcut: {exc}")
            try:
                connection_name = getattr(connection, 'nickname', '') if 'connection' in locals() else ''
                if connection_name and hasattr(win, '_show_manage_files_error'):
                    win._show_manage_files_error(connection_name, str(exc))
            except Exception:
                pass

    def on_local_terminal(self, action, param):
        """Handle local terminal action"""
        logger.debug("Local terminal action triggered")
        if self.props.active_window:
            self.props.active_window.terminal_manager.show_local_terminal()

    def on_terminal_search(self, action, param):
        """Toggle the search overlay for the active terminal."""
        window = self.props.active_window
        if not window:
            return

        handler = getattr(window, 'toggle_terminal_search_overlay', None)
        if callable(handler):
            handler(select_all=True)

    def on_preferences(self, action, param):
        """Handle preferences action"""
        logger.debug("Preferences action triggered")
        if self.props.active_window:
            self.props.active_window.show_preferences()

    def on_about(self, action, param):
        """Handle about dialog action"""
        logger.debug("About dialog action triggered")
        if self.props.active_window:
            self.props.active_window.show_about_dialog()

    def on_help(self, action, param):
        """Handle help action"""
        logger.debug("Help action triggered")
        if self.props.active_window:
            self.props.active_window.open_help_url()

    def on_shortcuts(self, action, param):
        """Handle keyboard shortcuts overlay action"""
        logger.debug("Shortcuts action triggered")
        if self.props.active_window:
            self.props.active_window.show_shortcuts_window()

    def on_tab_next(self, action, param):
        """Switch to next tab"""
        win = self.props.active_window
        if win and hasattr(win, '_select_tab_relative'):
            win._select_tab_relative(1)

    def on_tab_prev(self, action, param):
        """Switch to previous tab"""
        win = self.props.active_window
        if win and hasattr(win, '_select_tab_relative'):
            win._select_tab_relative(-1)

    def on_tab_close(self, action, param):
        """Close the current tab — or, in a split-view tab, the focused pane."""
        win = self.props.active_window
        if not win:
            return
        try:
            if hasattr(win, '_close_active_tab_or_pane'):
                win._close_active_tab_or_pane()
            else:
                page = win.tab_view.get_selected_page()
                if page:
                    win.tab_view.close_page(page)
        except Exception:
            pass

    def on_tab_move_left(self, action, param):
        """Move the current tab one position to the left."""
        win = self.props.active_window
        if win and hasattr(win, '_move_tab_relative'):
            win._move_tab_relative(-1)

    def on_tab_move_right(self, action, param):
        """Move the current tab one position to the right."""
        win = self.props.active_window
        if win and hasattr(win, '_move_tab_relative'):
            win._move_tab_relative(1)

    def on_tab_overview(self, action, param):
        """Toggle tab overview"""
        win = self.props.active_window
        if not win or not hasattr(win, 'tab_overview'):
            return
        try:
            # Toggle the tab overview
            is_open = win.tab_overview.get_open()
            win.tab_overview.set_open(not is_open)
        except Exception as e:
            logger.error(f"Failed to toggle tab overview: {e}")

    def on_broadcast_command(self, action, param):
        """Handle broadcast command action (Ctrl/⌘+Shift+B)"""
        logger.debug("Broadcast command action triggered")
        if self.props.active_window:
            # Forward to the window's action
            self.props.active_window.broadcast_command_action.activate(None)

    def on_new_split_view_tab(self, action, param=None):
        """Open a new empty split-view tab (Ctrl/⌘+Shift+S)."""
        logger.debug("New split view tab action triggered")
        win = self.props.active_window
        if win and hasattr(win, 'on_new_split_view_tab'):
            win.on_new_split_view_tab(action, param)

    def apply_color_overrides(self, config):
        """Apply color overrides to the application"""
        try:
            import gi
            gi.require_version('Gtk', '4.0')
            from gi.repository import Gtk, Gdk

            accent_color = config.get_setting('accent-color-override', None)

            if not accent_color:
                display = Gdk.Display.get_default()
                if display and hasattr(display, '_color_override_provider'):
                    Gtk.StyleContext.remove_provider_for_display(
                        display,
                        display._color_override_provider,
                    )
                    delattr(display, '_color_override_provider')
                return

            css_rules = [
                f"@define-color accent_color {accent_color};",
                f"@define-color accent_bg_color {accent_color};",
                "@define-color accent_fg_color white;",
                f"@define-color theme_selected_bg_color {accent_color};",
                "@define-color theme_selected_fg_color white;",
                f"@define-color theme_unfocused_selected_bg_color {accent_color};",
                "@define-color theme_unfocused_selected_fg_color white;",
            ]

            if css_rules:
                # Add specific CSS rules for row selection
                css_rules.append("")
                css_rules.append("/* Force row selection to use custom colors */")
                css_rules.append("row:selected {")
                css_rules.append("  background-color: @theme_selected_bg_color;")
                css_rules.append("  color: @theme_selected_fg_color;")
                css_rules.append("}")
                css_rules.append("")
                css_rules.append("row:selected:focus {")
                css_rules.append("  background-color: @theme_selected_bg_color;")
                css_rules.append("  color: @theme_selected_fg_color;")
                css_rules.append("}")
                css_rules.append("")
                css_rules.append("list row:selected {")
                css_rules.append("  background-color: @theme_selected_bg_color;")
                css_rules.append("  color: @theme_selected_fg_color;")
                css_rules.append("}")
                
                # Apply custom CSS
                provider = Gtk.CssProvider()
                css = "\n".join(css_rules)
                provider.load_from_data(css.encode('utf-8'))

                # Add provider to display
                display = Gdk.Display.get_default()
                if display:
                    if hasattr(display, '_color_override_provider'):
                        Gtk.StyleContext.remove_provider_for_display(
                            display,
                            display._color_override_provider,
                        )
                        delattr(display, '_color_override_provider')
                    Gtk.StyleContext.add_provider_for_display(
                        display,
                        provider,
                        Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
                    )
                    # Store provider reference for cleanup
                    display._color_override_provider = provider
                    logger.info("Applied accent color override on startup")

        except Exception as e:
            logger.error(f"Failed to apply color overrides: {e}")

_crash_log_fp = None  # module-global so the fd stays open for the process lifetime


def _rotate_previous_crash_log(log_dir):
    """Move a non-empty ``crash.log`` left by a previous run aside.

    ``crash.log`` is only written when a run crashes, so a non-empty
    ``crash.log`` in ``log_dir`` means the *previous* run crashed. Rotate it to
    ``crash.log.previous`` and return that path so the UI can offer to report
    it. Return ``None`` when there is nothing to preserve. Best-effort: any
    failure is swallowed and returns ``None`` (the caller still starts a fresh
    log). This is pure file I/O — it arms no faulthandler — so it is safe to
    unit-test without colliding with pytest's own faulthandler.
    """
    try:
        path = os.path.join(log_dir, 'crash.log')
        if os.path.exists(path) and os.path.getsize(path) > 0:
            preserved = os.path.join(log_dir, 'crash.log.previous')
            os.replace(path, preserved)
            return preserved
    except Exception:
        logging.getLogger(__name__).debug(
            "Could not rotate previous crash.log", exc_info=True)
    return None


def _enable_crash_diagnostics():
    """Dump all-thread Python tracebacks (and leave a core) on fatal signals.

    A shutdown segfault has been observed where a GObject in a surviving
    reference cycle is finalized during interpreter teardown (Py_FinalizeEx)
    and PyGObject tries to run a Python signal handler after the interpreter is
    gone. A core only shows C frames; faulthandler dumps the exact Python
    handler (file/line) of every thread, which is what pinpoints the culprit.

    ``crash.log`` is only ever written when a run crashes, so a non-empty
    ``crash.log`` at startup means the *previous* run crashed. We rotate it to
    ``crash.log.previous`` and return that path so the UI can offer to report
    it; this run then starts with a fresh, empty ``crash.log``.

    Returns the path of a preserved previous-crash report, or ``None``.
    """
    global _crash_log_fp
    previous_crash = None
    try:
        from .platform_utils import get_state_dir
        log_dir = get_state_dir()
        os.makedirs(log_dir, exist_ok=True)
        path = os.path.join(log_dir, 'crash.log')
        # Preserve a crash report left behind by the previous run.
        previous_crash = _rotate_previous_crash_log(log_dir)
        # Fresh log for this run so the next startup's detection stays clean.
        _crash_log_fp = open(path, 'w', buffering=1)
        faulthandler.enable(file=_crash_log_fp, all_threads=True)
        logging.getLogger(__name__).info("Crash diagnostics enabled → %s", path)
    except Exception:
        try:
            faulthandler.enable()  # fall back to stderr
        except Exception:
            pass
    # Best-effort: ensure a core is produced even if the soft limit is 0.
    try:
        _soft, hard = resource.getrlimit(resource.RLIMIT_CORE)
        resource.setrlimit(
            resource.RLIMIT_CORE,
            (resource.RLIM_INFINITY if hard in (0, resource.RLIM_INFINITY) else hard, hard),
        )
    except Exception:
        pass
    return previous_crash


_exception_hooks_installed = False


def _install_exception_hooks():
    """Log uncaught Python exceptions so they land in the log files / bug reports.

    Covers the main thread (``sys.excepthook`` — also used by PyGObject for
    exceptions raised inside GLib/GTK callbacks), worker threads
    (``threading.excepthook``), and unraisable exceptions such as those from
    ``__del__``/GObject finalizers (``sys.unraisablehook``). The previous hooks
    are still chained so default stderr output is preserved.
    """
    global _exception_hooks_installed
    if _exception_hooks_installed:
        return
    _exception_hooks_installed = True
    log = logging.getLogger('sshpilot')

    prev_excepthook = sys.excepthook

    def _excepthook(exc_type, exc_value, exc_tb):
        if not issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
            try:
                log.critical("Unhandled exception",
                             exc_info=(exc_type, exc_value, exc_tb))
            except Exception:
                pass
        try:
            prev_excepthook(exc_type, exc_value, exc_tb)
        except Exception:
            pass

    sys.excepthook = _excepthook

    def _thread_excepthook(args):
        if issubclass(args.exc_type, SystemExit):
            return
        try:
            log.critical("Unhandled exception in thread %r",
                         getattr(args.thread, 'name', '?'),
                         exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
        except Exception:
            pass

    try:
        threading.excepthook = _thread_excepthook
    except Exception:
        pass

    prev_unraisable = getattr(sys, 'unraisablehook', None)

    def _unraisablehook(unraisable):
        try:
            log.error("Unraisable exception in %r",
                      getattr(unraisable, 'object', None),
                      exc_info=(unraisable.exc_type, unraisable.exc_value,
                                unraisable.exc_traceback))
        except Exception:
            pass
        if prev_unraisable is not None:
            try:
                prev_unraisable(unraisable)
            except Exception:
                pass

    try:
        sys.unraisablehook = _unraisablehook
    except Exception:
        pass


_gtk_log_handler_ids: list = []
_gtk_log_writer_installed = False


def _gtk_level_to_py(level, level_map):
    for flag, py_level in level_map:
        if level & flag:
            return py_level
    return logging.WARNING


def _install_gtk_log_capture(verbose: bool = False):
    """Route GTK/GLib log messages into the rotating log files (and stderr).

    GTK/GLib warnings & criticals (the ``Gtk-CRITICAL`` / ``Gtk-WARNING`` lines
    that name the exact bad widget operation behind UI corruption and many
    crashes) normally go only to stderr. This forwards them to a ``gtk`` Python
    logger so they are durably captured in ``sshpilot.log``, while still echoing
    to the terminal.

    Two interception points are needed because GTK/GLib use two logging paths:
    * legacy ``g_log`` / ``g_warning`` (e.g. GLib's child-watch warning) is caught
      by ``g_log_set_handler`` per domain;
    * GTK4 emits its own warnings via **structured** logging
      (``g_log_structured``), which bypasses legacy handlers — those are caught by
      a ``g_log_set_writer_func``. The writer also catches legacy messages from
      any domain we did not register a handler for.

    Warnings, criticals, errors and messages are always captured; ``verbose``
    additionally captures lower-severity GTK/GLib info & debug messages.
    """
    global _gtk_log_writer_installed
    if _gtk_log_handler_ids or _gtk_log_writer_installed:  # idempotent
        return
    gtk_logger = logging.getLogger('gtk')
    level_map = (
        (GLib.LogLevelFlags.LEVEL_ERROR, logging.CRITICAL),
        (GLib.LogLevelFlags.LEVEL_CRITICAL, logging.ERROR),
        (GLib.LogLevelFlags.LEVEL_WARNING, logging.WARNING),
        (GLib.LogLevelFlags.LEVEL_MESSAGE, logging.INFO),
        (GLib.LogLevelFlags.LEVEL_INFO, logging.INFO),
        (GLib.LogLevelFlags.LEVEL_DEBUG, logging.DEBUG),
    )

    # Which levels we forward to the Python logger. Warnings & above (plus
    # message) always; info/debug only in verbose mode to avoid log spam.
    forward_mask = (
        GLib.LogLevelFlags.LEVEL_WARNING
        | GLib.LogLevelFlags.LEVEL_CRITICAL
        | GLib.LogLevelFlags.LEVEL_ERROR
        | GLib.LogLevelFlags.LEVEL_MESSAGE
    )
    if verbose:
        forward_mask |= GLib.LogLevelFlags.LEVEL_INFO | GLib.LogLevelFlags.LEVEL_DEBUG

    # --- legacy g_log handlers (per domain) ------------------------------
    def _handler(domain, level, message, _user=None):
        try:
            gtk_logger.log(_gtk_level_to_py(level, level_map),
                           "%s: %s", domain or 'GLib', message)
        except Exception:
            pass
        # Preserve terminal visibility (we replaced the default stderr handler).
        try:
            sys.stderr.write("%s: %s\n" % (domain or 'GLib', message))
        except Exception:
            pass

    handler_levels = forward_mask | GLib.LogLevelFlags.FLAG_FATAL | GLib.LogLevelFlags.FLAG_RECURSION
    for domain in ('Gtk', 'Gdk', 'GLib', 'GLib-GObject', 'GLib-GIO', 'Gio',
                   'Pango', 'GdkPixbuf', 'Adwaita', 'Vte', None):
        try:
            hid = GLib.log_set_handler(domain, handler_levels, _handler)
            _gtk_log_handler_ids.append((domain, hid))
        except Exception:
            logging.getLogger(__name__).debug(
                "Could not set GLib log handler for domain %r", domain, exc_info=True)

    # --- structured-logging writer (GTK4's own warnings) -----------------
    def _writer(level, fields, user_data=None):
        try:
            if int(level) & int(forward_mask):
                text = GLib.log_writer_format_fields(level, fields, False).strip()
                gtk_logger.log(_gtk_level_to_py(level, level_map), "%s", text)
        except Exception:
            pass
        # Delegate to the default writer so stderr output AND fatal-warnings
        # (G_DEBUG=fatal-warnings / log_set_always_fatal) still work.
        try:
            return GLib.log_writer_default(level, fields, user_data)
        except Exception:
            return GLib.LogWriterOutput.HANDLED

    try:
        GLib.log_set_writer_func(_writer)
        _gtk_log_writer_installed = True
    except Exception:
        logging.getLogger(__name__).debug(
            "Could not set GLib structured-log writer", exc_info=True)


def _enable_fatal_gtk_warnings():
    """Make the first GTK/GLib warning or critical abort with a backtrace.

    The resulting abort() raises SIGABRT, which the already-armed faulthandler
    dumps into crash.log (all-thread traceback) — pinpointing the exact stack at
    the offending warning. Aggressive: also aborts on benign warnings.
    """
    try:
        GLib.log_set_always_fatal(
            GLib.LogLevelFlags.LEVEL_WARNING | GLib.LogLevelFlags.LEVEL_CRITICAL)
    except Exception:
        logging.getLogger(__name__).debug(
            "Could not enable fatal GTK warnings", exc_info=True)


def main():
    """Main entry point"""
    from .cli_connect import parse_sshpilot_cli

    # Pre-parse so logging / isolated mode are configured before Gtk starts.
    # do_command_line parses again for single-instance handoff of ssh tokens.
    try:
        opts = parse_sshpilot_cli(sys.argv[1:])
    except SystemExit as exc:
        code = exc.code
        if code is None:
            return 0
        try:
            return int(code)
        except Exception:
            return 1

    # AT-SPI names the application node after g_get_prgname(), which otherwise
    # follows argv[0]: "sshpilot" when packaged, but "run.py" or "python3" when
    # run from source. Pin it so assistive technology and UI automation see one
    # stable application name however the app was started.
    GLib.set_prgname('sshpilot')
    GLib.set_application_name('SSH Pilot')

    verbose = opts.verbose or opts.diagnostics
    # GUI tests launch a second, throwaway instance alongside a developer's own
    # running copy; without a distinct application id GApplication would just
    # hand the launch off to that copy. Same shape as SSHPILOT_SSH_DIR: an
    # environment hook the app reads, no extra production surface.
    app = SshPilotApplication(
        verbose=verbose,
        quiet=opts.quiet,
        isolated=opts.isolated,
        log_gtk_warnings=opts.log_gtk_warnings or opts.diagnostics,
        fatal_warnings=opts.fatal_warnings,
        application_id=os.environ.get('SSHPILOT_APP_ID') or None,
    )
    return app.run(sys.argv)

if __name__ == '__main__':
    main()
