#!/usr/bin/env python3
"""
nterm Desktop — PyQt6 wrapper for nterm + Wirlwind Telemetry.

Launches the FastAPI backend in-process, renders the nterm web UI in
QWebEngineView (left pane), and hosts Wirlwind TelemetryWidget instances
in a tabbed right pane. Test socket bind ensures the port is available,
enabling multiple simultaneous instances.

Changes from v0.5:
- Persistent display settings (zoom, font scale, contrast) via NtermSettings
- View menu: Zoom In/Out/Reset, Font Scale Up/Down/Reset, Cycle Contrast
- setZoomFactor() wired to QWebEngineView — fixes Mac zoom
- CSS --ui-scale and contrast mode pushed to page via JavaScript
- Settings auto-restore on startup from ~/.nterm/settings.json

Usage:
    python nterm_desktop.py
    python nterm_desktop.py --debug
    python nterm_desktop.py --port 8800        # prefer specific port
    python nterm_desktop.py --remote-debug 9222  # Chrome DevTools remote debug
"""

import sys
import os
import json
import socket
import signal
import logging
import argparse
import threading
import time
from pathlib import Path

from nterm.help_dialog import HelpDialog

# ── Qt environment (must precede QApplication) ───────────────────────
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--no-sandbox --disable-gpu"
os.environ["QTWEBENGINE_DISABLE_SANDBOX"] = "1"

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QSplitter, QTabWidget, QWidget,
    QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QDialog,
    QLineEdit, QComboBox, QFormLayout, QDialogButtonBox,
    QMessageBox, QMenuBar, QMenu, QStatusBar, QToolButton,
    QFileDialog, QTextEdit,
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEnginePage
from PyQt6.QtCore import Qt, QUrl, QTimer, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QAction, QKeySequence, QPalette, QColor, QIcon, QPixmap

import uvicorn

from nterm.vault.vault_api import set_vault_resolver, set_launch_manager_callback
from nterm.vault.vault_connect import resolve_ssh_config
from nterm.manager import SessionStore, DEFAULT_DB_PATH
from nterm.settings import NtermSettings

logger = logging.getLogger("nterm.desktop")

# ── Version ──────────────────────────────────────────────────────────
__version__ = "0.5.0"

# ── Paths ────────────────────────────────────────────────────────────
NTERM_DIR = Path.home() / ".nterm"
PROJECT_ROOT = Path(__file__).parent


# =====================================================================
#  Port Discovery
# =====================================================================

def find_available_port(preferred: int = 8765, scan_range: int = 100) -> int:
    """
    Test socket bind to find an available port.

    Tries the preferred port first, then scans upward. This enables
    multiple nterm instances — each gets its own port.

    Args:
        preferred: First port to try (default 8765)
        scan_range: How many ports to scan before giving up

    Returns:
        Available port number

    Raises:
        RuntimeError: If no port is available in the range
    """
    for port in range(preferred, preferred + scan_range):
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                s.bind(("127.0.0.1", port))
                return port
        except OSError:
            continue
    raise RuntimeError(
        f"No available port in range {preferred}–{preferred + scan_range - 1}"
    )


# =====================================================================
#  Uvicorn Server Thread
# =====================================================================

class NtermServer:
    """
    Manages the FastAPI/uvicorn server in a daemon thread.

    Uses uvicorn.Server directly (not uvicorn.run) for clean shutdown
    via the should_exit flag.
    """

    def __init__(self, host: str = "127.0.0.1", port: int = 8765,
                 log_level: str = "warning"):
        self.host = host
        self.port = port
        self.log_level = log_level
        self._server: uvicorn.Server | None = None
        self._thread: threading.Thread | None = None

    @property
    def base_url(self) -> str:
        return f"http://{self.host}:{self.port}"

    def start(self):
        """Start the server in a daemon thread."""
        # Import path: nterm.app:app — adjust if your package layout differs
        config = uvicorn.Config(
            app="nterm.app:app",
            host=self.host,
            port=self.port,
            log_level=self.log_level,
            # No reload in desktop mode — we manage the lifecycle
            reload=False,
            # Suppress access logs in production
            access_log=(self.log_level == "debug"),
        )
        self._server = uvicorn.Server(config)

        self._thread = threading.Thread(
            target=self._server.run,
            name="nterm-uvicorn",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"Server starting on {self.base_url}")

    def wait_ready(self, timeout: float = 10.0) -> bool:
        """
        Block until the server is accepting connections.

        Returns True if server is ready, False on timeout.
        """
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                with socket.create_connection(
                    (self.host, self.port), timeout=0.5
                ):
                    logger.info("Server ready")
                    return True
            except (ConnectionRefusedError, OSError, socket.timeout):
                time.sleep(0.1)
        logger.error(f"Server not ready after {timeout}s")
        return False

    def stop(self):
        """Signal the server to shut down gracefully."""
        if self._server:
            self._server.should_exit = True
            logger.info("Server shutdown requested")
            if self._thread and self._thread.is_alive():
                self._thread.join(timeout=5.0)


# =====================================================================
#  Telemetry Connect Dialog
# =====================================================================

