"""
Telemetry Widget - Embeddable PyQt6 widget for device telemetry.

This is the top-level component. Embed it in nterm as a tab/window,
or run it standalone. It manages the poll engine, state store, and
dashboard display.

nterm integration API:
    widget = TelemetryWidget(auth_provider=auth, parent=tab_widget)
    widget.start(target)                     # connect + begin polling
    widget.stop()                            # non-blocking graceful stop
    widget.set_theme("dark" | "light")       # sync with host app theme
    widget.cleanup()                         # teardown (tab removal / app exit)
    widget.connectionStateChanged.connect()  # track state in host UI

Auth recovery:
    # Standalone: built-in credential dialog appears on auth failure
    # nterm: connect to authRequired signal to provide your own UI
    widget.authRequired.connect(my_credential_handler)
    # handler calls widget.retry_with_credentials(username, password, key_path)
"""

from __future__ import annotations
import json
import logging
from enum import Enum
from pathlib import Path
from typing import Optional

from PyQt6.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
    QFrame, QDialog, QFormLayout, QLineEdit, QDialogButtonBox,
    QFileDialog
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineSettings
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtCore import QUrl, Qt, pyqtSignal, QTimer

from .auth_interface import AuthProvider, DeviceTarget, SSHCredentials
from .state_store import DeviceStateStore
from .poll_engine import PollEngine
from .bridge import TelemetryBridge

logger = logging.getLogger(__name__)

# How long stop() waits before force-terminating the engine thread
_ENGINE_STOP_TIMEOUT_MS = 3000

# Substrings in engine error messages that indicate an auth failure.
# Checked case-insensitively against the error signal payload.
_AUTH_FAILURE_PATTERNS = (
    "authentication failed",
    "auth failed",
    "no credentials",
    "permission denied",
    "unknown cipher",        # paramiko transport corruption after auth failure
    "bad password",
    "keyboard-interactive",
)


class ConnectionState(str, Enum):
    """Connection lifecycle states emitted via connectionStateChanged."""
    DISCONNECTED = "disconnected"
    CONNECTING = "connecting"
    CONNECTED = "connected"
    STOPPING = "stopping"
    AUTH_FAILED = "auth_failed"
    ERROR = "error"


# ── Credential Dialog ────────────────────────────────────────────────

class _CredentialDialog(QDialog):
    """
    Minimal credential re-entry dialog for standalone mode.

    Styled to match the Wirlwind dark theme. Pre-fills the username
    from the last attempt so the user only has to fix the password.
    """

    def __init__(self, hostname: str, username: str = "", parent=None):
        super().__init__(parent)
        self.setWindowTitle("Authentication Required")
        self.setMinimumWidth(380)
        self.setStyleSheet("""
            QDialog {
                background: #0a0e14;
                color: #c8d6e5;
            }
            QLabel {
                color: #6b7d93;
                font-family: 'Consolas', monospace;
                font-size: 12px;
            }
            QLabel#header {
                color: #ff1744;
                font-size: 13px;
                font-weight: bold;
                padding-bottom: 8px;
            }
            QLineEdit {
                background: #151d27;
                border: 1px solid #2a3a4e;
                color: #c8d6e5;
                font-family: 'Consolas', monospace;
                font-size: 12px;
                padding: 4px 8px;
                border-radius: 2px;
            }
            QLineEdit:focus {
                border-color: #00e5ff;
            }
            QPushButton {
                background: #151d27;
                border: 1px solid #2a3a4e;
                color: #c8d6e5;
                font-family: 'Consolas', monospace;
                font-size: 12px;
                padding: 4px 16px;
                border-radius: 2px;
                min-width: 80px;
            }
            QPushButton:hover {
                background: #1e2a38;
                border-color: #00e5ff;
            }
            QPushButton:pressed {
                background: #0a0e14;
            }
        """)

        layout = QVBoxLayout(self)
        layout.setSpacing(12)

        # Header
        header = QLabel(f"Authentication failed for {hostname}")
        header.setObjectName("header")
        header.setWordWrap(True)
        layout.addWidget(header)

        # Form
        form = QFormLayout()
        form.setSpacing(8)

        self._username_input = QLineEdit(username)
        self._username_input.setPlaceholderText("SSH username")
        form.addRow("Username:", self._username_input)

        self._password_input = QLineEdit()
        self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
        self._password_input.setPlaceholderText("SSH password")
        form.addRow("Password:", self._password_input)

        # Key file row with browse button
        key_row = QHBoxLayout()
        self._key_input = QLineEdit()
        self._key_input.setPlaceholderText("(optional) path to SSH key")
        key_row.addWidget(self._key_input)

        browse_btn = QPushButton("…")
        browse_btn.setFixedWidth(32)
        browse_btn.setToolTip("Browse for SSH key file")
        browse_btn.clicked.connect(self._browse_key)
        key_row.addWidget(browse_btn)

        form.addRow("Key file:", key_row)

        layout.addLayout(form)

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

        # Focus password field if username is pre-filled
        if username:
            self._password_input.setFocus()
        else:
            self._username_input.setFocus()

    def _browse_key(self):
        path, _ = QFileDialog.getOpenFileName(
            self, "Select SSH Key", str(Path.home() / ".ssh"),
            "All Files (*)"
        )
        if path:
            self._key_input.setText(path)

    @property
    def username(self) -> str:
        return self._username_input.text().strip()

    @property
    def password(self) -> str:
        return self._password_input.text()

    @property
    def key_path(self) -> str:
        return self._key_input.text().strip() or None


