import os
import asyncio
import logging
import threading
from typing import Optional



from gi.repository import Gio, GLib, Adw, Gdk, Gtk
from gettext import gettext as _

from .terminal import TerminalWidget
from .file_manager_integration import should_hide_external_terminal_options

logger = logging.getLogger(__name__)


class TerminalManager:
    """Manage terminal creation and connection lifecycle"""

    def __init__(self, window):
        self.window = window

    def _ensure_backend_alignment(self, terminal) -> None:
        """Ensure the given terminal uses the configured backend."""
        if not terminal or not hasattr(self.window, 'config'):
            return
        desired = self.window.config.get_setting('terminal.backend', 'vte')
        getter = getattr(terminal, 'get_backend_name', None)
        current = getter() if callable(getter) else 'vte'
        if isinstance(desired, str) and isinstance(current, str):
            if current.lower() == (desired or 'vte').lower():
                return
        aligner = getattr(terminal, 'ensure_backend', None)
        if callable(aligner):
            try:
                aligner(desired)
            except Exception:
                logger.error("Failed to align terminal backend", exc_info=True)

    def refresh_backends(self) -> None:
        """Ensure all existing terminals use the configured backend."""
        seen = set()
        collections = []
        connection_terms = getattr(self.window, 'connection_to_terminals', None)
        if isinstance(connection_terms, dict):
            collections.extend(connection_terms.values())
        active = getattr(self.window, 'active_terminals', None)
        if isinstance(active, dict):
            collections.append(active.values())
        for group in collections:
            for term in list(group):
                if term in seen:
                    continue
                self._ensure_backend_alignment(term)
                seen.add(term)
        tab_view = getattr(self.window, 'tab_view', None)
        if tab_view is not None and hasattr(tab_view, 'get_n_pages'):
            try:
                for index in range(tab_view.get_n_pages()):
                    page = tab_view.get_nth_page(index)
                    if page is None:
                        continue
                    term = page.get_child()
                    if term is None or term in seen:
                        continue
                    self._ensure_backend_alignment(term)
                    seen.add(term)
            except Exception:
                logger.debug("Failed to iterate tab view while refreshing backends", exc_info=True)

    # Connecting/disconnecting hosts
    def _maybe_unlock_secrets_then(self, retry, _allow_reprompt: bool = True) -> bool:
        """If the selected secret backend is session-backed and locked, show the unlock
        prompt and call ``retry`` when it finishes; return True. Otherwise return False so
        the caller proceeds immediately.

        When this connection only *rode* an already-open prompt (e.g. the startup unlock)
        and that prompt resolves with the vault **still locked** (the user deferred it),
        we don't silently start a background terminal — we re-show *our own* prompt once so
        the user can unlock for this connection. After that one re-prompt we proceed
        regardless (the user cancelling their own prompt falls back to SSH's own prompt)."""
        try:
            from .secret_storage import get_secret_manager
            manager = get_secret_manager()

            # rbw is passive (session_backed=False), so selected_needs_unlock() skips it —
            # but a locked rbw-agent silently yields no secret on connect. Nudge through
            # rbw's own pinentry (rbw_setup runs `rbw unlock` + sync), then retry regardless
            # of the result (a cancel falls back to ssh's own prompt, like the bw path).
            rbw = manager.selected_backend()
            if (getattr(rbw, "name", "") == "rbw"
                    and rbw.is_available() and not rbw.is_unlocked()):
                from .rbw_setup import ensure_rbw_ready
                ensure_rbw_ready(self.window, lambda _ready: retry())
                return True

            if not manager.selected_needs_unlock():
                return False
            from .secret_unlock_dialog import prompt_unlock

            def _show_unlock():
                def _on_done(_success):
                    still_locked = False
                    try:
                        still_locked = get_secret_manager().selected_needs_unlock()
                    except Exception:
                        still_locked = False
                    if (still_locked and _allow_reprompt and not owned[0]
                            and self._maybe_unlock_secrets_then(retry, _allow_reprompt=False)):
                        return  # rode a deferred prompt -> our own prompt now drives the retry
                    retry()

                owned = [True]
                owned[0] = bool(prompt_unlock(self.window, on_done=_on_done))

            # Locked. A vault that isn't signed in can't be unlocked, so decide off the
            # main thread (``selected_needs_login`` spawns a slow ``bw`` process): when
            # not signed in, skip the doomed "Unlock vault" dialog and just proceed with
            # the connection (SSH prompts for auth itself); otherwise show the unlock prompt.
            def _probe():
                needs_login = manager.selected_needs_login()
                GLib.idle_add(lambda: (_after_probe(needs_login), False)[1])

            def _after_probe(needs_login):
                if needs_login:
                    retry()  # not signed in -> can't autofill; connect normally
                    return
                _show_unlock()

            threading.Thread(target=_probe, daemon=True).start()
            return True
        except Exception as exc:
            logger.error("Secret unlock prompt failed: %s", exc)
            return False

    def connect_to_host(self, connection, force_new: bool = False,
                        remote_command: Optional[str] = None,
                        tab_title: Optional[str] = None,
                        force_tty: bool = False,
                        pty_prompt: Optional[str] = None,
                        pty_response: Optional[str] = None,
                        _secret_unlock_attempted: bool = False):
        window = self.window
        group_color = self._resolve_group_color(connection)
        if not force_new:
            if connection in window.active_terminals:
                terminal = window.active_terminals[connection]
                self._ensure_backend_alignment(terminal)
                page = window._page_for_child(terminal)
                if page is not None:
                    window.tab_view.set_selected_page(page)
                    return
                else:
                    logger.warning(
                        f"Terminal for {connection.nickname} not found in tab view, removing from active terminals"
                    )
                    del window.active_terminals[connection]
            existing_terms = window.connection_to_terminals.get(connection) or []
            for t in reversed(existing_terms):
                page = window._page_for_child(t)
                if page is not None:
                    self._ensure_backend_alignment(t)
                    window.active_terminals[connection] = t
                    window.tab_view.set_selected_page(page)
                    return

        # A session-backed secret store (Bitwarden/Vaultwarden) must be unlocked
        # before this connection's password/passphrase can be autofilled. Prompt
        # once on the main thread, then re-enter to actually connect. We retry
        # regardless of the unlock result (and skip a second prompt) so a cancel
        # falls back to ssh's own prompt instead of looping.
        if not _secret_unlock_attempted and self._maybe_unlock_secrets_then(
            lambda: self.connect_to_host(
                connection, force_new=force_new, remote_command=remote_command,
                tab_title=tab_title, force_tty=force_tty,
                pty_prompt=pty_prompt, pty_response=pty_response,
                _secret_unlock_attempted=True,
            )
        ):
            return

        # The user's "use external terminal" preference is only applied when
        # external terminal options are not hidden by policy or environment.
        use_external = window.config.get_setting('use-external-terminal', False)
        if use_external and not should_hide_external_terminal_options():
            window._open_connection_in_external_terminal(connection)
            return
        else:
            group_color, group_name = self._resolve_group_color_and_name(connection)

            terminal = TerminalWidget(
                connection,
                window.config,
                window.connection_manager,
                group_color=group_color,
            )
            terminal.connect('connection-established', self.on_terminal_connected)
            terminal.connect('connection-failed', lambda w, e: logger.error(f"Connection failed: {e}"))
            terminal.connect('connection-lost', self.on_terminal_disconnected)
            terminal.connect('title-changed', self.on_terminal_title_changed)

            # One-shot PTY auto-fill: answer a known remote prompt (e.g. a sudo
            # password) by typing the response when the prompt appears. Set
            # before _connect_ssh() so the watcher is armed at spawn time.
            if pty_prompt and pty_response is not None:
                terminal._pty_autofill = (pty_prompt, pty_response)

            from sshpilot import icon_utils
            page = window.tab_view.append(terminal)
            page.set_title(tab_title or connection.nickname)
            page.set_icon(icon_utils.new_gicon_from_icon_name('utilities-terminal-symbolic'))
            if group_name:
                setattr(terminal, 'group_name', group_name)
            self._apply_tab_group_color(page, group_color, tooltip=group_name)


            window.connection_to_terminals.setdefault(connection, []).append(terminal)
            window.terminal_to_connection[terminal] = connection
            window.active_terminals[connection] = terminal

            window.show_tab_view()
            window.tab_view.set_selected_page(page)

        def _cleanup_failed_terminal():
            connection.is_connected = False
            window.tab_view.close_page(page)

            try:
                if connection in window.active_terminals and window.active_terminals[connection] is terminal:
                    del window.active_terminals[connection]
                if terminal in window.terminal_to_connection:
                    del window.terminal_to_connection[terminal]
                if connection in window.connection_to_terminals and terminal in window.connection_to_terminals[connection]:
                    window.connection_to_terminals[connection].remove(terminal)
                    if not window.connection_to_terminals[connection]:
                        del window.connection_to_terminals[connection]
            except Exception:
                pass

        def _set_terminal_colors():
            # Shutdown race guard: this idle callback can fire after the window
            # started closing. Drawing/connecting now queues rendering against a
            # disposed GSK renderer and spawns SSH post-cleanup, so bail early.
            if getattr(window, '_is_quitting', False):
                return

            try:
                if hasattr(window, 'get_application'):
                    try:
                        app = window.get_application()
                    except Exception:
                        app = None
                else:
                    app = None
                if app is None:
                    try:
                        app = Adw.Application.get_default()
                    except Exception:
                        app = None
                # sshPilot connects in native mode only; connect() delegates to
                # native_connect(), so either entry point prepares a native command.
                if (getattr(connection, 'protocol', 'ssh') == 'ssh'
                        and not getattr(connection, 'ssh_cmd', None)):
                    prepare = (
                        connection.native_connect(remote_command=remote_command,
                                                  force_tty=force_tty)
                        if hasattr(connection, 'native_connect')
                        else connection.connect()
                    )
                    try:
                        loop = asyncio.get_event_loop()
                        if loop.is_running():
                            fut = asyncio.run_coroutine_threadsafe(prepare, loop)
                            fut.result()
                        else:
                            loop.run_until_complete(prepare)
                    except Exception as prep_err:
                        logger.error(f"Failed to prepare SSH command: {prep_err}")

                terminal.apply_theme()
                if terminal.backend:
                    terminal.backend.queue_draw()
                elif hasattr(terminal, 'vte') and terminal.vte:
                    terminal.vte.queue_draw()
                if not terminal._connect_ssh():
                    logger.error('Failed to establish SSH connection')
                    _cleanup_failed_terminal()
            except Exception as e:
                logger.error(f"Error setting terminal colors: {e}")
                if not terminal._connect_ssh():
                    logger.error('Failed to establish SSH connection')
                    _cleanup_failed_terminal()

        GLib.idle_add(_set_terminal_colors)

    def create_terminal_for_pane(self, connection, on_connected=None, on_disconnected=None):
        """
        Create and connect a TerminalWidget for use inside a SplitPane.

        Unlike connect_to_host(), this does NOT append the terminal to tab_view.
        The caller (SplitPane) embeds the returned widget in its own layout.
        """
        window = self.window
        group_color, group_name = self._resolve_group_color_and_name(connection)

        terminal = TerminalWidget(
            connection,
            window.config,
            window.connection_manager,
            group_color=group_color,
        )
        terminal.connect('connection-established', self.on_terminal_connected)
        terminal.connect('connection-failed', lambda w, e: logger.error(f"Connection failed: {e}"))
        terminal.connect('connection-lost', self.on_terminal_disconnected)
        terminal.connect('title-changed', self._on_pane_terminal_title_changed)

        if on_connected:
            terminal.connect('connection-established', on_connected)
        if on_disconnected:
            terminal.connect('connection-lost', on_disconnected)

        if group_name:
            setattr(terminal, 'group_name', group_name)

        window.connection_to_terminals.setdefault(connection, []).append(terminal)
        window.terminal_to_connection[terminal] = connection
        window.active_terminals[connection] = terminal

        def _set_terminal_colors():
            try:
                try:
                    app = window.get_application()
                except Exception:
                    app = None
                if app is None:
                    try:
                        from gi.repository import Adw as _Adw
                        app = _Adw.Application.get_default()
                    except Exception:
                        app = None
                # Native-only connection (connect() delegates to native_connect()).
                if (getattr(connection, 'protocol', 'ssh') == 'ssh'
                        and not getattr(connection, 'ssh_cmd', None)):
                    prepare = (
                        connection.native_connect()
                        if hasattr(connection, 'native_connect')
                        else connection.connect()
                    )
                    try:
                        loop = asyncio.get_event_loop()
                        if loop.is_running():
                            fut = asyncio.run_coroutine_threadsafe(prepare, loop)
                            fut.result()
                        else:
                            loop.run_until_complete(prepare)
                    except Exception as prep_err:
                        logger.error(f"Failed to prepare SSH command for pane: {prep_err}")

                terminal.apply_theme()
                if terminal.backend:
                    terminal.backend.queue_draw()
                elif hasattr(terminal, 'vte') and terminal.vte:
                    terminal.vte.queue_draw()
                if not terminal._connect_ssh():
                    logger.error('Failed to establish SSH connection for pane')
                    connection.is_connected = False
                    try:
                        if window.active_terminals.get(connection) is terminal:
                            del window.active_terminals[connection]
                        window.terminal_to_connection.pop(terminal, None)
                        terms = window.connection_to_terminals.get(connection, [])
                        if terminal in terms:
                            terms.remove(terminal)
                            if not terms:
                                del window.connection_to_terminals[connection]
                    except Exception:
                        pass
            except Exception as exc:
                logger.error(f"Error initialising pane terminal: {exc}")

        # Gate the ssh spawn behind the vault unlock (same as connect_to_host) so a pane
        # connection doesn't start in the background while a session vault is locked. The
        # widget is returned now (it shows "connecting" until the spawn runs after unlock).
        def _start_pane_connect():
            GLib.idle_add(_set_terminal_colors)
        if not self._maybe_unlock_secrets_then(_start_pane_connect):
            _start_pane_connect()
        return terminal

    def _on_pane_terminal_title_changed(self, terminal, title):
        """Title-changed handler for terminals embedded in split panes."""
        # Title updates are reflected in the tab title via SplitViewTab._update_tab_title

    def _resolve_group_color(self, connection):
        color_value, _ = self._resolve_group_color_and_name(connection)
        return color_value

    def _resolve_group_color_and_name(self, connection):
        manager = getattr(self.window, 'group_manager', None)
        if not manager:
            return None, None

        nickname = getattr(connection, 'nickname', None)
        if not nickname:
            return None, None

        try:
            group_id = manager.get_connection_group(nickname)
        except Exception:
            group_id = None

        visited = set()
        while group_id:
            if group_id in visited:
                break
            visited.add(group_id)

            try:
                group_info = getattr(manager, 'groups', {}).get(group_id)
            except Exception:
                group_info = None

            if not isinstance(group_info, dict):
                break

            color = group_info.get('color')
            if color:
                return color, group_info.get('name')

            group_id = group_info.get('parent_id')

        return None, None

    def _create_tab_color_icon(self, rgba: Gdk.RGBA):
        # Use a simple themed icon instead of creating custom icons
        # This avoids the pixbuf save issues and works reliably
        return Gio.ThemedIcon.new("tag-symbolic")

    def _apply_tab_group_color(self, page, color_value, tooltip=None):
        use_pref = False
        try:
            use_pref = bool(
                self.window.config.get_setting('ui.use_group_color_in_tab', False)
            )
        except Exception:
            use_pref = False

        if not use_pref or not color_value:
            self._clear_tab_group_color(page)
            return

        tooltip_text = tooltip or _('Group color')

        rgba = Gdk.RGBA()
        try:
            if not rgba.parse(str(color_value)):
                self._clear_tab_group_color(page)
                return
        except Exception:
            self._clear_tab_group_color(page)
            return

        # Create a themed icon and apply color via CSS
        icon = self._create_tab_color_icon(rgba)
        if icon is None:
            self._clear_tab_group_color(page)
            return

        if hasattr(page, 'set_indicator_icon'):
            try:
                page.set_indicator_icon(icon)
                if hasattr(page, 'set_indicator_tooltip'):
                    page.set_indicator_tooltip(tooltip_text)

                # Apply color to the icon using CSS
                self._apply_tab_icon_color(page, rgba, tooltip_text)
                setattr(page, '_sshpilot_group_indicator_icon', icon)
                setattr(page, '_sshpilot_group_color', rgba.to_string())
            except Exception as exc:
                logger.debug("Failed to set tab indicator icon: %s", exc)
        elif hasattr(page, 'set_icon') and hasattr(page, 'get_icon'):
            try:
                if not hasattr(page, '_sshpilot_original_icon'):
                    setattr(page, '_sshpilot_original_icon', page.get_icon())
                page.set_icon(icon)

                if hasattr(page, 'set_indicator_tooltip'):
                    page.set_indicator_tooltip(tooltip_text)

                # Apply color to the icon using CSS
                self._apply_tab_icon_color(page, rgba, tooltip_text)
                setattr(page, '_sshpilot_group_indicator_icon', icon)
                setattr(page, '_sshpilot_group_color', rgba.to_string())
            except Exception as exc:
                logger.debug("Failed to set tab icon for group color: %s", exc)

    def _clear_tab_group_color(self, page):
        if hasattr(page, 'set_indicator_icon'):
            try:
                page.set_indicator_icon(None)
                if hasattr(page, 'set_indicator_tooltip'):
                    page.set_indicator_tooltip(None)
            except Exception:
                pass
        elif hasattr(page, 'set_icon') and hasattr(page, '_sshpilot_original_icon'):
            try:
                original = getattr(page, '_sshpilot_original_icon')
                if original is not None:
                    page.set_icon(original)
            except Exception:
                pass

        # Clear the custom icon from the tab page
        if hasattr(page, 'set_indicator_icon'):
            try:
                page.set_indicator_icon(None)
                if hasattr(page, 'set_indicator_tooltip'):
                    page.set_indicator_tooltip(None)
            except Exception:
                pass

        # No CSS provider to remove since we're using direct colored icons

        setattr(page, '_sshpilot_group_indicator_icon', None)
        if hasattr(page, '_sshpilot_group_color'):
            delattr(page, '_sshpilot_group_color')

    def _create_colored_tab_icon(self, rgba: Gdk.RGBA):
        """Create a simple colored icon for the tab indicator"""
        try:
            # Use a simple approach - create a basic colored icon
            # Convert RGBA to hex for CSS
            hex_color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}"
            
            # Create a tag SVG icon with the color
            # Based on tag-symbolic.svg but with the group color
            svg_data = f"""<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px">
                <path d="m 1 2 v 6 l 6.691406 6.691406 c 0.1875 0.1875 0.414063 0.203125 0.597656 0.019532 l 6.382813 -6.382813 c 0.222656 -0.222656 0.207031 -0.449219 0.019531 -0.636719 l -6.691406 -6.691406 h -6 c -0.464844 0 -1 0.492188 -1 1 z m 3 0.960938 c 0.589844 0 1.070312 0.480468 1.070312 1.070312 s -0.480468 1.070312 -1.070312 1.070312 s -1.070312 -0.480468 -1.070312 -1.070312 s 0.480468 -1.070312 1.070312 -1.070312 z m 0 0" fill="{hex_color}"/>
            </svg>"""
            
            # Create a bytes icon from the SVG
            svg_bytes = svg_data.encode('utf-8')
            return Gio.BytesIcon.new(GLib.Bytes.new(svg_bytes))
        except Exception as exc:
            logger.debug(f"Failed to create colored tab icon: {exc}")
            # Fallback to themed icon
            return Gio.ThemedIcon.new("tag-symbolic")

    def _apply_tab_css_color(self, page, rgba: Gdk.RGBA):
        """Apply CSS color to the tab view to color indicator icons"""
        try:
            # Get the tab view
            tab_view = None
            try:
                if hasattr(self.window, 'tab_view'):
                    tab_view = self.window.tab_view
            except Exception:
                pass
            
            if not tab_view:
                return
            
            # Remove any existing CSS provider
            if hasattr(tab_view, '_sshpilot_tab_css_provider'):
                tab_view.get_style_context().remove_provider(tab_view._sshpilot_tab_css_provider)
            
            # Create new CSS provider with the group color
            provider = Gtk.CssProvider()
            color_value = rgba.to_string()
            
            # Use a simple CSS rule to color indicator icons
            css_data = f"tab page indicator-icon {{ color: {color_value}; }}"
            provider.load_from_data(css_data.encode())
            tab_view.get_style_context().add_provider(
                provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
            )
            tab_view._sshpilot_tab_css_provider = provider
            
        except Exception as exc:
            logger.debug(f"Failed to apply tab CSS color: {exc}")

    def _apply_tab_icon_color(self, page, rgba: Gdk.RGBA, tooltip_text=None):
        """Apply color to tab icon by setting a colored icon"""
        try:
            # Create a colored icon
            icon = self._create_colored_tab_icon(rgba)
            if icon is None:
                self._clear_tab_group_color(page)
                return
            
            # Set the colored icon on the tab page
            page.set_indicator_icon(icon)
            page.set_indicator_activatable(True)
            if hasattr(page, 'set_indicator_tooltip'):
                page.set_indicator_tooltip(tooltip_text or _('Group color'))

            # Store reference to the icon and color for cleanup
            setattr(page, '_sshpilot_group_indicator_icon', icon)
            setattr(page, '_sshpilot_group_color', rgba)
        except Exception as exc:
            logger.debug("Failed to apply tab icon color: %s", exc)
            self._clear_tab_group_color(page)

    def restyle_open_terminals(self):
        window = self.window
        n_pages = window.tab_view.get_n_pages() if hasattr(window.tab_view, 'get_n_pages') else 0
        for index in range(n_pages):
            try:
                page = window.tab_view.get_nth_page(index)
            except Exception:
                continue
            if not page:
                continue

            terminal = page.get_child() if hasattr(page, 'get_child') else None
            if terminal is None:
                continue

            connection = window.terminal_to_connection.get(terminal)
            if connection:
                color_value, group_name = self._resolve_group_color_and_name(connection)
                if group_name:
                    setattr(terminal, 'group_name', group_name)
            else:
                color_value, group_name = None, None

            if hasattr(terminal, 'set_group_color'):
                try:
                    terminal.set_group_color(color_value, force=True)
                except Exception as exc:
                    logger.debug("Failed to update terminal group color: %s", exc)

            tooltip = getattr(terminal, 'group_name', None)
            self._apply_tab_group_color(page, color_value, tooltip=tooltip)


    def _on_disconnect_confirmed(self, dialog, response_id, connection):
        dialog.destroy()
        window = self.window
        if response_id == 'disconnect' and connection in window.active_terminals:
            terminal = window.active_terminals[connection]
            terminal.disconnect()
            if getattr(window, '_pending_delete_connection', None) is connection:
                try:
                    window.connection_manager.remove_connection(connection)
                finally:
                    window._pending_delete_connection = None

    def disconnect_from_host(self, connection):
        window = self.window
        if connection not in window.active_terminals:
            return
        confirm_disconnect = window.config.get_setting('confirm-disconnect', True)
        if confirm_disconnect:
            host_value = getattr(connection, 'hostname', getattr(connection, 'host', getattr(connection, 'nickname', '')))
            dialog = Adw.MessageDialog(
                transient_for=window,
                modal=True,
                heading=_("Disconnect from {host}").format(host=connection.nickname or host_value),
                body=_("Are you sure you want to disconnect from this host?")
            )
            dialog.add_response('cancel', _("Cancel"))
            dialog.add_response('disconnect', _("Disconnect"))
            dialog.set_response_appearance('disconnect', Adw.ResponseAppearance.DESTRUCTIVE)
            dialog.set_default_response('close')
            dialog.set_close_response('cancel')
            dialog.connect('response', self._on_disconnect_confirmed, connection)
            dialog.present()
        else:
            terminal = window.active_terminals[connection]
            terminal.disconnect()

    # Tab management and local terminals
    def _add_terminal_tab(self, terminal_widget, title):
        try:
            from sshpilot import icon_utils
            page = self.window.tab_view.append(terminal_widget)
            page.set_title(title)
            page.set_icon(icon_utils.new_gicon_from_icon_name('utilities-terminal-symbolic'))
            self.window.show_tab_view()
            self.window.tab_view.set_selected_page(page)
            logger.info(f"Added terminal tab: {title}")
        except Exception as e:
            logger.error(f"Failed to add terminal tab: {e}")

    def show_local_terminal(self, *, title="Local Terminal",
                            command: Optional[str] = None,
                            pty_prompt: Optional[str] = None,
                            pty_response: Optional[str] = None) -> bool:
        logger.info("Show local terminal tab")
        try:
            class LocalConnection:
                def __init__(self):
                    self.nickname = title
                    self.hostname = "localhost"
                    self.host = self.hostname
                    self.username = os.getenv('USER', 'user')
                    self.port = 22
                    self.is_connected = True
            local_connection = LocalConnection()
            terminal_widget = TerminalWidget(local_connection, self.window.config, self.window.connection_manager)
            if pty_prompt and pty_response is not None:
                terminal_widget._pty_autofill = (pty_prompt, pty_response)
            if command and str(command).strip():
                command_text = str(command).strip()

                def _run_command(*_args):
                    data = (command_text + "\n").encode("utf-8")
                    backend = getattr(terminal_widget, "backend", None)
                    if backend is not None and hasattr(backend, "feed_child"):
                        backend.feed_child(data)
                    elif getattr(terminal_widget, "vte", None) is not None:
                        terminal_widget.vte.feed_child(data)

                terminal_widget.connect("connection-established", _run_command)
            terminal_widget.setup_local_shell()
            self._add_terminal_tab(terminal_widget, title)

            # Register terminal so theme/font updates affect existing local tabs
            window = self.window
            window.connection_to_terminals.setdefault(local_connection, []).append(terminal_widget)
            window.terminal_to_connection[terminal_widget] = local_connection
            window.active_terminals[local_connection] = terminal_widget

            GLib.idle_add(terminal_widget.show)
            # Show the terminal widget (backend-agnostic)
            if hasattr(terminal_widget, 'terminal_widget') and terminal_widget.terminal_widget:
                GLib.idle_add(terminal_widget.terminal_widget.show)
            elif hasattr(terminal_widget, 'vte') and terminal_widget.vte:
                GLib.idle_add(terminal_widget.vte.show)

            def _focus_local_terminal():
                try:
                    if hasattr(terminal_widget, 'get_mapped') and not terminal_widget.get_mapped():
                        return
                    if hasattr(terminal_widget, 'backend') and terminal_widget.backend:
                        terminal_widget.backend.grab_focus()
                    elif hasattr(terminal_widget, 'vte') and hasattr(terminal_widget.vte, 'grab_focus'):
                        terminal_widget.vte.grab_focus()
                    elif hasattr(terminal_widget, 'grab_focus'):
                        terminal_widget.grab_focus()
                except Exception as focus_error:
                    logger.debug(f"Failed to focus local terminal: {focus_error}")

            # Use window's focus coordinator to avoid race conditions
            if hasattr(self.window, '_queue_focus_operation'):
                self.window._queue_focus_operation(_focus_local_terminal)
            else:
                # Fallback for older versions
                GLib.timeout_add(100, _focus_local_terminal)
            logger.info("Local terminal tab created successfully")
            return True
        except Exception as e:
            logger.error(f"Failed to show local terminal: {e}")
            try:
                dialog = Adw.MessageDialog(
                    transient_for=self.window,
                    modal=True,
                    heading=_("Error"),
                    body=_("Could not open local terminal.\n\n{error}").format(error=e)
                )
                dialog.add_response("ok", _("OK"))
                dialog.present()
            except Exception:
                pass
            return False

    # Terminal discovery (regular tabs + split-view panes)
    def _is_broadcastable_ssh_terminal(self, terminal) -> bool:
        if terminal is None:
            return False
        if not (hasattr(terminal, 'backend') or hasattr(terminal, 'vte')):
            return False
        connection = getattr(terminal, 'connection', None)
        if connection is None:
            return False
        nickname = getattr(connection, 'nickname', None)
        if nickname == "Local Terminal":
            return False
        return hasattr(connection, 'hostname')

    def iter_terminals(self):
        """Yield TerminalWidgets from regular tabs and split-view panes."""
        from .split_view import SplitViewTab

        tab_view = getattr(self.window, 'tab_view', None)
        if tab_view is None:
            return

        try:
            n_pages = tab_view.get_n_pages()
        except Exception:
            return

        for i in range(n_pages):
            try:
                page = tab_view.get_nth_page(i)
            except Exception:
                continue
            if page is None:
                continue
            try:
                child = page.get_child()
            except Exception:
                child = None
            if child is None:
                continue

            if isinstance(child, TerminalWidget):
                yield child
            elif isinstance(child, SplitViewTab):
                yield from child.get_all_terminals()

    def iter_ssh_terminals(self):
        """Yield SSH TerminalWidgets from regular tabs and split-view panes."""
        for terminal in self.iter_terminals():
            if self._is_broadcastable_ssh_terminal(terminal):
                yield terminal

    def get_focused_terminal(self):
        """Return the focused TerminalWidget from the selected main tab, if any."""
        from .split_view import SplitViewTab

        tab_view = getattr(self.window, 'tab_view', None)
        if tab_view is None:
            return None

        try:
            page = tab_view.get_selected_page()
        except Exception:
            return None
        if page is None:
            return None

        try:
            child = page.get_child()
        except Exception:
            return None

        if isinstance(child, TerminalWidget):
            return child
        if isinstance(child, SplitViewTab):
            return child.get_focused_terminal()
        return None

    # Broadcast commands
    def broadcast_command(self, command: str):
        cmd = (command + "\n").encode("utf-8")
        sent_count = 0
        failed_count = 0
        for terminal_widget in self.iter_ssh_terminals():
            connection = terminal_widget.connection
            try:
                if hasattr(terminal_widget, 'backend') and terminal_widget.backend:
                    terminal_widget.backend.feed_child(cmd)
                elif hasattr(terminal_widget, 'vte') and terminal_widget.vte:
                    terminal_widget.vte.feed_child(cmd)
                sent_count += 1
                logger.debug(
                    "Sent command to SSH terminal: %s",
                    getattr(connection, 'nickname', ''),
                )
            except Exception as e:
                failed_count += 1
                logger.error(
                    "Failed to send command to terminal %s: %s",
                    getattr(connection, 'nickname', ''),
                    e,
                )
        logger.info(
            f"Broadcast command completed: {sent_count} terminals received command, {failed_count} failed")
        return sent_count, failed_count

    # Terminal signal handlers
    def on_terminal_connected(self, terminal):
        # Aggregate across all terminals of this connection (OR-logic) and set
        # the authoritative state in the reporting layer instead of writing the
        # boolean here.
        self.window._recompute_connection_state(terminal.connection)

        # Stamp last-used time so the start page can order Recent connections.
        try:
            import time
            nickname = getattr(terminal.connection, 'nickname', None)
            if nickname:
                meta = self.window.config.get_connection_meta(nickname)
                meta['last_used'] = time.time()
                self.window.config.set_connection_meta(nickname, meta)
                welcome = getattr(self.window, 'welcome_view', None)
                if welcome is not None and hasattr(welcome, 'refresh_recent'):
                    welcome.refresh_recent()
        except Exception:
            logger.debug("Failed to record last-used time", exc_info=True)
        for row in self.window._rows_for_connection(terminal.connection):
            row.update_status()
            row.queue_draw()

        if hasattr(self.window, "_hide_reconnecting_message"):
            GLib.idle_add(self.window._hide_reconnecting_message)

        self.window._is_controlled_reconnect = False
        if not getattr(self.window, '_is_controlled_reconnect', False):
            host_value = getattr(terminal.connection, 'hostname', getattr(terminal.connection, 'host', getattr(terminal.connection, 'nickname', '')))
            logger.info(
                f"Terminal connected: {terminal.connection.nickname} ({terminal.connection.username}@{host_value})"
            )
        else:
            logger.debug(
                f"Terminal reconnected after settings update: {terminal.connection.nickname}")

        # Notify plugins (session_opened). The host dedupes reconnects by
        # terminal identity, so this is safe to call on every connect.
        try:
            host = getattr(self.window, 'plugin_host', None)
            if host is not None:
                host.dispatch_session_opened(terminal)
        except Exception:
            logger.exception("Plugin session_opened dispatch failed")

        self._maybe_offer_save_connection(terminal)

    def _maybe_offer_save_connection(self, terminal):
        """After a successful CLI/ad-hoc connect, offer to save if the host is unsaved."""
        try:
            connection = getattr(terminal, 'connection', None)
            if connection is None:
                return
            data = getattr(connection, 'data', None) or {}
            from .cli_connect import CLI_CONNECT_FLAG
            if not data.get(CLI_CONNECT_FLAG):
                return

            from .unsaved_host import SavePromptDismissals, is_unsaved_host

            window = self.window
            app = window.get_application() if hasattr(window, 'get_application') else None
            dismissals = getattr(app, 'save_prompt_dismissals', None) if app else None
            if dismissals is None:
                dismissals = SavePromptDismissals()
                if app is not None:
                    app.save_prompt_dismissals = dismissals

            mgr = getattr(window, 'connection_manager', None)
            cfg_path = getattr(mgr, 'ssh_config_path', None) if mgr else None
            if dismissals.is_connection_dismissed(connection, config_file=cfg_path):
                return
            if not is_unsaved_host(connection, mgr, config_file=cfg_path):
                return

            def _on_save(term):
                try:
                    window.show_connection_dialog(term.connection, as_new=True)
                except Exception:
                    logger.debug('Failed to open save connection dialog', exc_info=True)

            def _on_dismiss(term):
                try:
                    dismissals.dismiss_connection(
                        term.connection, config_file=cfg_path)
                except Exception:
                    logger.debug('Failed to record save-prompt dismissal', exc_info=True)

            if hasattr(terminal, 'show_save_connection_prompt'):
                # Delay the reveal so the revealer is mapped by the time it
                # fires; otherwise GTK skips the slide-up animation.
                from gi.repository import GLib

                def _reveal():
                    terminal.show_save_connection_prompt(
                        on_save=_on_save, on_dismiss=_on_dismiss)
                    return False

                GLib.timeout_add(600, _reveal)
        except Exception:
            logger.debug('Save-connection offer skipped', exc_info=True)

    def on_terminal_disconnected(self, terminal):
        # The just-disconnected terminal has already flipped its own
        # is_connected flag; recompute the connection's aggregate state so a
        # connection with other live terminals stays CONNECTED.
        self.window._recompute_connection_state(terminal.connection)
        for row in self.window._rows_for_connection(terminal.connection):
            row.update_status()
            row.queue_draw()
        host_value = getattr(
            terminal.connection,
            'hostname',
            getattr(terminal.connection, 'host', getattr(terminal.connection, 'nickname', ''))
        )
        logger.info(
            f"Terminal disconnected: {terminal.connection.nickname} ({terminal.connection.username}@{host_value})"
        )

        # Notify plugins (session_closed).
        try:
            host = getattr(self.window, 'plugin_host', None)
            if host is not None:
                host.dispatch_session_closed(terminal)
        except Exception:
            logger.exception("Plugin session_closed dispatch failed")

    def on_terminal_title_changed(self, terminal, title):
        page = self.window._page_for_child(terminal)
        if page:
            if getattr(page, 'custom_tab_title', None):
                return
            if title and title != terminal.connection.nickname:
                page.set_title(_("{nickname} - {title}").format(nickname=terminal.connection.nickname, title=title))
            else:
                page.set_title(terminal.connection.nickname)

    def get_terminal_job_status(self, terminal):
        """
        Get the job status of a terminal.
        Only works for local terminals.
        
        Args:
            terminal: TerminalWidget instance
            
        Returns:
            dict: Job status information including idle state and status string
        """
        if not hasattr(terminal, 'is_terminal_idle'):
            return {
                'is_idle': False,
                'has_active_job': False,
                'status': 'UNKNOWN',
                'error': 'Terminal does not support job detection'
            }
        
        try:
            is_idle = terminal.is_terminal_idle()
            has_active_job = terminal.has_active_job()
            status = terminal.get_job_status()
            
            # Check if this is an SSH terminal
            is_ssh = status == "SSH_TERMINAL"
            
            return {
                'is_idle': is_idle,
                'has_active_job': has_active_job,
                'status': status,
                'is_ssh_terminal': is_ssh,
                'error': None
            }
        except Exception as e:
            return {
                'is_idle': False,
                'has_active_job': False,
                'status': 'ERROR',
                'is_ssh_terminal': False,
                'error': str(e)
            }

    def get_all_terminal_job_statuses(self):
        """
        Get job status for all active terminals.
        Only local terminals will have meaningful job detection.
        
        Returns:
            list: List of dicts with terminal info and job status
        """
        statuses = []

        for terminal_widget in self.iter_terminals():
            if not (hasattr(terminal_widget, 'backend') or hasattr(terminal_widget, 'vte')):
                continue

            connection_info = (
                terminal_widget.get_connection_info()
                if hasattr(terminal_widget, 'get_connection_info') else None
            )
            job_status = self.get_terminal_job_status(terminal_widget)
            connection = getattr(terminal_widget, 'connection', None)
            page_title = getattr(connection, 'nickname', None) or _('Terminal')

            statuses.append({
                'page_title': page_title,
                'connection_info': connection_info,
                'job_status': job_status,
            })

        return statuses

    # --- Controlled reconnect after a connection edit ----------------------

    def prompt_reconnect(self, connection):
        """Ask whether to reconnect *connection* with its updated settings."""
        dialog = Adw.AlertDialog(
            heading=_("Settings Changed"),
            body=_("The connection settings have been updated.\n"
                   "Would you like to reconnect with the new settings?"),
        )
        dialog.add_response('cancel', _("Cancel"))
        dialog.add_response('reconnect', _("Reconnect"))
        dialog.set_response_appearance('reconnect', Adw.ResponseAppearance.SUGGESTED)
        dialog.set_default_response('reconnect')
        dialog.set_close_response('cancel')
        dialog.connect('response', self._on_reconnect_response, connection)
        dialog.present(self.window)

    def _on_reconnect_response(self, dialog, response, connection):
        """Handle response from the reconnect prompt."""
        window = self.window
        # Only proceed if the user confirmed and the connection is still active
        if response != 'reconnect' or connection not in window.active_terminals:
            # Clean up the stored terminal instance if it exists
            if hasattr(connection, '_terminal_instance'):
                delattr(connection, '_terminal_instance')
            return

        # Get the terminal instance either from active_terminals or the stored instance
        terminal = window.active_terminals.get(connection) or getattr(connection, '_terminal_instance', None)
        if not terminal:
            logger.warning("No terminal instance found for reconnection")
            return

        # Ensure the tab for this connection is focused so the user can
        # observe the reconnection process even if another tab was
        # previously active.
        try:
            window._focus_most_recent_tab(connection)
        except Exception:
            pass

        # Set controlled reconnect flag (read by terminal.py via the root window)
        window._is_controlled_reconnect = True

        try:
            # Disconnect first (defer to avoid blocking)
            logger.debug("Disconnecting terminal before reconnection")

            def _safe_disconnect():
                try:
                    terminal.disconnect()
                    logger.debug("Terminal disconnected, scheduling reconnect")
                    # Store the connection temporarily in active_terminals if not present
                    if connection not in window.active_terminals:
                        window.active_terminals[connection] = terminal
                    # Reconnect after disconnect completes
                    GLib.timeout_add(1000, self._reconnect_terminal, connection)
                except Exception as e:
                    logger.error(f"Error during disconnect: {e}")
                    GLib.idle_add(self._show_reconnect_error, connection, str(e))
                return False

            # Defer disconnect to avoid blocking the UI thread
            GLib.idle_add(_safe_disconnect)

        except Exception as e:
            logger.error(f"Error during reconnection: {e}")
            # Remove from active terminals if reconnection fails
            if connection in window.active_terminals:
                del window.active_terminals[connection]
            window._error_dialog(
                _("Reconnection Failed"),
                _("Failed to reconnect with the new settings. Please try connecting again manually."),
            )

        finally:
            # Clean up the stored terminal instance
            if hasattr(connection, '_terminal_instance'):
                delattr(connection, '_terminal_instance')

            # Reset the flag after a delay to ensure it's not set during normal operations
            GLib.timeout_add(1000, self._reset_controlled_reconnect)

    def _reset_controlled_reconnect(self):
        """Reset the controlled reconnect flag"""
        self.window._is_controlled_reconnect = False

    def _reconnect_terminal(self, connection):
        """Reconnect a terminal with updated connection settings"""
        window = self.window
        if connection not in window.active_terminals:
            logger.warning(f"Connection {connection.nickname} not found in active terminals")
            return False  # Don't repeat the timeout

        terminal = window.active_terminals[connection]

        try:
            logger.debug(f"Attempting to reconnect terminal for {connection.nickname}")

            # Rebuild the SSH command using the latest configuration so that
            # options resolved via ssh -G are honored for the reconnect.
            # Plugin protocols rebuild statelessly in build_spawn() instead.
            if getattr(connection, 'protocol', 'ssh') == 'ssh':
                try:
                    loop = asyncio.get_event_loop()
                    # Native-only (connect() delegates to native_connect()).
                    if hasattr(connection, 'native_connect'):
                        connect_coro = connection.native_connect()
                    else:
                        connect_coro = connection.connect()
                    if loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(connect_coro, loop)
                        future.result()
                    else:
                        loop.run_until_complete(connect_coro)
                except Exception as prep_err:
                    logger.error(
                        "Failed to prepare SSH command before reconnect: %s",
                        prep_err,
                    )
                    GLib.idle_add(self._show_reconnect_error, connection, str(prep_err))
                    return False

            # Reconnect with new settings
            if not terminal._connect_ssh():
                logger.error("Failed to reconnect with new settings")
                GLib.idle_add(self._show_reconnect_error, connection)
                return False

            logger.info(f"Successfully reconnected terminal for {connection.nickname}")

        except Exception as e:
            logger.error(f"Error reconnecting terminal: {e}", exc_info=True)
            GLib.idle_add(self._show_reconnect_error, connection, str(e))

        return False  # Don't repeat the timeout

    def _show_reconnect_error(self, connection, error_message=None):
        """Show an error message when reconnection fails"""
        window = self.window
        # Remove from active terminals if reconnection fails
        if connection in window.active_terminals:
            del window.active_terminals[connection]

        # Update UI to show disconnected state
        for row in window._rows_for_connection(connection):
            row.update_status()

        window._error_dialog(
            _("Reconnection Failed"),
            error_message or _("Failed to reconnect with the new settings. Please try connecting again manually."),
        )