class TelemetryConnectDialog(QDialog):
    """
    Dialog to add a Wirlwind Telemetry tab for a device.

    Auth modes:
      0 — Vault credential (dropdown of stored credentials)
      1 — Password
      2 — Key File
      3 — Key Paste (raw PEM content)
      4 — Password + Key

    When vault is locked or empty, the Vault option is disabled and
    the dialog defaults to Password mode.
    """

    # Auth combo indices
    AUTH_VAULT = 0
    AUTH_PASSWORD = 1
    AUTH_KEY_FILE = 2
    AUTH_KEY_PASTE = 3
    AUTH_PASS_KEY = 4

    def __init__(self, parent=None, credential_names=None, vault_unlocked=False):
        """
        Args:
            parent: Parent widget (usually NtermDesktop)
            credential_names: List of vault credential names (empty if locked)
            vault_unlocked: Whether the vault is currently unlocked
        """
        super().__init__(parent)
        self.setWindowTitle("Add Telemetry")
        self.setMinimumWidth(460)

        self._credential_names = credential_names or []
        self._vault_unlocked = vault_unlocked

        layout = QFormLayout(self)
        layout.setFieldGrowthPolicy(
            QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow
        )

        # ── Connection ───────────────────────────────────────
        self.host_input = QLineEdit()
        self.host_input.setPlaceholderText("10.0.0.1 or hostname")
        layout.addRow("Host:", self.host_input)

        self.port_input = QLineEdit("22")
        self.port_input.setFixedWidth(80)
        layout.addRow("SSH Port:", self.port_input)

        self.user_input = QLineEdit()
        self.user_input.setPlaceholderText("admin")
        layout.addRow("Username:", self.user_input)

        # ── Auth method selector ─────────────────────────────
        self.auth_combo = QComboBox()
        self.auth_combo.addItems([
            "Vault Credential",
            "Password",
            "Key File",
            "Key Paste",
            "Password + Key",
        ])
        self.auth_combo.currentIndexChanged.connect(self._on_auth_changed)
        layout.addRow("Auth:", self.auth_combo)

        # ── Vault credential selector ────────────────────────
        vault_row = QHBoxLayout()
        self.cred_combo = QComboBox()
        self.cred_combo.setMinimumWidth(200)
        if self._credential_names:
            self.cred_combo.addItems(self._credential_names)
        else:
            self.cred_combo.addItem("(no credentials)")
            self.cred_combo.setEnabled(False)
        vault_row.addWidget(self.cred_combo)

        self._vault_status = QLabel()
        self._vault_status.setFixedWidth(24)
        self._vault_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._update_vault_indicator()
        vault_row.addWidget(self._vault_status)

        self._vault_widget = QWidget()
        self._vault_widget.setLayout(vault_row)
        layout.addRow("Credential:", self._vault_widget)

        # ── Password field ───────────────────────────────────
        self.pass_input = QLineEdit()
        self.pass_input.setEchoMode(QLineEdit.EchoMode.Password)
        layout.addRow("Password:", self.pass_input)

        # ── Key file field with browse ───────────────────────
        key_row = QHBoxLayout()
        self.key_input = QLineEdit()
        self.key_input.setPlaceholderText("~/.ssh/id_rsa")
        key_row.addWidget(self.key_input)
        browse_btn = QPushButton("Browse…")
        browse_btn.setFixedWidth(80)
        browse_btn.clicked.connect(self._browse_key)
        key_row.addWidget(browse_btn)

        self._key_widget = QWidget()
        self._key_widget.setLayout(key_row)
        layout.addRow("Key File:", self._key_widget)

        # ── Key paste field ──────────────────────────────────
        self.key_paste_input = QTextEdit()
        self.key_paste_input.setPlaceholderText(
            "Paste PEM private key content here…\n"
            "-----BEGIN OPENSSH PRIVATE KEY-----"
        )
        self.key_paste_input.setMaximumHeight(90)
        self.key_paste_input.setFont(
            self.key_input.font()  # monospace from parent
        )
        layout.addRow("Key Content:", self.key_paste_input)

        # ── Key passphrase (optional) ────────────────────────
        self.passphrase_input = QLineEdit()
        self.passphrase_input.setEchoMode(QLineEdit.EchoMode.Password)
        self.passphrase_input.setPlaceholderText("(leave blank if unencrypted)")
        layout.addRow("Key Pass:", self.passphrase_input)

        # ── Vendor / Name / Legacy ───────────────────────────
        self.vendor_combo = QComboBox()
        self.vendor_combo.addItems([
            "arista_eos",
            "cisco_ios",
            "cisco_ios_xe",
            "cisco_nxos",
            "juniper_junos",
            "linux"
        ])
        layout.addRow("Vendor:", self.vendor_combo)

        self.name_input = QLineEdit()
        self.name_input.setPlaceholderText("(optional display name)")
        layout.addRow("Name:", self.name_input)

        self.legacy_check = QPushButton("Legacy Ciphers: ON")
        self.legacy_check.setCheckable(True)
        self.legacy_check.setChecked(True)
        self.legacy_check.toggled.connect(
            lambda on: self.legacy_check.setText(
                f"Legacy Ciphers: {'ON' if on else 'OFF'}"
            )
        )
        layout.addRow("", self.legacy_check)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok
            | QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addRow(buttons)

        # ── Set initial visibility ───────────────────────────
        # If vault is available, default to Vault; otherwise Password
        if self._vault_unlocked and self._credential_names:
            self.auth_combo.setCurrentIndex(self.AUTH_VAULT)
        else:
            self.auth_combo.setCurrentIndex(self.AUTH_PASSWORD)
            # Disable the vault option if not available
            model = self.auth_combo.model()
            item = model.item(self.AUTH_VAULT)
            if item and (not self._vault_unlocked or not self._credential_names):
                item.setEnabled(False)

        self._on_auth_changed(self.auth_combo.currentIndex())

    def _update_vault_indicator(self):
        """Show lock/unlock icon next to credential dropdown."""
        if self._vault_unlocked:
            self._vault_status.setText("🔓")
            self._vault_status.setToolTip("Vault unlocked")
        else:
            self._vault_status.setText("🔒")
            self._vault_status.setToolTip("Vault locked")

    def _on_auth_changed(self, index: int):
        """Show/hide fields based on selected auth method."""
        show_vault = (index == self.AUTH_VAULT)
        show_pass = index in (self.AUTH_PASSWORD, self.AUTH_PASS_KEY)
        show_key_file = index in (self.AUTH_KEY_FILE, self.AUTH_PASS_KEY)
        show_key_paste = (index == self.AUTH_KEY_PASTE)
        show_passphrase = index in (
            self.AUTH_KEY_FILE, self.AUTH_KEY_PASTE, self.AUTH_PASS_KEY
        )

        # Username not needed when vault provides it
        user_required = not show_vault
        self.user_input.setPlaceholderText(
            "(from vault)" if show_vault else "admin"
        )

        # Toggle visibility for each field + its label
        field_visibility = {
            self._vault_widget: show_vault,
            self.pass_input: show_pass,
            self._key_widget: show_key_file,
            self.key_paste_input: show_key_paste,
            self.passphrase_input: show_passphrase,
        }

        form = self.layout()
        for row in range(form.rowCount()):
            label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
            field_item = form.itemAt(row, QFormLayout.ItemRole.FieldRole)
            if not field_item:
                continue
            widget = field_item.widget()
            if widget in field_visibility:
                visible = field_visibility[widget]
                widget.setVisible(visible)
                if label_item and label_item.widget():
                    label_item.widget().setVisible(visible)

    def _browse_key(self):
        """Open a file dialog to select an SSH private key."""
        start_dir = str(Path.home() / ".ssh")
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Select SSH Private Key",
            start_dir,
            "All Files (*)",
        )
        if path:
            self.key_input.setText(path)

    def set_credential(self, name: str):
        """Pre-select a vault credential by name."""
        idx = self.cred_combo.findText(name)
        if idx >= 0:
            self.cred_combo.setCurrentIndex(idx)
            self.auth_combo.setCurrentIndex(self.AUTH_VAULT)

    def get_config(self) -> dict:
        auth_mode = self.auth_combo.currentIndex()
        key_path = self.key_input.text().strip() or None
        key_paste = self.key_paste_input.toPlainText().strip() or None
        passphrase = self.passphrase_input.text() or None

        # Expand ~ in key path
        if key_path:
            key_path = str(Path(key_path).expanduser())

        config = {
            "host": self.host_input.text().strip(),
            "port": int(self.port_input.text() or 22),
            "username": self.user_input.text().strip(),
            "password": None,
            "key_path": None,
            "key_content": None,
            "key_passphrase": None,
            "credential_name": None,
            "use_vault": False,
            "vendor": self.vendor_combo.currentText(),
            "display_name": (
                self.name_input.text().strip()
                or self.host_input.text().strip()
            ),
            "legacy_mode": self.legacy_check.isChecked(),
        }

        if auth_mode == self.AUTH_VAULT:
            cred_name = self.cred_combo.currentText()
            if cred_name and cred_name != "(no credentials)":
                config["credential_name"] = cred_name
                config["use_vault"] = True
        elif auth_mode == self.AUTH_PASSWORD:
            config["password"] = self.pass_input.text()
        elif auth_mode == self.AUTH_KEY_FILE:
            config["key_path"] = key_path
            config["key_passphrase"] = passphrase
        elif auth_mode == self.AUTH_KEY_PASTE:
            config["key_content"] = key_paste
            config["key_passphrase"] = passphrase
        elif auth_mode == self.AUTH_PASS_KEY:
            config["password"] = self.pass_input.text()
            config["key_path"] = key_path
            config["key_passphrase"] = passphrase

        return config


# =====================================================================
#  About Dialog
# =====================================================================

class AboutDialog(QDialog):
    """
    Custom About dialog with prominent logo, version info, feature
    summary, and project links — styled to match the nterm dark theme.
    """

    def __init__(self, version: str, server_url: str, parent=None):
        super().__init__(parent)
        self.setWindowTitle("About nterm")
        self.setFixedSize(520, 620)
        self.setModal(True)

        # ── Theme colours ────────────────────────────────────
        bg       = "#0a0e14"
        bg_card  = "#111820"
        border   = "#1e2a36"
        accent   = "#2dd4bf"
        text     = "#c8d6e5"
        muted    = "#6a7585"
        link     = "#2dd4bf"

        self.setStyleSheet(f"""
            QDialog {{
                background: {bg};
                border: 1px solid {border};
            }}
        """)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        # ── Logo banner area ─────────────────────────────────
        logo_container = QWidget()
        logo_container.setFixedHeight(200)
        logo_container.setStyleSheet(f"""
            QWidget {{
                background: {bg_card};
                border-bottom: 1px solid {border};
            }}
        """)
        logo_layout = QVBoxLayout(logo_container)
        logo_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

        logo_label = QLabel()
        logo_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # Try logo.png first, fall back to logo_full.svg
        logo_path = PROJECT_ROOT / "logos" / "logo.png"
        if not logo_path.exists():
            logo_path = PROJECT_ROOT / "logos" / "logo_full.svg"

        if logo_path.exists():
            pixmap = QPixmap(str(logo_path))
            if not pixmap.isNull():
                scaled = pixmap.scaled(
                    160, 160,
                    Qt.AspectRatioMode.KeepAspectRatio,
                    Qt.TransformationMode.SmoothTransformation,
                )
                logo_label.setPixmap(scaled)
            else:
                logo_label.setText("nterm")
                logo_label.setStyleSheet(
                    f"color: {accent}; font: bold 36px monospace;")
        else:
            logo_label.setText("nterm")
            logo_label.setStyleSheet(
                f"color: {accent}; font: bold 36px monospace;")

        logo_layout.addWidget(logo_label)
        layout.addWidget(logo_container)

        # ── Content area (scrollable-safe fixed layout) ──────
        content = QWidget()
        content.setStyleSheet(f"background: {bg};")
        clayout = QVBoxLayout(content)
        clayout.setContentsMargins(32, 20, 32, 20)
        clayout.setSpacing(12)

        # Title + version
        title = QLabel(f"nterm  <span style='color:{muted};'>v{version}</span>")
        title.setStyleSheet(
            f"color: {accent}; font: bold 22px monospace; background: transparent;")
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        clayout.addWidget(title)

        # Tagline
        tagline = QLabel("A network-aware SSH terminal that understands what it sees.")
        tagline.setStyleSheet(
            f"color: {text}; font: 13px sans-serif; background: transparent;")
        tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
        tagline.setWordWrap(True)
        clayout.addWidget(tagline)

        # ── Feature card ─────────────────────────────────────
        features_html = f"""
        <div style="
            background: {bg_card};
            border: 1px solid {border};
            border-radius: 6px;
            padding: 14px 18px;
            margin-top: 4px;
        ">
            <table cellspacing="8" cellpadding="0" style="color: {text}; font-size: 12px; font-family: sans-serif;">
                <tr>
                    <td style="color: {accent}; font-family: monospace; padding-right: 10px; vertical-align: top;">SNIFFER</td>
                    <td>Real-time parse chain with structured output gutter</td>
                </tr>
                <tr>
                    <td style="color: {accent}; font-family: monospace; padding-right: 10px; vertical-align: top;">VAULT</td>
                    <td>Encrypted credential storage with session resolver</td>
                </tr>
                <tr>
                    <td style="color: {accent}; font-family: monospace; padding-right: 10px; vertical-align: top;">TELEMETRY</td>
                    <td>Wirlwind live device monitoring &amp; LLDP topology</td>
                </tr>
                <tr>
                    <td style="color: {accent}; font-family: monospace; padding-right: 10px; vertical-align: top;">ENGINE</td>
                    <td>TTP / TextFSM / Jinja2 template pipeline</td>
                </tr>
            </table>
        </div>
        """
        features = QLabel(features_html)
        features.setStyleSheet("background: transparent;")
        features.setWordWrap(True)
        clayout.addWidget(features)

        # ── Server info ──────────────────────────────────────
        server_lbl = QLabel(
            f"<span style='color:{muted}; font-family:monospace; font-size:11px;'>"
            f"SERVER  {server_url}</span>"
        )
        server_lbl.setStyleSheet("background: transparent;")
        server_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        clayout.addWidget(server_lbl)

        # ── Separator ────────────────────────────────────────
        sep = QWidget()
        sep.setFixedHeight(1)
        sep.setStyleSheet(f"background: {border};")
        clayout.addWidget(sep)

        # ── Author / links ───────────────────────────────────
        author_html = f"""
        <div style="text-align: center; font-size: 12px; font-family: sans-serif;">
            <span style="color: {text};">Scott Peterman</span><br/>
            <span style="color: {muted}; font-size: 11px;">
                <a href="https://scottpeterman.github.io"
                   style="color: {link}; text-decoration: none;">scottpeterman.github.io</a>
                &nbsp;&middot;&nbsp;
                <a href="https://github.com/scottpeterman/nterm-ng"
                   style="color: {link}; text-decoration: none;">github</a>
            </span>
        </div>
        """
        author = QLabel(author_html)
        author.setStyleSheet("background: transparent;")
        author.setOpenExternalLinks(True)
        author.setAlignment(Qt.AlignmentFlag.AlignCenter)
        clayout.addWidget(author)

        clayout.addStretch()
        layout.addWidget(content, 1)

        # ── Close button ─────────────────────────────────────
        btn_bar = QWidget()
        btn_bar.setStyleSheet(f"""
            QWidget {{
                background: {bg_card};
                border-top: 1px solid {border};
            }}
        """)
        btn_layout = QHBoxLayout(btn_bar)
        btn_layout.setContentsMargins(20, 10, 20, 10)

        close_btn = QPushButton("Close")
        close_btn.setFixedWidth(100)
        close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        close_btn.setStyleSheet(f"""
            QPushButton {{
                background: {border};
                color: {text};
                border: 1px solid {border};
                border-radius: 4px;
                padding: 6px 16px;
                font: 12px sans-serif;
            }}
            QPushButton:hover {{
                background: {accent};
                color: {bg};
                border-color: {accent};
            }}
        """)
        close_btn.clicked.connect(self.accept)
        btn_layout.addStretch()
        btn_layout.addWidget(close_btn)
        layout.addWidget(btn_bar)