# ── Telemetry Widget ─────────────────────────────────────────────────

class TelemetryWidget(QWidget):
    """
    Self-contained telemetry dashboard widget.

    Usage (standalone):
        auth = SimpleAuthProvider("admin", password="cisco")
        target = DeviceTarget("10.0.0.1", vendor="cisco_ios_xe")
        widget = TelemetryWidget(auth_provider=auth)
        widget.start(target)
        widget.show()

    Usage (nterm integration):
        auth = NtermAuthProvider(credential_resolver)
        widget = TelemetryWidget(auth_provider=auth, parent=tab_widget)
        widget.connectionStateChanged.connect(on_telemetry_state)
        widget.start(target)

    Auth recovery (nterm):
        widget.authRequired.connect(my_auth_handler)
        # In handler: prompt user, then call:
        #   widget.retry_with_credentials(username, password, key_path)

    Cleanup (nterm tab close):
        widget.cleanup()   # stops engine, releases web view, safe to delete
    """

    # ── Signals ──────────────────────────────────────────────────────

    # Emitted on every connection lifecycle transition.
    # Payload is a ConnectionState value string.
    connectionStateChanged = pyqtSignal(str)

    # Emitted when auth fails and credentials need to be re-entered.
    # Payload: (hostname: str, username: str)
    # If connected: the external handler should call retry_with_credentials().
    # If not connected: the built-in dialog is shown as fallback.
    authRequired = pyqtSignal(str, str)

    # Emitted when user clicks "Connect SSH" on a neighbor in the LLDP map.
    # Payload: (hostname: str, port: int, vendor: str, device_id: str)
    # Host app should open an SSH terminal tab for this device.
    sshConnectRequested = pyqtSignal(str, int, str, str)

    # Emitted when user clicks "Telemetry" on a neighbor in the LLDP map.
    # Payload: (hostname: str, port: int, vendor: str, device_id: str)
    # Host app should open a Wirlwind telemetry tab, reusing credentials.
    telemetryRequested = pyqtSignal(str, int, str, str)

    def __init__(
        self,
        auth_provider: AuthProvider,
        template_dir: str | Path = None,
        legacy_mode: bool = True,
        parent=None,
    ):
        super().__init__(parent)
        self._auth = auth_provider
        self._legacy_mode = legacy_mode
        self._cleaned_up = False
        self._auth_failure_count = 0

        # ── Template search paths ────────────────────────────────────
        # Always resolve the package-local templates/textfsm/ directory
        # relative to THIS file, not the caller. This ensures templates
        # are found whether run standalone or embedded in nterm.
        self._template_search_paths: list[str] = []

        # Package-local templates (highest priority)
        _pkg_templates = Path(__file__).parent / "templates" / "textfsm"
        if _pkg_templates.exists():
            self._template_search_paths.append(str(_pkg_templates.resolve()))
            logger.debug(f"Template path (package): {_pkg_templates.resolve()}")
        else:
            logger.warning(
                f"Package template dir not found: {_pkg_templates} — "
                f"TextFSM may fall back to regex"
            )

        # Caller-provided template directory (searched after package-local)
        if template_dir:
            self._template_search_paths.append(str(Path(template_dir).resolve()))
            logger.debug(f"Template path (custom): {template_dir}")

        self._poll_engine: Optional[PollEngine] = None
        self._credentials: Optional[SSHCredentials] = None
        self._target: Optional[DeviceTarget] = None
        self._connection_state = ConnectionState.DISCONNECTED
        self._pending_target = None
        self._pending_credentials = None

        self._setup_ui()

    # ── UI Setup ─────────────────────────────────────────────────────

    def _setup_ui(self):
        """Build the widget UI."""
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        # Control bar
        self._control_bar = QFrame()
        self._control_bar.setFixedHeight(32)
        self._control_bar.setStyleSheet("""
            QFrame {
                background: #0a0e14;
                border-bottom: 1px solid #1e2a38;
            }
            QLabel {
                color: #6b7d93;
                font-family: 'Consolas', 'Monaco', monospace;
                font-size: 11px;
            }
            QPushButton {
                background: #151d27;
                border: 1px solid #2a3a4e;
                color: #c8d6e5;
                font-family: 'Consolas', monospace;
                font-size: 11px;
                padding: 2px 12px;
                border-radius: 2px;
            }
            QPushButton:hover {
                background: #1e2a38;
                border-color: #00e5ff;
            }
            QPushButton:pressed {
                background: #0a0e14;
            }
        """)

        bar_layout = QHBoxLayout(self._control_bar)
        bar_layout.setContentsMargins(8, 0, 8, 0)

        self._status_label = QLabel("Disconnected")
        bar_layout.addWidget(self._status_label)

        bar_layout.addStretch()

        # Re-auth button — visible only after auth failure
        self._reauth_btn = QPushButton("Re-authenticate")
        self._reauth_btn.setToolTip("Enter new credentials and retry")
        self._reauth_btn.clicked.connect(self._on_reauth_clicked)
        self._reauth_btn.setVisible(False)
        self._reauth_btn.setStyleSheet("""
            QPushButton {
                background: #151d27;
                border: 1px solid #ff1744;
                color: #ff1744;
                font-family: 'Consolas', monospace;
                font-size: 11px;
                padding: 2px 12px;
                border-radius: 2px;
            }
            QPushButton:hover {
                background: #2a1520;
                border-color: #ff5252;
                color: #ff5252;
            }
        """)
        bar_layout.addWidget(self._reauth_btn)

        self._reconnect_btn = QPushButton("Reconnect")
        self._reconnect_btn.clicked.connect(self._on_reconnect)
        self._reconnect_btn.setVisible(False)
        bar_layout.addWidget(self._reconnect_btn)

        self._stop_btn = QPushButton("Stop")
        self._stop_btn.clicked.connect(self.stop)
        self._stop_btn.setVisible(False)
        bar_layout.addWidget(self._stop_btn)

        layout.addWidget(self._control_bar)

        # Web view for ECharts dashboard
        self._web_view = QWebEngineView()
        self._web_view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)

        settings = self._web_view.settings()
        settings.setAttribute(
            QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True
        )

        # Web channel + bridge
        self._channel = QWebChannel()
        self._state_store = DeviceStateStore(self)
        self._bridge = TelemetryBridge(self._state_store, self)
        self._channel.registerObject("telemetry", self._bridge)
        self._web_view.page().setWebChannel(self._channel)

        # Forward bridge signals to widget-level signals for host app
        self._bridge.sshConnectRequested.connect(self.sshConnectRequested)
        self._bridge.telemetryRequested.connect(self.telemetryRequested)

        layout.addWidget(self._web_view)

        self._load_dashboard()

    def _load_dashboard(self):
        """Load the ECharts dashboard HTML into the web view."""
        dashboard_path = Path(__file__).parent / "dashboard" / "index.html"
        if dashboard_path.exists():
            self._web_view.setUrl(QUrl.fromLocalFile(str(dashboard_path.resolve())))
        else:
            logger.error(f"Dashboard not found: {dashboard_path}")
            self._web_view.setHtml(
                "<html><body style='background:#0a0e14;color:#ff1744;padding:20px;'>"
                "<h2>Dashboard Not Found</h2>"
                f"<p>Expected at: {dashboard_path}</p>"
                "</body></html>"
            )

    # ── Public API ───────────────────────────────────────────────────

    @property
    def connection_state(self) -> ConnectionState:
        """Current connection lifecycle state."""
        return self._connection_state

    @property
    def is_connected(self) -> bool:
        """True if the poll engine is running and connected."""
        return self._connection_state == ConnectionState.CONNECTED

    def start(self, target: DeviceTarget, credentials: SSHCredentials = None):
        """
        Start telemetry for a device.

        If an engine is already running, it is stopped first (non-blocking
        with deferred restart via _on_engine_finished).

        Args:
            target: Device to monitor
            credentials: Pre-resolved credentials (optional).
                         If None, auth_provider.get_credentials(target) is called.
        """
        if self._cleaned_up:
            logger.warning("start() called after cleanup — ignoring")
            return

        # Reset auth failure state on explicit start
        self._auth_failure_count = 0

        # ── Tear down any existing engine before starting a new one ──
        if self._poll_engine is not None:
            if self._poll_engine.isRunning():
                logger.info("Engine still running — stopping before restart")
                # Stash intent so _on_engine_finished can resume
                self._pending_target = target
                self._pending_credentials = credentials
                self._stop_engine()
                return
            else:
                # Thread finished but ref still alive — clean it up
                self._discard_engine()

        self._pending_target = None
        self._pending_credentials = None
        self._do_start(target, credentials)

    def _do_start(self, target: DeviceTarget, credentials: SSHCredentials = None):
        """Internal start — assumes no engine is running."""
        self._target = target

        if credentials:
            self._credentials = credentials
        else:
            self._credentials = self._auth.get_credentials(target)

        if not self._credentials:
            self._set_status("Auth failed — no credentials", error=True)
            self._handle_auth_failure("No credentials provided")
            return

        # Publish device info to state store + bridge
        self._state_store.set_device_info({
            "hostname": target.display_name or target.hostname,
            "ip": target.hostname,
            "port": target.port,
            "vendor": target.vendor,
            "tags": target.tags,
            "username": self._credentials.username,
        })

        if self._bridge:
            self._bridge.deviceInfoChanged.emit(
                json.dumps(self._state_store.device_info, default=str)
            )

        vendor = target.vendor
        if not vendor:
            self._set_status("No vendor specified — cannot load templates", error=True)
            self._set_connection_state(ConnectionState.ERROR)
            return

        # ── Preflight: verify parser chain readiness ─────────────────
        self._run_preflight(vendor)

        # ── Create and wire the poll engine ──────────────────────────
        self._poll_engine = PollEngine(
            credentials=self._credentials,
            state_store=self._state_store,
            vendor=vendor,
            legacy_mode=self._legacy_mode,
            template_search_paths=self._template_search_paths,
            parent=self,
        )

        self._poll_engine.connected.connect(self._on_connected)
        self._poll_engine.disconnected.connect(self._on_disconnected)
        self._poll_engine.error.connect(self._on_error)
        self._poll_engine.finished.connect(self._on_engine_finished)

        self._set_status(f"Connecting to {self._credentials.display}...")
        self._set_connection_state(ConnectionState.CONNECTING)
        self._stop_btn.setVisible(True)
        self._reconnect_btn.setVisible(False)
        self._reauth_btn.setVisible(False)

        self._poll_engine.start()

    def stop(self):
        """
        Request a graceful stop of the poll engine.

        Non-blocking: signals the engine thread to stop, then uses a
        single-shot timer to check completion. The UI stays responsive.
        """
        if self._poll_engine is None or not self._poll_engine.isRunning():
            return

        self._set_connection_state(ConnectionState.STOPPING)
        self._set_status("Stopping...")
        self._stop_btn.setEnabled(False)
        self._stop_engine()

    def restart(self):
        """Stop and restart telemetry for the current target."""
        if self._target:
            self.start(self._target, self._credentials)

    def retry_with_credentials(
            self,
            username: str,
            password: str = None,
            key_path: str = None,
    ):
        if not self._target:
            logger.warning("retry_with_credentials: no target — call start() first")
            return

        new_creds = SSHCredentials(
            hostname=self._target.hostname,
            port=self._target.port,
            username=username,
            password=password,
            key_path=key_path,
        )

        logger.info(f"Retrying with new credentials for {new_creds.display}")
        self.start(self._target, credentials=new_creds)

    def set_theme(self, mode: str):
        """
        Switch the dashboard between dark and light theme.

        Args:
            mode: "dark" or "light"

        This calls the dashboard's applyTheme() JS function, which sets
        [data-theme] on the <html> element and triggers Theme.refresh()
        to re-read CSS variables and rebuild ECharts instances.
        """
        if self._cleaned_up:
            return
        mode = mode.lower().strip()
        if mode not in ("dark", "light"):
            logger.warning(f"set_theme: unknown mode '{mode}', expected 'dark' or 'light'")
            return
        js = f"if (typeof applyTheme === 'function') applyTheme('{mode}');"
        self._web_view.page().runJavaScript(js)

    def set_zoom(self, level: float):
        """
        Set the dashboard zoom level.

        Args:
            level: Zoom factor (0.5 – 2.0). 1.0 is 100%.

        Stub for nterm integration — the dashboard also has its own
        zoom controls (+/−/reset) that persist to localStorage.
        """
        if self._cleaned_up:
            return
        level = max(0.5, min(2.0, float(level)))
        js = f"if (typeof applyZoom === 'function') applyZoom({level});"
        self._web_view.page().runJavaScript(js)

    def cleanup(self):
        """
        Full teardown — call this when removing the widget from a layout,
        closing a tab, or shutting down the host application.

        Safe to call multiple times. After cleanup(), start() is a no-op.
        """
        if self._cleaned_up:
            return
        self._cleaned_up = True

        logger.debug("TelemetryWidget.cleanup()")

        # Stop engine (blocking here is acceptable — we're tearing down)
        self._stop_engine_sync()
        self._discard_engine()

        # Disconnect bridge from web channel
        try:
            self._channel.deregisterObject(self._bridge)
        except Exception:
            pass

        # Release web view (prevents leaked QWebEnginePage processes)
        try:
            self._web_view.setUrl(QUrl("about:blank"))
        except Exception:
            pass

    # ── Auth Failure Recovery ────────────────────────────────────────

    @staticmethod
    def _is_auth_error(msg: str) -> bool:
        """Check if an engine error message indicates an auth failure."""
        msg_lower = msg.lower()
        return any(pattern in msg_lower for pattern in _AUTH_FAILURE_PATTERNS)

    def _handle_auth_failure(self, error_msg: str):
        """
        Route auth failures to the appropriate handler.

        Priority:
          1. External handler connected to authRequired signal (nterm)
          2. Built-in credential dialog (standalone mode)
        """
        self._auth_failure_count += 1
        hostname = self._target.hostname if self._target else "unknown"
        username = self._credentials.username if self._credentials else ""

        self._set_connection_state(ConnectionState.AUTH_FAILED)
        self._set_status(f"Authentication failed — {hostname}", error=True)

        # Show re-auth button in control bar
        self._reauth_btn.setVisible(True)
        self._reconnect_btn.setVisible(False)
        self._stop_btn.setVisible(False)

        # Check if an external handler is connected to authRequired
        if self.receivers(self.authRequired) > 0:
            logger.debug("Auth failure — delegating to external authRequired handler")
            self.authRequired.emit(hostname, username)
        else:
            # No external handler — show built-in dialog
            logger.debug("Auth failure — showing built-in credential dialog")
            # Defer to next event loop tick so the error state settles
            # and any engine cleanup completes first
            QTimer.singleShot(100, lambda: self._show_credential_dialog(hostname, username))

    def _show_credential_dialog(self, hostname: str, username: str):
        """Show the built-in credential re-entry dialog."""
        if self._cleaned_up:
            return

        dialog = _CredentialDialog(hostname, username, parent=self)

        if dialog.exec() == QDialog.DialogCode.Accepted:
            new_user = dialog.username
            new_pass = dialog.password
            new_key = dialog.key_path

            if not new_user:
                self._set_status("Auth cancelled — no username", error=True)
                return

            if not new_pass and not new_key:
                self._set_status("Auth cancelled — no password or key", error=True)
                return

            self.retry_with_credentials(new_user, new_pass, new_key)
        else:
            logger.info("Credential dialog cancelled by user")
            self._set_status(
                f"Auth failed — click Re-authenticate to retry",
                error=True,
            )

    def _on_reauth_clicked(self):
        """Re-authenticate button handler — triggers auth recovery flow."""
        hostname = self._target.hostname if self._target else "unknown"
        username = self._credentials.username if self._credentials else ""

        if self.receivers(self.authRequired) > 0:
            self.authRequired.emit(hostname, username)
        else:
            self._show_credential_dialog(hostname, username)

    # ── Engine Lifecycle (private) ───────────────────────────────────

    def _stop_engine(self):
        """
        Signal the engine to stop, then poll for completion without
        blocking the UI thread.
        """
        if self._poll_engine is None:
            return

        self._poll_engine.stop()

        # Poll for thread exit — avoids blocking the event loop
        self._stop_check_timer = QTimer(self)
        self._stop_check_timer.setSingleShot(False)
        self._stop_check_timer.setInterval(100)
        self._stop_check_count = 0
        self._stop_check_timer.timeout.connect(self._check_engine_stopped)
        self._stop_check_timer.start()

    def _check_engine_stopped(self):
        """Timer callback: check if the engine thread has exited."""
        self._stop_check_count += 1
        max_checks = _ENGINE_STOP_TIMEOUT_MS // 100

        if self._poll_engine is None or not self._poll_engine.isRunning():
            # Clean exit
            self._stop_check_timer.stop()
            self._stop_check_timer.deleteLater()
            self._stop_check_timer = None
            return

        if self._stop_check_count >= max_checks:
            # Timeout — force terminate
            logger.warning(
                f"Engine thread did not exit within {_ENGINE_STOP_TIMEOUT_MS}ms — terminating"
            )
            self._stop_check_timer.stop()
            self._stop_check_timer.deleteLater()
            self._stop_check_timer = None

            try:
                self._poll_engine.terminate()
                self._poll_engine.wait(1000)
            except Exception as e:
                logger.error(f"Engine terminate failed: {e}")

            self._discard_engine()
            self._set_status("Disconnected (forced)", error=True)
            self._set_connection_state(ConnectionState.DISCONNECTED)

    def _stop_engine_sync(self):
        """
        Blocking stop — only used in cleanup() where we must ensure
        the thread is dead before the widget is destroyed.
        """
        if self._poll_engine is None or not self._poll_engine.isRunning():
            return

        self._poll_engine.stop()

        if not self._poll_engine.wait(_ENGINE_STOP_TIMEOUT_MS):
            logger.warning("Engine did not stop in time — terminating")
            self._poll_engine.terminate()
            self._poll_engine.wait(1000)

    def _discard_engine(self):
        """Disconnect signals and release the engine reference."""
        if self._poll_engine is None:
            return

        try:
            self._poll_engine.connected.disconnect(self._on_connected)
            self._poll_engine.disconnected.disconnect(self._on_disconnected)
            self._poll_engine.error.disconnect(self._on_error)
            self._poll_engine.finished.disconnect(self._on_engine_finished)
        except (TypeError, RuntimeError):
            # Already disconnected or object deleted
            pass

        self._poll_engine.deleteLater()
        self._poll_engine = None

    # ── Signal Handlers ──────────────────────────────────────────────

    def _on_connected(self):
        # Successful connection resets auth failure counter
        self._auth_failure_count = 0

        collections_count = (
            len(self._poll_engine.collections)
            if self._poll_engine else 0
        )
        self._set_status(
            f"Connected: {self._credentials.display} · "
            f"Vendor: {self._target.vendor} · "
            f"Polling {collections_count} collections"
        )
        self._set_connection_state(ConnectionState.CONNECTED)
        self._reauth_btn.setVisible(False)
        if self._bridge:
            self._bridge.connectionStatus.emit("connected")

    def _on_disconnected(self):
        self._set_status("Disconnected")
        self._stop_btn.setVisible(False)
        self._reconnect_btn.setVisible(True)
        self._reauth_btn.setVisible(False)
        self._set_connection_state(ConnectionState.DISCONNECTED)
        if self._bridge:
            self._bridge.connectionStatus.emit("disconnected")

    def _on_error(self, msg: str):
        """
        Engine error handler. Distinguishes auth failures from other
        errors and routes to the appropriate recovery path.
        """
        logger.debug(f"Engine error: {msg}")
        self._stop_btn.setVisible(False)
        self._stop_btn.setEnabled(True)

        if self._is_auth_error(msg):
            self._handle_auth_failure(msg)
        else:
            # Non-auth error — show reconnect button as before
            self._set_status(msg, error=True)
            self._reconnect_btn.setVisible(True)
            self._reauth_btn.setVisible(False)
            self._set_connection_state(ConnectionState.ERROR)

        if self._bridge:
            self._bridge.connectionStatus.emit(f"error:{msg}")

    def _on_engine_finished(self):
        """
        Engine thread exited. Clean up the reference and check if we
        have a pending restart queued from start() being called while
        the previous engine was still running.
        """
        self._stop_btn.setVisible(False)
        self._stop_btn.setEnabled(True)
        self._discard_engine()

        # If start() was called while we were stopping, resume it now
        pending_target = getattr(self, "_pending_target", None)
        pending_creds = getattr(self, "_pending_credentials", None)
        if pending_target is not None:
            self._pending_target = None
            self._pending_credentials = None
            # Defer to next event loop iteration so finished signal
            # processing completes cleanly
            QTimer.singleShot(0, lambda: self._do_start(pending_target, pending_creds))

    def _on_reconnect(self):
        """Reconnect with existing credentials (non-auth errors)."""
        if self._target:
            self.start(self._target, self._credentials)

    # ── Internal Helpers ─────────────────────────────────────────────

    def _set_status(self, text: str, error: bool = False):
        color = "#ff1744" if error else "#6b7d93"
        self._status_label.setStyleSheet(f"color: {color};")
        self._status_label.setText(text)

    def _set_connection_state(self, state: ConnectionState):
        if state != self._connection_state:
            self._connection_state = state
            self.connectionStateChanged.emit(state.value)
            logger.debug(f"Connection state → {state.value}")

    # ── Preflight Check ────────────────────────────────────────────

    def _run_preflight(self, vendor: str):
        """
        Verify parser chain readiness before starting the poll engine.

        Checks template resolution, package availability, and collection
        configs. Logs results but does not block startup — degraded
        operation (regex fallback) is better than no telemetry.
        """
        try:
            from .parser_chain import CollectionLoader, TemplateResolver
        except ImportError as e:
            logger.warning(f"Preflight: cannot import parser_chain — {e}")
            return

        errors = []
        warnings = []

        # ── Package checks ───────────────────────────────────────
        try:
            import textfsm
            logger.debug("Preflight: ✓ textfsm installed")
        except ImportError:
            warnings.append("textfsm not installed — TextFSM parser unavailable")

        ntc_path = None
        try:
            import ntc_templates
            ntc_path = Path(ntc_templates.__file__).parent / "templates"
            count = len(list(ntc_path.glob("*.textfsm"))) if ntc_path.exists() else 0
            logger.debug(f"Preflight: ✓ ntc-templates ({count} templates)")
        except ImportError:
            warnings.append("ntc-templates not installed")

        # ── Collection configs ───────────────────────────────────
        loader = CollectionLoader()
        collections = loader.list_collections(vendor)

        if not collections:
            errors.append(f"No collections found for vendor '{vendor}'")
        else:
            logger.debug(
                f"Preflight: ✓ {len(collections)} collections for "
                f"{vendor}: {', '.join(collections)}"
            )

        # ── Template resolution ──────────────────────────────────
        resolver = TemplateResolver(self._template_search_paths)

        resolved_count = 0
        missing_count = 0

        for collection in collections:
            config = loader.get_config(collection, vendor)
            if not config:
                continue

            for parser_def in config.get("parsers", []):
                ptype = parser_def.get("type", "")
                if ptype not in ("textfsm", "ttp"):
                    continue

                for tname in parser_def.get("templates", []):
                    resolved = resolver.resolve(tname)
                    if resolved:
                        resolved_count += 1
                        logger.debug(
                            f"Preflight: ✓ [{collection}] {ptype} → "
                            f"{tname} → {resolved}"
                        )
                    else:
                        missing_count += 1
                        errors.append(
                            f"[{collection}] {ptype} template NOT FOUND: "
                            f"{tname}"
                        )

        # ── Report ───────────────────────────────────────────────
        for w in warnings:
            logger.warning(f"Preflight: ⚠ {w}")
        for e in errors:
            logger.error(f"Preflight: ✗ {e}")

        if missing_count:
            logger.error(
                f"Preflight: {missing_count} template(s) not found — "
                f"parser will fall back to regex. "
                f"Search paths: {self._template_search_paths}"
            )
        else:
            logger.info(
                f"Preflight: ✓ all {resolved_count} templates resolved "
                f"for {vendor} ({len(collections)} collections)"
            )

    # ── Cleanup ──────────────────────────────────────────────────────

    def closeEvent(self, event):
        """Clean up on widget close."""
        self.cleanup()
        super().closeEvent(event)