# ashyterm/terminal/manager.py

import os
import threading
from functools import lru_cache
from typing import Any, Callable, Dict, List, Optional, Union

import gi

gi.require_version("Adw", "1")
gi.require_version("Gtk", "4.0")
gi.require_version("Vte", "3.91")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk, Vte

from ..sessions.models import SessionItem
from ..settings.manager import SettingsManager
from ..utils.exceptions import TerminalCreationError
from ..utils.logger import get_logger, log_terminal_event
from ..utils.osc7 import OSC7Info, parse_directory_uri
from ..utils.osc7_tracker import get_osc7_tracker
from ..utils.platform import get_environment_manager, get_platform_info
from ..utils.security import validate_session_data
from ..utils.translation_utils import _
from .registry import ManualSSHTracker, TerminalLifecycleManager, TerminalRegistry
from .ssh_lifecycle import SSHLifecycleMixin
from .terminal_config import (
    compute_highlighting_config as _compute_highlighting_config_impl,
    get_ssh_highlight_config as _get_ssh_highlight_config_impl,
    resolve_working_directory as _resolve_working_directory_impl,
)
from .terminal_drag_drop import (
    emit_ssh_file_drop_signal as _emit_ssh_file_drop_signal_impl,
    handle_sftp_drop as _handle_sftp_drop_impl,
    handle_ssh_drop as _handle_ssh_drop_impl,
    setup_sftp_drop as _setup_sftp_drop_impl,
    setup_ssh_drop as _setup_ssh_drop_impl,
)
from .terminal_title import (
    compute_title,
    format_duration,
    local_title,
    ssh_title,
)
from .url_handler import URLHandlerMixin

# Lazy imports for heavy modules - loaded on first use

@lru_cache(maxsize=1)
def _get_spawner():
    """Lazy import spawner module."""
    from .spawner import get_spawner
    return get_spawner()


@lru_cache(maxsize=1)
def _get_highlight_manager():
    """Lazy import highlight manager."""
    from ..settings.highlights import get_highlight_manager
    return get_highlight_manager()


@lru_cache(maxsize=1)
def _get_output_highlighter():
    """Lazy import output highlighter."""
    from .highlighter import get_output_highlighter
    return get_output_highlighter()


@lru_cache(maxsize=1)
def _get_terminal_menu_creator():
    """Lazy import terminal menu creator."""
    from ..ui.menus import create_terminal_menu
    return create_terminal_menu


# Regex signatures for paste content that deserves a confirmation prompt
# when the user has the safety net enabled. Intentionally narrow — we
# don't want to block mundane multi-line commits from the clipboard.
import re as _re

_RISKY_PASTE_PATTERNS: tuple = (
    _re.compile(r"\bsudo\b", _re.IGNORECASE),
    _re.compile(r"\brm\s+-[rRfF]", _re.IGNORECASE),
    _re.compile(r"\bmkfs\.", _re.IGNORECASE),
    _re.compile(r"\bdd\s+[^|]*of=/dev/", _re.IGNORECASE),
    _re.compile(r"\bcurl\b[^|]*\|\s*(sudo\s+)?(bash|sh)\b", _re.IGNORECASE),
    _re.compile(r"\bwget\b[^|]*\|\s*(sudo\s+)?(bash|sh)\b", _re.IGNORECASE),
    _re.compile(r"\bchmod\s+(-R\s+)?[0-7]*777\b"),
    _re.compile(r">\s*/dev/sd[a-z]"),
)


def _paste_needs_confirmation(text: str) -> bool:
    """True when ``text`` looks multi-line or matches a risky pattern."""
    if not text:
        return False
    if "\n" in text.rstrip("\n"):  # a trailing newline alone is fine
        return True
    for rx in _RISKY_PASTE_PATTERNS:
        if rx.search(text):
            return True
    return False


def _create_terminal_menu(*args, **kwargs):
    """Create terminal menu via lazy-loaded creator."""
    return _get_terminal_menu_creator()(*args, **kwargs)