# =====================================================================
#  Main Window
# =====================================================================

class NtermDesktop(QMainWindow):
    """
    Two-pane desktop shell:
      Left  — QWebEngineView rendering nterm's web UI
      Right — QTabWidget holding Wirlwind TelemetryWidget instances
    """

    telemetry_requested = pyqtSignal(dict)  # future: nterm JS → telemetry tab

    def __init__(self, server_url: str, debug: bool = False):
        super().__init__()
        self.server_url = server_url
        self._debug = debug
        self._telemetry_widgets = []  # prevent GC

        # ── Settings (persistent zoom, font scale, contrast) ──
        self.settings = NtermSettings()

        self.setWindowTitle(f"nterm {__version__}")
        self.resize(1800, 1000)

        self._build_ui()
        self._build_menu()
        self._build_statusbar()

        # ── Vault ───────────────────────────────────────────────
        self._init_vault()

        # ── Session Store ──────────────────────────────────────
        self._init_session_store()

        # ── Telemetry request poll (JS → Qt bridge) ─────────────
        self._telemetry_poll_timer = QTimer(self)
        self._telemetry_poll_timer.timeout.connect(self._poll_telemetry_requests)
        self._telemetry_poll_timer.start(500)  # 500ms — responsive but cheap

        # Load nterm after a brief delay to ensure server is warm
        QTimer.singleShot(200, self._load_nterm)

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

    def _build_ui(self):
        # Central splitter: nterm (left) | telemetry tabs (right)
        self.splitter = QSplitter(Qt.Orientation.Horizontal)
        self.setCentralWidget(self.splitter)

        # Left pane — nterm web UI
        self.nterm_view = QWebEngineView()
        profile = self.nterm_view.page().profile()
        profile.setPersistentCookiesPolicy(
            QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
        )

        # Handle blob-URL downloads (Export JSON/CSV from visualizer)
        profile.downloadRequested.connect(self._on_download_requested)

        # Auto-grant clipboard permission so navigator.clipboard.write() works
        self.nterm_view.page().featurePermissionRequested.connect(
            self._on_permission_requested
        )

        self.splitter.addWidget(self.nterm_view)

        # Right pane — telemetry tab widget
        self.telemetry_container = QWidget()
        tlay = QVBoxLayout(self.telemetry_container)
        tlay.setContentsMargins(0, 0, 0, 0)

        # Tab bar with close buttons and an add button
        self.telemetry_tabs = QTabWidget()
        self.telemetry_tabs.setTabsClosable(True)
        self.telemetry_tabs.setMovable(True)
        self.telemetry_tabs.tabCloseRequested.connect(self._close_telemetry_tab)

        # Corner widget: "+" button to add telemetry
        add_btn = QToolButton()
        add_btn.setText("+")
        add_btn.setToolTip("Add telemetry for a device")
        add_btn.clicked.connect(self._on_add_telemetry)
        self.telemetry_tabs.setCornerWidget(
            add_btn, Qt.Corner.TopRightCorner
        )

        # Placeholder when no tabs are open
        self._placeholder = QLabel(
            "Click + to add device telemetry\n\n"
            "Wirlwind monitors interface throughput,\n"
            "LLDP/CDP neighbors, CPU, memory,\n"
            "and more — in real time."
        )
        self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._placeholder.setStyleSheet("color: #4a5568; font-size: 13px;")

        tlay.addWidget(self._placeholder)
        tlay.addWidget(self.telemetry_tabs)
        self.telemetry_tabs.hide()

        self.splitter.addWidget(self.telemetry_container)

        # Splitter proportions: 60% terminal, 40% telemetry
        self.splitter.setSizes([1080, 720])
        self.splitter.setCollapsible(0, False)
        self.splitter.setCollapsible(1, True)

    def _build_menu(self):
        menubar = self.menuBar()

        # ── File ─────────────────────────────────────────────────
        file_menu = menubar.addMenu("&File")

        add_telemetry = QAction("Add &Telemetry…", self)
        add_telemetry.setShortcut(QKeySequence("Ctrl+T"))
        add_telemetry.triggered.connect(self._on_add_telemetry)
        file_menu.addAction(add_telemetry)

        file_menu.addSeparator()

        quit_action = QAction("&Quit", self)
        quit_action.setShortcut(QKeySequence("Ctrl+Q"))
        quit_action.triggered.connect(self.close)
        file_menu.addAction(quit_action)

        # ── View ─────────────────────────────────────────────────
        view_menu = menubar.addMenu("&View")

        # ── Zoom (QWebEngineView zoom factor) ────────────────
        zoom_in = QAction("Zoom &In", self)
        zoom_in.setShortcut(QKeySequence("Ctrl+="))
        zoom_in.triggered.connect(self._zoom_in)
        view_menu.addAction(zoom_in)

        zoom_out = QAction("Zoom &Out", self)
        zoom_out.setShortcut(QKeySequence("Ctrl+-"))
        zoom_out.triggered.connect(self._zoom_out)
        view_menu.addAction(zoom_out)

        zoom_reset = QAction("Zoom &Reset", self)
        zoom_reset.setShortcut(QKeySequence("Ctrl+0"))
        zoom_reset.triggered.connect(self._zoom_reset)
        view_menu.addAction(zoom_reset)

        view_menu.addSeparator()

        # ── UI Font Scale (CSS --ui-scale) ───────────────────
        font_up = QAction("Increase &Font Scale", self)
        font_up.setShortcut(QKeySequence("Ctrl+Shift+="))
        font_up.triggered.connect(self._font_scale_up)
        view_menu.addAction(font_up)

        font_down = QAction("&Decrease Font Scale", self)
        font_down.setShortcut(QKeySequence("Ctrl+Shift+-"))
        font_down.triggered.connect(self._font_scale_down)
        view_menu.addAction(font_down)

        font_reset = QAction("Reset Font Sca&le", self)
        font_reset.setShortcut(QKeySequence("Ctrl+Shift+0"))
        font_reset.triggered.connect(self._font_scale_reset)
        view_menu.addAction(font_reset)

        view_menu.addSeparator()

        # ── Contrast Mode ────────────────────────────────────
        contrast_cycle = QAction("Cycle &Contrast Mode", self)
        contrast_cycle.setShortcut(QKeySequence("Ctrl+Shift+C"))
        contrast_cycle.triggered.connect(self._cycle_contrast)
        view_menu.addAction(contrast_cycle)

        view_menu.addSeparator()

        # ── Existing view items ──────────────────────────────
        toggle_telemetry = QAction("Toggle Telemetry &Panel", self)
        toggle_telemetry.setShortcut(QKeySequence("Ctrl+\\"))
        toggle_telemetry.triggered.connect(self._toggle_telemetry_pane)
        view_menu.addAction(toggle_telemetry)

        reload_nterm = QAction("&Reload nterm", self)
        reload_nterm.setShortcut(QKeySequence("Ctrl+Shift+R"))
        reload_nterm.triggered.connect(
            lambda: self.nterm_view.setUrl(QUrl(self.server_url))
        )
        view_menu.addAction(reload_nterm)

        if self._debug:
            view_menu.addSeparator()
            devtools = QAction("Open &DevTools", self)
            devtools.setShortcut(QKeySequence("F12"))
            devtools.triggered.connect(self._open_devtools)
            view_menu.addAction(devtools)

        # ── Vault ────────────────────────────────────────────────
        vault_menu = menubar.addMenu("V&ault")

        unlock_action = QAction("&Unlock Vault", self)
        unlock_action.setShortcut(QKeySequence("Ctrl+U"))
        unlock_action.triggered.connect(self._unlock_vault_dialog)
        vault_menu.addAction(unlock_action)

        lock_action = QAction("&Lock Vault", self)
        lock_action.triggered.connect(self._lock_vault)
        vault_menu.addAction(lock_action)

        vault_menu.addSeparator()

        manager_action = QAction("Credential &Manager…", self)
        manager_action.setShortcut(QKeySequence("Ctrl+Shift+K"))
        manager_action.triggered.connect(self._launch_credential_manager)
        vault_menu.addAction(manager_action)

        # ── Sessions ─────────────────────────────────────────────
        sessions_menu = menubar.addMenu("&Sessions")

        session_editor_action = QAction("Session &Manager…", self)
        session_editor_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
        session_editor_action.triggered.connect(self._launch_session_editor)
        sessions_menu.addAction(session_editor_action)

        sessions_menu.addSeparator()

        import_action = QAction("&Import Sessions…", self)
        import_action.triggered.connect(self._import_sessions)
        sessions_menu.addAction(import_action)

        import_tt_action = QAction("Import from &TerminalTelemetry…", self)
        import_tt_action.triggered.connect(self._import_terminal_telemetry)
        sessions_menu.addAction(import_tt_action)

        import_sc_action = QAction("Import from &Secure Cartography…", self)
        import_sc_action.triggered.connect(self._import_secure_cartography)
        sessions_menu.addAction(import_sc_action)

        sessions_menu.addSeparator()

        export_action = QAction("&Export Sessions…", self)
        export_action.triggered.connect(self._export_sessions)
        sessions_menu.addAction(export_action)

        # ── Tools ────────────────────────────────────────────────
        tools_menu = menubar.addMenu("&Tools")

        template_tester_action = QAction("Template &Tester…", self)
        template_tester_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
        template_tester_action.triggered.connect(self._launch_template_tester)
        tools_menu.addAction(template_tester_action)

        # ── Help ─────────────────────────────────────────────────
        help_menu = menubar.addMenu("&Help")

        help_action = QAction("&Help Topics", self)
        help_action.setShortcut(QKeySequence("F1"))
        help_action.triggered.connect(self._show_help)
        help_menu.addAction(help_action)

        help_menu.addSeparator()

        about = QAction("&About nterm", self)
        about.triggered.connect(self._show_about)
        help_menu.addAction(about)

    def _show_help(self):
        section = self._detect_help_context()
        if hasattr(self, '_help_dialog') and self._help_dialog is not None:
            try:
                self._help_dialog.navigate_to(section)
                self._help_dialog.raise_()
                self._help_dialog.activateWindow()
                return
            except RuntimeError:
                pass
        self._help_dialog = HelpDialog(
            server_url=self.server_url, fragment=section, parent=self)
        self._help_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
        self._help_dialog.destroyed.connect(
            lambda: setattr(self, '_help_dialog', None))
        self._help_dialog.show()

    def _detect_help_context(self) -> str:
        if (hasattr(self, '_template_tester_window')
                and self._template_tester_window is not None):
            try:
                if self._template_tester_window.isActiveWindow():
                    return 'tfsm-tester'
            except RuntimeError:
                pass
        sizes = self.splitter.sizes()
        if sizes[1] > 100 and self.telemetry_tabs.count() > 0:
            focused = QApplication.focusWidget()
            if focused and self.telemetry_container.isAncestorOf(focused):
                return 'wirlwind'
        return 'nterm'

    def _build_statusbar(self):
        self.status = self.statusBar()
        self.status.showMessage(f"nterm {__version__} — {self.server_url}")

    # ── Display Settings (Zoom / Font Scale / Contrast) ────────

    def _zoom_in(self):
        """Increase QWebEngineView zoom factor."""
        factor = self.settings.zoom_in()
        self.nterm_view.setZoomFactor(factor)
        self._sync_xterm_zoom(factor)
        self.status.showMessage(f"Zoom: {int(factor * 100)}%", 2000)
        logger.debug(f"Zoom in: {factor}")

    def _zoom_out(self):
        """Decrease QWebEngineView zoom factor."""
        factor = self.settings.zoom_out()
        self.nterm_view.setZoomFactor(factor)
        self._sync_xterm_zoom(factor)
        self.status.showMessage(f"Zoom: {int(factor * 100)}%", 2000)
        logger.debug(f"Zoom out: {factor}")

    def _zoom_reset(self):
        """Reset QWebEngineView zoom to 1.0."""
        factor = self.settings.zoom_reset()
        self.nterm_view.setZoomFactor(factor)
        self._sync_xterm_zoom(factor)
        self.status.showMessage("Zoom: 100%", 2000)

    def _sync_xterm_zoom(self, factor: float):
        """
        Sync xterm.js terminal fontSize to match QWebEngineView zoom.

        Canvas-rendered terminals ignore setZoomFactor(), so we scale
        their fontSize directly. Requires the frontend to expose
        window.ntermSyncTerminalZoom(factor).
        """
        js = f"""
        (function() {{
            if (typeof window.ntermSyncTerminalZoom === 'function') {{
                window.ntermSyncTerminalZoom({factor});
            }}
        }})();
        """
        self.nterm_view.page().runJavaScript(js)

    def _font_scale_up(self):
        """Increase CSS --ui-scale for larger UI fonts."""
        scale = self.settings.font_scale_up()
        self._push_display_settings()
        self.status.showMessage(f"Font scale: {int(scale * 100)}%", 2000)

    def _font_scale_down(self):
        """Decrease CSS --ui-scale for smaller UI fonts."""
        scale = self.settings.font_scale_down()
        self._push_display_settings()
        self.status.showMessage(f"Font scale: {int(scale * 100)}%", 2000)

    def _font_scale_reset(self):
        """Reset CSS --ui-scale to 1.0."""
        self.settings.font_scale_reset()
        self._push_display_settings()
        self.status.showMessage("Font scale: 100%", 2000)

    def _cycle_contrast(self):
        """Cycle through normal → enhanced → high contrast."""
        mode = self.settings.cycle_contrast()
        self._push_display_settings()
        self.status.showMessage(f"Contrast: {mode}", 2000)

    def _push_display_settings(self):
        """Push current zoom/contrast/font settings to the web UI."""
        settings_dict = self.settings.to_json()
        js = f"window.ntermZoom && window.ntermZoom.applySettings({json.dumps(settings_dict)});"
        self.nterm_view.page().runJavaScript(js)

    def _apply_saved_display_settings(self):
        """
        Apply saved display settings after the page has loaded.

        Restores QWebEngineView zoom factor and pushes CSS
        font scale + contrast mode to the page via JavaScript.
        """
        factor = self.settings.zoom_factor
        if factor != 1.0:
            self.nterm_view.setZoomFactor(factor)
            self._sync_xterm_zoom(factor)
            logger.info(f"Restored zoom factor: {factor}")

        # Push CSS settings (font scale, contrast mode)
        self._push_display_settings()

        mode = self.settings.contrast_mode
        scale = self.settings.ui_font_scale
        if factor != 1.0 or scale != 1.0 or mode != "normal":
            logger.info(
                f"Display settings restored — zoom: {factor}, "
                f"font scale: {scale}, contrast: {mode}"
            )

    # ── Vault ───────────────────────────────────────────────────

    def _init_vault(self):
        """Initialize the credential vault and register with the FastAPI app."""
        from nterm.vault.resolver import CredentialResolver

        self.resolver = CredentialResolver()

        # Register resolver with the vault API endpoints
        set_vault_resolver(self.resolver)

        # Register native dialog launcher (called from /api/vault/launch-manager)
        set_launch_manager_callback(self._launch_credential_manager)

        # Try auto-unlock via keychain (optional, non-fatal)
        try:
            from nterm.vault import KeychainIntegration
            if self.resolver.is_initialized() and KeychainIntegration.is_available():
                password = KeychainIntegration.get_master_password()
                if password and self.resolver.unlock_vault(password):
                    count = len(self.resolver.list_credentials())
                    logger.info(f"Vault auto-unlocked via keychain — {count} credentials")
                    self.status.showMessage(
                        f"Vault unlocked — {count} credentials", 5000
                    )
        except Exception as e:
            logger.debug(f"Keychain auto-unlock not available: {e}")

        logger.info(f"Vault initialized (db: {self.resolver.db_path})")

    def _unlock_vault_dialog(self):
        """Show vault unlock/init prompt."""
        if not self.resolver.is_initialized():
            # First time — initialize vault
            password, ok = self._password_prompt(
                "Initialize Vault",
                "Create a master password for the credential vault:",
            )
            if ok and password:
                confirm, ok2 = self._password_prompt(
                    "Confirm Password",
                    "Confirm master password:",
                )
                if ok2 and confirm == password:
                    self.resolver.init_vault(password)
                    QMessageBox.information(self, "Vault", "Vault initialized and unlocked")
                    self._push_vault_status()
                elif ok2:
                    QMessageBox.warning(self, "Vault", "Passwords don't match")
            return

        if self.resolver.store.is_unlocked:
            QMessageBox.information(self, "Vault", "Vault is already unlocked")
            return

        password, ok = self._password_prompt(
            "Unlock Vault",
            "Master password:",
        )
        if ok and password:
            if self.resolver.unlock_vault(password):
                count = len(self.resolver.list_credentials())
                self.status.showMessage(
                    f"Vault unlocked — {count} credentials", 5000
                )
                self._push_vault_status()
            else:
                QMessageBox.warning(self, "Vault", "Invalid master password")

    def _lock_vault(self):
        """Lock the vault."""
        self.resolver.lock_vault()
        self.status.showMessage("Vault locked", 3000)
        self._push_vault_status()

    def _launch_credential_manager(self):
        """Open the native credential manager dialog."""
        try:
            from nterm.vault import CredentialManagerWidget
        except ImportError:
            QMessageBox.warning(
                self, "Not Available",
                "CredentialManagerWidget not found in nterm.vault"
            )
            return

        dialog = QDialog(self)
        dialog.setWindowTitle("Credential Manager")
        dialog.setMinimumSize(800, 600)

        layout = QVBoxLayout(dialog)
        layout.setContentsMargins(0, 0, 0, 0)

        manager = CredentialManagerWidget(store=self.resolver.store)
        layout.addWidget(manager)

        dialog.exec()

        # After dialog closes, notify web UI that credentials may have changed
        self._push_vault_status()

    def _password_prompt(self, title: str, label: str) -> tuple:
        """Show a password input dialog. Returns (password, ok)."""
        from PyQt6.QtWidgets import QInputDialog, QLineEdit
        return QInputDialog.getText(
            self, title, label, QLineEdit.EchoMode.Password,
        )

    def _push_vault_status(self):
        """Push vault status to the web UI via JavaScript."""
        unlocked = (
            self.resolver.store.is_unlocked
            if self.resolver.is_initialized()
            else False
        )
        js = f"window._vaultStatus && window._vaultStatus({str(unlocked).lower()});"
        self.nterm_view.page().runJavaScript(js)

    # ── Session Store ──────────────────────────────────────────

    def _init_session_store(self):
        """Initialize the session store for the native editor."""
        self._session_store = None
        try:
            self._session_store = SessionStore(DEFAULT_DB_PATH)
            logger.info(f"Session store: {DEFAULT_DB_PATH}")
        except Exception as e:
            logger.error(f"Failed to init session store: {e}")

    def _launch_session_editor(self):
        """Open the native session editor dialog."""
        if not self._require_session_store():
            return

        # Gather credential names from vault if unlocked
        cred_names = []
        if (
            hasattr(self, 'resolver')
            and self.resolver.is_initialized()
            and self.resolver.store.is_unlocked
        ):
            try:
                raw = self.resolver.list_credentials()
                cred_names = [c.name if hasattr(c, 'name') else str(c) for c in raw]
            except Exception:
                pass

        dialog = QDialog(self)
        dialog.setWindowTitle("Session Manager")
        dialog.setMinimumSize(600, 500)
        dialog.resize(700, 600)

        layout = QVBoxLayout(dialog)
        layout.setContentsMargins(0, 0, 0, 0)

        from nterm.manager.session_editor import SessionEditorWidget
        editor = SessionEditorWidget(
            store=self._session_store,
            credential_names=cred_names,
        )
        layout.addWidget(editor)

        dialog.exec()

        # Notify web UI to refresh its session tree
        self._push_session_tree_refresh()

    def _push_session_tree_refresh(self):
        """Tell the web UI to reload the session tree."""
        js = "window._refreshSessionTree && window._refreshSessionTree();"
        self.nterm_view.page().runJavaScript(js)

    # ── Telemetry Request Poll (JS → Qt bridge) ───────────────

    def _poll_telemetry_requests(self):
        """
        Drain the shared telemetry request queue.

        FastAPI and Qt are in the same process — the JS POST lands in
        nterm.app.telemetry_request_queue (thread-safe queue.Queue),
        and we drain it here. No HTTP round-trip needed.
        """
        try:
            from nterm.app import telemetry_request_queue
        except ImportError:
            return  # app not loaded yet

        drained = []
        while not telemetry_request_queue.empty():
            try:
                drained.append(telemetry_request_queue.get_nowait())
            except Exception:
                break

        if drained:
            logger.info(f"Telemetry poll: drained {len(drained)} request(s)")

        for telem_req in drained:
            try:
                self._open_telemetry_from_session(telem_req)
            except Exception as e:
                logger.error(
                    f"Failed to open telemetry tab for "
                    f"{telem_req.get('display_name', '?')}: {e}",
                    exc_info=True,
                )

    def _open_telemetry_from_session(self, req: dict):
        """
        Open a Wirlwind telemetry tab from a session tree request.

        Resolves credentials from vault if available (using the
        session's credential_name or hostname match), then falls
        back to the TelemetryConnectDialog pre-populated with
        what we know.
        """
        hostname = req.get("hostname", "")
        port = req.get("port", 22)
        display_name = req.get("display_name") or hostname
        vendor = req.get("vendor", "arista_eos")
        credential_name = req.get("credential_name")

        logger.info(
            f"Telemetry from session tree: {display_name} "
            f"({hostname}:{port}, {vendor})"
        )

        # Build config dict matching TelemetryConnectDialog.get_config()
        config = {
            "host": hostname,
            "port": port,
            "vendor": vendor,
            "display_name": display_name,
            "legacy_mode": True,
            "username": "",
            "password": None,
            "key_path": None,
            "key_passphrase": None,
        }

        # ── Resolve credentials from vault ───────────────────
        # Use Wirlwind's own NtermAuthProvider to probe — it knows how
        # to convert vault profiles to SSHCredentials properly.
        vault_resolved = False
        if (
            hasattr(self, 'resolver')
            and self.resolver.is_initialized()
            and self.resolver.store.is_unlocked
        ):
            logger.debug(f"Vault unlocked — attempting credential lookup for {hostname}")
            try:
                from wirlwind_telemetry.auth_interface import (
                    NtermAuthProvider as WirlwindVaultAuth,
                    DeviceTarget as _DT,
                )
                vault_auth = WirlwindVaultAuth(self.resolver, preferred_credential=credential_name)
                tmp_target = _DT(
                    hostname=hostname, port=port,
                    display_name=display_name, vendor=vendor,
                )

                ssh_creds = None
                if credential_name:
                    logger.debug(f"Trying vault credential by name: {credential_name}")
                    ssh_creds = vault_auth.get_credentials_by_name(
                        credential_name, tmp_target
                    )
                if not ssh_creds:
                    logger.debug(f"Trying vault hostname match for {hostname}:{port}")
                    ssh_creds = vault_auth.get_credentials(tmp_target)

                if ssh_creds:
                    # Freeze resolved credentials into config so
                    # _add_telemetry_tab uses them directly without
                    # re-resolving from vault (which can fail)
                    config["username"] = ssh_creds.username
                    config["password"] = ssh_creds.password
                    config["key_path"] = ssh_creds.key_path
                    config["key_passphrase"] = ssh_creds.key_passphrase
                    # Handle vault-stored key (PEM in key_data)
                    if not ssh_creds.key_path and getattr(ssh_creds, 'key_data', None):
                        import tempfile
                        tmp = tempfile.NamedTemporaryFile(
                            mode='w', suffix='.pem', delete=False,
                            prefix='nterm_vault_key_',
                        )
                        tmp.write(ssh_creds.key_data)
                        tmp.close()
                        config["key_path"] = tmp.name
                    # Do NOT set use_vault — let _add_telemetry_tab
                    # use the explicit credentials we just froze
                    vault_resolved = True
                    logger.info(
                        f"Vault resolved credentials for {hostname} "
                        f"(user: {ssh_creds.username})"
                    )
                else:
                    logger.debug(f"No vault credentials matched for {hostname}")
            except Exception as e:
                logger.warning(f"Vault lookup failed for {hostname}: {e}")
        else:
            vault_init = hasattr(self, 'resolver') and self.resolver.is_initialized()
            vault_unlocked = (
                self.resolver.store.is_unlocked
                if vault_init else False
            )
            logger.debug(
                f"Vault not available for {hostname} "
                f"(initialized={vault_init}, unlocked={vault_unlocked})"
            )

        # ── Fall back to dialog if no vault creds ────────────
        if not vault_resolved:
            logger.info(f"Opening telemetry dialog for {display_name} (no vault creds)")
            cred_names, v_unlocked = self._get_vault_info()
            dlg = TelemetryConnectDialog(
                self,
                credential_names=cred_names,
                vault_unlocked=v_unlocked,
            )
            dlg.host_input.setText(hostname)
            dlg.port_input.setText(str(port))
            dlg.name_input.setText(display_name)

            # Pre-select vendor in combo
            for i in range(dlg.vendor_combo.count()):
                if dlg.vendor_combo.itemText(i) == vendor:
                    dlg.vendor_combo.setCurrentIndex(i)
                    break

            # Pre-select vault credential if session has one
            if credential_name and v_unlocked:
                dlg.set_credential(credential_name)

            if dlg.exec() != QDialog.DialogCode.Accepted:
                logger.info(f"Telemetry dialog cancelled for {display_name}")
                return

            config = dlg.get_config()

        logger.info(
            f"Opening telemetry tab: {config.get('display_name', '?')} "
            f"@ {config['host']}:{config['port']} "
            f"(user: {config.get('username', '?')}, vendor: {config.get('vendor', '?')})"
        )
        self._add_telemetry_tab(config)

    def _require_session_store(self) -> bool:
        """Check session store is available, warn if not."""
        if not self._session_store:
            QMessageBox.warning(
                self, "Not Available",
                "Session database not initialized."
            )
            return False
        return True

    def _import_sessions(self):
        """Open the JSON/CSV import dialog."""
        if not self._require_session_store():
            return

        from nterm.manager.io import ImportDialog
        dialog = ImportDialog(self._session_store, parent=self)
        if dialog.exec():
            self._push_session_tree_refresh()
            self.status.showMessage("Sessions imported", 5000)

    def _import_terminal_telemetry(self):
        """Open the TerminalTelemetry YAML import dialog."""
        if not self._require_session_store():
            return

        from nterm.manager.io import ImportTerminalTelemetryDialog
        dialog = ImportTerminalTelemetryDialog(self._session_store, parent=self)
        if dialog.exec():
            self._push_session_tree_refresh()
            self.status.showMessage("TerminalTelemetry sessions imported", 5000)

    def _import_secure_cartography(self):
        """Open the Secure Cartography map.json import dialog."""
        if not self._require_session_store():
            return

        from nterm.manager.io import ImportSecureCartographyDialog
        dialog = ImportSecureCartographyDialog(self._session_store, parent=self)
        if dialog.exec():
            self._push_session_tree_refresh()
            self.status.showMessage("Secure Cartography sessions imported", 5000)

    def _export_sessions(self):
        """Open the export dialog."""
        if not self._require_session_store():
            return

        from nterm.manager.io import ExportDialog
        dialog = ExportDialog(self._session_store, parent=self)
        dialog.exec()

    def _launch_template_tester(self):
        """Open the TextFSM Template Tester as a non-modal window."""
        # If already open, just raise it
        if hasattr(self, '_template_tester_window') and self._template_tester_window is not None:
            try:
                self._template_tester_window.raise_()
                self._template_tester_window.activateWindow()
                return
            except RuntimeError:
                pass  # Window was closed/deleted

        try:
            from nterm.parsing.tfsm_fire_tester import TextFSMTesterWidget
        except ImportError:
            QMessageBox.warning(
                self, "Not Available",
                "TextFSMTesterWidget not found in nterm.parsing.\n\n"
                "Ensure tfsm_fire_tester.py is in the nterm/parsing/ package."
            )
            return

        # Resolve db path from the parsing config
        db_path = None
        try:
            from nterm.parsing import load_parse_config
            config = load_parse_config()
            if hasattr(config, 'textfsm') and hasattr(config.textfsm, 'db_path'):
                db_path = config.textfsm.db_path
        except Exception:
            pass  # Widget will find the db itself

        window = QDialog(self)
        window.setWindowTitle("TextFSM Template Tester")
        window.setMinimumSize(1000, 700)
        window.resize(1400, 900)
        # Non-modal: user can interact with main window while tester is open
        window.setModal(False)

        layout = QVBoxLayout(window)
        layout.setContentsMargins(0, 0, 0, 0)

        tester = TextFSMTesterWidget(db_path=db_path)
        layout.addWidget(tester)

        # Load templates if database exists
        from pathlib import Path as _Path
        if _Path(tester.db_path).exists():
            tester.load_all_templates()

        # Clean up reference when closed
        window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
        window.destroyed.connect(lambda: setattr(self, '_template_tester_window', None))

        # Keep reference to prevent GC
        self._template_tester_window = window
        window.show()

    # ── Actions ──────────────────────────────────────────────────

    def _on_download_requested(self, download):
        """Handle download requests from the web view (blob URL exports, etc.)."""
        suggested = download.downloadFileName()
        if not suggested:
            suggested = "nterm_export"

        save_path, _ = QFileDialog.getSaveFileName(
            self,
            "Save Export",
            str(Path.home() / "Downloads" / suggested),
            "All Files (*)",
        )
        if save_path:
            download.setDownloadFileName(Path(save_path).name)
            download.setDownloadDirectory(str(Path(save_path).parent))
            download.accept()
            self.status.showMessage(f"Saved: {save_path}", 5000)
            logger.info(f"Download saved: {save_path}")
        else:
            download.cancel()

    def _on_permission_requested(self, origin, feature):
        """Auto-grant clipboard and other permissions for the local nterm page."""
        page = self.nterm_view.page()
        # Grant all permissions from our own server origin
        if origin.host() in ("127.0.0.1", "localhost"):
            page.setFeaturePermission(
                origin, feature,
                QWebEnginePage.PermissionPolicy.PermissionGrantedByUser,
            )
            logger.debug(f"Permission granted: {feature} for {origin.toString()}")
        else:
            page.setFeaturePermission(
                origin, feature,
                QWebEnginePage.PermissionPolicy.PermissionDeniedByUser,
            )

    def _load_nterm(self):
        """Load the nterm web UI into the left pane."""
        self.nterm_view.setUrl(QUrl(self.server_url))
        logger.info(f"Loading nterm UI from {self.server_url}")

        # Apply saved display settings after page renders
        QTimer.singleShot(500, self._apply_saved_display_settings)

    def _get_vault_info(self) -> tuple:
        """
        Return (credential_names, vault_unlocked) for dialog construction.
        """
        cred_names = []
        unlocked = False
        if (
            hasattr(self, 'resolver')
            and self.resolver.is_initialized()
            and self.resolver.store.is_unlocked
        ):
            unlocked = True
            try:
                raw = self.resolver.list_credentials()
                # list_credentials() returns StoredCredential objects — extract names
                cred_names = [
                    c.name if hasattr(c, 'name') else str(c)
                    for c in raw
                ]
            except Exception:
                pass
        return cred_names, unlocked

    def _on_add_telemetry(self):
        """Open the telemetry connect dialog and create a tab."""
        cred_names, vault_unlocked = self._get_vault_info()
        dlg = TelemetryConnectDialog(
            self,
            credential_names=cred_names,
            vault_unlocked=vault_unlocked,
        )

        if dlg.exec() != QDialog.DialogCode.Accepted:
            return

        config = dlg.get_config()
        # Username not required when vault provides it
        if not config["host"]:
            QMessageBox.warning(self, "Missing Info", "Host is required.")
            return
        if not config["use_vault"] and not config["username"]:
            QMessageBox.warning(self, "Missing Info", "Username is required.")
            return

        self._add_telemetry_tab(config)

    @staticmethod
    def _creds_to_simple_auth(ssh_creds):
        """
        Freeze SSHCredentials into a SimpleAuthProvider.

        Handles key_data (vault-stored keys) by writing to a temp file,
        since SimpleAuthProvider only accepts key_path.
        """
        from wirlwind_telemetry.auth_interface import SimpleAuthProvider

        key_path = ssh_creds.key_path

        # Vault stores keys as key_data (PEM string), not key_path
        if not key_path and getattr(ssh_creds, 'key_data', None):
            import tempfile
            tmp = tempfile.NamedTemporaryFile(
                mode='w', suffix='.pem', delete=False,
                prefix='nterm_vault_key_',
            )
            tmp.write(ssh_creds.key_data)
            tmp.close()
            key_path = tmp.name
            logger.debug(f"Vault key_data written to temp file: {tmp.name}")

        return SimpleAuthProvider(
            username=ssh_creds.username,
            password=ssh_creds.password,
            key_path=key_path,
            key_passphrase=ssh_creds.key_passphrase,
        )

    def _add_telemetry_tab(self, config: dict):
        """
        Create a Wirlwind TelemetryWidget and add it as a tab.

        This imports wirlwind_telemetry at call time so the desktop
        wrapper doesn't fail if wirlwind isn't installed yet.
        """
        try:
            from wirlwind_telemetry.auth_interface import (
                SimpleAuthProvider, DeviceTarget,
            )
            from wirlwind_telemetry.widget import TelemetryWidget
        except ImportError:
            QMessageBox.critical(
                self, "Wirlwind Not Available",
                "wirlwind_telemetry is not installed.\n\n"
                "Install it with: pip install -e .[telemetry]\n"
                "or place wirlwind_telemetry/ in the project root."
            )
            return

        # Build auth provider
        #
        # IMPORTANT: Always resolve vault credentials into SSHCredentials,
        # then wrap in SimpleAuthProvider. Do NOT pass NtermAuthProvider
        # directly to the widget — when the widget calls auth.get_credentials()
        # it does hostname pattern matching, which fails for devices without
        # match patterns. Resolve once here, freeze the result.
        auth = None
        cred_name = config.get("credential_name")
        use_vault = config.get("use_vault", False)

        if (
            (cred_name or use_vault)
            and hasattr(self, 'resolver')
            and self.resolver.is_initialized()
            and self.resolver.store.is_unlocked
        ):
            try:
                from wirlwind_telemetry.auth_interface import (
                    NtermAuthProvider as WirlwindVaultAuth,
                    DeviceTarget as _DT,
                )

                vault_auth = WirlwindVaultAuth(self.resolver, preferred_credential=cred_name)
                tmp_target = _DT(
                    hostname=config["host"],
                    port=config.get("port", 22),
                    display_name=config.get("display_name", config["host"]),
                    vendor=config.get("vendor", ""),
                )

                ssh_creds = None
                if cred_name:
                    ssh_creds = vault_auth.get_credentials_by_name(
                        cred_name, tmp_target
                    )
                    if ssh_creds:
                        logger.info(
                            f"Telemetry vault credential "
                            f"'{cred_name}' for {config['host']} "
                            f"(user: {ssh_creds.username})"
                        )

                if not ssh_creds:
                    ssh_creds = vault_auth.get_credentials(tmp_target)
                    if ssh_creds:
                        logger.info(
                            f"Telemetry vault (hostname match) "
                            f"for {config['host']} "
                            f"(user: {ssh_creds.username})"
                        )

                # Freeze resolved creds into a SimpleAuthProvider
                if ssh_creds:
                    auth = self._creds_to_simple_auth(ssh_creds)

            except Exception as e:
                logger.warning(
                    f"Vault auth failed for telemetry "
                    f"({config['host']}): {e}",
                )
                # Vault credential exists but has no auth methods
                # (password not set, no key stored). Extract the username
                # so the dialog fallback can pre-fill it.
                if "no auth methods" in str(e).lower():
                    try:
                        cred = self.resolver.get_credential(
                            cred_name or ""
                        )
                        if cred and cred.username:
                            config["username"] = cred.username
                            logger.info(
                                f"Vault credential '{cred.name}' has no "
                                f"password/key — pre-filling username "
                                f"'{cred.username}' for dialog"
                            )
                    except Exception:
                        pass

        # Implicit vault try for legacy callers (no use_vault flag)
        if (
            auth is None
            and not use_vault
            and not cred_name
            and hasattr(self, 'resolver')
            and self.resolver.is_initialized()
            and self.resolver.store.is_unlocked
        ):
            try:
                from wirlwind_telemetry.auth_interface import (
                    NtermAuthProvider as WirlwindVaultAuth,
                    DeviceTarget as _DT,
                )
                vault_auth = WirlwindVaultAuth(self.resolver)
                tmp_target = _DT(
                    hostname=config["host"],
                    port=config.get("port", 22),
                    display_name=config.get("display_name", config["host"]),
                    vendor=config.get("vendor", ""),
                )
                ssh_creds = vault_auth.get_credentials(tmp_target)
                if ssh_creds:
                    auth = self._creds_to_simple_auth(ssh_creds)
                    logger.info(
                        f"Telemetry auto-resolved vault credential "
                        f"for {config['host']} (user: {ssh_creds.username})"
                    )
            except Exception as e:
                logger.debug(f"Implicit vault try failed: {e}")

        # Fall back to explicit credentials from dialog
        if auth is None:
            key_content = config.get("key_content")
            key_path = config.get("key_path")
            username = config.get("username", "")
            password = config.get("password")

            # Check if we have anything usable
            has_explicit_auth = (
                password or key_path or key_content
            )

            if not has_explicit_auth:
                # No credentials from any source — open dialog as last resort
                logger.info(
                    f"No credentials resolved for {config['host']}, "
                    f"opening telemetry dialog"
                )
                cred_names, v_unlocked = self._get_vault_info()
                dlg = TelemetryConnectDialog(
                    self,
                    credential_names=cred_names,
                    vault_unlocked=v_unlocked,
                )
                dlg.host_input.setText(config["host"])
                dlg.port_input.setText(str(config.get("port", 22)))
                dlg.name_input.setText(config.get("display_name", config["host"]))

                # Pre-fill username if vault provided one
                if username:
                    dlg.user_input.setText(username)
                    # Default to password auth since vault had
                    # the username but not the password
                    dlg.auth_combo.setCurrentIndex(
                        dlg.AUTH_PASSWORD
                    )

                for i in range(dlg.vendor_combo.count()):
                    if dlg.vendor_combo.itemText(i) == config.get("vendor", ""):
                        dlg.vendor_combo.setCurrentIndex(i)
                        break

                if dlg.exec() != QDialog.DialogCode.Accepted:
                    return

                # Re-enter _add_telemetry_tab with dialog config
                self._add_telemetry_tab(dlg.get_config())
                return

            # Handle pasted key content — write to temp file
            if key_content and not key_path:
                import tempfile
                tmp = tempfile.NamedTemporaryFile(
                    mode='w', suffix='.pem', delete=False,
                    prefix='nterm_key_',
                )
                tmp.write(key_content)
                tmp.close()
                key_path = tmp.name
                logger.debug(f"Pasted key written to temp file: {tmp.name}")

            auth = SimpleAuthProvider(
                username=username,
                password=password,
                key_path=key_path,
                key_passphrase=config.get("key_passphrase"),
            )
            logger.debug(
                f"Using explicit credentials for {config['host']} "
                f"(user: {username or '?'}, "
                f"has_password: {bool(password)}, "
                f"key_path: {key_path})"
            )

        target = DeviceTarget(
            hostname=config["host"],
            port=config.get("port", 22),
            display_name=config["display_name"],
            vendor=config["vendor"],
        )

        # Create widget
        widget = TelemetryWidget(
            auth_provider=auth,
            legacy_mode=config.get("legacy_mode", True),
        )
        widget.start(target)
        self._telemetry_widgets.append(widget)

        # Wire LLDP neighbor → SSH connect bridge
        widget.sshConnectRequested.connect(
            lambda h, p, v, d, w=widget: self._on_telemetry_ssh_connect(h, p, v, d, w)
        )
        widget.telemetryRequested.connect(
            lambda h, p, v, d, w=widget: self._on_telemetry_neighbor_telemetry(h, p, v, d, w)
        )

        # Add tab
        tab_label = config["display_name"]
        idx = self.telemetry_tabs.addTab(widget, tab_label)
        self.telemetry_tabs.setCurrentIndex(idx)

        # Show tab widget, hide placeholder
        self._placeholder.hide()
        self.telemetry_tabs.show()

        # Make sure the telemetry pane is visible
        sizes = self.splitter.sizes()
        if sizes[1] < 100:
            self.splitter.setSizes([1080, 720])

        self.status.showMessage(
            f"Telemetry started for {config['display_name']} ({config['vendor']})",
            5000,
        )
        logger.info(f"Telemetry tab added: {config['display_name']} @ {config['host']}")

    def _on_telemetry_ssh_connect(
        self, hostname: str, port: int, vendor: str, device_id: str,
        source_widget=None,
    ):
        """
        Handle SSH connect request from Wirlwind LLDP neighbor map.

        Extracts credentials from the source telemetry widget and
        caches them server-side so the WebSocket handler can use them
        after vault resolution fails. Secrets never cross to JavaScript.
        """
        logger.info(
            f"LLDP neighbor connect: {device_id} ({hostname}:{port}, {vendor})"
        )

        # ── Extract credentials from source widget ───────────
        # The source widget has a frozen SimpleAuthProvider. Extract
        # the credentials and cache them server-side so the WebSocket
        # handler can inject them when vault resolution fails.
        cached_creds = {}
        if source_widget:
            if hasattr(source_widget, '_credentials') and source_widget._credentials:
                creds = source_widget._credentials
                cached_creds = {
                    "username": creds.username or "",
                    "password": getattr(creds, 'password', None),
                    "key_file": getattr(creds, 'key_path', None),
                    "key_content": getattr(creds, 'key_data', None),
                    "key_passphrase": getattr(creds, 'key_passphrase', None),
                }
            elif hasattr(source_widget, '_auth') and source_widget._auth:
                auth = source_widget._auth
                cached_creds = {
                    "username": getattr(auth, '_username', ''),
                    "password": getattr(auth, '_password', None),
                    "key_file": getattr(auth, '_key_path', None),
                    "key_passphrase": getattr(auth, '_key_passphrase', None),
                }

        has_cached = (
            cached_creds.get("password")
            or cached_creds.get("key_file")
            or cached_creds.get("key_content")
        )

        if has_cached:
            from nterm.app import _ssh_cred_cache, _ssh_cred_lock
            with _ssh_cred_lock:
                _ssh_cred_cache[hostname] = cached_creds
            logger.debug(
                f"Cached SSH credentials for {hostname} "
                f"(user: {cached_creds.get('username', '?')})"
            )
        else:
            logger.debug(
                f"No credentials extractable from source widget for {hostname}"
            )

        # Build config object for the JS side
        config_json = json.dumps({
            "hostname": hostname,
            "port": port,
            "vendor": vendor,
            "device_id": device_id,
            # Username hint for form pre-fill (no secrets in JS)
            "username": cached_creds.get("username", ""),
        })

        # Call into the nterm web UI
        js = f"""
        (function() {{
            if (typeof window.ntermConnectDevice === 'function') {{
                window.ntermConnectDevice({config_json});
            }} else {{
                console.warn('ntermConnectDevice not available');
            }}
        }})();
        """
        self.nterm_view.page().runJavaScript(js)

        self.status.showMessage(
            f"Connecting to {device_id} ({hostname}) from LLDP neighbor…",
            5000,
        )

    def _on_telemetry_neighbor_telemetry(
        self,
        hostname: str,
        port: int,
        vendor: str,
        device_id: str,
        source_widget,
    ):
        """
        Handle Telemetry request from Wirlwind LLDP neighbor map.

        Opens a new Wirlwind telemetry tab for the neighbor device,
        reusing the SSH credentials from the originating widget.
        """
        logger.info(
            f"LLDP neighbor telemetry: {device_id} ({hostname}:{port}, {vendor})"
        )

        # Reuse credentials from the source telemetry widget
        try:
            from wirlwind_telemetry.auth_interface import SimpleAuthProvider
        except ImportError:
            return

        auth_config = {}
        if hasattr(source_widget, '_credentials') and source_widget._credentials:
            creds = source_widget._credentials
            auth_config = {
                "username": creds.username or "",
                "password": getattr(creds, 'password', None),
                "key_path": getattr(creds, 'key_path', None),
                "key_passphrase": getattr(creds, 'key_passphrase', None),
            }
            logger.debug(f"LLDP neighbor: reusing _credentials (user: {auth_config['username']})")
        elif hasattr(source_widget, '_auth') and source_widget._auth:
            auth = source_widget._auth
            auth_config = {
                "username": getattr(auth, '_username', ''),
                "password": getattr(auth, '_password', None),
                "key_path": getattr(auth, '_key_path', None),
                "key_passphrase": getattr(auth, '_key_passphrase', None),
            }
            logger.debug(f"LLDP neighbor: reusing _auth (user: {auth_config['username']})")

        # Check if we actually got usable credentials
        has_auth = (
            auth_config.get("password")
            or auth_config.get("key_path")
            or auth_config.get("key_passphrase")
        )

        if not has_auth:
            # Source widget creds not extractable — try vault
            logger.info(
                f"LLDP neighbor: no creds from source widget, "
                f"trying vault for {hostname}"
            )
            auth_config["use_vault"] = True

        config = {
            "host": hostname,
            "port": port,
            "vendor": vendor or "arista_eos",
            "display_name": device_id or hostname,
            "legacy_mode": getattr(source_widget, '_legacy_mode', True),
            **auth_config,
        }

        self._add_telemetry_tab(config)

    def _close_telemetry_tab(self, index: int):
        """Close a telemetry tab and stop its polling."""
        widget = self.telemetry_tabs.widget(index)
        self.telemetry_tabs.removeTab(index)

        # Full teardown — stops engine thread and releases web view
        if hasattr(widget, "cleanup"):
            try:
                widget.cleanup()
            except Exception as e:
                logger.warning(f"Error during telemetry cleanup: {e}")

        if widget in self._telemetry_widgets:
            self._telemetry_widgets.remove(widget)

        widget.deleteLater()

        # Show placeholder if no tabs remain
        if self.telemetry_tabs.count() == 0:
            self.telemetry_tabs.hide()
            self._placeholder.show()

    def _toggle_telemetry_pane(self):
        """Collapse or expand the right pane."""
        sizes = self.splitter.sizes()
        total = sum(sizes)
        if sizes[1] > 50:
            # Collapse
            self._saved_sizes = sizes
            self.splitter.setSizes([total, 0])
        else:
            # Restore
            restored = getattr(self, "__saved_sizes", [1080, 720])
            self.splitter.setSizes(restored)

    def _open_devtools(self):
        """Open a separate DevTools window for the nterm WebView."""
        self._devtools_view = QWebEngineView()
        self._devtools_view.setWindowTitle("nterm DevTools")
        self._devtools_view.resize(1200, 800)
        self.nterm_view.page().setDevToolsPage(self._devtools_view.page())
        self._devtools_view.show()

    def _show_about(self):
        dlg = AboutDialog(
            version=__version__,
            server_url=self.server_url,
            parent=self,
        )
        dlg.exec()

    # ── Lifecycle ────────────────────────────────────────────────

    def _count_active_nterm_sessions(self) -> int:
        """Query the FastAPI backend for active SSH session count."""
        try:
            import urllib.request
            import json as _json
            url = f"{self.server_url}/api/diag"
            req = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(req, timeout=1) as resp:
                data = _json.loads(resp.read())
                return data.get("sessions_active", 0)
        except Exception:
            return 0

    def closeEvent(self, event):
        """Clean shutdown with confirmation if sessions are active."""
        # ── Count active sessions ────────────────────────────────
        nterm_count = self._count_active_nterm_sessions()
        telemetry_count = self.telemetry_tabs.count()

        if nterm_count > 0 or telemetry_count > 0:
            parts = []
            if nterm_count > 0:
                s = "session" if nterm_count == 1 else "sessions"
                parts.append(f"{nterm_count} active SSH {s}")
            if telemetry_count > 0:
                s = "tab" if telemetry_count == 1 else "tabs"
                parts.append(f"{telemetry_count} telemetry {s}")

            detail = " and ".join(parts)

            reply = QMessageBox.question(
                self,
                "Quit nterm?",
                f"You have {detail}.\n\n"
                f"All connections will be closed. Quit anyway?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No,
            )

            if reply != QMessageBox.StandardButton.Yes:
                event.ignore()
                return

        # ── Proceed with shutdown ────────────────────────────────
        # Stop telemetry poll timer
        if hasattr(self, '_telemetry_poll_timer'):
            self._telemetry_poll_timer.stop()

        # Stop all telemetry widgets — cleanup() is blocking but bounded
        # by _ENGINE_STOP_TIMEOUT_MS (3s) per widget, with force-terminate
        # as fallback. This ensures no orphaned SSH threads survive.
        for widget in self._telemetry_widgets[:]:
            if hasattr(widget, "cleanup"):
                try:
                    widget.cleanup()
                except Exception as e:
                    logger.warning(f"Telemetry cleanup error: {e}")
        self._telemetry_widgets.clear()

        # Lock vault (clear secrets from memory)
        if hasattr(self, 'resolver') and self.resolver.is_initialized():
            try:
                self.resolver.lock_vault()
                logger.info("Vault locked on shutdown")
            except Exception:
                pass

        # Close session store
        if hasattr(self, '_session_store') and self._session_store:
            try:
                self._session_store.close()
            except Exception:
                pass

        logger.info("Desktop shutdown complete")
        event.accept()


# =====================================================================
#  Palette (matches nterm's dark theme)
# =====================================================================

def apply_nterm_palette(app: QApplication):
    """Apply the nterm dark palette to the QApplication."""
    palette = QPalette()

    # Base colors from nterm CSS variables
    bg_primary = QColor(10, 14, 20)       # --bg-primary
    bg_secondary = QColor(17, 24, 32)     # --bg-secondary
    bg_tertiary = QColor(21, 29, 39)      # --bg-tertiary
    text_primary = QColor(200, 214, 229)  # --text-primary
    text_muted = QColor(106, 117, 133)    # --text-muted
    accent = QColor(45, 212, 191)         # --accent (teal)

    palette.setColor(QPalette.ColorRole.Window, bg_primary)
    palette.setColor(QPalette.ColorRole.WindowText, text_primary)
    palette.setColor(QPalette.ColorRole.Base, bg_secondary)
    palette.setColor(QPalette.ColorRole.AlternateBase, bg_tertiary)
    palette.setColor(QPalette.ColorRole.Text, text_primary)
    palette.setColor(QPalette.ColorRole.Button, bg_tertiary)
    palette.setColor(QPalette.ColorRole.ButtonText, text_primary)
    palette.setColor(QPalette.ColorRole.Highlight, accent)
    palette.setColor(QPalette.ColorRole.HighlightedText, bg_primary)
    palette.setColor(QPalette.ColorRole.ToolTipBase, bg_tertiary)
    palette.setColor(QPalette.ColorRole.ToolTipText, text_primary)
    palette.setColor(QPalette.ColorRole.PlaceholderText, text_muted)

    # Disabled states
    palette.setColor(
        QPalette.ColorGroup.Disabled,
        QPalette.ColorRole.WindowText, text_muted,
    )
    palette.setColor(
        QPalette.ColorGroup.Disabled,
        QPalette.ColorRole.Text, text_muted,
    )
    palette.setColor(
        QPalette.ColorGroup.Disabled,
        QPalette.ColorRole.ButtonText, text_muted,
    )

    app.setPalette(palette)