class TerminalManager(SSHLifecycleMixin, URLHandlerMixin):
    def __init__(self, parent_window, settings_manager: SettingsManager, spawner=None):
        self.logger = get_logger("ashyterm.terminal.manager")
        self.parent_window = parent_window
        self.settings_manager = settings_manager
        self.platform_info = get_platform_info()
        self.environment_manager = get_environment_manager()
        self.registry = TerminalRegistry()
        self.spawner = spawner or _get_spawner()
        self.lifecycle_manager = TerminalLifecycleManager(self.registry, self.logger)
        self.osc7_tracker = get_osc7_tracker(settings_manager)
        self.manual_ssh_tracker = ManualSSHTracker(
            self.registry, self._on_manual_ssh_state_changed
        )
        self._creation_lock = threading.Lock()
        self._cleanup_lock = threading.Lock()
        self._pending_kill_timers: Dict[int, int] = {}
        self.tab_manager = None
        self.on_terminal_focus_changed: Optional[Callable] = None
        self.terminal_exit_handler: Optional[Callable] = None
        self._stats = {
            "terminals_created": 0,
            "terminals_failed": 0,
            "terminals_closed": 0,
        }
        self._highlight_proxies: Dict[
            int, Any
        ] = {}  # Dict[int, HighlightedTerminalProxy]
        self._highlight_manager = None
        # Track when commands start for long-running command notifications
        self._command_start_times: Dict[int, float] = {}
        # Cached hostname for OSC7 URI assembly; os.uname() is a syscall we
        # don't want to do once per tab per tick.
        self._cached_hostname: str = os.uname().nodename
        # Process check timer — runs every 2 seconds. Low enough that tab
        # titles feel live, high enough not to waste CPU scanning /proc.
        self._PERIODIC_INTERVAL_MS = 2000
        self._process_check_timer_id = GLib.timeout_add(
            self._PERIODIC_INTERVAL_MS, self._periodic_process_check
        )
        self.logger.info("Terminal manager initialized")

    def _ensure_process_check_timer(self) -> None:
        """Re-arm the periodic process check timer if it was stopped."""
        if self._process_check_timer_id is None:
            self._process_check_timer_id = GLib.timeout_add(
                self._PERIODIC_INTERVAL_MS, self._periodic_process_check
            )

    def prepare_initial_terminal(self) -> None:
        """
        Pre-create the base terminal widget and prepare shell environment.

        All work happens on the GTK main thread. Earlier versions ran the
        shell-env prep and highlight pre-load on worker threads from the
        AsyncTaskManager pool; that pattern is incompatible with GTK4 +
        GLib >= 2.86 + PyGObject 3.50+ because:

        * Constructing GObject types (HighlightManager, OutputHighlighter)
          or connecting GObject signals from a non-main thread is undefined
          behavior and corrupts the GLib heap.
        * Even seemingly innocuous ``gi.repository`` calls
          (``Vte.get_user_shell()``, reading ``Vte.MAJOR_VERSION``, etc.)
          can lazily trigger non-thread-safe GIRepository initialization.

        Heap corruption from a worker thread surfaces later as a cryptic
        ``GLib-ERROR: g_malloc: failed to allocate 1 bytes`` abort during
        an unrelated main-thread allocation (e.g. during CSS load).

        The shell-env prep takes well under a millisecond, so doing it
        inline costs nothing observable. The highlight pre-load (which
        parses ~50 JSON files) is deferred to a ``GLib.idle_add`` callback
        so it runs once the main loop becomes idle, after the first frame.
        """
        self._precreated_terminal = None
        # Kept for API compatibility with consumers that still call
        # ``_precreated_env_ready.wait(...)``. Set immediately because env
        # prep is now synchronous.
        self._precreated_env_ready = threading.Event()
        self._precreated_env_data = None
        self._highlights_ready = threading.Event()

        try:
            self._precreated_terminal = self._create_base_terminal(apply_settings=False)
            if self._precreated_terminal:
                self.logger.info("Pre-created base terminal widget for faster startup")
        except Exception as e:
            self.logger.warning(f"Failed to pre-create terminal: {e}")
            self._precreated_terminal = None

        try:
            cmd, env, temp_dir_path, shell_name = self.spawner._prepare_shell_environment()
            self._precreated_env_data = (cmd, env, temp_dir_path, shell_name)
            self.logger.debug("Pre-prepared shell environment on main thread")
        except Exception as e:
            self.logger.warning(f"Failed to pre-prepare shell environment: {e}")
            self._precreated_env_data = None
        finally:
            self._precreated_env_ready.set()

        def prepare_highlights_on_main_thread():
            try:
                from ..settings.highlights import get_highlight_manager

                self._highlight_manager = get_highlight_manager()
                self.logger.debug("Pre-loaded HighlightManager (JSON rules) on main thread")
            except Exception as e:
                self.logger.warning(f"Failed to pre-load HighlightManager: {e}")

            try:
                from .highlighter.output import get_output_highlighter
                from .highlighter.shell_input import get_shell_input_highlighter

                get_output_highlighter()
                get_shell_input_highlighter()
                self.logger.debug("Pre-loaded highlighting modules on main thread")
            except Exception as e:
                self.logger.warning(f"Failed to pre-load highlights: {e}")
            finally:
                self._highlights_ready.set()
            return GLib.SOURCE_REMOVE

        GLib.idle_add(prepare_highlights_on_main_thread)

    def get_precreated_terminal(self) -> "Optional[Vte.Terminal]":
        """
        Get the pre-created terminal if available.
        Returns None if no terminal was pre-created or it was already consumed.
        """
        terminal = getattr(self, "_precreated_terminal", None)
        self._precreated_terminal = None
        return terminal

    def get_precreated_env_data(self, timeout: float = 0.1) -> "Optional[tuple]":
        """``(cmd, env, temp_dir)`` from the precomputed env, or ``None`` if not ready."""
        ready_event = getattr(self, "_precreated_env_ready", None)
        if ready_event and ready_event.wait(timeout):
            data = getattr(self, "_precreated_env_data", None)
            self._precreated_env_data = None
            return data
        return None

    def _get_highlight_manager(self):
        if self._highlight_manager is None:
            self._highlight_manager = _get_highlight_manager()
        return self._highlight_manager

    def _cleanup_highlight_proxy(self, terminal_id: int):
        proxy = self._highlight_proxies.pop(terminal_id, None)
        if proxy:
            try:
                proxy.stop()
                self.logger.debug(f"Stopped highlight proxy for terminal {terminal_id}")
            except Exception as e:
                self.logger.error(f"Error stopping highlight proxy: {e}")

    def pause_highlight_proxy(self, terminal_id: int) -> None:
        """Pause highlighting for an inactive terminal."""
        proxy = self._highlight_proxies.get(terminal_id)
        if proxy:
            proxy.pause_highlighting()

    def resume_highlight_proxy(self, terminal_id: int) -> None:
        """Resume highlighting when terminal becomes visible."""
        proxy = self._highlight_proxies.get(terminal_id)
        if proxy:
            proxy.resume_highlighting()

    def apply_settings_to_all_terminals(self):
        self.logger.info("Applying settings to all active terminals.")
        for terminal_id in self.registry.get_all_terminal_ids():
            terminal = self.registry.get_terminal(terminal_id)
            if terminal:
                try:
                    self.settings_manager.apply_terminal_settings(
                        terminal, self.parent_window
                    )
                except Exception as e:
                    self.logger.error(
                        f"Failed to apply settings to terminal {terminal_id}: {e}"
                    )

    def set_tab_manager(self, tab_manager):
        self.tab_manager = tab_manager

    def set_terminal_exit_handler(self, handler: Callable):
        self.terminal_exit_handler = handler

    def _periodic_process_check(self) -> bool:
        """
        Periodic check to detect manual SSH sessions and update CWD titles.

        Runs every self._PERIODIC_INTERVAL_MS. Does two things:
        - Probes the active tab for a manual SSH session.
        - Kicks off an off-thread /proc/<pid>/cwd snapshot and applies the
          results back on the main thread.
        """
        if not self.registry.get_all_terminal_ids():
            self._process_check_timer_id = None
            return False

        try:
            if self.parent_window and hasattr(self.parent_window, "tab_manager"):
                active_terminal = self.parent_window.tab_manager.get_selected_terminal()

                if active_terminal:
                    terminal_id = getattr(active_terminal, "terminal_id", None)
                    if terminal_id is not None:
                        self.manual_ssh_tracker.check_process_tree(terminal_id)

            self._poll_terminal_cwd_async()
        except Exception as e:
            self.logger.debug(f"Periodic check error: {e}")
        return True

    def _poll_terminal_cwd_async(self) -> None:
        """Read /proc/<pid>/cwd off the main thread, apply titles back on it.

        `os.readlink` itself is cheap, but doing N of them per tick in the
        GTK main thread can still stall the UI when /proc is slow (e.g.
        under heavy fork load or on containers with cgroup limits).
        """
        processes = self.spawner.process_tracker.snapshot()
        # Keep only tabs where we both have a live terminal widget and
        # the process is local. SSH tabs are tracked by OSC7 and don't
        # need this polling.
        candidates: list[tuple[int, Vte.Terminal, str]] = []
        for pid, info in processes.items():
            if info.get("type") != "local":
                continue
            terminal = info.get("terminal")
            if terminal is None:
                continue
            last_cwd = getattr(terminal, "_last_polled_cwd", None)
            candidates.append((pid, terminal, last_cwd or ""))

        if not candidates:
            return

        from ..core.tasks import AsyncTaskManager

        def worker() -> list[tuple[Vte.Terminal, str]]:
            updates: list[tuple[Vte.Terminal, str]] = []
            for pid, terminal, last_cwd in candidates:
                try:
                    cwd = os.readlink(f"/proc/{pid}/cwd")
                except OSError:
                    continue
                if cwd != last_cwd:
                    updates.append((terminal, cwd))
            return updates

        def apply_updates(updates: list[tuple[Vte.Terminal, str]]) -> bool:
            for terminal, cwd in updates:
                terminal._last_polled_cwd = cwd
                osc7_info = parse_directory_uri(
                    f"file://{self._cached_hostname}{cwd}",
                    self.osc7_tracker.parser,
                )
                if osc7_info:
                    self._update_title(terminal, osc7_info)
            return False

        future = AsyncTaskManager.get().submit_io(worker)
        if future is None:
            # Pool shutting down; fall back to doing it inline so we don't
            # drop state updates during teardown.
            apply_updates(worker())
            return

        def _on_done(f) -> None:
            try:
                updates = f.result()
            except Exception as exc:
                self.logger.debug(f"CWD poll worker failed: {exc}")
                return
            if updates:
                GLib.idle_add(apply_updates, updates)

        future.add_done_callback(_on_done)

    def _on_manual_ssh_state_changed(self, terminal: Vte.Terminal):
        self._update_title(terminal)
        return False

    def _resolve_working_directory(
        self, working_directory: Optional[str]
    ) -> Optional[str]:
        return _resolve_working_directory_impl(working_directory)

    def _on_directory_uri_changed(self, terminal: Vte.Terminal, _param_spec):
        try:
            uri = terminal.get_current_directory_uri()
            osc7_info = parse_directory_uri(uri, self.osc7_tracker.parser)
            if osc7_info:
                self._update_title(terminal, osc7_info)
            self._check_long_command_notification(terminal)
        except Exception as e:
            self.logger.error(f"Directory URI change handling failed: {e}")

    def _on_terminal_commit(
        self, terminal: Vte.Terminal, text: str, size: int, terminal_id: int
    ):
        """Track when user sends input containing Enter (command execution)."""
        import time

        if "\r" in text or "\n" in text:
            self._command_start_times[terminal_id] = time.monotonic()

    def _check_long_command_notification(self, terminal: Vte.Terminal):
        """Send desktop notification if a long-running command finished in a background tab."""
        import time

        terminal_id = getattr(terminal, "terminal_id", None)
        if terminal_id is None:
            return

        start_time = self._command_start_times.pop(terminal_id, None)
        if start_time is None:
            return

        threshold = self.settings_manager.get("long_command_threshold", 30)
        if threshold <= 0:
            return

        elapsed = time.monotonic() - start_time
        if elapsed < threshold:
            return

        # Only notify if this terminal is NOT in the foreground
        if self._is_terminal_in_foreground(terminal):
            return

        app = self.parent_window.get_application()
        if not app:
            return

        terminal_info = self.registry.get_terminal_info(terminal_id)
        tab_label = "Terminal"
        if terminal_info:
            tab_label = terminal_info.get("name", "Terminal")

        elapsed_str = self._format_duration(elapsed)
        notification = Gio.Notification.new(_("Command Finished"))
        notification.set_body(
            _("{tab} — completed after {time}").format(tab=tab_label, time=elapsed_str)
        )
        notification.set_icon(Gio.ThemedIcon.new("utilities-terminal-symbolic"))
        app.send_notification(f"ashy-long-cmd-{terminal_id}", notification)

    def _is_terminal_in_foreground(self, terminal: Vte.Terminal) -> bool:
        """Check if a terminal is the currently visible/active one."""
        if not self.tab_manager:
            return True
        active_tab = self.tab_manager.active_tab
        if not active_tab:
            return False
        page = self.tab_manager.pages.get(active_tab)
        if not page:
            return False
        # Check if this terminal is visible in the active tab's page
        return terminal.is_visible() and terminal.get_mapped()

    _format_duration = staticmethod(format_duration)

    def _compute_terminal_title(
        self,
        terminal_info: dict,
        terminal_id: int,
        terminal: Vte.Terminal,
        osc7_info: Optional[OSC7Info],
    ) -> str:
        """Computes the display title for a terminal based on its type."""
        # Only the local branch needs the manual-SSH lookup; only the
        # SFTP branch needs a tab_manager query. Pass both in so the
        # title renderer stays pure.
        return compute_title(
            terminal_info,
            ssh_target=self.manual_ssh_tracker.get_ssh_target(terminal_id),
            sftp_title=self._get_sftp_title(terminal_info, terminal),
            osc7_info=osc7_info,
        )

    def _get_ssh_title(self, terminal_info: dict, osc7_info: Optional[OSC7Info]) -> str:
        return ssh_title(terminal_info, osc7_info)

    def _get_local_title(
        self, terminal_info: dict, terminal_id: int, osc7_info: Optional[OSC7Info]
    ) -> str:
        return local_title(
            terminal_info,
            ssh_target=self.manual_ssh_tracker.get_ssh_target(terminal_id),
            osc7_info=osc7_info,
        )

    def _get_sftp_title(self, terminal_info: dict, terminal: Vte.Terminal) -> str:
        """Gets the title for an SFTP terminal.

        Kept on the manager because it needs to query the tab_manager
        for the authoritative SFTP base title (tracked on the tab).
        """
        session = terminal_info.get("identifier")
        if isinstance(session, SessionItem):
            return self._get_sftp_display_title(session, terminal)
        return "Terminal"

    def _update_title(
        self, terminal: Vte.Terminal, osc7_info: Optional[OSC7Info] = None
    ):
        terminal_id = getattr(terminal, "terminal_id", None)
        if terminal_id is None:
            return
        terminal_info = self.registry.get_terminal_info(terminal_id)
        if not terminal_info:
            return

        if osc7_info is None:
            uri = terminal.get_current_directory_uri()
            if uri:
                osc7_info = parse_directory_uri(uri, self.osc7_tracker.parser)

        # Fallback: use last polled CWD from /proc/<pid>/cwd.
        if osc7_info is None:
            last_cwd = getattr(terminal, "_last_polled_cwd", None)
            if last_cwd:
                osc7_info = parse_directory_uri(
                    f"file://{self._cached_hostname}{last_cwd}",
                    self.osc7_tracker.parser,
                )

        new_title = self._compute_terminal_title(
            terminal_info, terminal_id, terminal, osc7_info
        )

        if self.tab_manager:
            self.tab_manager.update_titles_for_terminal(terminal, new_title, osc7_info)

    def _get_sftp_display_title(
        self, session: SessionItem, terminal: Vte.Terminal
    ) -> str:
        if self.tab_manager:
            page = self.tab_manager.get_page_for_terminal(terminal)
            if page:
                for tab in self.tab_manager.tabs:
                    if self.tab_manager.pages.get(tab) == page:
                        return getattr(tab, "_base_title", f"SFTP-{session.name}")
        return f"SFTP-{session.name}"

    def create_local_terminal(
        self,
        session: Optional[SessionItem] = None,
        title: str = "Local Terminal",
        working_directory: Optional[str] = None,
        execute_command: Optional[str] = None,
        close_after_execute: bool = False,
    ):
        terminal = self._get_or_create_terminal()
        if not terminal:
            raise TerminalCreationError("base terminal creation failed", "local")

        identifier = session if session else title
        terminal_id = self.registry.register_terminal(terminal, "local", identifier)
        self._setup_terminal_events(terminal, identifier, terminal_id)
        self._ensure_process_check_timer()

        try:
            resolved_working_dir = self._resolve_working_directory(working_directory)
            if working_directory and not resolved_working_dir:
                self.logger.warning(
                    f"Invalid working directory '{working_directory}', using default"
                )

            user_data_for_spawn = (
                terminal_id,
                {
                    "execute_command": execute_command,
                    "close_after_execute": close_after_execute,
                },
            )

            should_highlight, _ = self._compute_highlighting_config(
                session, is_local=True
            )

            if should_highlight:
                self._spawn_highlighted_local(
                    terminal,
                    session,
                    user_data_for_spawn,
                    resolved_working_dir,
                    terminal_id,
                )
            else:
                precreated_env = self._get_precreated_env(working_directory)
                self.spawner.spawn_local_terminal(
                    terminal,
                    callback=self._on_spawn_callback,
                    user_data=user_data_for_spawn,
                    working_directory=resolved_working_dir,
                    precreated_env=precreated_env,
                )

            self._log_terminal_creation(session, title, terminal_id, "local")
            return terminal
        except TerminalCreationError:
            self.registry.unregister_terminal(terminal_id)
            self._cleanup_highlight_proxy(terminal_id)
            self._stats["terminals_failed"] += 1
            raise

    def _get_or_create_terminal(self) -> Optional[Vte.Terminal]:
        """Get a pre-created terminal or create a new one."""
        terminal = self.get_precreated_terminal()
        if terminal:
            self.logger.debug("Using pre-created terminal for faster startup")
            self.settings_manager.apply_terminal_settings(terminal, self.parent_window)
            self.logger.debug("Applied terminal settings to pre-created terminal")
        else:
            terminal = self._create_base_terminal()
        return terminal

    def _get_precreated_env(self, working_directory: Optional[str]):
        """Get pre-prepared environment if no custom working directory."""
        if not working_directory:
            return self.get_precreated_env_data(timeout=0.05)
        return None

    def _compute_highlighting_config(
        self, session: Optional[SessionItem], is_local: bool
    ) -> tuple[bool, dict]:
        return _compute_highlighting_config_impl(
            session=session,
            is_local=is_local,
            highlight_manager=self._get_highlight_manager(),
            settings_manager=self.settings_manager,
        )

    def _spawn_highlighted_local(
        self,
        terminal,
        session: Optional[SessionItem],
        user_data_for_spawn,
        resolved_working_dir: Optional[str],
        terminal_id: int,
    ) -> None:
        """Spawn a highlighted local terminal."""
        # Wait briefly for highlight modules if preparing
        highlights_ready = getattr(self, "_highlights_ready", None)
        if highlights_ready is not None:
            highlights_ready.wait(timeout=0.05)

        proxy = self.spawner.spawn_highlighted_local_terminal(
            terminal,
            session=session,
            callback=self._on_spawn_callback,
            user_data=user_data_for_spawn,
            working_directory=resolved_working_dir,
            terminal_id=terminal_id,
        )
        if proxy:
            self._highlight_proxies[terminal_id] = proxy
            self.logger.info(f"Highlighted local terminal spawned (ID: {terminal_id})")
        else:
            self.logger.warning(
                "Highlighted spawn failed, falling back to standard spawning"
            )
            self.spawner.spawn_local_terminal(
                terminal,
                callback=self._on_spawn_callback,
                user_data=user_data_for_spawn,
                working_directory=resolved_working_dir,
            )

    def _log_terminal_creation(
        self,
        session: Optional[SessionItem],
        title: str,
        terminal_id: int,
        term_type: str,
    ) -> None:
        """Log terminal creation event."""
        log_title = session.name if session else title
        self.logger.info(
            f"{term_type.capitalize()} terminal created successfully: '{log_title}' (ID: {terminal_id})"
        )
        log_terminal_event("created", log_title, f"{term_type} terminal")
        self._stats["terminals_created"] += 1

    def _validate_session(self, session: SessionItem, terminal_type: str) -> None:
        """Validates session data before terminal creation."""
        session_data = session.to_dict()
        is_valid, errors = validate_session_data(session_data)
        if not is_valid:
            error_msg = f"Session validation failed for {terminal_type.upper()}: {', '.join(errors)}"
            raise TerminalCreationError(error_msg, terminal_type)

    def _setup_remote_terminal(
        self, session: SessionItem, terminal_type: str
    ) -> tuple[Vte.Terminal, int]:
        """Creates and sets up a remote terminal, returning terminal and ID."""
        terminal = self._create_base_terminal()
        if not terminal:
            raise TerminalCreationError(
                f"base terminal creation failed for {terminal_type.upper()}",
                terminal_type,
            )

        terminal_id = self.registry.register_terminal(terminal, terminal_type, session)
        self._setup_terminal_events(terminal, session, terminal_id)
        self._ensure_process_check_timer()
        return terminal, terminal_id

    def _spawn_ssh_terminal(
        self,
        terminal: Vte.Terminal,
        session: SessionItem,
        user_data: tuple,
        initial_command: Optional[str],
        terminal_id: int,
    ) -> None:
        """Spawns an SSH terminal with optional highlighting."""
        highlight_config = self._get_ssh_highlight_config(session)

        if highlight_config["should_highlight"]:
            self._spawn_highlighted_ssh(
                terminal, session, user_data, initial_command, terminal_id
            )
        else:
            self.spawner.spawn_ssh_session(
                terminal,
                session,
                callback=self._on_spawn_callback,
                user_data=user_data,
                initial_command=initial_command,
            )

        self._setup_ssh_drag_and_drop(terminal, terminal_id)

    def _get_ssh_highlight_config(self, session: SessionItem) -> dict:
        return _get_ssh_highlight_config_impl(
            session=session,
            highlight_manager=self._get_highlight_manager(),
            settings_manager=self.settings_manager,
        )

    def _spawn_highlighted_ssh(
        self,
        terminal: Vte.Terminal,
        session: SessionItem,
        user_data: tuple,
        initial_command: Optional[str],
        terminal_id: int,
    ) -> None:
        """Spawns a highlighted SSH session with fallback."""
        proxy = self.spawner.spawn_highlighted_ssh_session(
            terminal,
            session,
            callback=self._on_spawn_callback,
            user_data=user_data,
            initial_command=initial_command,
            terminal_id=terminal_id,
        )
        if proxy:
            self._highlight_proxies[terminal_id] = proxy
            self.logger.info(f"Highlighted SSH terminal spawned (ID: {terminal_id})")
        else:
            self.logger.warning(
                "Highlighted SSH spawn failed, falling back to standard spawning"
            )
            self.spawner.spawn_ssh_session(
                terminal,
                session,
                callback=self._on_spawn_callback,
                user_data=user_data,
                initial_command=initial_command,
            )

    def _spawn_sftp_terminal(
        self,
        terminal: Vte.Terminal,
        session: SessionItem,
        user_data: tuple,
        local_directory: Optional[str],
        remote_path: Optional[str],
    ) -> None:
        """Spawns an SFTP terminal."""
        self._setup_sftp_drag_and_drop(terminal)
        self.spawner.spawn_sftp_session(
            terminal,
            session,
            callback=self._on_spawn_callback,
            user_data=user_data,
            local_directory=local_directory,
            remote_path=remote_path,
        )

    def _create_remote_terminal(
        self,
        session: SessionItem,
        terminal_type: str,
        initial_command: Optional[str] = None,
        sftp_remote_path: Optional[str] = None,
        sftp_local_directory: Optional[str] = None,
    ) -> Optional[Vte.Terminal]:
        with self._creation_lock:
            self._validate_session(session, terminal_type)
            terminal, terminal_id = self._setup_remote_terminal(session, terminal_type)
            user_data_for_spawn = (terminal_id, session)

            try:
                if terminal_type == "ssh":
                    self._spawn_ssh_terminal(
                        terminal,
                        session,
                        user_data_for_spawn,
                        initial_command,
                        terminal_id,
                    )
                elif terminal_type == "sftp":
                    self._spawn_sftp_terminal(
                        terminal,
                        session,
                        user_data_for_spawn,
                        sftp_local_directory,
                        sftp_remote_path,
                    )
                else:
                    raise ValueError(
                        f"Unsupported remote terminal type: {terminal_type}"
                    )

                self._log_terminal_creation(
                    session, session.name, terminal_id, terminal_type
                )
                return terminal
            except TerminalCreationError:
                self.registry.unregister_terminal(terminal_id)
                self._cleanup_highlight_proxy(terminal_id)
                self._stats["terminals_failed"] += 1
                raise

    def create_ssh_terminal(
        self, session: SessionItem, initial_command: Optional[str] = None
    ) -> Optional[Vte.Terminal]:
        commands: List[str] = []
        if initial_command:
            commands.append(initial_command)
        if session.post_login_command_enabled and session.post_login_command:
            commands.append(session.post_login_command)
        combined_command = "; ".join(commands) if commands else None
        return self._create_remote_terminal(session, "ssh", combined_command)

    def create_sftp_terminal(self, session: SessionItem) -> Optional[Vte.Terminal]:
        remote_path = None
        local_directory = None
        if session.sftp_session_enabled:
            remote_path = session.sftp_remote_directory or None
            local_directory = session.sftp_local_directory or None
        return self._create_remote_terminal(
            session,
            "sftp",
            sftp_remote_path=remote_path,
            sftp_local_directory=local_directory,
        )

    def _create_base_terminal(
        self, apply_settings: bool = True
    ) -> Optional[Vte.Terminal]:
        try:
            terminal = Vte.Terminal()
            terminal.set_vexpand(True)
            terminal.set_hexpand(True)
            terminal.set_mouse_autohide(True)
            terminal.set_cursor_blink_mode(Vte.CursorBlinkMode.ON)
            terminal.set_scroll_on_output(False)
            terminal.set_scroll_on_keystroke(True)
            if hasattr(terminal, "set_scroll_unit_is_pixels"):
                terminal.set_scroll_unit_is_pixels(True)
            if hasattr(terminal, "set_search_highlight_enabled"):
                terminal.set_search_highlight_enabled(True)
            if apply_settings:
                self.settings_manager.apply_terminal_settings(
                    terminal, self.parent_window
                )
            self._setup_context_menu(terminal)
            self._setup_url_patterns(terminal)

            return terminal
        except Exception as e:
            self.logger.error(f"Base terminal creation failed: {e}")
            return None

    def _apply_font_size_change(self, family, new_size):
        self.settings_manager.set("font", f"{family} {new_size}")
        if hasattr(self, "parent_window"):
            self.parent_window._update_font_sizer_widget()
        return False

    # ── Drag-and-drop delegators ────────────────────────────
    # Helpers live in terminal_drag_drop; these wrappers preserve
    # the method signatures the signal connections already use.

    def _setup_sftp_drag_and_drop(self, terminal: Vte.Terminal):
        _setup_sftp_drop_impl(terminal, on_drop=self._on_file_drop)

    def _setup_ssh_drag_and_drop(self, terminal: Vte.Terminal, terminal_id: int):
        _setup_ssh_drop_impl(
            terminal, terminal_id=terminal_id, on_drop=self._on_ssh_file_drop
        )

    def _on_file_drop(
        self, drop_target, value, x, y, terminal: Vte.Terminal
    ) -> bool:
        return _handle_sftp_drop_impl(value, terminal)

    def _on_ssh_file_drop(
        self, drop_target, value, x, y, terminal: Vte.Terminal, terminal_id: int
    ) -> bool:
        return _handle_ssh_drop_impl(self, value, terminal_id)

    def _emit_ssh_file_drop_signal(
        self, terminal_id: int, local_paths: list, session, ssh_target: str
    ):
        return _emit_ssh_file_drop_signal_impl(
            self, terminal_id, local_paths, session, ssh_target
        )

    def set_ssh_file_drop_callback(self, callback):
        """Set callback for SSH file drop events."""
        self._ssh_file_drop_callback = callback

    def _setup_terminal_events(
        self,
        terminal: Vte.Terminal,
        identifier: Union[str, SessionItem],
        terminal_id: int,
    ) -> None:
        try:
            terminal.ashy_handler_ids = []
            terminal.ashy_controllers = []

            handler_id = terminal.connect(
                "child-exited", self._on_child_exited, identifier, terminal_id
            )
            terminal.ashy_handler_ids.append(handler_id)

            handler_id = terminal.connect("eof", self._on_eof, identifier, terminal_id)
            terminal.ashy_handler_ids.append(handler_id)

            handler_id = terminal.connect(
                "notify::current-directory-uri", self._on_directory_uri_changed
            )
            terminal.ashy_handler_ids.append(handler_id)

            handler_id = terminal.connect(
                "commit", self._on_terminal_commit, terminal_id
            )
            terminal.ashy_handler_ids.append(handler_id)

            handler_id = terminal.connect(
                "selection-changed", self._on_selection_changed
            )
            terminal.ashy_handler_ids.append(handler_id)

            self.manual_ssh_tracker.track(terminal_id, terminal)

            click_controller = Gtk.GestureClick()
            click_controller.set_button(1)
            click_controller.connect(
                "pressed", self._on_terminal_clicked, terminal, terminal_id
            )
            terminal.add_controller(click_controller)
            terminal.ashy_controllers.append(click_controller)

            # VTE 0.84 dropped built-in middle-click PRIMARY paste; reinstate it here.
            middle_click_controller = Gtk.GestureClick()
            middle_click_controller.set_button(Gdk.BUTTON_MIDDLE)
            middle_click_controller.connect(
                "pressed", self._on_terminal_middle_clicked, terminal
            )
            terminal.add_controller(middle_click_controller)
            terminal.ashy_controllers.append(middle_click_controller)

            # Context menu is now handled natively by VTE via setup-context-menu signal

            # Key event controller for command detection via screen scraping
            # Captures Enter key press to read the current command line from VTE
            key_controller = Gtk.EventControllerKey()
            key_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
            key_controller.connect(
                "key-pressed",
                self._on_terminal_key_pressed_for_detection,
                terminal,
                terminal_id,
            )
            terminal.add_controller(key_controller)
            terminal.ashy_controllers.append(key_controller)

            terminal.terminal_id = terminal_id
        except Exception as e:
            self.logger.error(
                f"Failed to configure terminal events for ID {terminal_id}: {e}"
            )

    def _setup_context_menu(self, terminal: Vte.Terminal) -> None:
        """Configure VTE's native context menu support."""
        terminal.connect("setup-context-menu", self._on_setup_context_menu)

    def _on_setup_context_menu(self, terminal: Vte.Terminal, context) -> None:
        """Build context menu when VTE requests it (native handler)."""
        terminal_id = getattr(terminal, "terminal_id", None)
        if terminal_id is None:
            return

        menu_model = _create_terminal_menu(
            terminal,
            terminal_id,
            settings_manager=self.settings_manager,
        )
        terminal.set_context_menu_model(menu_model)

    def _on_terminal_focus_in(self, _controller, terminal, terminal_id):
        try:
            self.registry.update_terminal_status(terminal_id, "focused")
            if self.on_terminal_focus_changed:
                self.on_terminal_focus_changed(terminal, False)
        except Exception as e:
            self.logger.error(f"Terminal focus in handling failed: {e}")

    def copy_selection(self, terminal: Vte.Terminal):
        if terminal.get_has_selection():
            terminal.copy_clipboard_format(Vte.Format.TEXT)

    def _on_selection_changed(self, terminal: Vte.Terminal) -> None:
        """Auto-copy selection when ``copy_on_select`` is enabled."""
        if not self.settings_manager.get("copy_on_select", False):
            return
        if terminal.get_has_selection():
            terminal.copy_clipboard_format(Vte.Format.TEXT)

    def _on_terminal_middle_clicked(
        self, gesture: Gtk.GestureClick, _n_press: int, _x: float, _y: float,
        terminal: Vte.Terminal,
    ) -> None:
        try:
            terminal.paste_primary()
        except Exception as e:
            self.logger.error(f"Middle-click paste failed: {e}")
            return
        gesture.set_state(Gtk.EventSequenceState.CLAIMED)

    def paste_clipboard(self, terminal: Vte.Terminal):
        if not self.settings_manager.get("paste_warning_enabled", True):
            terminal.paste_clipboard()
            return
        self._paste_with_confirmation(terminal)

    def _paste_with_confirmation(self, terminal: Vte.Terminal) -> None:
        """Read clipboard, confirm if multi-line or risky, then paste."""
        clipboard = terminal.get_clipboard()

        def on_text(clip, result):
            try:
                text = clip.read_text_finish(result)
            except Exception:
                terminal.paste_clipboard()
                return
            if text is None or not _paste_needs_confirmation(text):
                terminal.paste_clipboard()
                return
            self._show_paste_confirmation(terminal, text)

        clipboard.read_text_async(None, on_text)

    def _show_paste_confirmation(
        self, terminal: Vte.Terminal, text: str
    ) -> None:
        preview = text if len(text) <= 600 else text[:600] + "…"
        dialog = Adw.AlertDialog(
            heading=_("Confirm paste"),
            body=_(
                "The clipboard contains multiple lines or a potentially "
                "risky command. Paste anyway?"
            ),
        )
        scrolled = Gtk.ScrolledWindow(
            min_content_height=160, max_content_height=300, has_frame=True,
        )
        label = Gtk.Label(label=preview, xalign=0.0, yalign=0.0, wrap=True)
        label.add_css_class("monospace")
        label.set_margin_start(8)
        label.set_margin_end(8)
        label.set_margin_top(8)
        label.set_margin_bottom(8)
        scrolled.set_child(label)
        dialog.set_extra_child(scrolled)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("paste", _("Paste"))
        dialog.set_response_appearance(
            "paste", Adw.ResponseAppearance.SUGGESTED
        )
        dialog.set_default_response("cancel")
        dialog.set_close_response("cancel")

        def on_response(_dlg, response):
            if response == "paste":
                terminal.paste_clipboard()

        dialog.connect("response", on_response)
        parent = self.parent_window if hasattr(self, "parent_window") else None
        dialog.present(parent if isinstance(parent, Gtk.Widget) else terminal)

    def select_all(self, terminal: Vte.Terminal):
        terminal.select_all()

    def clear_terminal(self, terminal: Vte.Terminal):
        try:
            terminal.reset(True, True)

            def _send_newline():
                try:
                    if hasattr(terminal, "feed_child_binary"):
                        terminal.feed_child_binary(b"\n")
                    else:
                        terminal.feed_child("\n", -1)
                except Exception as exc:
                    self.logger.debug(f"Failed to send newline after clear: {exc}")
                return GLib.SOURCE_REMOVE

            GLib.timeout_add(120, _send_newline)
            terminal_id = getattr(terminal, "terminal_id", None)
            terminal_name = "terminal"
            if terminal_id is not None:
                info = self.registry.get_terminal_info(terminal_id) or {}
                terminal_name = (
                    info.get("title")
                    or info.get("session_name")
                    or info.get("type")
                    or f"terminal-{terminal_id}"
                )
                log_terminal_event(
                    "cleared", terminal_name, "screen and scrollback cleared"
                )
            self.logger.info(f"Cleared terminal output for {terminal_name}")
        except Exception as e:
            self.logger.error(f"Failed to clear terminal output: {e}")

    def cleanup_all_terminals(self):
        """
        Force closes all terminals managed by this window instance.
        Corrected to only kill processes owned by this window, avoiding global app shutdown.
        """
        if self._process_check_timer_id:
            GLib.source_remove(self._process_check_timer_id)
            self._process_check_timer_id = None

        # Clean up all highlight proxies
        for terminal_id in self._highlight_proxies.copy():
            self._cleanup_highlight_proxy(terminal_id)

        # KILL ONLY LOCAL PROCESSES BELONGING TO THIS WINDOW
        spawner = _get_spawner()
        all_ids = self.registry.get_all_terminal_ids()

        count_killed = 0
        for t_id in all_ids:
            info = self.registry.get_terminal_info(t_id)
            if info and info.get("process_id"):
                pid = info["process_id"]
                # Use the new targeted kill method
                spawner.process_tracker.terminate_process(pid)
                count_killed += 1

        self.logger.info(
            f"cleanup_all_terminals: Terminated {count_killed} processes for this window."
        )