# =====================================================================
#  Entry Point
# =====================================================================

def main():
    parser = argparse.ArgumentParser(
        description=f"nterm Desktop {__version__} — network terminal with telemetry"
    )
    parser.add_argument(
        "--port", type=int, default=8765,
        help="Preferred server port (will scan upward if busy, default: 8765)",
    )
    parser.add_argument(
        "--host", default="127.0.0.1",
        help="Server bind address (default: 127.0.0.1)",
    )
    parser.add_argument(
        "--debug", action="store_true",
        help="Enable debug logging and DevTools menu",
    )
    parser.add_argument(
        "--remote-debug", type=int, default=0, metavar="PORT",
        help="Enable Chrome remote debugger on PORT (e.g. --remote-debug 9222)",
    )

    # parse_known_args lets Qt/Chromium flags pass through
    # e.g. --webEngineArgs --remote-debugging-port=9122
    args, qt_args = parser.parse_known_args()

    # ── Detect Chromium remote-debug in passthrough args ─────────
    # If user passed --remote-debugging-port via Qt-style flags,
    # extract the port so we can log it and enable debug features
    chromium_debug_port = 0
    for arg in qt_args:
        if arg.startswith("--remote-debugging-port="):
            try:
                chromium_debug_port = int(arg.split("=", 1)[1])
            except ValueError:
                pass

    # ── Logging ──────────────────────────────────────────────────
    level = logging.DEBUG if args.debug else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
        datefmt="%H:%M:%S",
    )

    if qt_args:
        logger.info(f"Qt/Chromium passthrough args: {qt_args}")

    # ── Find available port ──────────────────────────────────────
    try:
        port = find_available_port(preferred=args.port)
    except RuntimeError as e:
        print(f"FATAL: {e}", file=sys.stderr)
        sys.exit(1)

    if port != args.port:
        logger.info(f"Port {args.port} busy, using {port}")

    # ── Start FastAPI server ─────────────────────────────────────
    server = NtermServer(
        host=args.host,
        port=port,
        log_level="debug" if args.debug else "warning",
    )
    server.start()

    if not server.wait_ready(timeout=10.0):
        print("FATAL: Server failed to start", file=sys.stderr)
        sys.exit(1)

    # ── Qt Application ───────────────────────────────────────────
    # --remote-debug (our flag) sets the env var directly
    if args.remote_debug:
        os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = str(args.remote_debug)
        logger.info(f"Chrome remote debugger enabled on port {args.remote_debug}")

    # Chromium passthrough (--remote-debugging-port=NNNN) is handled
    # by QApplication via sys.argv — just log it
    if chromium_debug_port:
        logger.info(
            f"Chrome remote debugger (Chromium flag) on port {chromium_debug_port}"
        )

    # Pass sys.argv so Qt picks up --webEngineArgs and Chromium flags
    app = QApplication(sys.argv)
    app.setApplicationName("nterm")
    app.setApplicationVersion(__version__)
    app.setStyle("Fusion")

    apply_nterm_palette(app)

    # ── Main Window ──────────────────────────────────────────────
    debug_mode = args.debug or chromium_debug_port > 0
    window = NtermDesktop(
        server_url=server.base_url,
        debug=debug_mode,
    )
    window.show()

    remote_info = ""
    if args.remote_debug:
        remote_info = f"\n  DevTools: http://127.0.0.1:{args.remote_debug}"
    elif chromium_debug_port:
        remote_info = f"\n  DevTools: http://127.0.0.1:{chromium_debug_port}"

    print(f"\n  nterm {__version__} (desktop)")
    print(f"  Server: {server.base_url}")
    print(f"  Port:   {port}" + (" (auto-selected)" if port != args.port else ""))
    if remote_info:
        print(remote_info)
    print()

    # ── Run ──────────────────────────────────────────────────────
    exit_code = app.exec()

    # ── Cleanup ──────────────────────────────────────────────────
    server.stop()
    sys.exit(exit_code)


if __name__ == "__main__":
    main()