#!/usr/bin/python3
# @file
# @ingroup uatools_tools
# @copyright
#   SPDX-FileCopyrightText: 2026 European Southern Observatory (ESO)
#   SPDX-License-Identifier: LGPL-3.0-only

"""
UA Explorer GUI - 2026-05-07T22:45:00

This Python GUI application provides a comprehensive OPC UA client interface with the following features:
- Connect to OPC UA servers with configurable URI
- Browse OPC UA node tree with lazy loading for performance
- Subscribe to variable nodes for real-time monitoring
- Execute OPC UA methods with parameter input
- Comprehensive logging with configurable levels
- Persistent settings storage
- Resizable interface with splitter layouts
- Multi-selection support for subscriptions
- Enhanced keyboard shortcuts

Built using PyQt6 and asyncua library for robust OPC UA communication.
Specification: OPC UA Client GUI with node browsing, subscriptions, method execution, and logging capabilities.
"""

import sys
import sqlite3
import os
import json
import asyncio
import getpass
import logging
import argparse
import signal
import shutil
import csv
import fnmatch
import socket
import threading
import traceback
import concurrent.futures as concurrent_futures
from collections import deque
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, Optional, List, Tuple, Callable
from pathlib import Path
from datetime import datetime, timezone, timedelta
import re
import time

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QSplitter, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator, QLineEdit, QPushButton,
    QTextEdit, QPlainTextEdit, QLabel, QMessageBox, QMenu, QTableWidget, QTableWidgetItem,
    QHeaderView, QComboBox, QSpinBox, QFormLayout, QDialog, QDialogButtonBox,
    QScrollArea, QFrame, QCheckBox, QGroupBox, QProgressBar, QRadioButton,
    QButtonGroup, QSizePolicy, QStyle, QToolButton, QInputDialog, QFileDialog
)
from PyQt6.QtCore import Qt, QThread, QObject, pyqtSignal, QTimer, QSettings, QMetaObject, Q_ARG, QProcess, QMimeData, QUrl
from PyQt6.QtNetwork import QLocalSocket, QLocalServer
from PyQt6.QtGui import QFont, QFontDatabase, QAction, QPixmap, QKeySequence, QShortcut, QColor, QBrush, QPalette, QDrag, QDesktopServices, QDoubleValidator, QIntValidator, QTextOption

from asyncua import Client, Node, ua
try:
    # asyncua <1.1.8
    from asyncua.common.subscription import SubHandler as _SubHandler
except Exception:
    try:
        # asyncua >=1.1.8
        from asyncua.common.subscription_handler import SubHandler as _SubHandler
    except Exception:
        # Fallback shim to keep interface compatible
        class _SubHandler:
            def datachange_notification(self, node, val, data):
                pass

            def event_notification(self, event):
                pass


# ---------------------------------------------------------------------------
# File resolution
#
# Implements the workspace File Location Rules so resources (logos, configs,
# referenced files) can be located in a standalone single-file run as well
# as in a packaged INTROOT/PREFIX install.
#
# Resolution order for a given name:
#   1. Absolute paths (after expanding $VAR, ${VAR}, and ~) resolve directly.
#   2. Relative paths are searched in CFGPATH (colon-separated roots), in
#      order.
#   3. If a parent file is supplied, fall back to <parent_dir>/<name>.
#   4. INTROOT and PREFIX (in that order) are tried as implicit roots.
#   5. As a final fallback for the standalone-script case, search the
#      directory of this source file and its sibling `resource/` directory.
#
# Logs at INFO which path was resolved and how, so config issues are
# debuggable in deployed environments without a rebuild.
# ---------------------------------------------------------------------------

def find_file(name: str, parent: Optional[Path] = None) -> Optional[Path]:
    """Locate ``name`` per the workspace File Location Rules.

    Returns the resolved absolute Path, or None if the file cannot be found.
    """
    logger = logging.getLogger(__name__)

    expanded = os.path.expanduser(os.path.expandvars(name))
    candidate = Path(expanded)

    if candidate.is_absolute():
        if candidate.is_file():
            logger.info("find_file: %r resolved as absolute -> %s", name, candidate)
            return candidate.resolve()
        logger.info("find_file: %r is absolute but does not exist", name)
        return None

    rel = Path(expanded)

    cfgpath = os.environ.get("CFGPATH", "")
    for root in [p for p in cfgpath.split(os.pathsep) if p]:
        hit = Path(root) / rel
        if hit.is_file():
            logger.info("find_file: %r resolved via CFGPATH (%s) -> %s", name, root, hit)
            return hit.resolve()

    if parent is not None:
        parent_dir = parent if parent.is_dir() else parent.parent
        hit = parent_dir / rel
        if hit.is_file():
            logger.info(
                "find_file: %r resolved relative to parent (%s) -> %s", name, parent_dir, hit
            )
            return hit.resolve()

    for env_name in ("INTROOT", "PREFIX"):
        root = os.environ.get(env_name, "")
        if not root:
            continue
        hit = Path(root) / rel
        if hit.is_file():
            logger.info("find_file: %r resolved via %s (%s) -> %s", name, env_name, root, hit)
            return hit.resolve()

    here = Path(__file__).resolve().parent
    for fallback_root in (here, here.parent / "resource", here.parent):
        hit = fallback_root / rel
        if hit.is_file():
            logger.info(
                "find_file: %r resolved via standalone fallback (%s) -> %s",
                name, fallback_root, hit,
            )
            return hit.resolve()

    logger.info("find_file: %r not found (CFGPATH=%r, INTROOT=%r, PREFIX=%r)",
                name, cfgpath, os.environ.get("INTROOT", ""), os.environ.get("PREFIX", ""))
    return None


# ---------------------------------------------------------------------------
# Scope subsystem
#
# A "scope" filters the OPC UA node tree displayed in the browser. The user
# selects from a set of named scopes; the active scope decides which nodes
# are visible. Built-in scopes ship with the tool; custom scopes are saved
# under ~/.uatools/UaExplorer/views/<name>.json.
#
# Filter pipeline applied per node:
#   1. Namespace filter:    if scope.namespace_uris non-empty, the node's
#                           namespace URI must be in the list.
#   2. Include filter:      if scope.include_patterns has any enabled
#                           pattern, the node's path must match at least
#                           one of them. Empty includes = "include all".
#   3. Exclude filter:      if any enabled exclude pattern matches the
#                           path, the node is hidden (overrides include).
#
# Patterns are glob-style (fnmatch). A bare word with no '/' and no glob
# special chars is treated as a substring match (wrapped to *word*).
# ---------------------------------------------------------------------------

@dataclass
class ScopePattern:
    pattern: str
    enabled: bool = True


@dataclass
class Scope:
    name: str
    description: str = ""
    include_patterns: List[ScopePattern] = field(default_factory=list)
    exclude_patterns: List[ScopePattern] = field(default_factory=list)
    namespace_uris: List[str] = field(default_factory=list)
    format_version: int = 1

    def to_dict(self) -> Dict[str, Any]:
        return {
            "format_version": self.format_version,
            "name": self.name,
            "description": self.description,
            "include_patterns": [asdict(p) for p in self.include_patterns],
            "exclude_patterns": [asdict(p) for p in self.exclude_patterns],
            "namespace_uris": list(self.namespace_uris),
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "Scope":
        def _patlist(raw: Any) -> List[ScopePattern]:
            out = []
            for p in (raw or []):
                if isinstance(p, dict):
                    out.append(ScopePattern(
                        pattern=str(p.get("pattern", "")),
                        enabled=bool(p.get("enabled", True)),
                    ))
                elif isinstance(p, str):
                    # Backward compat: bare strings = enabled patterns
                    out.append(ScopePattern(pattern=p, enabled=True))
            return [p for p in out if p.pattern]
        return cls(
            name=str(data.get("name", "Unnamed")),
            description=str(data.get("description", "")),
            include_patterns=_patlist(data.get("include_patterns")),
            exclude_patterns=_patlist(data.get("exclude_patterns")),
            namespace_uris=[str(u) for u in (data.get("namespace_uris") or [])],
            format_version=int(data.get("format_version", 1)),
        )


def _glob_match(path: str, pattern: str) -> bool:
    """Match `path` against a glob pattern.

    A bare word with no path separators or glob metacharacters is treated as
    a substring match (auto-wrapped to *word*). This matches the user's
    intuition that typing `PLC1` finds anything containing that name.

    `**` is treated as `*` by fnmatch (no recursive semantic), but since
    the slash is just a normal character in fnmatch, `/foo/**` still
    matches `/foo/bar/baz`. We do NOT add special `**` handling — fnmatch's
    behaviour is good enough for the v1 scope use case.
    """
    if not pattern:
        return False
    has_glob = any(c in pattern for c in "*?[]")
    if "/" not in pattern and not has_glob:
        # Bare word -> substring
        pattern = f"*{pattern}*"
    return fnmatch.fnmatchcase(path, pattern)


def _ancestor_paths(path: str):
    """Yield `path` and each of its ancestor paths, e.g.
    /a/b/c -> /a/b/c, /a/b, /a. Used so an include that matches a node also
    protects that node's whole subtree."""
    parts = path.split("/")
    # parts[0] is "" for a leading-slash path; ancestors start at /a.
    for i in range(len(parts), 1, -1):
        yield "/".join(parts[:i])


def _path_included(path: str, enabled_includes) -> bool:
    """True if `path` matches an enabled include OR is a DESCENDANT of a node
    that does (subtree-aware). So include `/system/subsys001` covers
    `/system/subsys001` and everything below it."""
    return any(_glob_match(anc, p)
               for anc in _ancestor_paths(path)
               for p in enabled_includes)


def _include_prefix(pattern: str) -> Optional[str]:
    """If `pattern` is a PREFIX-style include (a fixed subtree root, optionally
    with a trailing /* or /**), return the fixed prefix path; else None.

    Prefix-style is safely prunable at browse time because we can enumerate
    exactly which nodes lead to it (its ancestors) and which are under it.
      /system/subsys001      -> "/system/subsys001"
      /system/subsys001/*    -> "/system/subsys001"
      /system/subsys001/**   -> "/system/subsys001"
      */Temperature          -> None (leading glob, could be anywhere)
      /system/subsys*        -> None (mid glob — a pattern, not a subtree root)
      subsys001              -> None (bare word = substring match)
    """
    if not pattern or not pattern.startswith("/"):
        return None
    p = pattern
    if p.endswith("/**"):
        p = p[:-3]
    elif p.endswith("/*"):
        p = p[:-2]
    # After stripping a trailing wildcard, the remainder must be a plain path
    # (no glob metacharacters anywhere).
    if any(c in p for c in "*?[]"):
        return None
    return p.rstrip("/") or None


def _browse_keep_for_prefixes(path: str, prefixes) -> bool:
    """True if `path` must be BROWSED given prefix-style includes `prefixes`:
    it is at/under a prefix (the wanted subtree) OR a strict ancestor of one
    (we must descend through it to reach the subtree). Everything else is
    pruned. `prefixes` non-empty is assumed."""
    for pref in prefixes:
        # At or under the prefix subtree.
        if path == pref or path.startswith(pref + "/"):
            return True
        # A strict ancestor of the prefix (needed to reach it).
        if pref.startswith(path + "/"):
            return True
    return False


def scope_matches_node(
    scope: Optional[Scope],
    path: str,
    ns_uri: Optional[str] = None,
) -> bool:
    """Return True if the node at `path` (in namespace `ns_uri`) is visible
    under `scope`. A None scope means "show everything" (no filtering).

    Precedence (2026-07-08): INCLUDE WINS over EXCLUDE, subtree-aware. A node
    that is explicitly included (or lives under an included subtree) is shown
    even if it also matches an exclude — this enables "exclude broadly, then
    re-include a specific subtree" cherry-picking. Excludes only apply to nodes
    that are NOT included. When there are no enabled includes, everything is
    "included" (pass-through) and excludes apply normally.
    """
    if scope is None:
        return True

    # Namespace filter (only when list non-empty AND we have a URI)
    if scope.namespace_uris:
        # If we don't know the node's namespace URI, fail-open (show it)
        # rather than fail-closed. Otherwise stub root nodes that aren't
        # tagged with a namespace would silently disappear.
        if ns_uri is not None and ns_uri not in scope.namespace_uris:
            return False

    enabled_includes = [p.pattern for p in scope.include_patterns
                        if p.enabled and p.pattern]
    enabled_excludes = [p.pattern for p in scope.exclude_patterns
                        if p.enabled and p.pattern]

    if enabled_includes:
        included = _path_included(path, enabled_includes)
        if not included:
            # Include list is an allow-list: non-included nodes are hidden.
            return False
        # Explicitly included -> exclude is ignored (include wins).
        return True

    # No includes -> everything passes the include stage; excludes apply.
    if any(_glob_match(path, p) for p in enabled_excludes):
        return False
    return True


def filter_nodes_by_scope(
    nodes_dict: Dict[str, Dict[str, Any]],
    scope: Optional[Scope],
    ns_uri_for_path: Optional[Any] = None,
) -> Dict[str, Dict[str, Any]]:
    """Return a new dict containing only the nodes that pass `scope`.

    `ns_uri_for_path`, if given, is a callable `(path, stub) -> ns_uri or None`.
    If None, no namespace filtering is performed (treated as "URI unknown").

    The returned dict drops `parent_id` references that point at filtered-out
    nodes (re-rooted to None) so the tree builder doesn't end up with orphans.
    Children-id lists are not pruned here — the tree builder only follows
    parents that are still in the dict.
    """
    if scope is None:
        return dict(nodes_dict)

    visible = {}
    for path, stub in nodes_dict.items():
        ns_uri = ns_uri_for_path(path, stub) if ns_uri_for_path else None
        if scope_matches_node(scope, path, ns_uri):
            visible[path] = stub

    # Re-root any node whose recorded parent_id no longer survives the filter,
    # so it shows up at the top level instead of disappearing.
    for path, stub in list(visible.items()):
        parent_id = stub.get("parent_id")
        if parent_id and parent_id not in visible:
            new_stub = dict(stub)
            new_stub["parent_id"] = None
            visible[path] = new_stub

    return visible


# ---------------------------------------------------------------------------
# Compact action-button styling
#
# Qt's default QPushButton on Linux is generously padded and rendered at the
# system font size, which makes a row of action buttons (Rebrowse / Subscribe
# / Plot / scope controls) feel chunky next to the tighter QComboBox /
# QLabel controls beside them. This stylesheet trims vertical padding,
# rounds the corners slightly, and uses a marginally smaller font so a
# multi-button toolbar fits in roughly the same vertical space as the
# adjacent combo box.
#
# Apply via `_compact_button(btn)` to make the look uniform across the GUI.
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Theming
#
# All colors live on a Theme dataclass keyed by named tokens (background,
# panel, text, accent, node_object, node_variable, node_method, ...).
# Three built-in themes are provided: Light (default), Dark, and
# Atacama (warm reddish/brownish — homage to the ESO observatory site).
#
# A single global ThemeManager owns the active theme and broadcasts a
# theme_changed signal; styling helpers re-read the active theme each
# time they're invoked, so wiring the signal to call them re-skins the
# affected widgets live without restart.
# ---------------------------------------------------------------------------

# Point size used for items rendered in the Node Browser and Methods
# Quick Access trees. Set explicitly (not via stylesheet) so bold methods
# don't visually appear larger than non-bold variables/objects, and so
# the two trees read at the same height side-by-side.
NODE_TREE_FONT_PT = 9


@dataclass
class Theme:
    """Named color tokens for one full UI theme.

    Tokens are intentionally semantic ("section_title", "node_object")
    rather than perceptual ("dark_blue") so themes can substitute very
    different hues without breaking call sites.
    """
    name: str

    # Surface colors
    window_bg: str       # main window background (frame around panels)
    input_bg: str        # background for editable inputs / lists / tables
                          # — i.e. the QPalette.Base role. Should contrast
                          # with window_bg so user input areas read as
                          # distinct "in front of" surfaces.
    panel_bg: str        # default widget background (buttons, combos idle)
    panel_alt_bg: str    # secondary surface (group rows, group headers)
    panel_hover_bg: str  # hover state for buttons
    panel_pressed_bg: str  # pressed state for buttons
    panel_disabled_bg: str  # disabled control background

    # Text colors
    text_primary: str    # main text
    text_muted: str      # secondary / hint text
    text_disabled: str   # disabled control text
    text_on_accent: str  # text drawn on top of the accent color

    # Borders / lines
    border: str          # default control border
    border_strong: str   # focused/hovered border
    border_subtle: str   # disabled border

    # Accents
    accent: str            # primary accent (focus, selection, links)
    accent_hover: str      # accent hover variant
    section_title: str     # section header label color

    # Node-class palette (Node Browser tree)
    node_object: str
    node_variable: str
    node_method: str

    # Status indicators (connection light, write status messages)
    status_ok: str         # connected / success
    status_warn: str       # warning
    status_error: str      # error / disconnected

    # Recording button (the prominent red "RECORDING" pulse)
    record_idle_bg: str
    record_idle_border: str
    record_active_bg: str
    record_active_border: str


# ---- Built-in themes ----

LIGHT_THEME = Theme(
    name="Light",
    # Light grey frame with white editable surfaces, dark blue-grey accent.
    # window_bg is intentionally grey (not white) so editable inputs/lists/
    # tables — which keep input_bg=white — read as distinct surfaces "inside"
    # the window frame.
    window_bg="#e8e8e8",
    input_bg="#ffffff",
    panel_bg="#dcdcdc",
    panel_alt_bg="#d4d8db",
    panel_hover_bg="#cfcfcf",
    panel_pressed_bg="#bfbfbf",
    panel_disabled_bg="#d8d8d8",
    text_primary="#202020",
    text_muted="#666666",
    text_disabled="#aaaaaa",
    text_on_accent="#ffffff",
    border="#888888",
    border_strong="#555555",
    border_subtle="#bbbbbb",
    accent="#3a6ea5",
    accent_hover="#2c5380",
    section_title="#2c4a5e",
    node_object="#b35a00",
    node_variable="#1a6e3f",
    node_method="#1a3f8f",
    status_ok="#2E7D32",
    status_warn="#C68400",
    status_error="#C62828",
    record_idle_bg="#b30000",
    record_idle_border="#7a0000",
    record_active_bg="#ff6666",
    record_active_border="#7a0000",
)

DARK_THEME = Theme(
    name="Dark",
    # Charcoal surfaces, soft off-white text, restrained accent.
    window_bg="#1f2226",
    input_bg="#15181c",
    panel_bg="#2b2f34",
    panel_alt_bg="#363c44",
    panel_hover_bg="#3a4048",
    panel_pressed_bg="#222629",
    panel_disabled_bg="#262a2e",
    text_primary="#e6e6e6",
    text_muted="#a8b0b8",
    text_disabled="#6a7079",
    text_on_accent="#ffffff",
    border="#4a4f55",
    border_strong="#6a7079",
    border_subtle="#3a3f44",
    accent="#5b9bd5",
    accent_hover="#80b6e6",
    section_title="#9fb8c8",
    # Slightly desaturated/lighter so they remain legible on dark surfaces.
    node_object="#e08a3a",
    node_variable="#5fbd80",
    node_method="#7da6e6",
    status_ok="#5fbd80",
    status_warn="#e6a93b",
    status_error="#e06c6c",
    record_idle_bg="#a02020",
    record_idle_border="#601818",
    record_active_bg="#e25555",
    record_active_border="#601818",
)

ATACAMA_THEME = Theme(
    name="Atacama",
    # Deeper red/brown desert palette inspired by ESO observatory sites.
    # Bronzed clay surfaces, terracotta accent, near-black umber text.
    # Surfaces sit roughly two shades darker than the original tan so the
    # palette reads as "warm dusk in the desert" rather than "cream paper".
    window_bg="#c9a07a",
    # Use the same tan as panel_bg for tables/trees/inputs so the
    # Node Browser and the Subscriptions output share one surface.
    input_bg="#b88a5f",
    panel_bg="#b88a5f",
    panel_alt_bg="#a37047",
    panel_hover_bg="#a87b50",
    panel_pressed_bg="#8b5a35",
    panel_disabled_bg="#b09075",
    text_primary="#2a1607",
    text_muted="#5a3622",
    text_disabled="#7a5945",
    text_on_accent="#ffffff",
    border="#5a3622",
    border_strong="#3a2010",
    border_subtle="#8b5a35",
    accent="#8a2e10",
    accent_hover="#6a1f08",
    section_title="#3a1a08",
    # Reddish-brown family for nodes; methods get the deepest saturated tone.
    # Variable color shifted toward olive so it stays distinguishable on
    # the warmer background without losing the green/brown contrast.
    node_object="#8a2e10",
    node_variable="#4a5520",
    node_method="#3a1408",
    status_ok="#4a5520",
    status_warn="#a86a0a",
    status_error="#8a2e10",
    record_idle_bg="#8a2810",
    record_idle_border="#5a1808",
    record_active_bg="#c44a28",
    record_active_border="#5a1808",
)

THEMES: Dict[str, Theme] = {
    LIGHT_THEME.name: LIGHT_THEME,
    DARK_THEME.name: DARK_THEME,
    ATACAMA_THEME.name: ATACAMA_THEME,
}
DEFAULT_THEME_NAME = LIGHT_THEME.name


class ThemeManager(QObject):
    """Owns the currently-active Theme and broadcasts changes.

    There is one ThemeManager singleton (`THEME`) per process. Widgets
    that need to restyle on theme changes connect a slot to
    `theme_changed` and re-call the relevant style helper.

    Theme changes are LIVE — no restart required. Helpers that build
    stylesheets (`build_compact_button_style()`, etc.) read from the
    active theme each call, so re-applying the helper is enough.
    """
    theme_changed = pyqtSignal(object)  # emits the new Theme

    def __init__(self) -> None:
        super().__init__()
        self._active: Theme = LIGHT_THEME

    @property
    def active(self) -> Theme:
        return self._active

    def set_theme(self, name_or_theme) -> None:
        if isinstance(name_or_theme, Theme):
            theme = name_or_theme
        else:
            theme = THEMES.get(str(name_or_theme), LIGHT_THEME)
        if theme is self._active:
            return
        self._active = theme
        self.theme_changed.emit(theme)


# Process-wide singleton. `THEME.active` is the live theme; subscribe to
# `THEME.theme_changed` to react. Importing modules MUST go through this
# instance rather than caching `THEME.active` at module load time.
THEME = ThemeManager()


# ---------------------------------------------------------------------------
# Styling helpers — all read from THEME.active each call so re-running
# them after a theme change is enough to restyle.
# ---------------------------------------------------------------------------

def build_compact_input_style() -> str:
    """Stylesheet for compact QLineEdit / QComboBox controls used in
    toolbars (matches button height + uses the active theme)."""
    t = THEME.active
    return (
        f"QLineEdit, QComboBox {{ padding: 1px 6px; font-size: 11px;"
        f" border: 1px solid {t.border}; border-radius: 4px;"
        f" background: {t.input_bg}; color: {t.text_primary};"
        f" min-height: 18px; max-height: 22px; }}"
    )


def build_compact_button_style() -> str:
    """Compact toolbar button with a subtle vertical gradient + bevel.

    The gradient goes from `panel_hover_bg` at the top to `panel_bg` at
    the bottom. Combined with a slightly darker bottom border this
    gives the buttons a soft 3D look without leaving the flat-design
    aesthetic. On press the gradient inverts (darker on top) so the
    button visually depresses.
    """
    t = THEME.active
    return f"""
    QPushButton {{
        padding: 2px 10px;
        font-size: 11px;
        border: 1px solid {t.border};
        border-bottom: 1px solid {t.border_strong};
        border-radius: 4px;
        background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
            stop:0 {t.panel_hover_bg}, stop:1 {t.panel_bg});
        color: {t.text_primary};
        min-height: 18px;
        max-height: 22px;
    }}
    QPushButton:hover {{
        background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
            stop:0 {t.panel_bg}, stop:1 {t.panel_hover_bg});
        border-color: {t.border_strong};
    }}
    QPushButton:pressed {{
        background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
            stop:0 {t.panel_pressed_bg}, stop:1 {t.panel_hover_bg});
        border: 1px solid {t.border_strong};
        padding-top: 3px;
        padding-bottom: 1px;
    }}
    QPushButton:disabled {{
        background: {t.panel_disabled_bg};
        color: {t.text_disabled};
        border: 1px solid {t.border_subtle};
    }}
    """


def make_compact_button(text: str, *, tip: Optional[str] = None) -> "QPushButton":
    """Create a QPushButton with the project's compact toolbar styling.

    The button registers itself with the theme manager so its
    stylesheet rebuilds whenever the user switches themes — no
    bookkeeping required at the call site.
    """
    btn = QPushButton(text)
    btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
    btn.setStyleSheet(build_compact_button_style())
    if tip:
        btn.setToolTip(tip)

    def _restyle(_theme):
        try:
            btn.setStyleSheet(build_compact_button_style())
        except RuntimeError:
            # Widget was destroyed before signal fired — disconnect ourselves.
            try:
                THEME.theme_changed.disconnect(_restyle)
            except (TypeError, RuntimeError):
                pass
    THEME.theme_changed.connect(_restyle)
    return btn


# ---------------------------------------------------------------------------
# Compact tree styling
# ---------------------------------------------------------------------------

def build_compact_tree_style() -> str:
    t = THEME.active
    return f"""
    QTreeWidget {{
        font-size: 11px;
        background: {t.input_bg};
        color: {t.text_primary};
        alternate-background-color: {t.panel_alt_bg};
    }}
    QTreeWidget::item {{
        padding: 1px 2px;
        min-height: 16px;
    }}
    QTreeWidget::item:selected {{
        background: {t.accent};
        color: {t.text_on_accent};
    }}
    QHeaderView::section {{
        padding: 2px 4px;
        font-size: 11px;
        background: {t.panel_bg};
        color: {t.text_primary};
        border: 1px solid {t.border_subtle};
    }}
    """
# NOTE: per-item font weight (bold for methods) is set via item.setFont()
# in NodeTreeWidget._apply_node_style. We deliberately do NOT specify a
# font-weight in the stylesheet above — Qt's stylesheet `font` properties
# on `QTreeWidget::item` REPLACE the item's font rather than merging,
# which would erase per-item bold settings.


def apply_compact_tree_style(tree: "QTreeWidget") -> None:
    """Apply the project's compact tree styling. Subscribes the tree
    to theme changes so its stylesheet rebuilds automatically."""
    tree.setStyleSheet(build_compact_tree_style())

    def _restyle(_theme):
        try:
            tree.setStyleSheet(build_compact_tree_style())
        except RuntimeError:
            try:
                THEME.theme_changed.disconnect(_restyle)
            except (TypeError, RuntimeError):
                pass
    THEME.theme_changed.connect(_restyle)


# ---------------------------------------------------------------------------
# Section title styling
# ---------------------------------------------------------------------------

def apply_section_title_style(label: "QLabel") -> None:
    """Apply the project's section-title typography. Subscribes the label
    to theme changes so the color follows the active theme live."""
    font = QFont()  # system default
    font.setPointSize(11)
    font.setWeight(QFont.Weight.DemiBold)
    label.setFont(font)
    label.setStyleSheet(f"color: {THEME.active.section_title};")

    def _restyle(_theme):
        try:
            label.setStyleSheet(f"color: {THEME.active.section_title};")
        except RuntimeError:
            try:
                THEME.theme_changed.disconnect(_restyle)
            except (TypeError, RuntimeError):
                pass
    THEME.theme_changed.connect(_restyle)


def build_qpalette_for_theme(theme: Theme) -> "QPalette":
    """Build a QPalette mirroring the theme so Qt's built-in widgets
    (system menus, default-styled QLineEdits, scrollbars, ...) follow
    the same surface and text colors as our custom widgets.

    `Window` is the frame surrounding panels; `Base` is the background
    of editable / listing widgets (QLineEdit, QTextEdit, QTableWidget,
    QTreeWidget). They MUST be different on themes like Atacama, where
    the window frame is bronze but the input surfaces should be deeper
    so users can see where to type / where the data is.
    """
    pal = QPalette()
    pal.setColor(QPalette.ColorRole.Window, QColor(theme.window_bg))
    pal.setColor(QPalette.ColorRole.WindowText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Base, QColor(theme.input_bg))
    pal.setColor(QPalette.ColorRole.AlternateBase, QColor(theme.panel_alt_bg))
    pal.setColor(QPalette.ColorRole.Text, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Button, QColor(theme.panel_bg))
    pal.setColor(QPalette.ColorRole.ButtonText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Highlight, QColor(theme.accent))
    pal.setColor(QPalette.ColorRole.HighlightedText, QColor(theme.text_on_accent))
    pal.setColor(QPalette.ColorRole.ToolTipBase, QColor(theme.panel_alt_bg))
    pal.setColor(QPalette.ColorRole.ToolTipText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Link, QColor(theme.accent))
    pal.setColor(QPalette.ColorRole.PlaceholderText, QColor(theme.text_muted))
    pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text,
                 QColor(theme.text_disabled))
    pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText,
                 QColor(theme.text_disabled))
    return pal


def apply_theme_to_application(theme: Theme) -> None:
    """Apply the theme to QApplication: sets style to Fusion (which
    actually obeys QPalette) and pushes the theme's QPalette so all
    standard Qt widgets follow."""
    app = QApplication.instance()
    if app is None:
        return
    try:
        app.setStyle("Fusion")
    except Exception:
        pass
    app.setPalette(build_qpalette_for_theme(theme))


# Built-in scopes embedded as Python dicts. These are written to the user's
# ~/.uatools/UaExplorer/scopes/ directory on first run if missing, so the user
# can edit and own them. Deleting a built-in file regenerates it next start.

_BUILTIN_DEFAULT_SCOPE = {
    "format_version": 1,
    "name": "Default",
    "description": "Show everything (no filtering).",
    "include_patterns": [],
    "exclude_patterns": [],
    "namespace_uris": [],
}

_BUILTIN_TWINCAT_SCOPE = {
    "format_version": 1,
    "name": "TwinCAT",
    "description": (
        "Hide Beckhoff/TwinCAT infrastructure (BeckhoffCtrlTypes, "
        "AlarmsConditions, Configuration, DeviceSet, Server, "
        "BkUaServerConfig, type dictionaries, Aliases, Locations) AND "
        "TwinCAT-injected PLC metadata (DeviceManual, DeviceRevision, "
        "HardwareRevision, Manufacturer, Model, RevisionCounter, "
        "SerialNumber, SoftwareRevision, DeviceState, Programs, Tasks, "
        "Aliases). Show user PLC project content only. Tested against "
        "TwinCAT-3.1 OPC UA server with Beckhoff Information Model."
    ),
    "include_patterns": [],
    "exclude_patterns": [
        # Top-level OPC UA / Beckhoff infrastructure branches
        {"pattern": "/Server",                "enabled": True},
        {"pattern": "/Server/*",              "enabled": True},
        {"pattern": "/Types",                 "enabled": True},
        {"pattern": "/Types/*",               "enabled": True},
        {"pattern": "/Views",                 "enabled": True},
        {"pattern": "/Views/*",               "enabled": True},
        {"pattern": "/AlarmsConditions",      "enabled": True},
        {"pattern": "/AlarmsConditions/*",    "enabled": True},
        {"pattern": "/BeckhoffCtrlTypes",     "enabled": True},
        {"pattern": "/BeckhoffCtrlTypes/*",   "enabled": True},
        {"pattern": "/BkUaServerConfig",      "enabled": True},
        {"pattern": "/Configuration",         "enabled": True},
        {"pattern": "/Configuration/*",       "enabled": True},
        {"pattern": "/DeviceSet",             "enabled": True},
        {"pattern": "/DeviceSet/*",           "enabled": True},
        {"pattern": "/*Dictionary",           "enabled": True},
        {"pattern": "*_TwinCAT_*",            "enabled": True},

        # TwinCAT-injected per-PLC metadata. These appear directly under
        # each PLC's root object (e.g. /PLC1/DeviceManual,
        # /MyPLC/SerialNumber). The patterns are PLC-name-agnostic by
        # using `*/<name>` so they hit any second-level node regardless
        # of the PLC's name.
        {"pattern": "*/DeviceManual",         "enabled": True},
        {"pattern": "*/DeviceRevision",       "enabled": True},
        {"pattern": "*/HardwareRevision",     "enabled": True},
        {"pattern": "*/Manufacturer",         "enabled": True},
        {"pattern": "*/Model",                "enabled": True},
        {"pattern": "*/RevisionCounter",      "enabled": True},
        {"pattern": "*/SerialNumber",         "enabled": True},
        {"pattern": "*/SoftwareRevision",     "enabled": True},
        # DeviceState, Programs, Tasks have children we also don't want.
        {"pattern": "*/DeviceState",          "enabled": True},
        {"pattern": "*/DeviceState/*",        "enabled": True},
        {"pattern": "*/Programs",             "enabled": True},
        {"pattern": "*/Programs/*",           "enabled": True},
        {"pattern": "*/Tasks",                "enabled": True},
        {"pattern": "*/Tasks/*",              "enabled": True},

        # Additional TwinCAT/Beckhoff admin branches surfaced in real
        # deployments. /Aliases and /Locations live at the root, and
        # per-PLC */Aliases/* mirrors the second-level pattern used for
        # DeviceState/Programs/Tasks above.
        {"pattern": "/Aliases",               "enabled": True},
        {"pattern": "*/Aliases/*",            "enabled": True},
        {"pattern": "/Locations",             "enabled": True},
    ],
    "namespace_uris": [],
}

_BUILTIN_SCOPES = [_BUILTIN_DEFAULT_SCOPE, _BUILTIN_TWINCAT_SCOPE]


class ScopeStore:
    """Persistent store for user views under ~/.uatools/UaExplorer/views/.

    (The user-facing term is "view"; internal class names still use
    "Scope" for backwards compatibility with the implementation.)

    Files are JSON, named <view-name>.json. A separate state.json one
    level up records the last-active view so it can be reloaded on start.
    """

    SCOPES_SUBDIR = "views"
    LEGACY_SUBDIR = "scopes"   # used by older versions; auto-migrated below
    STATE_FILE = "state.json"

    def __init__(self, base_dir: Optional[Path] = None):
        # base_dir = ~/.uatools/UaExplorer/  (or test-injected override)
        self.base_dir: Path = base_dir or (Path.home() / ".uatools" / "UaExplorer")
        self.scopes_dir: Path = self.base_dir / self.SCOPES_SUBDIR
        self.state_file: Path = self.base_dir / self.STATE_FILE
        self._migrate_legacy_scopes_dir()

    def _migrate_legacy_scopes_dir(self) -> None:
        """One-shot migration: if an older ~/.uatools/UaExplorer/scopes/
        directory exists from a previous version of UaExplorer (when
        the feature was called "Scope"), rename it to views/ so the
        user keeps their saved views.

        Done quietly — failures are non-fatal; the user can manually
        copy files if anything goes wrong.
        """
        legacy = self.base_dir / self.LEGACY_SUBDIR
        if not legacy.is_dir():
            return
        if self.scopes_dir.exists():
            # New dir already exists — don't clobber it. Leave the
            # legacy dir alone so the user can inspect it.
            return
        try:
            legacy.rename(self.scopes_dir)
        except OSError:
            # Rename across filesystems can fail; skip silently.
            pass

    def ensure_dirs(self) -> None:
        self.scopes_dir.mkdir(parents=True, exist_ok=True)

    def ensure_builtins(self) -> None:
        """Write built-in scope files if they don't already exist on disk."""
        self.ensure_dirs()
        for scope_data in _BUILTIN_SCOPES:
            target = self.scopes_dir / f"{scope_data['name']}.json"
            if not target.exists():
                try:
                    with open(target, "w") as f:
                        json.dump(scope_data, f, indent=2)
                except OSError:
                    # Non-fatal: the user can still run with no scope
                    pass

    def list_scope_names(self) -> List[str]:
        if not self.scopes_dir.is_dir():
            return []
        names = []
        for entry in sorted(self.scopes_dir.iterdir()):
            if entry.is_file() and entry.suffix == ".json":
                names.append(entry.stem)
        return names

    def load(self, name: str) -> Optional[Scope]:
        path = self.scopes_dir / f"{name}.json"
        if not path.is_file():
            return None
        try:
            with open(path, "r") as f:
                return Scope.from_dict(json.load(f))
        except (OSError, ValueError, json.JSONDecodeError):
            return None

    def save(self, scope: Scope) -> bool:
        self.ensure_dirs()
        path = self.scopes_dir / f"{scope.name}.json"
        try:
            with open(path, "w") as f:
                json.dump(scope.to_dict(), f, indent=2)
            return True
        except OSError:
            return False

    def delete(self, name: str) -> bool:
        path = self.scopes_dir / f"{name}.json"
        try:
            path.unlink(missing_ok=True)
            return True
        except OSError:
            return False

    def get_last_used(self) -> Optional[str]:
        if not self.state_file.is_file():
            return None
        try:
            with open(self.state_file, "r") as f:
                data = json.load(f)
            name = data.get("last_scope")
            return str(name) if name else None
        except (OSError, ValueError, json.JSONDecodeError):
            return None

    def set_last_used(self, name: Optional[str]) -> None:
        try:
            self.ensure_dirs()
            data = {"last_scope": name} if name else {}
            with open(self.state_file, "w") as f:
                json.dump(data, f, indent=2)
        except OSError:
            pass


class ScopeEditDialog(QDialog):
    """Modal dialog for editing a Scope.

    Three tab-like sections:
      - Description (free text)
      - Includes / Excludes (lists with checkbox per pattern)
      - Namespaces (checklist; populated from server's namespace array
        when one is available, otherwise shows the URIs already saved
        in the scope)

    `get_scope()` returns the edited Scope. The original is not
    modified — callers persist with `scope_store.save(...)`.
    """

    def __init__(self, scope: Scope, parent=None):
        super().__init__(parent)
        self.setWindowTitle(f"Edit View — {scope.name}")
        self.resize(640, 520)
        self._original = scope
        # If the user clicks "Save As..." inside this dialog and supplies a
        # name, this attribute holds the new name and the caller should
        # save the edited Scope under that name (rather than overwriting
        # the original). None means "save in place".
        self.save_as_name: Optional[str] = None

        layout = QVBoxLayout()

        # Name + description (read-only name; rename via Save As)
        form = QFormLayout()
        form.addRow("Name:", QLabel(scope.name))
        self.description_edit = QLineEdit(scope.description)
        form.addRow("Description:", self.description_edit)
        layout.addLayout(form)

        # Patterns — two side-by-side lists
        patterns_box = QHBoxLayout()
        patterns_box.addLayout(self._build_pattern_section("Include patterns", scope.include_patterns, "include"))
        patterns_box.addLayout(self._build_pattern_section("Exclude patterns", scope.exclude_patterns, "exclude"))
        layout.addLayout(patterns_box)

        # Namespaces — checklist driven by current server's namespace_array
        ns_group = QGroupBox("Namespaces (empty = no filter)")
        ns_layout = QVBoxLayout()
        self.ns_table = QTableWidget(0, 2)
        self.ns_table.setHorizontalHeaderLabels(["Show", "URI"])
        self.ns_table.horizontalHeader().setStretchLastSection(True)
        self.ns_table.verticalHeader().setVisible(False)
        self.ns_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
        # Populate from parent's worker.namespace_array if available; merge
        # in any URIs already in scope.namespace_uris that aren't on this server.
        seen = set()
        ns_array: List[str] = []
        try:
            mw = parent
            if mw and hasattr(mw, "worker") and mw.worker is not None:
                ns_array = list(mw.worker.namespace_array or [])
        except Exception:
            ns_array = []
        for uri in ns_array:
            self._add_ns_row(uri, uri in scope.namespace_uris)
            seen.add(uri)
        for uri in scope.namespace_uris:
            if uri not in seen:
                self._add_ns_row(f"{uri}  (not on current server)", True, raw_uri=uri)
        ns_layout.addWidget(self.ns_table)
        ns_group.setLayout(ns_layout)
        layout.addWidget(ns_group)

        # Bottom row: Save As (left, ActionRole), then Cancel / OK (right).
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        save_as_btn = QPushButton("Save As...")
        save_as_btn.setToolTip(
            "Save the edited view under a new name (instead of overwriting the current one)"
        )
        save_as_btn.clicked.connect(self._on_save_as_clicked)
        buttons.addButton(save_as_btn, QDialogButtonBox.ButtonRole.ActionRole)
        buttons.accepted.connect(self.accept)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

        self.setLayout(layout)

    def _on_save_as_clicked(self) -> None:
        """Prompt for a new name; record it and close the dialog with
        an accept (so the caller saves under the new name)."""
        name, ok = QInputDialog.getText(self, "Save View As", "New view name:")
        if not ok:
            return
        name = (name or "").strip()
        if not name:
            return
        # Sanitise (only letters, digits, underscore, hyphen)
        safe = re.sub(r"[^A-Za-z0-9_\-]+", "_", name)
        if safe != name:
            QMessageBox.information(
                self,
                "Save View As",
                f"Name normalised to '{safe}' (only letters, digits, underscore, hyphen allowed).",
            )
            name = safe
        self.save_as_name = name
        self.accept()

    def _build_pattern_section(self, title: str, initial: List[ScopePattern], key: str) -> QVBoxLayout:
        """Build a (label + list-with-checkbox + add/remove) column."""
        col = QVBoxLayout()
        col.addWidget(QLabel(title))
        table = QTableWidget(0, 2)
        table.setHorizontalHeaderLabels(["On", "Pattern"])
        table.horizontalHeader().setStretchLastSection(True)
        table.verticalHeader().setVisible(False)
        col.addWidget(table)
        for pat in initial:
            self._add_pattern_row(table, pat.pattern, pat.enabled)

        btn_row = QHBoxLayout()
        add_btn = QPushButton("Add")
        add_btn.clicked.connect(lambda: self._add_pattern_row(table, "", True, edit=True))
        rm_btn = QPushButton("Remove")
        rm_btn.clicked.connect(lambda: self._remove_selected_row(table))
        btn_row.addWidget(add_btn)
        btn_row.addWidget(rm_btn)
        btn_row.addStretch()
        col.addLayout(btn_row)

        if key == "include":
            self.include_table = table
        else:
            self.exclude_table = table
        return col

    def _add_pattern_row(self, table: QTableWidget, pattern: str, enabled: bool, edit: bool = False) -> None:
        row = table.rowCount()
        table.insertRow(row)
        chk = QCheckBox()
        chk.setChecked(enabled)
        cell = QWidget()
        cell_layout = QHBoxLayout(cell)
        cell_layout.setContentsMargins(4, 0, 4, 0)
        cell_layout.addWidget(chk)
        cell_layout.addStretch()
        table.setCellWidget(row, 0, cell)
        item = QTableWidgetItem(pattern)
        table.setItem(row, 1, item)
        # Make the pattern column editable in-place
        item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
        if edit:
            table.editItem(item)

    def _remove_selected_row(self, table: QTableWidget) -> None:
        rows = sorted({i.row() for i in table.selectedIndexes()}, reverse=True)
        for r in rows:
            table.removeRow(r)

    def _add_ns_row(self, label: str, checked: bool, raw_uri: Optional[str] = None) -> None:
        row = self.ns_table.rowCount()
        self.ns_table.insertRow(row)
        chk = QCheckBox()
        chk.setChecked(checked)
        cell = QWidget()
        cell_layout = QHBoxLayout(cell)
        cell_layout.setContentsMargins(4, 0, 4, 0)
        cell_layout.addWidget(chk)
        cell_layout.addStretch()
        self.ns_table.setCellWidget(row, 0, cell)
        item = QTableWidgetItem(label)
        item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
        # Stash the raw URI on the item via Qt.UserRole so we can
        # round-trip it even when we've decorated the label.
        item.setData(Qt.ItemDataRole.UserRole, raw_uri if raw_uri is not None else label)
        self.ns_table.setItem(row, 1, item)

    def _read_pattern_table(self, table: QTableWidget) -> List[ScopePattern]:
        out = []
        for row in range(table.rowCount()):
            item = table.item(row, 1)
            text = (item.text() if item else "").strip()
            if not text:
                continue
            cell = table.cellWidget(row, 0)
            chk = cell.findChild(QCheckBox) if cell else None
            enabled = bool(chk.isChecked()) if chk else True
            out.append(ScopePattern(pattern=text, enabled=enabled))
        return out

    def _read_ns_table(self) -> List[str]:
        out = []
        for row in range(self.ns_table.rowCount()):
            cell = self.ns_table.cellWidget(row, 0)
            chk = cell.findChild(QCheckBox) if cell else None
            if chk and chk.isChecked():
                item = self.ns_table.item(row, 1)
                if item:
                    raw = item.data(Qt.ItemDataRole.UserRole)
                    out.append(str(raw))
        return out

    def get_scope(self) -> Scope:
        return Scope(
            name=self._original.name,
            description=self.description_edit.text(),
            include_patterns=self._read_pattern_table(self.include_table),
            exclude_patterns=self._read_pattern_table(self.exclude_table),
            namespace_uris=self._read_ns_table(),
            format_version=self._original.format_version,
        )


class SubscriptionHandler(_SubHandler):
    def __init__(self, worker):
        super().__init__()
        self.worker = worker

    def datachange_notification(self, node, val, data):
        """Handle data change notifications from subscriptions"""
        if self.worker:
            # Convert NodeId to the path used as our cache key
            nodeid_str = str(node.nodeid)
            path_key = self.worker.nodeid_to_path.get(nodeid_str, nodeid_str)
            self.worker.subscription_update.emit(path_key, val, data)


class MethodInvokeDialog(QDialog):
    def __init__(self, node_id, method_name, input_arguments, parent=None):
        super().__init__(parent)
        self.node_id = node_id
        self.method_name = method_name
        self.input_arguments = input_arguments or []

        self.setWindowTitle(f"Invoke Method: {method_name}")
        self.setModal(True)

        layout = QVBoxLayout()
        layout.setSpacing(8)

        info_label = QLabel(f"Method: {method_name}")
        apply_section_title_style(info_label)
        layout.addWidget(info_label)

        self.param_widgets = {}
        if self.input_arguments:
            params_group = QGroupBox("Input Parameters")
            params_layout = QFormLayout()
            # Tighten the form so the dialog isn't mostly empty space.
            params_layout.setHorizontalSpacing(12)
            params_layout.setVerticalSpacing(6)
            params_layout.setContentsMargins(8, 8, 8, 8)

            for i, arg in enumerate(self.input_arguments):
                param_name = f"Param_{i}"
                if hasattr(arg, 'Name') and arg.Name:
                    param_name = str(arg.Name)
                elif hasattr(arg, 'name') and arg.name:
                    param_name = str(arg.name)

                data_type = self.format_data_type(arg)
                param_widget = QLineEdit()
                # No placeholder text in the field — the type belongs in the
                # label, and a bogus NodeId(...) repr used to leak in here.
                # A description (if the server provides one) becomes the
                # tooltip so it's available without cluttering the row.
                desc = self._arg_description(arg)
                if desc:
                    param_widget.setToolTip(desc)
                # Label reads "name (type)", e.g. "position (Double)".
                label_text = f"{param_name} ({data_type})" if data_type else param_name
                label = QLabel(label_text)
                if desc:
                    label.setToolTip(desc)
                params_layout.addRow(label, param_widget)
                self.param_widgets[param_name] = param_widget

            params_group.setLayout(params_layout)
            layout.addWidget(params_group)
        else:
            no_params_label = QLabel("No input parameters required")
            layout.addWidget(no_params_label)

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

        self.setLayout(layout)
        # Size to content rather than a fixed 400x300 that left big gaps.
        # A sensible minimum keeps short single-arg methods from being cramped.
        self.setMinimumWidth(360)
        self.adjustSize()

    def _arg_description(self, arg) -> str:
        """Best-effort human help text for an argument, if the server gave one.

        asyncua's Argument carries a Description LocalizedText; surface its
        Text. Returns "" when absent so callers can skip the tooltip."""
        desc = getattr(arg, 'Description', None)
        if desc is None:
            return ""
        text = getattr(desc, 'Text', None)
        if text:
            return str(text)
        s = str(desc).strip()
        # Avoid leaking a "LocalizedText(...)" repr as help.
        return s if s and 'LocalizedText(' not in s else ""

    # Built-in OPC UA DataType identifiers (namespace 0) -> readable name.
    _BUILTIN_TYPE_BY_ID = {
        1: 'Boolean', 2: 'SByte', 3: 'Byte', 4: 'Int16', 5: 'UInt16',
        6: 'Int32', 7: 'UInt32', 8: 'Int64', 9: 'UInt64', 10: 'Float',
        11: 'Double', 12: 'String', 13: 'DateTime', 14: 'Guid',
        15: 'ByteString', 16: 'XmlElement', 17: 'NodeId', 18: 'ExpandedNodeId',
        19: 'StatusCode', 20: 'QualifiedName', 21: 'LocalizedText',
        22: 'ExtensionObject', 23: 'DataValue', 24: 'Variant',
        25: 'DiagnosticInfo',
    }

    def format_data_type(self, arg):
        """Human-readable type name for a method Argument's DataType.

        The DataType is an asyncua NodeId object. Its str() is e.g.
        ``NodeId(Identifier=11, NamespaceIndex=0, NodeIdType=...)`` — which is
        exactly what used to leak into the input field as "bogus". We pull the
        numeric Identifier (when NamespaceIndex==0, i.e. a built-in type) and
        map it to a readable name; otherwise fall back to a clean string.
        """
        dt = getattr(arg, 'DataType', None) or getattr(arg, 'data_type', None)
        if dt is None:
            return 'Variant'

        # Preferred: read the structured NodeId fields directly.
        ident = getattr(dt, 'Identifier', None)
        ns = getattr(dt, 'NamespaceIndex', None)
        if isinstance(ident, int) and (ns in (0, None)):
            name = self._BUILTIN_TYPE_BY_ID.get(ident)
            if name:
                return name

        text = str(dt)
        # Parse the ``Identifier=N, NamespaceIndex=0`` repr form.
        if 'Identifier=' in text:
            try:
                id_str = text.split('Identifier=')[1].split(',')[0].strip().strip("'\"")
                ns_str = '0'
                if 'NamespaceIndex=' in text:
                    ns_str = text.split('NamespaceIndex=')[1].split(',')[0].strip()
                if ns_str == '0' and id_str.isdigit():
                    name = self._BUILTIN_TYPE_BY_ID.get(int(id_str))
                    if name:
                        return name
            except Exception:
                pass
        # Parse the compact ``ns=0;i=N`` / ``i=N`` form.
        if ';i=' in text or text.startswith('i='):
            try:
                id_str = text.split('i=')[1].split(';')[0]
                if id_str.isdigit():
                    name = self._BUILTIN_TYPE_BY_ID.get(int(id_str))
                    if name:
                        return name
            except Exception:
                pass
        # Last resort: a non-built-in (custom/structured) type — show its
        # browse-ish identifier rather than the noisy NodeId(...) repr.
        ident = getattr(dt, 'Identifier', None)
        return str(ident) if ident is not None else 'Variant'

    def get_parameters(self):
        params = []
        for param_name, widget in self.param_widgets.items():
            value = widget.text()
            if not value:
                params.append(None)
                continue

            if value.lower() in ['true', 'false']:
                params.append(value.lower() == 'true')
            elif value.replace('-', '', 1).isdigit():
                params.append(int(value))
            elif value.replace('-', '', 1).replace('.', '', 1).isdigit():
                params.append(float(value))
            else:
                params.append(value)
        return params


class PlotSelectionDialog(QDialog):
    """Dialog to pick an existing plot or create a new one."""

    def __init__(self, plots: List[Dict[str, str]], default_new_title: str, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Plot Variable(s)")
        self.setMinimumWidth(320)
        layout = QVBoxLayout()

        form = QFormLayout()
        self.plot_combo = QComboBox()
        for plot in plots:
            self.plot_combo.addItem(plot.get("title", ""), plot.get("id"))
        self.plot_combo.addItem("New plot...", "__new__")
        form.addRow("Plot:", self.plot_combo)

        self.name_input = QLineEdit(default_new_title)
        form.addRow("Title:", self.name_input)
        layout.addLayout(form)

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

        self.setLayout(layout)
        self._toggle_name_field()
        self.plot_combo.currentIndexChanged.connect(self._toggle_name_field)

    def _toggle_name_field(self):
        is_new = self.plot_combo.currentData() == "__new__"
        self.name_input.setEnabled(is_new)

    def get_selection(self):
        data = self.plot_combo.currentData()
        if data == "__new__":
            return {"plot_id": None, "title": self.name_input.text().strip()}
        return {"plot_id": data, "title": self.plot_combo.currentText()}


def _ipc_channel_name() -> str:
    """Build a process-unique IPC channel name safe for QLocalServer on Windows (named pipes)."""
    raw = f"uaplot_{socket.gethostname()}_{os.getpid()}"
    # Windows: pipe name must be alphanumeric or underscore only; keep short to avoid edge cases
    safe = "".join(c if c.isalnum() or c == "_" else "_" for c in raw)
    return safe[:64] if len(safe) > 64 else safe


def _uaplot_stderr_to_user_message(stderr_text: str) -> Optional[str]:
    """If UaPlot stderr looks like a missing Python module, return a short user-facing message."""
    if not stderr_text or ("ModuleNotFoundError" not in stderr_text and "No module named" not in stderr_text):
        return None
    m = re.search(r"No module named ['\"]([^'\"]+)['\"]", stderr_text)
    if m:
        mod = m.group(1)
        return f"UaPlot could not start: missing Python package '{mod}'. Install it with: pip install {mod}"
    return None


class UaPlotBridge:
    """Thin helper for talking to the external UaPlot process over QLocalSocket."""

    def __init__(self, parent=None):
        self.parent = parent
        self.channel = _ipc_channel_name()
        self.process: Optional[QProcess] = None
        self._uaplot_path: Optional[Path] = None
        self._ui_keep_alive = QApplication.instance()

    def _log(self, level: str, message: str):
        if self.parent and hasattr(self.parent, "handle_log_message"):
            try:
                self.parent.handle_log_message(level, message)
            except Exception:
                pass

    def _server_name_for(self, uri: str) -> str:
        """Friendly name UaExplorer holds for ``uri`` (URI->name dict), or ""."""
        if not uri or not self.parent:
            return ""
        try:
            return str(self.parent.server_names.get(uri, "")).strip()
        except Exception:
            return ""

    def _cleanup_ipc_channel(self):
        """Remove stale local socket file if a previous UaPlot crashed."""
        try:
            QLocalServer.removeServer(self.channel)
        except Exception:
            pass

    def _find_executable(self) -> Optional[Path]:
        if self._uaplot_path and self._uaplot_path.exists():
            return self._uaplot_path
        # Same repo layout: uatools/uaexplorer/src, uatools/uaplot/src/UaPlot.py
        uatools_from_file = Path(__file__).resolve().parent.parent.parent
        uatools_from_argv = Path(sys.argv[0]).resolve().parent.parent.parent
        candidates = [
            uatools_from_file / "uaplot" / "src" / "UaPlot.py",
            uatools_from_argv / "uaplot" / "src" / "UaPlot.py",  # fallback from running script path (e.g. Windows)
            Path(sys.argv[0]).resolve().parent / "UaPlot",
        ]
        uaplot_in_path = shutil.which("UaPlot")
        if uaplot_in_path:
            candidates.append(Path(uaplot_in_path))
        for cand in candidates:
            if cand and Path(cand).exists():
                self._uaplot_path = Path(cand)
                break
        return self._uaplot_path

    def _stop_process(self):
        if not self.process:
            return
        try:
            # Try to send quit command first for graceful shutdown
            self.send_request({"command": "quit"}, timeout_ms=200)
        except Exception:
            pass
        try:
            self.process.terminate()
            self.process.waitForFinished(500)
            if self.process.state() != QProcess.ProcessState.NotRunning:
                self.process.kill()
                self.process.waitForFinished(500)
        except Exception:
            pass
        finally:
            self.process = None
        # Clear any stale events in the Qt loop so UI stays responsive
        if self._ui_keep_alive:
            self._ui_keep_alive.processEvents()

    def stop(self):
        """Public helper to stop UaPlot and clean up IPC channel."""
        self._log("INFO", "Stopping UaPlot...")
        self._stop_process()
        self._cleanup_ipc_channel()

    def ensure_running(self, uri: str, server_name: str = "") -> Tuple[bool, Optional[str]]:
        # Check if an existing process is still healthy
        if self.process and self.process.state() == QProcess.ProcessState.Running:
            pong, err = self.send_request({"command": "ping"})
            if pong and pong.get("type") == "pong":
                return True, None
            # Channel died; restart the process
            self._stop_process()
        else:
            self._stop_process()  # ensure stale handle is cleared

        executable = self._find_executable()
        if not executable:
            return False, "UaPlot not installed on this host"

        # Try multiple times to recover in case a stale socket exists or UaPlot is slow to bind
        last_err = "UaPlot did not respond on IPC channel"
        attempts = 3
        sb = getattr(self.parent, "statusBar", lambda: None)()
        for attempt in range(attempts):
            self._cleanup_ipc_channel()
            self._log("INFO", f"Starting UaPlot attempt {attempt + 1}/{attempts} on channel {self.channel} (path: {executable})")

            self.process = QProcess(self.parent)
            if executable.suffix == ".py":
                program = sys.executable
                args = [str(executable)]
            else:
                program = str(executable)
                args = []

            args += ["--channel", self.channel]
            if uri:
                args += ["--uri", uri]
            # Propagate the friendly name the user gave this URI in UaExplorer
            # so UaPlot shows it too. UaExplorer and UaPlot keep independent
            # URI->name maps; without this the name set in one tool is invisible
            # in the other. Same precedence model as --theme: the CLI value wins
            # over UaPlot's persisted server_names for this URI.
            if server_name:
                args += ["--server-name", server_name]
            # Propagate the currently active UaExplorer theme so UaPlot starts
            # with matching colors. UaPlot accepts the same theme names; an
            # unknown value falls back to Light on UaPlot's side.
            try:
                args += ["--theme", THEME.active.name]
            except Exception:
                pass

            self.process.start(program, args)
            if not self.process.waitForStarted(2000):
                last_err = "Failed to start UaPlot process"
                self._stop_process()
                continue
            self._log("INFO", f"UaPlot process started (pid={self.process.processId()})")
            if sb:
                sb.showMessage("Starting UaPlot...", 0)

            # Give the process time to bind the socket; use short timeout per ping so UI stays responsive
            ping_timeout_ms = 1500
            for _ in range(60):
                resp, err = self.send_request({"command": "ping"}, timeout_ms=ping_timeout_ms)
                if resp and resp.get("type") == "pong":
                    self._log("INFO", "UaPlot IPC channel is ready.")
                    return True, None
                if err:
                    last_err = err
                QThread.msleep(200)
                if self._ui_keep_alive:
                    self._ui_keep_alive.processEvents()

            # No response; stop and retry once (log stderr in case UaPlot crashed)
            if self._ui_keep_alive:
                self._ui_keep_alive.processEvents()
            if self.process:
                err_out = bytes(self.process.readAllStandardError()).decode(errors="replace").strip()
                if err_out:
                    self._log("ERROR", f"UaPlot stderr: {err_out}")
                    friendly = _uaplot_stderr_to_user_message(err_out)
                    if friendly:
                        last_err = friendly
            self._stop_process()

        if sb:
            sb.clearMessage()
        return False, last_err

    def _wait_with_events(self, fn, timeout_ms: int) -> bool:
        """Call a wait function in small steps to keep UI responsive."""
        elapsed = 0
        step = 100
        while elapsed < timeout_ms:
            if fn(step):
                return True
            elapsed += step
            if self._ui_keep_alive:
                self._ui_keep_alive.processEvents()
        return False

    def send_request(self, payload: Dict[str, Any], timeout_ms: int = 8000) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
        socket_conn = QLocalSocket()
        socket_conn.connectToServer(self.channel)
        if not self._wait_with_events(socket_conn.waitForConnected, timeout_ms):
            return None, f"Unable to connect to UaPlot IPC channel ({self.channel})"

        try:
            socket_conn.write((json.dumps(payload) + "\n").encode())
            socket_conn.flush()
            if not self._wait_with_events(socket_conn.waitForReadyRead, timeout_ms):
                return None, "No reply from UaPlot"
            data = bytes(socket_conn.readAll()).decode()
            lines = [line for line in data.splitlines() if line.strip()]
            if not lines:
                return None, "Empty reply from UaPlot"
            resp = json.loads(lines[-1])
            return resp, None
        except Exception as exc:
            return None, str(exc)
        finally:
            socket_conn.disconnectFromServer()

    def list_plots(self) -> Tuple[Optional[List[Dict[str, str]]], Optional[str]]:
        resp, err = self.send_request({"command": "list_plots"})
        if err:
            return None, err
        if resp and resp.get("type") == "plots":
            return resp.get("plots", []), None
        if resp and resp.get("type") == "error":
            return None, resp.get("message", "UaPlot returned error")
        return None, "Unexpected reply from UaPlot"

    def get_plot_details(self) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
        """Get detailed info about all plots including their variables."""
        resp, err = self.send_request({"command": "get_plot_details"})
        if err:
            return None, err
        if resp and resp.get("type") == "plot_details":
            return resp, None
        if resp and resp.get("type") == "error":
            return None, resp.get("message", "UaPlot returned error")
        return None, "Unexpected reply from UaPlot"

    def create_plot(self, title: str) -> Tuple[Optional[Dict[str, str]], Optional[str]]:
        resp, err = self.send_request({"command": "create_plot", "title": title})
        if err:
            return None, err
        if resp and resp.get("type") == "plot_created":
            return resp.get("plot"), None
        if resp and resp.get("type") == "error":
            return None, resp.get("message", "UaPlot returned error")
        return None, "Unexpected reply from UaPlot"

    def plot_variable(
        self,
        node_data: Dict[str, Any],
        plot_id: Optional[str],
        title: Optional[str],
        uri: str,
        monitor_mode: str,
        polling_interval_ms: int,
        focus_on_plot: bool = False,
    ) -> Tuple[bool, Optional[str]]:
        node_id = node_data.get("node_id")
        node_id_str = self._format_node_id(node_id)
        # Use path for display to distinguish variables with the same name
        display_label = node_data.get("path") or node_data.get("display_name", node_id_str)
        # Carry the friendly name so an already-running UaPlot that switches to
        # this URI (via _set_uri) labels it the same as UaExplorer. Mirrors the
        # --server-name launch arg for the cold-start case.
        payload = {
            "command": "plot_variable",
            "node_id": node_id_str,
            "display_name": display_label,
            "plot_id": plot_id,
            "title": title,
            "uri": uri,
            "server_name": self._server_name_for(uri),
            "monitor_mode": monitor_mode,
            "polling_interval_ms": polling_interval_ms,
            "focus": bool(focus_on_plot),
        }
        resp, err = self.send_request(payload, timeout_ms=6000)
        if err:
            return False, err
        if resp and resp.get("type") != "error":
            return True, None
        if resp:
            return False, resp.get("message", "UaPlot returned error")
        return False, "Unexpected reply from UaPlot"

    def _format_node_id(self, node_id: Any) -> str:
        if node_id is None:
            return ""
        # asyncua NodeId has to_string()
        try:
            if hasattr(node_id, "to_string"):
                return node_id.to_string()
        except Exception:
            pass
        try:
            text = str(node_id)
        except Exception:
            return ""

        if text.startswith("ns="):
            return text

        # Try to parse "NodeId(Identifier='foo', NamespaceIndex=2, ...)"
        if "Identifier=" in text and "NamespaceIndex=" in text:
            try:
                ident = text.split("Identifier=")[1].split(",")[0].strip().strip("'\"")
                ns_part = text.split("NamespaceIndex=")[1].split(",")[0].strip()
                if ident and ns_part:
                    return f"ns={ns_part};s={ident}"
            except Exception:
                pass

        return text


# Default per-URI node-count limit for Lazy Node Loading. On connect the
# namespace is browsed breadth-first up to this many nodes: if it fits, the whole
# namespace loads (COMPLETE); if the cap is reached with more to browse, loading
# stops there (PARTIAL) and deeper nodes fetch on expand. Overridable per-URI via
# settings.json ("lazy_loading_limits"); 0 disables lazy mode (always full).
DEFAULT_LAZY_LOADING_LIMIT = 10000

# Default global Quick Filter node limit (all URIs). When the loaded namespace
# has more nodes than this, the Quick Filter stops filtering live as the user
# types (a full hierarchical tree rebuild per keystroke freezes the GUI on a big
# cache) — the user types a filter and presses Return to search explicitly.
# 0 = no limit (always filter live).
DEFAULT_QUICK_FILTER_NODE_LIMIT = 10000

# Placeholder child shown under a not-yet-loaded node. Two states:
#  - PLACEHOLDER_LOADING: a load is actually in flight (user expanded the node).
#  - PLACEHOLDER_NOT_LOADED: a Lazy Node Loading frontier node whose children
#    were NOT browsed (e.g. after Expand-all, which is load-free). "Not loaded"
#    rather than "Loading..." so it doesn't read as a hung load. Both are treated
#    as placeholders (skipped in selection / context menu / etc.).
PLACEHOLDER_LOADING = "Loading..."
PLACEHOLDER_NOT_LOADED = "(not loaded — expand to load)"

# A single BFS level can be 100k+ parents wide. Browsing it as one asyncio
# gather is unbreakable — a Cancel can't take effect until the whole level
# finishes. We browse each level in chunks of this many parents (well above the
# 150-way concurrency, so throughput is unaffected) and check cancel / node
# limit between chunks, making Cancel responsive on huge namespaces.
BROWSE_LEVEL_CHUNK = 1000
_PLACEHOLDER_TEXTS = (PLACEHOLDER_LOADING, PLACEHOLDER_NOT_LOADED)


def _is_placeholder_text(text: str) -> bool:
    """True if a tree item's label is one of the lazy-load placeholders."""
    return text in _PLACEHOLDER_TEXTS


class OpcUaWorker(QThread):
    connected = pyqtSignal(bool)
    error = pyqtSignal(str)
    nodes_loaded = pyqtSignal(dict)
    namespace_chunk_loaded = pyqtSignal(dict)
    node_children_loaded = pyqtSignal(str, dict)
    subscription_update = pyqtSignal(str, object, object)
    method_result = pyqtSignal(str, object)
    log_message = pyqtSignal(str, str)
    rebrowse_requested = pyqtSignal()
    revalidate_requested = pyqtSignal()
    load_children_requested = pyqtSignal(str)
    loading_progress = pyqtSignal(int, int)
    current_value_updated = pyqtSignal(str, object)
    value_written = pyqtSignal(str, bool, str)  # node_id, success, message
    full_dump_progress = pyqtSignal(int, int)  # current, total
    full_dump_complete = pyqtSignal(dict)  # values_dict
    # Lazy Node Loading: report the load mode after (or during) a browse.
    # mode: "loading" | "partial" (lazy, only shallow loaded) | "complete".
    # estimate: best-guess total node count (for the load-time prompt).
    namespace_load_mode = pyqtSignal(str, int)  # mode, estimated_total
    # Lazy Node Loading: children of a partial-load frontier node, browsed on
    # expand. Keyed by PATH (like the fast browse), so it splices cleanly into
    # the path-keyed tree. Args: parent_path, children_dict.
    frontier_children_loaded = pyqtSignal(str, dict)
    frontier_children_requested = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.client = None
        self.subscription = None
        self.subscription_handler = None
        self.subscribed_nodes = {}
        self.running = False
        self.uri = ""
        self.loop = None
        self.nodes_cache = {}
        self.namespace_array: List[str] = []
        self.username: Optional[str] = None
        self.password: Optional[str] = None
        # `app_namespace_index` and `show_admin_nodes` were dropped in favour
        # of the Scope subsystem. The worker now browses every node it can
        # reach; filtering is applied at the GUI level via the active Scope.

        # --- Lazy Node Loading ---
        # On connect we browse breadth-first up to lazy_loading_limit REAL nodes.
        # If the namespace fits, it loads fully; otherwise loading STOPS at the
        # limit (lazy/partial): the tree is usable immediately with up to the
        # limit's worth of nodes, and deeper nodes load on demand (expand) or when
        # the user asks for a full load. lazy_loading_limit is a per-URI setting
        # pushed from the GUI (default DEFAULT_LAZY_LOADING_LIMIT); 0/None disables
        # lazy mode (always full).
        self.lazy_loading_limit = DEFAULT_LAZY_LOADING_LIMIT
        self._browse_frontier = []      # unbrowsed (node, path) at the load edge
        self._browse_truncated = False  # True after a shallow browse stopped early
        self._namespace_estimate = 0    # extrapolated total, for the load prompt

        # --- View-aware browse pruning (P3f) ---
        # Enabled exclude glob patterns of the active View, pushed from the GUI
        # before a browse. A node whose path matches any of these is not cached
        # and not descended into — its whole subtree is excluded, which is safe
        # (an excluded node cannot contain visible descendants). None/empty = no
        # pruning (full browse; the GUI still filters at render time as before).
        self.browse_exclude_patterns: List[str] = []
        # Enabled INCLUDE patterns of the active View, and — when ALL of them are
        # prefix-style (a fixed subtree root, P3f.2) — the extracted prefixes.
        # With prefix-style includes we prune the browse to just those subtrees
        # (plus the ancestor path needed to reach them). If any include is a
        # non-prefix glob (e.g. */Temperature), include_prunable is False and we
        # fall back to a full browse + render filter (can't safely prune).
        self.browse_include_patterns: List[str] = []
        self.browse_include_prefixes: List[str] = []
        self.browse_include_prunable = False

        # --- Browse cancellation (P3f.5) ---
        # Set from the GUI (Cancel button) to stop a long browse cooperatively.
        # Checked between BFS levels and mid-level; on cancel the browse stops
        # and keeps whatever was cached so far (-> PARTIAL). Reset at browse
        # start. Node count of the last COMPLETE load per URI feeds the ETA.
        self._cancel_browse = False

        # Monitoring mode: 'subscription' (default) or 'polling'
        self.monitor_mode = "subscription"
        self.polling_interval_ms = 500
        self.polling_nodes = {}        # node_id -> Node
        self.polling_task = None       # asyncio.Task
        self.nodeid_to_path = {}       # str(NodeId) -> path for subscription updates
        self._poll_log_last_summary = 0.0
        self._connection_fail_count = 0
        self._last_health_check = 0.0

        self.rebrowse_requested.connect(self._handle_rebrowse)
        self.revalidate_requested.connect(self._handle_revalidate)
        self.load_children_requested.connect(self._handle_load_children)
        self.frontier_children_requested.connect(self._handle_load_frontier_children)


    def set_uri(self, uri):
        self.uri = uri

    def set_monitor_mode(self, mode):
        """Set monitoring mode: 'subscription' or 'polling'."""
        if mode not in ("subscription", "polling"):
            return
        self.monitor_mode = mode
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self._update_polling_task(),
                self.loop,
            )

    def set_polling_interval(self, interval_ms):
        """Set polling interval in milliseconds (minimum 50 ms)."""
        try:
            interval = int(interval_ms)
        except Exception:
            interval = self.polling_interval_ms
        else:
            if interval < 50:
                interval = 50
        self.polling_interval_ms = interval
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self._update_polling_task(),
                self.loop,
            )

    def set_credentials(self, username: Optional[str], password: Optional[str]):
        self.username = username
        self.password = password

    async def _update_polling_task(self):
        """Start or stop the polling loop depending on current state."""
        # Reset summary timer whenever we change polling task state
        self._poll_log_last_summary = 0.0

        if self.monitor_mode == "polling" and self.polling_nodes:
            if not self.polling_task:
                self.polling_task = asyncio.create_task(self._polling_loop())
        else:
            if self.polling_task:
                self.polling_task.cancel()
                try:
                    await self.polling_task
                except Exception:
                    pass
                self.polling_task = None

    async def _polling_loop(self):
        """Background loop that periodically reads values of polling nodes."""
        self.log_message.emit(
            "INFO",
            f"Starting polling with interval {self.polling_interval_ms} ms",
        )
        try:
            while self.running and self.monitor_mode == "polling":
                if not self.client:
                    break

                nodes_snapshot = list(self.polling_nodes.items())
                for node_id, node in nodes_snapshot:
                    try:
                        value = await node.read_value()
                        # Reuse subscription_update signal so the UI stays unchanged
                        self.subscription_update.emit(node_id, value, None)
                    except Exception as e:
                        self.log_message.emit(
                            "ERROR",
                            f"Polling read failed for {node_id}: {str(e)}",
                        )

                # Periodic summary log (at most every 10 seconds)
                now = time.time()
                if now - self._poll_log_last_summary >= 10.0:
                    self._poll_log_last_summary = now
                    self.log_message.emit(
                        "DEBUG",
                        f"Polling {len(self.polling_nodes)} nodes "
                        f"at {self.polling_interval_ms} ms",
                    )

                await asyncio.sleep(self.polling_interval_ms / 1000.0)
        except asyncio.CancelledError:
            # Normal during shutdown or mode switch
            pass
        finally:
            self.log_message.emit("INFO", "Polling loop stopped")
            self.polling_task = None

    def _get_node_for_monitoring(self, node_id):
        """Resolve different node id string formats into a Node object.

        If node_id is a path (like /Objects/system/Status), look up in cache first.
        """
        if not self.client:
            return None
        try:
            # First check if this is a path in our cache
            if node_id in self.nodes_cache and 'node_object' in self.nodes_cache[node_id]:
                return self.nodes_cache[node_id]['node_object']

            if isinstance(node_id, ua.NodeId):
                return self.client.get_node(node_id)

            if isinstance(node_id, str) and node_id.startswith("ns="):
                return self.client.get_node(node_id)

            node_id_str = str(node_id)

            if "NodeId(" in node_id_str:
                import re

                identifier_match = re.search(r"Identifier='([^']*)'", node_id_str)
                namespace_match = re.search(r"NamespaceIndex=(\d+)", node_id_str)
                nodetype_match = re.search(
                    r"NodeIdType=<NodeIdType\.(\w+):",
                    node_id_str,
                )

                if identifier_match and namespace_match:
                    identifier = identifier_match.group(1)
                    namespace = int(namespace_match.group(1))

                    if nodetype_match:
                        node_type = nodetype_match.group(1)
                        if node_type == "String":
                            nodeid = ua.NodeId(identifier, namespace)
                        elif node_type == "Numeric":
                            nodeid = ua.NodeId(int(identifier), namespace)
                        else:
                            nodeid = ua.NodeId(identifier, namespace)
                    else:
                        nodeid = ua.NodeId(identifier, namespace)

                    return self.client.get_node(nodeid)

            # Fallback to using raw node_id
            return self.client.get_node(node_id)
        except Exception as e:
            self.log_message.emit(
                "ERROR",
                f"Failed to parse node ID {node_id}: {str(e)}",
            )
            return None

    async def _add_polling_node(self, node_id):
        """Add a node to the polling list and start polling if needed.

        We log only once when the first node is added to keep the log clean.
        """
        if node_id in self.polling_nodes:
            return

        node = self._get_node_for_monitoring(node_id)
        if not node:
            return

        self.polling_nodes[node_id] = node
        # Store reverse mapping: NodeId string -> path for subscription updates
        self.nodeid_to_path[str(node.nodeid)] = node_id

        # Log only when the first node is added
        if len(self.polling_nodes) == 1:
            self.log_message.emit(
                "INFO",
                f"Polling mode enabled; first node added: {node_id}",
            )

        await self._update_polling_task()

    async def _check_connection_health(self):
        """Periodically validate connection by reading the standard ServerStatus node.

        Wraps the read in ``asyncio.wait_for(..., 2.0)`` so that a dead TCP
        socket is detected in a bounded time. Without the timeout, asyncua's
        read can block until the OS-level socket timeout fires (often 30s+),
        which makes auto-reconnect feel sluggish and breaks fast tests.
        """
        if not self.client:
            return
        try:
            server_status = self.client.get_node(ua.ObjectIds.Server_ServerStatus)
            await asyncio.wait_for(server_status.read_value(), timeout=2.0)
            self._connection_fail_count = 0
        except Exception as e:
            self._connection_fail_count += 1
            if self._connection_fail_count >= 3:
                # Mark as lost so UI auto-reconnect kicks in via timer
                self.log_message.emit("ERROR", f"Connection lost: {str(e)}")
                self.error.emit("Connection lost")
                self.running = False


    def run(self):
        self.running = True
        self.loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self.loop)

        try:
            self.loop.run_until_complete(self.async_run())
        except Exception as e:
            self.error.emit(str(e))
        finally:
            self.loop.close()

    async def async_run(self):
        try:
            await self.connect_to_server()
            self._last_health_check = self.loop.time() if self.loop else time.time()
            while self.running:
                now = self.loop.time() if self.loop else time.time()
                if now - self._last_health_check >= 1.0:
                    await self._check_connection_health()
                    self._last_health_check = now
                await asyncio.sleep(0.1)
        except Exception as e:
            self.error.emit(str(e))
        finally:
            await self.disconnect_from_server()

    async def connect_to_server(self):
        try:
            self.log_message.emit("INFO", f"Connecting to server: {self.uri}")
            self.log_message.emit("DEBUG", f"Creating OPC UA client for URI: {self.uri}")
            self.client = Client(self.uri)

            # Log security settings
            self.log_message.emit(
                "DEBUG",
                f"Client security policy: {self.client.security_policy}"
            )

            if self.username:
                try:
                    self.log_message.emit(
                        "DEBUG", f"Setting credentials for user: {self.username}"
                    )
                    self.client.set_user(self.username)
                    if self.password is not None:
                        self.client.set_password(self.password)
                except Exception as cred_err:
                    self.log_message.emit(
                        "WARNING", f"Failed to set credentials: {cred_err}"
                    )

            self.log_message.emit("DEBUG", "Initiating connection...")
            try:
                await self.client.connect()
            except Exception as conn_err:
                import traceback
                tb_str = traceback.format_exc()
                self.log_message.emit(
                    "ERROR",
                    f"Connection failed: {type(conn_err).__name__}: {conn_err}"
                )
                self.log_message.emit(
                    "DEBUG", f"Connection traceback:\n{tb_str}"
                )
                raise

            self._namespace_load_start = time.time()
            self.log_message.emit("DEBUG", "Connection established, reading namespace array...")

            # Capture the server's namespace array. The mapping ns_index -> URI
            # is used by the GUI's Scope filter to identify which namespaces
            # the user wants to see (URIs are stable across reconnects;
            # ns indices may shift between servers).
            try:
                self.namespace_array = await self.client.get_namespace_array()
                self.log_message.emit(
                    "INFO",
                    f"Namespace array: {self.namespace_array}",
                )
            except Exception as exc:
                self.log_message.emit("DEBUG", f"Failed to read namespace array: {exc}")
                self.namespace_array = []
            self._connection_fail_count = 0
            self._last_health_check = self.loop.time() if self.loop else time.time()
            self.log_message.emit("INFO", f"Connected to server: {self.uri}")
            self.connected.emit(True)
            # Skip the initial live browse when the GUI has a disk cache to
            # paint from — it will paint the cache and request a background
            # full revalidation (which comes back through the normal browse
            # path). Otherwise browse the namespace now.
            if not getattr(self, "_skip_initial_browse", False):
                await self.load_root_nodes()
        except Exception as e:
            self.log_message.emit(
                "ERROR", f"Failed to connect to server: {type(e).__name__}: {str(e)}"
            )
            self.error.emit(str(e))
            self.running = False

    async def disconnect_from_server(self):
        try:
            self.log_message.emit("INFO", "Disconnecting from server...")
            if self.subscription:
                await self.subscription.delete()
                self.subscription = None
            self.subscribed_nodes.clear()
            self.nodeid_to_path.clear()

            # Stop polling loop and clear polling nodes
            if getattr(self, "polling_task", None):
                self.polling_task.cancel()
                try:
                    await self.polling_task
                except Exception:
                    pass
                self.polling_task = None
            if getattr(self, "polling_nodes", None) is not None:
                self.polling_nodes.clear()

            if self.client:
                await self.client.disconnect()
                self.client = None
            self.connected.emit(False)
            self.log_message.emit("INFO", "Disconnected from server")
        except Exception as e:
            self.log_message.emit("ERROR", f"Error during disconnect: {str(e)}")


    async def load_root_nodes(self, force_full: bool = False):
        """Connect-time namespace load with Lazy Node Loading.

        Browses breadth-first and stops once the cache reaches
        `lazy_loading_limit` REAL nodes — so the user gets a genuinely populated
        tree (up to the limit), not a thin skeleton. Then:
          * browse completed before the cap  -> COMPLETE (whole namespace fits).
          * cap hit, nodes left on the frontier -> PARTIAL: those nodes load on
            expand, and a full load happens only when the user asks (Search).
        A limit of 0/None disables lazy mode (always full).

        force_full: ignore the lazy limit AND the active View scope — a full,
        unscoped browse. Used by the background namespace-cache revalidation so
        the persisted cache is always the complete namespace."""
        limit = 0 if force_full else (self.lazy_loading_limit or 0)
        self.log_message.emit(
            "DEBUG",
            f"Lazy Node Loading: connect-time limit = {limit} "
            f"(0 = disabled / always full)")

        if limit <= 0:
            # Lazy disabled -> full browse. But the browse can still stop early
            # if the user CANCELS it (sets _browse_truncated via the frontier),
            # so report PARTIAL in that case rather than a false COMPLETE.
            await self.load_full_namespace_fast(force_full=force_full)
            count = len(self.nodes_cache)
            if self._browse_truncated:
                self.namespace_load_mode.emit("partial", count)
            else:
                self.namespace_load_mode.emit("complete", count)
            return

        # Browse up to `limit` nodes. load_full_namespace_fast stops and records
        # the unbrowsed frontier once the cache reaches node_limit.
        await self.load_full_namespace_fast(node_limit=limit, force_full=force_full)
        count = len(self.nodes_cache)
        self._namespace_estimate = count

        if not self._browse_truncated:
            # Whole namespace fit under the limit -> fully loaded.
            self.namespace_load_mode.emit("complete", count)
        else:
            # Hit the limit with more to browse -> partial. The estimate we
            # report is a lower bound (>= count); the GUI treats partial as
            # "unknown, at least this many".
            self.log_message.emit(
                "INFO",
                f"Reached lazy loading limit ({limit} nodes): "
                f"Lazy Node Loading active (partial). {count} nodes loaded; "
                f"deeper nodes load on demand.")
            self.namespace_load_mode.emit("partial", count)

    async def _resume_full_browse(self):
        """Continue browsing from the load-edge frontier to complete the cache.
        Extends (does not clear) the cache, so nothing is re-browsed. Used by
        load_full_namespace() when the user explicitly asks for a full load."""
        frontier = getattr(self, "_browse_frontier", []) or []
        if not frontier:
            return
        # Re-run the browse with no depth cap, seeded from the frontier, keeping
        # the shallow cache. load_full_namespace_fast starts from the root, so
        # instead we walk the frontier directly here.
        await self.load_full_namespace_fast(
            max_depth=None, clear_cache=False, initial_frontier=frontier)

    def _is_browse_excluded(self, path: str) -> bool:
        """True if `path` should be PRUNED from the browse (View-aware browse,
        P3f) — not cached, not descended into. Combines two safe strategies:

        1. Prefix-include scoping (P3f.2): if the View's includes are all
           prefix-style, browse ONLY nodes at/under an include prefix or on the
           ancestor path that reaches one; prune everything else.
        2. Exclude pruning (P3f.1): with NO includes, prune excluded subtrees.

        When includes exist but are NOT all prefix-style, we cannot safely
        prune (a match could be anywhere / an excluded node could be under an
        include) -> no pruning, full browse + render filter (include wins)."""
        # force_full (background cache revalidation) ignores View scope entirely.
        if getattr(self, "_browse_force_full", False):
            return False
        # 1) Prefix-include scoping wins when available.
        if self.browse_include_prunable and self.browse_include_prefixes:
            return not _browse_keep_for_prefixes(path, self.browse_include_prefixes)
        # 2) Exclude pruning only when there are no includes at all.
        if self.browse_include_patterns:
            return False
        patterns = self.browse_exclude_patterns
        if not patterns:
            return False
        return any(_glob_match(path, p) for p in patterns)

    async def load_full_namespace(self):
        """Public: force a full namespace load (resume from the shallow
        frontier if lazy, else a fresh full browse). Emits 'complete' when the
        whole namespace loaded, or 'partial' if the user cancelled part-way.
        Used by the GUI when the user asks for a proper Search / full load."""
        if getattr(self, "_browse_truncated", False) and self._browse_frontier:
            await self._resume_full_browse()
        else:
            await self.load_full_namespace_fast()
        count = len(self.nodes_cache)
        if self._browse_truncated:
            self.namespace_load_mode.emit("partial", count)
        else:
            self.namespace_load_mode.emit("complete", count)

    async def load_full_namespace_fast(self, max_depth=None, clear_cache=True,
                                       initial_frontier=None, node_limit=None,
                                       force_full=False):
        """Browse and cache the OPC UA namespace using parallel requests.

        Uses asyncio.gather with get_children_descriptions() to browse multiple
        nodes in parallel.

        max_depth: if set, stop after this many levels below the root and record
            the unbrowsed frontier on self._browse_frontier. None = no depth cap.
        node_limit: if set, stop once the cache reaches this many nodes and record
            the unbrowsed frontier on self._browse_frontier. Used by Lazy Node
            Loading on connect so up to `node_limit` REAL nodes are loaded, then
            the rest loads on demand. None = no count cap. The cap is checked
            between BFS levels, so the final count may modestly exceed the limit
            (the last level completes atomically).
        clear_cache: clear the cache first. A deeper continuation browse (e.g.
            "load full namespace" after a capped one) passes False to extend
            rather than restart.
        initial_frontier: start the BFS from these (node, parent_path) pairs
            instead of from the root. Used to RESUME a full browse from where a
            capped one stopped (no re-browsing of the loaded region). Implies
            clear_cache=False.
        """
        try:
            if not self.client:
                return

            self.log_message.emit("INFO", "Loading namespace (parallel browsing)...")
            # force_full: this browse ignores the active View scope (used by the
            # background namespace-cache revalidation). Read in _is_browse_excluded.
            self._browse_force_full = bool(force_full)
            if clear_cache:
                self.nodes_cache.clear()
            # Frontier = (node, parent_path) pairs left unbrowsed when a shallow
            # browse stops at max_depth; used to resume a full load later.
            self._browse_frontier = []
            self._browse_truncated = False
            self._cancel_browse = False  # fresh browse -> clear any stale cancel

            start_time = time.time()
            # Reset the namespace-load start timestamp so the status-bar
            # "Loaded X nodes in Ys" message reflects THIS browse cycle.
            # Without this, Rebrowse would report elapsed time since the
            # original connect (set in connect_to_server() / line ~1986).
            self._namespace_load_start = start_time

            # Concurrency control - balance between speed and not overwhelming server
            MAX_CONCURRENT = 150
            semaphore = asyncio.Semaphore(MAX_CONCURRENT)

            # Progress tracking
            nodes_processed = [0]
            last_progress_time = [start_time]
            last_emit_count = [0]

            async def browse_node_with_semaphore(node: Node, parent_path: str):
                """Browse a single node with concurrency limiting."""
                async with semaphore:
                    return await browse_node(node, parent_path)

            async def browse_node(node: Node, parent_path: str):
                """Browse a node using get_children_descriptions() for efficiency.

                Returns list of (child_node, child_path, child_stub) tuples.

                Uses PATH as the cache key (like UaShell) so the same node appearing
                under multiple parents is preserved in the tree.

                Filtering decisions are NOT made here — the worker browses the
                full namespace and the GUI applies the active Scope at render
                time. Switching scopes is then instant (no re-browse).
                """
                results = []
                try:
                    # get_children_descriptions() returns ReferenceDescription objects
                    # which contain DisplayName, NodeClass, NodeId, BrowseName in ONE request
                    refs = await node.get_children_descriptions()

                    for ref in refs:
                        try:
                            child_node_id = str(ref.NodeId)
                            ns_idx = getattr(ref.NodeId, "NamespaceIndex", 0)

                            # Extract display name
                            if ref.DisplayName and ref.DisplayName.Text:
                                display_name = ref.DisplayName.Text
                            elif ref.BrowseName and ref.BrowseName.Name:
                                display_name = ref.BrowseName.Name
                            else:
                                display_name = child_node_id

                            browse_name = ref.BrowseName.Name if ref.BrowseName else display_name
                            node_class = ref.NodeClass

                            # Build child path (using browse_name).
                            # Disambiguate by node class so a Variable and a
                            # Method with the same browse name (e.g. "State")
                            # don't collide in the cache.
                            path_name = browse_name
                            if node_class == ua.NodeClass.Method:
                                path_name = f"{browse_name}()"

                            if parent_path:
                                child_path = f"{parent_path}/{path_name}"
                            else:
                                child_path = f"/{path_name}"

                            child_node = self.client.get_node(ref.NodeId)

                            # ns_index is stored on the stub so the GUI can
                            # look up the corresponding namespace URI when
                            # applying the Scope's namespace filter.
                            stub = {
                                "node_id": child_node_id,
                                "nodeid_obj": ref.NodeId,
                                "node_object": child_node,
                                "browse_name": browse_name,
                                "display_name": display_name,
                                "node_class": node_class,
                                "ns_index": ns_idx,
                                "path": child_path,
                                "parent_id": parent_path if parent_path else None,
                                "children_ids": [],
                                "children_loaded": True,
                                "has_children": False,
                            }

                            results.append((child_node, child_path, stub))
                        except Exception:
                            continue

                except Exception as exc:
                    self.log_message.emit("DEBUG", f"Browse failed for node: {exc}")
                return results

            # Nodes deferred to the frontier because the node_limit was reached
            # mid-level (a single BFS level can be very wide). Collected here so
            # the caller can mark them lazy after the level finishes.
            deferred_frontier = []

            async def process_level(nodes_to_process: list) -> list:
                """Browse a level of nodes in parallel; return next-level work.

                The worker browses every reachable node — no per-node admin
                filtering. The active Scope is applied at render time.

                Honors `node_limit` MID-level: a single level can be huge (a wide
                folder), so once the cache reaches the limit we stop caching new
                nodes, divert the already-browsed remainder to deferred_frontier
                (they become lazy), and return no further work. This keeps the cap
                tight regardless of how wide any one level is.
                """
                if not nodes_to_process:
                    return []

                next_level = []
                capped = False

                # Browse the level in CHUNKS rather than one giant gather. A
                # single level can be 100k+ parents wide; gathering all at once
                # is an unbreakable block (cancel/limit can't bite until it
                # finishes — a 121k-wide level took ~7s, so Cancel felt ignored).
                # Chunking keeps concurrency (each chunk's browses run in
                # parallel via the semaphore) but gives cancel/limit a check
                # point every chunk. The chunk size is well above the semaphore
                # width so throughput is unaffected.
                for chunk_start in range(0, len(nodes_to_process), BROWSE_LEVEL_CHUNK):
                    if self._cancel_browse or capped:
                        # Divert the not-yet-browsed remainder to the frontier.
                        deferred_frontier.extend(nodes_to_process[chunk_start:])
                        capped = True
                        break
                    chunk = nodes_to_process[chunk_start:chunk_start + BROWSE_LEVEL_CHUNK]
                    browse_tasks = [
                        browse_node_with_semaphore(node, parent_path)
                        for node, parent_path in chunk
                    ]
                    chunk_results = await asyncio.gather(
                        *browse_tasks, return_exceptions=True)

                    for (node, parent_path), result in zip(chunk, chunk_results):
                        if isinstance(result, Exception):
                            continue

                        child_paths = []
                        for child_node, child_path, stub in result:
                            # View-aware browse (P3f): skip a subtree the active
                            # View EXCLUDES — don't cache, don't descend. Safe:
                            # an excluded node can't contain visible children.
                            if self._is_browse_excluded(child_path):
                                continue
                            # Once the cap is reached (node limit) OR the user
                            # cancelled, cache nothing more; divert expandable
                            # nodes to the frontier so they load on demand.
                            if not capped and self._cancel_browse:
                                capped = True
                                self.log_message.emit(
                                    "INFO",
                                    f"Browse cancelled at {len(self.nodes_cache)} "
                                    f"nodes; diverting rest to lazy frontier")
                            if (node_limit is not None and not capped
                                    and len(self.nodes_cache) >= node_limit):
                                capped = True
                                self.log_message.emit(
                                    "INFO",
                                    f"Node limit reached: {len(self.nodes_cache)} "
                                    f">= {node_limit}; diverting rest to frontier")
                            if capped:
                                if stub["node_class"] in (
                                        ua.NodeClass.Object, ua.NodeClass.Variable):
                                    deferred_frontier.append((child_node, child_path))
                                continue

                            child_paths.append(child_path)
                            # Cache every node — render filter decides what shows.
                            self.nodes_cache[child_path] = stub
                            nodes_processed[0] += 1

                            # Recurse into Objects (folders) AND Variables:
                            # structured Variables (PLC DataBlocks like
                            # CCS_CMD_DB/DATA) carry child nodes that would
                            # otherwise be invisible. A childless Variable adds
                            # nothing to the next level. Methods stay leaves.
                            if stub["node_class"] in (
                                    ua.NodeClass.Object, ua.NodeClass.Variable):
                                next_level.append((child_node, child_path))

                        # Hook child IDs back onto the parent stub.
                        if parent_path in self.nodes_cache and child_paths:
                            self.nodes_cache[parent_path]["children_ids"] = child_paths
                            self.nodes_cache[parent_path]["has_children"] = True

                    # Emit progress per chunk (not just per level) so the ETA /
                    # rate in the status bar updates smoothly even when a level
                    # is very wide (few levels, but many chunks).
                    now = time.time()
                    if now - last_progress_time[0] > 0.3:
                        self.loading_progress.emit(
                            nodes_processed[0], nodes_processed[0] + len(next_level))
                        last_progress_time[0] = now
                        if nodes_processed[0] - last_emit_count[0] >= 500:
                            self.namespace_chunk_loaded.emit(dict(self.nodes_cache))
                            last_emit_count[0] = nodes_processed[0]

                # If we capped mid-level, stop the BFS: carry both this level's
                # unbrowsed remainder and any pending next_level to the frontier.
                if capped:
                    deferred_frontier.extend(next_level)
                    return []

                # Progress
                now = time.time()
                if now - last_progress_time[0] > 0.5:
                    elapsed = now - start_time
                    rate = nodes_processed[0] / elapsed if elapsed > 0 else 0
                    self.log_message.emit("INFO", f"Browsing... {nodes_processed[0]} nodes ({rate:.0f}/sec)")
                    self.loading_progress.emit(nodes_processed[0], nodes_processed[0] + len(next_level))
                    last_progress_time[0] = now

                    if nodes_processed[0] - last_emit_count[0] >= 500:
                        self.namespace_chunk_loaded.emit(dict(self.nodes_cache))
                        last_emit_count[0] = nodes_processed[0]

                return next_level

            # Resume mode: start from a supplied frontier, skip root handling.
            if initial_frontier is not None:
                current_level = initial_frontier
                level = 0
                truncated = False
                while current_level:
                    if self._cancel_browse:
                        self._browse_frontier = current_level
                        self._browse_truncated = True
                        truncated = True
                        break
                    if max_depth is not None and level >= max_depth:
                        self._browse_frontier = current_level
                        self._browse_truncated = True
                        truncated = True
                        break
                    level += 1
                    current_level = await process_level(current_level)
                    # process_level caps mid-level on node_limit (unused in the
                    # full-load resume path, but kept consistent).
                    if deferred_frontier:
                        self._browse_frontier = deferred_frontier
                        self._browse_truncated = True
                        truncated = True
                        break
                # Only clear the frontier when the resume ran to completion; a
                # depth-capped resume leaves a fresh frontier for the next pass.
                if not truncated:
                    self._browse_truncated = False
                    self._browse_frontier = []
                elapsed = time.time() - start_time
                self.loading_progress.emit(nodes_processed[0], nodes_processed[0])
                self.nodes_loaded.emit(dict(self.nodes_cache))
                self.log_message.emit(
                    "INFO",
                    f"Namespace load resumed: {len(self.nodes_cache)} nodes "
                    f"cached ({elapsed:.1f}s for this segment)")
                return

            # ----- Root-level handling -----
            #
            # OPC UA's standard layout puts user data under `/Objects`.
            # Historically we "promoted" Objects' children to root level
            # (so `/PLC1` instead of `/Objects/PLC1`) for nicer browsing.
            # The Scope feature can fully express either layout, but to
            # keep paths short and existing scope patterns intuitive
            # (e.g. `/PLC1/*`), we preserve the promotion. Server, Types,
            # Views, AlarmsConditions etc. stay at root with their own
            # paths and the Scope can hide them.
            root = self.client.get_root_node()
            try:
                root_refs = await root.get_children_descriptions()
            except Exception as exc:
                self.log_message.emit("ERROR", f"Failed to browse root: {exc}")
                self.error.emit(str(exc))
                return

            initial_nodes = []
            for ref in root_refs:
                try:
                    child_node_id = str(ref.NodeId)
                    ns_idx = getattr(ref.NodeId, "NamespaceIndex", 0)

                    if ref.DisplayName and ref.DisplayName.Text:
                        display_name = ref.DisplayName.Text
                    elif ref.BrowseName and ref.BrowseName.Name:
                        display_name = ref.BrowseName.Name
                    else:
                        display_name = child_node_id

                    browse_name = ref.BrowseName.Name if ref.BrowseName else display_name
                    node_class = ref.NodeClass
                    child_node = self.client.get_node(ref.NodeId)
                    child_path = f"/{browse_name}"
                    is_objects_folder = browse_name == "Objects"

                    # View-aware browse (P3f): drop an excluded root node (e.g.
                    # /Server, /Types) entirely — no cache, no descend. The
                    # /Objects folder is a structural container (never excluded);
                    # its promoted children are exclude-checked in process_level.
                    if not is_objects_folder and self._is_browse_excluded(child_path):
                        continue

                    # The Objects folder itself is hidden from the cache;
                    # its children become root nodes (path `/<child>`).
                    if not is_objects_folder:
                        stub = {
                            "node_id": child_node_id,
                            "nodeid_obj": ref.NodeId,
                            "node_object": child_node,
                            "browse_name": browse_name,
                            "display_name": display_name,
                            "node_class": node_class,
                            "ns_index": ns_idx,
                            "path": child_path,
                            "parent_id": None,
                            "children_ids": [],
                            "children_loaded": True,
                            "has_children": False,
                        }
                        self.nodes_cache[child_path] = stub
                        nodes_processed[0] += 1

                    if node_class == ua.NodeClass.Object and is_objects_folder:
                        # Children of /Objects become root-level nodes.
                        initial_nodes.append((child_node, ""))
                    elif node_class in (ua.NodeClass.Object, ua.NodeClass.Variable):
                        # Objects and structured Variables both get browsed.
                        initial_nodes.append((child_node, child_path))

                except Exception:
                    continue

            # Process levels breadth-first with parallel browsing. If max_depth
            # is set, stop once we've browsed that many levels and keep the
            # unbrowsed nodes as the frontier (for a later full load).
            def _stop_at_frontier(frontier_nodes, reason: str):
                """Record `frontier_nodes` as the lazy frontier and mark the tree
                so those subtrees can be loaded on demand.

                A frontier node already in the cache (browsed, but we stopped
                before descending) is marked expandable directly. A DEFERRED node
                (browsed mid-level but never cached once the cap hit) has no stub
                — so instead we mark its PARENT `children_loaded=False`, so
                expanding the parent re-browses and picks up the deferred
                children. We can't know if a node truly has children without
                browsing, so assume yes — a childless one expands to nothing.
                """
                self._browse_frontier = frontier_nodes
                self._browse_truncated = True
                for _node, fpath in frontier_nodes:
                    stub = self.nodes_cache.get(fpath)
                    if stub is not None:
                        stub["has_children"] = True
                        stub["children_loaded"] = False
                    else:
                        # Deferred (uncached): mark the parent as reloadable.
                        parent_path = fpath.rsplit("/", 1)[0] or None
                        parent = self.nodes_cache.get(parent_path)
                        if parent is not None:
                            parent["has_children"] = True
                            parent["children_loaded"] = False
                self.log_message.emit(
                    "DEBUG",
                    f"Browse stopped ({reason}); "
                    f"{len(frontier_nodes)} nodes on the frontier")

            current_level = initial_nodes
            level = 0
            while current_level:
                if self._cancel_browse:
                    # User cancelled: stop, keep what's browsed as the frontier
                    # so the tree is PARTIAL and the rest stays load-on-demand.
                    _stop_at_frontier(current_level, "cancelled by user")
                    break
                if max_depth is not None and level >= max_depth:
                    _stop_at_frontier(current_level, f"depth {level}")
                    break
                level += 1
                self.log_message.emit("DEBUG", f"Processing level {level}: {len(current_level)} nodes to browse")
                current_level = await process_level(current_level)
                # process_level caps MID-level on node_limit and returns [] with
                # the remainder in deferred_frontier. If that happened, stop.
                if deferred_frontier:
                    _stop_at_frontier(
                        deferred_frontier,
                        f"node limit {node_limit}, {len(self.nodes_cache)} cached")
                    break

            # Final progress and emit
            elapsed = time.time() - start_time
            rate = nodes_processed[0] / elapsed if elapsed > 0 else 0
            self.loading_progress.emit(nodes_processed[0], nodes_processed[0])
            self.nodes_loaded.emit(dict(self.nodes_cache))
            self.log_message.emit(
                "INFO", 
                f"Namespace loaded: {nodes_processed[0]} nodes in {elapsed:.1f}s ({rate:.0f} nodes/sec)"
            )

        except Exception as e:
            self.log_message.emit("ERROR", f"Failed to load namespace: {str(e)}")
            self.error.emit(str(e))

    async def get_node_data(self, node):
        try:
            node_id = str(node.nodeid)
            browse_name = await node.read_browse_name()
            display_name = await node.read_display_name()
            node_class = await node.read_node_class()

            node_data = {
                'node_id': node_id,
                'nodeid_obj': node.nodeid,
                'node_object': node,
                'browse_name': browse_name.Name,
                'display_name': display_name.Text,
                'node_class': node_class,
                'children': {},
                'children_loaded': False,
                'has_children': node_class == ua.NodeClass.Method
            }

            # Common attributes
            try:
                description = await node.read_description()
                node_data['description'] = getattr(description, "Text", None) or str(description)
            except Exception:
                pass

            try:
                type_def = await node.read_type_definition()
                node_data['type_definition'] = str(type_def)
            except Exception:
                pass

            try:
                event_notifier = await node.read_attribute(ua.AttributeIds.EventNotifier)
                node_data['event_notifier'] = getattr(event_notifier, "Value", event_notifier)
            except Exception:
                pass

            if node_class == ua.NodeClass.Variable:
                try:
                    data_value = await node.read_data_value()
                    variant = getattr(data_value, "Value", None)
                    value = getattr(variant, "Value", variant)
                    data_type = await node.read_data_type()

                    # Read both AccessLevel and UserAccessLevel.
                    # read_attribute() returns a DataValue whose .Value is
                    # a Variant whose .Value is the raw int. Both fields
                    # use capital "Value" (NOT lowercase ".value", which
                    # is the cause of the previous regression where these
                    # came out as Variant objects and later int() failed,
                    # so Properties showed "N/A" and Writable disabled).
                    access_dv = await node.read_attribute(ua.AttributeIds.AccessLevel)
                    user_dv = await node.read_attribute(ua.AttributeIds.UserAccessLevel)
                    access_level_value = self._unwrap_attribute_value(access_dv)
                    user_access_level_value = self._unwrap_attribute_value(user_dv)

                    node_data['value'] = str(value)
                    node_data['data_type'] = str(data_type)
                    node_data['last_read'] = datetime.now()
                    node_data['access_level'] = access_level_value
                    node_data['user_access_level'] = user_access_level_value
                    node_data['raw_value'] = value
                    node_data['source_timestamp'] = getattr(data_value, "SourceTimestamp", None)
                    node_data['server_timestamp'] = getattr(data_value, "ServerTimestamp", None)

                    # Additional variable attributes
                    try:
                        value_rank_attr = await node.read_attribute(ua.AttributeIds.ValueRank)
                        node_data['value_rank'] = getattr(value_rank_attr, "Value", value_rank_attr)
                    except Exception:
                        pass
                    try:
                        array_dims_attr = await node.read_attribute(ua.AttributeIds.ArrayDimensions)
                        node_data['array_dimensions'] = getattr(array_dims_attr, "Value", array_dims_attr)
                    except Exception:
                        pass
                    try:
                        historizing_attr = await node.read_attribute(ua.AttributeIds.Historizing)
                        node_data['historizing'] = getattr(historizing_attr, "Value", historizing_attr)
                    except Exception:
                        pass
                    try:
                        sampling_attr = await node.read_attribute(ua.AttributeIds.MinimumSamplingInterval)
                        node_data['minimum_sampling_interval'] = getattr(sampling_attr, "Value", sampling_attr)
                    except Exception:
                        pass

                    # Optional engineering units and range
                    try:
                        children_props = await node.get_children()
                        for child in children_props:
                            try:
                                child_bn = await child.read_browse_name()
                                if child_bn.Name == "EngineeringUnits":
                                    eu_val = await child.read_value()
                                    node_data['engineering_units'] = str(eu_val)
                                elif child_bn.Name == "EURange":
                                    eu_range_val = await child.read_value()
                                    node_data['eu_range'] = str(eu_range_val)
                            except Exception:
                                continue
                    except Exception:
                        pass
                except Exception as e:
                    self.log_message.emit("DEBUG", f"Could not read variable attributes for {node_id}: {str(e)}")
                    pass

            return node_data
        except Exception as e:
            self.log_message.emit("DEBUG", f"Error getting node data for {node}: {str(e)}")
            return None

    async def load_node_children(self, node_id):
        try:
            if not self.client:
                return

            self.log_message.emit("DEBUG", f"Loading children for node: {node_id}")

            if node_id in self.nodes_cache and 'node_object' in self.nodes_cache[node_id]:
                node = self.nodes_cache[node_id]['node_object']
            else:
                node = self.client.get_node(node_id)

            parent_node_class = None
            if node_id in self.nodes_cache:
                parent_node_class = self.nodes_cache[node_id].get("node_class")

            # Special handling for Method nodes: expose Input/Output arguments as synthetic children
            if parent_node_class == ua.NodeClass.Method:
                children_dict = {}
                input_args = await self._read_method_arguments_list(node, "InputArguments")
                output_args = await self._read_method_arguments_list(node, "OutputArguments")

                def add_args_child(label, args_list):
                    child_id = f"{node_id}::{label}"
                    description_lines = self._format_method_args(args_list)
                    arg_children = {}
                    for idx, arg in enumerate(args_list):
                        name, type_label, is_array = self._format_arg_descriptor(arg, idx)
                        display = f"{name}: {type_label}"
                        if is_array:
                            display += " [array]"
                        arg_id = f"{child_id}::{name}"
                        arg_children[arg_id] = {
                            "node_id": arg_id,
                            "nodeid_obj": None,
                            "node_object": None,
                            "browse_name": name,
                            "display_name": display,
                            "node_class": ua.NodeClass.Variable,
                            "children": {},
                            "children_loaded": True,
                            "has_children": False,
                            "description": display,
                            "data_type": type_label,
                        }

                    children_dict[child_id] = {
                        "node_id": child_id,
                        "nodeid_obj": None,
                        "node_object": None,
                        "browse_name": label,
                        "display_name": label,
                        "node_class": ua.NodeClass.Variable,
                        "children": arg_children,
                        "children_loaded": True,
                        "has_children": bool(arg_children),
                        "description": "\n".join(description_lines),
                        "value": "\n".join(description_lines),
                    }

                add_args_child("InputArguments", input_args)
                add_args_child("OutputArguments", output_args)

                self.nodes_cache[node_id]['children'] = children_dict
                self.nodes_cache[node_id]['children_loaded'] = True
                self.nodes_cache.update(children_dict)
                self.node_children_loaded.emit(node_id, children_dict)
                self.log_message.emit("DEBUG", f"Loaded method arguments for node {node_id}")
                return

            children = await node.get_children()
            children_dict = {}

            for child in children:
                try:
                    child_data = await self.get_node_data(child)
                    if child_data:
                        # Skip InputArguments/OutputArguments under Method nodes
                        if (
                            parent_node_class == ua.NodeClass.Method
                            and child_data.get("browse_name")
                            in ("InputArguments", "OutputArguments")
                        ):
                            continue

                        children_dict[child_data['node_id']] = child_data
                        if child_data['node_class'] == ua.NodeClass.Method:
                            child_data['has_children'] = True
                        else:
                            try:
                                grandchildren = await child.get_children()
                                child_data['has_children'] = len(grandchildren) > 0
                            except:
                                child_data['has_children'] = False
                except Exception as e:
                    self.log_message.emit("DEBUG", f"Could not load child: {str(e)}")

            if node_id in self.nodes_cache:
                self.nodes_cache[node_id]['children'] = children_dict
                self.nodes_cache[node_id]['children_loaded'] = True

            self.nodes_cache.update(children_dict)
            self.node_children_loaded.emit(node_id, children_dict)
            self.log_message.emit("DEBUG", f"Loaded {len(children_dict)} children for node {node_id}")
        except Exception as e:
            self.log_message.emit("ERROR", f"Failed to load children for node {node_id}: {str(e)}")

    async def load_frontier_children(self, parent_path):
        """Lazy-load one frontier node's children (Lazy Node Loading, partial).

        Browses the node at `parent_path` with the SAME path-keyed scheme as the
        fast connect browse, so the result splices cleanly into the path-keyed
        tree. Each new child is itself marked as a potential frontier
        (has_children=True, children_loaded=False) when it can carry children —
        i.e. an Object or a Variable (structured Variables like PLC DataBlocks) —
        so the tree stays lazily expandable further down. Emits
        frontier_children_loaded(parent_path, children_dict) with PATH-keyed
        children.
        """
        try:
            if not self.client:
                return
            stub = self.nodes_cache.get(parent_path)
            if stub is None:
                self.log_message.emit(
                    "DEBUG", f"Frontier expand: {parent_path} not in cache")
                self.frontier_children_loaded.emit(parent_path, {})
                return

            # Already loaded (a previous expand): just re-emit what we have.
            if stub.get("children_loaded") and stub.get("children_ids"):
                existing = {
                    cid: self.nodes_cache[cid]
                    for cid in stub["children_ids"] if cid in self.nodes_cache
                }
                self.frontier_children_loaded.emit(parent_path, existing)
                return

            node = stub.get("node_object") or self.client.get_node(stub["node_id"])
            refs = await node.get_children_descriptions()

            children_dict = {}
            for ref in refs:
                try:
                    child_node_id = str(ref.NodeId)
                    ns_idx = getattr(ref.NodeId, "NamespaceIndex", 0)
                    if ref.DisplayName and ref.DisplayName.Text:
                        display_name = ref.DisplayName.Text
                    elif ref.BrowseName and ref.BrowseName.Name:
                        display_name = ref.BrowseName.Name
                    else:
                        display_name = child_node_id
                    browse_name = ref.BrowseName.Name if ref.BrowseName else display_name
                    node_class = ref.NodeClass

                    path_name = browse_name
                    if node_class == ua.NodeClass.Method:
                        path_name = f"{browse_name}()"
                    child_path = f"{parent_path}/{path_name}"

                    # View-aware browse (P3f): skip excluded children on lazy
                    # expand too, so on-demand loading matches the connect browse.
                    if self._is_browse_excluded(child_path):
                        continue

                    child_node = self.client.get_node(ref.NodeId)

                    # Objects and structured Variables may carry children -> keep
                    # them lazily expandable. Methods/plain leaves do not.
                    may_have_children = node_class in (
                        ua.NodeClass.Object, ua.NodeClass.Variable)

                    child_stub = {
                        "node_id": child_node_id,
                        "nodeid_obj": ref.NodeId,
                        "node_object": child_node,
                        "browse_name": browse_name,
                        "display_name": display_name,
                        "node_class": node_class,
                        "ns_index": ns_idx,
                        "path": child_path,
                        "parent_id": parent_path,
                        "children_ids": [],
                        # Not yet browsed; assume expandable if it may have
                        # children so the arrow shows and expanding loads it.
                        "children_loaded": not may_have_children,
                        "has_children": may_have_children,
                    }
                    children_dict[child_path] = child_stub
                except Exception:
                    continue

            # Update the cache and hook children onto the parent stub.
            self.nodes_cache.update(children_dict)
            stub["children_ids"] = list(children_dict.keys())
            stub["children_loaded"] = True
            stub["has_children"] = bool(children_dict)

            self.frontier_children_loaded.emit(parent_path, children_dict)
            self.log_message.emit(
                "DEBUG",
                f"Frontier expand: {len(children_dict)} children under {parent_path}")
        except Exception as e:
            self.log_message.emit(
                "ERROR", f"Failed to load frontier children for {parent_path}: {e}")
            self.frontier_children_loaded.emit(parent_path, {})

    @staticmethod
    def _unwrap_attribute_value(data_value):
        """Pull the raw Python value out of a DataValue returned by
        ``node.read_attribute(...)``.

        DataValue → .Value → Variant → .Value → raw value (e.g. int).
        Both layers use capital ``Value`` in asyncua. An earlier version
        of this method looked for lowercase ``.value`` and so silently
        returned the Variant object — which then failed ``int(...)``
        downstream, making access-level bits look unreadable. Done here
        as a small helper so the two callers stay consistent."""
        if data_value is None:
            return None
        variant = getattr(data_value, "Value", data_value)
        # Variant exposes the raw payload as .Value too.
        return getattr(variant, "Value", variant)

    async def read_current_value(self, node_id):
        try:
            if not self.client:
                return None

            if node_id in self.nodes_cache and 'node_object' in self.nodes_cache[node_id]:
                node = self.nodes_cache[node_id]['node_object']
            else:
                node = self.client.get_node(node_id)

            node_class = self.nodes_cache.get(node_id, {}).get('node_class')
            if node_class == ua.NodeClass.Variable:
                try:
                    data_value = await node.read_data_value()
                    variant = getattr(data_value, "Value", None)
                    value = getattr(variant, "Value", variant)
                    data_type = await node.read_data_type()

                    # Read both AccessLevel and UserAccessLevel.
                    # read_attribute() returns a DataValue whose .Value is
                    # a Variant whose .Value is the raw int. Both fields
                    # use capital "Value" (NOT lowercase ".value", which
                    # is the cause of the previous regression where these
                    # came out as Variant objects and later int() failed,
                    # so Properties showed "N/A" and Writable disabled).
                    access_dv = await node.read_attribute(ua.AttributeIds.AccessLevel)
                    user_dv = await node.read_attribute(ua.AttributeIds.UserAccessLevel)
                    access_level_value = self._unwrap_attribute_value(access_dv)
                    user_access_level_value = self._unwrap_attribute_value(user_dv)

                    if node_id in self.nodes_cache:
                        self.nodes_cache[node_id]['value'] = str(value)
                        self.nodes_cache[node_id]['data_type'] = str(data_type)
                        self.nodes_cache[node_id]['last_read'] = datetime.now()
                        self.nodes_cache[node_id]['access_level'] = access_level_value
                        self.nodes_cache[node_id]['user_access_level'] = user_access_level_value
                        self.nodes_cache[node_id]['raw_value'] = value
                        self.nodes_cache[node_id]['source_timestamp'] = getattr(data_value, "SourceTimestamp", None)
                        self.nodes_cache[node_id]['server_timestamp'] = getattr(data_value, "ServerTimestamp", None)

                    value_data = {
                        'value': value,
                        'data_type': str(data_type),
                        'timestamp': datetime.now(),
                        'access_level': access_level_value,
                        'user_access_level': user_access_level_value,
                        'raw_value': value,
                        'source_timestamp': getattr(data_value, "SourceTimestamp", None),
                        'server_timestamp': getattr(data_value, "ServerTimestamp", None)
                    }

                    self.current_value_updated.emit(node_id, value_data)
                    return value_data
                except Exception as e:
                    self.log_message.emit("DEBUG", f"Could not read value for {node_id}: {str(e)}")
            return None
        except Exception as e:
            self.log_message.emit("ERROR", f"Failed to read current value for {node_id}: {str(e)}")
            return None

    def read_current_value_async(self, node_id):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(self.read_current_value(node_id), self.loop)

    def read_all_values_async(self, node_ids):
        """Read values for all given node IDs in parallel (for full dump)."""
        if self.loop and self.running:
            self.log_message.emit("INFO", f"read_all_values_async: scheduling read of {len(node_ids)} nodes")
            asyncio.run_coroutine_threadsafe(self._read_all_values(node_ids), self.loop)
        else:
            self.log_message.emit("ERROR", "read_all_values_async: worker loop not running")
            self.full_dump_complete.emit({})

    async def _read_all_values(self, node_ids):
        """Read values, types, and access for all given nodes using batch requests."""
        if not self.client or not node_ids:
            self.log_message.emit("WARNING", "_read_all_values: no client or no node_ids")
            self.full_dump_complete.emit({})
            return

        self.log_message.emit("INFO", f"_read_all_values: starting batch read of {len(node_ids)} nodes")

        # Standard OPC UA data type NodeId to name mapping (namespace 0)
        DATA_TYPE_NAMES = {
            1: "Boolean", 2: "SByte", 3: "Byte", 4: "Int16", 5: "UInt16",
            6: "Int32", 7: "UInt32", 8: "Int64", 9: "UInt64", 10: "Float",
            11: "Double", 12: "String", 13: "DateTime", 14: "Guid", 15: "ByteString",
            16: "XmlElement", 17: "NodeId", 18: "ExpandedNodeId", 19: "StatusCode",
            20: "QualifiedName", 21: "LocalizedText", 22: "ExtensionObject",
            23: "DataValue", 24: "Variant", 25: "DiagnosticInfo", 26: "Number",
            27: "Integer", 28: "UInteger", 29: "Enumeration",
        }

        async def resolve_data_type_name(data_type_nodeid):
            """Resolve a data type NodeId to its name."""
            if data_type_nodeid is None:
                return "?"
            try:
                # Check if it's a standard type (namespace 0)
                if hasattr(data_type_nodeid, 'NamespaceIndex') and data_type_nodeid.NamespaceIndex == 0:
                    identifier = data_type_nodeid.Identifier
                    if identifier in DATA_TYPE_NAMES:
                        return DATA_TYPE_NAMES[identifier]
                # For non-standard types, read the browse name
                type_node = self.client.get_node(data_type_nodeid)
                browse_name = await type_node.read_browse_name()
                return browse_name.Name
            except Exception:
                return "?"

        results = {}
        total = len(node_ids)
        BATCH_SIZE = 200  # Smaller batches for stability

        for batch_start in range(0, total, BATCH_SIZE):
            batch_end = min(batch_start + BATCH_SIZE, total)
            batch_ids = node_ids[batch_start:batch_end]

            try:
                # Build nodes for batch read - use cached node objects if available
                nodes = []
                for nid in batch_ids:
                    cached = self.nodes_cache.get(nid)
                    if cached and 'node_object' in cached:
                        nodes.append(cached['node_object'])
                    elif cached and 'nodeid_obj' in cached:
                        nodes.append(self.client.get_node(cached['nodeid_obj']))
                    else:
                        # Fallback: try to parse as proper OPC UA node ID format
                        nodes.append(self.client.get_node(nid))

                # Read all values in one batch
                try:
                    values = await self.client.read_values(nodes)
                except Exception as e:
                    self.log_message.emit("DEBUG", f"Batch read_values failed: {e}")
                    values = [None] * len(nodes)

                # Read data types and access levels individually but concurrently
                async def read_node_attrs(node, node_id):
                    data_type_name = "?"
                    access = 0
                    try:
                        data_type_nodeid = await node.read_data_type()
                        data_type_name = await resolve_data_type_name(data_type_nodeid)
                    except Exception:
                        pass
                    try:
                        access_result = await node.read_attribute(ua.AttributeIds.AccessLevel)
                        access_val = access_result.Value if hasattr(access_result, 'Value') else access_result
                        if hasattr(access_val, 'Value'):
                            access_val = access_val.Value
                        access = int(access_val) if access_val is not None else 0
                    except Exception:
                        pass
                    return node_id, data_type_name, access

                # Gather data types and access levels concurrently
                attr_results = await asyncio.gather(
                    *[read_node_attrs(nodes[i], batch_ids[i]) for i in range(len(nodes))],
                    return_exceptions=True
                )

                # Build results dict
                for i, node_id in enumerate(batch_ids):
                    value = values[i] if i < len(values) else None

                    # Get data_type_name and access from gathered results
                    data_type_name = "?"
                    access = 0
                    if i < len(attr_results) and not isinstance(attr_results[i], Exception):
                        _, data_type_name, access = attr_results[i]

                    results[node_id] = {
                        'value': str(value) if value is not None else None,
                        'data_type': data_type_name,
                        'access_level': access,
                    }

            except Exception as e:
                self.log_message.emit("ERROR", f"Batch read error: {e}")
                # On batch error, mark all nodes in batch as error
                for node_id in batch_ids:
                    results[node_id] = {
                        'value': None,
                        'data_type': "?",
                        'access_level': 0,
                    }

            self.full_dump_progress.emit(batch_end, total)

        self.log_message.emit("INFO", f"_read_all_values: completed, read {len(results)} values")
        self.full_dump_complete.emit(results)

    async def write_node_value(self, node_id, value):
        try:
            if not self.client:
                self.value_written.emit(node_id, False, "Not connected to server")
                return False

            self.log_message.emit("INFO", f"Writing value: {value} (type: {type(value).__name__})")

            if node_id in self.nodes_cache and 'node_object' in self.nodes_cache[node_id]:
                node = self.nodes_cache[node_id]['node_object']
            else:
                node = self.client.get_node(node_id)

            # Write the value directly - if it's a ua.Variant, it should work properly
            await node.write_value(value)

            self.log_message.emit("INFO", f"Successfully wrote value to node {node_id}")

            # Read back the value to confirm
            await self.read_current_value(node_id)

            self.value_written.emit(node_id, True, f"Successfully wrote value: {value}")
            return True
        except Exception as e:
            error_msg = f"Failed to write value to node {node_id}: {str(e)}"
            self.log_message.emit("ERROR", error_msg)
            self.value_written.emit(node_id, False, error_msg)
            return False
    def write_node_value_async(self, node_id, value):
        if self.loop and self.running:
            future = asyncio.run_coroutine_threadsafe(self.write_node_value(node_id, value), self.loop)
            return future
        return None

    def _handle_rebrowse(self):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(self.load_root_nodes(), self.loop)

    def _handle_revalidate(self):
        """Background full, unscoped rebrowse (namespace-cache revalidation)."""
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self.load_root_nodes(force_full=True), self.loop)

    def request_revalidate(self):
        self.revalidate_requested.emit()

    def _handle_load_children(self, node_id):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(self.load_node_children(node_id), self.loop)

    def _handle_load_frontier_children(self, parent_path):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self.load_frontier_children(parent_path), self.loop)

    def request_rebrowse(self):
        self.rebrowse_requested.emit()

    def request_cancel_browse(self):
        """Ask the running browse to stop cooperatively (checked between/within
        BFS levels). Safe to set from the GUI thread — it's a plain bool the
        browse coroutine polls; whatever is cached so far is kept (PARTIAL)."""
        self._cancel_browse = True

    def request_load_children(self, node_id):
        self.load_children_requested.emit(node_id)

    def request_frontier_children(self, parent_path):
        self.frontier_children_requested.emit(parent_path)

    def subscribe_to_node_async(self, node_id):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self._subscribe_to_node(node_id),
                self.loop,
            )

    async def _subscribe_to_node(self, node_id):
        try:
            if not self.client:
                return

            # In polling mode we don't create OPC UA subscriptions at all
            if self.monitor_mode == "polling":
                await self._add_polling_node(node_id)
                return

            self.log_message.emit("INFO", f"Subscribing to node: {node_id}")

            if not self.subscription:
                self.subscription_handler = SubscriptionHandler(self)
                self.subscription = await self.client.create_subscription(
                    500,
                    self.subscription_handler,
                )

            node = self._get_node_for_monitoring(node_id)
            if not node:
                return

            handle = await self.subscription.subscribe_data_change(node)
            self.subscribed_nodes[node_id] = handle
            # Store reverse mapping: NodeId string -> path for subscription updates
            self.nodeid_to_path[str(node.nodeid)] = node_id
            self.log_message.emit(
                "INFO",
                f"Successfully subscribed to node: {node_id}",
            )
        except Exception as e:
            self.log_message.emit(
                "ERROR",
                f"Failed to subscribe to node {node_id}: {str(e)}",
            )

    def unsubscribe_from_node_async(self, node_id):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self._unsubscribe_from_node(node_id),
                self.loop,
            )

    async def _unsubscribe_from_node(self, node_id):
        try:
            # Remove from polling list if present
            if node_id in self.polling_nodes:
                del self.polling_nodes[node_id]
                self.log_message.emit(
                    "INFO",
                    f"Removed node from polling list: {node_id}",
                )
                # If no more nodes are polled, stop the polling task
                await self._update_polling_task()

            # Remove OPC UA subscription if present
            if node_id in self.subscribed_nodes and self.subscription:
                handle = self.subscribed_nodes[node_id]
                await self.subscription.unsubscribe(handle)
                del self.subscribed_nodes[node_id]
                self.log_message.emit(
                    "INFO",
                    f"Unsubscribed from node: {node_id}",
                )

            # Clean up the reverse mapping (find and remove by value)
            keys_to_remove = [k for k, v in self.nodeid_to_path.items() if v == node_id]
            for k in keys_to_remove:
                del self.nodeid_to_path[k]
        except Exception as e:
            self.log_message.emit(
                "ERROR",
                f"Failed to unsubscribe from node {node_id}: {str(e)}",
            )


    def invoke_method_async(self, parent_node_id, method_node_id, parameters):
        if self.loop and self.running:
            asyncio.run_coroutine_threadsafe(
                self._invoke_method(parent_node_id, method_node_id, parameters), 
                self.loop
            )

    async def _invoke_method(self, parent_node_id, method_node_id, parameters):
        try:
            if not self.client:
                return

            # Log method invocation with parameters
            if parameters:
                param_str = ", ".join([str(p) for p in parameters])
                self.log_message.emit("INFO", f"Invoking method {method_node_id} with parameters: [{param_str}]")
            else:
                self.log_message.emit("INFO", f"Invoking method {method_node_id} with no parameters")

            # Get method node from cache (method_node_id is a path)
            if method_node_id in self.nodes_cache and 'node_object' in self.nodes_cache[method_node_id]:
                method_node = self.nodes_cache[method_node_id]['node_object']
            else:
                self.log_message.emit("ERROR", f"Method node not found in cache: {method_node_id}")
                self.method_result.emit(method_node_id, f"Error: Method node not found in cache")
                return

            # Get parent node from cache (parent_node_id is also a path)
            if parent_node_id in self.nodes_cache and 'node_object' in self.nodes_cache[parent_node_id]:
                parent_node = self.nodes_cache[parent_node_id]['node_object']
            else:
                # Fallback: use the parent_id from the method's cache entry
                method_parent_path = self.nodes_cache[method_node_id].get('parent_id')
                if method_parent_path and method_parent_path in self.nodes_cache:
                    parent_node = self.nodes_cache[method_parent_path]['node_object']
                else:
                    # Last resort: Objects folder
                    parent_node = self.client.get_node("i=85")

            result = await parent_node.call_method(method_node, *parameters)
            self.method_result.emit(method_node_id, result)
            self.log_message.emit("INFO", f"Method {method_node_id} executed successfully. Result: {result}")
        except Exception as e:
            self.log_message.emit("ERROR", f"Failed to invoke method {method_node_id}: {str(e)}")
            self.method_result.emit(method_node_id, f"Error: {str(e)}")

    async def _read_method_arguments_list(self, method_node, browse_name: str):
        """Helper to read InputArguments/OutputArguments lists for a method node."""
        try:
            children = await method_node.get_children()
            for child in children:
                try:
                    bn = await child.read_browse_name()
                    if bn.Name == browse_name:
                        args_value = await child.read_value()
                        if args_value and hasattr(args_value, "__iter__"):
                            return list(args_value)
                        return []
                except Exception:
                    continue
        except Exception as e:
            self.log_message.emit("DEBUG", f"Could not read {browse_name} for method {method_node}: {str(e)}")
        return []

    def _format_arg_descriptor(self, arg, idx):
        """Return (name, type_label, is_array) for a method argument."""
        name = getattr(arg, "Name", getattr(arg, "name", f"Param_{idx}"))
        data_type = getattr(arg, "DataType", getattr(arg, "data_type", "Variant"))
        type_label = str(data_type)
        is_array = False
        try:
            value_rank = getattr(arg, "ValueRank", getattr(arg, "value_rank", None))
            if value_rank is not None and int(value_rank) >= 0:
                is_array = True
        except Exception:
            pass
        return name, type_label, is_array

    def _format_method_args(self, args_list):
        """Format method arguments into human-readable lines."""
        if not args_list:
            return ["None"]

        formatted = []
        for idx, arg in enumerate(args_list):
            name = getattr(arg, "Name", getattr(arg, "name", f"Param_{idx}"))
            data_type = getattr(arg, "DataType", getattr(arg, "data_type", "Variant"))
            value_rank = getattr(arg, "ValueRank", getattr(arg, "value_rank", None))
            array_suffix = ""
            try:
                if value_rank is not None and int(value_rank) >= 0:
                    array_suffix = " [array]"
            except Exception:
                pass
            formatted.append(f"{name}: {data_type}{array_suffix}")
        return formatted

    async def get_method_arguments(self, method_node_id):
        try:
            if not self.client:
                return []

            # Get method node from cache (method_node_id is a path)
            if method_node_id in self.nodes_cache and 'node_object' in self.nodes_cache[method_node_id]:
                method_node = self.nodes_cache[method_node_id]['node_object']
            else:
                self.log_message.emit("DEBUG", f"Method node not found in cache: {method_node_id}")
                return []

            children = await method_node.get_children()
            input_args = []

            for child in children:
                browse_name = await child.read_browse_name()
                if browse_name.Name == "InputArguments":
                    try:
                        args_value = await child.read_value()
                        if args_value and hasattr(args_value, '__iter__'):
                            input_args = list(args_value)
                        break
                    except Exception as e:
                        self.log_message.emit("DEBUG", f"Could not read InputArguments: {str(e)}")

            return input_args
        except Exception as e:
            self.log_message.emit("DEBUG", f"Could not get method arguments for {method_node_id}: {str(e)}")
            return []

    def get_method_arguments_async(self, method_node_id):
        if self.loop and self.running:
            future = asyncio.run_coroutine_threadsafe(self.get_method_arguments(method_node_id), self.loop)
            try:
                return future.result(timeout=2.0)
            except:
                return []
        return []

    def stop(self):
        self.running = False


class NodePropertiesWidget(QWidget):
    def __init__(self, worker):
        super().__init__()
        self.worker = worker
        self.current_node_data = None
        self.current_value_data = None
        self.setup_ui()

    def setup_ui(self):
        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(6)

        header_layout = QHBoxLayout()
        header_layout.setContentsMargins(0, 0, 0, 0)
        header_layout.setSpacing(8)
        self.title_label = QLabel("Node Properties")
        apply_section_title_style(self.title_label)
        header_layout.addWidget(self.title_label)
        header_layout.addStretch()

        layout.addLayout(header_layout)

        self.properties_text = QTextEdit()
        self.properties_text.setReadOnly(True)
        # Compact 8 pt — properties content can be quite long (data type
        # repr, timestamps, access flags, ...) so a small font lets users
        # see the whole record without scrolling.
        prop_font = QFont(QApplication.font())
        prop_font.setPointSize(8)
        self.properties_text.setFont(prop_font)
        # Wrap at the widget edge AND allow breaking inside long unbreakable
        # tokens (e.g. a structured-variable Node ID like
        # ns=3;s="CCS_CMD_DB"."DATA"."...")  — otherwise the QTextEdit's width
        # hint grows to fit the longest line, dragging the whole splitter panel
        # wider than the window and making it impossible to shrink back.
        self.properties_text.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
        self.properties_text.setWordWrapMode(
            QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
        # Let the panel shrink freely: don't let this widget impose a wide
        # minimum from its content. A small minimum + horizontal scrollbar
        # fallback keeps the panel user-resizable.
        self.properties_text.setSizePolicy(
            QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
        self.properties_text.setMinimumWidth(0)
        layout.addWidget(self.properties_text)

        # Write Value Widget — compact one-row layout:
        #   [Write]  [value input field]
        # No section title (the [Write] button is the label). The status
        # line below the row is a transient QLabel that is hidden until a
        # write happens; successful writes auto-clear after a few seconds,
        # errors persist until the user selects another node or writes again.
        # The internal write_input_layout still hosts the dynamically-built
        # input widget (per data type) — boolean nodes render their own
        # radio buttons in there instead of the QLineEdit.
        self.write_frame = QFrame()
        self.write_frame.setFrameStyle(QFrame.Shape.NoFrame)
        write_layout = QVBoxLayout()
        write_layout.setContentsMargins(0, 4, 0, 0)
        write_layout.setSpacing(2)

        # Node path of the current selection — shown above the Write row so
        # the user can confirm which node a write targets. Updated in
        # _update_input_widget() each time a new node is selected.
        self.write_node_label = QLabel("")
        self.write_node_label.setWordWrap(True)
        self.write_node_label.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
        )
        self.write_node_label.setStyleSheet(
            f"color: {THEME.active.text_muted}; font-style: italic;"
        )
        write_layout.addWidget(self.write_node_label)
        THEME.theme_changed.connect(
            lambda _t: self.write_node_label.setStyleSheet(
                f"color: {THEME.active.text_muted}; font-style: italic;"
            )
        )

        # Row: [Write button] [dynamic input container]
        write_row = QHBoxLayout()
        write_row.setContentsMargins(0, 0, 0, 0)
        write_row.setSpacing(4)

        self.write_button = QPushButton("Write")
        self.write_button.clicked.connect(self.write_value)
        self.write_button.setEnabled(False)
        write_row.addWidget(self.write_button)

        self.write_input_container = QWidget()
        self.write_input_layout = QVBoxLayout(self.write_input_container)
        self.write_input_layout.setContentsMargins(0, 0, 0, 0)
        self.write_input_layout.setSpacing(2)
        write_row.addWidget(self.write_input_container, 1)

        write_layout.addLayout(write_row)

        # Inline transient status line — hidden until a write happens.
        self.write_status = QLabel("")
        self.write_status.setWordWrap(True)
        self.write_status.setVisible(False)
        write_layout.addWidget(self.write_status)

        # Auto-clear timer for successful writes (errors stay visible).
        self._write_status_timer = QTimer(self)
        self._write_status_timer.setSingleShot(True)
        self._write_status_timer.timeout.connect(self._clear_write_status)

        self.write_frame.setLayout(write_layout)
        self.write_frame.setVisible(False)
        layout.addWidget(self.write_frame)

        # Method Execution Widget
        self.method_frame = QFrame()
        self.method_frame.setFrameStyle(QFrame.Shape.StyledPanel)
        method_layout = QVBoxLayout()

        # The method's path + signature already appears as the "Script:"
        # snippet at the bottom of Node Properties above, so we don't
        # duplicate it inline here. Just the section title.
        self.method_label = QLabel("Method Execution")
        apply_section_title_style(self.method_label)
        method_layout.addWidget(self.method_label)

        self.invoke_button = QPushButton("Invoke Method")
        self.invoke_button.clicked.connect(self.invoke_method)
        self.invoke_button.setEnabled(False)
        method_layout.addWidget(self.invoke_button)

        self.method_result = QTextEdit()
        self.method_result.setReadOnly(True)
        self.method_result.setMaximumHeight(100)
        method_layout.addWidget(self.method_result)

        self.method_frame.setLayout(method_layout)
        self.method_frame.setVisible(False)
        layout.addWidget(self.method_frame)

        self.setLayout(layout)

        # Input widgets for different value types
        self.value_input = None
        self.bool_radio_group = None
        self.bool_true_radio = None
        self.bool_false_radio = None

    def update_properties(self, node_data):
        self.current_node_data = node_data
        self.current_value_data = None

        # Clear method result when selecting a new method
        if node_data.get('node_class') == ua.NodeClass.Method:
            self.method_result.clear()

        # Clear write status when selecting a new node
        self._clear_write_status()

        # For variables, read current value
        if (node_data.get('node_class') == ua.NodeClass.Variable and 
            self.worker and self.worker.isRunning()):
            try:
                # Use path as cache key (path is the cache key, not node_id)
                cache_key = node_data.get('path') or node_data.get('node_id')
                self.worker.read_current_value_async(cache_key)
            except Exception as e:
                pass

        self.display_properties()

    def display_properties(self):
        if not self.current_node_data:
            return

        node_data = self.current_node_data

        # Format data type for display in properties (with Real for Float)
        raw_data_type = node_data.get('data_type', 'N/A')
        if raw_data_type != 'N/A':
            friendly_data_type = self.format_data_type_for_properties(str(raw_data_type))
        else:
            friendly_data_type = 'N/A'

        # Compact canonical id (ns=2;s=...) instead of the noisy
        # NodeId(Identifier='...', NamespaceIndex=2, NodeIdType=...) repr.
        node_id_str = self._clean_node_id(node_data)

        # Ready-to-paste script snippet at the TOP — this is usually the
        # single most useful piece of info about the selected node, so we
        # put it where it doesn't get lost below dozens of attribute lines.
        # No "Script:" label — the snippet is self-evident from its
        # Read(...)/Write(...)/Execute(...) form, and dropping the label
        # makes each line a clean triple-click + Ctrl+C target.
        node_path_for_snippet = node_data.get("path") or node_id_str
        node_class = node_data.get("node_class")
        snippet = self._build_script_snippet(node_class, node_path_for_snippet, node_data)

        properties_text = ""
        if snippet:
            properties_text += f"{snippet}\n\n"
        properties_text += f"Node: {node_id_str} - Data Type: {friendly_data_type}\n"
        properties_text += f"Node ID: {node_id_str}\n"
        properties_text += self._format_nodeid_details(node_data)
        if 'data_type' in node_data:
            if friendly_data_type != str(raw_data_type):
                properties_text += f"Data Type: {friendly_data_type} ({raw_data_type})\n"
            else:
                properties_text += f"Data Type: {friendly_data_type}\n"
        properties_text += f"Browse Name: {node_data.get('browse_name', 'N/A')}\n"
        properties_text += f"Display Name: {node_data.get('display_name', 'N/A')}\n"
        properties_text += f"Node Class: {self._node_class_name(node_data.get('node_class'))}\n"
        if node_data.get('description'):
            properties_text += f"Description: {node_data.get('description')}\n"
        if node_data.get('type_definition'):
            properties_text += f"Type Definition: {node_data.get('type_definition')}\n"
        if node_data.get('event_notifier') is not None and node_data.get('node_class') != ua.NodeClass.Variable:
            properties_text += f"Event Notifier: {node_data.get('event_notifier')}\n"

        if 'value' in node_data:
            last_read = node_data.get('last_read', 'Unknown')
            if isinstance(last_read, datetime):
                last_read = last_read.strftime("%H:%M:%S")
            properties_text += f"Value: {node_data['value']} (read at {last_read})\n"
            source_ts = node_data.get('source_timestamp')
            server_ts = node_data.get('server_timestamp')
            if source_ts:
                properties_text += f"Source Timestamp: {self._format_timestamp(source_ts)}\n"
            if server_ts:
                properties_text += f"Server Timestamp: {self._format_timestamp(server_ts)}\n"

        if node_data.get('node_class') == ua.NodeClass.Variable:
            if node_data.get('value_rank') is not None:
                properties_text += f"Value Rank: {node_data.get('value_rank')}\n"
            if node_data.get('array_dimensions') not in (None, [], ()):
                properties_text += f"Array Dimensions: {node_data.get('array_dimensions')}\n"
            if node_data.get('historizing') is not None:
                properties_text += f"Historizing: {node_data.get('historizing')}\n"
            if node_data.get('minimum_sampling_interval') is not None:
                properties_text += f"Min Sampling Interval: {node_data.get('minimum_sampling_interval')} ms\n"
            if node_data.get('engineering_units'):
                properties_text += f"Engineering Units: {node_data.get('engineering_units')}\n"
            if node_data.get('eu_range'):
                properties_text += f"EU Range: {node_data.get('eu_range')}\n"

        if 'access_level' in node_data or 'user_access_level' in node_data:
            access_level_raw = node_data.get('access_level', None)
            user_access_level_raw = node_data.get('user_access_level', None)

            def _normalize_level(val):
                if hasattr(val, "value"):
                    val = val.value
                if val is None:
                    return None
                try:
                    return int(val)
                except Exception:
                    return None

            access_level = _normalize_level(access_level_raw)
            user_access_level = _normalize_level(user_access_level_raw)

            effective_level = user_access_level if user_access_level is not None else access_level
            if effective_level is None and node_data.get('node_class') == ua.NodeClass.Variable:
                # Fallback: we could read a value, so consider it readable
                readable = 'value' in node_data or self.current_value_data is not None
                writable = "Not Available"
            else:
                readable = bool(effective_level & 1) if effective_level is not None else False
                writable = bool(effective_level & 2) if effective_level is not None else "Not Available"

            properties_text += f"Access Level: {access_level if access_level is not None else 'N/A'} ({self.format_access_level(access_level) if access_level is not None else 'Unknown'})\n"
            properties_text += f"User Access Level: {user_access_level if user_access_level is not None else 'N/A'} ({self.format_access_level(user_access_level) if user_access_level is not None else 'Unknown'})\n"
            properties_text += f"Readable: {readable}\n"
            properties_text += f"Writable: {writable}\n"

        properties_text += f"Has Children: {node_data.get('has_children', False)}\n"
        properties_text += f"Children Loaded: {node_data.get('children_loaded', False)}\n"

        self.properties_text.setText(properties_text)

        # Show/hide write interface. STRICT mode: the Write button is
        # enabled only if the server explicitly reports the variable as
        # writable via the AccessLevel/UserAccessLevel bit 1 (CurrentWrite).
        # If the server doesn't report access bits, we DON'T speculate —
        # the field is shown for inspection but writes are disabled. This
        # surfaces missing/incomplete server-side node definitions instead
        # of silently allowing writes that may or may not work.
        is_variable = node_data.get('node_class') == ua.NodeClass.Variable

        if is_variable:
            self.setup_write_interface()
            self.write_frame.setVisible(True)
            try:
                ua_lvl = node_data.get("user_access_level")
                a_lvl = node_data.get("access_level")
                # Use whichever is reported; user-level overrides if set.
                effective = ua_lvl if ua_lvl is not None else a_lvl
                writable = bool(int(effective) & 2) if effective is not None else False
            except Exception:
                writable = False
            self.write_button.setEnabled(writable)
            # Hint for users who wonder why the button is greyed out.
            if not writable:
                self.write_button.setToolTip(
                    "Server did not report this node as writable "
                    "(AccessLevel/UserAccessLevel bit CurrentWrite not set)."
                )
            else:
                self.write_button.setToolTip("")
        else:
            self.write_frame.setVisible(False)
            self.write_node_label.clear()
            self.write_node_label.setToolTip("")

        # Show/hide method interface. The method's path + signature is
        # already shown as the Script snippet at the bottom of the
        # Node Properties text above, so no separate inline display
        # is needed here.
        is_method = node_data.get('node_class') == ua.NodeClass.Method
        self.invoke_button.setEnabled(is_method and self.worker and self.worker.isRunning())
        self.method_frame.setVisible(is_method)

    def format_data_type_for_input(self, data_type):
        """Format data type string for input widget display (Int8, UInt8, Float, Double, etc.)"""
        # Convert OPC UA data types to readable format
        type_mapping = {
            'i=1': 'Boolean',
            'i=2': 'Int8', 
            'i=3': 'UInt8',
            'i=4': 'Int16',
            'i=5': 'UInt16', 
            'i=6': 'Int32',
            'i=7': 'UInt32',
            'i=8': 'Int64',
            'i=9': 'UInt64',
            'i=10': 'Float',
            'i=11': 'Double',
            'i=12': 'String',
            'i=13': 'DateTime',
            'i=14': 'Guid',
            'i=15': 'ByteString',
            'i=16': 'XmlElement',
            'i=17': 'NodeId',
            'i=18': 'ExpandedNodeId',
            'i=19': 'StatusCode',
            'i=20': 'QualifiedName',
            'i=21': 'LocalizedText',
            'i=22': 'ExtensionObject',
            'i=23': 'DataValue',
            'i=24': 'Variant',
            'i=25': 'DiagnosticInfo'
        }

        # Check if it's a standard type by exact match first
        for node_id, readable_type in type_mapping.items():
            if node_id in data_type:
                return readable_type

        # Check if data_type contains namespace and identifier pattern like "ns=0;i=6"
        if 'ns=' in data_type and ';i=' in data_type:
            try:
                # Extract the identifier part
                identifier_part = data_type.split(';i=')[1].split(';')[0]  # Get just the number
                lookup_key = f'i={identifier_part}'
                if lookup_key in type_mapping:
                    return type_mapping[lookup_key]
            except:
                pass

        # Check for direct i= format
        if data_type.startswith('i='):
            if data_type in type_mapping:
                return type_mapping[data_type]

        # Return original if no mapping found
        return data_type

    def format_data_type_for_properties(self, data_type):
        """Format data type string for properties display (Int8, UInt8, Real, Double, etc.)"""
        data_type = str(data_type)
        # Convert OPC UA data types to readable format for properties
        type_mapping = {
            'i=1': 'Boolean',
            'i=2': 'Int8', 
            'i=3': 'UInt8',
            'i=4': 'Int16',
            'i=5': 'UInt16', 
            'i=6': 'Int32',
            'i=7': 'UInt32',
            'i=8': 'Int64',
            'i=9': 'UInt64',
            'i=10': 'Real',      # Float displayed as Real in properties
            'i=11': 'Double',
            'i=12': 'String',
            'i=13': 'DateTime',
            'i=14': 'Guid',
            'i=15': 'ByteString',
            'i=16': 'XmlElement',
            'i=17': 'NodeId',
            'i=18': 'ExpandedNodeId',
            'i=19': 'StatusCode',
            'i=20': 'QualifiedName',
            'i=21': 'LocalizedText',
            'i=22': 'ExtensionObject',
            'i=23': 'DataValue',
            'i=24': 'Variant',
            'i=25': 'DiagnosticInfo'
        }

        # Try to normalize to a NodeId id (e.g., 11 -> Double)
        type_id_num = self.get_opc_ua_data_type_id(data_type)
        if type_id_num is not None:
            lookup_key = f"i={type_id_num}"
            if lookup_key in type_mapping:
                return type_mapping[lookup_key]

        # Check if it's a standard type by exact match first
        for node_id, readable_type in type_mapping.items():
            if node_id in data_type:
                return readable_type

        # Check if data_type contains namespace and identifier pattern like "ns=0;i=6"
        if 'ns=' in data_type and ';i=' in data_type:
            try:
                # Extract the identifier part
                identifier_part = data_type.split(';i=')[1].split(';')[0]  # Get just the number
                lookup_key = f'i={identifier_part}'
                if lookup_key in type_mapping:
                    return type_mapping[lookup_key]
            except:
                pass

        # Check for direct i= format
        if data_type.startswith('i='):
            if data_type in type_mapping:
                return type_mapping[data_type]

        # Return original if no mapping found
        return data_type

    def get_opc_ua_data_type_id(self, data_type_str):
        """Extract OPC UA data type identifier from data type string"""
        # Handle NodeId object format: NodeId(Identifier=6, NamespaceIndex=0, NodeIdType=<NodeIdType.TwoByte: 0>)
        if "NodeId(" in data_type_str and "Identifier=" in data_type_str:
            try:
                import re
                # Extract the Identifier value from NodeId format
                match = re.search(r"Identifier=(\d+)", data_type_str)
                if match:
                    return int(match.group(1))
            except:
                pass

        # Check if data_type contains namespace and identifier pattern like "ns=0;i=6"
        if 'ns=' in data_type_str and ';i=' in data_type_str:
            try:
                # Extract the identifier part
                identifier_part = data_type_str.split(';i=')[1].split(';')[0]
                return int(identifier_part)
            except:
                pass

        # Check for direct i= format like "i=6"
        if data_type_str.startswith('i='):
            try:
                # Extract number after i=
                return int(data_type_str[2:])
            except:
                pass

        # Check for any i= pattern in the string
        if 'i=' in data_type_str:
            try:
                # Extract number after i=
                import re
                match = re.search(r'i=(\d+)', data_type_str)
                if match:
                    return int(match.group(1))
            except:
                pass
        return None

    def _extract_identifier(self, node_id_str: str) -> str:
        """Return the Identifier component from a NodeId string if present."""
        if not node_id_str:
            return ""
        if "Identifier=" in node_id_str:
            try:
                import re
                match = re.search(r"Identifier=['\"]?([^,'\"]+)", node_id_str)
                if match:
                    return match.group(1)
            except Exception:
                pass
        # Fallback to the raw string
        return node_id_str

    def format_access_level(self, access_level):
        """Format access level integer to human-readable string"""
        if not isinstance(access_level, int):
            return "Unknown"

        flags = []
        if access_level & 1:  # CurrentRead
            flags.append("CurrentRead")
        if access_level & 2:  # CurrentWrite
            flags.append("CurrentWrite")
        if access_level & 4:  # HistoryRead
            flags.append("HistoryRead")
        if access_level & 8:  # HistoryWrite
            flags.append("HistoryWrite")
        if access_level & 16:  # SemanticChange
            flags.append("SemanticChange")
        if access_level & 32:  # StatusWrite
            flags.append("StatusWrite")
        if access_level & 64:  # TimestampWrite
            flags.append("TimestampWrite")

        return ", ".join(flags) if flags else "None"

    def convert_to_opc_ua_type(self, text_value, data_type_id):
        """Convert string input to proper OPC UA data type"""
        if data_type_id is None:
            # Fallback to auto-detection
            if text_value.lower() in ['true', 'false']:
                return text_value.lower() == 'true'
            elif text_value.replace('-', '', 1).replace('.', '', 1).isdigit():
                if '.' in text_value:
                    return float(text_value)
                else:
                    return int(text_value)
            else:
                return text_value

        # Convert based on OPC UA data type ID
        try:
            if data_type_id == 1:  # Boolean
                return text_value.lower() in ['true', '1', 'yes', 'on']
            elif data_type_id == 2:  # Int8
                val = int(text_value)
                if val < -128 or val > 127:
                    raise ValueError("Int8 value must be between -128 and 127")
                return val
            elif data_type_id == 3:  # UInt8
                val = int(text_value)
                if val < 0 or val > 255:
                    raise ValueError("UInt8 value must be between 0 and 255")
                return val
            elif data_type_id == 4:  # Int16
                val = int(text_value)
                if val < -32768 or val > 32767:
                    raise ValueError("Int16 value must be between -32768 and 32767")
                return val
            elif data_type_id == 5:  # UInt16
                val = int(text_value)
                if val < 0 or val > 65535:
                    raise ValueError("UInt16 value must be between 0 and 65535")
                return val
            elif data_type_id == 6:  # Int32
                val = int(text_value)
                if val < -2147483648 or val > 2147483647:
                    raise ValueError("Int32 value must be between -2147483648 and 2147483647")
                return val
            elif data_type_id == 7:  # UInt32
                val = int(text_value)
                if val < 0 or val > 4294967295:
                    raise ValueError("UInt32 value must be between 0 and 4294967295")
                return val
            elif data_type_id == 8:  # Int64
                val = int(text_value)
                if val < -9223372036854775808 or val > 9223372036854775807:
                    raise ValueError("Int64 value out of range")
                return val
            elif data_type_id == 9:  # UInt64
                val = int(text_value)
                if val < 0 or val > 18446744073709551615:
                    raise ValueError("UInt64 value out of range")
                return val
            elif data_type_id == 10:  # Float
                return float(text_value)
            elif data_type_id == 11:  # Double
                return float(text_value)
            elif data_type_id == 12:  # String
                return text_value
            else:
                # For other types, return as string
                return text_value

        except ValueError as e:
            if "invalid literal" in str(e):
                type_names = {
                    1: "Boolean", 2: "Int8", 3: "UInt8", 4: "Int16", 5: "UInt16",
                    6: "Int32", 7: "UInt32", 8: "Int64", 9: "UInt64", 
                    10: "Float", 11: "Double", 12: "String"
                }
                type_name = type_names.get(data_type_id, "Unknown")
                raise ValueError(f"Invalid {type_name} value: '{text_value}'")
            else:
                raise e

    def setup_write_interface(self):
        """Setup the write interface based on the current node's data type"""
        # Clear existing widgets - setParent(None) removes from layout immediately,
        # deleteLater() schedules destruction for later
        for i in reversed(range(self.write_input_layout.count())):
            item = self.write_input_layout.takeAt(i)
            child = item.widget()
            if child:
                child.setParent(None)
                child.deleteLater()

        # Show the full path of the currently selected node above the Write row
        # so the user can confirm the write target. Falls back to node_id if
        # path is missing. Tooltip carries the node_id for copy-paste.
        node = self.current_node_data or {}
        node_path = node.get("path") or node.get("display_name") or str(node.get("node_id", ""))
        node_id_for_tip = str(node.get("node_id", ""))
        self.write_node_label.setText(node_path)
        if node_id_for_tip:
            self.write_node_label.setToolTip(f"Node ID: {node_id_for_tip}")

        # Reset widget references
        self.value_input = None
        self.bool_radio_group = None
        self.bool_true_radio = None
        self.bool_false_radio = None

        if not self.current_value_data:
            # If we don't have current value data yet, create a simple text input
            self.value_input = QLineEdit()
            if 'value' in self.current_node_data:
                self.value_input.setText(str(self.current_node_data['value']))
            self.value_input.setPlaceholderText("Enter value...")

            # Connect Enter key to write value
            self.value_input.returnPressed.connect(self.write_value)

            self.write_input_layout.addWidget(self.value_input)
            return

        raw_value = self.current_value_data.get('raw_value')
        data_type = self.current_value_data.get('data_type', '')
        node_id_str = str(self.current_node_data.get('node_id', 'N/A'))
        node_identifier = self._extract_identifier(node_id_str)

        # Common header text for the value input area
        type_display = self.format_data_type_for_properties(str(data_type)) if data_type else "N/A"
        node_display = node_identifier or node_id_str

        # For boolean values, use radio buttons
        if isinstance(raw_value, bool):
            # Create a group box for better organization
            bool_group = QGroupBox("Boolean Value")
            bool_layout = QHBoxLayout()
            # No node/type label here: the node path is already shown in
            # write_node_label above the Write row, and the full type/ID info is
            # in the Node Properties panel above. Repeating it here just added
            # clutter (the raw NodeId repr) and, for long structured-variable
            # identifiers, blew out the panel width.
            bool_layout.addStretch()

            self.bool_radio_group = QButtonGroup(self)
            self.bool_true_radio = QRadioButton("True")
            self.bool_false_radio = QRadioButton("False")

            self.bool_radio_group.addButton(self.bool_true_radio, 1)  # ID 1 for True
            self.bool_radio_group.addButton(self.bool_false_radio, 0)  # ID 0 for False

            # Set current value
            if raw_value:
                self.bool_true_radio.setChecked(True)
            else:
                self.bool_false_radio.setChecked(True)

            bool_layout.addWidget(self.bool_true_radio)
            bool_layout.addWidget(self.bool_false_radio)
            bool_layout.addStretch()

            bool_group.setLayout(bool_layout)
            self.write_input_layout.addWidget(bool_group)

        else:
            # Compact inline layout: just the line edit. The "Value Input"
            # group box, type-hint label and "Write Value" button title are
            # dropped — the [Write] button to the left and the parent section
            # title already give enough context, and the type information is
            # still visible in the Node Properties text above. Tooltip on
            # the field carries the type hint for users who want it.
            self.value_input = QLineEdit()
            self.value_input.setText(str(raw_value))
            self.value_input.returnPressed.connect(self.write_value)
            self.value_input.setToolTip(
                f"Node: {node_display}\nData Type: {type_display}"
            )

            type_lower = type_display.lower()
            if 'int' in type_lower and 'uint' not in type_lower:
                self.value_input.setPlaceholderText("integer")
            elif 'uint' in type_lower:
                self.value_input.setPlaceholderText("positive integer")
            elif 'float' in type_lower or 'double' in type_lower or 'real' in type_lower:
                self.value_input.setPlaceholderText("decimal")
            elif 'string' in type_lower:
                self.value_input.setPlaceholderText("text")
            elif 'boolean' in type_lower:
                self.value_input.setPlaceholderText("true or false")
            else:
                self.value_input.setPlaceholderText("value")

            self.write_input_layout.addWidget(self.value_input)

    def write_value(self):
        """Write the entered value to the OPC UA server"""
        if not self.current_node_data or not self.worker:
            QMessageBox.warning(self, "Error", "No node selected or worker not available")
            return

        try:
            # Use path as cache key (path is the cache key, not node_id)
            cache_key = self.current_node_data.get('path') or self.current_node_data.get('node_id')

            # Get value based on input type
            if self.bool_radio_group:
                # Boolean value from radio buttons
                value = self.bool_radio_group.checkedId() == 1  # 1 = True, 0 = False

            elif self.value_input:
                # Text input - convert to proper OPC UA data type
                text_value = self.value_input.text().strip()

                if not text_value:
                    QMessageBox.warning(self, "Invalid Value", "Please enter a value")
                    return

                # Convert to correct OPC UA data type based on the node's data type
                if self.current_value_data:
                    data_type_str = self.current_value_data.get('data_type', '')
                    data_type_id = self.get_opc_ua_data_type_id(data_type_str)

                    self.worker.log_message.emit("DEBUG", f"Converting '{text_value}' for data type: {data_type_str} (ID: {data_type_id})")

                    try:
                        value = self.convert_to_opc_ua_type(text_value, data_type_id)
                        self.worker.log_message.emit("DEBUG", f"Converted value: {value} (Python type: {type(value).__name__})")
                    except ValueError as e:
                        QMessageBox.warning(self, "Invalid Value", str(e))
                        return
                else:
                    # Fallback: auto-detect type
                    if text_value.lower() in ['true', 'false']:
                        value = text_value.lower() == 'true'
                    elif text_value.replace('-', '', 1).replace('.', '', 1).isdigit():
                        if '.' in text_value:
                            value = float(text_value)
                        else:
                            value = int(text_value)
                    else:
                        value = text_value
            else:
                QMessageBox.warning(self, "Error", "No input widget available")
                return

            # Write the value to the server
            if self.worker and self.worker.isRunning():
                self.write_button.setText("Writing...")
                self.write_button.setEnabled(False)
                self._show_write_status(
                    f"Writing {value!r} ({type(value).__name__})…", "info"
                )

                # Connect to write result signal if not already connected
                try:
                    self.worker.value_written.disconnect()
                except:
                    pass
                self.worker.value_written.connect(self.on_value_written)

                future = self.worker.write_node_value_async(cache_key, value)
                if not future:
                    self.write_button.setText("Write")
                    self.write_button.setEnabled(True)
                    self._show_write_status("Failed to initiate write operation", "error")
            else:
                QMessageBox.warning(self, "Error", "Not connected to server")

        except Exception as e:
            QMessageBox.critical(self, "Write Error", f"Failed to write value: {str(e)}")
            self.write_button.setText("Write")
            self.write_button.setEnabled(True)

    def _show_write_status(self, text: str, kind: str):
        """Display an inline status message under the Write row.

        ``kind`` is one of "info" (transient, muted), "success" (green,
        auto-clears after 5s), or "error" (red, persists until next write
        or node selection).
        """
        self._write_status_timer.stop()
        theme = THEME.active
        if kind == "success":
            color = theme.status_ok
        elif kind == "error":
            color = theme.status_error
        else:
            color = theme.text_muted
        self.write_status.setStyleSheet(f"color: {color}; padding: 2px 0;")
        timestamp = datetime.now().strftime("%H:%M:%S")
        self.write_status.setText(f"[{timestamp}] {text}")
        self.write_status.setVisible(True)
        if kind == "success":
            self._write_status_timer.start(5000)

    def _clear_write_status(self):
        """Hide the inline status line and stop any pending auto-clear."""
        self._write_status_timer.stop()
        self.write_status.clear()
        self.write_status.setVisible(False)

    def on_value_written(self, node_id, success, message):
        """Handle write operation result"""
        current_path = self.current_node_data.get('path') if self.current_node_data else None
        if current_path and current_path == node_id:
            self.write_button.setText("Write")
            self.write_button.setEnabled(True)
            if success:
                self._show_write_status(message, "success")
            else:
                self._show_write_status(message, "error")
                # Also show error in a message box for immediate attention
                QMessageBox.warning(self, "Write Failed", f"Failed to write to node:\n\n{message}")

    def update_current_value(self, node_id, value_data):
        """Update current value data and refresh interface"""
        current_path = self.current_node_data.get('path') if self.current_node_data else None
        if current_path and current_path == node_id:
            self.current_value_data = value_data

            if value_data:
                self.current_node_data['value'] = str(value_data['value'])
                self.current_node_data['data_type'] = value_data['data_type']
                self.current_node_data['last_read'] = value_data['timestamp']
                self.current_node_data['access_level'] = value_data.get('access_level', 0)
                self.current_node_data['user_access_level'] = value_data.get('user_access_level', 0)
                self.current_node_data['raw_value'] = value_data['raw_value']
                if value_data.get('source_timestamp') is not None:
                    self.current_node_data['source_timestamp'] = value_data.get('source_timestamp')
                if value_data.get('server_timestamp') is not None:
                    self.current_node_data['server_timestamp'] = value_data.get('server_timestamp')

            self.display_properties()

    def _build_script_snippet(self, node_class, path: str, node_data: dict) -> str:
        """Build a ready-to-paste Script snippet for the given node.

        Variable → ``Read("/path")`` (plus ``Write("/path", <value>)`` if
        the node is writable per its access level).
        Method   → ``Execute("/path", arg1, arg2, ...)`` using the
        cached input-argument names as placeholders when available.
        Other node classes return an empty string.
        """
        if not path:
            return ""
        if node_class == ua.NodeClass.Variable:
            snippet = f'Read("{path}")'
            # If writable (access-level bit 1 set), also offer Write(...).
            try:
                access = int(node_data.get("user_access_level") or
                             node_data.get("access_level") or 0)
            except Exception:
                access = 0
            if access & 0x02:  # CurrentWrite
                # Newline (not ' | ') so each call is on its own line —
                # the user can triple-click either line and Ctrl+C the
                # exact text without grabbing the other one.
                snippet += f'\nWrite("{path}", <value>)'
            return snippet
        if node_class == ua.NodeClass.Method:
            # Doc-style signature lives INSIDE the quoted path string so
            # the snippet reads as "this method takes these args".
            # Example: Execute("/.../MoveAbs(position: Double, velocity: Double)")
            #
            # Path quirk: method paths are stored with a trailing "()"
            # to disambiguate from same-named Variables in the cache
            # (see worker.load_node_children). Strip it before appending
            # our own signature suffix, otherwise we get "MoveAbs()()".
            base_path = path[:-2] if path.endswith("()") else path

            # Args come from the live OPC UA server via the worker's
            # get_method_arguments_async (same call the Invoke dialog
            # uses). This works for any method, not just those whose
            # children have been expanded in the Node Browser tree.
            args_spec: List[str] = []
            worker = getattr(self, "worker", None)
            if worker is not None and hasattr(worker, "get_method_arguments_async"):
                try:
                    input_args = worker.get_method_arguments_async(path) or []
                except Exception:
                    input_args = []
                for arg in input_args:
                    name = ""
                    if hasattr(arg, "Name"):
                        name = str(arg.Name)
                    elif hasattr(arg, "name"):
                        name = str(arg.name)
                    if not name:
                        continue
                    type_label = self._format_arg_data_type(arg)
                    args_spec.append(
                        f"{name}:{type_label}" if type_label else name
                    )
            sig = f"({', '.join(args_spec)})" if args_spec else "()"
            return f'Execute("{base_path}{sig}")'
        return ""

    # OPC UA builtin-type IDs (ns=0) → Python-style type name shown in
    # method-arg signatures. Lowercase names match what the user would
    # type in a script ("float" rather than "Float"), and "int" collapses
    # the integer variants (Int8/16/32/64 + UInt*) to a single hint —
    # `Execute("/path", 5)` works regardless of the underlying width.
    _OPCUA_TYPE_BY_ID = {
        1: "bool",
        2: "int", 3: "int", 4: "int", 5: "int",
        6: "int", 7: "int", 8: "int", 9: "int",
        10: "float", 11: "float",
        12: "str",
        13: "datetime",
        14: "Guid",
        15: "bytes",
        16: "XmlElement",
        17: "NodeId", 18: "ExpandedNodeId",
        19: "StatusCode",
        20: "QualifiedName", 21: "LocalizedText",
        22: "ExtensionObject", 23: "DataValue",
        24: "Variant", 25: "DiagnosticInfo",
    }

    def _format_arg_data_type(self, arg) -> str:
        """Human-readable Python-style type name for an asyncua Argument.

        ``arg.DataType`` is a NodeId object — pull its numeric Identifier
        directly (when it's the standard ns=0 builtin namespace) rather
        than going through ``str()`` which gives a verbose
        ``NodeId(Identifier=11, NamespaceIndex=0, ...)`` dump."""
        data_type = None
        if hasattr(arg, "DataType"):
            data_type = arg.DataType
        elif hasattr(arg, "data_type"):
            data_type = arg.data_type
        if data_type is None:
            return ""

        # Standard case: a NodeId object in the ns=0 builtin namespace.
        ns_index = getattr(data_type, "NamespaceIndex", None)
        identifier = getattr(data_type, "Identifier", None)
        if ns_index == 0 and isinstance(identifier, int):
            return self._OPCUA_TYPE_BY_ID.get(identifier, f"i={identifier}")

        # Fallback for non-standard / custom types: try to extract a
        # readable name from str(NodeId) without dumping the full repr.
        raw = str(data_type)
        if isinstance(identifier, int):
            # Custom namespace, numeric id — show "ns=<n>;i=<id>".
            return f"ns={ns_index};i={identifier}"
        if isinstance(identifier, str):
            return identifier  # string-id custom type
        return raw

    def invoke_method(self):
        """Invoke the selected method"""
        if not self.current_node_data or not self.worker:
            return

        # Use path as cache key
        method_id = self.current_node_data.get('path') or self.current_node_data.get('node_id')
        method_name = self.current_node_data.get('display_name', 'Unknown Method')

        try:
            input_arguments = self.worker.get_method_arguments_async(method_id)
        except:
            input_arguments = []

        if input_arguments:
            dialog = MethodInvokeDialog(method_id, method_name, input_arguments, self)
            if dialog.exec() == QDialog.DialogCode.Accepted:
                parameters = dialog.get_parameters()
            else:
                return
        else:
            parameters = []

        parent_id = self.find_parent_node_id(method_id)
        self.worker.invoke_method_async(parent_id, method_id, parameters)

    def find_parent_node_id(self, method_node_id):
        """Find the parent node ID for method invocation.

        Prefer the parent_node_id stored in current_node_data, which is
        derived from the tree structure. Fall back to the standard
        Objects folder (i=85) if no parent is available.
        """
        if self.current_node_data:
            parent_id = self.current_node_data.get('parent_node_id')
            if parent_id:
                return parent_id

        # Fallback: Objects folder
        return "i=85"


    def update_method_result(self, method_id, result):
        """Update method execution result"""
        current_path = self.current_node_data.get('path') if self.current_node_data else None
        if current_path and method_id == current_path:
            timestamp = datetime.now().strftime("%H:%M:%S")
            self.method_result.setText(f"[{timestamp}] Result: {result}")

    def _format_timestamp(self, ts):
        try:
            if isinstance(ts, datetime):
                return ts.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
            return str(ts)
        except Exception:
            return str(ts)

    @staticmethod
    def _clean_node_id(node_data) -> str:
        """Compact, canonical node-id string (e.g. ``ns=2;s=foo.bar``).

        Prefers the asyncua NodeId object's to_string(); falls back to the
        stored node_id, parsing away the noisy ``NodeId(Identifier=...,
        NamespaceIndex=..., NodeIdType=...)`` repr if that's all we have."""
        obj = node_data.get('nodeid_obj')
        if obj is not None:
            to_str = getattr(obj, 'to_string', None)
            if callable(to_str):
                try:
                    return to_str()
                except Exception:
                    pass
            ns = getattr(obj, 'NamespaceIndex', None)
            ident = getattr(obj, 'Identifier', None)
            id_type = getattr(obj, 'NodeIdType', None)
            tag = {1: 'i', 2: 's', 3: 'g', 4: 'b'}.get(
                getattr(id_type, 'value', id_type), 's')
            if ident is not None:
                return f"ns={ns if ns is not None else 0};{tag}={ident}"
        raw = str(node_data.get('node_id', 'N/A'))
        if raw.startswith('ns=') or raw == 'N/A':
            return raw
        # Parse the NodeId(Identifier='X', NamespaceIndex=N, NodeIdType=...) repr.
        if 'Identifier=' in raw and 'NamespaceIndex=' in raw:
            try:
                ident = raw.split('Identifier=')[1].split(',')[0].strip().strip("'\"")
                ns = raw.split('NamespaceIndex=')[1].split(',')[0].strip()
                tag = 'i' if ident.isdigit() else 's'
                return f"ns={ns};{tag}={ident}"
            except Exception:
                pass
        return raw

    # OPC UA NodeClass value -> readable name.
    _NODE_CLASS_NAMES = {
        0: 'Unspecified', 1: 'Object', 2: 'Variable', 4: 'Method',
        8: 'ObjectType', 16: 'VariableType', 32: 'ReferenceType',
        64: 'DataType', 128: 'View',
    }

    @classmethod
    def _node_class_name(cls, node_class) -> str:
        """Readable NodeClass name with the raw value, e.g. 'Method (4)'."""
        if node_class is None:
            return 'N/A'
        val = getattr(node_class, 'value', node_class)
        try:
            val = int(val)
        except (TypeError, ValueError):
            return str(node_class)
        name = cls._NODE_CLASS_NAMES.get(val)
        return f"{name} ({val})" if name else str(val)

    def _format_nodeid_details(self, node_data):
        nodeid_obj = node_data.get('nodeid_obj')
        if nodeid_obj:
            ns = getattr(nodeid_obj, "NamespaceIndex", getattr(nodeid_obj, "namespace_index", None))
            ident = getattr(nodeid_obj, "Identifier", getattr(nodeid_obj, "identifier", None))
            # The enum lives on .NodeIdType (NOT .IdentifierType — that
            # attribute doesn't exist, so the old code always read None and
            # printed "Unknown"). Render its name (String/Numeric/Guid/...).
            id_type = getattr(nodeid_obj, "NodeIdType",
                              getattr(nodeid_obj, "identifier_type", None))
            ns_text = f"{ns}" if ns is not None else "N/A"
            id_type_text = getattr(id_type, "name", None) or (
                str(id_type) if id_type is not None else "Unknown")
            ident_text = str(ident) if ident is not None else "N/A"
            return f"Namespace: {ns_text}, IdentifierType: {id_type_text}, Identifier: {ident_text}\n"

        node_id_str = node_data.get('node_id', '')
        if node_id_str.startswith("ns="):
            try:
                ns_part = node_id_str.split(";")[0].split("=")[1]
                ident_part = node_id_str.split(";")[1]
                ident_type = "Numeric" if ident_part.startswith("i=") else "String" if ident_part.startswith("s=") else "ByteString" if ident_part.startswith("b=") else "Guid" if ident_part.startswith("g=") else "Unknown"
                ident_val = ident_part.split("=", 1)[1]
                return f"Namespace: {ns_part}, IdentifierType: {ident_type}, Identifier: {ident_val}\n"
            except Exception:
                return ""
        return ""


class SubscriptionDropTable(QTableWidget):
    """QTableWidget that accepts drops of variable-node IDs from the
    Node Browser tree. Drops trigger the same subscribe path used by
    the Subscribe button / S shortcut.

    Decoupled from SubscriptionWidget so the table doesn't need a
    back-reference to the parent widget for everything else; the
    widget connects this table's `nodes_dropped` signal to its own
    handler at construction time.
    """
    NODES_MIME_TYPE = "application/x-uaexplorer-nodes"
    nodes_dropped = pyqtSignal(list)  # list[str] of node IDs

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setAcceptDrops(True)
        self.setDragDropMode(QTableWidget.DragDropMode.DropOnly)
        self.setDropIndicatorShown(True)

    def dragEnterEvent(self, event) -> None:
        if event.mimeData().hasFormat(self.NODES_MIME_TYPE):
            event.acceptProposedAction()
        else:
            event.ignore()

    def dragMoveEvent(self, event) -> None:
        if event.mimeData().hasFormat(self.NODES_MIME_TYPE):
            event.acceptProposedAction()
        else:
            event.ignore()

    def dropEvent(self, event) -> None:
        if not event.mimeData().hasFormat(self.NODES_MIME_TYPE):
            event.ignore()
            return
        raw = bytes(event.mimeData().data(self.NODES_MIME_TYPE)).decode("utf-8", "ignore")
        node_ids = [nid for nid in raw.split("\n") if nid.strip()]
        if node_ids:
            self.nodes_dropped.emit(node_ids)
        event.acceptProposedAction()


def _ensure_csv_suffix(name: str) -> str:
    """Return ``name`` with its extension forced to ``.csv``.

    Recordings are CSV-only, so any other suffix the user typed is
    replaced (``foo`` -> ``foo.csv``, ``foo.dat`` -> ``foo.csv``). An
    empty / dot-only name falls back to ``recording.csv``.
    """
    stem = Path(name).stem.strip()
    if not stem:
        return "recording.csv"
    return f"{stem}.csv"


class SubscriptionWidget(QWidget):
    # Emitted when the [U] button is clicked - the main window toggles the
    # panel between docked (in the main splitter) and a floating dialog.
    # Same idiom as LogWidget.undock_requested.
    undock_requested = pyqtSignal()

    # Emitted (with a list of node_data dicts) when the user asks to plot the
    # selected subscription rows — from the "Plot" button or context menu. The
    # main window handles it the same way as the Node Browser's plot_requested.
    plot_requested = pyqtSignal(list)

    def __init__(self, worker):
        super().__init__()
        self.worker = worker
        self.recording = False
        self.record_timer = QTimer(self)
        self.record_timer.timeout.connect(self._record_tick)
        self.record_samples = 0
        self.record_start_time = 0.0
        self.record_file_path: Optional[Path] = None
        self.record_limit_value: float = 0.0
        self.latest_values: Dict[str, Dict[str, Any]] = {}
        self.record_nodes: List[str] = []
        self.current_uri: str = ""
        # Per-row stats config & buffers. enabled=False keeps the new
        # stat columns hidden so users who don't care never see them.
        # window_size is the deque maxlen; only numeric samples are
        # appended, so boolean/string subscriptions show "-" in the
        # stat cells when enabled. Defaults mirror UaPlot.
        self.subscription_stats = {
            "enabled": False,
            "show_min": True,
            "show_max": True,
            "show_mean": True,
            "show_count": True,
            "window_size": 1000,
        }
        # node_id -> deque of recent numeric values (bounded by
        # window_size). Non-numeric subscriptions get no entry.
        self._stats_buffers: Dict[str, deque] = {}
        # node_id -> total number of numeric samples received since
        # subscribe (NOT bounded by window_size — the deque length
        # saturates once the window fills and stops being a useful
        # "how active is this signal" indicator, so we count
        # separately).
        self._stats_total_counts: Dict[str, int] = {}
        # Decimal places used to render stat values. Higher = more
        # precision but wider columns; lower = compact but rounds away
        # detail. Configurable in Preferences > Subscription > Statistics.
        self.subscription_stats["decimals"] = 3
        self.setup_ui()

    def _apply_undock_button_style(self) -> None:
        """Style the [U] button - round, theme-aware, accent on hover.
        Mirrors LogWidget._apply_undock_button_style."""
        t = THEME.active
        self.undock_button.setStyleSheet(
            f"QPushButton {{ border: 1px solid {t.border}; border-radius: 11px;"
            f" padding: 0; color: {t.text_primary};"
            f" background: {t.panel_bg}; }}"
            f"QPushButton:hover {{ background: {t.accent};"
            f" color: {t.text_on_accent}; }}"
        )

    def setup_ui(self):
        layout = QVBoxLayout()
        # No bottom margin so the subscription table sits flush against
        # the splitter handle below — the LogWidget below also clears its
        # top margin, so the visible gap between Subscriptions and the
        # log controls is just the 3px splitter handle.
        layout.setContentsMargins(4, 2, 4, 0)
        layout.setSpacing(2)

        # --- Single-row toolbar: title + mode + recording controls ---
        # All recording/monitor controls share the row with the section
        # title to maximise vertical space available for the subscriptions
        # table below.
        toolbar = QHBoxLayout()
        toolbar.setContentsMargins(0, 0, 0, 0)
        toolbar.setSpacing(4)

        title_label = QLabel("Subscriptions")
        apply_section_title_style(title_label)
        toolbar.addWidget(title_label)
        toolbar.addSpacing(8)

        # Compact, theme-aware stylesheets for the small controls in
        # this row. Setting any stylesheet on a QLineEdit/QComboBox
        # makes Qt stop honouring QPalette.Base for that widget — so
        # we have to push the theme's input/panel colors explicitly
        # and rebuild on theme change.
        def _input_css() -> str:
            t = THEME.active
            return (
                f"QLineEdit {{ padding: 1px 3px; font-size: 11px;"
                f" min-height: 18px; max-height: 22px;"
                f" background: {t.input_bg}; color: {t.text_primary};"
                f" border: 1px solid {t.border}; border-radius: 3px; }}"
                f"QComboBox {{ padding: 1px 3px; font-size: 11px;"
                f" min-height: 18px; max-height: 22px;"
                f" background: {t.input_bg}; color: {t.text_primary};"
                f" border: 1px solid {t.border}; border-radius: 3px; }}"
            )
        def _label_css() -> str:
            t = THEME.active
            return f"QLabel, QRadioButton {{ font-size: 11px; color: {t.text_primary}; }}"

        themed_inputs: list = []   # widgets that should use _input_css
        themed_labels: list = []   # widgets that should use _label_css
        def _add_input(w):
            w.setStyleSheet(_input_css())
            themed_inputs.append(w)
        def _add_label(w):
            w.setStyleSheet(_label_css())
            themed_labels.append(w)

        # Mode selection (radio buttons)
        self.subscription_radio = QRadioButton("Subscription")
        self.polling_radio = QRadioButton("Polling")
        self.subscription_radio.setChecked(True)
        _add_label(self.subscription_radio)
        _add_label(self.polling_radio)
        toolbar.addWidget(self.subscription_radio)
        toolbar.addWidget(self.polling_radio)

        # Polling interval [500] – visible only in polling mode (ms label dropped)
        self.polling_interval_input = QLineEdit("500")
        self.polling_interval_input.setFixedWidth(48)
        _add_input(self.polling_interval_input)
        self.polling_interval_label = QLabel("ms")  # kept for backwards-compat refs
        self.polling_interval_label.setVisible(False)
        toolbar.addWidget(self.polling_interval_input)

        self.polling_interval_input.setVisible(False)

        # Connect mode + interval signals
        self.subscription_radio.toggled.connect(self.on_mode_changed)
        self.polling_radio.toggled.connect(self.on_mode_changed)
        self.polling_interval_input.editingFinished.connect(
            self.on_polling_interval_changed
        )

        toolbar.addSpacing(10)

        # Record button — uses the project's compact button style.
        self.record_button = make_compact_button("Record")
        self.record_button.setCheckable(True)
        self.record_button.clicked.connect(self.toggle_recording)
        toolbar.addWidget(self.record_button)

        rate_label = QLabel("Rate")
        _add_label(rate_label)
        toolbar.addWidget(rate_label)
        self.record_rate_input = QLineEdit("1")
        # Hz is a sample rate, so it may be fractional: 0.1 .. 999.9 (one
        # decimal). "999.9" is 5 chars; the validator enforces the range.
        self.record_rate_input.setFixedWidth(48)
        self.record_rate_input.setMaxLength(5)
        _rate_validator = QDoubleValidator(0.1, 999.9, 1, self.record_rate_input)
        _rate_validator.setNotation(QDoubleValidator.Notation.StandardNotation)
        self.record_rate_input.setValidator(_rate_validator)
        _add_input(self.record_rate_input)
        toolbar.addWidget(self.record_rate_input)
        hz_label = QLabel("Hz")
        _add_label(hz_label)
        toolbar.addWidget(hz_label)

        method_label = QLabel("Method")
        _add_label(method_label)
        toolbar.addWidget(method_label)
        self.record_mode_combo = QComboBox()
        # "Number of samples" -> "Samples" (terser).
        self.record_mode_combo.addItems(["Until stopped", "Period of time", "Samples"])
        self.record_mode_combo.setCurrentText("Period of time")
        self.record_mode_combo.currentTextChanged.connect(self._on_record_mode_changed)
        # Narrower combo — width tuned to fit "Until stopped" comfortably.
        self.record_mode_combo.setFixedWidth(110)
        _add_input(self.record_mode_combo)
        toolbar.addWidget(self.record_mode_combo)

        self.record_limit_label = QLabel("Limit")
        _add_label(self.record_limit_label)
        toolbar.addWidget(self.record_limit_label)
        self.record_limit_input = QLineEdit("10")
        # Limit is an integer (samples or seconds — there's no fractional
        # sample): 1 .. 9999. Shared by both limited modes.
        self.record_limit_input.setFixedWidth(56)
        self.record_limit_input.setMaxLength(4)
        self.record_limit_input.setValidator(
            QIntValidator(1, 9999, self.record_limit_input)
        )
        _add_input(self.record_limit_input)
        toolbar.addWidget(self.record_limit_input)

        # Output file row: "Output File" [<full path>] [...] [ ] Auto [Load].
        # Mirrors the Scripting output-file pattern: a single editable
        # path field replaces the previous separate File / Folder inputs.
        # The legacy record_filename_input / record_dir_input attributes are
        # kept (hidden, off-toolbar) so start_recording() and the Preferences
        # mirror keep working — they're derived from the full path on demand.
        output_label = QLabel("Output File")
        _add_label(output_label)
        toolbar.addWidget(output_label)

        # Basename-only field; the directory is held in the hidden
        # record_dir_input (defaults to $HOME, settable from Preferences).
        self.record_output_input = QLineEdit(self._default_record_filename())
        self.record_output_input.setFixedWidth(228)
        _add_input(self.record_output_input)
        self.record_output_input.setCursorPosition(0)
        self.record_output_input.setToolTip(
            "Filename recordings write to (within the recording directory).\n"
            "Set the directory in Preferences → Recording."
        )
        toolbar.addWidget(self.record_output_input)

        self.record_output_browse_btn = make_compact_button(
            "…", tip="Pick a CSV output file")
        self.record_output_browse_btn.clicked.connect(self._on_browse_record_output)
        toolbar.addWidget(self.record_output_browse_btn)

        # "Overwrite" — when checked, the recording always writes to the
        # same file, OVERWRITING it at each new recording session. When
        # unchecked (default), a fresh timestamped CSV name is generated
        # for each recording so previous data is preserved.
        #
        # We deliberately do NOT append across recordings: subscriptions
        # can change between runs, which would produce a CSV whose columns
        # don't match — bad data, silently. Truncate-on-start is the
        # safe behavior.
        self.record_fixed_check = QCheckBox("Overwrite")
        self.record_fixed_check.setChecked(False)
        self.record_fixed_check.setToolTip(
            "Overwrite: always write to the same filename; previous "
            "content is overwritten at each Record.\n"
            "Off (default): a fresh timestamped CSV name is generated per "
            "recording so previous runs are preserved."
        )
        self.record_fixed_check.toggled.connect(self._on_record_fixed_toggled)
        _add_label(self.record_fixed_check)
        toolbar.addWidget(self.record_fixed_check)

        self.record_load_btn = make_compact_button(
            "Load", tip="Browse and view an existing recording file")
        self.record_load_btn.clicked.connect(self._on_load_recording)
        toolbar.addWidget(self.record_load_btn)

        # Hidden legacy widgets — kept so start_recording / preferences
        # mirror still see ``record_filename_input`` and ``record_dir_input``.
        # Synced from record_output_input by _sync_legacy_record_inputs().
        self.record_filename_input = QLineEdit(self._default_record_filename())
        self.record_filename_input.setVisible(False)
        self.record_dir_input = QLineEdit(str(Path.home()))
        self.record_dir_input.setVisible(False)

        # Keep the legacy fields in sync with the combined path.
        self.record_output_input.editingFinished.connect(self._sync_legacy_record_inputs)
        self._apply_record_fixed_state()
        self._sync_legacy_record_inputs()

        self.record_status_label = QLabel("Ready")
        _add_label(self.record_status_label)
        toolbar.addWidget(self.record_status_label)

        # Plot the selected subscription rows (same UaPlot path as the Node
        # Browser's Plot button). Unrelated to recording, so separated from the
        # recording controls by a small gap and placed after them.
        toolbar.addSpacing(16)
        self.sub_plot_btn = make_compact_button(
            "Plot", tip="Plot the selected subscribed variable(s) in UaPlot")
        self.sub_plot_btn.clicked.connect(self.plot_selected)
        toolbar.addWidget(self.sub_plot_btn)

        # Refresh all the toolbar control stylesheets when the theme
        # changes (Qt drops QPalette.Base as soon as a stylesheet rule
        # is set, so we have to push the new colors ourselves).
        def _refresh_toolbar(_t):
            css_in = _input_css()
            css_lbl = _label_css()
            for w in themed_inputs:
                try:
                    w.setStyleSheet(css_in)
                except RuntimeError:
                    pass
            for w in themed_labels:
                try:
                    w.setStyleSheet(css_lbl)
                except RuntimeError:
                    pass
        THEME.theme_changed.connect(_refresh_toolbar)

        toolbar.addStretch()

        # Compact [U] button at the right edge - undocks/docks the whole
        # Subscriptions panel into a floating window, so a long subscription
        # list can be enlarged or moved to a second monitor. Mirrors the log
        # panel's [U] button.
        self.undock_button = QPushButton("U")
        self.undock_button.setFixedSize(22, 22)
        self.undock_button.setToolTip(
            "Undock the Subscriptions panel (toggle: dock when undocked)")
        self.undock_button.setSizePolicy(
            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self._apply_undock_button_style()
        THEME.theme_changed.connect(lambda _t: self._apply_undock_button_style())
        self.undock_button.clicked.connect(self.undock_requested.emit)
        toolbar.addWidget(self.undock_button)

        layout.addLayout(toolbar)

        # --- Subscription table ---
        # 4 base columns (Node ID / Value / Timestamp / Status) plus 4
        # optional stat columns (Min / Max / Mean / Count). The stat
        # columns are individually hideable via Preferences > Statistics
        # using setColumnHidden — that's cleaner than mutating
        # setColumnCount as the user toggles checkboxes.
        self.subscription_table = SubscriptionDropTable()
        self.subscription_table.setColumnCount(8)
        self.subscription_table.setHorizontalHeaderLabels(
            ["Node ID", "Value", "Timestamp", "Status",
             "Min", "Max", "Mean", "Count"]
        )
        self.subscription_table.setSelectionBehavior(
            QTableWidget.SelectionBehavior.SelectRows
        )
        # Drag-and-drop handler: connect the table's drop signal to
        # the same subscribe path used by the Subscribe button.
        self.subscription_table.nodes_dropped.connect(self._on_nodes_dropped)
        self.subscription_table.setContextMenuPolicy(
            Qt.ContextMenuPolicy.CustomContextMenu
        )
        self.subscription_table.customContextMenuRequested.connect(
            self.show_subscription_context_menu
        )

        # Enable keyboard shortcuts
        self.delete_shortcut = QShortcut(
            QKeySequence.StandardKey.Delete,
            self.subscription_table,
        )
        self.delete_shortcut.activated.connect(
            self.delete_selected_subscriptions
        )

        # Make columns resizable
        header = self.subscription_table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)  # Node ID - resizable
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)  # Value stretches to use free space
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)  # Timestamp sized to content
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)  # Status sized to content
        # Stat columns: size to content; they're hidden by default so
        # this is cheap.
        for col in range(4, 8):
            header.setSectionResizeMode(col, QHeaderView.ResizeMode.ResizeToContents)

        # Default visible-rows height (overridable via Preferences).
        self._visible_subscription_rows = 8
        self.set_visible_subscription_rows(self._visible_subscription_rows)
        header.setStretchLastSection(False)

        # Node ID column auto-fits to its widest content as rows are added, so
        # long node ids stay fully visible. Once the USER manually drags the
        # column, we stop auto-fitting it and honour their width (they may want
        # it narrower or wider than the content). Detected via sectionResized.
        self._node_id_user_sized = False
        self._suppress_node_id_resize_signal = False
        header.sectionResized.connect(self._on_subscription_section_resized)

        # Make rows as compact as Qt will allow without truncating the
        # 10 px font: tight per-cell padding + a fixed minimum-section
        # height that overrides Qt's default ~24 px row floor. The
        # vertical header is not visible to the user, so its sizing
        # is purely about how much space each data row takes.
        #
        # NOTE: as soon as a stylesheet rule is applied to QTableWidget,
        # Qt stops honouring QPalette.Base for the viewport background
        # and falls back to a hard-coded white. We therefore have to
        # set the background explicitly from the active theme — and
        # rebuild on theme change so live switching works.
        def _apply_table_style():
            t = THEME.active
            self.subscription_table.setStyleSheet(
                f"QTableWidget {{ font-size: 10px;"
                f" background: {t.input_bg}; color: {t.text_primary};"
                f" alternate-background-color: {t.panel_alt_bg};"
                f" gridline-color: {t.border_subtle}; }}"
                f"QTableWidget::item {{ padding: 0px 3px; }}"
                f"QHeaderView::section {{ padding: 1px 4px; font-size: 10px;"
                f" background: {t.panel_bg}; color: {t.text_primary};"
                f" border: 1px solid {t.border_subtle}; }}"
            )
        _apply_table_style()
        THEME.theme_changed.connect(lambda _t: _apply_table_style())
        vheader = self.subscription_table.verticalHeader()
        vheader.setMinimumSectionSize(14)
        vheader.setDefaultSectionSize(14)

        # Set initial column widths
        self.subscription_table.setColumnWidth(0, 300)  # Node ID wider
        self.subscription_table.setColumnWidth(1, 250)  # Value
        self.subscription_table.setColumnWidth(2, 140)  # Timestamp
        self.subscription_table.setColumnWidth(3, 120)  # Status
        # Stat columns: hidden until the user enables them in Prefs.
        self._apply_subscription_stats_visibility()

        self._resize_subscription_columns()

        # Let the table consume all remaining vertical space so the
        # subscribed-nodes output is as tall as possible.
        self.subscription_table.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
        )
        layout.addWidget(self.subscription_table, 1)
        self.setLayout(layout)

    def _on_subscription_section_resized(self, index, old_size, new_size):
        """Note when the USER manually resizes the Node ID column so we stop
        auto-fitting it. Ignores our own programmatic resizes (guarded by
        _suppress_node_id_resize_signal)."""
        if index == 0 and not self._suppress_node_id_resize_signal:
            self._node_id_user_sized = True

    def _node_id_content_width(self) -> int:
        """Pixel width needed to show the widest Node ID cell (and the header),
        so the column can auto-fit to its longest item."""
        table = self.subscription_table
        fm = table.fontMetrics()
        # Header text.
        widest = fm.horizontalAdvance("Node ID")
        for row in range(table.rowCount()):
            item = table.item(row, 0)
            if item is not None:
                widest = max(widest, fm.horizontalAdvance(item.text()))
        # + cell padding (see the stylesheet: 3px each side) and a small margin.
        return widest + 16

    def _resize_subscription_columns(self):
        """Resize subscription table columns to content while capping extremes.

        The Node ID column (0) auto-fits to its widest content so long node ids
        stay visible — UNLESS the user has manually resized it, in which case
        their width is left untouched.
        """
        header = self.subscription_table.horizontalHeader()
        if not header:
            return

        header.resizeSections(QHeaderView.ResizeMode.ResizeToContents)

        # Value/Timestamp/Status keep their content-based caps. Node ID (0) is
        # handled separately below so it can grow to fit long ids.
        caps = {1: 700, 2: 240, 3: 180}
        for col, max_w in caps.items():
            try:
                current = header.sectionSize(col)
                header.resizeSection(col, min(current, max_w))
            except Exception:
                continue

        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        header.setStretchLastSection(False)

        # Auto-fit Node ID to its longest item, unless the user took control.
        # Cap generously so one pathological id can't eat the whole table, but
        # high enough that normal long ids fit. Our own resize must not be
        # mistaken for a user drag, so suppress the signal around it.
        if not self._node_id_user_sized:
            width = min(self._node_id_content_width(), 900)
            self._suppress_node_id_resize_signal = True
            try:
                header.resizeSection(0, width)
            finally:
                self._suppress_node_id_resize_signal = False


    def subscribe_selected_nodes(self):
        # This will be called from the main window when nodes are selected in the tree
        pass

    def delete_selected_subscriptions(self):
        selected_rows = set()
        for item in self.subscription_table.selectedItems():
            selected_rows.add(item.row())

        if not selected_rows:
            return

        # Get node IDs before deleting rows
        node_ids_to_unsubscribe = []
        for row in selected_rows:
            item = self.subscription_table.item(row, 0)
            if item:
                # Use stored full node_id for unsubscription
                stored_node_id = item.data(Qt.ItemDataRole.UserRole)
                if stored_node_id:
                    node_ids_to_unsubscribe.append(stored_node_id)
                else:
                    node_ids_to_unsubscribe.append(item.text())

        # Unsubscribe from nodes
        if self.worker:
            for node_id in node_ids_to_unsubscribe:
                self.worker.unsubscribe_from_node_async(node_id)

        # Remove rows from table (in reverse order to maintain indices)
        for row in sorted(selected_rows, reverse=True):
            node_item = self.subscription_table.item(row, 0)
            if node_item:
                node_id = node_item.data(Qt.ItemDataRole.UserRole) or node_item.text()
                self.latest_values.pop(node_id, None)
                self._stats_buffers.pop(node_id, None)
                self._stats_total_counts.pop(node_id, None)
            self.subscription_table.removeRow(row)

    def show_subscription_context_menu(self, position):
        if self.subscription_table.itemAt(position) is None:
            return

        menu = QMenu()

        plot_action = QAction("Plot", self)
        plot_action.triggered.connect(self.plot_selected)
        menu.addAction(plot_action)

        unsubscribe_action = QAction("Unsubscribe", self)
        unsubscribe_action.triggered.connect(self.unsubscribe_selected)
        menu.addAction(unsubscribe_action)

        menu.exec(self.subscription_table.mapToGlobal(position))

    def unsubscribe_selected(self):
        self.delete_selected_subscriptions()

    def _selected_subscription_node_data(self) -> list:
        """Build node_data dicts for the selected subscription rows.

        The full node id lives in each row's Node ID cell (UserRole). Each dict
        carries node_id (+ a display label) — the minimum _plot_single_node /
        plot_variable need. node_class is set to Variable (only variables can be
        subscribed, so every row is one)."""
        rows = sorted({item.row() for item in self.subscription_table.selectedItems()})
        node_data_list = []
        for row in rows:
            item = self.subscription_table.item(row, 0)
            if item is None:
                continue
            node_id = item.data(Qt.ItemDataRole.UserRole) or item.text()
            node_data_list.append({
                "node_id": node_id,
                "display_name": item.text(),
                "node_class": ua.NodeClass.Variable,
            })
        return node_data_list

    def plot_selected(self):
        """Ask the main window to plot the selected subscription rows."""
        node_data_list = self._selected_subscription_node_data()
        if not node_data_list:
            return
        self.plot_requested.emit(node_data_list)

    def _on_nodes_dropped(self, node_ids: list) -> None:
        """Handle a drop of variable-node IDs from the Node Browser.

        Calls the same `subscribe + add_subscription` path the Subscribe
        button uses, deduplicating against rows that already exist.
        Methods/Objects are filtered upstream (in the tree's mimeData),
        so anything arriving here is a subscribable variable.
        """
        if not self.worker or not node_ids:
            return
        for node_id in node_ids:
            if self.is_subscribed(node_id):
                continue
            self.worker.subscribe_to_node_async(node_id)
            self.add_subscription(node_id, node_id)

    def add_subscription(self, node_id, node_name):
        # Check if already subscribed
        if self.is_subscribed(node_id):
            return  # Already subscribed

        row = self.subscription_table.rowCount()
        self.subscription_table.insertRow(row)

        # Extract readable node identifier from full node ID
        display_node_id = self.extract_node_identifier(node_id)

        self.subscription_table.setItem(row, 0, QTableWidgetItem(display_node_id))
        self.subscription_table.setItem(row, 1, QTableWidgetItem("--"))
        self.subscription_table.setItem(row, 2, QTableWidgetItem("--"))
        self._set_status_for_row(row)
        # Initial stat cells (placeholder dashes); become real values
        # once numeric samples start arriving.
        for col in (4, 5, 6, 7):
            self.subscription_table.setItem(row, col, QTableWidgetItem("-"))

        # Store the full node_id as item data for internal use
        self.subscription_table.item(row, 0).setData(Qt.ItemDataRole.UserRole, node_id)
        self._resize_subscription_columns()

    def extract_node_identifier(self, node_id):
        """Extract readable identifier from full node ID"""
        try:
            # Handle different node ID formats
            if "Identifier='" in node_id:
                # Extract from format: NodeId(Identifier='MAIN.Motor1.stat.sState', NamespaceIndex=4, ...)
                import re
                match = re.search(r"Identifier='([^']*)'", node_id)
                if match:
                    return match.group(1)
            elif node_id.startswith("ns="):
                # Extract from format: ns=4;s=MAIN.Motor1.stat.sState
                if ";s=" in node_id:
                    return node_id.split(";s=", 1)[1]
                elif ";i=" in node_id:
                    return node_id.split(";i=", 1)[1]
            elif node_id.startswith("i="):
                # Numeric identifier: i=2258
                return node_id

            # If no pattern matches, return the original
            return node_id
        except:
            return node_id

    def remove_subscription(self, node_id):
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if item:
                # Check both display text and stored full node_id
                stored_node_id = item.data(Qt.ItemDataRole.UserRole)
                if stored_node_id == node_id or item.text() == node_id:
                    self.subscription_table.removeRow(row)
                    break
        self.latest_values.pop(node_id, None)
        self._stats_buffers.pop(node_id, None)
        self._stats_total_counts.pop(node_id, None)

    def clear_all(self):
        """Remove all subscriptions from the table."""
        self.subscription_table.setRowCount(0)
        self._stats_buffers.clear()
        self._stats_total_counts.clear()
        self.latest_values.clear()

    def on_mode_changed(self):
        """Update worker mode and show/hide polling interval field."""
        if self.polling_radio.isChecked():
            # Show polling interval input
            self.polling_interval_input.setVisible(True)
            self.polling_interval_label.setVisible(True)
            if self.worker:
                self.worker.set_monitor_mode("polling")
                # Also push current interval into worker
                self.worker.set_polling_interval(
                    self.polling_interval_input.text()
                )
            self._refresh_status_column()
        else:
            # Hide polling interval input
            self.polling_interval_input.setVisible(False)
            self.polling_interval_label.setVisible(False)
            if self.worker:
                self.worker.set_monitor_mode("subscription")
            self._refresh_status_column()


    def on_polling_interval_changed(self):
        """Send new polling interval to worker when the text field is edited."""
        if self.worker and self.polling_radio.isChecked():
            self.worker.set_polling_interval(
                self.polling_interval_input.text()
            )

    def update_subscription_data(self, node_id, value, data):
        """Update the value and timestamp cells for the given node."""
        timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        # Push numeric values into the per-row buffer for stats. Booleans
        # are excluded — even though Python treats them as int, recording
        # "min=0 max=1 mean=0.43" for an On/Off signal is useless.
        # Strings simply can't be coerced. In both cases the stat cells
        # will render "-" via _refresh_stats_cells_for_row.
        if isinstance(value, (int, float)) and not isinstance(value, bool):
            try:
                numeric = float(value)
            except (TypeError, ValueError):
                numeric = None
            if numeric is not None:
                buf = self._stats_buffers.get(node_id)
                if buf is None or buf.maxlen != int(self.subscription_stats["window_size"]):
                    buf = deque(
                        buf or (),
                        maxlen=int(self.subscription_stats["window_size"]),
                    )
                    self._stats_buffers[node_id] = buf
                buf.append(numeric)
                self._stats_total_counts[node_id] = (
                    self._stats_total_counts.get(node_id, 0) + 1
                )
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if item:
                stored_node_id = item.data(Qt.ItemDataRole.UserRole)
                if stored_node_id == node_id:
                    self.subscription_table.setItem(row, 1, QTableWidgetItem(str(value)))
                    self.subscription_table.setItem(row, 2, QTableWidgetItem(timestamp))
                    self._set_status_for_row(row)
                    if self.subscription_stats.get("enabled"):
                        self._refresh_stats_cells_for_row(row, node_id)
                    # NB: do NOT resize columns here. A value update never
                    # changes the column set; resizing-to-contents walks every
                    # cell in every row, so doing it per notification turns a
                    # handful of fast subscriptions into a GUI-wide stall. Column
                    # sizing happens when rows are ADDED (_add_subscription_row)
                    # and when stat-column visibility changes - the only times
                    # the needed widths actually change.
                    break
        self.latest_values[node_id] = {"value": str(value), "timestamp": datetime.now()}

    # ---- Subscription statistics --------------------------------------
    # Min / Max / Mean / Count over a bounded sliding window of recent
    # samples for each numeric subscription. Configured from Preferences
    # > Statistics. Non-numeric rows show "-" in every stat cell.

    def _fmt_stat(self, v: float) -> str:
        """Render a stat value with the configured number of decimal places.

        Uses ``{:.<N>f}`` (fixed decimals) rather than ``{:.<N>g}``
        (significant figures). Users see consistent precision regardless
        of the value's magnitude. ``-`` if the value isn't numeric.
        """
        try:
            decimals = int(self.subscription_stats.get("decimals", 3))
        except (TypeError, ValueError):
            decimals = 3
        decimals = max(0, min(decimals, 12))
        try:
            return f"{float(v):.{decimals}f}"
        except (TypeError, ValueError):
            return "-"

    def _refresh_stats_cells_for_row(self, row: int, node_id: str):
        """Write the stat cells for one row from its buffered samples.

        Numeric rows:
          - Min/Max/Mean computed over the bounded buffer (deque).
          - Count is the TOTAL number of samples ever received for
            this subscription, NOT the buffer length — once the buffer
            fills, len(buf) sticks at window_size forever and the
            number becomes useless. The total counter shows live
            activity: you can see at a glance which subscriptions are
            firing and which are stuck.
        Non-numeric rows: write ``-`` in every stat cell.
        Stats are NOT computed for hidden columns — but writing the
        cells is so cheap (a few str() calls) that we always do it,
        keeping the code branchless.
        """
        buf = self._stats_buffers.get(node_id)
        if buf and len(buf) > 0:
            mn = min(buf)
            mx = max(buf)
            mean = sum(buf) / len(buf)
            cnt = self._stats_total_counts.get(node_id, len(buf))
            self.subscription_table.setItem(row, 4, QTableWidgetItem(self._fmt_stat(mn)))
            self.subscription_table.setItem(row, 5, QTableWidgetItem(self._fmt_stat(mx)))
            self.subscription_table.setItem(row, 6, QTableWidgetItem(self._fmt_stat(mean)))
            self.subscription_table.setItem(row, 7, QTableWidgetItem(str(cnt)))
        else:
            # Non-numeric or no samples yet -> placeholder.
            for col in (4, 5, 6, 7):
                self.subscription_table.setItem(row, col, QTableWidgetItem("-"))

    def _apply_subscription_stats_visibility(self):
        """Show/hide the four stat columns based on self.subscription_stats."""
        enabled = bool(self.subscription_stats.get("enabled"))
        flags = (
            ("show_min", 4),
            ("show_max", 5),
            ("show_mean", 6),
            ("show_count", 7),
        )
        for key, col in flags:
            hidden = not (enabled and bool(self.subscription_stats.get(key, True)))
            self.subscription_table.setColumnHidden(col, hidden)

    def _refresh_all_stats(self):
        """Re-render the stat cells for every row. Used after a Prefs
        change so the table reflects the new config immediately."""
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if not item:
                continue
            node_id = item.data(Qt.ItemDataRole.UserRole)
            if node_id is None:
                continue
            self._refresh_stats_cells_for_row(row, node_id)

    def set_subscription_stats(self, config: Dict[str, Any]):
        """Apply a new stats config: visibility, per-column toggles,
        window size. Resizes existing buffers if the window shrank or
        grew. Called from the Preferences dialog's Apply path."""
        old_window = int(self.subscription_stats.get("window_size") or 1000)
        self.subscription_stats.update(config)
        new_window = int(self.subscription_stats.get("window_size") or 1000)
        if new_window != old_window:
            # Resize every buffer in place — deque doesn't support
            # maxlen change, so rebuild from the existing samples.
            for nid, buf in list(self._stats_buffers.items()):
                self._stats_buffers[nid] = deque(buf, maxlen=new_window)
        self._apply_subscription_stats_visibility()
        self._refresh_all_stats()
        self._resize_subscription_columns()

    def is_subscribed(self, node_id: str) -> bool:
        """Check whether the given node_id is already in the subscription table."""
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if not item:
                continue
            stored_node_id = item.data(Qt.ItemDataRole.UserRole)
            if stored_node_id == node_id or item.text() == node_id:
                return True
        return False

    def _current_status_text(self):
        mode = getattr(self.worker, "monitor_mode", None)
        if mode is None:
            mode = "polling" if self.polling_radio.isChecked() else "subscription"
        return "Polling" if mode == "polling" else "Subscribed"

    def _set_status_for_row(self, row, text: Optional[str] = None):
        status_text = text if text is not None else self._current_status_text()
        self.subscription_table.setItem(row, 3, QTableWidgetItem(status_text))

    def _refresh_status_column(self):
        for row in range(self.subscription_table.rowCount()):
            self._set_status_for_row(row)

    def toggle_recording(self):
        if self.recording:
            self.stop_recording()
        else:
            self.start_recording()

    def start_recording(self):
        if self.subscription_table.rowCount() == 0:
            QMessageBox.warning(self, "Recording", "No subscriptions to record.")
            self.record_button.setChecked(False)
            return

        try:
            rate = float(self.record_rate_input.text())
        except Exception:
            rate = 1.0
        if rate <= 0:
            rate = 1.0
        # Floor at 1 ms so the full 0.1..999.9 Hz input range is honoured
        # (999.9 Hz -> ~1 ms). A 50 ms floor would silently cap at 20 Hz.
        interval_ms = max(1, int(1000 / rate))

        mode = self.record_mode_combo.currentText()
        try:
            limit_val = float(self.record_limit_input.text())
        except Exception:
            limit_val = 0.0
        if limit_val < 0:
            limit_val = 0.0

        # Pull dir+filename from the combined Output File field (legacy
        # inputs are kept in sync via _sync_legacy_record_inputs()).
        self._sync_legacy_record_inputs()
        directory = Path(self.record_dir_input.text()).expanduser()
        try:
            directory.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            QMessageBox.warning(self, "Recording", f"Could not create directory:\n{e}")
            self.record_button.setChecked(False)
            return

        fixed = self.record_fixed_check.isChecked()
        filename = self.record_filename_input.text().strip()
        if not fixed or not filename:
            # Fresh timestamped filename (default behavior).
            filename = self._default_record_filename()
            self._set_record_filename(filename)
        # CSV-only: enforce the extension even if the user typed something
        # else directly into the filename field (e.g. a .dat from an old run).
        else:
            csv_name = _ensure_csv_suffix(filename)
            if csv_name != filename:
                filename = csv_name
                self._set_record_filename(filename)
        # Recording always writes a fresh CSV (truncate + header). Append
        # mode was dropped because subscription changes between sessions
        # would silently produce a CSV with mismatched columns.
        self.record_file_path = directory / filename
        self.record_nodes = self._collect_record_nodes()
        self._prime_latest_values()

        if not self._ensure_record_file():
            self.record_button.setChecked(False)
            return

        self.recording = True
        self.record_samples = 0
        self.record_start_time = time.time()
        self.record_limit_value = limit_val
        self.record_button.setText("Recording")
        self.record_button.setChecked(True)
        self.record_timer.start(interval_ms)
        self._update_record_status_label()

    def stop_recording(self):
        self.recording = False
        self.record_timer.stop()
        self.record_button.setText("Record")
        self.record_button.setChecked(False)
        self.record_file_path = None
        self.record_samples = 0
        self.record_limit_value = 0.0
        # Refresh the default filename for the next session unless Fixed
        # is checked — Fixed mode keeps the same filename so the next
        # recording overwrites this one.
        if not self.record_fixed_check.isChecked():
            self._set_record_filename(self._default_record_filename())
        self.record_status_label.setText("Ready")

    def _record_tick(self):
        if not self.recording or not self.record_file_path:
            return

        mode = self.record_mode_combo.currentText()
        now = time.time()
        # Check limits before writing
        if mode == "Period of time" and self.record_limit_value > 0:
            if now - self.record_start_time >= self.record_limit_value:
                self.stop_recording()
                return
        if mode == "Samples" and self.record_limit_value > 0:
            if self.record_samples >= self.record_limit_value:
                self.stop_recording()
                return

        try:
            with self.record_file_path.open("a", newline="", encoding="utf-8") as f:
                # lineterminator="\n" overrides csv.excel's default of
                # "\r\n" — RFC-4181-compliant but the trailing ^M shows
                # up as garbage in Linux text viewers (cat, less, tail).
                # We're on a Linux filesystem so \n is the right choice.
                writer = csv.writer(f, lineterminator="\n")
                ts_value = f"{now:.6f}"
                row_values = []
                for node_id in self.record_nodes:
                    latest = self.latest_values.get(node_id, {})
                    val = latest.get("value")
                    if val is None:
                        # Fallback to table cell if available
                        val = ""
                        item = self._find_row_item(node_id, 1)
                        if item:
                            val = item.text()
                    row_values.append(val)
                writer.writerow([ts_value] + row_values)
                self.record_samples += 1
        except Exception as e:
            QMessageBox.warning(self, "Recording", f"Error writing recording:\n{e}")
            self.stop_recording()
            return

        # Check limits after writing
        if mode == "Samples" and self.record_limit_value > 0 and self.record_samples >= self.record_limit_value:
            self.stop_recording()
        if mode == "Period of time" and self.record_limit_value > 0 and (time.time() - self.record_start_time) >= self.record_limit_value:
            self.stop_recording()
        self._update_record_status_label()

    def _ensure_record_file(self) -> bool:
        try:
            # Always truncate-and-write a fresh header. Fixed mode means
            # "same filename" — successive Records still overwrite cleanly
            # so the resulting CSV always matches the current subscriptions.
            with self.record_file_path.open("w", newline="", encoding="utf-8") as f:
                header_lines = self._record_header_lines()
                for line in header_lines:
                    f.write(f"{line}\n")
            return True
        except Exception as e:
            QMessageBox.warning(self, "Recording", f"Could not open file for recording:\n{e}")
            return False

    def _default_record_filename(self) -> str:
        ts = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
        return f"UaExplorer_{ts}.csv"

    def _set_record_filename(self, filename: str) -> None:
        """Set the record filename input and anchor the view at the start.

        QLineEdit.setText scrolls the view so the cursor (placed at end)
        is visible. For long filenames that overflow the field, we want
        users to see the leading prefix instead, so we move the cursor
        back to position 0 after assigning the text. Also updates the
        visible basename field so the user sees the change.
        """
        self.record_filename_input.setText(filename)
        self.record_filename_input.setCursorPosition(0)
        if hasattr(self, "record_output_input"):
            self._set_record_output_basename(filename)

    def _set_record_output_basename(self, basename: str) -> None:
        """Write a basename into the visible Output File field without
        retriggering editingFinished syncs. The directory is implied by
        record_dir_input."""
        if not hasattr(self, "record_output_input"):
            return
        self.record_output_input.blockSignals(True)
        self.record_output_input.setText(basename)
        self.record_output_input.setCursorPosition(0)
        self.record_output_input.blockSignals(False)

    # Back-compat alias: a few call sites still pass a full path string.
    # Treat it as basename (last path component) — directory parts are
    # ignored now that the field is basename-only.
    def _set_record_output_path(self, path_str: str) -> None:
        self._set_record_output_basename(Path(path_str).name or path_str)

    def _sync_legacy_record_inputs(self):
        """Push the visible basename field back into the legacy
        record_filename_input used by start_recording() and Preferences.
        Directory parts in the field (if any) are stripped — only the
        basename is meaningful here; set the directory via Preferences."""
        text = self.record_output_input.text().strip()
        if not text:
            return
        basename = Path(text).name or text
        if basename != text:
            # Normalise the field if the user pasted a full path.
            self._set_record_output_basename(basename)
        self.record_filename_input.setText(basename)
        self.record_filename_input.setCursorPosition(0)

    def _apply_record_fixed_state(self):
        """Toggle the path field's read-only state based on the Fixed
        checkbox. When Fixed is off (default), the field shows a fresh
        timestamped name on each Record — read-only. When Fixed is on,
        the user can edit the filename (or pick via [...]); subsequent
        Records overwrite that same file."""
        fixed = self.record_fixed_check.isChecked()
        self.record_output_input.setReadOnly(not fixed)
        self.record_output_browse_btn.setEnabled(fixed)

    def _on_record_fixed_toggled(self, _checked: bool):
        self._apply_record_fixed_state()

    def _on_browse_record_output(self):
        """Pick a CSV output filename. Defaults to the recording directory
        and the current basename. Only the basename of the selection is
        stored — the directory is set via Preferences → Recording.

        Uses an explicit QFileDialog (rather than the static helper) so
        we can control the opening size — Qt otherwise reuses its last
        remembered geometry, which is often unnecessarily wide."""
        directory = self.record_dir_input.text().strip() or str(Path.home())
        current = self.record_output_input.text().strip()
        start_path = str(Path(directory) / current) if current else directory
        dialog = QFileDialog(self, "Output File", start_path)
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        # Recordings are CSV-only: no format choice in the picker.
        dialog.setNameFilters(["CSV files (*.csv)"])
        dialog.setDefaultSuffix("csv")
        dialog.setOption(QFileDialog.Option.DontConfirmOverwrite, True)
        # Use Qt's own dialog widget — the native (GTK/KDialog) backends
        # often ignore resize() and reuse a too-wide remembered geometry.
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        # Force a .csv extension regardless of what the user typed
        # (foo -> foo.csv, foo.dat -> foo.csv) — recordings are CSV-only.
        basename = _ensure_csv_suffix(Path(files[0]).name)
        self._set_record_output_basename(basename)
        self._sync_legacy_record_inputs()

    def _on_load_recording(self):
        """Browse an existing recording file and open it in the built-in
        read-only viewer. Does not modify the Output File field."""
        start_dir = self.record_dir_input.text().strip() or str(Path.home())
        dialog = QFileDialog(self, "View Recording", start_dir)
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["CSV files (*.csv)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        viewer = _FileViewerDialog(Path(files[0]), title="View Recording", parent=self)
        viewer.exec()

    def _strip_legacy_millis_suffix(self, filename: str) -> str:
        """Strip the legacy '.NNN' millisecond infix from a record filename.

        Older versions of UaExplorer baked a 3-digit millisecond fragment
        into the default filename (e.g. UaExplorer_<ts>.123.csv). The
        millisecond infix has been dropped, but a settings file from an
        earlier run may still hold one. Normalise it on read.
        """
        return re.sub(r"_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})\.\d{3}\.csv$",
                      r"_\1.csv", filename)

    def _on_record_mode_changed(self, mode_text: str):
        if mode_text == "Period of time":
            self.record_limit_label.setText("Seconds")
        elif mode_text == "Samples":
            self.record_limit_label.setText("Samples")
        else:
            self.record_limit_label.setText("Limit")
        self._update_record_status_label()

    def set_visible_subscription_rows(self, n: int):
        """Set the MINIMUM number of subscription rows visible in the
        table. The table keeps its Expanding size policy, so growing the
        panel (e.g. dragging the main splitter handle down) lets the
        table grow with the available space — extra rows show up
        automatically. Shrinking the panel below ``n`` rows is still
        allowed: the user can drag the splitter to a smaller height.

        ``n`` is therefore a starting-size hint, not a hard cap."""
        n = max(1, int(n))
        self._visible_subscription_rows = n
        table = self.subscription_table
        header = table.horizontalHeader()
        header_h = header.height() if header.isVisible() else header.sizeHint().height()
        row_h = table.verticalHeader().defaultSectionSize()
        # +6 leaves room for the table frame and horizontal scrollbar margin.
        min_h = header_h + row_h * n + 6
        # Clear any previously-set fixed height so Expanding can grow the
        # table when extra vertical space appears in the panel.
        table.setMaximumHeight(16777215)  # Qt's QWIDGETSIZE_MAX
        table.setMinimumHeight(min_h)

    def get_visible_subscription_rows(self) -> int:
        return self._visible_subscription_rows

    def export_recording_settings(self) -> Dict[str, Any]:
        return {
            "rate_hz": self.record_rate_input.text(),
            "mode": self.record_mode_combo.currentText(),
            "limit": self.record_limit_input.text(),
            "filename": self.record_filename_input.text(),
            "directory": self.record_dir_input.text(),
            "output_dir": self.record_dir_input.text(),
            "fixed": self.record_fixed_check.isChecked(),
            "visible_rows": self._visible_subscription_rows,
        }

    def export_monitor_settings(self) -> Dict[str, Any]:
        return {
            "mode": "polling" if self.polling_radio.isChecked() else "subscription",
            "polling_interval": self.polling_interval_input.text(),
        }

    def apply_recording_settings(self, settings: Dict[str, Any]):
        if not isinstance(settings, dict):
            return
        rate = settings.get("rate_hz")
        if isinstance(rate, str):
            self.record_rate_input.setText(rate)
        mode = settings.get("mode")
        if isinstance(mode, str):
            # Migrate legacy persisted value: the combo entry was renamed
            # from "Number of samples" to "Samples" for compactness.
            if mode == "Number of samples":
                mode = "Samples"
            if mode in [self.record_mode_combo.itemText(i) for i in range(self.record_mode_combo.count())]:
                self.record_mode_combo.setCurrentText(mode)
        limit = settings.get("limit")
        if isinstance(limit, str):
            self.record_limit_input.setText(limit)
        filename = settings.get("filename")
        if isinstance(filename, str) and filename.strip():
            if filename.startswith("UaGateway_"):
                filename = UaExplorer._default_record_filename(self)
            else:
                filename = self._strip_legacy_millis_suffix(filename)
            self._set_record_filename(filename)
        directory = settings.get("directory") or settings.get("output_dir")
        if isinstance(directory, str) and directory.strip():
            self.record_dir_input.setText(directory)
        # Restore Fixed checkbox state. Older settings used the inverse
        # "auto" boolean — migrate by negating it.
        fixed = settings.get("fixed")
        if not isinstance(fixed, bool):
            auto = settings.get("auto")
            if isinstance(auto, bool):
                fixed = not auto
        if isinstance(fixed, bool):
            self.record_fixed_check.setChecked(fixed)
            self._apply_record_fixed_state()
        # Restore visible subscription rows.
        visible_rows = settings.get("visible_rows")
        if isinstance(visible_rows, int):
            self.set_visible_subscription_rows(visible_rows)
        # Sync the combined Output File field from the restored dir+name.
        if hasattr(self, "record_output_input"):
            self._set_record_output_path(
                str(Path(self.record_dir_input.text()) / self.record_filename_input.text())
            )

    def apply_monitor_settings(self, settings: Dict[str, Any]):
        if not isinstance(settings, dict):
            return
        interval = settings.get("polling_interval")
        if isinstance(interval, str):
            self.polling_interval_input.setText(interval)

        mode = settings.get("mode")
        if mode == "polling":
            self.polling_radio.setChecked(True)
        elif mode == "subscription":
            self.subscription_radio.setChecked(True)

    def set_current_uri(self, uri: str):
        self.current_uri = uri or ""

    def _collect_record_nodes(self) -> List[str]:
        nodes = []
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if not item:
                continue
            node_id = item.data(Qt.ItemDataRole.UserRole) or item.text()
            nodes.append(node_id)
        return nodes

    def _prime_latest_values(self):
        """Prime latest values from the table so initial rows are populated."""
        for row in range(self.subscription_table.rowCount()):
            node_item = self.subscription_table.item(row, 0)
            val_item = self.subscription_table.item(row, 1)
            if not node_item:
                continue
            node_id = node_item.data(Qt.ItemDataRole.UserRole) or node_item.text()
            if not node_id:
                continue
            val_text = val_item.text() if val_item else ""
            self.latest_values[node_id] = {"value": val_text, "timestamp": datetime.now()}

    def _update_record_status_label(self):
        if not self.recording:
            self.record_status_label.setText("Ready")
            return
        self.record_status_label.setText(f"Recording | Samples: {self.record_samples}")

    def _find_row_item(self, node_id: str, column: int) -> Optional[QTableWidgetItem]:
        for row in range(self.subscription_table.rowCount()):
            item = self.subscription_table.item(row, 0)
            if not item:
                continue
            stored_node_id = item.data(Qt.ItemDataRole.UserRole) or item.text()
            if stored_node_id == node_id:
                return self.subscription_table.item(row, column)
        return None

    def _record_header_lines(self) -> List[str]:
        now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
        lines = [
            "# Generated by UA Explorer",
            f"# Date (UTC): {now}",
            f"# Server URI: {self.current_uri or 'N/A'}",
            f"# Recording rate (Hz): {self.record_rate_input.text()}",
            f"# Recording mode: {self.record_mode_combo.currentText()}",
            f"# Recording limit: {self.record_limit_input.text()}",
            f"# Output file: {self.record_file_path}",
        ]
        lines.append("# " + ",".join(self._data_header_fields()))
        return lines

    def _data_header_fields(self) -> List[str]:
        headers = ["TimeStamp"]
        for node_id in self.record_nodes:
            headers.append(self.extract_node_identifier(node_id))
        return headers


class LogWidget(QWidget):
    # Emitted when the user clicks the [X] in the corner — the parent
    # is responsible for hiding/dismissing the widget. Keeping the
    # actual hide logic outside this widget lets the main window
    # decide whether to suppress, undock, or close.
    close_requested = pyqtSignal()
    # Asks the parent to toggle dock state (undock if docked, dock if
    # already undocked). The actual dock/undock logic lives on the main
    # window so it can update related state (menu labels, persisted
    # log_undocked flag, etc.).
    undock_requested = pyqtSignal()

    def __init__(self):
        super().__init__()
        self.setup_ui()
        self.max_buffer = 1000  # Internal buffer limit
        self.log_lines = []
        self.current_level = "INFO"
        # Inline text filter (substring AND, '+' separated). Empty = off.
        # Layered on top of current_level — both must accept a line.
        self.text_filter: str = ""
        self.update_enabled = True
        self.user_scrolled = False

    def setup_ui(self):
        layout = QVBoxLayout()
        # Hug the panel boundaries — no top margin so the controls row
        # sits flush against the splitter handle above, no bottom margin
        # so the log_display sits flush against the main window's status
        # bar area. Side margins kept small (2px) just so text doesn't
        # touch the window border.
        layout.setContentsMargins(2, 0, 2, 0)
        layout.setSpacing(2)

        controls_layout = QHBoxLayout()
        controls_layout.setSpacing(4)

        # Log level dropdown — no leading "Log Level:" label, the combo's
        # current value (DEBUG / INFO / WARNING / ERROR) is self-evident.
        # Tooltip carries the descriptive name for users who want it.
        self.level_combo = QComboBox()
        self.level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR"])
        self.level_combo.setCurrentText("INFO")
        self.level_combo.setToolTip("Minimum log level to display")
        self.level_combo.currentTextChanged.connect(self.filter_changed)
        controls_layout.addWidget(self.level_combo)

        # Inline text filter, layered on top of the level dropdown. Same
        # syntax as the Node Browser filter: substring match,
        # case-insensitive, '+' means AND. Empty = no extra filtering.
        # Matches against the full rendered line so users can type "info"
        # or "[DATA]" or "[script]" or any message substring.
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("filter... (a+b = AND)")
        self.filter_input.setToolTip(
            "Substring filter on log lines (case-insensitive).\n"
            "Multiple terms separated by '+' are AND-combined.\n"
            "Applied on top of the Log Level dropdown."
        )
        self.filter_input.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
        )
        self.filter_input.setMaximumWidth(220)
        self.filter_input.textChanged.connect(self._on_text_filter_changed)
        controls_layout.addWidget(self.filter_input)

        # Tiny [X] clear button next to the filter (same idiom as Node
        # Browser). Styled per theme so it doesn't look like a regular
        # button.
        self.filter_clear_button = make_compact_button(
            "X", tip="Clear the log filter")
        self.filter_clear_button.setFixedWidth(22)

        def _style_filter_clear():
            t = THEME.active
            self.filter_clear_button.setStyleSheet(
                f"QPushButton {{ padding: 2px 0; font-size: 11px;"
                f" border: 1px solid {t.border}; border-radius: 4px;"
                f" background: {t.panel_bg}; color: {t.text_primary};"
                f" min-height: 18px; max-height: 22px; }}"
                f"QPushButton:hover {{ background: {t.status_error};"
                f" color: {t.text_on_accent}; border-color: {t.border_strong}; }}"
                f"QPushButton:pressed {{ background: {t.panel_pressed_bg}; }}"
            )
        _style_filter_clear()
        THEME.theme_changed.connect(lambda _t: _style_filter_clear())
        self.filter_clear_button.clicked.connect(self.filter_input.clear)
        controls_layout.addWidget(self.filter_clear_button)

        # Match the Node Browser's button styling (compact bevel + theme).
        self.clear_button = make_compact_button(
            "Clear", tip="Clear the log display")
        self.clear_button.clicked.connect(self.clear_log)
        controls_layout.addWidget(self.clear_button)

        self.update_button = make_compact_button(
            "Pause", tip="Pause or resume live log updates")
        self.update_button.clicked.connect(self.toggle_update)
        controls_layout.addWidget(self.update_button)

        controls_layout.addStretch()

        # Compact [U] button — undocks/docks the log panel. Same idiom
        # as the menu "Undock log" / "Dock log" action; provided here
        # so the user can toggle without going through the panel menu.
        self.undock_button = QPushButton("U")
        self.undock_button.setFixedSize(22, 22)
        self.undock_button.setToolTip("Undock the log panel (toggle: dock when undocked)")
        self.undock_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self._apply_undock_button_style()
        THEME.theme_changed.connect(lambda _t: self._apply_undock_button_style())
        self.undock_button.clicked.connect(self.undock_requested.emit)
        controls_layout.addWidget(self.undock_button)

        # Compact [X] button on the right — hides the log panel.
        # The main window restores it from the View menu / log toggle.
        # ASCII 'X' (not unicode ✕) so it always renders, even on
        # systems whose default font lacks the unicode glyph.
        self.close_button = QPushButton("X")
        self.close_button.setFixedSize(22, 22)
        self.close_button.setToolTip("Hide log panel (re-enable from the log toggle)")
        self.close_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self._apply_close_button_style()
        THEME.theme_changed.connect(lambda _t: self._apply_close_button_style())
        self.close_button.clicked.connect(self.close_requested.emit)
        controls_layout.addWidget(self.close_button)

        layout.addLayout(controls_layout)

        self.log_display = QTextEdit()
        self.log_display.setReadOnly(True)
        # Smaller font so more log lines fit in the fixed-height panel.
        # Matches the Node Properties text size already used elsewhere.
        log_font = QFont(QApplication.font())
        log_font.setPointSize(8)
        self.log_display.setFont(log_font)
        self._visible_lines = 4
        self.set_visible_lines(self._visible_lines)

        # Connect scroll bar to detect user scrolling
        scrollbar = self.log_display.verticalScrollBar()
        scrollbar.valueChanged.connect(self.on_scroll_changed)

        layout.addWidget(self.log_display)

        self.setLayout(layout)

    def _apply_undock_button_style(self) -> None:
        """Style the [U] button. Same round shape as the close button but
        hover uses the theme accent (not status_error) — undocking isn't
        destructive."""
        t = THEME.active
        self.undock_button.setStyleSheet(
            f"QPushButton {{ border: 1px solid {t.border}; border-radius: 11px;"
            f" padding: 0; color: {t.text_primary};"
            f" background: {t.panel_bg}; }}"
            f"QPushButton:hover {{ background: {t.accent};"
            f" color: {t.text_on_accent}; }}"
        )

    def _apply_close_button_style(self) -> None:
        t = THEME.active
        self.close_button.setStyleSheet(
            f"QPushButton {{ border: 1px solid {t.border}; border-radius: 11px;"
            f" padding: 0; color: {t.text_primary};"
            f" background: {t.panel_bg}; }}"
            f"QPushButton:hover {{ background: {t.status_error};"
            f" color: {t.text_on_accent}; }}"
        )

    def set_visible_lines(self, n: int):
        """Set the MINIMUM number of log rows visible. The log_display
        keeps its Expanding size policy so resizing the host (e.g.
        dragging the main splitter, or resizing the undocked log window)
        grows the visible area — extra rows appear automatically.

        ``n`` is a starting-size hint, not a hard cap. Internal buffer
        (``max_buffer``) is unaffected — older lines still scroll out
        of view if they don't fit."""
        n = max(1, int(n))
        self._visible_lines = n
        fm = self.log_display.fontMetrics()
        # Line height * n, plus a small margin for the QTextEdit frame
        # and document padding. Empirically ~12px is enough.
        min_h = int(fm.lineSpacing() * n + 12)
        # Make sure Expanding can grow the widget (clear any cap that
        # a prior setFixedHeight might have left), then set the floor.
        self.log_display.setMaximumHeight(16777215)  # QWIDGETSIZE_MAX
        self.log_display.setMinimumHeight(min_h)
        self.log_display.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
        )

    def get_visible_lines(self) -> int:
        return self._visible_lines

    def on_scroll_changed(self, value):
        """Detect if user has scrolled away from bottom"""
        scrollbar = self.log_display.verticalScrollBar()
        # If not at bottom and update is enabled, user has scrolled
        if value < scrollbar.maximum() and self.update_enabled:
            self.user_scrolled = True
        else:
            self.user_scrolled = False

    def add_log_message(self, level, message):
        if not self.update_enabled:
            return

        # Include milliseconds so closely-spaced events (subscription
        # bursts, fast script ops) are distinguishable in the log.
        timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        log_entry = f"[{timestamp}] {level}: {message}"

        self.log_lines.append((level, log_entry))

        # Keep internal buffer at max 1000 lines
        if len(self.log_lines) > self.max_buffer:
            self.log_lines = self.log_lines[-self.max_buffer:]

        self.update_display()

    def update_display(self):
        level_priority = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
        current_priority = level_priority.get(self.current_level, 1)

        # Pre-parse the text filter into lowercase AND terms. '+' is the
        # AND separator (same idiom as the Node Browser inline filter).
        # Empty terms (e.g. trailing '+') are skipped, so "info+" still
        # matches "info".
        terms: List[str] = []
        if self.text_filter:
            terms = [t for t in (s.strip().lower() for s in self.text_filter.split("+")) if t]

        def keep(level: str, entry: str) -> bool:
            if level_priority.get(level, 1) < current_priority:
                return False
            if terms:
                line_lc = entry.lower()
                if not all(t in line_lc for t in terms):
                    return False
            return True

        display_lines = [entry for level, entry in self.log_lines if keep(level, entry)]

        self.log_display.setText("\n".join(display_lines))

        # Auto-scroll to bottom only if user hasn't scrolled or if paused
        if not self.user_scrolled or not self.update_enabled:
            scrollbar = self.log_display.verticalScrollBar()
            scrollbar.setValue(scrollbar.maximum())

    def filter_changed(self, level):
        self.current_level = level
        self.update_display()

    def _on_text_filter_changed(self, text: str):
        """Live-update the displayed log lines as the user types."""
        self.text_filter = text or ""
        self.update_display()

    def clear_log(self):
        """Clear the log buffer AND the inline text filter.

        Clearing only the buffer left users wondering why fresh log lines
        didn't appear when a filter was active — Clear is meant to give
        a clean slate, so we wipe both. The level dropdown is left alone
        (changing severity is a more deliberate user choice)."""
        self.log_lines.clear()
        self.log_display.clear()
        self.user_scrolled = False
        # Clearing the field fires _on_text_filter_changed via textChanged,
        # which resets self.text_filter and re-renders (an empty render).
        if hasattr(self, "filter_input"):
            self.filter_input.clear()

    def toggle_update(self):
        self.update_enabled = not self.update_enabled
        self.update_button.setText("Resume" if not self.update_enabled else "Pause")
        if self.update_enabled:
            # When resuming, reset scroll state and show latest
            self.user_scrolled = False
            self.update_display()

class NodeTreeWidget(QTreeWidget):
    method_invocation_requested = pyqtSignal(dict)
    plot_requested = pyqtSignal(dict)

    # Custom MIME type used when dragging variable nodes from the tree
    # to the Subscriptions table. The data is a UTF-8 newline-separated
    # list of node IDs (paths) — same identifier the worker uses for
    # subscribe_to_node_async.
    NODES_MIME_TYPE = "application/x-uaexplorer-nodes"

    def __init__(self, worker, subscription_widget):
        super().__init__()
        self.worker = worker
        self.subscription_widget = subscription_widget
        self.nodes_data = {}
        self._pending_expand = set()
        self._expand_all_mode = False
        # When True, on_item_expanded does NOT trigger lazy loading — used by
        # expand_loaded_tree so the toolbar 'Expand' never loads new nodes.
        self._suppress_expand_load = False
        self._frontier_pending = set()
        # Load-on-scroll: auto-load not-loaded frontier nodes as they scroll into
        # view. Enabled by default; debounced so a fast scroll doesn't fire a
        # request per pixel, and capped per tick so it never floods the server.
        self._autoload_on_scroll = True
        self.setup_ui()

    def setup_ui(self):
        self.setHeaderLabel("Node Tree")
        # Compact font + row padding so more nodes fit per screen.
        apply_compact_tree_style(self)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.show_context_menu)
        self.itemExpanded.connect(self.on_item_expanded)

        # Enable multi-selection
        self.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)

        # Drag-out support: users can drag variable nodes onto the
        # Subscriptions table to subscribe them. We only allow drag,
        # never drop (the tree itself is read-only structurally).
        self.setDragEnabled(True)
        self.setDragDropMode(QTreeWidget.DragDropMode.DragOnly)

        # Add keyboard shortcuts
        self.subscribe_shortcut = QShortcut(QKeySequence("S"), self)
        self.subscribe_shortcut.activated.connect(self.subscribe_selected_nodes)

        # Load-on-scroll: a debounce timer collapses a burst of scroll events
        # into one viewport scan (see _autoload_visible_frontier). Scrolling,
        # expanding and resizing all restart it.
        self._autoload_timer = QTimer(self)
        self._autoload_timer.setSingleShot(True)
        self._autoload_timer.setInterval(150)
        self._autoload_timer.timeout.connect(self._autoload_visible_frontier)
        self.verticalScrollBar().valueChanged.connect(self._schedule_autoload)
        self.itemExpanded.connect(lambda _i: self._schedule_autoload())

    def mimeTypes(self) -> List[str]:
        # Tell Qt which MIME types this tree can produce.
        return [self.NODES_MIME_TYPE]

    def mimeData(self, items) -> QMimeData:
        """Package the selected items as a list of node IDs.

        Only Variable nodes are included — Objects and Methods cannot
        be subscribed to, so dropping them on the Subscriptions table
        would be a no-op. Filtering at the source means the drop
        cursor / no-drop indicator is correct without the target
        having to inspect the payload twice.
        """
        node_ids: List[str] = []
        for item in items:
            if item is None or _is_placeholder_text(item.text(0)):
                continue
            data = item.data(0, Qt.ItemDataRole.UserRole)
            if not isinstance(data, dict):
                continue
            if data.get("node_class") != ua.NodeClass.Variable:
                continue
            nid = data.get("path") or data.get("node_id")
            if nid:
                node_ids.append(str(nid))
        mime = QMimeData()
        if node_ids:
            mime.setData(
                self.NODES_MIME_TYPE,
                "\n".join(node_ids).encode("utf-8"),
            )
        return mime

    def subscribe_selected_nodes(self):
        """Subscribe to all selected variable nodes"""
        selected_items = self.selectedItems()
        variable_nodes = []

        for item in selected_items:
            if _is_placeholder_text(item.text(0)):
                continue

            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            # Use path as the identifier (path is the cache key)
            node_id = node_data.get('path') or node_data.get('node_id') if node_data else None
            if (node_data and
                node_data.get('node_class') == ua.NodeClass.Variable and
                node_id and not self.subscription_widget.is_subscribed(node_id)):
                variable_nodes.append(node_data)

        if variable_nodes and self.worker:
            for node_data in variable_nodes:
                node_id = node_data.get('path') or node_data.get('node_id')
                self.worker.subscribe_to_node_async(node_id)
                self.subscription_widget.add_subscription(node_id, node_data['display_name'])

    def populate_tree(self, nodes_dict):
        self.setUpdatesEnabled(False)
        self.blockSignals(True)
        try:
            self.clear()
            self.nodes_data = nodes_dict or {}

            if not self.nodes_data:
                return

            def _label_for_node(node_id):
                node = self.nodes_data.get(node_id, {})
                return node.get("display_name") or node.get("browse_name") or node_id

            def _sort_key(node_id):
                node = self.nodes_data.get(node_id, {})
                node_class = node.get("node_class")
                # Methods first, then Variables, then everything else (Objects/Folders)
                if node_class == ua.NodeClass.Method:
                    bucket = 0
                elif node_class == ua.NodeClass.Variable:
                    bucket = 1
                else:
                    bucket = 2
                return (bucket, (_label_for_node(node_id) or "").lower())

            # Build parent -> children map; treat unknown parents as roots
            children_map = {}
            for node_id, node_data in self.nodes_data.items():
                parent_id = node_data.get("parent_id")
                if parent_id not in self.nodes_data:
                    parent_id = None
                children_map.setdefault(parent_id, []).append(node_id)

            def _build_items(node_id):
                node_data = self.nodes_data.get(node_id, {})
                item = QTreeWidgetItem([_label_for_node(node_id)])
                item.setData(0, Qt.ItemDataRole.UserRole, node_data)
                self._apply_node_style(item, node_data)

                children_ids = sorted(children_map.get(node_id, []), key=_sort_key)
                if children_ids:
                    node_data["children_loaded"] = True
                    node_data["children"] = {cid: self.nodes_data.get(cid, {}) for cid in children_ids}
                    node_data["has_children"] = True
                    item.setData(0, Qt.ItemDataRole.UserRole, node_data)
                    for child_id in children_ids:
                        child_item = _build_items(child_id)
                        item.addChild(child_item)
                elif node_data.get("has_children") and not node_data.get("children_loaded"):
                    # Lazy Node Loading frontier: node may have children that
                    # weren't browsed (partial load). Show an expand arrow via a
                    # placeholder; on_item_expanded browses it on demand. Don't
                    # recurse — that's the whole point of staying partial.
                    dummy = QTreeWidgetItem([PLACEHOLDER_NOT_LOADED])
                    dummy.setData(0, Qt.ItemDataRole.UserRole, None)
                    item.addChild(dummy)
                else:
                    node_data["children_loaded"] = False
                    node_data["children"] = {}
                    item.setData(0, Qt.ItemDataRole.UserRole, node_data)
                return item

            root_ids = sorted(children_map.get(None, []), key=_sort_key)
            logging.getLogger(__name__).debug(
                "populate_tree: %d cached nodes, %d top-level roots",
                len(self.nodes_data), len(root_ids))
            for root_id in root_ids:
                self.addTopLevelItem(_build_items(root_id))

            # Auto-expanding the top level is cheap when there are a handful of
            # roots, but a View/filter can re-root thousands of nodes to the top
            # level (filter_nodes_by_scope re-parents survivors whose parent was
            # filtered out). Expanding + rendering thousands of rows at once
            # freezes the GUI in font layout (FreeType glyph hinting). Skip the
            # auto-expand above a threshold — the user can expand what they need.
            if len(root_ids) <= 200:
                self.expandToDepth(0)
        finally:
            self.blockSignals(False)
            self.setUpdatesEnabled(True)
        # Kick off load-on-scroll for the first screenful (no manual scroll
        # needed to start filling visible frontier nodes).
        self._schedule_autoload()

    def on_item_expanded(self, item):
        # During a bulk "Expand all loaded" operation we must NOT load more —
        # Expand works only on already-loaded nodes (the lazy frontier stays
        # collapsed). A genuine user click still loads on demand.
        if getattr(self, "_suppress_expand_load", False):
            return
        # Fully-materialized nodes need nothing. A Lazy Node Loading frontier
        # node carries a single "Loading..." placeholder child (UserRole=None)
        # and is browsed on demand here.
        if item.childCount() != 1:
            return
        placeholder = item.child(0)
        if placeholder is None or placeholder.data(0, Qt.ItemDataRole.UserRole) is not None:
            return
        node_data = item.data(0, Qt.ItemDataRole.UserRole)
        if not node_data:
            return
        self._request_frontier_load(item, placeholder)

    def _request_frontier_load(self, item, placeholder=None) -> bool:
        """Request the lazy children of a frontier `item` (if it is one).

        Returns True if a load was actually kicked off. Shared by on-expand
        (user click) and load-on-scroll. `placeholder` is the item's single
        "(not loaded)" child; looked up if not supplied. Guards against
        double-requests via _frontier_pending.
        """
        if placeholder is None:
            if item.childCount() != 1:
                return False
            placeholder = item.child(0)
            if placeholder is None or placeholder.data(0, Qt.ItemDataRole.UserRole) is not None:
                return False
        node_data = item.data(0, Qt.ItemDataRole.UserRole)
        if not node_data:
            return False
        parent_path = node_data.get("path") or node_data.get("node_id")
        worker = getattr(self, "worker", None)
        if not parent_path or worker is None:
            return False
        if getattr(self, "_frontier_pending", None) is None:
            self._frontier_pending = set()
        if parent_path in self._frontier_pending:
            return False
        self._frontier_pending.add(parent_path)
        # A real load is now in flight — reflect it in the placeholder text.
        placeholder.setText(0, PLACEHOLDER_LOADING)
        worker.request_frontier_children(parent_path)
        return True

    # Max frontier loads kicked off per scroll tick — bounds the request burst
    # so a fast scroll over many not-loaded nodes doesn't flood the server.
    AUTOLOAD_PER_TICK = 12

    def _schedule_autoload(self, *_):
        """(Re)start the load-on-scroll debounce timer."""
        if getattr(self, "_autoload_on_scroll", False) and hasattr(self, "_autoload_timer"):
            self._autoload_timer.start()

    def _autoload_visible_frontier(self):
        """Auto-load not-loaded frontier nodes currently in the viewport.

        Walks only the visible rows (top of the viewport downward), so we load
        what the user is actually looking at and nothing off-screen. Capped at
        AUTOLOAD_PER_TICK loads per tick; if the viewport was full of frontier
        nodes, the next scroll tick picks up where this one stopped. Loaded
        children arrive async via on_frontier_children_loaded and, being newly
        visible, get scanned again on the next tick.
        """
        if not getattr(self, "_autoload_on_scroll", False):
            return
        if getattr(self, "worker", None) is None:
            return
        if getattr(self, "_suppress_expand_load", False):
            return  # a bulk expand is in progress; don't stampede
        launched = 0
        height = self.viewport().height()
        y = 0
        guard = 0  # hard stop against a non-advancing probe
        while y < height and launched < self.AUTOLOAD_PER_TICK and guard < 10000:
            guard += 1
            item = self.itemAt(0, y)
            if item is None:
                break
            rect = self.visualItemRect(item)
            # Advance past this row for the next probe (min 1px so we progress).
            y = (rect.bottom() + 1) if rect.height() > 0 else (y + 18)
            # A not-loaded frontier item has exactly one child = a placeholder
            # (UserRole=None) labelled PLACEHOLDER_NOT_LOADED. Only those load.
            if item.childCount() == 1:
                child = item.child(0)
                if (child is not None
                        and child.data(0, Qt.ItemDataRole.UserRole) is None
                        and child.text(0) == PLACEHOLDER_NOT_LOADED):
                    if self._request_frontier_load(item, child):
                        launched += 1

    def resizeEvent(self, event):
        # A taller viewport reveals more rows -> rescan for frontier nodes.
        super().resizeEvent(event)
        self._schedule_autoload()

    def on_frontier_children_loaded(self, parent_path, children_dict):
        """Replace a frontier node's "Loading..." placeholder with its browsed
        children (Lazy Node Loading). Children are PATH-keyed, matching the
        tree; each may itself be a frontier (shown expandable)."""
        if getattr(self, "_frontier_pending", None):
            self._frontier_pending.discard(parent_path)
        # Frontier stubs are PATH-keyed, so match on path (not node_id).
        parent_item = self._find_item_by_path(parent_path)
        if parent_item is None:
            return

        self.setUpdatesEnabled(False)
        try:
            parent_item.takeChildren()  # drop the "Loading..." placeholder
            self.nodes_data.update(children_dict)

            def _sort_key(node_data):
                node_class = node_data.get("node_class")
                if node_class == ua.NodeClass.Method:
                    bucket = 0
                elif node_class == ua.NodeClass.Variable:
                    bucket = 1
                else:
                    bucket = 2
                label = (node_data.get("display_name") or node_data.get("browse_name")
                         or node_data.get("path") or "")
                return (bucket, label.lower())

            for _path, child_data in sorted(
                    children_dict.items(), key=lambda kv: _sort_key(kv[1])):
                label = (child_data.get("display_name")
                         or child_data.get("browse_name") or _path)
                child_item = QTreeWidgetItem([label])
                child_item.setData(0, Qt.ItemDataRole.UserRole, child_data)
                self._apply_node_style(child_item, child_data)
                # If this child is itself a frontier, give it a placeholder so it
                # too can be expanded on demand.
                if child_data.get("has_children") and not child_data.get("children_loaded"):
                    dummy = QTreeWidgetItem([PLACEHOLDER_NOT_LOADED])
                    dummy.setData(0, Qt.ItemDataRole.UserRole, None)
                    child_item.addChild(dummy)
                parent_item.addChild(child_item)
        finally:
            self.setUpdatesEnabled(True)
        # Newly-inserted children may themselves be visible frontier nodes —
        # rescan so load-on-scroll keeps filling the viewport without a manual
        # scroll nudge.
        self._schedule_autoload()

    def _find_item_by_path(self, path):
        """Locate a tree item by its cached node PATH (frontier stubs are
        path-keyed). Linear scan; only used on explicit lazy expand."""
        it = QTreeWidgetItemIterator(self)
        while it.value():
            item = it.value()
            data = item.data(0, Qt.ItemDataRole.UserRole)
            if isinstance(data, dict) and data.get("path") == path:
                return item
            it += 1
        return None

    def on_children_loaded(self, parent_node_id, children_dict):
        parent_item = self.find_item_by_node_id(parent_node_id)
        if not parent_item:
            return

        parent_item.takeChildren()

        def _sort_key(node_data):
            node_class = node_data.get("node_class")
            if node_class == ua.NodeClass.Method:
                bucket = 0
            elif node_class == ua.NodeClass.Variable:
                bucket = 1
            else:
                bucket = 2
            label = node_data.get("display_name") or node_data.get("browse_name") or node_data.get("node_id") or ""
            return (bucket, label.lower())

        for child_id, child_data in sorted(children_dict.items(), key=lambda kv: _sort_key(kv[1])):
            child_item = QTreeWidgetItem([child_data['display_name']])
            child_item.setData(0, Qt.ItemDataRole.UserRole, child_data)
            self._apply_node_style(child_item, child_data)
            parent_item.addChild(child_item)

            if child_data.get('has_children', False):
                if child_data.get('children_loaded'):
                    self._populate_child_items(child_item, child_data)
                else:
                    dummy_item = QTreeWidgetItem([PLACEHOLDER_NOT_LOADED])
                    child_item.addChild(dummy_item)

        self.nodes_data.update(children_dict)
        if parent_node_id in self.nodes_data:
            self.nodes_data[parent_node_id]['children'] = children_dict
            self.nodes_data[parent_node_id]['children_loaded'] = True

        parent_data = parent_item.data(0, Qt.ItemDataRole.UserRole)
        if parent_data:
            parent_data['children_loaded'] = True
            parent_data['children'] = children_dict
            parent_item.setData(0, Qt.ItemDataRole.UserRole, parent_data)

        # If this parent was part of a requested full expand, continue expanding its children
        if parent_node_id in self._pending_expand or self._expand_all_mode:
            self._expand_children_recursively(parent_item)
            self._pending_expand.discard(parent_node_id)
            if not self._pending_expand and self._expand_all_mode:
                self._expand_all_mode = False

    def find_item_by_node_id(self, node_id):
        iterator = QTreeWidgetItemIterator(self)
        while iterator.value():
            item = iterator.value()
            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            if node_data and node_data.get('node_id') == node_id:
                return item
            iterator += 1
        return None

    def _apply_node_style(self, item, node_data):
        """Visually distinguish node types.

        Three-color palette + bold for methods. The bold-for-methods
        marker is the colorblind-safe fallback in case the user can't
        perceive the color difference.

        Method   -> dark blue, BOLD, arrow icon
        Variable -> dark green
        Object   -> dark orange

        Implementation note: we build a fresh QFont from QApplication's
        default font rather than `item.font(0)`. When this method is
        called BEFORE the item is added to the tree (which is what
        populate_tree does), `item.font(0)` returns a fresh-default font
        anyway, but using QApplication.font() makes the intent explicit
        and avoids subtle inheritance bugs.
        """
        try:
            node_class = node_data.get('node_class')
        except Exception:
            return

        # Pin the size explicitly so all three node classes render at the
        # same height — calling setFont() with QApplication.font() overrides
        # the tree stylesheet's `font-size: 11px`, so we have to set the
        # point size here. Methods get bold (colour-blind fallback) but
        # NOT a different size.
        font = QFont(QApplication.font())
        font.setPointSize(NODE_TREE_FONT_PT)
        t = THEME.active
        if node_class == ua.NodeClass.Method:
            font.setBold(True)
            item.setFont(0, font)
            item.setForeground(0, QBrush(QColor(t.node_method)))
            method_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward)
            item.setIcon(0, method_icon)
        elif node_class == ua.NodeClass.Variable:
            font.setBold(False)
            item.setFont(0, font)
            item.setForeground(0, QBrush(QColor(t.node_variable)))
        else:
            # Object / Folder / anything else gets the Object color.
            font.setBold(False)
            item.setFont(0, font)
            item.setForeground(0, QBrush(QColor(t.node_object)))

    def _populate_child_items(self, parent_item, parent_data):
        """Populate already-loaded children (used for synthetic method args)."""
        children = parent_data.get('children') or {}
        for child_id, child_data in children.items():
            child_item = QTreeWidgetItem([child_data.get('display_name', child_id)])
            child_item.setData(0, Qt.ItemDataRole.UserRole, child_data)
            self._apply_node_style(child_item, child_data)
            parent_item.addChild(child_item)

            if child_data.get('has_children'):
                if child_data.get('children_loaded'):
                    self._populate_child_items(child_item, child_data)
                else:
                    dummy_item = QTreeWidgetItem([PLACEHOLDER_NOT_LOADED])
                    child_item.addChild(dummy_item)

    def _build_object_path(self, item):
        """Return a human-readable path of labels leading to the given item (excluding None)."""
        parts = []
        current = item
        while current:
            node_data = current.data(0, Qt.ItemDataRole.UserRole)
            label = node_data.get("display_name") if node_data else current.text(0)
            parts.append(label)
            current = current.parent()
        if not parts:
            return "(root)"
        return " / ".join(reversed(parts))

    def collect_methods(self):
        """Collect all method nodes currently visible/loaded in the tree."""
        methods = []
        iterator = QTreeWidgetItemIterator(self)
        while iterator.value():
            item = iterator.value()
            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            if isinstance(node_data, dict) and node_data.get("node_class") == ua.NodeClass.Method:
                parent_item = item.parent()
                parent_node_id = None
                if parent_item:
                    parent_data = parent_item.data(0, Qt.ItemDataRole.UserRole)
                    if isinstance(parent_data, dict):
                        parent_node_id = parent_data.get("node_id")
                path_label = self._build_object_path(parent_item)
                methods.append(
                    {
                        "path": path_label,
                        "method": node_data,
                        "parent_node_id": parent_node_id,
                    }
                )
            iterator += 1
        return methods

    def show_context_menu(self, position):
        item = self.itemAt(position)
        if not item or _is_placeholder_text(item.text(0)):
            return

        selected_items = self.selectedItems()
        if len(selected_items) > 1:
            # Multi-selection context menu
            variable_count = 0
            for sel_item in selected_items:
                if _is_placeholder_text(sel_item.text(0)):
                    continue
                node_data = sel_item.data(0, Qt.ItemDataRole.UserRole)
                if (node_data and 
                    node_data.get('node_class') == ua.NodeClass.Variable):
                    variable_count += 1

            if variable_count > 0:
                menu = QMenu()
                subscribe_action = QAction(f"Subscribe to {variable_count} variables", self)
                subscribe_action.triggered.connect(self.subscribe_selected_nodes)
                menu.addAction(subscribe_action)
                menu.exec(self.mapToGlobal(position))
        else:
            # Single selection context menu
            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            if not node_data:
                return

            menu = QMenu()
            subscribe_action = None
            plot_action = None
            expand_action = None
            collapse_action = None
            invoke_action = None

            node_class = node_data.get('node_class')
            can_subscribe = node_class == ua.NodeClass.Variable and not self.subscription_widget.is_subscribed(node_data['node_id'])
            has_children_flag = bool(node_data.get('has_children', False))
            has_loaded_children = item.childCount() > 0 and not (item.childCount() == 1 and _is_placeholder_text(item.child(0).text(0)))
            can_expand = (node_class != ua.NodeClass.Method) and (has_children_flag or has_loaded_children)
            # Collapse only makes sense for items that actually have a
            # subtree visible — i.e. children loaded, not just stubs.
            can_collapse = has_loaded_children
            can_invoke = node_class == ua.NodeClass.Method and self.worker and self.worker.isRunning()

            if can_subscribe:
                subscribe_action = QAction("Subscribe", self)
                menu.addAction(subscribe_action)

            if node_class == ua.NodeClass.Variable:
                plot_action = QAction("Plot...", self)
                menu.addAction(plot_action)

            if can_expand:
                expand_action = QAction("Expand", self)
                menu.addAction(expand_action)

            if can_collapse:
                collapse_action = QAction("Collapse", self)
                menu.addAction(collapse_action)

            if can_invoke:
                invoke_action = QAction("Invoke", self)
                menu.addAction(invoke_action)

            if menu.actions():
                chosen = menu.exec(self.mapToGlobal(position))
                if chosen == subscribe_action:
                    self.subscribe_to_node(node_data)
                elif chosen == plot_action:
                    self.request_plot(node_data)
                elif chosen == expand_action:
                    self.expand_entire_branch(item)
                elif chosen == collapse_action:
                    self.collapse_entire_branch(item)
                elif chosen == invoke_action:
                    self.request_method_invocation(item)

    def request_method_invocation(self, item):
        """Emit a request to invoke the selected method node."""
        if not item:
            return

        node_data = item.data(0, Qt.ItemDataRole.UserRole)
        if not node_data:
            return

        # Copy data so we can attach the parent id without mutating shared state
        node_data = dict(node_data)

        parent_item = item.parent()
        if parent_item:
            parent_data = parent_item.data(0, Qt.ItemDataRole.UserRole)
            if parent_data and isinstance(parent_data, dict):
                node_data["parent_node_id"] = parent_data.get("node_id")

        # Ensure the UI reflects the selection before invoking
        self.setCurrentItem(item)

        self.method_invocation_requested.emit(node_data)

    def subscribe_to_node(self, node_data):
        if not node_data or node_data.get("node_class") != ua.NodeClass.Variable:
            return
        # Use path as the identifier (path is the cache key)
        node_id = node_data.get('path') or node_data.get('node_id')
        if self.subscription_widget.is_subscribed(node_id):
            return
        if self.worker:
            self.worker.subscribe_to_node_async(node_id)
            self.subscription_widget.add_subscription(node_id, node_data['display_name'])

    def request_plot(self, node_data):
        if node_data:
            self.plot_requested.emit(node_data)

    def unsubscribe_from_node(self, node_data):
        # Use path as the identifier (path is the cache key)
        node_id = node_data.get('path') or node_data.get('node_id')
        if self.worker:
            self.worker.unsubscribe_from_node_async(node_id)
            self.subscription_widget.remove_subscription(node_id)

    def collapse_entire_branch(self, item):
        """Collapse the given item and all of its descendants.

        Each `collapseItem()` triggers a layout/repaint, so a naive walk
        on a tree with thousands of nodes is painfully slow. We disable
        updates for the duration of the recursion so Qt does a single
        layout at the end instead of one per node.
        """
        if not item:
            return
        self.setUpdatesEnabled(False)
        try:
            def _walk(it):
                for i in range(it.childCount()):
                    child = it.child(i)
                    _walk(child)
                    self.collapseItem(child)
            _walk(item)
            self.collapseItem(item)
        finally:
            self.setUpdatesEnabled(True)

    def expand_loaded_tree(self):
        """Expand every already-LOADED node, without loading any more.

        Used by the toolbar 'Expand' button. Unlike expand_entire_branch (the
        right-click, deliberate single-branch action), this never issues load
        requests: the Lazy Node Loading frontier stays collapsed. We suppress
        the on-expand loader for the duration via `_suppress_expand_load` and
        use Qt's native expandAll() (a single batched C++ call), so a large but
        bounded (e.g. 10 000-node) lazy tree expands quickly and safely.
        """
        self._suppress_expand_load = True
        self.setUpdatesEnabled(False)
        try:
            self.expandAll()
        finally:
            self.setUpdatesEnabled(True)
            self._suppress_expand_load = False

    def expand_entire_branch(self, item):
        """Expand the given item and all descendants, loading children as needed."""
        if not item:
            return

        # Mark expand-all mode for this operation
        self._expand_all_mode = True

        node_data = item.data(0, Qt.ItemDataRole.UserRole)
        node_id = node_data.get('node_id') if node_data else None

        if node_id:
            self._pending_expand.add(node_id)

        self.expandItem(item)
        self.on_item_expanded(item)

        # If already loaded, expand children immediately; otherwise, on_children_loaded will continue
        if node_data and node_data.get('children_loaded'):
            self._expand_children_recursively(item)
            if node_id:
                self._pending_expand.discard(node_id)

    def _expand_children_recursively(self, item):
        """Recursively expand children and trigger loading."""
        for i in range(item.childCount()):
            child = item.child(i)
            child_data = child.data(0, Qt.ItemDataRole.UserRole)
            if not child_data:
                continue

            self.expandItem(child)
            self.on_item_expanded(child)

            if child_data.get('has_children'):
                if child_data.get('children_loaded'):
                    self._expand_children_recursively(child)
                else:
                    node_id = child_data.get('node_id')
                    if node_id and node_id not in self._pending_expand:
                        self._pending_expand.add(node_id)
                        if self.worker:
                            self.worker.request_load_children(node_id)


class MethodsQuickAccessWidget(QWidget):
    method_invocation_requested = pyqtSignal(dict)

    def __init__(self):
        super().__init__()
        self.worker = None
        self.quick_exec_enabled = False
        self.setup_ui()

    def setup_ui(self):
        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(4)

        header_layout = QHBoxLayout()
        header_layout.setContentsMargins(0, 0, 0, 0)
        header_layout.setSpacing(8)
        title = QLabel("Methods - Direct Access")
        apply_section_title_style(title)
        header_layout.addWidget(title)

        # Tree-wide Expand / Collapse buttons (mirror the Node Browser).
        # Methods tree has no async-loaded children, so the native
        # expandAll()/collapseAll() calls are enough — no recursion needed.
        self.expand_button = make_compact_button(
            "Expand", tip="Expand all method groups")
        self.expand_button.clicked.connect(lambda: self.tree.expandAll())
        header_layout.addWidget(self.expand_button)

        self.collapse_button = make_compact_button(
            "Collapse", tip="Collapse all method groups")
        self.collapse_button.clicked.connect(lambda: self.tree.collapseAll())
        header_layout.addWidget(self.collapse_button)

        header_layout.addStretch()

        self.quick_exec_toggle = QRadioButton("Direct Invoke")
        self.quick_exec_toggle.setChecked(False)
        self.quick_exec_toggle.toggled.connect(self._on_quick_exec_toggled)
        self._update_quick_exec_style(False)
        # Restyle on theme change so the toggle keeps its current
        # checked-or-unchecked palette but re-derived from the new theme.
        THEME.theme_changed.connect(
            lambda _t: self._update_quick_exec_style(self.quick_exec_enabled)
        )
        header_layout.addWidget(self.quick_exec_toggle)
        layout.addLayout(header_layout)

        self.tree = QTreeWidget()
        self.tree.setHeaderHidden(True)
        # Compact font + row padding — same size as Node Browser so the
        # two trees read uniformly side-by-side.
        apply_compact_tree_style(self.tree)
        self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tree.customContextMenuRequested.connect(self._show_context_menu)
        self.tree.itemActivated.connect(self._on_item_activated)
        self.tree.itemDoubleClicked.connect(self._on_item_activated)
        self.tree.itemClicked.connect(self._on_item_clicked)
        layout.addWidget(self.tree)

        self.setLayout(layout)
        self._show_empty_state()

    def _on_item_clicked(self, item, _column):
        if not self.quick_exec_enabled:
            return
        node_copy = self._extract_node_copy(item)
        if not isinstance(node_copy, dict):
            return
        self._emit_method_invocation(node_copy)

    def _on_quick_exec_toggled(self, checked):
        self.quick_exec_enabled = bool(checked)
        self._update_quick_exec_style(checked)

    def _update_quick_exec_style(self, checked):
        t = THEME.active
        if checked:
            self.quick_exec_toggle.setStyleSheet(
                f"""
                QRadioButton {{
                    background-color: {t.record_idle_bg};
                    color: {t.text_on_accent};
                    padding: 2px 8px;
                    border-radius: 10px;
                }}
                QRadioButton::indicator {{
                    width: 12px;
                    height: 12px;
                    border: 1px solid {t.record_idle_border};
                    background: {t.record_active_bg};
                }}
                QRadioButton::indicator:checked {{
                    background: {t.record_idle_bg};
                    border: 1px solid {t.record_idle_border};
                }}
                """
            )
        else:
            self.quick_exec_toggle.setStyleSheet(
                f"""
                QRadioButton {{
                    background-color: {t.panel_disabled_bg};
                    color: {t.text_muted};
                    padding: 2px 8px;
                    border-radius: 10px;
                }}
                QRadioButton::indicator {{
                    width: 12px;
                    height: 12px;
                    border: 1px solid {t.border};
                    background: {t.panel_pressed_bg};
                }}
                QRadioButton::indicator:checked {{
                    background: {t.border};
                    border: 1px solid {t.border_strong};
                }}
                """
            )

    def _show_empty_state(self):
        self.tree.clear()
        placeholder = QTreeWidgetItem(["No methods found"])
        placeholder.setFlags(Qt.ItemFlag.NoItemFlags)
        self.tree.addTopLevelItem(placeholder)

    def clear_methods(self):
        self._show_empty_state()

    def set_worker(self, worker):
        """Store worker reference so the invoke action can reflect connection state."""
        self.worker = worker

    def _should_skip_path(self, path):
        """Hide infrastructure paths from quick-access method list."""
        path_norm = str(path).replace(" / ", "/").replace("\\", "/").lower().strip()
        if not path_norm:
            return False

        segments = [segment for segment in path_norm.strip("/").split("/") if segment]
        if not segments:
            return False

        if "types" in segments:
            return True

        if segments[0] == "objects" and len(segments) > 1 and segments[1] in {"server", "aliases"}:
            return True

        return False

    def update_methods(self, methods):
        """Refresh list of methods grouped by object path."""
        self.tree.clear()
        if not methods:
            self._show_empty_state()
            return

        grouped = {}
        for entry in methods:
            path = entry.get("path") or "(root)"
            if self._should_skip_path(path):
                continue
            grouped.setdefault(path, []).append(entry)

        expand_groups = len(grouped) <= 3

        for path, entries in sorted(grouped.items()):
            parent_item = QTreeWidgetItem([path])
            parent_item.setData(0, Qt.ItemDataRole.UserRole, {"is_group": True})
            # Group rows are container paths — match the Object node-class
            # colour used in the Node Browser. We deliberately do NOT set
            # an explicit background here so the row inherits the tree's
            # input_bg surface (same as the Node Browser); painting our
            # own background made the panel look mismatched.
            parent_item.setForeground(0, QBrush(QColor(THEME.active.node_object)))
            # Pin the same point size used in the Node Browser so all
            # rows in both trees render at one consistent height.
            group_font = QFont(QApplication.font())
            group_font.setPointSize(NODE_TREE_FONT_PT)
            parent_item.setFont(0, group_font)
            self.tree.addTopLevelItem(parent_item)

            for entry in sorted(entries, key=lambda e: (e.get("method") or {}).get("display_name", "")):
                method_data = entry.get("method") or {}
                if not isinstance(method_data, dict):
                    continue
                if "node_id" not in method_data:
                    continue
                label = method_data.get("display_name") or method_data.get("browse_name") or str(
                    method_data.get("node_id", "Method")
                )
                child = QTreeWidgetItem([label])
                payload = {
                    "node_data": dict(method_data),
                    "parent_node_id": entry.get("parent_node_id"),
                }
                child.setData(0, Qt.ItemDataRole.UserRole, payload)
                # Match Node Browser: methods are dark blue + bold, at
                # the SAME point size as variables/objects so bold doesn't
                # visually inflate the row height.
                method_font = QFont(QApplication.font())
                method_font.setPointSize(NODE_TREE_FONT_PT)
                method_font.setBold(True)
                child.setFont(0, method_font)
                child.setForeground(0, QBrush(QColor(THEME.active.node_method)))
                parent_item.addChild(child)

            parent_item.setExpanded(expand_groups)

    def _on_item_activated(self, item, _column):
        node_copy = self._extract_node_copy(item)
        if not isinstance(node_copy, dict):
            return
        self._emit_method_invocation(node_copy)

    def _extract_node_copy(self, item):
        """Safely pull a shallow copy of the node payload for invocation."""
        if not item:
            return None
        payload = item.data(0, Qt.ItemDataRole.UserRole)
        if not isinstance(payload, dict):
            return None
        node_data = payload.get("node_data")
        if not node_data or not isinstance(node_data, dict):
            return None
        node_copy = dict(node_data)
        parent_node_id = payload.get("parent_node_id")
        if parent_node_id:
            node_copy["parent_node_id"] = parent_node_id
        return node_copy

    def _emit_method_invocation(self, node_copy):
        """Emit invocation only if the payload is valid."""
        if not isinstance(node_copy, dict):
            return
        # Use a shallow copy to avoid accidental mutation
        self.method_invocation_requested.emit(dict(node_copy))

    def _show_context_menu(self, pos):
        item = self.tree.itemAt(pos)
        if not item:
            return
        node_copy = self._extract_node_copy(item)
        if not isinstance(node_copy, dict):
            return

        menu = QMenu(self)
        invoke_action = QAction("Invoke", self)
        # Disable invoke when no active worker, emit with captured payload to avoid stale item refs
        can_invoke = bool(self.worker and getattr(self.worker, "isRunning", lambda: False)())
        invoke_action.setEnabled(can_invoke)
        invoke_action.triggered.connect(lambda _checked=False, nc=node_copy: self._emit_method_invocation(nc))
        menu.addAction(invoke_action)
        menu.exec(self.tree.viewport().mapToGlobal(pos))


# ---------------------------------------------------------------------------
# Scripting subsystem
#
# A Script is a user-authored Python file run inside UaExplorer. Scripts
# get a preloaded namespace (stdlib modules + numpy) and built-in operations
# that wrap UaExplorer's existing UA client (Read/Write/Execute/Wait/...).
#
# Threading: each run executes on its own QThread (ScriptRunner). UA ops
# dispatch into OpcUaWorker's asyncio loop via run_coroutine_threadsafe and
# block the runner thread for the future's result. The UI stays responsive.
#
# Cancellation: a threading.Event is shared between the UI and the runner.
# Built-in ops check it; Wait() polls it on a short timer; the runner thread
# can be interrupted between ops but not mid-coroutine — Python doesn't
# allow injecting exceptions into arbitrary threads safely.
# ---------------------------------------------------------------------------

class ScriptAborted(Exception):
    """Raised by Abort() inside a script — clean stop, logged as aborted."""


class ScriptStopped(Exception):
    """Raised when the user presses Stop — logged as stopped."""


class ScriptRunner(QThread):
    """Runs one user script. Signals carry log lines and status updates
    back to the UI thread. Owned by ScriptControlWidget."""

    log_line = pyqtSignal(str, str)      # (level, message)
    status_changed = pyqtSignal(str)     # "running" / "idle" / "error" / "stopped" / "aborted"
    run_finished = pyqtSignal(bool, str)  # (success, summary)

    def __init__(self, script_path: Path, worker: "OpcUaWorker",
                 output_dir: Path,
                 data_file: Optional[Path] = None,
                 data_append: bool = False,
                 use_utc: bool = False,
                 parent=None):
        """``data_file`` overrides the auto-generated .dat name when set;
        ``data_append`` selects append vs truncate mode. When ``data_file``
        is None, ``run()`` generates a timestamped name (legacy behavior).
        ``use_utc`` controls whether the script-facing Iso() returns local
        or UTC time."""
        super().__init__(parent)
        self.script_path = script_path
        self.worker = worker
        self.output_dir = output_dir
        self._explicit_data_file = data_file
        self._data_append = data_append
        self._use_utc = use_utc
        # Verbose() auto-logging level. None = off (default). When set
        # to a level name, every Read/Write/Execute/Wait/WaitUntil/Store
        # call emits one log line at that level showing the call args
        # and return value. Iso() is excluded (it's a pure helper).
        self._verbose_level: Optional[str] = None
        self._stop_event = threading.Event()
        # Data file for Store() — opened lazily on the first Store() call
        # so runs that only use Log() don't leave behind an empty .dat.
        # NB: there is intentionally no per-run .log file. Log() messages
        # route only to the UaExplorer log panel; persistence is handled
        # by UaExplorer's own logging engine.
        self._data_file: Optional["Path"] = None
        self._data_fp = None
        self._data_filename_ts: Optional[str] = None
        self._start_time: Optional[datetime] = None

    def request_stop(self):
        """UI thread asks the script to stop at the next checkpoint."""
        self._stop_event.set()

    # ------------------------------------------------------------------
    # Logging — writes to both the per-run file and the UaExplorer log
    # ------------------------------------------------------------------

    def _emit_log(self, level: str, msg: str):
        """Route a script log line to the UaExplorer log panel.

        No per-run file is written — UaExplorer's logging engine owns
        persistence (file logging will be wired up separately if needed)."""
        self.log_line.emit(level, msg)

    # ------------------------------------------------------------------
    # UA op shims — synchronous facade over OpcUaWorker's async API
    # ------------------------------------------------------------------

    def _check_stop(self):
        if self._stop_event.is_set():
            raise ScriptStopped("Stop requested")

    def _run_coro(self, coro):
        """Dispatch a coroutine onto the worker's asyncio loop and block
        until it completes. Raises if the worker is not connected."""
        if not self.worker or not self.worker.loop or not self.worker.running:
            raise RuntimeError("Not connected to OPC UA server")
        future = asyncio.run_coroutine_threadsafe(coro, self.worker.loop)
        # Poll the future so Stop interrupts long ops at the runner level.
        # We can't kill the coroutine itself, but we can stop waiting on it.
        while True:
            try:
                return future.result(timeout=0.1)
            except concurrent_futures.TimeoutError:
                if self._stop_event.is_set():
                    future.cancel()
                    raise ScriptStopped("Stop requested")

    def _resolve_node(self, identifier: str):
        """Resolve a Read/Execute argument to an asyncua Node.

        Scripts identify nodes the same way users see them in
        the Node Browser — typically a path like
        ``/system/subsys001/fcs/sensor1/Temperature``. The cache maps
        these paths to the real OPC UA node objects. If the argument
        isn't in the cache, fall back to ``client.get_node()`` which
        treats it as a node-ID string (e.g. ``ns=2;s=Foo``).

        Raises RuntimeError if neither route resolves to a node."""
        if not self.worker.client:
            raise RuntimeError("Not connected")
        cache = self.worker.nodes_cache
        entry = cache.get(identifier)
        if entry and "node_object" in entry:
            return entry["node_object"]
        # Fall back: maybe the caller really did pass a node ID.
        try:
            return self.worker.client.get_node(identifier)
        except Exception as e:
            raise RuntimeError(
                self._suggest_paths_error("node", identifier, e)
            ) from e

    def _auto_log(self, msg: str) -> None:
        """Emit a Verbose() auto-log line at the configured level.

        No-op when Verbose() has not been called (or was called with OFF).
        DEBUG-level calls always go through _emit_log("DEBUG", ...) too,
        so per-op DEBUG tracing keeps working even when Verbose is off."""
        if self._verbose_level and self._verbose_level != "OFF":
            self._emit_log(self._verbose_level, msg)

    _VERBOSE_LEVELS = ("OFF", "ERROR", "WARNING", "INFO", "DEBUG")

    def _op_verbose(self, level: Optional[str] = None) -> None:
        """Enable / disable auto-logging of script ops.

        ``Verbose()``         → defaults to INFO.
        ``Verbose(INFO)``     → enable, log at INFO.
        ``Verbose(DEBUG)``    → enable, log at DEBUG.
        ``Verbose(OFF)``      → disable (silent again).

        Accepts the bare uppercase constants exposed in script globals
        or their string equivalents. Unknown values raise."""
        if level is None:
            level_name = "INFO"
        else:
            level_name = str(level).upper()
        if level_name not in self._VERBOSE_LEVELS:
            raise ValueError(
                f"Verbose() level must be one of {self._VERBOSE_LEVELS}, "
                f"got {level!r}"
            )
        self._verbose_level = None if level_name == "OFF" else level_name
        if self._verbose_level is None:
            self._emit_log("INFO", "Verbose: OFF")
        else:
            self._emit_log("INFO", f"Verbose: {self._verbose_level}")

    def _op_read(self, node_id: str):
        self._check_stop()

        async def _do():
            node = self._resolve_node(node_id)
            return await node.read_value()

        value = self._run_coro(_do())
        self._emit_log("DEBUG", f"Read({node_id!r}) -> {value!r}")
        self._auto_log(f"Read({node_id!r}) -> {value!r}")
        return value

    def _op_write(self, node_id: str, value):
        self._check_stop()
        self._emit_log("DEBUG", f"Write({node_id!r}, {value!r})")
        self._auto_log(f"Write({node_id!r}, {value!r})")
        ok = self._run_coro(self.worker.write_node_value(node_id, value))
        if not ok:
            raise RuntimeError(f"Write to {node_id} failed")
        return ok

    def _op_execute(self, method_node_id: str, *args, parent_node_id: Optional[str] = None):
        """Invoke an OPC UA method. ``parent_node_id`` defaults to the
        method's cached parent."""
        self._check_stop()

        async def _do():
            if not self.worker.client:
                raise RuntimeError("Not connected")
            cache = self.worker.nodes_cache
            method_entry = cache.get(method_node_id)
            if method_entry and "node_object" in method_entry:
                method_node = method_entry["node_object"]
            else:
                try:
                    method_node = self.worker.client.get_node(method_node_id)
                except Exception as e:
                    raise RuntimeError(
                        self._suggest_paths_error("method", method_node_id, e)
                    ) from e
            parent = parent_node_id or (method_entry or {}).get("parent_id")
            if parent and parent in cache and "node_object" in cache[parent]:
                parent_node = cache[parent]["node_object"]
            elif parent:
                try:
                    parent_node = self.worker.client.get_node(parent)
                except Exception as e:
                    raise RuntimeError(
                        f"Could not resolve parent {parent!r} for method "
                        f"{method_node_id!r}: {e}"
                    ) from e
            else:
                parent_node = self.worker.client.get_node("i=85")  # Objects
            return await parent_node.call_method(method_node, *args)

        self._emit_log("DEBUG", f"Execute({method_node_id!r}, {args})")
        result = self._run_coro(_do())
        self._auto_log(f"Execute({method_node_id!r}, {args}) -> {result!r}")
        return result

    def _suggest_paths_error(self, kind: str, identifier: str, parse_err: Exception) -> str:
        """Build a friendly error message for failed Read/Write/Execute
        lookups. If the user passed a bare name like ``"Reset"`` we
        scan the cache for paths ending in ``/Reset`` (variables) AND
        ``/Reset()`` (methods — the cache stores method paths with a
        trailing ``()`` disambiguation suffix; see
        ``OpcUaWorker.load_node_children``). Both forms are listed."""
        cache = getattr(self.worker, "nodes_cache", {}) or {}
        bare = identifier.lstrip("/").rstrip("()")
        # Match both variable-style (/Bare) and method-style (/Bare())
        # endings so the suggestion works for either kind.
        suffixes = ("/" + bare, "/" + bare + "()")
        matches = [
            k for k in cache.keys()
            if isinstance(k, str) and any(k.endswith(s) for s in suffixes)
        ]
        # Cap the listing so it stays readable in the log.
        MAX_SHOWN = 10
        if matches:
            shown = matches[:MAX_SHOWN]
            more = len(matches) - len(shown)
            joined = "\n  ".join(shown)
            extra = f"\n  ...and {more} more" if more > 0 else ""
            return (
                f"Could not resolve {kind} {identifier!r}: a bare name is "
                f"not a valid path or node ID.\nFound {len(matches)} cached "
                f"{kind}{'s' if len(matches) != 1 else ''} matching "
                f"{bare!r} — pass the FULL PATH to disambiguate:\n  "
                f"{joined}{extra}"
            )
        return (
            f"Could not resolve {kind} {identifier!r}: not in browser cache "
            f"and not a valid node ID ({parse_err}). Use the full path as "
            f"shown in the Node Browser."
        )

    def _op_wait(self, seconds: float):
        """Cancellable sleep. Checks the stop event every 100 ms."""
        self._auto_log(f"Wait({seconds!r})")
        deadline = time.monotonic() + max(0.0, float(seconds))
        while True:
            self._check_stop()
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return
            time.sleep(min(0.1, remaining))

    def _op_wait_until(self, node_id: str, predicate, timeout: Optional[float] = None,
                       poll_interval: float = 0.2):
        """Poll ``node_id`` until ``predicate(value)`` returns truthy.

        ``predicate`` is called from the runner thread, so it can be any
        Python callable (lambda, function). Returns the last read value.
        Raises TimeoutError if ``timeout`` elapses first."""
        self._auto_log(f"WaitUntil({node_id!r}, predicate, timeout={timeout!r})")
        deadline = None if timeout is None else (time.monotonic() + float(timeout))
        while True:
            self._check_stop()
            value = self._op_read(node_id)
            try:
                ok = bool(predicate(value))
            except Exception as e:
                raise RuntimeError(f"WaitUntil predicate raised: {e}") from e
            if ok:
                self._auto_log(f"WaitUntil({node_id!r}) -> {value!r}")
                return value
            if deadline is not None and time.monotonic() >= deadline:
                raise TimeoutError(
                    f"WaitUntil({node_id!r}) timed out after {timeout}s, last value={value!r}"
                )
            time.sleep(poll_interval)

    def _op_abort(self, msg: str = ""):
        raise ScriptAborted(msg or "Script aborted by Abort()")

    def _op_iso(self) -> str:
        """Return an ISO-8601 timestamp string with millisecond precision.

        Local time by default; UTC with a trailing 'Z' if the runner was
        constructed with ``use_utc=True`` (controlled from Preferences →
        Scripting). Typical use: stamp Store() rows with a wall-clock
        timestamp, e.g. ``Store(Iso(), Read("ns=2;s=T"))``."""
        if self._use_utc:
            now = datetime.now(timezone.utc)
            return now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
        return datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]

    def _ensure_data_file(self):
        """Lazily open the per-run data file. Returns the file object on
        success, or None if opening failed (errors are reported via Log).

        If an explicit ``data_file`` was supplied at construction (Auto OFF
        in the UI) it is used as-is, in append mode if ``data_append`` is
        set. Otherwise a fresh timestamped name is generated (Auto ON)."""
        if self._data_fp is not None:
            return self._data_fp
        if self._explicit_data_file is not None:
            self._data_file = self._explicit_data_file
            if not self._data_file.is_absolute():
                self._data_file = self.output_dir / self._data_file
            mode = "a" if self._data_append else "w"
        else:
            if not self._data_filename_ts:
                self._data_filename_ts = datetime.now().strftime(
                    "%Y-%m-%dT%H-%M-%S.%f"
                )[:-3]
            self._data_file = (
                self.output_dir / f"{self.script_path.stem}_{self._data_filename_ts}.dat"
            )
            mode = "w"
        try:
            self._data_file.parent.mkdir(parents=True, exist_ok=True)
            self._data_fp = open(self._data_file, mode, encoding="utf-8")
            mode_desc = "appending to" if mode == "a" else "writing to"
            self._emit_log("INFO", f"Store: {mode_desc} {self._data_file}")
        except Exception as e:
            self._emit_log("ERROR", f"Store: could not open {self._data_file}: {e}")
            self._data_file = None
            self._data_fp = None
        return self._data_fp

    def _op_store(self, *values):
        """Append one tab-separated record of raw values to the per-run
        data file. The file (``<script>_<iso8601-ms>.dat``) is created
        on the first call.

        Also echoes the row to the log panel and per-run .log as an INFO
        line prefixed with ``[DATA]`` — visible in real time and grep-able
        offline (``grep '\\[DATA\\]' run.log``)."""
        self._check_stop()
        fp = self._ensure_data_file()
        if fp is None:
            return
        try:
            line = "\t".join(self._format_store_value(v) for v in values)
            fp.write(line + "\n")
            fp.flush()
            self._emit_log("INFO", f"[DATA] {line}")
        except Exception as e:
            self._emit_log("ERROR", f"Store: write failed: {e}")

    @staticmethod
    def _format_store_value(v) -> str:
        """Convert one Store() argument to a tab-safe text token.

        Tabs in strings are replaced with spaces so the row layout stays
        valid; newlines are escaped for the same reason. Non-string values
        pass through repr() only if str() would lose useful info — keeps
        floats/ints/bools natural and short."""
        if isinstance(v, str):
            return v.replace("\t", " ").replace("\n", "\\n").replace("\r", "")
        return str(v).replace("\t", " ").replace("\n", "\\n").replace("\r", "")

    # ------------------------------------------------------------------
    # Script execution
    # ------------------------------------------------------------------

    def _build_globals(self) -> Dict[str, Any]:
        """Construct the global namespace exposed to the script."""

        class _LogProxy:
            """Callable + level-named methods, so users can write either
            ``Log("msg")`` or ``Log.info("msg")`` / ``Log.warn("msg")``."""

            def __init__(self, emit):
                self._emit = emit

            def __call__(self, msg, level: str = "INFO"):
                self._emit(level.upper(), str(msg))

            def info(self, msg):
                self._emit("INFO", str(msg))

            def warn(self, msg):
                self._emit("WARNING", str(msg))

            warning = warn

            def error(self, msg):
                self._emit("ERROR", str(msg))

            def debug(self, msg):
                self._emit("DEBUG", str(msg))

        log_proxy = _LogProxy(self._emit_log)

        # numpy is optional; if not installed, expose a stub that raises
        # on first use rather than failing the run before user code starts.
        try:
            import numpy as np_module
        except Exception:
            np_module = None

        g: Dict[str, Any] = {
            "__name__": "__script__",
            "__file__": str(self.script_path),
            # Preloaded modules
            "os": os,
            "sys": sys,
            "time": time,
            "math": __import__("math"),
            "datetime": datetime,
            "timedelta": timedelta,
            "re": re,
            "json": json,
            "csv": csv,
            "Path": Path,
            "np": np_module,
            # Built-in operations
            "Read": self._op_read,
            "Write": self._op_write,
            "Execute": self._op_execute,
            "Method": self._op_execute,  # alias
            "Wait": self._op_wait,
            "WaitUntil": self._op_wait_until,
            "Log": log_proxy,
            "log": log_proxy,  # tolerate lowercase
            "Store": self._op_store,
            "Save": self._op_store,   # alias
            "Iso": self._op_iso,
            "Verbose": self._op_verbose,
            "Abort": self._op_abort,
            "ScriptAborted": ScriptAborted,
            # Level constants for Verbose() and Log(). Just the uppercase
            # strings — that way `Verbose(INFO)` and `Verbose("INFO")` are
            # both valid, and `Log("msg", level=WARNING)` reads naturally.
            "OFF": "OFF",
            "ERROR": "ERROR",
            "WARNING": "WARNING",
            "INFO": "INFO",
            "DEBUG": "DEBUG",
        }
        return g

    def run(self):
        """QThread entry point. Reads the script, executes it inside the
        prepared globals dict, and emits status + log lines as it goes.

        No per-run .log file is created — Log() messages go only to the
        UaExplorer log panel via the log_line signal. Only the .dat (if
        the script uses Store()) is written to ``output_dir``."""
        self._start_time = datetime.now()
        try:
            self.output_dir.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            self.run_finished.emit(False, f"Could not create output dir: {e}")
            self.status_changed.emit("error")
            return

        # Filesystem-safe ISO-8601-with-ms timestamp used for the .dat
        # filename if Store() is called and no explicit data_file was
        # supplied. ':' is replaced with '-' so the name is valid on
        # Windows-shared filesystems too.
        self._data_filename_ts = self._start_time.strftime(
            "%Y-%m-%dT%H-%M-%S.%f"
        )[:-3]

        try:
            source = self.script_path.read_text(encoding="utf-8")
        except Exception as e:
            self._emit_log("ERROR", f"Could not read script: {e}")
            self._close_files()
            self.run_finished.emit(False, str(e))
            self.status_changed.emit("error")
            return

        self.status_changed.emit("running")
        self._emit_log("INFO", f"[START] {self.script_path.name}")

        success = True
        summary = ""
        try:
            code = compile(source, str(self.script_path), "exec")
            globals_dict = self._build_globals()
            exec(code, globals_dict, globals_dict)
            summary = "completed"
            self._emit_log("INFO", "[DONE] script completed")
            self.status_changed.emit("idle")
        except ScriptStopped as e:
            success = False
            summary = "stopped"
            self._emit_log("WARNING", f"[STOPPED] {e}")
            self.status_changed.emit("stopped")
        except ScriptAborted as e:
            success = False
            summary = f"aborted: {e}"
            self._emit_log("WARNING", f"[ABORTED] {e}")
            self.status_changed.emit("aborted")
        except Exception as e:
            success = False
            tb = traceback.format_exc()
            summary = f"error: {e}"
            self._emit_log("ERROR", f"[ERROR] {e}\n{tb}")
            self.status_changed.emit("error")
        finally:
            runtime = datetime.now() - self._start_time
            self._emit_log("INFO", f"[END] runtime={runtime}")
            self._close_files()

        self.run_finished.emit(success, summary)

    def _close_files(self):
        """Close the .dat file if Store() ever opened one. No .log file
        is created any more, so this used to also close the log file —
        that branch is gone."""
        if self._data_fp:
            try:
                self._data_fp.close()
            except Exception:
                pass
            self._data_fp = None


class _FileViewerDialog(QDialog):
    """Read-only viewer for arbitrary text files (script output, recording
    CSVs, ad-hoc logs).

    Used instead of QDesktopServices.openUrl + xdg-open because:
      - xdg-open's choice depends on the user's MIME setup and may
        launch a spreadsheet, a browser, or nothing at all for .dat /
        .csv. Inconsistent.
      - The built-in viewer is read-only by design — users can't
        accidentally edit a recording file.
      - Same widget UaPlot uses for its "Load" button, so the two
        apps feel consistent.

    Monospace QPlainTextEdit so tab-separated columns line up; no line
    wrap so wide CSV rows stay aligned. A Browse button lets the user
    open a different file without closing the dialog.
    """

    def __init__(self, initial: Path, title: str = "View File", parent=None):
        super().__init__(parent)
        self.setWindowTitle(title)
        self.setSizeGripEnabled(True)
        # Many X11 / Wayland window managers honour Min/Max hints only on
        # top-level "Window" windows, not on "Dialog" — request a full
        # top-level decoration set so the user can maximise the viewer
        # (handy for wide CSVs / long logs).
        self.setWindowFlags(
            Qt.WindowType.Window
            | Qt.WindowType.CustomizeWindowHint
            | Qt.WindowType.WindowTitleHint
            | Qt.WindowType.WindowSystemMenuHint
            | Qt.WindowType.WindowMinimizeButtonHint
            | Qt.WindowType.WindowMaximizeButtonHint
            | Qt.WindowType.WindowCloseButtonHint
        )
        layout = QVBoxLayout(self)

        picker_row = QHBoxLayout()
        picker_row.addWidget(QLabel("File:"))
        self.path_label = QLineEdit(str(initial))
        self.path_label.setReadOnly(True)
        picker_row.addWidget(self.path_label, 1)
        browse_btn = QPushButton("Browse…")
        browse_btn.clicked.connect(self._on_browse)
        picker_row.addWidget(browse_btn)
        layout.addLayout(picker_row)

        self.text = QPlainTextEdit()
        self.text.setReadOnly(True)
        mono = QFont("Monospace")
        mono.setStyleHint(QFont.StyleHint.TypeWriter)
        self.text.setFont(mono)
        self.text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        layout.addWidget(self.text, 1)

        bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        bbox.rejected.connect(self.reject)
        bbox.accepted.connect(self.accept)
        layout.addWidget(bbox)

        self.resize(800, 560)
        self._load_file(initial)

    def _load_file(self, path: Path):
        try:
            content = path.read_text(encoding="utf-8")
        except Exception as exc:
            QMessageBox.warning(self, self.windowTitle(),
                                f"Could not read file:\n{exc}")
            return
        self.path_label.setText(str(path))
        self.text.setPlainText(content)
        cursor = self.text.textCursor()
        cursor.movePosition(cursor.MoveOperation.Start)
        self.text.setTextCursor(cursor)

    def _on_browse(self):
        start_dir = str(Path(self.path_label.text()).parent)
        dialog = QFileDialog(self, self.windowTitle(), start_dir)
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if files:
            self._load_file(Path(files[0]))


class ScriptEditorDialog(QDialog):
    """Plain-text editor for script .py files. Held intentionally simple:
    monospace QPlainTextEdit, file picker / New / Save As, Save & Run.

    Syntax highlighting and code completion are deliberately deferred to v2."""

    NEW_TEMPLATE = (
        "# {name}\n"
        "# Generated by UA Explorer Script Editor on {ts}\n"
        "#\n"
        "# Available built-ins (no import needed):\n"
        "#\n"
        "#   Read(node_id) -> value\n"
        "#   Write(node_id, value)\n"
        "#   Execute(method_node_id, *args)\n"
        "#       alias: Method(...)\n"
        "#   Wait(seconds)\n"
        "#       cancellable via the Stop button\n"
        "#   WaitUntil(node_id, predicate, timeout=None)\n"
        "#   Log(\"msg\", level=\"INFO\")\n"
        "#       human-readable status -> UaExplorer log panel\n"
        "#   Store(*values)\n"
        "#       alias: Save(*values)\n"
        "#       tab-separated row -> <script>_<iso-ms>.dat\n"
        "#       also echoed to the log as INFO [DATA] ...\n"
        "#   Iso() -> str\n"
        "#       ISO-8601 timestamp, ms precision\n"
        "#       local time, or UTC if enabled in Preferences\n"
        "#   Verbose(level=INFO)\n"
        "#       auto-log every Read / Write / Execute / Wait / WaitUntil\n"
        "#       levels: OFF / ERROR / WARNING / INFO / DEBUG\n"
        "#   Abort(\"reason\")\n"
        "#       clean stop, raises ScriptAborted\n"
        "#\n"
        "# Level constants (for Verbose() and Log()):\n"
        "#   OFF, ERROR, WARNING, INFO, DEBUG\n"
        "#\n"
        "# Preloaded modules:\n"
        "#   os, sys, time, math, datetime, timedelta, re, json, csv, Path, np\n"
        "\n"
        "Log(\"Hello from {name}\")\n"
    )

    file_saved = pyqtSignal(Path)
    run_requested = pyqtSignal(Path)

    def __init__(self, scripts_dir: Path, initial: Optional[Path] = None, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Script Editor")
        self.scripts_dir = scripts_dir
        self.scripts_dir.mkdir(parents=True, exist_ok=True)
        self.current_path: Optional[Path] = None
        self._dirty = False

        layout = QVBoxLayout(self)

        # ---- File picker row ----
        picker_row = QHBoxLayout()
        picker_row.addWidget(QLabel("File:"))
        self.file_combo = QComboBox()
        self.file_combo.setEditable(False)
        self.file_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        picker_row.addWidget(self.file_combo, 1)
        self.new_btn = QPushButton("New...")
        self.save_as_btn = QPushButton("Save As...")
        picker_row.addWidget(self.new_btn)
        picker_row.addWidget(self.save_as_btn)
        layout.addLayout(picker_row)

        # ---- Editor ----
        self.editor = QPlainTextEdit()
        font = QFont("Monospace")
        font.setStyleHint(QFont.StyleHint.TypeWriter)
        font.setPointSize(10)
        self.editor.setFont(font)
        self.editor.setTabStopDistance(4 * self.editor.fontMetrics().horizontalAdvance(" "))
        layout.addWidget(self.editor, 1)

        # ---- Status row ----
        status_row = QHBoxLayout()
        self.position_label = QLabel("Line 1, Col 1")
        status_row.addWidget(self.position_label)
        status_row.addStretch()
        self.dirty_label = QLabel("")
        status_row.addWidget(self.dirty_label)
        layout.addLayout(status_row)

        # ---- Buttons ----
        button_row = QHBoxLayout()
        button_row.addStretch()
        self.save_btn = QPushButton("Save")
        self.save_run_btn = QPushButton("Save && Exit")
        self.close_btn = QPushButton("Close")
        button_row.addWidget(self.save_btn)
        button_row.addWidget(self.save_run_btn)
        button_row.addWidget(self.close_btn)
        layout.addLayout(button_row)

        # Wire signals
        self.editor.textChanged.connect(self._on_text_changed)
        self.editor.cursorPositionChanged.connect(self._update_position)
        self.file_combo.currentIndexChanged.connect(self._on_combo_changed)
        self.new_btn.clicked.connect(self._on_new)
        self.save_as_btn.clicked.connect(self._on_save_as)
        self.save_btn.clicked.connect(self._on_save)
        self.save_run_btn.clicked.connect(self._on_save_and_run)
        self.close_btn.clicked.connect(self._on_close)

        self.resize(820, 600)
        self._refresh_file_list(select=initial)

    def _refresh_file_list(self, select: Optional[Path] = None):
        self.file_combo.blockSignals(True)
        self.file_combo.clear()
        self.file_combo.addItem("<no file loaded>", None)
        for p in sorted(self.scripts_dir.glob("*.py")):
            self.file_combo.addItem(p.name, p)
        if select is not None:
            for i in range(self.file_combo.count()):
                data = self.file_combo.itemData(i)
                if isinstance(data, Path) and data == select:
                    self.file_combo.setCurrentIndex(i)
                    break
        self.file_combo.blockSignals(False)
        idx = self.file_combo.currentIndex()
        if idx >= 0:
            self._load_index(idx)

    def _load_index(self, index: int):
        data = self.file_combo.itemData(index)
        if isinstance(data, Path) and data.exists():
            try:
                text = data.read_text(encoding="utf-8")
            except Exception as e:
                QMessageBox.warning(self, "Script Editor", f"Cannot read {data}: {e}")
                return
            self.current_path = data
            self.editor.blockSignals(True)
            self.editor.setPlainText(text)
            self.editor.blockSignals(False)
            self._dirty = False
            self.dirty_label.setText("")
        else:
            self.current_path = None
            self.editor.blockSignals(True)
            self.editor.setPlainText("")
            self.editor.blockSignals(False)
            self._dirty = False
            self.dirty_label.setText("")

    def _on_combo_changed(self, index: int):
        if self._dirty and not self._confirm_discard():
            # Revert combo to current_path
            self.file_combo.blockSignals(True)
            target = -1
            for i in range(self.file_combo.count()):
                if self.file_combo.itemData(i) == self.current_path:
                    target = i
                    break
            if target >= 0:
                self.file_combo.setCurrentIndex(target)
            self.file_combo.blockSignals(False)
            return
        self._load_index(index)

    def _on_text_changed(self):
        if not self._dirty:
            self._dirty = True
            self.dirty_label.setText("(unsaved changes)")

    def _update_position(self):
        cursor = self.editor.textCursor()
        line = cursor.blockNumber() + 1
        col = cursor.columnNumber() + 1
        self.position_label.setText(f"Line {line}, Col {col}")

    def _on_new(self):
        if self._dirty and not self._confirm_discard():
            return
        name, ok = QInputDialog.getText(
            self, "New Script", "File name (without .py):"
        )
        if not ok or not name.strip():
            return
        stem = name.strip()
        if stem.endswith(".py"):
            stem = stem[:-3]
        path = self.scripts_dir / f"{stem}.py"
        if path.exists():
            QMessageBox.warning(self, "Script Editor", f"{path.name} already exists")
            return
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        template = self.NEW_TEMPLATE.format(name=path.name, ts=ts)
        try:
            path.write_text(template, encoding="utf-8")
        except Exception as e:
            QMessageBox.warning(self, "Script Editor", f"Could not create {path}: {e}")
            return
        self._refresh_file_list(select=path)
        self.file_saved.emit(path)

    def _on_save_as(self):
        if not self.editor.toPlainText().strip():
            QMessageBox.information(self, "Script Editor", "Nothing to save.")
            return
        name, ok = QInputDialog.getText(
            self, "Save Script As", "File name (without .py):"
        )
        if not ok or not name.strip():
            return
        stem = name.strip()
        if stem.endswith(".py"):
            stem = stem[:-3]
        path = self.scripts_dir / f"{stem}.py"
        try:
            path.write_text(self.editor.toPlainText(), encoding="utf-8")
        except Exception as e:
            QMessageBox.warning(self, "Script Editor", f"Cannot write {path}: {e}")
            return
        self.current_path = path
        self._dirty = False
        self.dirty_label.setText("")
        self._refresh_file_list(select=path)
        self.file_saved.emit(path)

    def _on_save(self):
        if self.current_path is None:
            self._on_save_as()
            return
        try:
            self.current_path.write_text(self.editor.toPlainText(), encoding="utf-8")
        except Exception as e:
            QMessageBox.warning(self, "Script Editor", f"Cannot write {self.current_path}: {e}")
            return
        self._dirty = False
        self.dirty_label.setText("")
        self.file_saved.emit(self.current_path)

    def _on_save_and_run(self):
        """Save the current buffer, then close the editor. The "Loaded"
        dropdown in the Scripting panel auto-syncs to whichever
        file was last open here (see ScriptControlWidget._on_edit)."""
        self._on_save()
        if self.current_path is not None and not self._dirty:
            self.accept()

    def _on_close(self):
        if self._dirty and not self._confirm_discard():
            return
        self.reject()

    def _confirm_discard(self) -> bool:
        reply = QMessageBox.question(
            self, "Script Editor",
            "You have unsaved changes. Discard them?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        return reply == QMessageBox.StandardButton.Yes


class ScriptControlWidget(QWidget):
    """The 'Scripting' panel: dropdown of scripts, status indicator,
    Start/Stop/Edit buttons, last-run info. Sits at the top of the right
    column, above Node Properties."""

    # Status state -> (display label, theme token attribute name).
    # Idle uses text_muted (neutral grey/brown depending on theme) rather
    # than accent — on Atacama the accent is a terracotta red, which made
    # idle look like an error.
    _STATUS_STYLES = {
        "idle":     ("Idle",    "text_muted"),
        "running":  ("Running", "status_ok"),
        "stopped":  ("Stopped", "status_warn"),
        "aborted":  ("Aborted", "status_warn"),
        "error":    ("Error",   "status_error"),
    }

    log_emitted = pyqtSignal(str, str)  # forwarded log lines (level, msg)

    def __init__(self, scripts_dir: Path, output_dir_provider: Callable[[], Path], parent=None):
        super().__init__(parent)
        self.scripts_dir = scripts_dir
        self._output_dir_provider = output_dir_provider
        self.worker: Optional["OpcUaWorker"] = None
        self.runner: Optional[ScriptRunner] = None
        self._status = "idle"
        self._last_run_at: Optional[datetime] = None
        self._last_output_file: Optional[Path] = None
        # Timezone preference for the script-facing Iso() built-in.
        # Pushed in by the main window from script_iso_utc; default
        # is local time.
        self._iso_use_utc: bool = False
        # Reference to a currently-open Script Editor window (non-modal).
        # Held so Edit re-clicks bring the existing window forward rather
        # than stacking new copies.
        self._editor_dialog: Optional[ScriptEditorDialog] = None

        self.scripts_dir.mkdir(parents=True, exist_ok=True)

        layout = QVBoxLayout(self)
        # Top margin is 0 so this widget's title row starts at the same
        # y-coordinate as the Node Browser's title row across the
        # splitter — both rows then vertically center-align their
        # contents and the section titles end up on the same midline.
        # Bottom margin kept small so the separator + splitter handle
        # don't add up to a visible gap above Node Properties.
        layout.setContentsMargins(0, 0, 0, 2)
        # Tight inter-row spacing keeps the title / action / last-run /
        # separator stack visually compact.
        layout.setSpacing(2)

        # Title row:  Scripting  [<script dropdown>]   ● <status>
        # Combines what used to be three separate rows (title, dropdown,
        # status) into one — saves vertical space. The dropdown sits in
        # the middle and expands to fill available width; long script
        # names are elided in the closed view but visible in the popup.
        title_row = QHBoxLayout()
        title = QLabel("Scripting")
        apply_section_title_style(title)
        title_row.addWidget(title)

        self.file_combo = QComboBox()
        self.file_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        self.file_combo.setMinimumWidth(120)
        self.file_combo.setToolTip("Loaded script")
        title_row.addWidget(self.file_combo, 1)

        self.status_dot = QLabel("●")
        self.status_text = QLabel("Idle")
        title_row.addWidget(self.status_dot)
        title_row.addWidget(self.status_text)
        layout.addLayout(title_row)

        # Single combined action row:
        #   [Run] [Stop] [Load] [Edit] Output [filename] [...] [ ] Auto [Load]
        # All controls live on one line to keep the Scripting panel
        # vertically compact. The script-name input is the only widget
        # that stretches; everything else is fixed-size. When the panel
        # is narrow the input shrinks first; the dropdown popup still
        # shows full names, and the field's tooltip carries the full
        # path. The two Load buttons are deliberately both called "Load"
        # — context (left cluster = scripts; right cluster = past
        # output files) plus tooltips disambiguate them.
        action_row = QHBoxLayout()
        action_row.setSpacing(4)

        self.start_btn = make_compact_button(
            "Run", tip="Run the loaded script")
        self.stop_btn = make_compact_button(
            "Stop", tip="Request the running script to stop")
        self.load_btn = make_compact_button(
            "Load", tip="Load a script file")
        self.edit_btn = make_compact_button(
            "Edit", tip="Open the Script Editor")
        for b in (self.start_btn, self.stop_btn, self.load_btn, self.edit_btn):
            action_row.addWidget(b)

        action_row.addSpacing(8)
        action_row.addWidget(QLabel("Output"))

        self.output_file_input = QLineEdit()
        self.output_file_input.setPlaceholderText("auto-generated on Run")
        self.output_file_input.setToolTip(
            "Filename Store() writes to (within the configured output directory)"
        )
        # Allow the field to shrink quite far before the row spills off
        # the panel — the popup picker and tooltip cover full names.
        self.output_file_input.setMinimumWidth(60)
        action_row.addWidget(self.output_file_input, 1)

        self.output_file_browse_btn = make_compact_button(
            "…", tip="Pick an output .dat file")
        self.output_file_browse_btn.clicked.connect(self._on_browse_output_file)
        action_row.addWidget(self.output_file_browse_btn)

        self.output_auto_check = QCheckBox("Auto")
        self.output_auto_check.setToolTip(
            "Auto: generate a fresh timestamped filename for each run.\n"
            "Off: keep the same filename and append across runs."
        )
        self.output_auto_check.setChecked(True)
        self.output_auto_check.toggled.connect(self._on_output_auto_toggled)
        action_row.addWidget(self.output_auto_check)

        self.output_btn = make_compact_button(
            "Load", tip="Browse and open a past output file")
        action_row.addWidget(self.output_btn)

        layout.addLayout(action_row)
        self._apply_auto_state()

        # Last-run info
        self.last_run_label = QLabel("Last run: —")
        self.last_run_label.setStyleSheet(f"color: {THEME.active.text_muted};")
        layout.addWidget(self.last_run_label)

        # NB: no addStretch() here — we want "Last run" to sit directly
        # above the separator and the splitter handle, with no empty
        # space below it. The user can drag the splitter to make the
        # Scripting panel taller if they want more breathing room;
        # without a stretch, the extra space falls below the splitter
        # handle (in Node Properties) rather than below "Last run".

        # Thin horizontal rule to visually separate this section from
        # Node Properties below it in the right-column splitter. Uses
        # border_strong (not border) so it stays visible on the darker
        # surfaces in Dark and Atacama; on Light it reads as a clean
        # mid-grey rather than nearly invisible. Drawn as a Plain HLine
        # with an explicit 1px width — Sunken HLine adds a second
        # highlight line that on dark themes looked like the rule was
        # missing entirely because both halves blended into the surface.
        self._separator = QFrame()
        self._separator.setFrameShape(QFrame.Shape.HLine)
        self._separator.setFrameShadow(QFrame.Shadow.Plain)
        self._separator.setLineWidth(1)
        self._apply_separator_style()
        layout.addWidget(self._separator)
        THEME.theme_changed.connect(lambda _t: self._apply_separator_style())

        # Wiring
        self.start_btn.clicked.connect(self._on_start)
        self.stop_btn.clicked.connect(self._on_stop)
        self.load_btn.clicked.connect(self._on_load)
        self.edit_btn.clicked.connect(self._on_edit)
        self.output_btn.clicked.connect(self._on_output)
        self.file_combo.currentIndexChanged.connect(lambda _i: self._refresh_start_enabled())
        self.stop_btn.setEnabled(False)
        self._refresh_start_enabled()

        THEME.theme_changed.connect(lambda _t: self._refresh_status_style())
        THEME.theme_changed.connect(lambda _t: self._refresh_muted_labels())

        self.refresh_file_list()
        self._refresh_status_style()
        self._refresh_output_dir_label()

    # ----- Wiring helpers -----

    def set_worker(self, worker: Optional["OpcUaWorker"]):
        self.worker = worker

    def set_iso_use_utc(self, use_utc: bool):
        """Push the timezone preference for the script-facing Iso() helper.
        Picked up at Run time (each ScriptRunner instance captures it)."""
        self._iso_use_utc = bool(use_utc)

    def refresh_file_list(self):
        current = self.file_combo.currentData()
        self.file_combo.blockSignals(True)
        self.file_combo.clear()
        self.file_combo.addItem("<no script>", None)
        for p in sorted(self.scripts_dir.glob("*.py")):
            self.file_combo.addItem(p.name, p)
        # Restore selection
        for i in range(self.file_combo.count()):
            if self.file_combo.itemData(i) == current:
                self.file_combo.setCurrentIndex(i)
                break
        self.file_combo.blockSignals(False)

    def _refresh_output_dir_label(self):
        """Kept as a no-op so legacy call sites still work. The output
        file is now visible directly in the output_file_input field."""
        return

    # ----- Output file controls -----

    def _apply_auto_state(self):
        """Enable / disable the filename field + browse button based on the
        Auto checkbox. When Auto is on, the field is read-only (it shows
        whatever the last run generated, or empty)."""
        auto = self.output_auto_check.isChecked()
        self.output_file_input.setReadOnly(auto)
        self.output_file_browse_btn.setEnabled(not auto)

    def _on_output_auto_toggled(self, _checked: bool):
        self._apply_auto_state()

    def _on_browse_output_file(self):
        """Pick an output .dat file via QFileDialog, rooted in the
        configured script output directory. Only the basename of the
        picked file is stored in the field — the directory is implied
        by the configured output dir."""
        try:
            output_dir = self._output_dir_provider()
        except Exception:
            output_dir = Path.home()
        try:
            output_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            pass
        existing = self.output_file_input.text().strip()
        if existing:
            # Treat existing field as basename within output_dir for the
            # dialog's initial location.
            start_path = str(output_dir / existing)
        else:
            start_path = str(output_dir)
        # Explicit QFileDialog + non-native widget so resize() actually
        # takes effect (the native backend tends to inherit a very wide
        # remembered geometry).
        dialog = QFileDialog(self, "Output File", start_path)
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        dialog.setNameFilters(["Script data (*.dat)", "All files (*)"])
        dialog.setOption(QFileDialog.Option.DontConfirmOverwrite, True)
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        self.output_file_input.setText(Path(files[0]).name)

    def get_output_file_state(self) -> Tuple[str, bool]:
        """Returns (filename, auto). Used to persist UI state."""
        return self.output_file_input.text().strip(), self.output_auto_check.isChecked()

    def set_output_file_state(self, filename: Optional[str], auto: Optional[bool]):
        """Restore filename + auto from persisted settings. Either may be
        None to leave the corresponding field at its default. Settings
        from older versions may contain full paths; strip to basename."""
        if auto is not None:
            self.output_auto_check.setChecked(bool(auto))
        if filename is not None:
            self.output_file_input.setText(Path(str(filename)).name or str(filename))
        self._apply_auto_state()

    def _refresh_muted_labels(self):
        muted = THEME.active.text_muted
        self.last_run_label.setStyleSheet(f"color: {muted};")

    def _apply_separator_style(self):
        """Repaint the HLine separator. The right tone depends on theme
        surface luminance: a too-dark rule on Atacama looked like a heavy
        underline; a too-light rule on Dark vanished. Pick per-theme:

        - Light:   border_strong (mid grey, ~#555)
        - Dark:    border_strong (lighter grey, ~#6a7079)
        - Atacama: border        (warm mid-brown, ~#5a3622) — softer than
                                  border_strong's near-black #3a2010

        Sets both ``color`` and ``background-color`` so the rule renders
        consistently across Fusion and platform Qt styles."""
        theme = THEME.active
        if theme.name == "Atacama":
            c = theme.border
        else:
            c = theme.border_strong
        self._separator.setStyleSheet(
            f"QFrame {{ color: {c}; background-color: {c}; max-height: 1px; }}"
        )

    def _refresh_status_style(self):
        label, token = self._STATUS_STYLES.get(self._status, self._STATUS_STYLES["idle"])
        color = getattr(THEME.active, token, THEME.active.text_muted)
        self.status_dot.setStyleSheet(f"color: {color}; font-size: 14pt;")
        self.status_text.setText(label)

    def _set_status(self, status: str):
        self._status = status
        self._refresh_status_style()

    # ----- Button handlers -----

    def _selected_path(self) -> Optional[Path]:
        data = self.file_combo.currentData()
        return data if isinstance(data, Path) else None

    def _refresh_start_enabled(self):
        """Start is enabled only when a script is selected AND no run is
        in progress. Worker connectivity is checked at click time, not here,
        so the button doesn't flicker on every connect/disconnect."""
        has_selection = self._selected_path() is not None
        running = bool(self.runner and self.runner.isRunning())
        self.start_btn.setEnabled(has_selection and not running)

    def _on_start(self):
        path = self._selected_path()
        if not path:
            QMessageBox.information(self, "Scripting", "No script selected.")
            return
        if not self.worker or not self.worker.running:
            QMessageBox.warning(self, "Scripting",
                                "Not connected to a server. Connect first.")
            return
        if self.runner and self.runner.isRunning():
            return
        try:
            output_dir = self._output_dir_provider()
        except Exception as e:
            QMessageBox.warning(self, "Scripting", f"Output directory invalid: {e}")
            return

        # Output file selection:
        # - Auto ON: runner generates a fresh timestamped <stem>_<iso>.dat;
        #            the field is stamped with that basename in
        #            _on_run_finished.
        # - Auto OFF: take the field as a basename inside output_dir, in
        #            append mode.
        auto = self.output_auto_check.isChecked()
        explicit_data_file: Optional[Path] = None
        data_append = False
        if not auto:
            name = self.output_file_input.text().strip()
            if name:
                # Always treat the field as a basename — strip any
                # directory parts a user might have pasted in.
                basename = Path(name).name
                if not basename:
                    basename = name
                if Path(basename).suffix == "":
                    basename = basename + ".dat"
                explicit_data_file = output_dir / basename
                data_append = True
                # Reflect the normalized basename back into the field.
                self.output_file_input.setText(basename)
        self.runner = ScriptRunner(
            path, self.worker, output_dir,
            data_file=explicit_data_file,
            data_append=data_append,
            use_utc=self._iso_use_utc,
            parent=self,
        )
        self.runner.log_line.connect(self.log_emitted.emit)
        self.runner.status_changed.connect(self._set_status)
        self.runner.run_finished.connect(self._on_run_finished)
        self.stop_btn.setEnabled(True)
        self._refresh_start_enabled()
        self.runner.start()

    def _on_stop(self):
        if self.runner and self.runner.isRunning():
            self.runner.request_stop()

    def _on_run_finished(self, success: bool, summary: str):
        self._last_run_at = datetime.now()
        ts = self._last_run_at.strftime("%Y-%m-%d %H:%M:%S")
        self.last_run_label.setText(f"Last run: {ts} — {summary}")
        if self.runner:
            # The per-run .log was dropped — the runner only writes a .dat
            # when Store() is used. Use that as the "last output file" so
            # the Output button still has something useful to open.
            self._last_output_file = getattr(self.runner, "_data_file", None)
            # When Auto was on, the runner generated a fresh data filename.
            # Stamp it into the field so the user can see what was written,
            # and so toggling Auto off keeps that file. When Auto was off
            # the field already holds the path; leave it alone.
            if self.output_auto_check.isChecked():
                data_file = getattr(self.runner, "_data_file", None)
                if data_file is not None:
                    # Only the basename — directory is implied.
                    self.output_file_input.setText(Path(data_file).name)
        self.stop_btn.setEnabled(False)
        self._refresh_start_enabled()

    def _on_load(self):
        """Pick a script .py file. Scoped to the scripts folder by
        default; the user can navigate elsewhere if they really want to.

        Uses an explicit non-native QFileDialog so we can fix the opening
        size — matches the other Load dialogs in the app."""
        self.scripts_dir.mkdir(parents=True, exist_ok=True)
        dialog = QFileDialog(self, "Load Script", str(self.scripts_dir))
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["Python scripts (*.py)", "All files (*)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        path = Path(files[0])
        if not path.exists():
            return
        # Refresh the dropdown so the picked file is present, then select it.
        self.refresh_file_list()
        for i in range(self.file_combo.count()):
            data = self.file_combo.itemData(i)
            if isinstance(data, Path) and data.resolve() == path.resolve():
                self.file_combo.setCurrentIndex(i)
                return
        # File was outside the scripts folder — add a one-off entry so
        # the user can run it without copying. It won't appear after a
        # refresh, but that's fine for ad-hoc runs.
        self.file_combo.addItem(path.name + "  (external)", path)
        self.file_combo.setCurrentIndex(self.file_combo.count() - 1)

    def _on_edit(self):
        """Open the Script Editor as a NON-MODAL window so the user can
        keep interacting with the main panel (browse the Node Browser,
        copy node IDs / paths, watch values) while writing a script.

        The editor is owned by this widget but uses Qt.Window so it shows
        up with its own title bar and decorations (resizable, minimizable).
        Re-opening Edit while an editor is already open just brings the
        existing one to the front instead of stacking new ones."""
        # If an editor is already open, just bring it forward.
        existing = getattr(self, "_editor_dialog", None)
        if existing is not None:
            try:
                if existing.isVisible():
                    existing.raise_()
                    existing.activateWindow()
                    return
            except RuntimeError:
                # Dialog was destroyed; fall through and open a new one.
                pass

        dlg = ScriptEditorDialog(
            self.scripts_dir,
            initial=self._selected_path(),
            parent=self,
        )
        # Window flags: top-level window, not a modal child. Setting
        # WindowFlags to Window gives it native window-frame controls.
        dlg.setWindowFlag(Qt.WindowType.Window, True)
        dlg.setModal(False)
        dlg.file_saved.connect(lambda _p: self.refresh_file_list())
        dlg.run_requested.connect(self._run_from_editor)
        dlg.finished.connect(self._on_editor_finished)
        # Delete the dialog when closed so we don't accumulate stale state
        # (e.g. references to a removed script file) across edits.
        dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
        self._editor_dialog = dlg
        dlg.show()
        dlg.raise_()
        dlg.activateWindow()

    def _on_editor_finished(self, _result: int):
        """Replaces the old post-exec() logic: refresh the file list and
        auto-select the last-edited file in the panel combo."""
        dlg = self._editor_dialog
        self._editor_dialog = None
        # Capture last_edited BEFORE the WA_DeleteOnClose dialog is gone.
        last_edited = getattr(dlg, "current_path", None) if dlg is not None else None
        self.refresh_file_list()
        if last_edited is not None:
            for i in range(self.file_combo.count()):
                data = self.file_combo.itemData(i)
                if isinstance(data, Path) and data == last_edited:
                    self.file_combo.setCurrentIndex(i)
                    break

    def _run_from_editor(self, path: Path):
        # Set the combo to the saved file then trigger Start
        for i in range(self.file_combo.count()):
            if self.file_combo.itemData(i) == path:
                self.file_combo.setCurrentIndex(i)
                break
        self._on_start()

    def _on_output(self):
        """Pick a per-run output file to view. Scoped to the configured
        script output directory; pre-selects the most recent run if any.

        The default filter shows both .log and .dat so users can find
        Store() data files alongside Log() text files. "All files" is also
        available for output files with custom extensions."""
        try:
            output_dir = self._output_dir_provider()
        except Exception as e:
            QMessageBox.warning(self, "Scripting", f"Output directory invalid: {e}")
            return
        try:
            output_dir.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            QMessageBox.warning(self, "Scripting",
                                f"Could not create output dir {output_dir}: {e}")
            return
        # Prefer the most recent run's log/data; otherwise the explicit
        # output file from the field; otherwise just the folder.
        start_path = str(output_dir)
        if self._last_output_file and self._last_output_file.exists():
            start_path = str(self._last_output_file)
        elif self.output_file_input.text().strip():
            candidate = Path(self.output_file_input.text().strip()).expanduser()
            if not candidate.is_absolute():
                candidate = output_dir / candidate
            if candidate.exists():
                start_path = str(candidate)
        # Use an explicit QFileDialog so we can set a sensible starting
        # size — the static getOpenFileName() helper can otherwise reuse
        # Qt's last remembered dialog geometry, which is often very wide.
        dialog = QFileDialog(self, "View Script Output", start_path)
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters([
            "Script files (*.log *.dat)",
            "Log files (*.log)",
            "Data files (*.dat)",
            "All files (*)",
        ])
        # Use Qt's own dialog — the native backend often ignores resize().
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        viewer = _FileViewerDialog(
            Path(files[0]), title="View Script Output", parent=self
        )
        viewer.exec()


class UaExplorer(QMainWindow):
    def __init__(self, override_home: Optional[Path] = None):
        super().__init__()
        base_home = Path.home() if override_home is None else Path(override_home)
        preferred_settings_dir = base_home / ".uatools" / "UaExplorer"
        self.settings_dir = preferred_settings_dir
        self.settings_dir.mkdir(parents=True, exist_ok=True)

        # Sessions directory
        self.sessions_dir = self.settings_dir / "sessions"
        self.sessions_dir.mkdir(parents=True, exist_ok=True)

        # Scripts directory (the user-authored .py files live here). The
        # per-run output directory is user-configurable in Preferences and
        # lives OUTSIDE .uatools by default — same convention as recording
        # output. NB: older versions used a "sequences" folder; if you
        # had files there, copy them over manually.
        self.scripts_dir = self.settings_dir / "scripts"
        self.scripts_dir.mkdir(parents=True, exist_ok=True)
        self.script_output_dir: str = str(Path.home() / "uaexplorer_script_runs")
        # Iso() timezone preference: False = local time, True = UTC.
        self.script_iso_utc: bool = False

        self.settings_file = self.settings_dir / "settings.json"
        # Persistent namespace cache (SQLite): on connect, load the last full
        # browse of a URI from disk for instant tree population, then revalidate
        # in the background. Global on/off, stored in settings.json.
        self.nscache_file = self.settings_dir / "nscache.db"
        self.nscache_enabled: bool = True
        # True once the current tree was populated from the on-disk cache this
        # session (pending background revalidation).
        self._nscache_from_disk: bool = False
        # Set for the duration of a from-cache load so the ETA recorder skips
        # it (a near-instant cache load must not overwrite the server-browse
        # timing). Mirrors the _last_load_was_scoped guard.
        self._last_load_was_from_cache: bool = False
        # True while a background revalidation browse is in flight, so its
        # nodes_loaded lands as an atomic swap without disturbing visible state.
        self._nscache_revalidating: bool = False
        self.uri_history: List[str] = []
        # URI -> friendly name. Global per-user mapping, persisted in
        # settings.json. Independent of sessions (sessions store the
        # URI; the name comes from this dict at display time). Empty /
        # missing -> the Name field shows its "-" placeholder.
        self.server_names: Dict[str, str] = {}
        # Lazy Node Loading: per-URI node-count limit above which a namespace is
        # loaded lazily. Overridden per-URI from settings.json; missing URIs use
        # DEFAULT_LAZY_LOADING_LIMIT.
        self.lazy_loading_limits: Dict[str, int] = {}
        # Per-URI remembered View (Scope) name. When the user switches to a URI,
        # its last-used View is restored (render-only; browse-prune applies on the
        # next Connect/Rebrowse). Missing URI -> global last-used -> Default.
        self.view_by_uri: Dict[str, str] = {}
        # Per-URI node count of the last COMPLETE load, used as the "target" for
        # the browse ETA on subsequent loads (we can't know the total up front).
        self.namespace_size_by_uri: Dict[str, int] = {}
        # Timestamp of the current browse start (set when a browse begins), for
        # the ETA / rate shown in on_loading_progress.
        self._browse_ui_start: Optional[float] = None
        # Durations of the last load: total (browse+render) and render-only, for
        # the ETA and the static pre-render estimate respectively.
        self._last_load_seconds: float = 0.0
        self._last_render_seconds: float = 0.0
        # Snapshot: was the last completed browse View-scoped? Captured in
        # on_nodes_loaded so on_namespace_load_mode reads a stable value.
        self._last_load_was_scoped: bool = False
        # Browse-prune spec the current cache was loaded with — compared on a
        # View switch to decide render-only vs auto-Rebrowse. ("full",) = unpruned.
        self._loaded_browse_spec = ("full",)
        # True while programmatically restoring a View (so on_scope_selected
        # doesn't re-record it as a fresh user choice).
        self._restoring_view: bool = False
        # Global Quick Filter node limit (all URIs): above this the Quick Filter
        # requires an explicit Return to search (no live filtering).
        self.quick_filter_node_limit: int = DEFAULT_QUICK_FILTER_NODE_LIMIT
        # Current Quick Filter styling state: normal | pending | searched.
        self._quick_filter_style_state: str = "normal"
        # Current namespace load state ("complete" / "partial") and the estimated
        # total node count, set from the worker's namespace_load_mode signal.
        self._namespace_load_mode: str = "complete"
        self._namespace_estimate: int = 0
        self._logger = logging.getLogger(__name__)
        mtime = Path(__file__).stat().st_mtime
        self._last_update_date = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")
        self._last_update_timestamp = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
        # Remember the exact startup script so Restart uses the same path the user launched
        try:
            candidate = Path(sys.argv[0]).resolve()
            self._startup_script = candidate if candidate.exists() else Path(__file__).resolve()
        except Exception:
            self._startup_script = Path(__file__).resolve()
        self.log_suppressed = False
        self.log_undocked = False
        self.log_dialog = None
        self.namespace_cache: Dict[str, Dict[str, Any]] = {}

        # Scope subsystem: active Scope decides which cached nodes are
        # visible in the tree. Built-in scopes are written to disk on
        # first run; the user can edit/delete/save more.
        self.scope_store = ScopeStore()
        self.scope_store.ensure_builtins()
        self.active_scope: Optional[Scope] = None
        self._load_initial_scope()

        self.worker = None
        self.plot_bridge = UaPlotBridge(self)

        # Track plotted variables for session save/restore
        self.plotted_variables: List[Dict[str, Any]] = []

        # Auto-reconnect state
        # Disabled until user presses 'Connect'
        self.auto_reconnect_enabled = False
        self._user_requested_disconnect = False
        self._last_uri = ""
        self._had_successful_connection = False
        self._auth_username: Optional[str] = None
        self._auth_password: Optional[str] = None
        self._connect_button_state: str = "disconnected"
        self._spinner_active = False

        # Periodic reconnect timer (~1 second)
        # Reconnect timer: ticks every second; the handler decides whether
        # to actually attempt a reconnect based on (a) whether we ever had
        # a successful connection, (b) whether the user explicitly
        # disconnected, and (c) a 5-second cool-down between attempts so
        # we don't hammer the server with retries.
        self._reconnect_timer = QTimer(self)
        self._reconnect_timer.setInterval(1000)
        self._reconnect_timer.timeout.connect(self._on_reconnect_timer)
        self._reconnect_timer.start()
        # Wall-clock of the last reconnect attempt (or disconnect, when
        # we first enter the reconnect loop). The handler waits at least
        # RECONNECT_INTERVAL_S between attempts. None = never tried yet.
        self._reconnect_last_attempt: Optional[float] = None
        self.RECONNECT_INTERVAL_S = 5.0
        # Periodic WARNING heartbeat so a long-running disconnect is
        # visible in the log without flooding it. The first warning is
        # emitted immediately when the reconnect loop arms; subsequent
        # warnings repeat every RECONNECT_WARN_PERIOD_S seconds.
        self.RECONNECT_WARN_PERIOD_S = 30.0
        self._reconnect_last_warn: Optional[float] = None
        self._auth_prompt_open = False

        # Background method discovery through the loaded tree
        self._method_scan_queue: List[QTreeWidgetItem] = []
        self._method_scan_pending: set[str] = set()
        self._method_scan_timer = QTimer(self)
        self._method_scan_timer.setInterval(150)
        self._method_scan_timer.timeout.connect(self._method_scan_tick)

        self.setup_ui()
        self.load_settings()
        # Re-sync the URI -> name display now that load_settings has
        # populated both self.server_names AND self._current_uri.
        # setup_ui ran first and called this against empty state, so
        # without this second call the field stays blank until the
        # user typed-and-reverted the URI.
        self._refresh_server_name_display()
        # Same for the per-URI Lazy limit controls: setup_ui seeded them from
        # the DEFAULT uri (checkbox on, 10000) before settings loaded, so
        # without this re-sync the restored URI's stored limit (e.g. 0 =
        # disabled) is ignored and the wrong limit gets pushed on Connect.
        self._refresh_lazy_limit_display()
        # Live re-skinning hooks: when the user picks a different theme
        # in Preferences, refresh the bits whose styling isn't covered
        # by Qt's QPalette (custom stylesheets, foreground colors set
        # directly on tree items, recording-button colors, etc.).
        THEME.theme_changed.connect(self._on_theme_changed_live)


    def setup_ui(self):
        # Updated window title with last update timestamp of this file
        self.setWindowTitle(f"UA Explorer - {self._last_update_timestamp}")
        self.setGeometry(100, 100, 1200, 800)

        central_widget = QWidget()
        self.setCentralWidget(central_widget)

        main_layout = QVBoxLayout()
        # Trim the default 9px QVBoxLayout margins so the log panel sits
        # close to the status bar — the default leaves a visible gap that
        # users perceive as wasted space, especially at small log heights.
        main_layout.setContentsMargins(4, 4, 4, 0)
        main_layout.setSpacing(4)

        # The top "ribbon" holds the menu, URI input, indicator and the
        # Connect button. Wrap it in a QWidget so we can paint a
        # theme-coloured background on it (a layout alone has no paint
        # surface). The background colour comes from the active theme's
        # panel_bg and follows live theme switches.
        # Use a QFrame instead of plain QWidget — QFrame paints its own
        # background reliably (plain QWidget often doesn't, even with
        # WA_StyledBackground, when nested in a QMainWindow's central
        # widget). The background colour is pushed through autoFill +
        # palette so it works regardless of stylesheet inheritance.
        self.top_ribbon = QFrame()
        self.top_ribbon.setObjectName("topRibbon")
        self.top_ribbon.setFrameShape(QFrame.Shape.NoFrame)
        self.top_ribbon.setAutoFillBackground(True)
        connection_layout = QHBoxLayout(self.top_ribbon)
        connection_layout.setContentsMargins(6, 4, 6, 4)
        connection_layout.setSpacing(8)

        def _apply_ribbon_style():
            t = THEME.active
            pal = self.top_ribbon.palette()
            pal.setColor(QPalette.ColorRole.Window, QColor(t.panel_bg))
            self.top_ribbon.setPalette(pal)
            # Stylesheet adds the bottom border (palette can't do borders).
            self.top_ribbon.setStyleSheet(
                f"QFrame#topRibbon {{ background: {t.panel_bg};"
                f" border-bottom: 1px solid {t.border_subtle}; }}"
            )
        _apply_ribbon_style()
        THEME.theme_changed.connect(lambda _t: _apply_ribbon_style())

        # Menu button (hamburger)
        self.menu_button = QToolButton()
        self.menu_button.setText("=")
        self.menu_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
        self.menu_button.setAutoRaise(True)
        self.menu_button.setToolTip("Menu")
        self.menu_button.clicked.connect(self.show_main_menu)
        connection_layout.addWidget(self.menu_button)

        connection_layout.addWidget(QLabel("URI"))

        self.uri_input = QComboBox()
        self.uri_input.setEditable(True)
        self.uri_input.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
        self.uri_input.setMaxCount(20)
        self.uri_input.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
        # Monospace the POPUP list only (not the line edit) so the two-column
        # "URI   name" rows align — space-padding needs a fixed-width font.
        # Use the platform's guaranteed fixed-width font (QFont("Monospace")
        # doesn't resolve everywhere), and ALSO set it per-item via FontRole so
        # it survives an app stylesheet overriding the view font.
        self._uri_popup_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
        self._uri_popup_font.setPointSize(NODE_TREE_FONT_PT)
        self.uri_input.view().setFont(self._uri_popup_font)
        self.uri_input.setEditText("opc.tcp://localhost:4840")
        # Track current URI to detect changes
        self._current_uri = "opc.tcp://localhost:4840"
        # Connect signal for URI changes (when user presses Enter or selects from dropdown)
        self.uri_input.lineEdit().editingFinished.connect(self._on_uri_changed)
        self.uri_input.activated.connect(self._on_uri_selected)
        connection_layout.addWidget(self.uri_input, 1)

        # User-editable friendly name for the current URI. Mirrors the
        # UaPlot pattern: self.server_names is a global URI->name dict
        # persisted to settings, so the same URI on a future run / on
        # a session load shows the same name automatically.
        connection_layout.addSpacing(8)
        connection_layout.addWidget(QLabel("Name:"))
        self.server_name_edit = QLineEdit()
        self.server_name_edit.setPlaceholderText("-")
        self.server_name_edit.setToolTip(
            "Friendly name for this server URI. Saved per URI; the same\n"
            "URI on a future run will show the same name automatically."
        )
        self.server_name_edit.setMaximumWidth(180)
        # Frameless, transparent — match the UaPlot styling so it sits
        # quietly alongside the URI combo rather than reading as a
        # separate framed input.
        self.server_name_edit.setFrame(False)
        def _apply_name_edit_style():
            color = THEME.active.text_primary
            self.server_name_edit.setStyleSheet(
                f"QLineEdit {{ background: transparent; padding: 0px; "
                f"color: {color}; }}"
            )
        _apply_name_edit_style()
        # Qt treats stylesheet'd widgets as opaque for palette
        # propagation, so a theme switch won't restyle this field
        # automatically. Re-apply on theme change to keep the text
        # color in sync with the rest of the URI row.
        THEME.theme_changed.connect(lambda _t: _apply_name_edit_style())
        self.server_name_edit.editingFinished.connect(self._on_server_name_edited)
        connection_layout.addWidget(self.server_name_edit)

        # Per-URI Lazy Node Loading control. The checkbox enables/disables lazy
        # loading; when enabled, the spinbox sets the node limit above which the
        # namespace loads partially (deeper nodes on expand). Disabled -> always
        # load fully (effective limit 0). Saved per URI in settings.json
        # (lazy_loading_limits), same model as the Name.
        connection_layout.addSpacing(8)
        self.lazy_enable_check = QCheckBox("Lazy limit:")
        self.lazy_enable_check.setToolTip(
            "Enable Lazy Node Loading for this server URI.\n"
            "On: namespaces larger than the limit load PARTIALLY (deeper nodes\n"
            "load on expand). Off: always load the whole namespace.\n"
            "Saved per URI; takes effect on the next Connect / Rebrowse."
        )
        # Match the 11px font used by the other URI-row labels (Name/View/
        # Filter) so "Lazy limit:" doesn't tower over its neighbours; re-apply
        # on theme change since a stylesheet'd widget won't inherit palette-only
        # font changes.
        def _style_lazy_check():
            self.lazy_enable_check.setStyleSheet(
                f"QCheckBox {{ font-size: 11px; color: {THEME.active.text_primary}; }}"
            )
        _style_lazy_check()
        THEME.theme_changed.connect(lambda _t: _style_lazy_check())
        self.lazy_enable_check.setChecked(True)
        self.lazy_enable_check.toggled.connect(self._on_lazy_enable_toggled)
        connection_layout.addWidget(self.lazy_enable_check)

        self.lazy_limit_spin = QSpinBox()
        self.lazy_limit_spin.setRange(1, 100_000_000)
        self.lazy_limit_spin.setSingleStep(1000)
        self.lazy_limit_spin.setGroupSeparatorShown(True)
        self.lazy_limit_spin.setValue(DEFAULT_LAZY_LOADING_LIMIT)
        self.lazy_limit_spin.setToolTip(
            "Lazy Node Loading limit for this server URI.\n"
            "Namespaces larger than this load PARTIALLY (deeper nodes load on\n"
            "expand); the rest can be loaded on demand.\n"
            "Saved per URI; takes effect on the next Connect / Rebrowse."
        )
        self.lazy_limit_spin.setMaximumWidth(110)
        self.lazy_limit_spin.editingFinished.connect(self._on_lazy_limit_edited)
        connection_layout.addWidget(self.lazy_limit_spin)

        # Inline spinner/indicator shown next to the URI field
        self._spinner_frames = ["|", "/", "-", "\\"]
        self._spinner_index = 0
        self.spinner_label = QLabel("")
        self.spinner_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.spinner_label.setFixedWidth(22)
        self.spinner_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self._set_indicator_idle()
        connection_layout.addWidget(self.spinner_label)

        # Lazy Node Loading status badge: whether the namespace is loaded
        # COMPLETELY or only PARTIALLY (with a node count). Updated from the
        # worker's namespace_load_mode signal via on_namespace_load_mode.
        self.load_status_badge = QLabel("")
        self.load_status_badge.setToolTip(
            "Namespace load status. COMPLETE = the whole namespace is loaded.\n"
            "PARTIAL = Lazy Node Loading is active; only part of the namespace\n"
            "is loaded (deeper nodes load on expand). Raise the Lazy limit or\n"
            "load the whole namespace to make it COMPLETE."
        )
        self._set_load_status_badge(None)
        # Re-render with theme-appropriate colours on a theme switch (the badge
        # colour is picked per theme so PARTIAL stays legible on each surface).
        THEME.theme_changed.connect(lambda _t: self._refresh_load_status_badge_style())
        connection_layout.addWidget(self.load_status_badge)

        self._spinner_timer = QTimer(self)
        self._spinner_timer.setInterval(100)
        self._spinner_timer.timeout.connect(self._advance_spinner)

        self.connect_button = QPushButton("Connect")
        self.connect_button.clicked.connect(self.toggle_connection)
        self.connect_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self._set_connect_button_state("disconnected")
        connection_layout.addWidget(self.connect_button)

        # Stash the layout so we can splice in the Subscription/Polling
        # radios after SubscriptionWidget has been constructed — those
        # widgets logically belong on this URI row (they describe how
        # we talk to *this server*, not how the Subscriptions table is
        # displayed). Done in _move_mode_radios_to_uri_row below.
        self._uri_row_layout = connection_layout

        main_layout.addWidget(self.top_ribbon)

        # Main splitter with adjustable layout
        main_splitter = QSplitter(Qt.Orientation.Vertical)
        main_splitter.setHandleWidth(3)
        self.main_splitter = main_splitter

        # Top section with node browser and properties
        top_widget = QWidget()
        top_layout = QHBoxLayout()
        top_layout.setContentsMargins(0, 0, 0, 0)

        # Left side - Node browser
        browser_widget = QWidget()
        browser_layout = QVBoxLayout()
        browser_layout.setContentsMargins(0, 0, 0, 0)

        browser_header = QHBoxLayout()
        browser_title = QLabel("Node Browser")
        apply_section_title_style(browser_title)
        browser_header.addWidget(browser_title)
        browser_header.setSpacing(8)

        # Tree-wide Expand / Collapse buttons. Sit next to the title so
        # they're spatially associated with the tree, separate from the
        # action cluster (Rebrowse / Subscribe / Plot / Scope) further right.
        self.tree_expand_button = make_compact_button(
            "Expand", tip="Expand all visible nodes in the tree")
        self.tree_expand_button.clicked.connect(self.on_expand_all_tree)
        browser_header.addWidget(self.tree_expand_button)
        self.tree_collapse_button = make_compact_button(
            "Collapse", tip="Collapse all visible nodes in the tree")
        self.tree_collapse_button.clicked.connect(self.on_collapse_all_tree)
        browser_header.addWidget(self.tree_collapse_button)

        actions_layout = QHBoxLayout()
        actions_layout.setContentsMargins(0, 0, 0, 0)
        actions_layout.setSpacing(4)

        # Action buttons (left cluster) — Rebrowse / Subscribe / Plot.
        self.rebrowse_button = make_compact_button(
            "Rebrowse", tip="Re-browse the server's namespace")
        self.rebrowse_button.clicked.connect(self.rebrowse_nodes)
        self.rebrowse_button.setEnabled(False)
        actions_layout.addWidget(self.rebrowse_button)

        self.subscribe_button = make_compact_button(
            "Subscribe", tip="Subscribe to the selected variable nodes")
        self.subscribe_button.clicked.connect(self.subscribe_selected_nodes)
        self.subscribe_button.setEnabled(False)
        actions_layout.addWidget(self.subscribe_button)

        self.plot_button = make_compact_button(
            "Plot", tip="Plot the selected variable")
        self.plot_button.clicked.connect(self.plot_selected_node)
        self.plot_button.setEnabled(False)
        actions_layout.addWidget(self.plot_button)

        # Scope cluster — same row, separated by a small visual gap.
        actions_layout.addSpacing(12)
        scope_label = QLabel("View")
        # Include color: a stylesheet without a color makes Qt drop the
        # QPalette WindowText role on theme switches, leaving the label
        # stuck at whatever color it had at first paint (white from Light,
        # invisible on Atacama). Setting it explicitly + re-applying on
        # theme_changed keeps the label legible across all themes.
        def _style_scope_label():
            scope_label.setStyleSheet(
                f"font-size: 11px; color: {THEME.active.text_primary};"
            )
        _style_scope_label()
        THEME.theme_changed.connect(lambda _t: _style_scope_label())
        actions_layout.addWidget(scope_label)

        self.scope_combo = QComboBox()
        self.scope_combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
        self.scope_combo.setMinimumContentsLength(8)
        self.scope_combo.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        # Match the compact toolbar height so the combo doesn't tower over
        # the buttons next to it. Re-styles automatically on theme change.
        self.scope_combo.setStyleSheet(build_compact_input_style())
        THEME.theme_changed.connect(
            lambda _t: self.scope_combo.setStyleSheet(build_compact_input_style())
        )
        self.scope_combo.currentTextChanged.connect(self.on_scope_selected)
        actions_layout.addWidget(self.scope_combo)

        self.scope_edit_button = make_compact_button(
            "Edit", tip="Edit the active view (Save As is inside the dialog)")
        self.scope_edit_button.clicked.connect(self.on_edit_scope)
        actions_layout.addWidget(self.scope_edit_button)

        # Inline ad-hoc filter — applied on top of the active scope.
        # Substring match against node path, live-updated on every keystroke.
        # Empty input = no extra filtering. Not persisted across sessions.
        actions_layout.addSpacing(8)
        filter_label = QLabel("Filter")
        # Same theme-switch caveat as the View label above.
        def _style_filter_label():
            filter_label.setStyleSheet(
                f"font-size: 11px; color: {THEME.active.text_primary};"
            )
        _style_filter_label()
        THEME.theme_changed.connect(lambda _t: _style_filter_label())

        # Small spinner to the LEFT of the "Filter" label, shown while a filter
        # search is running (the tree rebuild on a big cache is not instant).
        # Idle = blank so it doesn't clutter the row.
        self.filter_spinner_label = QLabel("")
        self.filter_spinner_label.setFixedWidth(14)
        self.filter_spinner_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.filter_spinner_label.setSizePolicy(
            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)

        def _style_filter_spinner():
            self.filter_spinner_label.setStyleSheet(
                f"font-size: 11px; color: {THEME.active.text_muted}; font-weight: bold;")
        _style_filter_spinner()
        THEME.theme_changed.connect(lambda _t: _style_filter_spinner())
        actions_layout.addWidget(self.filter_spinner_label)

        self._filter_spinner_index = 0
        self._filter_spinner_timer = QTimer(self)
        self._filter_spinner_timer.setInterval(100)
        self._filter_spinner_timer.timeout.connect(self._advance_filter_spinner)

        actions_layout.addWidget(filter_label)
        self.scope_filter_input = QLineEdit()
        self.scope_filter_input.setPlaceholderText("pattern... (a+b = AND)")
        self.scope_filter_input.setToolTip(
            "Substring filter on node paths (case-insensitive).\n"
            "Use '+' to combine terms with AND, e.g. 'stat+temp'\n"
            "matches paths containing both 'stat' and 'temp'."
        )
        self.scope_filter_input.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self.scope_filter_input.setFixedWidth(160)
        self.scope_filter_input.setStyleSheet(build_compact_input_style())
        # Re-apply the base style AND the current pending/searched colour on a
        # theme switch (setStyleSheet with the base alone would wipe the
        # orange/green Quick Filter state).
        THEME.theme_changed.connect(
            lambda _t: self._set_quick_filter_style(
                getattr(self, "_quick_filter_style_state", "normal"))
        )
        # Debounce: re-render only 250 ms after the user stops typing.
        # textChanged fires on every keystroke; if we filtered immediately
        # each keystroke would do a tree refresh on every char, which is
        # noticeably laggy on big namespaces (8000+ nodes). The QTimer
        # collapses bursts of edits into a single re-render.
        self._scope_filter_debounce = QTimer(self)
        self._scope_filter_debounce.setSingleShot(True)
        self._scope_filter_debounce.setInterval(250)
        self._scope_filter_debounce.timeout.connect(self._filter_refresh_with_spinner)
        self.scope_filter_input.textChanged.connect(self._on_scope_filter_changed)
        # Return runs the search explicitly (required in large-namespace gated
        # mode; harmless on small caches that already filter live).
        self.scope_filter_input.returnPressed.connect(self._on_scope_filter_return)
        actions_layout.addWidget(self.scope_filter_input)
        # Clear-filter button. ASCII 'X' (instead of the unicode ✕) so it
        # renders reliably across system fonts that lack the unicode glyph.
        # We override the compact-button padding to 0 horizontal so the
        # single-character label isn't clipped at this small width.
        self.scope_filter_clear_button = make_compact_button(
            "X", tip="Clear the inline filter")
        self.scope_filter_clear_button.setFixedWidth(22)
        # Override the compact-button padding to 0 horizontal so the
        # single-character label isn't clipped at this small width.
        def _style_clear_btn():
            t = THEME.active
            self.scope_filter_clear_button.setStyleSheet(
                f"QPushButton {{ padding: 2px 0; font-size: 11px;"
                f" border: 1px solid {t.border}; border-radius: 4px;"
                f" background: {t.panel_bg}; color: {t.text_primary};"
                f" min-height: 18px; max-height: 22px; }}"
                f"QPushButton:hover {{ background: {t.status_error};"
                f" color: {t.text_on_accent}; border-color: {t.border_strong}; }}"
                f"QPushButton:pressed {{ background: {t.panel_pressed_bg}; }}"
            )
        _style_clear_btn()
        THEME.theme_changed.connect(lambda _t: _style_clear_btn())
        self.scope_filter_clear_button.clicked.connect(self.scope_filter_input.clear)
        actions_layout.addWidget(self.scope_filter_clear_button)

        browser_header.addLayout(actions_layout)
        browser_header.addStretch()

        browser_layout.addLayout(browser_header)
        # Populate the scope combo now that it exists.
        self._populate_scope_combo()

        self.subscription_widget = SubscriptionWidget(None)
        # Subscription / Polling radios live on the SubscriptionWidget's
        # toolbar by historical accident; move them up to the URI row
        # where they actually belong (per-server policy, not a
        # per-subscription display setting).
        self._move_mode_radios_to_uri_row()

        self.node_tree = NodeTreeWidget(None, self.subscription_widget)
        self.node_tree.itemClicked.connect(self.on_node_selected)
        self.node_tree.itemSelectionChanged.connect(self.on_selection_changed)
        self.node_tree.method_invocation_requested.connect(self.on_method_invocation_requested)
        self.node_tree.plot_requested.connect(self.on_plot_requested)
        self.subscription_widget.plot_requested.connect(self.on_subscriptions_plot_requested)

        self.methods_quick_access = MethodsQuickAccessWidget()
        self.methods_quick_access.method_invocation_requested.connect(self.on_method_invocation_requested)

        browser_splitter = QSplitter(Qt.Orientation.Horizontal)
        browser_splitter.addWidget(self.node_tree)
        browser_splitter.addWidget(self.methods_quick_access)
        browser_splitter.setSizes([450, 250])
        browser_layout.addWidget(browser_splitter)

        browser_widget.setLayout(browser_layout)

        # Right side - stacked vertically: Scripting / Node Properties.
        # NodePropertiesWidget already embeds the Read/Write Value section
        # at its bottom, so the visible stack is effectively three sections:
        #   [Scripting] [Node Properties] [Read/Write Value]
        self.properties_widget = NodePropertiesWidget(None)
        self.script_widget = ScriptControlWidget(
            self.scripts_dir,
            self._script_output_dir,
            None,
        )
        self.script_widget.set_iso_use_utc(self.script_iso_utc)
        self.script_widget.log_emitted.connect(self._on_script_log)

        right_splitter = QSplitter(Qt.Orientation.Vertical)
        right_splitter.addWidget(self.script_widget)
        right_splitter.addWidget(self.properties_widget)
        # Match the slim main-splitter handle so the gap between
        # Scripting and Node Properties is just a thin draggable line.
        right_splitter.setHandleWidth(3)
        right_splitter.setSizes([200, 600])
        self._right_splitter = right_splitter

        # Horizontal splitter for browser and the right column
        top_splitter = QSplitter(Qt.Orientation.Horizontal)
        top_splitter.addWidget(browser_widget)
        top_splitter.addWidget(right_splitter)
        top_splitter.setSizes([600, 400])  # Initial sizes

        top_widget.setLayout(QHBoxLayout())
        top_widget.layout().addWidget(top_splitter)
        top_widget.layout().setContentsMargins(0, 0, 0, 0)

        main_splitter.addWidget(top_widget)
        main_splitter.addWidget(self.subscription_widget)
        # The [U] button undocks/docks the Subscriptions panel into a floating
        # window (same idiom as the log panel).
        self.subscription_widget.undock_requested.connect(
            self.toggle_subscription_dock)
        # State flag + handle to the floating dialog when undocked.
        self.subscription_undocked = False
        self.subscription_dialog = None

        self.log_widget = LogWidget()
        # The [X] in the LogWidget header asks to be hidden. We honour it
        # by suppressing the log (same code path as the log toggle), and
        # we keep the toggle in sync so the user can bring it back.
        self.log_widget.close_requested.connect(self._on_log_close_requested)
        # The [U] button toggles dock state — same handler as the menu's
        # "Undock log" / "Dock log" entry.
        self.log_widget.undock_requested.connect(self.toggle_log_dock)
        main_splitter.addWidget(self.log_widget)

        # Set initial sizes: top section larger, subscription medium, log smaller
        main_splitter.setSizes([500, 200, 100])

        main_layout.addWidget(main_splitter)
        central_widget.setLayout(main_layout)

        # Global keyboard shortcut to restart the panel. Bound to
        # Ctrl+Shift+R — same mnemonic as a browser "hard reload" and
        # deliberately requires Shift to avoid accidental triggers from
        # muscle memory (Ctrl+R is sometimes used for "Rebrowse" semantics
        # in other apps, so we leave it free for that).
        self.restart_shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self)
        self.restart_shortcut.activated.connect(self.restart_application)

        # Ctrl+Shift+L toggles the log panel's visibility. Same Shift
        # rationale as the Restart shortcut — Ctrl+L alone is too easy
        # to hit by accident (and is used as "focus URL bar" in many
        # apps). Toggle goes through set_log_suppressed so the menu
        # checkbox and the log_settings persistence stay in sync.
        self.log_toggle_shortcut = QShortcut(QKeySequence("Ctrl+Shift+L"), self)
        self.log_toggle_shortcut.activated.connect(self._toggle_log_suppressed)

        # Now that both server_names (from settings) and server_name_edit
        # (just built) exist, populate the Name field for whatever URI
        # the input shows. Same for the per-URI Lazy limit spinbox.
        self._refresh_server_name_display()
        self._refresh_lazy_limit_display()
        # Restore the View last used for the startup URI (per-URI memory).
        self._restore_view_for_uri(
            (self._current_uri or self.uri_input.currentText() or "").strip())

        self.statusBar().showMessage("Ready - UA Explorer")

    def show_main_menu(self):
        """Display top-level menu actions."""
        menu = QMenu(self)

        # Recompute dock state from widget placement
        self.log_undocked = not self._is_log_docked()

        # Session management
        save_session_action = QAction("Save Session...", self)
        save_session_action.triggered.connect(self.save_session_dialog)
        menu.addAction(save_session_action)

        load_session_action = QAction("Load Session...", self)
        load_session_action.triggered.connect(self.load_session_dialog)
        menu.addAction(load_session_action)

        menu.addSeparator()

        about_action = QAction("About", self)
        about_action.triggered.connect(self.show_about_dialog)
        menu.addAction(about_action)

        prefs_action = QAction("Preferences", self)
        prefs_action.triggered.connect(self.show_preferences_dialog)
        menu.addAction(prefs_action)

        log_toggle = QAction("Show log\tCtrl+Shift+L", self, checkable=True)
        log_toggle.setChecked(not self.log_suppressed)
        log_toggle.toggled.connect(lambda checked: self.set_log_suppressed(not checked))
        menu.addAction(log_toggle)

        dock_label = "Undock log" if self._is_log_docked() else "Dock log"
        dock_action = QAction(dock_label, self)
        dock_action.setEnabled(not self.log_suppressed)
        dock_action.triggered.connect(self.toggle_log_dock)
        menu.addAction(dock_action)

        menu.addSeparator()

        dump_ns_action = QAction("Export Namespace...", self)
        dump_ns_action.setEnabled(bool(self.namespace_cache))
        dump_ns_action.triggered.connect(self.dump_namespace)
        menu.addAction(dump_ns_action)

        menu.addSeparator()

        restart_action = QAction("Restart", self)
        restart_action.setShortcut(QKeySequence("Ctrl+Shift+R"))
        restart_action.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
        restart_action.triggered.connect(self.restart_application)
        menu.addAction(restart_action)

        exit_action = QAction("Exit", self)
        exit_action.triggered.connect(self.close)
        menu.addAction(exit_action)

        menu.exec(self.menu_button.mapToGlobal(self.menu_button.rect().bottomLeft()))

    def show_about_dialog(self):
        """Show an overview of UA Tools and UA Explorer.

        The dialog text is selectable so users can paste it into bug reports.
        If the UA Tools logo can be located via the file-resolution rules, it
        is shown above the text and rescales with the dialog width; otherwise
        the dialog falls back to a text-only layout.
        """
        class _ScalingLogo(QLabel):
            """QLabel that rescales its source pixmap to its current width,
            keeping the original aspect ratio. Re-paints on every resize."""

            def __init__(self, source: QPixmap, parent=None):
                super().__init__(parent)
                self._source = source
                self.setAlignment(Qt.AlignmentFlag.AlignCenter)
                self.setMinimumHeight(1)
                policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
                policy.setHeightForWidth(True)
                self.setSizePolicy(policy)
                self._rescale()

            def _rescale(self):
                width = max(self.width(), 1)
                if self._source.isNull():
                    return
                target_h = self.heightForWidth(width)
                scaled = self._source.scaled(
                    width,
                    target_h,
                    Qt.AspectRatioMode.KeepAspectRatio,
                    Qt.TransformationMode.SmoothTransformation,
                )
                self.setPixmap(scaled)
                self.setFixedHeight(target_h)

            def resizeEvent(self, event):  # noqa: N802 (Qt naming)
                self._rescale()
                super().resizeEvent(event)

            def hasHeightForWidth(self):  # noqa: N802
                return True

            def heightForWidth(self, w):  # noqa: N802
                if self._source.isNull() or self._source.width() == 0:
                    return 0
                return int(w * self._source.height() / self._source.width())

        # Unified label column width keeps the Storage / Version blocks
        # visually aligned. The longest label is "Working/home directory:"
        # (23 chars); 25 leaves a 2-space gutter before the value.
        LBL = 25
        summary = (
            "ESO IFW — UA Tools\n"
            "\n"
            "UA Tools is the ESO Instrument Framework collection of utilities\n"
            "for working with OPC UA servers used in instrument control. It\n"
            "provides client-side tools for browsing namespaces, exercising\n"
            "methods, recording data, and scripting — used both during the\n"
            "development of OPC UA control servers and for diagnostics on\n"
            "deployed instruments. Preparing first release under the UA Tools\n"
            "project name.\n"
            "\n"
            "UA Explorer\n"
            "\n"
            "A PyQt6 GUI client for any OPC UA server. Built to run either as\n"
            "a single-file standalone script (no install step) or from a\n"
            "regular INTROOT / PREFIX deployment. A companion UaPlot process\n"
            "is launched on demand for live charting.\n"
            "\n"
            "Capabilities\n"
            "\n"
            "  Browsing\n"
            "    - Full namespace browse with parallel children loading\n"
            "    - Named Views (formerly Scopes): include/exclude patterns +\n"
            "      namespace filters, persisted to disk\n"
            "    - Inline path/name filter on top of the active View\n"
            "    - Built-in TwinCAT view hides Beckhoff/PLC infrastructure\n"
            "    - Tree-wide Expand / Collapse buttons\n"
            "\n"
            "  Node interaction\n"
            "    - Properties view with ready-to-paste Script snippet on top\n"
            "    - Subscribe or Poll variables; configurable polling interval\n"
            "    - Read / Write values; strict mode honours server access\n"
            "      bits (Write disabled when not reported writable)\n"
            "    - Invoke methods; auto-discovered input args dialog\n"
            "    - Plot variable history in the companion UaPlot tool\n"
            "    - Drag & drop variables from the Node Browser into\n"
            "      Subscriptions to subscribe quickly\n"
            "\n"
            "  Scripting (Python scripts run inside the GUI)\n"
            "    - Non-modal editor; main panel stays usable while editing\n"
            "    - Built-ins: Read / Write / Execute / Wait / WaitUntil /\n"
            "      Log / Store / Iso / Verbose / Abort\n"
            "    - Preloaded: os, sys, time, math, datetime, timedelta, re,\n"
            "      json, csv, Path, np (numpy if installed)\n"
            "    - Cooperative Stop button; cancellable Wait()\n"
            "    - Store() writes tab-separated rows to a per-run .dat;\n"
            "      Auto-name per run, or pick a fixed filename\n"
            "    - Verbose(INFO|DEBUG|OFF) auto-logs each op call\n"
            "\n"
            "  Recording\n"
            "    - Periodic capture of all subscriptions to CSV\n"
            "    - Stop modes: Until stopped / Period of time / Samples\n"
            "    - Fresh timestamped filename per run, or Overwrite mode\n"
            "    - Load button to open past recordings with the OS viewer\n"
            "\n"
            "  Sessions\n"
            "    - Save / Load full session: URI, View, subscriptions, plots,\n"
            "      monitor mode and polling interval\n"
            "\n"
            "  Export Namespace\n"
            "    - Save the loaded namespace as TXT / CSV / JSON / YAML\n"
            "    - OPC UA NodeSet2 XML for interop with UaModeler, Prosys,\n"
            "      asyncua's import_xml, etc.\n"
            "\n"
            "  Appearance\n"
            "    - Three themes: Light / Dark / Atacama; switched live\n"
            "    - UaPlot shares the active theme over IPC\n"
            "    - Configurable log-panel visible lines and Subscriptions\n"
            "      table row count\n"
            "\n"
            "Storage\n"
            "\n"
            f"{'Working/home directory:':<{LBL}}$HOME/.uatools/UaExplorer\n"
            f"{'Settings file:':<{LBL}}$HOME/.uatools/UaExplorer/settings.json\n"
            f"{'Views:':<{LBL}}$HOME/.uatools/UaExplorer/views/\n"
            f"{'Sessions:':<{LBL}}$HOME/.uatools/UaExplorer/sessions/\n"
            f"{'Scripts:':<{LBL}}$HOME/.uatools/UaExplorer/scripts/\n"
            f"{'Script output dir:':<{LBL}}configurable (Preferences); default\n"
            f"{'':<{LBL}}~/uaexplorer_script_runs/\n"
            f"{'Recording output dir:':<{LBL}}configurable (Preferences)\n"
            "\n"
            "Keyboard\n"
            "\n"
            f"{'Subscribe (Node Browser):':<{LBL}}S\n"
            f"{'Remove subscription:':<{LBL}}Delete\n"
            f"{'Restart panel:':<{LBL}}Ctrl+Shift+R\n"
            f"{'Toggle log panel:':<{LBL}}Ctrl+Shift+L\n"
            "\n"
            "Version\n"
            "\n"
            f"{'Source file:':<{LBL}}{Path(__file__).resolve()}\n"
            f"{'Last update:':<{LBL}}{self._last_update_timestamp}"
        )

        dlg = QDialog(self)
        dlg.setWindowTitle("About UA Explorer")
        dlg.setSizeGripEnabled(True)
        layout = QVBoxLayout(dlg)

        logo_widget = None
        logo_path = find_file(
            "image/uatools/EsoOpcUaToolsLogo.png",
            parent=Path(__file__).resolve().parent.parent / "resource",
        )
        if logo_path is not None:
            pixmap = QPixmap(str(logo_path))
            if not pixmap.isNull():
                logo_widget = _ScalingLogo(pixmap)
                layout.addWidget(logo_widget)
            else:
                self._logger.debug("About dialog: QPixmap failed to load %s", logo_path)

        text = QTextEdit()
        text.setReadOnly(True)
        # Monospace font so the column-aligned path/file list actually
        # lines up (proportional fonts make padded-with-spaces text look
        # uneven even when the character counts are correct).
        mono_font = QFont("Monospace")
        mono_font.setStyleHint(QFont.StyleHint.TypeWriter)
        text.setFont(mono_font)
        text.setPlainText(summary)
        text.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
            | Qt.TextInteractionFlag.TextSelectableByKeyboard
        )
        # Horizontal minimum keeps the column-aligned blocks from
        # wrapping. Vertical minimum is set MODEST (200 px) so the
        # dialog still shows a sensible amount of text on short screens
        # without forcing the layout minimum above the screen height.
        # The earlier 380 px was the cause of "dialog goes off-screen"
        # on shorter displays.
        text.setMinimumSize(480, 200)
        # Stretch=1 so any extra dialog height goes to the text widget,
        # not to the logo above it.
        layout.addWidget(text, 1)

        dlg.resize(560, 760)

        bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
        bbox.accepted.connect(dlg.accept)
        layout.addWidget(bbox)

        # Clamp the dialog to the available screen geometry and reposition
        # so the OK button can never end up below the screen edge. The
        # default 760 px height is comfortable on tall monitors but
        # exceeded the visible area on shorter ones, with the centered-
        # on-parent placement pushing the bottom off-screen.
        screen = self.screen() if hasattr(self, "screen") else None
        if screen is not None:
            avail = screen.availableGeometry()
            max_h = max(280, avail.height() - 60)
            max_w = max(360, avail.width() - 60)
            # If the screen is shorter than the default, cap the logo
            # so it doesn't push the layout's height minimum above the
            # available height.
            if logo_widget is not None and max_h < 760:
                logo_widget.setMaximumHeight(max(60, int(max_h * 0.25)))
            cur = dlg.size()
            clamp_w = min(cur.width(), max_w)
            clamp_h = min(cur.height(), max_h)
            if clamp_w != cur.width() or clamp_h != cur.height():
                dlg.resize(clamp_w, clamp_h)
            parent_geo = self.frameGeometry()
            x = parent_geo.center().x() - clamp_w // 2
            y = parent_geo.center().y() - clamp_h // 2
            x = max(avail.left(), min(x, avail.right() - clamp_w))
            y = max(avail.top(), min(y, avail.bottom() - clamp_h))
            dlg.move(x, y)

        dlg.exec()

    def show_preferences_dialog(self):
        """Grouped, editable preferences dialog.

        The dialog is the canonical place to change settings that the
        user updates infrequently (theme, recording defaults, monitor
        mode). Some of the same controls also live in toolbars for
        quick access — we read FROM the toolbar widgets to seed the
        dialog and write BACK to the same widgets on Apply, so both
        views stay in sync without an extra source of truth.
        """
        dlg = QDialog(self)
        dlg.setWindowTitle("Preferences")
        # Default width was driven by the natural size of the form rows
        # (~360 px); too narrow for the longer path strings in Scripting
        # and Recording. Set an explicit minimum that comfortably shows
        # ~80-char path values without horizontal scrolling.
        dlg.setMinimumWidth(640)
        outer = QVBoxLayout()
        outer.setSpacing(8)

        # ---- Connection (read-only snapshot) ----
        conn_group = QGroupBox("Connection")
        conn_form = QFormLayout()
        conn_form.addRow("Current URI:", QLabel(self.uri_input.currentText() or "(not set)"))
        conn_group.setLayout(conn_form)
        outer.addWidget(conn_group)

        # ---- Monitor (editable: subscription/polling, polling interval) ----
        mon_group = QGroupBox("Monitor")
        mon_form = QFormLayout()
        mon_mode_combo = QComboBox()
        mon_mode_combo.addItems(["Subscription", "Polling"])
        mon_mode_combo.setCurrentText(
            "Polling" if self.subscription_widget.polling_radio.isChecked()
            else "Subscription"
        )
        mon_form.addRow("Monitor mode:", mon_mode_combo)

        mon_interval_input = QLineEdit(self.subscription_widget.polling_interval_input.text())
        mon_interval_input.setMaxLength(6)
        mon_interval_input.setFixedWidth(80)
        mon_form.addRow("Polling interval (ms):", mon_interval_input)
        mon_group.setLayout(mon_form)
        outer.addWidget(mon_group)

        # ---- Subscription (umbrella group) ----
        # Everything in here applies to the Subscriptions table /
        # SubscriptionWidget — visible rows, Recording (which captures
        # subscription values), and Statistics (per-row min/max/mean/
        # count over a sliding window of the subscribed value). Nesting
        # makes the scoping obvious: "stats" here are NOT a panel-wide
        # concept, they're a property of a subscription.
        sub_group = QGroupBox("Subscription")
        sub_outer = QVBoxLayout()
        sub_outer.setSpacing(8)
        sub_group.setLayout(sub_outer)

        # Subscription-level setting: how tall the table is by default.
        sub_top_form = QFormLayout()
        sub_rows_spin = QSpinBox()
        sub_rows_spin.setRange(1, 100)
        sub_rows_spin.setValue(
            self.subscription_widget.get_visible_subscription_rows()
            if hasattr(self.subscription_widget, "get_visible_subscription_rows") else 8
        )
        sub_rows_spin.setToolTip(
            "Number of subscription rows visible in the Subscriptions table.\n"
            "Additional rows scroll within the table."
        )
        sub_top_form.addRow("Visible subscriptions:", sub_rows_spin)
        sub_outer.addLayout(sub_top_form)

        # ---- Recording (nested under Subscription) ----
        rec_group = QGroupBox("Recording")
        rec_form = QFormLayout()
        rec = self.subscription_widget.export_recording_settings() \
            if hasattr(self.subscription_widget, "export_recording_settings") else {}
        rec_rate_input = QLineEdit(str(rec.get("rate_hz", "1")))
        # Hz: fractional rate 0.1 .. 999.9 (one decimal); "999.9" is 5 chars.
        rec_rate_input.setMaxLength(5)
        rec_rate_input.setFixedWidth(60)
        _pref_rate_validator = QDoubleValidator(0.1, 999.9, 1, rec_rate_input)
        _pref_rate_validator.setNotation(QDoubleValidator.Notation.StandardNotation)
        rec_rate_input.setValidator(_pref_rate_validator)
        rec_form.addRow("Rate (Hz):", rec_rate_input)
        rec_mode_combo = QComboBox()
        rec_mode_combo.addItems(["Until stopped", "Period of time", "Samples"])
        if rec.get("mode") in ("Until stopped", "Period of time", "Samples"):
            rec_mode_combo.setCurrentText(rec["mode"])
        rec_form.addRow("Mode:", rec_mode_combo)
        rec_limit_input = QLineEdit(str(rec.get("limit", "")))
        # Limit: integer 1 .. 9999 (no fractional sample).
        rec_limit_input.setMaxLength(4)
        rec_limit_input.setFixedWidth(60)
        rec_limit_input.setValidator(QIntValidator(1, 9999, rec_limit_input))
        rec_form.addRow("Limit:", rec_limit_input)
        rec_filename_input = QLineEdit(str(rec.get("filename", "")))
        rec_form.addRow("File:", rec_filename_input)
        rec_dir_input = QLineEdit(str(rec.get("directory", "")))
        rec_form.addRow("Directory:", rec_dir_input)
        rec_group.setLayout(rec_form)
        sub_outer.addWidget(rec_group)

        # ---- Statistics (nested under Subscription) ----
        # Shown as extra columns in the Subscriptions table. Numeric
        # rows get live values; boolean / string rows show "-". Window
        # size is the per-row sliding buffer length (deque maxlen).
        stats_group = QGroupBox("Statistics")
        stats_form = QFormLayout()
        cur_stats = (
            self.subscription_widget.subscription_stats
            if hasattr(self.subscription_widget, "subscription_stats") else {}
        )
        stats_enabled_check = QCheckBox(
            "Show statistics columns in the Subscriptions table"
        )
        stats_enabled_check.setChecked(bool(cur_stats.get("enabled")))
        stats_form.addRow("", stats_enabled_check)

        stats_min_check = QCheckBox("Min")
        stats_min_check.setChecked(bool(cur_stats.get("show_min", True)))
        stats_max_check = QCheckBox("Max")
        stats_max_check.setChecked(bool(cur_stats.get("show_max", True)))
        stats_mean_check = QCheckBox("Mean")
        stats_mean_check.setChecked(bool(cur_stats.get("show_mean", True)))
        stats_count_check = QCheckBox("Count")
        stats_count_check.setChecked(bool(cur_stats.get("show_count", True)))
        per_stat_row = QHBoxLayout()
        per_stat_row.setSpacing(12)
        per_stat_row.addWidget(stats_min_check)
        per_stat_row.addWidget(stats_max_check)
        per_stat_row.addWidget(stats_mean_check)
        per_stat_row.addWidget(stats_count_check)
        per_stat_row.addStretch(1)
        per_stat_container = QWidget()
        per_stat_container.setLayout(per_stat_row)
        stats_form.addRow("Show:", per_stat_container)

        stats_window_spin = QSpinBox()
        stats_window_spin.setRange(10, 1000000)
        stats_window_spin.setSingleStep(100)
        stats_window_spin.setValue(int(cur_stats.get("window_size") or 1000))
        stats_window_spin.setToolTip(
            "Sliding window of recent samples each subscription remembers\n"
            "for the stats columns. Older samples drop off as new ones\n"
            "arrive. Bigger = more representative average; smaller =\n"
            "more responsive to recent changes."
        )
        stats_form.addRow("Window size (samples):", stats_window_spin)
        stats_decimals_spin = QSpinBox()
        stats_decimals_spin.setRange(0, 12)
        stats_decimals_spin.setValue(int(cur_stats.get("decimals", 3)))
        stats_decimals_spin.setToolTip(
            "Number of decimal places shown for Min / Max / Mean.\n"
            "Higher = more precision but wider columns; lower = compact.\n"
            "Count is always shown as an integer."
        )
        stats_form.addRow("Decimals:", stats_decimals_spin)
        stats_group.setLayout(stats_form)
        sub_outer.addWidget(stats_group)

        outer.addWidget(sub_group)

        # ---- Scripting (output dir for per-run data) ----
        seq_group = QGroupBox("Scripting")
        seq_form = QFormLayout()
        seq_dir_input = QLineEdit(str(self.script_output_dir))
        seq_form.addRow("Output directory:", seq_dir_input)
        seq_form.addRow("Scripts folder:", QLabel(str(self.scripts_dir)))
        seq_iso_utc_check = QCheckBox("Use UTC for Iso()")
        seq_iso_utc_check.setChecked(bool(self.script_iso_utc))
        seq_iso_utc_check.setToolTip(
            "When checked, the Iso() built-in returns UTC time with a "
            "trailing 'Z'. When unchecked (default), it returns local "
            "time without a timezone suffix."
        )
        seq_form.addRow("Timezone:", seq_iso_utc_check)
        seq_group.setLayout(seq_form)
        outer.addWidget(seq_group)

        # ---- Node Browser (Quick Filter behavior on large namespaces) ----
        nb_group = QGroupBox("Node Browser")
        nb_form = QFormLayout()
        qf_limit_spin = QSpinBox()
        qf_limit_spin.setRange(0, 100_000_000)
        qf_limit_spin.setSingleStep(1000)
        qf_limit_spin.setGroupSeparatorShown(True)
        qf_limit_spin.setValue(int(getattr(self, "quick_filter_node_limit",
                                           DEFAULT_QUICK_FILTER_NODE_LIMIT)))
        qf_limit_spin.setToolTip(
            "Quick Filter node limit (applies to all URIs).\n"
            "When the loaded namespace exceeds this many nodes, the Quick\n"
            "Filter no longer filters as you type (which is slow on a big\n"
            "tree) — you type a filter and press Return to search explicitly.\n"
            "The filter text is orange while a search is pending, green once\n"
            "the search has run. 0 = always filter live (no limit)."
        )
        nb_form.addRow("Quick Filter node limit:", qf_limit_spin)

        nscache_check = QCheckBox("Cache namespaces on disk (faster reconnect)")
        nscache_check.setChecked(bool(getattr(self, "nscache_enabled", True)))
        nscache_check.setToolTip(
            "Persist each full namespace browse to an on-disk cache\n"
            "(~/.uatools/UaExplorer/nscache.db). Reconnecting to a server then\n"
            "loads its namespace instantly from disk and revalidates it with a\n"
            "full rebrowse in the background. Unchecked: every connect browses\n"
            "the server live."
        )
        nb_form.addRow("Persistent cache:", nscache_check)

        nb_group.setLayout(nb_form)
        outer.addWidget(nb_group)

        # ---- Log (visible-rows height) ----
        log_group = QGroupBox("Log")
        log_form = QFormLayout()
        log_lines_spin = QSpinBox()
        log_lines_spin.setRange(1, 50)
        log_lines_spin.setValue(
            self.log_widget.get_visible_lines() if self.log_widget else 4
        )
        log_lines_spin.setToolTip(
            "Number of log rows visible in the log panel.\n"
            "Internal buffer (older lines, scrollable) is unaffected."
        )
        log_form.addRow("Visible lines:", log_lines_spin)
        log_group.setLayout(log_form)
        outer.addWidget(log_group)

        # ---- Appearance (theme picker — applies live on Apply/OK) ----
        app_group = QGroupBox("Appearance")
        app_form = QFormLayout()
        theme_combo = QComboBox()
        theme_combo.addItems(list(THEMES.keys()))
        theme_combo.setCurrentText(THEME.active.name)
        app_form.addRow("Theme:", theme_combo)
        app_group.setLayout(app_form)
        outer.addWidget(app_group)

        # ---- About ----
        about_group = QGroupBox("About")
        about_form = QFormLayout()
        settings_label = QLabel(str(self.settings_file))
        settings_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
        about_form.addRow("Settings file:", settings_label)
        about_group.setLayout(about_form)
        outer.addWidget(about_group)

        # ---- Buttons ----
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok |
            QDialogButtonBox.StandardButton.Cancel |
            QDialogButtonBox.StandardButton.Apply
        )

        # Cap the dialog at the UA Explorer window's current height so
        # it never opens taller than its parent (which on short displays
        # would force the OK/Cancel/Apply bar below the screen edge).
        # The form groups live inside a QScrollArea so any overflow
        # scrolls vertically instead of stretching the dialog.
        form_container = QWidget()
        form_container.setLayout(outer)
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        scroll.setWidget(form_container)

        dlg_layout = QVBoxLayout(dlg)
        dlg_layout.setContentsMargins(8, 8, 8, 8)
        dlg_layout.setSpacing(6)
        dlg_layout.addWidget(scroll, 1)
        dlg_layout.addWidget(buttons)
        dlg.setSizeGripEnabled(True)

        # Clamp the dialog's initial and maximum height. Choose the
        # tighter of (parent window height) and (available screen height
        # minus a small headroom for the desktop's title bar/taskbar)
        # so we cover both "short window on a tall screen" and "short
        # screen overall".
        parent_h = self.height() if self.height() > 0 else 720
        screen_h = parent_h
        screen = self.screen() if hasattr(self, "screen") else None
        if screen is not None:
            avail = screen.availableGeometry()
            screen_h = max(280, avail.height() - 80)
        cap_h = max(320, min(parent_h, screen_h))
        dlg.setMaximumHeight(cap_h)
        dlg.resize(dlg.width() or 720, cap_h)

        def _apply():
            # Push values back to the toolbar widgets, which are the
            # canonical state for these settings during a session.
            mode = mon_mode_combo.currentText()
            if mode == "Polling":
                self.subscription_widget.polling_radio.setChecked(True)
            else:
                self.subscription_widget.subscription_radio.setChecked(True)
            self.subscription_widget.polling_interval_input.setText(
                mon_interval_input.text().strip() or "500"
            )
            # Recording values
            self.subscription_widget.record_rate_input.setText(
                rec_rate_input.text().strip() or "1"
            )
            self.subscription_widget.record_mode_combo.setCurrentText(
                rec_mode_combo.currentText()
            )
            self.subscription_widget.record_limit_input.setText(
                rec_limit_input.text().strip() or "10"
            )
            if rec_filename_input.text().strip():
                self.subscription_widget._set_record_filename(rec_filename_input.text().strip())
            if rec_dir_input.text().strip():
                self.subscription_widget.record_dir_input.setText(rec_dir_input.text().strip())
            # Re-sync the visible combined Output File field after touching
            # the legacy hidden inputs from Preferences.
            sw = self.subscription_widget
            if hasattr(sw, "record_output_input"):
                sw._set_record_output_path(
                    str(Path(sw.record_dir_input.text()) / sw.record_filename_input.text())
                )
            # Subscriptions table visible rows
            if hasattr(sw, "set_visible_subscription_rows"):
                sw.set_visible_subscription_rows(sub_rows_spin.value())
            # Subscription statistics
            if hasattr(sw, "set_subscription_stats"):
                sw.set_subscription_stats({
                    "enabled": bool(stats_enabled_check.isChecked()),
                    "show_min": bool(stats_min_check.isChecked()),
                    "show_max": bool(stats_max_check.isChecked()),
                    "show_mean": bool(stats_mean_check.isChecked()),
                    "show_count": bool(stats_count_check.isChecked()),
                    "window_size": int(stats_window_spin.value()),
                    "decimals": int(stats_decimals_spin.value()),
                })
            # Script output dir
            new_seq_dir = seq_dir_input.text().strip()
            if new_seq_dir:
                self.script_output_dir = new_seq_dir
                if hasattr(self, "script_widget"):
                    self.script_widget._refresh_output_dir_label()
            # Script Iso() timezone
            self.script_iso_utc = bool(seq_iso_utc_check.isChecked())
            if hasattr(self, "script_widget"):
                self.script_widget.set_iso_use_utc(self.script_iso_utc)
            # Quick Filter node limit (Node Browser)
            self.quick_filter_node_limit = int(qf_limit_spin.value())
            # Re-evaluate the filter styling/behavior for the current cache.
            if hasattr(self, "_update_quick_filter_mode"):
                self._update_quick_filter_mode()
            # Persistent namespace cache (Node Browser)
            self.nscache_enabled = bool(nscache_check.isChecked())
            # Log visible lines
            if self.log_widget:
                self.log_widget.set_visible_lines(log_lines_spin.value())
            # Theme — fires the theme_changed signal which restyles
            # everything live.
            THEME.set_theme(theme_combo.currentText())
            # Persist immediately so a crash before exit doesn't lose
            # the user's preference.
            try:
                self.save_settings()
            except Exception:
                pass

        buttons.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(_apply)
        buttons.accepted.connect(lambda: (_apply(), dlg.accept()))
        buttons.rejected.connect(dlg.reject)
        dlg.exec()

    def _on_theme_changed_live(self, _theme):
        """Re-apply per-item styling that the QPalette + global stylesheet
        rebuilds don't reach: tree-item foregrounds (set with QBrush on
        each item) and the connect button's coloured background."""
        # Connect button colors are derived from the theme on each call,
        # so just re-fire the setter.
        try:
            self._set_connect_button_state(self._connect_button_state)
        except Exception:
            pass
        # Re-color every node tree row from the new node-class palette.
        try:
            tree = getattr(self, "node_tree", None)
            if tree is not None and hasattr(tree, "_apply_node_style"):
                iterator = QTreeWidgetItemIterator(tree)
                while iterator.value():
                    item = iterator.value()
                    node_data = item.data(0, Qt.ItemDataRole.UserRole)
                    if isinstance(node_data, dict):
                        tree._apply_node_style(item, node_data)
                    iterator += 1
        except Exception:
            pass
        # Re-color the methods quick-access tree items.
        try:
            mqa = getattr(self, "methods_quick_access", None)
            if mqa is not None and hasattr(mqa, "update_methods"):
                # The tree is rebuilt from the cache, so call the same
                # refresh hook we use after a scope change.
                self.refresh_methods_quick_access()
        except Exception:
            pass
        # Push the theme to a running UaPlot, if any. Best-effort: short
        # timeout, swallow errors — UaPlot may be down or starting up.
        try:
            bridge = getattr(self, "plot_bridge", None)
            if bridge is not None and getattr(bridge, "process", None) is not None:
                bridge.send_request(
                    {"command": "set_theme", "name": _theme.name},
                    timeout_ms=500,
                )
        except Exception:
            pass

    def restart_application(self):
        """Restart the application process."""
        try:
            self.save_settings()
        except Exception:
            pass

        if self.worker:
            try:
                self.worker.stop()
                self.worker.wait()
            except Exception:
                pass
        if self.plot_bridge:
            try:
                self.plot_bridge.stop()
            except Exception:
                pass

        # Use the exact script that launched this instance to avoid hard-coded home paths
        script_path = getattr(self, "_startup_script", None)
        if not script_path or not Path(script_path).exists():
            script_path = Path(__file__).resolve()

        python = sys.executable
        os.execl(python, python, str(script_path), *sys.argv[1:])

    def _on_log_close_requested(self):
        """Slot for the [X] button on the log widget. Suppresses the
        log via the same path as the menu toggle, and persists the
        new state so it stays hidden across restarts."""
        self.set_log_suppressed(True)
        try:
            self.save_settings()
        except Exception:
            pass

    def _toggle_log_suppressed(self):
        """Flip the log visibility — invoked by the Ctrl+Shift+L
        global shortcut. Goes through set_log_suppressed + save so the
        menu checkbox and the persisted log_settings stay in sync."""
        self.set_log_suppressed(not self.log_suppressed)
        try:
            self.save_settings()
        except Exception:
            pass

    def set_log_suppressed(self, suppressed: bool):
        """Show/hide the log widget and stop/start updates."""
        self.log_suppressed = suppressed
        if self.log_undocked and self.log_dialog:
            self.log_dialog.setVisible(not suppressed)
            return

        # Ensure the log widget is attached to the splitter
        if self.main_splitter.indexOf(self.log_widget) == -1:
            self.main_splitter.addWidget(self.log_widget)
            self.log_undocked = False

        self.log_widget.setVisible(not suppressed)
        if not suppressed:
            # Restore a reasonable splitter size so the pane is visible
            sizes = self.main_splitter.sizes()
            if len(sizes) == 3 and sizes[2] == 0:
                self.main_splitter.setSizes([500, 200, 120])

    def toggle_log_dock(self):
        """Undock/dock the log widget."""
        self.log_undocked = not self._is_log_docked()
        if not self.log_undocked:
            self._undock_log()
        else:
            self._dock_log()

    def _undock_log(self):
        if self.log_undocked:
            return
        if not self.log_widget:
            return

        # Remove from splitter
        idx = self.main_splitter.indexOf(self.log_widget)
        if idx >= 0:
            self.main_splitter.widget(idx).setParent(None)

        class _LogDialog(QDialog):
            def __init__(self, parent_gateway):
                super().__init__(parent_gateway)
                self.gateway = parent_gateway

            def closeEvent(self, event):
                # On close, re-dock the log
                if self.gateway:
                    self.gateway._dock_log()
                event.accept()

        self.log_dialog = _LogDialog(self)
        self.log_dialog.setWindowTitle("Log")
        dlg_layout = QVBoxLayout()
        dlg_layout.addWidget(self.log_widget)
        self.log_dialog.setLayout(dlg_layout)
        self.log_dialog.resize(800, 200)
        self.log_dialog.show()
        self.log_undocked = True

        # Honor suppression state
        self.log_dialog.setVisible(not self.log_suppressed)

    def _dock_log(self):
        if not self.log_undocked:
            return
        if not self.log_widget:
            return

        # Remove from dialog
        if self.log_dialog:
            self.log_dialog.hide()
            self.log_dialog.setParent(None)
            self.log_dialog = None

        self.main_splitter.addWidget(self.log_widget)
        self.main_splitter.setSizes([500, 200, 100])
        self.log_undocked = False
        self.log_widget.setVisible(not self.log_suppressed)

    # ---- Subscriptions panel dock/undock (mirrors the log panel) -------
    def toggle_subscription_dock(self):
        """Undock/dock the Subscriptions panel.

        The trigger is the [U] button which lives INSIDE subscription_widget.
        Reparenting that widget (which we do on both dock and undock) while the
        button's own click handler is still on the stack reparents the widget
        out from under the in-flight event and crashes Qt. Defer the actual
        toggle to the next event-loop turn so the click handler has fully
        returned first.
        """
        QTimer.singleShot(0, self._do_toggle_subscription_dock)

    def _do_toggle_subscription_dock(self):
        if self._is_subscription_docked():
            self._undock_subscriptions()
        else:
            self._dock_subscriptions()

    def _is_subscription_docked(self) -> bool:
        """True if the subscription widget is inside the main splitter."""
        return self.main_splitter.indexOf(self.subscription_widget) != -1

    def _undock_subscriptions(self):
        if self.subscription_undocked:
            return
        if not self.subscription_widget:
            return

        # Detach from the splitter.
        idx = self.main_splitter.indexOf(self.subscription_widget)
        if idx >= 0:
            self.main_splitter.widget(idx).setParent(None)

        gateway = self

        class _SubscriptionDialog(QDialog):
            def __init__(self, parent_gateway):
                super().__init__(parent_gateway)
                self.gateway = parent_gateway

            def closeEvent(self, event):
                # Closing the floating window re-docks the panel.
                if self.gateway:
                    self.gateway._dock_subscriptions()
                event.accept()

        self.subscription_dialog = _SubscriptionDialog(self)
        self.subscription_dialog.setWindowTitle("Subscriptions")
        dlg_layout = QVBoxLayout()
        dlg_layout.addWidget(self.subscription_widget)
        self.subscription_dialog.setLayout(dlg_layout)
        # Roomy by default - the whole point is to see MORE subscriptions.
        self.subscription_dialog.resize(900, 500)
        self.subscription_dialog.show()
        self.subscription_undocked = True
        self.subscription_widget.undock_button.setToolTip(
            "Dock the Subscriptions panel back into the main window")

    def _dock_subscriptions(self):
        if not self.subscription_undocked:
            return
        if not self.subscription_widget:
            return

        dialog = self.subscription_dialog
        self.subscription_dialog = None

        # Reparent the widget OUT of the dialog's layout FIRST, then dispose
        # of the now-empty dialog. Detaching the dialog while it still owns the
        # widget in its layout can take the widget down with it.
        if dialog is not None:
            lay = dialog.layout()
            if lay is not None:
                lay.removeWidget(self.subscription_widget)
            self.subscription_widget.setParent(None)

        # Re-insert at its original position (between the top section and the
        # log), not appended at the end. Clamp the index to the current child
        # count in case the log is also undocked (then only the top widget
        # remains and index 1 == end).
        target_idx = min(1, self.main_splitter.count())
        self.main_splitter.insertWidget(target_idx, self.subscription_widget)

        # Now safe to tear down the empty dialog. deleteLater (not immediate
        # delete) since we may be inside the dialog's own closeEvent.
        if dialog is not None:
            dialog.hide()
            dialog.deleteLater()
        # Restore reasonable proportions for however many children exist now.
        n = self.main_splitter.count()
        if n == 3:
            self.main_splitter.setSizes([500, 200, 100])
        elif n == 2:
            self.main_splitter.setSizes([500, 200])
        self.subscription_undocked = False
        self.subscription_widget.setVisible(True)
        self.subscription_widget.undock_button.setToolTip(
            "Undock the Subscriptions panel (toggle: dock when undocked)")

    def handle_log_message(self, level, message):
        """Route log messages honoring suppression."""
        if self.log_suppressed:
            return
        if self.log_widget:
            self.log_widget.add_log_message(level, message)

    def _script_output_dir(self) -> Path:
        """Resolve the configured script-run output directory."""
        raw = (self.script_output_dir or "").strip()
        if not raw:
            raw = str(Path.home() / "uaexplorer_script_runs")
        return Path(raw).expanduser()

    def _on_script_log(self, level: str, message: str):
        """Forward log lines from a running script to the main log panel,
        tagged so they're distinguishable from worker logs."""
        self.handle_log_message(level, f"[script] {message}")

    def _is_log_docked(self) -> bool:
        """Check if the log widget is currently inside the main splitter."""
        return self.main_splitter.indexOf(self.log_widget) != -1

    def on_selection_changed(self):
        """Update subscribe button state based on selection"""
        selected_items = self.node_tree.selectedItems()
        variable_count = 0

        for item in selected_items:
            if _is_placeholder_text(item.text(0)):
                continue
            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            if (node_data and
                node_data.get('node_class') == ua.NodeClass.Variable):
                variable_count += 1

        self.subscribe_button.setEnabled(variable_count > 0 and self.worker and self.worker.isRunning())
        self.plot_button.setEnabled(variable_count > 0 and self.worker and self.worker.isRunning())
        if variable_count > 1:
            self.subscribe_button.setText(f"Subscribe ({variable_count})")
            self.plot_button.setText(f"Plot ({variable_count})")
        else:
            # Single selection (or none) — short labels.
            self.subscribe_button.setText("Subscribe")
            self.plot_button.setText("Plot")

    def plot_selected_node(self):
        """Plot selected variable nodes (all selected)."""
        # Immediate feedback so we know the handler ran (e.g. if nothing else appears, button may be disabled)
        self.statusBar().showMessage("Plot clicked...", 4000)
        QApplication.processEvents()

        try:
            selected_items = self.node_tree.selectedItems()
            variable_nodes = []
            for item in selected_items:
                if _is_placeholder_text(item.text(0)):
                    continue
                node_data = item.data(0, Qt.ItemDataRole.UserRole)
                if node_data and node_data.get("node_class") == ua.NodeClass.Variable:
                    variable_nodes.append(node_data)

            if not variable_nodes:
                self.statusBar().showMessage("Select one or more variable nodes to plot.", 4000)
                QMessageBox.information(
                    self,
                    "Plot",
                    "Select one or more variable nodes in the tree, then click Plot.",
                )
                return

            plot_id = None
            title = None
            for idx, node_data in enumerate(variable_nodes):
                plot_id, title, ok = self._plot_single_node(
                    node_data,
                    plot_id=plot_id,
                    title=title,
                    focus_on_plot=(idx == 0),
                )
                if not ok:
                    break
            if len(variable_nodes) > 1 and plot_id:
                self.statusBar().showMessage(f"Plotted {min(len(variable_nodes), idx + 1)} variables to {title or plot_id}", 4000)
        except Exception as exc:
            self.handle_log_message("ERROR", f"Plot error: {exc}")
            QMessageBox.critical(self, "Plot", f"Plot failed:\n{exc}")

    def on_plot_requested(self, node_data):
        """Handle plot requests coming from the node browser."""
        if not node_data:
            return
        self._plot_single_node(node_data, focus_on_plot=True)

    def on_subscriptions_plot_requested(self, node_data_list):
        """Plot the variables selected in the Subscriptions table.

        Same UaPlot path as the Node Browser's Plot button: all selected
        variables go into one plot (the first prompts for/creates the target
        plot, the rest reuse it).

        Subscription rows store the node's PATH (the namespace-cache key), not a
        resolvable node id — so resolve each to its full cache stub, which
        carries the real node_id UaPlot needs. Fall back to the row's own data
        if the node isn't in the cache."""
        if not node_data_list:
            return
        resolved = []
        cache = self.namespace_cache or {}
        for nd in node_data_list:
            key = nd.get("node_id")  # the subscription row's stored path
            stub = cache.get(key)
            resolved.append(stub if stub else nd)
        node_data_list = resolved
        plot_id = None
        title = None
        idx = 0
        for idx, node_data in enumerate(node_data_list):
            plot_id, title, ok = self._plot_single_node(
                node_data, plot_id=plot_id, title=title,
                focus_on_plot=(idx == 0))
            if not ok:
                break
        if len(node_data_list) > 1 and plot_id:
            self.statusBar().showMessage(
                f"Plotted {min(len(node_data_list), idx + 1)} variables "
                f"to {title or plot_id}", 4000)

    def _plot_single_node(self, node_data, plot_id=None, title=None, focus_on_plot=True):
        """Plot one variable, optionally reusing an existing plot selection."""
        self.statusBar().showMessage("Plot request started...", 2000)
        self.handle_log_message("INFO", "Plot action triggered from node browser.")
        uri = self.uri_input.currentText().strip()
        if not uri:
            QMessageBox.warning(self, "Plot", "Connect to a server before plotting.")
            return plot_id, title, False
        monitor_mode = "polling" if self.subscription_widget.polling_radio.isChecked() else "subscription"
        try:
            polling_interval_ms = int(self.subscription_widget.polling_interval_input.text())
        except Exception:
            polling_interval_ms = 500

        server_name = str(self.server_names.get(uri, "")).strip()
        try:
            ok, err = self.plot_bridge.ensure_running(uri, server_name)
            if not ok:
                self.handle_log_message("ERROR", err or "UaPlot start failed")
                QMessageBox.critical(self, "Plot", err or "Unable to start UaPlot.")
                return plot_id, title, False

            if plot_id is None or title is None:
                plots, err = self.plot_bridge.list_plots()
                if err:
                    # Retry once in case UaPlot was still starting
                    self.handle_log_message("WARNING", f"UaPlot list_plots error: {err}, retrying once...")
                    ok, err_start = self.plot_bridge.ensure_running(uri, server_name)
                    if ok:
                        plots, err = self.plot_bridge.list_plots()
                    else:
                        err = err or err_start
                    if err:
                        self.handle_log_message("ERROR", f"UaPlot list_plots error: {err}")
                        QMessageBox.warning(self, "Plot", f"UaPlot did not respond to plot list:\n{err}")
                        return plot_id, title, False
                if plots is None:
                    QMessageBox.warning(self, "Plot", "UaPlot returned no plots.")
                    return plot_id, title, False

                default_title = f"Plot {len(plots) + 1}" if plots else "Plot 1"
                dialog = PlotSelectionDialog(plots, default_title, self)
                if dialog.exec() != QDialog.DialogCode.Accepted:
                    return plot_id, title, False

                selection = dialog.get_selection()
                plot_id = selection.get("plot_id")
                title = selection.get("title") or default_title

                if plot_id is None:
                    created, cerr = self.plot_bridge.create_plot(title)
                    if created:
                        plot_id = created.get("id")
                    else:
                        self.handle_log_message("ERROR", f"UaPlot refused to create plot: {cerr}")
                        QMessageBox.warning(self, "Plot", f"Failed to create plot in UaPlot:\n{cerr or ''}")
                        return plot_id, title, False

            success, perr = self.plot_bridge.plot_variable(
                node_data,
                plot_id,
                title,
                uri,
                monitor_mode,
                polling_interval_ms,
                focus_on_plot=focus_on_plot,
            )
            if not success:
                self.handle_log_message("ERROR", f"UaPlot rejected plot_variable command: {perr}")
                QMessageBox.warning(self, "Plot", f"Failed to send plot request to UaPlot:\n{perr or ''}")
                return plot_id, title, False
            else:
                self.handle_log_message("INFO", f"Sent plot request for {node_data.get('node_id')}")
                self.statusBar().showMessage("Sent plot request to UaPlot", 3000)
                # Track plotted variable for session save
                self._track_plotted_variable(node_data, plot_id, title, monitor_mode, polling_interval_ms)
                return plot_id, title, True
        except Exception as exc:
            self.handle_log_message("ERROR", f"Plotting error: {exc}")
            QMessageBox.critical(self, "Plot", f"Unexpected plotting error:\n{exc}")
            return plot_id, title, False


    def subscribe_selected_nodes(self):
        """Subscribe to selected variable nodes, with auto-reconnect support."""
        if not (self.worker and self.worker.isRunning()):
            if self.auto_reconnect_enabled and not self._user_requested_disconnect:
                self.statusBar().showMessage("Attempting to reconnect before subscribing...")
                self.connect_to_server(show_progress=False, status_message="Reconnecting...")
                # Let the user press subscribe again after connection is established
                return
        self.node_tree.subscribe_selected_nodes()


    def _move_mode_radios_to_uri_row(self):
        """Reparent the Subscription/Polling radios + polling-interval
        input from the SubscriptionWidget toolbar to the URI row.

        Why: subscription-vs-polling is a *connection policy* — how the
        client talks to a given OPC UA server — not a property of the
        Subscriptions display. Putting the controls next to the URI
        input makes that scoping obvious. All read-from-radio code
        paths still reference ``self.subscription_widget.subscription_radio``
        etc., so the move is purely a re-parent and the rest of the
        widget keeps working unchanged.
        """
        layout = getattr(self, "_uri_row_layout", None)
        sw = getattr(self, "subscription_widget", None)
        if layout is None or sw is None:
            return
        # Insert position: just before the spinner_label. spinner_label
        # is index N-2 in the URI row (spinner, Connect button at the end);
        # use indexOf to be robust against future row changes.
        spinner_idx = layout.indexOf(self.spinner_label)
        if spinner_idx < 0:
            spinner_idx = layout.count()
        # Move the four widgets in order. addWidget / insertWidget
        # auto-detaches from the previous parent layout.
        layout.insertWidget(spinner_idx, sw.subscription_radio)
        layout.insertWidget(spinner_idx + 1, sw.polling_radio)
        layout.insertWidget(spinner_idx + 2, sw.polling_interval_input)
        # The ``ms`` label is hidden by default; move it too so any
        # future show()/hide() lives in the right row.
        if hasattr(sw, "polling_interval_label"):
            layout.insertWidget(spinner_idx + 3, sw.polling_interval_label)

    def toggle_connection(self):
        if getattr(self, "_connect_button_state", "") == "browsing":
            # Cancel an in-progress browse (keeps what's loaded -> PARTIAL).
            if self.worker:
                self.worker.request_cancel_browse()
            self.statusBar().showMessage("Cancelling browse...")
            self.connect_button.setEnabled(False)  # re-enabled by on_nodes_loaded
            return
        if getattr(self, "_connect_button_state", "") == "reconnecting":
            self.disconnect_from_server()
            self.statusBar().showMessage("Auto-reconnect cancelled")
            return
        if self.worker and self.worker.isRunning():
            self.disconnect_from_server()
        else:
            self.connect_to_server()


    def connect_to_server(self, show_progress=True, status_message=None):
        uri = self.uri_input.currentText().strip()
        if not uri:
            QMessageBox.warning(self, "Error", "Please enter a valid URI")
            return

        self._logger.debug(f"Connect requested to: {uri}")

        # Test basic TCP connectivity before attempting OPC UA connection
        try:
            from urllib.parse import urlparse
            parsed = urlparse(uri)
            host = parsed.hostname
            port = parsed.port or 4840
            self._logger.debug(f"Testing TCP connectivity to {host}:{port}...")
            test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            test_socket.settimeout(5.0)
            result = test_socket.connect_ex((host, port))
            test_socket.close()
            if result == 0:
                self._logger.debug(f"TCP port {port} is reachable on {host}")
            else:
                # Pre-connect probe failure is NOT user-actionable on its own:
                # asyncua's connect call below will raise a clear error if the
                # port is really unreachable. Logging WARNING here just spams
                # the panel during normal auto-reconnect retry loops. Keep it
                # as DEBUG so it's still visible when investigating.
                self._logger.debug(
                    f"TCP port {port} NOT reachable on {host} (error code: {result})"
                )
        except Exception as tcp_err:
            self._logger.debug(f"TCP connectivity test failed: {tcp_err}")

        # Track URI usage before attempting connection
        self._add_uri_to_history(uri)

        # Remember URI and reset manual-disconnect flag.
        # NB: auto_reconnect_enabled is NOT set here — it's enabled
        # ONLY after a successful connection (in on_connected(True)).
        # An invalid URI that never connects will therefore not be
        # retried, which avoids the "reconnects every second" loop a
        # user hit when typing a bad URI.
        self._last_uri = uri
        self._user_requested_disconnect = False

        if getattr(self, "methods_quick_access", None):
            self.methods_quick_access.clear_methods()
        self.namespace_cache = {}

        self.worker = OpcUaWorker()

        # Connect all signals BEFORE starting the worker
        self.worker.connected.connect(self.on_connected)
        self.worker.error.connect(self.on_error)
        self.worker.nodes_loaded.connect(self.on_nodes_loaded)
        self.worker.namespace_chunk_loaded.connect(self.on_namespace_chunk_loaded)
        self.worker.node_children_loaded.connect(self.on_node_children_loaded)
        self.worker.method_result.connect(self.on_method_result)
        self.worker.log_message.connect(self.handle_log_message)
        self.worker.subscription_update.connect(self.on_subscription_update)
        self.worker.loading_progress.connect(self.on_loading_progress)
        self.worker.current_value_updated.connect(self.on_current_value_updated)
        self.worker.value_written.connect(self.on_value_written)
        self.worker.namespace_load_mode.connect(self.on_namespace_load_mode)
        # Lazy Node Loading: frontier expand results go straight to the tree.
        self.worker.frontier_children_loaded.connect(
            self.node_tree.on_frontier_children_loaded)

        # Update worker references in widgets
        self.node_tree.worker = self.worker
        self.properties_widget.worker = self.worker
        self.subscription_widget.worker = self.worker
        self.methods_quick_access.set_worker(self.worker)
        if hasattr(self, "script_widget"):
            self.script_widget.set_worker(self.worker)

        # Sync worker monitor mode & polling interval with current UI state
        if self.subscription_widget.polling_radio.isChecked():
            self.worker.set_monitor_mode("polling")
            self.worker.set_polling_interval(
                self.subscription_widget.polling_interval_input.text()
            )
        else:
            self.worker.set_monitor_mode("subscription")

        # Pass current URI to subscription widget for recording metadata
        if hasattr(self.subscription_widget, "set_current_uri"):
            self.subscription_widget.set_current_uri(uri)

        self.worker.set_uri(uri)
        # Apply cached credentials (if any)
        if self._auth_username is not None:
            self.worker.set_credentials(self._auth_username, self._auth_password)
        # Lazy Node Loading: push this URI's node-count limit to the worker so
        # it decides shallow-vs-full at connect time (0 disables lazy mode).
        pushed_limit = self._effective_lazy_limit(uri)
        self.worker.lazy_loading_limit = pushed_limit
        self.handle_log_message(
            "DEBUG",
            f"Lazy Node Loading: pushing limit {pushed_limit} for {uri} "
            f"(checkbox={self.lazy_enable_check.isChecked()}, "
            f"spin={self.lazy_limit_spin.value()}, "
            f"stored={self.lazy_loading_limits.get(uri, '<default>')})")
        # View-aware browse (P3f): push the active View's exclude patterns so the
        # worker can prune excluded subtrees during the browse itself.
        self._push_browse_prune()

        # Persistent namespace cache: if a full browse of this URI is on disk,
        # skip the worker's initial live browse — we paint the tree from the
        # cache in on_connected, then trigger a background full revalidation.
        self._pending_cache_dict = None
        self.worker._skip_initial_browse = False
        if self.nscache_enabled:
            cached = self._nscache_load(uri)
            if cached:
                self._pending_cache_dict = cached
                self.worker._skip_initial_browse = True

        self.worker.start()

        # Disable button during connection attempt (will be re-enabled by on_connected)
        self.connect_button.setEnabled(False)
        self._start_spinner(status_message or "Connecting...")
        if status_message is not None:
            self.statusBar().showMessage(status_message)
        else:
            self.statusBar().showMessage("Connecting...")



    def disconnect_from_server(self):
        # Explicit user disconnect disables auto-reconnect
        self._user_requested_disconnect = True
        self.auto_reconnect_enabled = False

        # Close UaPlot when disconnecting (it can't function without server connection)
        if hasattr(self, 'plot_bridge') and self.plot_bridge:
            try:
                self.plot_bridge.stop()
            except Exception:
                pass

        if self.worker:
            self.worker.stop()
            self.worker.wait()
            self.worker = None
        self._stop_method_scan()
        self._stop_spinner()
        self._set_load_status_badge(None)

        self.namespace_cache = {}
        self.node_tree.clear()
        self.node_tree.worker = None
        self.properties_widget.worker = None
        self.subscription_widget.worker = None
        self.methods_quick_access.set_worker(None)
        if hasattr(self, "script_widget"):
            self.script_widget.set_worker(None)

        self.on_connected(False)

    def _force_disconnect_for_test(self):
        """TEST HOOK: simulate an unexpected connection loss.

        Tears down the worker WITHOUT setting ``_user_requested_disconnect``,
        so the auto-reconnect policy (cool-down, WARNING heartbeat) engages
        exactly as it would for a real network drop — but without waiting
        for asyncua to notice the dead socket. Use this from integration
        tests to verify reconnect behaviour in seconds instead of minutes.

        Not used by any production code path.
        """
        # Stop the worker thread cleanly. We don't go through
        # disconnect_from_server() because that flips
        # _user_requested_disconnect=True, which disables auto-reconnect.
        if self.worker:
            self.worker.stop()
            self.worker.wait()
            self.worker = None
        self._stop_method_scan()
        self._stop_spinner()
        self.namespace_cache = {}
        self.node_tree.clear()
        self.node_tree.worker = None
        self.properties_widget.worker = None
        self.subscription_widget.worker = None
        self.methods_quick_access.set_worker(None)
        if hasattr(self, "script_widget"):
            self.script_widget.set_worker(None)
        # Drive the disconnected state through the normal slot — this is
        # the same call that runs when health-check declares the connection
        # lost, so it engages the reconnect timer/heartbeat machinery.
        self.on_connected(False)

    def _start_spinner(self, message: Optional[str] = None):
        try:
            self._spinner_active = True
            self._spinner_index = 0
            self.spinner_label.setStyleSheet(
                f"min-width: 18px; max-width: 18px; min-height: 18px; max-height: 18px; "
                f"border: none; color: {THEME.active.text_muted}; font-weight: bold;"
            )
            self.spinner_label.setText(self._spinner_frames[self._spinner_index])
            if message:
                self.statusBar().showMessage(message)
            self._spinner_timer.start()
            # Busy mouse cursor alongside the inline spinner while browsing /
            # connecting (same wait cursor used for the View-switch rebuild, for
            # consistency). Balanced with _stop_spinner via _busy_cursor_active
            # so repeated start/stop don't unbalance Qt's override-cursor stack.
            if not getattr(self, "_busy_cursor_active", False):
                QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
                self._busy_cursor_active = True
        except Exception:
            pass

    def _stop_spinner(self):
        try:
            self._spinner_timer.stop()
            self._spinner_active = False
            self.spinner_label.setText("")
            if getattr(self, "_busy_cursor_active", False):
                QApplication.restoreOverrideCursor()
                self._busy_cursor_active = False
            if self._connect_button_state == "connected":
                self._set_indicator_connected()
            else:
                self._set_indicator_idle()
        except Exception:
            pass

    def _advance_spinner(self):
        try:
            self._spinner_index = (self._spinner_index + 1) % len(self._spinner_frames)
            self.spinner_label.setText(self._spinner_frames[self._spinner_index])
        except Exception:
            pass

    def _start_filter_spinner(self):
        """Show the Quick Filter spinner (a filter search is running)."""
        try:
            self._filter_spinner_index = 0
            self.filter_spinner_label.setText(self._spinner_frames[0])
            self._filter_spinner_timer.start()
        except Exception:
            pass

    def _stop_filter_spinner(self):
        try:
            self._filter_spinner_timer.stop()
            self.filter_spinner_label.setText("")
        except Exception:
            pass

    def _advance_filter_spinner(self):
        try:
            self._filter_spinner_index = (
                self._filter_spinner_index + 1) % len(self._spinner_frames)
            self.filter_spinner_label.setText(
                self._spinner_frames[self._filter_spinner_index])
        except Exception:
            pass

    def _set_indicator_connected(self):
        try:
            t = THEME.active
            self.spinner_label.setStyleSheet(
                f"min-width: 18px; max-width: 18px; min-height: 18px; max-height: 18px; "
                f"border-radius: 9px; background-color: {t.status_ok};"
                f" border: 1px solid {t.status_ok};"
            )
            self.spinner_label.setText("")
            self.spinner_label.setToolTip("Connected")
        except Exception:
            pass

    def _set_indicator_idle(self):
        try:
            t = THEME.active
            self.spinner_label.setStyleSheet(
                f"min-width: 18px; max-width: 18px; min-height: 18px; max-height: 18px; "
                f"border-radius: 9px; background-color: transparent;"
                f" border: 1px solid {t.border};"
            )
            self.spinner_label.setText("")
            self.spinner_label.setToolTip("")
        except Exception:
            pass


    def restore_subscriptions_after_connect(self):
        if not self.worker:
            return

        table = self.subscription_widget.subscription_table
        for row in range(table.rowCount()):
            item = table.item(row, 0)
            if item:
                # Use stored full node_id for reconnection
                stored_node_id = item.data(Qt.ItemDataRole.UserRole)
                node_id = stored_node_id if stored_node_id else item.text()
                if hasattr(self.subscription_widget, "_set_status_for_row"):
                    self.subscription_widget._set_status_for_row(row)
                self.worker.subscribe_to_node_async(node_id)


    def _start_nscache_revalidate(self):
        """Kick off a background full-unscoped rebrowse to refresh the cache.

        Its nodes_loaded comes back through the normal browse path and swaps
        the tree in; the background flag lets the completion handler re-persist
        and clear the from-disk state quietly.
        """
        if not (self.worker and getattr(self.worker, "running", False)):
            return
        self._nscache_revalidating = True
        self.worker.request_revalidate()

    def on_connected(self, connected):
        if connected:
            # If we had emitted disconnect-warning heartbeats, this is a
            # successful RECONNECT (not a first connect) — log an INFO
            # line so the recovery is visible in the log timeline.
            if self._reconnect_last_warn is not None:
                self.handle_log_message(
                    "INFO",
                    f"Reconnected to {self.uri_input.currentText().strip()}",
                )
            self._had_successful_connection = True
            # Enable auto-reconnect ONLY after a successful connection —
            # this is the signal that the URI is real and reachable, so
            # if we lose it later we should try to re-establish.
            self.auto_reconnect_enabled = True
            self._reconnect_last_attempt = None
            self._reconnect_last_warn = None
            self._set_connect_button_state("connected")
            # The initial namespace browse is now in flight — offer to Cancel it.
            self._begin_browsing_ui()
            line_edit = self.uri_input.lineEdit()
            if line_edit:
                line_edit.setReadOnly(True)
            # Update current URI to match what we're connected to
            self._current_uri = self.uri_input.currentText().strip()
            # Rebuild the dropdown so its per-URI name labels reflect any names
            # assigned during this session (blockSignals inside keeps the line
            # edit / connection state untouched).
            self._refresh_uri_dropdown(current_text=self._current_uri)
            self.rebrowse_button.setEnabled(True)
            self.node_tree.setEnabled(True)
            self.properties_widget.setEnabled(True)
            self.subscription_widget.setEnabled(True)
            self.statusBar().showMessage("Connected")

            # Persistent namespace cache: if we pre-loaded a disk cache for this
            # URI, paint the tree from it INSTANTLY (no browse), then kick off a
            # background full revalidation whose result atomically replaces it.
            pending = getattr(self, "_pending_cache_dict", None)
            if pending:
                self._pending_cache_dict = None
                self._nscache_from_disk = True
                self._last_load_was_from_cache = True  # keep the ETA honest
                self._end_browsing_ui()
                self.on_nodes_loaded(pending)
                self.on_namespace_load_mode("complete", len(pending))
                self._last_load_was_from_cache = False
                self.statusBar().showMessage(
                    f"Loaded {len(pending)} nodes from cache (revalidating…)")
                self._start_nscache_revalidate()

            # Restore session subscriptions and plots if we're loading a session
            self._restore_session_after_connect()
        else:
            # Disconnected state
            line_edit = self.uri_input.lineEdit()
            if line_edit:
                line_edit.setReadOnly(False)
            self.rebrowse_button.setEnabled(False)
            self.subscribe_button.setEnabled(False)
            self.namespace_cache = {}
            if getattr(self, "methods_quick_access", None):
                self.methods_quick_access.clear_methods()

            # Grey out widgets when disconnected
            self.node_tree.setEnabled(False)
            self.properties_widget.setEnabled(False)
            self.subscription_widget.setEnabled(False)

            # Decide button text/style based on whether this was a connection loss
            if self._had_successful_connection and not self._user_requested_disconnect:
                # Unexpected connection loss
                self._set_connect_button_state("reconnecting")
                if self.auto_reconnect_enabled:
                    self.statusBar().showMessage(
                        "Disconnected - will try to reconnect automatically..."
                    )
                else:
                    self.statusBar().showMessage("Disconnected")
            else:
                # Normal disconnected state
                self._set_connect_button_state("disconnected")
                self.statusBar().showMessage("Disconnected")

        self.connect_button.setEnabled(True)



    def on_error(self, error_msg):
        # If auto-reconnect is enabled and this wasn't an explicit disconnect,
        # avoid spamming message boxes and just update the status bar.
        if ( "BadUserAccessDenied" in str(error_msg) or "BadIdentityTokenRejected" in str(error_msg) ):
            self._prompt_credentials_and_reconnect(str(error_msg))
            return

        if self.auto_reconnect_enabled and not self._user_requested_disconnect:
            self._set_connect_button_state("reconnecting")
            self.statusBar().showMessage(f"Connection error, will retry: {error_msg}")
        else:
            QMessageBox.critical(
                self,
                "Connection Error",
                f"Failed to connect to OPC UA server:\n\n{error_msg}",
            )
            self._set_connect_button_state("disconnected")
            self.statusBar().showMessage("Connection failed")
        self.connect_button.setEnabled(True)
        self._stop_spinner()

    def _prompt_credentials_and_reconnect(self, error_msg: str):
        """Prompt for username/password on auth failure and retry once."""
        if self._auth_prompt_open:
            return
        self._auth_prompt_open = True
        # Prevent auto-reconnect loop while prompting
        self.auto_reconnect_enabled = False
        self._user_requested_disconnect = False
        dlg = QDialog(self)
        dlg.setWindowTitle("Credentials Required")
        form = QFormLayout(dlg)
        user_edit = QLineEdit()
        pwd_edit = QLineEdit()
        pwd_edit.setEchoMode(QLineEdit.EchoMode.Normal)
        form.addRow("Username", user_edit)
        form.addRow("Password", pwd_edit)
        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
            parent=dlg,
        )
        form.addRow(buttons)
        buttons.accepted.connect(dlg.accept)
        buttons.rejected.connect(dlg.reject)
        if dlg.exec() == QDialog.DialogCode.Accepted:
            username = user_edit.text().strip() or None
            password = pwd_edit.text()
            self._auth_username = username
            self._auth_password = password
            if self.worker:
                self.worker.set_credentials(username, password)
            # Reset cache so load will rebuild with auth context
            self.namespace_cache = {}
            self.node_tree.clear()
            if getattr(self, "methods_quick_access", None):
                self.methods_quick_access.clear_methods()
            # Retry connect once manually (user-driven)
            self.connect_to_server(show_progress=True, status_message="Retrying with credentials...")
        else:
            QMessageBox.critical(
                self,
                "Connection Error",
                f"Authentication required:\n\n{error_msg}",
            )
        self._auth_prompt_open = False


    def on_current_value_updated(self, node_id, value_data):
        self.properties_widget.update_current_value(node_id, value_data)

    def on_value_written(self, node_id, success, message):
        # This is handled by the properties widget
        pass

    def on_loading_progress(self, current, total):
        if total <= 0:
            return
        # Status: nodes browsed so far + elapsed. ETA is TIME-BASED: on a prior
        # COMPLETE load we remembered the whole operation's wall-clock (browse +
        # render); the ETA is simply that learned total minus elapsed. Time-based
        # is robust against the bursty node rate (parallel gather returns in
        # spurts, which made a rate-based ETA jump around and read wrong).
        parts = [f"Loading nodes... {current:,}"]
        start = getattr(self, "_browse_ui_start", None)
        elapsed = max(0.0, time.time() - start) if start else 0.0
        if start:
            parts.append(f"{elapsed:.1f}s")
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        stats = self.namespace_size_by_uri.get(uri) if uri else None
        total_secs = float(stats.get("seconds", 0.0)) if isinstance(stats, dict) else 0.0
        if total_secs > 0.5 and start:
            eta = max(0.0, total_secs - elapsed)
            pct = min(99, int(100 * elapsed / total_secs)) if total_secs else 0
            parts.append(f"~{pct}% — ETA {eta:.0f}s (last complete load {total_secs:.0f}s)")
        self.statusBar().showMessage("  |  ".join(parts))

    def on_namespace_chunk_loaded(self, nodes_dict):
        """Incrementally refresh tree/MDA as the namespace arrives."""
        if not nodes_dict:
            return
        self.namespace_cache = nodes_dict
        # No live repaints during load to keep UI responsive; final refresh in
        # on_nodes_loaded. The status line is owned by on_loading_progress (which
        # shows the richer nodes|rate|ETA message) — don't overwrite it here with
        # a plainer "Loading nodes... N cached" (that hid the ETA).

    @staticmethod
    def _scope_browse_spec(scope):
        """A hashable signature of how `scope` would PRUNE a browse — mirroring
        the worker's _is_browse_excluded logic. Two scopes with the same spec
        produce the same browsed node set, so switching between them needs no
        Rebrowse. Returns ("full",) when nothing is pruned.

        - All-prefix-style includes -> ("prefix", <sorted prefixes>).
        - Non-prefix include(s) present -> ("full",) (browse-prune disabled).
        - No includes, some excludes -> ("exclude", <sorted excludes>).
        - Nothing -> ("full",).
        """
        if scope is None:
            return ("full",)
        includes = [p.pattern for p in scope.include_patterns
                    if p.enabled and p.pattern]
        excludes = [p.pattern for p in scope.exclude_patterns
                    if p.enabled and p.pattern]
        if includes:
            prefixes = [_include_prefix(p) for p in includes]
            if all(p is not None for p in prefixes):
                return ("prefix", tuple(sorted(prefixes)))
            return ("full",)  # non-prefix include -> browse not pruned
        if excludes:
            return ("exclude", tuple(sorted(excludes)))
        return ("full",)

    def _browse_was_scoped(self) -> bool:
        """True if the last browse was pruned by the active View (exclude-prune
        or prefix-include scoping) — i.e. it loaded only part of the namespace on
        purpose. Such a load is NOT a full-namespace load, so its size/time must
        not be learned as the URI's namespace stats (would poison the ETA)."""
        w = self.worker
        if w is None:
            return False
        if getattr(w, "browse_include_prunable", False) and getattr(w, "browse_include_prefixes", None):
            return True
        # Exclude-prune only actually prunes when there are no includes.
        if getattr(w, "browse_exclude_patterns", None) and not getattr(w, "browse_include_patterns", None):
            return True
        return False

    def _push_browse_prune(self):
        """Push the active View's browse-prune spec to the worker (View-aware
        browse, P3f). Sends enabled EXCLUDE and INCLUDE patterns. The worker
        prunes excluded subtrees during the browse — but ONLY when there are no
        includes (include-wins semantics mean an excluded node may be an ancestor
        of / under an included subtree, so with includes present it browses fully
        and lets the render filter decide). Takes effect on the next browse /
        Rebrowse (a View switch alone re-filters at render, no re-browse)."""
        if self.worker is None:
            return
        excludes, includes = [], []
        scope = self.active_scope
        if scope is not None:
            excludes = [p.pattern for p in scope.exclude_patterns
                        if p.enabled and p.pattern]
            includes = [p.pattern for p in scope.include_patterns
                        if p.enabled and p.pattern]
        self.worker.browse_exclude_patterns = excludes
        self.worker.browse_include_patterns = includes

        # P3f.2: if EVERY enabled include is prefix-style, we can prune the
        # browse to just those subtrees. Any non-prefix include disables it.
        prefixes = [_include_prefix(p) for p in includes]
        include_prunable = bool(includes) and all(p is not None for p in prefixes)
        self.worker.browse_include_prefixes = [p for p in prefixes if p] if include_prunable else []
        self.worker.browse_include_prunable = include_prunable

        # Record the prune spec this browse will use, so a later View switch can
        # tell whether it needs to Rebrowse (spec changed) or can stay render-only
        # (same browsed node set).
        self._loaded_browse_spec = self._scope_browse_spec(self.active_scope)

        if include_prunable:
            self.handle_log_message(
                "DEBUG", "View-aware browse: prefix-include scoping -> browse "
                f"only {self.worker.browse_include_prefixes}")
        elif includes:
            self.handle_log_message(
                "DEBUG", "View-aware browse: View has non-prefix include(s) -> "
                "browse-prune disabled (full browse + render filter).")
        elif excludes:
            self.handle_log_message(
                "DEBUG", f"View-aware browse: pruning {len(excludes)} exclude "
                f"pattern(s) at browse time: {excludes}")

    def _lazy_loading_limit_for_uri(self, uri: str) -> int:
        """Return the stored Lazy-Node-Loading node-count limit for `uri`.

        Falls back to DEFAULT_LAZY_LOADING_LIMIT when the URI has no explicit
        entry in settings.json. Used to seed the UI (checkbox/spinbox) for a URI.
        NB: for the value actually pushed to the worker at connect/rebrowse time
        use `_effective_lazy_limit(uri)`, which reads the LIVE widgets so the UI
        the user sees is authoritative (avoids any UI/stored drift).
        """
        try:
            return int(self.lazy_loading_limits.get(uri, DEFAULT_LAZY_LOADING_LIMIT))
        except (TypeError, ValueError):
            return DEFAULT_LAZY_LOADING_LIMIT

    def _effective_lazy_limit(self, uri: str) -> int:
        """The lazy limit to push to the worker for `uri`.

        For the URI currently shown in the toolbar, the checkbox + spinbox are
        the source of truth (what the user sees), so read them directly: 0 when
        the checkbox is unchecked, else the spinbox value. For any other URI,
        fall back to the stored per-URI value. This guarantees the pushed limit
        always matches the visible controls, regardless of dict/restore order.
        """
        shown_uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        if uri and uri == shown_uri and hasattr(self, "lazy_enable_check"):
            if not self.lazy_enable_check.isChecked():
                return 0
            return int(self.lazy_limit_spin.value())
        return self._lazy_loading_limit_for_uri(uri)

    def on_namespace_load_mode(self, mode: str, estimated_total: int) -> None:
        """Record whether the current namespace is loaded partially or fully.

        Emitted by the worker after the connect-time shallow browse: "complete"
        when the estimate is within the URI's limit (a full load follows /
        finished), "partial" when Lazy Node Loading is active (only a shallow
        skeleton is present). Stored here for the panel indicator (P3b) and the
        lazy Search gating (P3c/P3d); this handler only tracks state.
        """
        self._namespace_load_mode = mode if mode in ("complete", "partial") else "complete"
        self._namespace_estimate = max(0, int(estimated_total or 0))
        self._set_load_status_badge(self._namespace_load_mode, self._namespace_estimate)
        # Remember the node count AND wall-clock duration of a COMPLETE load per
        # URI, to drive the ETA of future loads (see on_loading_progress). Done
        # HERE (not on_nodes_loaded) because the worker emits nodes_loaded BEFORE
        # this mode signal, so the mode is only definitive now. Refreshed on every
        # complete load, so it tracks a namespace that changes over time.
        # IMPORTANT: only record for a FULL, UNSCOPED, SERVER load — a View-scoped
        # browse loads only a subtree, and a from-disk cache load is near-instant;
        # either would poison the server-browse timing used for the ETA. So skip
        # both a scoped load and a cache load here.
        if self._namespace_load_mode == "complete" and not getattr(
                self, "_last_load_was_scoped", False) and not getattr(
                self, "_last_load_was_from_cache", False):
            uri = (self._current_uri or self.uri_input.currentText() or "").strip()
            n = len(self.namespace_cache or {})
            if uri and n > 0:
                self.namespace_size_by_uri[uri] = {
                    "count": n,
                    "seconds": round(float(getattr(self, "_last_load_seconds", 0.0)), 2),
                    "render_seconds": round(float(getattr(self, "_last_render_seconds", 0.0)), 2),
                }
                try:
                    self.save_settings()
                except Exception:
                    pass
                # Persist the full namespace for instant startup next time.
                try:
                    self._nscache_save(uri, self.namespace_cache)
                except Exception:
                    pass
        # A background revalidation completing clears the from-disk state — the
        # tree now reflects a fresh live browse.
        if getattr(self, "_nscache_revalidating", False) and \
                self._namespace_load_mode == "complete":
            self._nscache_revalidating = False
            self._nscache_from_disk = False
        self._logger.debug(
            "namespace load mode=%s estimate=%d",
            self._namespace_load_mode, self._namespace_estimate,
        )

    def on_nodes_loaded(self, nodes_dict):
        new_cache = nodes_dict or {}
        # Background namespace-cache revalidation: if the freshly-browsed
        # namespace is UNCHANGED from what's already shown, update the cache
        # and re-persist SILENTLY — do NOT rebuild the tree. Rebuilding would
        # destroy the QTreeWidgetItems the user is interacting with (and reset
        # expansion/scroll) for no benefit. Only a genuine change repopulates.
        if getattr(self, "_nscache_revalidating", False):
            old_cache = self.namespace_cache or {}
            unchanged = (len(new_cache) == len(old_cache)
                         and set(new_cache.keys()) == set(old_cache.keys()))
            # Consume the revalidation flag NOW, on both paths — leaving it set
            # would make the NEXT legitimate browse hit this guard and get
            # swallowed (empty tree). from_disk is cleared too: the tree now
            # reflects a fresh live browse either way.
            self._nscache_revalidating = False
            self._nscache_from_disk = False
            if unchanged:
                self.namespace_cache = new_cache
                uri = (self._current_uri
                       or self.uri_input.currentText() or "").strip()
                if uri:
                    try:
                        self._nscache_save(uri, new_cache)
                    except Exception:
                        pass
                return
            # Changed -> fall through and repopulate the tree normally.
        self.namespace_cache = new_cache
        # Browse finished (or was cancelled) — leave the cancellable UI state.
        self._end_browsing_ui()
        # Building the tree from a large cache is a synchronous, multi-second
        # step that blocks the GUI (no progress events) — so it looked like a
        # second, unexplained load phase. Tell the user we're rendering and paint
        # that message + wait cursor before the blocking rebuild.
        n = len(self.namespace_cache or {})
        if n > 5000:
            # Static pre-render estimate: the tree build is a synchronous
            # GUI-thread block, so a live ETA can't tick during it. Show the
            # expected render time (learned from the last complete load) so the
            # user knows how long the freeze will last.
            uri0 = (self._current_uri or self.uri_input.currentText() or "").strip()
            stats0 = self.namespace_size_by_uri.get(uri0) if uri0 else None
            est = float(stats0.get("render_seconds", 0.0)) if isinstance(stats0, dict) else 0.0
            msg = f"Rendering tree ({n:,} nodes"
            msg += f", ~{est:.0f}s)..." if est > 0.5 else ")..."
            self.statusBar().showMessage(msg)
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            QApplication.processEvents()
        render_t0 = time.time()
        try:
            # Apply the active Scope before rendering. Cache holds the full
            # browsed namespace; filtering is presentation-only.
            filtered = self._filter_nodes_for_active_scope(self.namespace_cache)
            self.node_tree.populate_tree(filtered)
        finally:
            if n > 5000:
                QApplication.restoreOverrideCursor()
        self._last_render_seconds = max(0.0, time.time() - render_t0)
        # Snapshot whether THIS browse was View-scoped, now — before the user can
        # trigger another (which would change the worker's live prune state). The
        # mode signal (on_namespace_load_mode) fires right after this and reads
        # the snapshot, so a scoped load's stats aren't mistakenly learned as the
        # full namespace size just because a later unscoped browse was queued.
        self._last_load_was_scoped = self._browse_was_scoped()
        self.node_tree.setEnabled(True)
        self.properties_widget.setEnabled(True)
        self.subscription_widget.setEnabled(True)
        self._stop_spinner()
        duration = 0.0
        try:
            start_ts = getattr(self.worker, "_namespace_load_start", None)
            if start_ts:
                duration = max(0.0, time.time() - start_ts)
        except Exception:
            duration = 0.0
        self.statusBar().showMessage(f"Loaded {len(nodes_dict)} nodes in {duration:.1f}s")
        # Stash this load's duration; on_namespace_load_mode (runs next, with the
        # definitive mode) records count + seconds per URI for the ETA. Kept fresh
        # every complete load so it tracks a namespace that changes over time.
        self._last_load_seconds = duration
        self._logger.debug(
            "NSCACHE_DBG on_nodes_loaded: n=%d duration=%.2fs "
            "from_cache=%s revalidating=%s start_ts=%s",
            len(nodes_dict or {}), duration,
            getattr(self, "_last_load_was_from_cache", False),
            getattr(self, "_nscache_revalidating", False),
            getattr(self.worker, "_namespace_load_start", None))
        self.refresh_methods_quick_access()
        # Re-evaluate Quick Filter gating for the new cache size (a typed-but-
        # unsearched filter now reads 'pending' if the cache exceeds the limit).
        self._update_quick_filter_mode()

        self.restore_subscriptions_after_connect()

    def on_node_children_loaded(self, parent_node_id, children_dict):
        # Keep namespace cache in sync if children arrive from worker
        if self.namespace_cache is not None:
            self.namespace_cache.update(children_dict or {})
            parent_entry = self.namespace_cache.get(parent_node_id)
            if isinstance(parent_entry, dict):
                parent_entry["children_ids"] = list(children_dict.keys())
                parent_entry["has_children"] = bool(children_dict)
                parent_entry["children_loaded"] = True

        self.node_tree.on_children_loaded(parent_node_id, children_dict)
        self.refresh_methods_quick_access()


    def on_node_selected(self, item):
        if _is_placeholder_text(item.text(0)):
            return

        node_data = item.data(0, Qt.ItemDataRole.UserRole)
        if node_data:
            # Attach parent node id if available so methods know their object
            parent_item = item.parent()
            if parent_item is not None:
                parent_data = parent_item.data(0, Qt.ItemDataRole.UserRole)
                if parent_data and isinstance(parent_data, dict):
                    parent_node_id = parent_data.get('node_id')
                else:
                    parent_node_id = None
            else:
                parent_node_id = None

            if parent_node_id:
                # Work on a shallow copy to avoid mutating shared structures
                node_data = dict(node_data)
                node_data['parent_node_id'] = parent_node_id

            self.properties_widget.update_properties(node_data)


    def on_method_invocation_requested(self, node_data):
        """Handle method invocation requests originating from the tree context menu."""
        if not (self.worker and self.worker.isRunning()):
            return
        if not isinstance(node_data, dict):
            return

        node_copy = dict(node_data)
        node_id = node_copy.get("node_id")
        parent_node_id = node_copy.get("parent_node_id")

        item = None
        if node_id and not parent_node_id:
            item = self.node_tree.find_item_by_node_id(node_id)

        if not parent_node_id and item:
            parent_item = item.parent()
            if parent_item:
                parent_data = parent_item.data(0, Qt.ItemDataRole.UserRole)
                if isinstance(parent_data, dict):
                    parent_node_id = parent_data.get("node_id")

        if not parent_node_id and node_id and node_id in self.namespace_cache:
            parent_node_id = self.namespace_cache.get(node_id, {}).get("parent_id")

        if not parent_node_id:
            # Fallback to standard Objects folder if parent not known
            parent_node_id = "i=85"

        node_copy["parent_node_id"] = parent_node_id

        # Keep tree item data in sync (helps future payloads)
        if item:
            item_data = item.data(0, Qt.ItemDataRole.UserRole)
            if isinstance(item_data, dict):
                updated = dict(item_data)
                updated["parent_node_id"] = parent_node_id
                item.setData(0, Qt.ItemDataRole.UserRole, updated)
            self.node_tree.setCurrentItem(item)
            self.on_node_selected(item)
        else:
            # No tree item found; use payload directly
            self.properties_widget.update_properties(node_copy)

        # Invoke using the current properties context
        self.properties_widget.invoke_method()


    def on_method_result(self, method_id, result):
        self.properties_widget.update_method_result(method_id, result)

    def on_subscription_update(self, node_id, value, data):
        self.subscription_widget.update_subscription_data(node_id, value, data)


    def rebrowse_nodes(self):
        if self.worker and self.worker.isRunning():
            self.namespace_cache = {}
            self.node_tree.clear()
            if getattr(self, "methods_quick_access", None):
                self.methods_quick_access.clear_methods()
            # Push the (possibly just-edited) per-URI limit so Rebrowse honors it.
            uri = (self._current_uri or self.uri_input.currentText() or "").strip()
            self.worker.lazy_loading_limit = self._effective_lazy_limit(uri)
            # View-aware browse (P3f): re-apply the active View's exclude prune.
            self._push_browse_prune()
            self._set_load_status_badge(None)
            self.worker.request_rebrowse()
            self._start_spinner("Rebrowsing nodes...")
            self.statusBar().showMessage("Rebrowsing nodes...")
            # Offer to Cancel the (possibly long) browse.
            self._begin_browsing_ui()
        else:
            # Attempt reconnect on user action
            if self.auto_reconnect_enabled and not self._user_requested_disconnect:
                self.statusBar().showMessage("Attempting to reconnect...")
                self.connect_to_server(show_progress=False, status_message="Reconnecting...")

    def refresh_methods_quick_access(self):
        if not getattr(self, "methods_quick_access", None):
            return
        methods = self._collect_methods_from_cache()
        self.methods_quick_access.update_methods(methods)

    def _collect_methods_from_cache(self):
        """Build method list from cached namespace, filtered by active scope.

        The quick-access methods view should reflect the same Scope as
        the node tree — otherwise the user sees admin methods in the
        side panel that aren't in the tree.
        """
        methods = []
        cache = self._filter_nodes_for_active_scope(self.namespace_cache or {})
        for node_id, node_data in cache.items():
            if not isinstance(node_data, dict):
                continue
            if node_data.get("node_class") != ua.NodeClass.Method:
                continue
            parent_id = node_data.get("parent_id")
            methods.append(
                {
                    "path": self._build_cached_path(parent_id),
                    "method": node_data,
                    "parent_node_id": parent_id,
                }
            )
        return methods

    def _build_cached_path(self, node_id):
        """Resolve a readable path from the cached hierarchy."""
        cache = self.namespace_cache or {}
        parts = []
        visited = set()
        current = node_id
        while current and current in cache and current not in visited:
            visited.add(current)
            data = cache[current]
            label = data.get("display_name") or data.get("browse_name") or current
            parts.append(label)
            current = data.get("parent_id")
        if not parts:
            return "(root)"
        return " / ".join(reversed(parts))

    def dump_namespace(self):
        """Export the namespace cache to JSON, CSV, YAML, XML (NodeSet2)
        or TXT for offline inspection / interop with other OPC UA tools."""
        if not self.namespace_cache:
            QMessageBox.warning(self, "Export Namespace", "No namespace loaded. Connect to a server first.")
            return

        # Ask user for dump type
        from PyQt6.QtWidgets import QDialog, QVBoxLayout, QRadioButton, QButtonGroup, QLabel, QDialogButtonBox

        dlg = QDialog(self)
        dlg.setWindowTitle("Export Namespace")
        layout = QVBoxLayout(dlg)

        layout.addWidget(QLabel("Select dump type:"))

        quick_radio = QRadioButton("Quick dump (structure only - fast)")
        quick_radio.setChecked(True)
        full_radio = QRadioButton("Full dump (with values/types/access - slower)")

        # Disable full dump if not connected
        if not (self.worker and self.worker.isRunning()):
            full_radio.setEnabled(False)
            full_radio.setText("Full dump (requires active connection)")

        layout.addWidget(quick_radio)
        layout.addWidget(full_radio)

        # Count variables for info
        var_count = sum(1 for n in self.namespace_cache.values()
                       if isinstance(n, dict) and n.get("node_class") == ua.NodeClass.Variable)
        layout.addWidget(QLabel(f"\nVariables to read for full dump: {var_count}"))

        # Warn if the export would only cover a partially-loaded namespace.
        # NB both Quick and Full dump work from the loaded cache — Full dump
        # reads VALUES for the loaded nodes, it does NOT fetch missing nodes.
        if self._namespace_is_partial():
            partial_lbl = QLabel(
                f"\n⚠  Namespace is only PARTIALLY loaded "
                f"({len(self.namespace_cache):,} nodes). Both dump types export "
                f"only the loaded nodes — Full dump adds their values but does "
                f"not fetch the missing ones.\nDisable Lazy loading and Rebrowse "
                f"first for a complete export.")
            partial_lbl.setWordWrap(True)
            partial_lbl.setStyleSheet(
                f"color: {self._partial_badge_color()}; font-weight: bold;")
            layout.addWidget(partial_lbl)

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

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

        full_dump = full_radio.isChecked()

        # If full dump requested, read all values first
        if full_dump and self.worker and self.worker.isRunning():
            self._dump_namespace_full()
        else:
            self._dump_namespace_quick()

    def _dump_namespace_full(self):
        """Perform full dump by first reading all variable values."""
        # Initialize state
        self._dump_progress = None
        self._dump_cancelled = False

        # Collect all variable node IDs
        variable_ids = [
            node_id for node_id, data in self.namespace_cache.items()
            if isinstance(data, dict) and data.get("node_class") == ua.NodeClass.Variable
        ]

        self.handle_log_message("INFO", f"Full dump: found {len(variable_ids)} variables to read")

        if not variable_ids:
            self._dump_namespace_quick(full_dump=True)
            return

        # Show progress dialog
        from PyQt6.QtWidgets import QProgressDialog
        from PyQt6.QtCore import Qt

        self._dump_progress = QProgressDialog(
            f"Reading {len(variable_ids)} variable values...", 
            "Cancel", 0, len(variable_ids), self
        )
        self._dump_progress.setWindowModality(Qt.WindowModality.WindowModal)
        self._dump_progress.setMinimumDuration(0)
        self._dump_progress.setValue(0)
        self._dump_progress.show()

        self._dump_variable_ids = variable_ids
        self._dump_values_read = 0

        # Connect cancel
        self._dump_progress.canceled.connect(self._on_dump_cancelled)

        # Connect worker signals
        try:
            self.worker.full_dump_progress.disconnect(self._on_full_dump_progress)
        except (TypeError, RuntimeError):
            pass
        try:
            self.worker.full_dump_complete.disconnect(self._on_full_dump_complete)
        except (TypeError, RuntimeError):
            pass

        self.worker.full_dump_progress.connect(self._on_full_dump_progress)
        self.worker.full_dump_complete.connect(self._on_full_dump_complete)

        # Start reading values via worker
        self.handle_log_message("INFO", "Starting batch read of variable values...")
        self.worker.read_all_values_async(variable_ids)

    def _on_dump_cancelled(self):
        """Handle dump cancellation."""
        self._dump_cancelled = True
        if hasattr(self, '_dump_progress') and self._dump_progress:
            self._dump_progress.close()
            self._dump_progress = None

    def _on_full_dump_progress(self, current, total):
        """Update progress during full dump."""
        if self._dump_cancelled:
            return
        try:
            if self._dump_progress is not None:
                self._dump_progress.setValue(current)
                self._dump_progress.setLabelText(f"Reading values... {current}/{total}")
        except (AttributeError, RuntimeError):
            # Dialog may have been closed/deleted
            pass

    def _on_full_dump_complete(self, values_dict):
        """Called when all values have been read for full dump."""
        self.handle_log_message("INFO", f"Full dump complete: received {len(values_dict)} values")

        # Disconnect signals first to prevent any race conditions
        try:
            self.worker.full_dump_progress.disconnect(self._on_full_dump_progress)
            self.worker.full_dump_complete.disconnect(self._on_full_dump_complete)
        except Exception:
            pass

        # Mark as completing (not cancelled) before closing dialog
        was_cancelled = self._dump_cancelled

        # Close progress dialog - disconnect canceled signal first to prevent it triggering
        try:
            if self._dump_progress is not None:
                try:
                    self._dump_progress.canceled.disconnect(self._on_dump_cancelled)
                except Exception:
                    pass
                self._dump_progress.close()
                self._dump_progress = None
        except Exception:
            pass

        if was_cancelled:
            self.handle_log_message("INFO", "Full dump was cancelled")
            return

        # Update namespace cache with read values
        self.handle_log_message("INFO", "Updating namespace cache with values...")
        for node_id, value_data in values_dict.items():
            if node_id in self.namespace_cache:
                self.namespace_cache[node_id].update(value_data)

        # Show file dialog directly
        self.handle_log_message("INFO", "Opening file save dialog...")
        self._dump_namespace_quick(full_dump=True)

    def _dump_namespace_quick(self, full_dump=False):
        """Export the namespace cache to file."""
        # Generate default filename with timestamp and sanitized server name
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        uri = self.uri_input.currentText().strip()
        # Extract host and port from URI for filename (e.g., "opc.tcp://plc1:4840" -> "plc1_4840")
        # Dots in IP addresses are replaced with underscores to avoid extension confusion
        server_name = "server"
        if uri:
            import re
            match = re.search(r'://([^:/]+)(?::(\d+))?', uri)
            if match:
                host = re.sub(r'[^\w\-]', '_', match.group(1))  # dots become underscores
                port = match.group(2)
                server_name = f"{host}_{port}" if port else host
        default_name = f"namespace_{server_name}_{timestamp}"
        default_path = Path.cwd() / default_name

        # Prompt for save location with format options
        # Explicit non-native QFileDialog at 720x480 (consistent with the
        # other Load/Save dialogs in the app) — the native KDE dialog
        # otherwise inherits a huge remembered geometry.
        dialog = QFileDialog(self, "Export Namespace", str(default_path))
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        dialog.setNameFilters([
            "Text Files (*.txt)",
            "YAML Files (*.yaml *.yml)",
            "CSV Files (*.csv)",
            "JSON Files (*.json)",
            "OPC UA NodeSet2 XML (*.xml)",
            "All Files (*)",
        ])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        file_path = files[0]
        selected_filter = dialog.selectedNameFilter()

        # Determine format from extension or filter
        file_ext = Path(file_path).suffix.lower()
        if not file_ext:
            # Add extension based on selected filter
            if "Text" in selected_filter:
                file_path += ".txt"
                file_ext = ".txt"
            elif "YAML" in selected_filter:
                file_path += ".yaml"
                file_ext = ".yaml"
            elif "CSV" in selected_filter:
                file_path += ".csv"
                file_ext = ".csv"
            elif "XML" in selected_filter or "NodeSet" in selected_filter:
                file_path += ".xml"
                file_ext = ".xml"
            else:
                file_path += ".json"
                file_ext = ".json"

        try:
            # Prepare serializable namespace data
            def serialize_node(node_data):
                """Convert node data to JSON-serializable format."""
                if not isinstance(node_data, dict):
                    return node_data
                result = {}
                for key, value in node_data.items():
                    if isinstance(value, ua.NodeClass):
                        result[key] = value.name  # e.g., "Variable", "Object", "Method"
                    elif hasattr(value, '__class__') and value.__class__.__module__.startswith('asyncua'):
                        result[key] = str(value)
                    elif isinstance(value, (list, tuple)):
                        result[key] = [serialize_node(v) if isinstance(v, dict) else str(v) if hasattr(v, '__class__') and v.__class__.__module__.startswith('asyncua') else v for v in value]
                    else:
                        try:
                            json.dumps(value)  # Test if serializable
                            result[key] = value
                        except (TypeError, ValueError):
                            result[key] = str(value)
                return result

            serialized_namespace = {
                node_id: serialize_node(node_data)
                for node_id, node_data in self.namespace_cache.items()
            }

            if file_ext == ".txt":
                # ASCII text format: human-readable one-liner per node
                # Quick: /path  # node_type [ns=N]
                # Full:  /path=value  # data_type, node_type, rwh [ns=N]
                def build_path(node_id):
                    """Build full path from node to root."""
                    parts = []
                    current = node_id
                    visited = set()
                    while current and current not in visited:
                        visited.add(current)
                        data = self.namespace_cache.get(current)
                        if not data:
                            break
                        name = data.get("display_name") or data.get("browse_name") or current
                        parts.append(name)
                        current = data.get("parent_id")
                    return "/" + "/".join(reversed(parts)) if parts else "/" + node_id

                def get_access_str(node_data):
                    """Build rwh-style access string."""
                    access = node_data.get("access_level") or node_data.get("user_access_level") or 0
                    if isinstance(access, str):
                        return access[:3] if len(access) >= 3 else access
                    try:
                        access = int(access)
                    except (TypeError, ValueError):
                        return "---"
                    r = 'r' if (access & 1) else '-'
                    w = 'w' if (access & 2) else '-'
                    h = 'h' if (access & 4) else '-'
                    return f"{r}{w}{h}"

                lines = []
                lines.append(f"# OPC UA Namespace Dump")
                lines.append(f"# URI: {uri}")
                lines.append(f"# Timestamp: {datetime.now().isoformat()}")
                lines.append(f"# Nodes: {len(self.namespace_cache)}")
                lines.append(f"# Dump type: {'Full (with values)' if full_dump else 'Quick (structure only)'}")
                lines.append(f"#")
                if full_dump:
                    lines.append(f"# Format: /path=value  # data_type, node_type, access [ns=N]")
                    lines.append(f"#   access: r=readable, w=writable, h=history")
                else:
                    lines.append(f"# Format: /path  # node_type [ns=N]")
                lines.append(f"#")

                # Sort by path for readability
                node_paths = []
                for node_id, node_data in self.namespace_cache.items():
                    if isinstance(node_data, dict):
                        path = build_path(node_id)
                        node_paths.append((path, node_id, node_data))
                node_paths.sort(key=lambda x: x[0].lower())

                for path, node_id, node_data in node_paths:
                    node_class = node_data.get("node_class")
                    if isinstance(node_class, ua.NodeClass):
                        class_name = node_class.name
                    else:
                        class_name = str(node_class) if node_class else "?"

                    ns_idx = node_data.get("namespace_index", "")
                    ns_str = f"[ns={ns_idx}]" if ns_idx != "" else ""

                    if full_dump and class_name == "Variable":
                        # Full format with value, type, access
                        value = node_data.get("value")
                        if value is None:
                            value_str = "<null>"
                        else:
                            value_str = str(value)
                            if len(value_str) > 60:
                                value_str = value_str[:57] + "..."

                        data_type = node_data.get("data_type", "?")
                        access_str = get_access_str(node_data)

                        line = f"{path}={value_str}  # {data_type}, {class_name}, {access_str}"
                    elif class_name == "Method":
                        line = f"{path}()  # {class_name}"
                    else:
                        line = f"{path}  # {class_name}"

                    if ns_str:
                        line += f" {ns_str}"

                    lines.append(line)

                with open(file_path, 'w', encoding='utf-8') as f:
                    f.write('\n'.join(lines) + '\n')

            elif file_ext == ".csv":
                # CSV format: flat table with key columns
                import csv
                # Collect all possible keys from all nodes
                all_keys = set()
                for node_data in serialized_namespace.values():
                    if isinstance(node_data, dict):
                        all_keys.update(node_data.keys())
                # Define preferred column order
                preferred_order = ['node_id', 'display_name', 'browse_name', 'node_class', 
                                   'parent_id', 'namespace_index', 'has_children']
                ordered_keys = [k for k in preferred_order if k in all_keys]
                ordered_keys += sorted(k for k in all_keys if k not in preferred_order)

                with open(file_path, 'w', encoding='utf-8', newline='') as f:
                    # Linux-friendly line endings (\n) instead of csv's
                    # default \r\n which renders as ^M in cat/less.
                    writer = csv.writer(f, lineterminator="\n")
                    writer.writerow(ordered_keys)
                    for node_id, node_data in serialized_namespace.items():
                        if isinstance(node_data, dict):
                            row = []
                            for key in ordered_keys:
                                value = node_data.get(key, '')
                                # Flatten lists/dicts for CSV
                                if isinstance(value, (list, dict)):
                                    value = str(value)
                                row.append(value)
                            writer.writerow(row)

            elif file_ext in (".yaml", ".yml"):
                # YAML format: human-readable hierarchical
                try:
                    import yaml
                except ImportError:
                    QMessageBox.warning(
                        self, "Dump Namespace",
                        "PyYAML is not installed. Install with: pip install pyyaml\n\n"
                        "Falling back to JSON format."
                    )
                    file_path = str(Path(file_path).with_suffix('.json'))
                    file_ext = ".json"
                else:
                    output = {
                        "metadata": {
                            "uri": uri,
                            "timestamp": datetime.now().isoformat(),
                            "node_count": len(self.namespace_cache),
                            "uaexplorer_version": self._last_update_timestamp,
                        },
                        "namespace": serialized_namespace
                    }
                    with open(file_path, 'w', encoding='utf-8') as f:
                        yaml.dump(output, f, default_flow_style=False, allow_unicode=True, 
                                  sort_keys=False, width=120)

            if file_ext == ".xml":
                # OPC UA NodeSet2 XML. Validates against the official
                # UANodeSet schema and is loadable by other OPC UA tools
                # (UaModeler, Prosys, FreeOpcUa server's import_xml).
                # NB: client-side cache lacks some data a server-emitted
                # NodeSet2 would have (TypeDefinition references, full
                # inverse refs, etc.), so this is a SUBSET — round-trips
                # the structure + values but isn't a server-rebuildable
                # type system.
                self._write_namespace_xml(file_path, uri)

            elif file_ext == ".json":
                # JSON format (default)
                output = {
                    "metadata": {
                        "uri": uri,
                        "timestamp": datetime.now().isoformat(),
                        "node_count": len(self.namespace_cache),
                        "uaexplorer_version": self._last_update_timestamp,
                    },
                    "namespace": serialized_namespace
                }
                with open(file_path, 'w', encoding='utf-8') as f:
                    json.dump(output, f, indent=2, ensure_ascii=False)

            self.statusBar().showMessage(f"Namespace dumped to {file_path}", 5000)
            self.handle_log_message("INFO", f"Namespace dumped: {len(self.namespace_cache)} nodes to {file_path}")

        except Exception as e:
            self.handle_log_message("ERROR", f"Failed to dump namespace: {e}")
            QMessageBox.critical(self, "Export Namespace", f"Failed to save namespace:\n{e}")

    # ------------------------------------------------------------------
    # NodeSet2 XML export
    #
    # The OPC UA NodeSet2 schema (UANodeSet.xsd, OPC UA Spec Part 6) is
    # what UaModeler / Prosys / asyncua's import_xml all consume. Even
    # though our client-side cache doesn't hold every field a full server
    # nodeset would have (TypeDefinition references, full inverse refs,
    # etc.), the output validates against the schema and is round-trip
    # parseable — useful for archiving a server's surface, sharing it
    # between sites, or seeding a mock server.
    #
    # NodeClass values are spec-defined:
    #   1 = Object, 2 = Variable, 4 = Method, 8 = ObjectType,
    #   16 = VariableType, 32 = ReferenceType, 64 = DataType, 128 = View
    # ------------------------------------------------------------------

    _NODECLASS_TO_ELEMENT = {
        1: "UAObject",
        2: "UAVariable",
        4: "UAMethod",
        8: "UAObjectType",
        16: "UAVariableType",
        32: "UAReferenceType",
        64: "UADataType",
        128: "UAView",
    }

    @staticmethod
    def _xml_escape(s: str) -> str:
        """Minimal escape for XML attribute / text content."""
        return (
            str(s)
            .replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace('"', "&quot;")
            .replace("'", "&apos;")
        )

    def _format_xml_nodeid(self, node_id_raw) -> str:
        """Render a NodeId as the string form used in NodeSet2 XML
        (e.g. ``i=85``, ``ns=2;s=system.Counter``)."""
        if node_id_raw is None:
            return ""
        s = str(node_id_raw)
        # asyncua's NodeId.__str__ already produces the canonical form
        # ``ns=N;<type>=<value>`` (or just ``i=N`` for ns=0). Strip a
        # surrounding "NodeId(...)" wrapper if present from a fallback.
        if s.startswith("NodeId(") and s.endswith(")"):
            # Best-effort: try to pull out the standard form from the
            # repr — most asyncua versions never enter this branch.
            return s
        return s

    def _write_namespace_xml(self, file_path: str, uri: str) -> None:
        """Emit the cached namespace as a NodeSet2 XML document.

        Each cache entry becomes one UAObject / UAVariable / UAMethod /
        UAObjectType / ... element. Parent-child relations are emitted
        as ``HasComponent`` references. Variable values are written as
        ``<Value>`` text using the OPC UA scalar variant tags."""
        lines: List[str] = []
        lines.append('<?xml version="1.0" encoding="utf-8"?>')
        lines.append('<UANodeSet '
                     'xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd" '
                     'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
                     'xmlns:uax="http://opcfoundation.org/UA/2008/02/Types.xsd">')

        # NamespaceUris from the worker's namespace_array (skip index 0,
        # which is the standard OPC UA namespace and is implicit).
        ns_array = list(getattr(self.worker, "namespace_array", []) or [])
        if len(ns_array) > 1:
            lines.append('  <NamespaceUris>')
            for ns in ns_array[1:]:
                lines.append(f'    <Uri>{self._xml_escape(ns)}</Uri>')
            lines.append('  </NamespaceUris>')

        # One header comment with metadata so users can trace the export.
        ts = datetime.now().isoformat()
        lines.append(
            f'  <!-- Exported by UA Explorer from {self._xml_escape(uri)} '
            f'at {self._xml_escape(ts)}; {len(self.namespace_cache)} nodes -->'
        )

        # Emit nodes. Skip synthetic InputArguments/OutputArguments
        # children we manufacture for the GUI (keyed with '::') — they
        # aren't real server-side nodes.
        for path, node_data in self.namespace_cache.items():
            if not isinstance(node_data, dict):
                continue
            if "::" in path:
                continue
            self._emit_xml_node(lines, path, node_data)

        lines.append('</UANodeSet>')
        with open(file_path, "w", encoding="utf-8", newline="\n") as f:
            f.write("\n".join(lines))
            f.write("\n")

    def _emit_xml_node(self, lines: List[str], path: str, node_data: dict) -> None:
        """Append one NodeSet2 element for a single namespace_cache entry."""
        node_class_raw = node_data.get("node_class")
        # node_class can be an int (from JSON-restored caches) or a
        # ua.NodeClass enum. Normalize to int.
        if hasattr(node_class_raw, "value"):
            nc = int(node_class_raw.value)
        else:
            try:
                nc = int(node_class_raw) if node_class_raw is not None else 0
            except Exception:
                nc = 0
        element = self._NODECLASS_TO_ELEMENT.get(nc, "UAObject")

        node_id_str = self._format_xml_nodeid(node_data.get("node_id") or path)
        browse_name = node_data.get("browse_name") or node_data.get("display_name") or path.rsplit("/", 1)[-1]
        display_name = node_data.get("display_name") or browse_name
        ns_index = node_data.get("namespace_index", 0) or 0
        # BrowseName in NodeSet2 is "<ns>:<name>" (or just "<name>" for ns=0)
        if ns_index:
            browse_name_attr = f"{ns_index}:{browse_name}"
        else:
            browse_name_attr = browse_name

        attrs = (
            f'NodeId="{self._xml_escape(node_id_str)}" '
            f'BrowseName="{self._xml_escape(browse_name_attr)}"'
        )

        # Variable / VariableType need DataType + ValueRank attributes.
        if element in ("UAVariable", "UAVariableType"):
            dt = node_data.get("data_type")
            if dt:
                attrs += f' DataType="{self._xml_escape(self._format_xml_nodeid(dt))}"'
            vr = node_data.get("value_rank")
            if vr is not None:
                attrs += f' ValueRank="{int(vr)}"'
            al = node_data.get("user_access_level")
            if al is None:
                al = node_data.get("access_level")
            if al is not None:
                try:
                    al_int = int(al)
                    attrs += f' AccessLevel="{al_int}" UserAccessLevel="{al_int}"'
                except Exception:
                    pass

        lines.append(f"  <{element} {attrs}>")
        lines.append(f"    <DisplayName>{self._xml_escape(display_name)}</DisplayName>")

        desc = node_data.get("description")
        if desc:
            lines.append(f"    <Description>{self._xml_escape(desc)}</Description>")

        # References: emit HasComponent to children (best-effort —
        # client-side cache is the truth we have). Only the direct
        # children we know about.
        children = node_data.get("children") or {}
        if children:
            lines.append("    <References>")
            for child_path, child_data in children.items():
                if not isinstance(child_data, dict):
                    continue
                if "::" in child_path:
                    continue  # synthetic
                child_node_id = self._format_xml_nodeid(
                    child_data.get("node_id") or child_path
                )
                lines.append(
                    f'      <Reference ReferenceType="HasComponent">'
                    f'{self._xml_escape(child_node_id)}</Reference>'
                )
            lines.append("    </References>")

        # Variable values — write the current cached scalar as <Value>.
        if element == "UAVariable" and "raw_value" in node_data:
            raw = node_data.get("raw_value")
            if raw is not None:
                variant_tag = self._xml_variant_tag_for(node_data.get("data_type"))
                if variant_tag:
                    lines.append(
                        f'    <Value><uax:{variant_tag}>'
                        f'{self._xml_escape(raw)}</uax:{variant_tag}></Value>'
                    )

        lines.append(f"  </{element}>")

    @staticmethod
    def _xml_variant_tag_for(data_type_str) -> Optional[str]:
        """Map a data_type string (e.g. ``Int64``, ``Boolean``,
        ``NodeId(Identifier=11, ...)``) to the NodeSet2 ``<uax:...>``
        tag used inside ``<Value>``. Returns None when unknown."""
        if data_type_str is None:
            return None
        s = str(data_type_str)
        # Look for both readable names and ns=0 numeric IDs.
        builtins = {
            "Boolean": "Boolean", "i=1": "Boolean",
            "SByte": "SByte",     "i=2": "SByte",
            "Byte": "Byte",       "i=3": "Byte",
            "Int16": "Int16",     "i=4": "Int16",
            "UInt16": "UInt16",   "i=5": "UInt16",
            "Int32": "Int32",     "i=6": "Int32",
            "UInt32": "UInt32",   "i=7": "UInt32",
            "Int64": "Int64",     "i=8": "Int64",
            "UInt64": "UInt64",   "i=9": "UInt64",
            "Float": "Float",     "i=10": "Float",
            "Double": "Double",   "i=11": "Double",
            "String": "String",   "i=12": "String",
        }
        for key, tag in builtins.items():
            if key in s:
                return tag
        return None

    def _start_method_scan(self):
        """Walk the loaded tree and eagerly load children to discover methods."""
        if not self.worker or not self.worker.isRunning():
            return
        self._method_scan_queue.clear()
        self._method_scan_pending.clear()
        for i in range(self.node_tree.topLevelItemCount()):
            self._method_scan_queue.append(self.node_tree.topLevelItem(i))
        if self._method_scan_queue:
            self._method_scan_timer.start()

    def _stop_method_scan(self):
        self._method_scan_timer.stop()
        self._method_scan_queue.clear()
        self._method_scan_pending.clear()

    def _method_scan_tick(self):
        if not self.worker or not self.worker.isRunning():
            self._stop_method_scan()
            return

        # If nothing left to process, finish and refresh once more
        if not self._method_scan_queue and not self._method_scan_pending:
            self._stop_method_scan()
            self.refresh_methods_quick_access()
            return

        # Process a small batch to keep UI responsive
        batch = min(5, len(self._method_scan_queue))
        for _ in range(batch):
            if not self._method_scan_queue:
                break
            item = self._method_scan_queue.pop(0)
            if not item:
                continue
            node_data = item.data(0, Qt.ItemDataRole.UserRole)
            if not node_data:
                continue
            node_id = node_data.get("node_id")
            has_children = bool(node_data.get("has_children"))
            children_loaded = bool(node_data.get("children_loaded"))

            # If children are already loaded, enqueue them directly
            if children_loaded and item.childCount() > 0:
                for i in range(item.childCount()):
                    self._method_scan_queue.append(item.child(i))
                continue

            if has_children and not children_loaded and node_id and node_id not in self._method_scan_pending:
                self._method_scan_pending.add(node_id)
                self.worker.request_load_children(node_id)


    def _refresh_uri_dropdown(self, current_text: Optional[str] = None):
        """Refresh URI combo box with current history while preserving text."""
        # Prevent signal cascades while we rebuild the list
        self.uri_input.blockSignals(True)
        self.uri_input.clear()

        if self.uri_history:
            # Two-column popup: URI first, then the friendly name (if any).
            # The popup font is monospaced (set once, below) so padding the URI
            # to a common width aligns the name column. The clean URI is stored
            # as userData and restored to the line edit on selection (see
            # _on_uri_selected), so the "uri   name" display text never leaks
            # into the field.
            width = max((len(u) for u in self.uri_history), default=0)
            popup_font = getattr(self, "_uri_popup_font", None)
            for uri in self.uri_history:
                name = str(self.server_names.get(uri, "")).strip()
                display = f"{uri.ljust(width)}   {name}" if name else uri
                self.uri_input.addItem(display, uri)
                if popup_font is not None:
                    # Per-item monospace so the name column aligns even if the
                    # app stylesheet overrides the view's font.
                    self.uri_input.setItemData(
                        self.uri_input.count() - 1, popup_font,
                        Qt.ItemDataRole.FontRole)

        if current_text is None:
            if self.uri_history:
                current_text = self.uri_history[0]
            else:
                current_text = "opc.tcp://localhost:4840"

        self.uri_input.setEditText(current_text)
        self.uri_input.blockSignals(False)


    def _add_uri_to_history(self, uri: str, persist: bool = True):
        """Insert URI at top of history, keeping only the latest 20 entries."""
        uri = uri.strip()
        if not uri:
            return

        self.uri_history = [existing for existing in self.uri_history if existing != uri]
        self.uri_history.insert(0, uri)
        self.uri_history = self.uri_history[:20]
        self._refresh_uri_dropdown(current_text=uri)

        if persist:
            self.save_settings()

    def _on_uri_changed(self):
        """Handle URI editing finished (Enter pressed or focus lost)."""
        # Guard against re-entrancy during reconnection
        if getattr(self, '_uri_change_in_progress', False):
            return
        new_uri = self.uri_input.currentText().strip()
        if new_uri and new_uri != self._current_uri:
            # Clear subscriptions and close UaPlot when URI changes
            self._clear_for_uri_change()
            self._current_uri = new_uri
            self._refresh_server_name_display()
            self._refresh_lazy_limit_display()
            self._restore_view_for_uri(new_uri)
            self._set_load_status_badge(None)
            # If connected, disconnect from old and connect to new
            if self._connect_button_state == "connected":
                self.statusBar().showMessage(f"URI changed, reconnecting to {new_uri}...")
                self._reconnect_to_new_uri()

    def _on_uri_selected(self, index: int):
        """Handle URI selected from dropdown."""
        # Guard against re-entrancy during reconnection
        if getattr(self, '_uri_change_in_progress', False):
            return
        # Read the raw URI from userData (set to the URI when building the
        # dropdown) — the visible text may be "name - uri".
        data_val = self.uri_input.itemData(index)
        if data_val:
            new_uri = str(data_val).strip()
        else:
            # Fall back to the display text, stripping any "name  —  " prefix.
            raw = self.uri_input.itemText(index).strip()
            new_uri = raw.split("  —  ")[-1].strip() if "  —  " in raw else raw
        # Selecting an item drops the visible "name  —  uri" text into the line
        # edit; replace it with the clean URI so the field always holds a raw URI.
        if new_uri:
            self.uri_input.blockSignals(True)
            self.uri_input.setEditText(new_uri)
            self.uri_input.blockSignals(False)
        if new_uri and new_uri != self._current_uri:
            # Clear subscriptions and close UaPlot when URI changes
            self._clear_for_uri_change()
            self._current_uri = new_uri
            self._refresh_server_name_display()
            self._refresh_lazy_limit_display()
            self._restore_view_for_uri(new_uri)
            self._set_load_status_badge(None)
            # If connected, disconnect from old and connect to new
            if self._connect_button_state == "connected":
                self.statusBar().showMessage(f"URI changed, reconnecting to {new_uri}...")
                self._reconnect_to_new_uri()

    def _refresh_server_name_display(self):
        """Sync the Name field with self.server_names for the current URI."""
        if not hasattr(self, "server_name_edit"):
            return
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        name = str(self.server_names.get(uri, "")).strip() if uri else ""
        self.server_name_edit.blockSignals(True)
        self.server_name_edit.setText(name)
        self.server_name_edit.blockSignals(False)

    def _on_server_name_edited(self):
        """Persist the entered name against the current URI.

        Empty input removes the mapping entry to keep the dict tidy.
        No-op if no URI is set yet.
        """
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        if not uri:
            self._refresh_server_name_display()
            return
        name = self.server_name_edit.text().strip()
        if name:
            self.server_names[uri] = name
        else:
            self.server_names.pop(uri, None)
        # Reflect the (new/cleared) name in the dropdown labels right away,
        # keeping the current URI in the line edit.
        self._refresh_uri_dropdown(current_text=uri)
        try:
            self.save_settings()
        except Exception:
            pass

    def _refresh_lazy_limit_display(self):
        """Sync the Lazy checkbox + limit spinbox with the current URI's stored
        setting. Stored 0 = lazy disabled (box unchecked, spinbox greyed but
        keeps a sensible value); positive/missing = enabled with that limit."""
        if not hasattr(self, "lazy_limit_spin"):
            return
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        stored = self._lazy_loading_limit_for_uri(uri) if uri else DEFAULT_LAZY_LOADING_LIMIT
        enabled = int(stored) > 0
        self.lazy_enable_check.blockSignals(True)
        self.lazy_enable_check.setChecked(enabled)
        self.lazy_enable_check.blockSignals(False)
        self.lazy_limit_spin.blockSignals(True)
        # When disabled the stored value is 0; show the default so the spinbox
        # has a usable number the moment the user re-enables lazy loading.
        self.lazy_limit_spin.setValue(int(stored) if enabled else DEFAULT_LAZY_LOADING_LIMIT)
        self.lazy_limit_spin.setEnabled(enabled)
        self.lazy_limit_spin.blockSignals(False)

    def _persist_lazy_setting(self, uri, value):
        """Store the per-URI lazy setting. `value` is the effective limit (0 =
        disabled). The default (enabled @ DEFAULT_LAZY_LOADING_LIMIT) is not
        stored, so a missing URI cleanly falls back to it."""
        if value == DEFAULT_LAZY_LOADING_LIMIT:
            self.lazy_loading_limits.pop(uri, None)
        else:
            self.lazy_loading_limits[uri] = int(value)
        try:
            self.save_settings()
        except Exception:
            pass

    def _on_lazy_limit_edited(self):
        """Persist the edited limit against the current URI (only meaningful when
        lazy is enabled). Takes effect on the next Connect / Rebrowse."""
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        if not uri:
            self._refresh_lazy_limit_display()
            return
        self._persist_lazy_setting(uri, int(self.lazy_limit_spin.value()))

    def _on_lazy_enable_toggled(self, checked):
        """Enable/disable Lazy Node Loading for the current URI. Unchecked stores
        0 (always full); checked stores the spinbox limit.

        Turning lazy OFF while connected means the current tree is still only
        partially loaded — so offer to load the whole namespace now (a full
        Rebrowse), which is what the user almost certainly wants."""
        if hasattr(self, "lazy_limit_spin"):
            self.lazy_limit_spin.setEnabled(checked)
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        if not uri:
            return
        value = int(self.lazy_limit_spin.value()) if checked else 0
        self._persist_lazy_setting(uri, value)

        # Just disabled lazy while connected -> the effective limit is now 0
        # (full), but the loaded tree is still the old partial set. Offer to
        # reload the whole namespace right away.
        if not checked and self.worker and self.worker.isRunning():
            resp = QMessageBox.question(
                self, "Lazy Node Loading disabled",
                "Lazy Node Loading is now off for this server.\n\n"
                "Load the full namespace now? On a large server this can take a "
                "while.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes)
            if resp == QMessageBox.StandardButton.Yes:
                self._trigger_full_load()

    def _trigger_full_load(self):
        """Reload the whole namespace: push a 0 (unlimited) limit to the worker
        and Rebrowse. Shared by the lazy-off prompt and the partial-warning
        'Load full namespace' action."""
        if not (self.worker and self.worker.isRunning()):
            return
        self.worker.lazy_loading_limit = 0
        self.rebrowse_nodes()

    def _namespace_is_partial(self) -> bool:
        """True when the current namespace is only partially loaded (Lazy Node
        Loading stopped at the limit). Full-cache features warn when this holds."""
        return getattr(self, "_namespace_load_mode", "complete") == "partial"

    def _warn_if_partial(self, feature: str) -> None:
        """Non-blocking notice that `feature` ran against a partially-loaded
        namespace, so results may be incomplete — with a one-click full load.
        No-op when the namespace is COMPLETE."""
        if not self._namespace_is_partial():
            return
        n = len(self.namespace_cache or {})
        self.statusBar().showMessage(
            f"{feature}: namespace only PARTIALLY loaded ({n:,} nodes) — "
            f"results may be incomplete. Disable Lazy loading (or clear the "
            f"limit) and Rebrowse for the full set.", 8000)

    def _partial_badge_color(self) -> str:
        """PARTIAL badge colour, picked per theme so it's legible on each
        surface. The generic bright orange washes out on Atacama's reddish tan,
        so that theme gets a deep burnt-umber; Light/Dark get a punchy orange."""
        per_theme = {
            "Atacama": "#7a2e00",  # deep burnt umber — stands out on tan/clay
            "Dark": "#ff9a3c",     # bright orange — pops on dark
            "Light": "#e06600",    # strong orange — clear on white
        }
        return per_theme.get(THEME.active.name, THEME.active.status_warn)

    def _set_load_status_badge(self, mode, count=0):
        """Render the PARTIAL/COMPLETE load badge. mode None -> hidden (idle).
        Remembers the last mode/count so a theme switch can re-render with the
        theme-appropriate colour (see _refresh_load_status_badge_style)."""
        if not hasattr(self, "load_status_badge"):
            return
        self._load_badge_mode = mode
        self._load_badge_count = count
        if mode == "partial":
            text = f"PARTIAL ({count:,})" if count else "PARTIAL"
            color = self._partial_badge_color()
        elif mode == "complete":
            # A View-scoped browse loads only its subtree fully — that IS complete
            # for the View, but flag it so the count doesn't read as the whole
            # namespace ("COMPLETE (12,943) [View]" vs the full "COMPLETE").
            # Use the snapshot from on_nodes_loaded (the live worker state may
            # already reflect a newer browse).
            scoped = getattr(self, "_last_load_was_scoped", False)
            base = f"COMPLETE ({count:,})" if count else "COMPLETE"
            text = f"{base} [View]" if scoped else base
            color = THEME.active.status_ok
            if scoped:
                self.load_status_badge.setToolTip(
                    "The active View scopes the browse — this is the full set of "
                    "nodes the View allows, not the entire namespace. Switch to "
                    "the Default view and Rebrowse for the whole namespace.")
            else:
                self.load_status_badge.setToolTip(
                    "The entire namespace is loaded.")
        else:
            self.load_status_badge.setText("")
            self.load_status_badge.setVisible(False)
            return
        self.load_status_badge.setText(text)
        self.load_status_badge.setStyleSheet(
            f"QLabel {{ color: {color}; font-weight: bold; padding: 0 4px; }}")
        self.load_status_badge.setVisible(True)

    def _refresh_load_status_badge_style(self) -> None:
        """Re-render the badge with the current theme's colours (on theme
        switch). No-op if the badge is idle/hidden."""
        mode = getattr(self, "_load_badge_mode", None)
        if mode in ("partial", "complete"):
            self._set_load_status_badge(mode, getattr(self, "_load_badge_count", 0))

    def _reconnect_to_new_uri(self):
        """Disconnect from current server and connect to new URI."""
        # Guard against re-entrancy
        if getattr(self, '_uri_change_in_progress', False):
            return
        self._uri_change_in_progress = True

        # Set flag to prevent auto-reconnect to old URI
        self._user_requested_disconnect = True

        # Disconnect from current server
        self.disconnect_from_server()

        # Reset flags for new connection
        self._user_requested_disconnect = False
        self._had_successful_connection = False

        # Use a single-shot timer to allow UI to process, then connect
        QTimer.singleShot(200, self._connect_after_uri_change)

    def _clear_for_uri_change(self):
        """Clear subscriptions and close UaPlot when URI changes."""
        # Clear subscription table
        if self.subscription_widget:
            self.subscription_widget.clear_all()

        # Clear plotted variables tracking
        self.plotted_variables.clear()

        # Close UaPlot if it exists - stop() is safe to call even if not running
        if self.plot_bridge:
            self.plot_bridge.stop()
            self.handle_log_message("INFO", "UaPlot closed due to URI/session change")

        self.handle_log_message("INFO", "Subscriptions cleared due to URI/session change")

    def _connect_after_uri_change(self):
        """Connect to new URI after disconnect."""
        self._uri_change_in_progress = False
        # Start connection to new URI
        self.connect_to_server()

    def _set_connect_button_state(self, state: str):
        """Centralize connect button label/colors for different states."""
        self._connect_button_state = state
        t = THEME.active
        if state == "connected":
            text = "Disconnect"
            color = t.status_ok
        elif state == "reconnecting":
            text = "Reconnecting..."
            color = t.status_error
        elif state == "browsing":
            # A browse is in progress and can be cancelled — orange call-to-stop.
            text = "Cancel"
            color = self._partial_badge_color()  # theme-appropriate orange
        else:
            text = "Connect"
            # Slightly different shade for "Connect" so it reads as a
            # call-to-action rather than a status indicator.
            color = t.accent

        self.connect_button.setText(text)
        self.connect_button.setStyleSheet(
            f"background-color: {color}; color: {t.text_on_accent}; font-weight: bold;"
        )
        if not self._spinner_active:
            if state == "connected":
                self._set_indicator_connected()
            else:
                self._set_indicator_idle()

    def _begin_browsing_ui(self):
        """Put the connect button into the cancellable 'browsing' state (orange
        'Cancel', enabled) for the duration of a namespace browse."""
        self._set_connect_button_state("browsing")
        self.connect_button.setEnabled(True)
        self._browse_ui_start = time.time()

    def _end_browsing_ui(self):
        """Leave the 'browsing' state when a browse finishes/cancels, restoring
        the button to its connected/disconnected look."""
        if self._connect_button_state == "browsing":
            self._set_connect_button_state(
                "connected" if (self.worker and self.worker.isRunning())
                else "disconnected")
        self.connect_button.setEnabled(True)

    # -------------------------------------------------------------------------
    # Scope subsystem helpers
    # -------------------------------------------------------------------------

    def _load_initial_scope(self) -> None:
        """Restore the last-used scope on startup. Falls back to 'Default'
        if no last-used record exists or the named scope no longer exists."""
        last = self.scope_store.get_last_used()
        scope = self.scope_store.load(last) if last else None
        if scope is None:
            scope = self.scope_store.load("Default")
        self.active_scope = scope

    def _ns_uri_for_path(self, _path: str, stub: Dict[str, Any]) -> Optional[str]:
        """Resolve the namespace URI for a node, given its cached stub.

        The worker stores `ns_index` on each stub; we look that index up
        in the server's namespace_array (URIs). Returns None if the
        index can't be resolved (missing array, out-of-range).
        """
        ns_array: List[str] = []
        if self.worker is not None:
            ns_array = self.worker.namespace_array or []
        idx = stub.get("ns_index")
        if isinstance(idx, int) and 0 <= idx < len(ns_array):
            return ns_array[idx]
        return None

    def _filter_nodes_for_active_scope(
        self,
        nodes_dict: Dict[str, Dict[str, Any]],
    ) -> Dict[str, Dict[str, Any]]:
        """Apply the active Scope and the inline filter to a node dict.

        The inline filter is a free-text substring (case-insensitive)
        applied AFTER the scope. Empty filter = no further restriction.

        Multi-term AND syntax: '+' separates terms, ALL of which must
        appear (case-insensitive) somewhere in the node's path. So
        'stat+temp' matches a path that contains both 'stat' and 'temp'.
        Whitespace around terms is stripped; empty terms are ignored.
        """
        filtered = filter_nodes_by_scope(
            nodes_dict,
            self.active_scope,
            ns_uri_for_path=self._ns_uri_for_path,
        )
        # Inline ad-hoc filter (substring on path, case-insensitive).
        text = ""
        if hasattr(self, "scope_filter_input") and self.scope_filter_input is not None:
            text = self.scope_filter_input.text().strip().lower()
        if not text:
            return filtered

        terms = [t.strip() for t in text.split("+") if t.strip()]
        if not terms:
            return filtered

        # Visibility rule: a node is shown if its OWN path matches ALL
        # terms OR any of its descendants' paths do. Keeping parent
        # containers visible lets the user navigate to the match
        # (otherwise a deep match would be orphaned without its folders).
        matched_paths = {
            p for p in filtered
            if all(term in p.lower() for term in terms)
        }
        if not matched_paths:
            return {}

        keep = set(matched_paths)
        # Include every ancestor of every matched path.
        for path in matched_paths:
            parts = path.split("/")
            for i in range(2, len(parts)):
                keep.add("/".join(parts[:i]))
        # Include all descendants of every matched path so the user sees the
        # full subtree once they've zeroed in on it. Done in O(n) by walking
        # each node's ancestors and testing set membership — NOT O(n*matches)
        # by scanning every matched path per node (that nested startswith loop
        # froze the GUI on a full 130k-node namespace).
        for path in filtered:
            if path in keep:
                continue
            parts = path.split("/")
            # Ancestors are "/a", "/a/b", ... ; if any is a matched path, this
            # node is a descendant of a match -> keep it.
            for i in range(2, len(parts)):
                if "/".join(parts[:i]) in matched_paths:
                    keep.add(path)
                    break

        narrowed = {p: s for p, s in filtered.items() if p in keep}

        # Re-root any node whose recorded parent_id is no longer in the
        # surviving set (e.g. a matched leaf without an ancestor chain
        # in the cache). Same fix-up as filter_nodes_by_scope does.
        for path, stub in list(narrowed.items()):
            parent_id = stub.get("parent_id")
            if parent_id and parent_id not in narrowed:
                new_stub = dict(stub)
                new_stub["parent_id"] = None
                narrowed[path] = new_stub
        return narrowed

    def _quick_filter_gated(self) -> bool:
        """True when the loaded namespace is too big to filter live, so the
        Quick Filter requires an explicit Return (see quick_filter_node_limit)."""
        limit = int(getattr(self, "quick_filter_node_limit",
                            DEFAULT_QUICK_FILTER_NODE_LIMIT))
        if limit <= 0:
            return False
        cache = self.namespace_cache or {}
        return len(cache) > limit

    def _set_quick_filter_style(self, state: str) -> None:
        """Colour the Quick Filter input to signal search state.

        normal   -> theme default (live filtering, or empty field)
        pending  -> ORANGE BOLD: a filter is typed but not yet searched (gated)
        searched -> GREEN: the typed filter has been applied
        """
        if not hasattr(self, "scope_filter_input") or self.scope_filter_input is None:
            return
        base = build_compact_input_style()
        if state == "pending":
            # Same per-theme "partial/pending" colour as the load badge so it
            # stays legible on every surface (bright orange washes out on
            # Atacama's reddish tan).
            extra = (f"QLineEdit {{ color: {self._partial_badge_color()};"
                     f" font-weight: bold; }}")
        elif state == "searched":
            extra = f"QLineEdit {{ color: {THEME.active.status_ok}; font-weight: bold; }}"
        else:
            extra = ""
        self.scope_filter_input.setStyleSheet(base + extra)
        self._quick_filter_style_state = state

    def _update_quick_filter_mode(self) -> None:
        """Re-evaluate Quick Filter styling after the cache or the node limit
        changes. In gated mode a non-empty, unsearched filter reads 'pending'."""
        if not hasattr(self, "scope_filter_input") or self.scope_filter_input is None:
            return
        text = (self.scope_filter_input.text() or "").strip()
        if not text:
            self._set_quick_filter_style("normal")
        elif self._quick_filter_gated():
            # Big cache: whatever is typed hasn't been searched live.
            self._set_quick_filter_style("pending")
        else:
            self._set_quick_filter_style("normal")

    def _on_scope_filter_changed(self, text: str) -> None:
        """Filter handling with a large-namespace guard.

        Small cache (<= quick_filter_node_limit): debounced live filtering, the
        tree re-renders 250 ms after the user stops typing. Clearing refreshes
        immediately.

        Large cache (> limit): live filtering would freeze the GUI (full tree
        rebuild per keystroke), so we DON'T auto-filter. The typed text is shown
        ORANGE BOLD (pending) and the user must press Return to search
        (see _on_scope_filter_return). Clearing still refreshes immediately.
        """
        if not hasattr(self, "_scope_filter_debounce"):
            return
        stripped = (text or "").strip()
        if not stripped:
            self._scope_filter_debounce.stop()
            self._set_quick_filter_style("normal")
            self._refresh_tree_with_scope()
            return
        if self._quick_filter_gated():
            # No live filtering; wait for explicit Return.
            self._scope_filter_debounce.stop()
            self._set_quick_filter_style("pending")
        else:
            self._set_quick_filter_style("normal")
            self._scope_filter_debounce.start()

    def _on_scope_filter_return(self) -> None:
        """Return pressed in the Quick Filter — run the search explicitly. Only
        meaningful in gated mode (small caches already filtered live), but safe
        to run either way. Marks the filter GREEN once applied."""
        text = (self.scope_filter_input.text() or "").strip()
        if not text:
            self._set_quick_filter_style("normal")
            self._filter_refresh_with_spinner()
            return
        self._scope_filter_debounce.stop()
        self._filter_refresh_with_spinner()
        self._set_quick_filter_style("searched")
        # A search over a partial cache only sees loaded nodes — tell the user.
        self._warn_if_partial("Filter")

    def _filter_refresh_with_spinner(self) -> None:
        """Run the filter tree-rebuild with the Quick Filter spinner visible.

        The rebuild is synchronous and blocks the GUI thread, so the spinner
        can't animate DURING it — we show the first frame and force a repaint
        before the blocking call so the user sees "working", then stop it after.
        On a small cache this is near-instant; the spinner just blinks briefly.
        """
        self._start_filter_spinner()
        try:
            # Paint the spinner frame before the blocking rebuild.
            QApplication.processEvents()
            self._refresh_tree_with_scope()
        finally:
            self._stop_filter_spinner()

    def _refresh_tree_with_scope_busy(self, message: str = "Applying view...") -> None:
        """Re-render the tree with a visible 'busy' indication. Re-filtering +
        rebuilding the tree on a large cache (100k+ nodes) is a synchronous,
        multi-second operation that blocks the GUI thread — without feedback the
        panel just looks frozen. Show a wait cursor + status message and force a
        repaint before the blocking rebuild, then restore."""
        big = len(self.namespace_cache or {}) > 5000
        if big:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            self.statusBar().showMessage(message)
            QApplication.processEvents()  # paint cursor + message before we block
        try:
            self._refresh_tree_with_scope()
        finally:
            if big:
                QApplication.restoreOverrideCursor()
                self.statusBar().showMessage(
                    f"View applied ({len(self.namespace_cache or {}):,} nodes)", 3000)

    def _refresh_tree_with_scope(self) -> None:
        """Re-render the node tree using the current namespace cache and the
        active scope. Cheap — operates on cached data, no server round-trips."""
        if not self.namespace_cache:
            return
        filtered = self._filter_nodes_for_active_scope(self.namespace_cache)
        if hasattr(self, "node_tree") and self.node_tree is not None:
            self.node_tree.populate_tree(filtered)
        # Methods quick-access reads from the same cache and applies the
        # filter internally (see _collect_methods_from_cache), so just
        # re-trigger its refresh.
        if hasattr(self, "methods_quick_access") and self.methods_quick_access is not None:
            try:
                self.refresh_methods_quick_access()
            except Exception:
                pass

    def _populate_scope_combo(self) -> None:
        """Populate the scope dropdown from on-disk scopes; preserve current selection."""
        if not hasattr(self, "scope_combo") or self.scope_combo is None:
            return
        prev_block = self.scope_combo.blockSignals(True)
        try:
            current = self.active_scope.name if self.active_scope else "Default"
            self.scope_combo.clear()
            for name in self.scope_store.list_scope_names():
                self.scope_combo.addItem(name)
            idx = self.scope_combo.findText(current)
            if idx >= 0:
                self.scope_combo.setCurrentIndex(idx)
            elif self.scope_combo.count() > 0:
                self.scope_combo.setCurrentIndex(0)
                # Active scope no longer exists on disk; fall back.
                first = self.scope_combo.itemText(0)
                self.active_scope = self.scope_store.load(first)
        finally:
            self.scope_combo.blockSignals(prev_block)

    def on_expand_all_tree(self) -> None:
        """Expand every currently-LOADED node in the tree.

        Expand operates only on what is already in the tree — it never loads
        more nodes. In Lazy Node Loading (PARTIAL) mode this means the unbrowsed
        frontier stays collapsed (its "Loading..." placeholder is left alone);
        to see more the user raises / disables the per-URI Lazy limit. This
        keeps Expand a cheap, bounded, load-free operation.
        """
        if not hasattr(self, "node_tree") or self.node_tree is None:
            return
        self.node_tree.expand_loaded_tree()

    def on_collapse_all_tree(self) -> None:
        """Collapse every node in the tree.

        Uses Qt's native `collapseAll()` (a single C++ call) rather than
        recursing in Python. On a TwinCAT-sized tree (~8000 nodes) this
        is the difference between instant and many-seconds.
        """
        if not hasattr(self, "node_tree") or self.node_tree is None:
            return
        self.node_tree.setUpdatesEnabled(False)
        try:
            self.node_tree.collapseAll()
        finally:
            self.node_tree.setUpdatesEnabled(True)

    def on_scope_selected(self, name: str) -> None:
        """User picked a different scope from the dropdown."""
        if not name:
            return
        scope = self.scope_store.load(name)
        if scope is None:
            return
        self.active_scope = scope
        self.scope_store.set_last_used(name)
        # Remember this View per URI, so returning to the URI restores it. Skip
        # while we are programmatically restoring (no user choice to record).
        if not getattr(self, "_restoring_view", False):
            uri = (self._current_uri or self.uri_input.currentText() or "").strip()
            if uri:
                self.view_by_uri[uri] = name
                try:
                    self.save_settings()
                except Exception:
                    pass
        # Decide: does this View change what the BROWSE loads, or only what the
        # tree DISPLAYS? If the browse-prune spec differs from what the current
        # cache was loaded with, auto-Rebrowse so the scoped browse takes effect;
        # otherwise just re-filter the cache (instant). Never auto-Rebrowse while
        # restoring a View (that's a passive URI switch, not a user request).
        connected = bool(self.worker and self.worker.isRunning())
        new_spec = self._scope_browse_spec(scope)
        loaded_spec = getattr(self, "_loaded_browse_spec", ("full",))
        if (connected and not getattr(self, "_restoring_view", False)
                and new_spec != loaded_spec):
            self.statusBar().showMessage(
                f"View '{name}' changes the browse scope — reloading...")
            self.rebrowse_nodes()  # applies the new prune + records the spec
            return

        # Render-only path: re-filter the cached tree. On a large cache this
        # still takes a moment, so show a busy indication instead of looking
        # frozen. Include the lazy-loading state as context when it is on.
        msg = f"Applying view '{name}'"
        uri = (self._current_uri or self.uri_input.currentText() or "").strip()
        eff = self._effective_lazy_limit(uri) if uri else 0
        if eff > 0:
            msg += f" (Lazy loading on, limit {eff:,})"
        self._refresh_tree_with_scope_busy(msg + "...")

    def _restore_view_for_uri(self, uri: str) -> None:
        """Apply the View last used for `uri` (per-URI memory). Falls back to the
        global last-used View, then whatever is active. Render-only: browse-prune
        takes effect on the next Connect / Rebrowse. No-op if the View combo
        doesn't offer the remembered name (e.g. a deleted custom View)."""
        uri = (uri or "").strip()
        if not uri or not hasattr(self, "scope_combo"):
            return
        name = self.view_by_uri.get(uri)
        if not name:
            return
        # Only apply if the View still exists in the dropdown.
        available = [self.scope_combo.itemText(i)
                     for i in range(self.scope_combo.count())]
        if name not in available or name == self.scope_combo.currentText():
            return
        self._restoring_view = True
        try:
            self.scope_combo.setCurrentText(name)  # fires on_scope_selected
        finally:
            self._restoring_view = False

    def on_edit_scope(self) -> None:
        """Open the scope editor on the active scope.

        The dialog exposes a `Save As...` button. If the user clicks it,
        `dlg.save_as_name` is set and we save under the new name (creating
        a new scope file) rather than overwriting the original. If the
        user clicks plain `OK`, the edits are persisted in place.
        """
        if self.active_scope is None:
            QMessageBox.information(self, "Edit View", "No active view to edit.")
            return
        dlg = ScopeEditDialog(self.active_scope, self)
        if not dlg.exec():
            return
        edited = dlg.get_scope()
        if dlg.save_as_name:
            # User chose Save As → save the edited content under the new name.
            new_name = dlg.save_as_name
            new_scope = Scope(
                name=new_name,
                description=edited.description,
                include_patterns=[ScopePattern(p.pattern, p.enabled) for p in edited.include_patterns],
                exclude_patterns=[ScopePattern(p.pattern, p.enabled) for p in edited.exclude_patterns],
                namespace_uris=list(edited.namespace_uris),
                format_version=edited.format_version,
            )
            self.scope_store.save(new_scope)
            self.active_scope = new_scope
            self.scope_store.set_last_used(new_name)
        else:
            # Plain OK → save in place.
            self.scope_store.save(edited)
            self.active_scope = edited
        self._populate_scope_combo()
        self._refresh_tree_with_scope()

    def load_settings(self):
        try:
            if self.settings_file.exists():
                with open(self.settings_file, 'r') as f:
                    settings = json.load(f)

                if 'geometry' in settings:
                    geometry_data = settings['geometry']
                    if isinstance(geometry_data, str):
                        self.restoreGeometry(bytes.fromhex(geometry_data))

                history = settings.get('uri_history', [])
                if isinstance(history, list):
                    cleaned_history = []
                    for entry in history:
                        if isinstance(entry, str):
                            stripped = entry.strip()
                            if stripped:
                                cleaned_history.append(stripped)
                    self.uri_history = cleaned_history[:20]
                else:
                    self.uri_history = []

                if 'uri' in settings and isinstance(settings['uri'], str):
                    self._add_uri_to_history(settings['uri'], persist=False)
                    # Keep _current_uri in sync with the restored URI.
                    # Without this, the cached default
                    # ``opc.tcp://localhost:4840`` overrides whatever
                    # was actually saved — and the Name-from-URI lookup
                    # downstream reads the wrong key, so the name field
                    # stays blank until the user changes the URI and
                    # changes back.
                    restored_uri = settings['uri'].strip()
                    if restored_uri:
                        self._current_uri = restored_uri

                names = settings.get('server_names')
                if isinstance(names, dict):
                    # Defensive filter — only keep string keys/values
                    # with non-empty stripped names.
                    self.server_names = {
                        str(k): str(v).strip()
                        for k, v in names.items()
                        if isinstance(k, str) and isinstance(v, str)
                        and str(v).strip()
                    }

                # Lazy Node Loading: per-URI node-count limit above which a
                # namespace is loaded lazily (default DEFAULT_LAZY_LOADING_LIMIT).
                limits = settings.get('lazy_loading_limits')
                if isinstance(limits, dict):
                    self.lazy_loading_limits = {
                        str(k): int(v)
                        for k, v in limits.items()
                        if isinstance(k, str) and isinstance(v, (int, float))
                    }

                # Global Quick Filter node limit.
                qf_limit = settings.get('quick_filter_node_limit')
                if isinstance(qf_limit, (int, float)):
                    self.quick_filter_node_limit = int(qf_limit)

                # Global persistent-namespace-cache toggle.
                nsc = settings.get('nscache_enabled')
                if isinstance(nsc, bool):
                    self.nscache_enabled = nsc

                # Per-URI remembered View.
                vbu = settings.get('view_by_uri')
                if isinstance(vbu, dict):
                    self.view_by_uri = {
                        str(k): str(v)
                        for k, v in vbu.items()
                        if isinstance(k, str) and isinstance(v, str) and str(v)
                    }

                # Per-URI last-complete-load stats (browse ETA target): each entry
                # is {"count": N, "seconds": S}. Backward-compatible with the old
                # plain-int form (count only).
                nsz = settings.get('namespace_size_by_uri')
                if isinstance(nsz, dict):
                    parsed = {}
                    for k, v in nsz.items():
                        if not isinstance(k, str):
                            continue
                        if isinstance(v, dict) and int(v.get("count", 0)) > 0:
                            parsed[k] = {
                                "count": int(v.get("count", 0)),
                                "seconds": float(v.get("seconds", 0.0)),
                                "render_seconds": float(v.get("render_seconds", 0.0)),
                            }
                        elif isinstance(v, (int, float)) and int(v) > 0:
                            parsed[k] = {"count": int(v), "seconds": 0.0, "render_seconds": 0.0}
                    self.namespace_size_by_uri = parsed

                # Restore splitter states
                if 'main_splitter_state' in settings:
                    main_splitter = self.findChild(QSplitter)
                    if main_splitter:
                        state_data = settings['main_splitter_state']
                        if isinstance(state_data, str):
                            main_splitter.restoreState(bytes.fromhex(state_data))

                # Restore recording settings
                rec_settings = settings.get('recording_settings', {})
                if self.subscription_widget and hasattr(self.subscription_widget, "apply_recording_settings"):
                    self.subscription_widget.apply_recording_settings(rec_settings)

                # Restore monitor mode (subscription vs polling) and interval
                monitor_settings = settings.get('monitor_settings', {})
                if self.subscription_widget and hasattr(self.subscription_widget, "apply_monitor_settings"):
                    self.subscription_widget.apply_monitor_settings(monitor_settings)

                # Restore subscription-stats config (Min/Max/Mean/Count
                # columns + sliding-window size). Goes through the public
                # setter so column visibility is applied immediately.
                stats_cfg = settings.get('subscription_stats', {})
                if (isinstance(stats_cfg, dict) and self.subscription_widget
                        and hasattr(self.subscription_widget, "set_subscription_stats")):
                    self.subscription_widget.set_subscription_stats(stats_cfg)

                # Restore log settings
                log_settings = settings.get('log_settings', {})
                if isinstance(log_settings, dict):
                    self.log_suppressed = bool(log_settings.get('suppressed', False))
                    self.log_undocked = bool(log_settings.get('undocked', False))
                    # Apply suppression state after widgets are created
                    self.set_log_suppressed(self.log_suppressed)
                    if self.log_undocked:
                        self._undock_log()
                    visible_lines = log_settings.get('visible_lines')
                    if isinstance(visible_lines, int) and self.log_widget:
                        self.log_widget.set_visible_lines(visible_lines)

                # Restore theme — applied live, no restart needed.
                theme_name = settings.get('theme')
                if isinstance(theme_name, str) and theme_name in THEMES:
                    THEME.set_theme(theme_name)

                # Restore script output dir (fallback to legacy
                # 'sequence_output_dir' key for settings written by older
                # versions of UaExplorer).
                seq_dir = settings.get('script_output_dir') or settings.get('sequence_output_dir')
                if isinstance(seq_dir, str) and seq_dir.strip():
                    self.script_output_dir = seq_dir.strip()
                    if hasattr(self, "script_widget"):
                        self.script_widget._refresh_output_dir_label()

                # Restore script Iso() timezone (legacy key fallback)
                seq_iso_utc = settings.get('script_iso_utc')
                if seq_iso_utc is None:
                    seq_iso_utc = settings.get('sequence_iso_utc')
                if isinstance(seq_iso_utc, bool):
                    self.script_iso_utc = seq_iso_utc
                    if hasattr(self, "script_widget"):
                        self.script_widget.set_iso_use_utc(seq_iso_utc)

                # Restore script output-file UI state (legacy key fallback)
                seq_fname = settings.get('script_output_filename') or settings.get('sequence_output_filename')
                seq_auto = settings.get('script_output_auto')
                if seq_auto is None:
                    seq_auto = settings.get('sequence_output_auto')
                if hasattr(self, "script_widget") and (
                    seq_fname is not None or seq_auto is not None
                ):
                    self.script_widget.set_output_file_state(
                        seq_fname if isinstance(seq_fname, str) else None,
                        bool(seq_auto) if isinstance(seq_auto, bool) else None,
                    )

                # The legacy 'show_admin_nodes' setting was retired in favour
                # of the Scope subsystem. If an old settings file still has
                # the key it's silently ignored — no migration; the user
                # picks a scope from the toolbar instead.

        except Exception as e:
            self._logger.warning(f"Error loading settings: {e}")
        finally:
            self._refresh_uri_dropdown()
            # Update current URI to match what's in the input
            self._current_uri = self.uri_input.currentText().strip()

    def save_settings(self):
        try:
            settings = {
                'geometry': self.saveGeometry().toHex().data().decode(),
                'uri': self.uri_input.currentText(),
                'uri_history': self.uri_history,
                'server_names': dict(self.server_names),
                'lazy_loading_limits': dict(getattr(self, 'lazy_loading_limits', {})),
                'view_by_uri': dict(getattr(self, 'view_by_uri', {})),
                'namespace_size_by_uri': dict(getattr(self, 'namespace_size_by_uri', {})),
                'quick_filter_node_limit': int(getattr(
                    self, 'quick_filter_node_limit', DEFAULT_QUICK_FILTER_NODE_LIMIT)),
                'nscache_enabled': bool(getattr(self, 'nscache_enabled', True)),
            }

            # Save splitter states
            main_splitter = self.findChild(QSplitter)
            if main_splitter:
                settings['main_splitter_state'] = main_splitter.saveState().toHex().data().decode()

            # Save recording settings
            if self.subscription_widget and hasattr(self.subscription_widget, "export_recording_settings"):
                settings['recording_settings'] = self.subscription_widget.export_recording_settings()

            # Save monitor mode settings
            if self.subscription_widget and hasattr(self.subscription_widget, "export_monitor_settings"):
                settings['monitor_settings'] = self.subscription_widget.export_monitor_settings()

            # Save per-subscription statistics config
            if self.subscription_widget and hasattr(self.subscription_widget, "subscription_stats"):
                settings['subscription_stats'] = dict(
                    self.subscription_widget.subscription_stats
                )

            settings['log_settings'] = {
                'suppressed': self.log_suppressed,
                'undocked': self.log_undocked,
                'visible_lines': (
                    self.log_widget.get_visible_lines()
                    if self.log_widget else 4
                ),
            }

            settings['theme'] = THEME.active.name

            settings['script_output_dir'] = self.script_output_dir
            settings['script_iso_utc'] = bool(self.script_iso_utc)

            # Script output-file UI state (filename + Auto checkbox).
            if hasattr(self, "script_widget"):
                try:
                    fname, auto = self.script_widget.get_output_file_state()
                    settings['script_output_filename'] = fname
                    settings['script_output_auto'] = auto
                except Exception:
                    pass

            # Atomic write: serialise to a sibling .tmp file then
            # rename. Plain ``open('w')`` truncates the target before
            # writing, so a crash mid-write (or two UaExplorer
            # instances saving at the same time) can leave a
            # zero-byte / half-written settings file that the next
            # startup fails to parse. ``Path.replace`` is atomic on
            # POSIX and "near-atomic" on Windows (a brief window
            # exists between two filesystem ops, but no truncation).
            self.settings_file.parent.mkdir(parents=True, exist_ok=True)
            tmp = self.settings_file.with_suffix(".tmp")
            tmp.write_text(json.dumps(settings, indent=2))
            tmp.replace(self.settings_file)

        except Exception as e:
            self._logger.warning(f"Error saving settings: {e}")

    # -------------------------------------------------------------------------
    # Persistent namespace cache (SQLite)
    # -------------------------------------------------------------------------
    # On connect, load the last FULL browse of a URI from disk so the tree
    # populates instantly, then revalidate with a background full rebrowse and
    # swap atomically. Only the serializable subset of each stub is stored;
    # the live asyncua objects (nodeid_obj, node_object) are reconstructed on
    # demand by get_node(). Own DB, same schema as UaShell's nscache.

    NSCACHE_SCHEMA_VERSION = 1

    def _nscache_connect(self):
        """Open (creating if needed) the nscache DB, or None on any failure.

        Best-effort: a missing/corrupt/locked DB must never block the browse —
        callers fall back to a live browse.
        """
        try:
            self.settings_dir.mkdir(parents=True, exist_ok=True)
            conn = sqlite3.connect(str(self.nscache_file))
            conn.execute(
                "CREATE TABLE IF NOT EXISTS meta ("
                " uri TEXT PRIMARY KEY, node_count INTEGER, captured_at TEXT,"
                " schema_version INTEGER)")
            conn.execute(
                "CREATE TABLE IF NOT EXISTS nodes ("
                " uri TEXT, path TEXT, node_id TEXT, browse_name TEXT,"
                " display_name TEXT, node_class INTEGER, ns_index INTEGER,"
                " parent_id TEXT, children_ids TEXT, children_loaded INTEGER,"
                " has_children INTEGER)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_ns_uri_path"
                         " ON nodes(uri, path)")
            return conn
        except Exception:
            return None

    def _nscache_save(self, uri: str, nodes_dict: Dict[str, Any]) -> bool:
        """Persist a full namespace snapshot for `uri`.

        Called only for a FULL, COMPLETE, UNSCOPED browse (the canonical
        namespace). Serializes the stub subset; node_class stored as int.
        """
        if not self.nscache_enabled:
            return False
        uri = (uri or "").strip()
        if not uri or not nodes_dict:
            return False
        conn = self._nscache_connect()
        if conn is None:
            return False
        try:
            rows = []
            for path, stub in nodes_dict.items():
                nc = stub.get("node_class")
                try:
                    nc_int = int(nc) if nc is not None else -1
                except (TypeError, ValueError):
                    nc_int = -1
                rows.append((
                    uri, path,
                    str(stub.get("node_id", "")),
                    str(stub.get("browse_name", "")),
                    str(stub.get("display_name", "")),
                    nc_int,
                    int(stub.get("ns_index", 0) or 0),
                    stub.get("parent_id"),
                    json.dumps(stub.get("children_ids", []) or []),
                    1 if stub.get("children_loaded") else 0,
                    1 if stub.get("has_children") else 0,
                ))
            with conn:
                conn.execute("DELETE FROM nodes WHERE uri = ?", (uri,))
                conn.execute("DELETE FROM meta WHERE uri = ?", (uri,))
                conn.executemany(
                    "INSERT INTO nodes (uri, path, node_id, browse_name,"
                    " display_name, node_class, ns_index, parent_id,"
                    " children_ids, children_loaded, has_children)"
                    " VALUES (?,?,?,?,?,?,?,?,?,?,?)", rows)
                conn.execute(
                    "INSERT INTO meta (uri, node_count, captured_at,"
                    " schema_version) VALUES (?,?,?,?)",
                    (uri, len(rows), datetime.now().isoformat(),
                     self.NSCACHE_SCHEMA_VERSION))
            return True
        except Exception:
            return False
        finally:
            try:
                conn.close()
            except Exception:
                pass

    def _nscache_load(self, uri: str) -> Optional[Dict[str, Any]]:
        """Load `uri`'s persisted namespace as a nodes_dict, or None.

        Rebuilds each stub's serializable fields; node_class is restored to a
        ua.NodeClass enum (the rest of the code compares against it). The live
        nodeid_obj/node_object are intentionally omitted — get_node()
        reconstructs them on first use from node_id.
        """
        if not self.nscache_enabled:
            return None
        uri = (uri or "").strip()
        if not uri:
            return None
        conn = self._nscache_connect()
        if conn is None:
            return None
        try:
            cur = conn.execute(
                "SELECT node_count, schema_version FROM meta WHERE uri = ?",
                (uri,))
            row = cur.fetchone()
            if not row or int(row[1] or 0) != self.NSCACHE_SCHEMA_VERSION:
                return None
            cur = conn.execute(
                "SELECT path, node_id, browse_name, display_name, node_class,"
                " ns_index, parent_id, children_ids, children_loaded,"
                " has_children FROM nodes WHERE uri = ?", (uri,))
            fetched = cur.fetchall()
            if not fetched:
                return None
            nodes_dict: Dict[str, Any] = {}
            for (path, node_id, bname, dname, nc_int, ns_idx, parent_id,
                 child_json, child_loaded, has_child) in fetched:
                try:
                    node_class = ua.NodeClass(nc_int) if nc_int >= 0 else None
                except ValueError:
                    node_class = None
                try:
                    children_ids = json.loads(child_json) if child_json else []
                except Exception:
                    children_ids = []
                nodes_dict[path] = {
                    "node_id": node_id,
                    "browse_name": bname,
                    "display_name": dname,
                    "node_class": node_class,
                    "ns_index": int(ns_idx or 0),
                    "path": path,
                    "parent_id": parent_id,
                    "children_ids": children_ids,
                    "children_loaded": bool(child_loaded),
                    "has_children": bool(has_child),
                }
            return nodes_dict
        except Exception:
            return None
        finally:
            try:
                conn.close()
            except Exception:
                pass

    # -------------------------------------------------------------------------
    # Session Management
    # -------------------------------------------------------------------------
    def _track_plotted_variable(self, node_data: Dict[str, Any], plot_id: str, 
                                 plot_title: str, monitor_mode: str, polling_interval_ms: int):
        """Track a plotted variable for session save/restore."""
        node_id = node_data.get("node_id")
        node_id_str = self.plot_bridge._format_node_id(node_id) if node_id else ""
        display_name = node_data.get("path") or node_data.get("display_name", node_id_str)

        # Avoid duplicates
        for pv in self.plotted_variables:
            if pv.get("node_id") == node_id_str and pv.get("plot_title") == plot_title:
                return

        self.plotted_variables.append({
            "node_id": node_id_str,
            "display_name": display_name,
            "plot_id": plot_id,
            "plot_title": plot_title,
            "monitor_mode": monitor_mode,
            "polling_interval_ms": polling_interval_ms,
        })

    def _get_subscribed_nodes(self) -> List[Dict[str, Any]]:
        """Extract current subscriptions from the subscription table."""
        subscriptions = []
        if not self.subscription_widget:
            return subscriptions

        table = self.subscription_widget.subscription_table
        for row in range(table.rowCount()):
            node_item = table.item(row, 0)
            if not node_item:
                continue
            # Get full node_id from UserRole data
            node_id = node_item.data(Qt.ItemDataRole.UserRole) or node_item.text()
            display_name = node_item.text()

            # Determine monitor mode from worker state
            monitor_mode = "subscription"
            if self.worker and hasattr(self.worker, "monitor_mode"):
                monitor_mode = self.worker.monitor_mode

            subscriptions.append({
                "node_id": node_id,
                "display_name": display_name,
                "monitor_mode": monitor_mode,
            })

        return subscriptions

    def save_session_dialog(self):
        """Show dialog to save current session to a file.

        Explicit non-native QFileDialog at 720x480 so the dialog doesn't
        inherit Qt/KDE's remembered-from-elsewhere width (consistent with
        the Scripting Load/Output pickers)."""
        default_name = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        start_path = str(self.sessions_dir / default_name)
        dialog = QFileDialog(self, "Save Session", start_path)
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        dialog.setNameFilters(["Session Files (*.json)", "All Files (*)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        filepath = files[0]

        # Ensure .json extension
        if not filepath.lower().endswith('.json'):
            filepath += '.json'

        try:
            self._save_session_to_file(filepath)
            self.statusBar().showMessage(f"Session saved to {filepath}", 5000)
            self.handle_log_message("INFO", f"Session saved to {filepath}")
        except Exception as e:
            QMessageBox.critical(self, "Save Session", f"Failed to save session:\n{e}")
            self.handle_log_message("ERROR", f"Failed to save session: {e}")

    def _save_session_to_file(self, filepath: str):
        """Save current session state to a JSON file."""
        uri = self.uri_input.currentText().strip()
        is_connected = bool(self.worker and self.worker.isRunning())

        # Get monitor settings
        monitor_mode = "subscription"
        polling_interval_ms = 500
        if self.subscription_widget:
            if hasattr(self.subscription_widget, "polling_radio") and self.subscription_widget.polling_radio.isChecked():
                monitor_mode = "polling"
            if hasattr(self.subscription_widget, "polling_interval_input"):
                try:
                    polling_interval_ms = int(self.subscription_widget.polling_interval_input.text())
                except Exception:
                    pass

        # Get current plots from UaPlot (query actual state instead of tracked variables)
        plots_data = []
        if self.plot_bridge and self.plot_bridge.process:
            details, err = self.plot_bridge.get_plot_details()
            if details and not err:
                plot_monitor_mode = details.get("monitor_mode", monitor_mode)
                plot_polling_ms = details.get("polling_interval_ms", polling_interval_ms)
                for plot_info in details.get("plots", []):
                    plot_id = plot_info.get("id")
                    plot_title = plot_info.get("title")
                    for var in plot_info.get("variables", []):
                        plots_data.append({
                            "node_id": var.get("node_id"),
                            "display_name": var.get("display_name"),
                            "plot_id": plot_id,
                            "plot_title": plot_title,
                            "monitor_mode": plot_monitor_mode,
                            "polling_interval_ms": plot_polling_ms,
                        })

        # Active View (Scope) — save by name; on load we look it up in the
        # ScopeStore. If the user deleted/renamed it between save and load,
        # we fall back to whatever's currently selected (with a warning).
        view_name: Optional[str] = None
        if self.active_scope is not None:
            view_name = self.active_scope.name

        session = {
            "version": "1.1",
            "timestamp": datetime.now().isoformat(),
            "uri": uri,
            "was_connected": is_connected,
            "monitor_mode": monitor_mode,
            "polling_interval_ms": polling_interval_ms,
            "view": view_name,
            "subscriptions": self._get_subscribed_nodes(),
            "plots": plots_data,
        }

        with open(filepath, 'w') as f:
            json.dump(session, f, indent=2)

    def load_session_dialog(self):
        """Show dialog to load a session from a file.

        Explicit non-native QFileDialog at 720x480 (see save counterpart)."""
        dialog = QFileDialog(self, "Load Session", str(self.sessions_dir))
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["Session Files (*.json)", "All Files (*)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        filepath = files[0]

        try:
            self._load_session_from_file(filepath)
            self.statusBar().showMessage(f"Session loaded from {filepath}", 5000)
            self.handle_log_message("INFO", f"Session loaded from {filepath}")
        except Exception as e:
            QMessageBox.critical(self, "Load Session", f"Failed to load session:\n{e}")
            self.handle_log_message("ERROR", f"Failed to load session: {e}")

    def _load_session_from_file(self, filepath: str):
        """Load session state from a JSON file and restore it."""
        with open(filepath, 'r') as f:
            session = json.load(f)

        uri = session.get("uri", "")
        was_connected = session.get("was_connected", False)
        monitor_mode = session.get("monitor_mode", "subscription")
        polling_interval_ms = session.get("polling_interval_ms", 500)
        view_name = session.get("view")  # None for pre-1.1 sessions
        subscriptions = session.get("subscriptions", [])
        plots = session.get("plots", [])

        # Clear existing subscriptions and close UaPlot before loading new session
        self._clear_for_uri_change()

        # Disconnect if currently connected
        if self._connect_button_state == "connected":
            self._user_requested_disconnect = True
            self.disconnect_from_server()
            self._user_requested_disconnect = False
            self._had_successful_connection = False

        # Set URI
        if uri:
            self._current_uri = uri
            self.uri_input.setEditText(uri)
            self._add_uri_to_history(uri)
            self._refresh_server_name_display()

        # Set monitor mode
        if self.subscription_widget:
            if monitor_mode == "polling":
                if hasattr(self.subscription_widget, "polling_radio"):
                    self.subscription_widget.polling_radio.setChecked(True)
            else:
                if hasattr(self.subscription_widget, "subscription_radio"):
                    self.subscription_widget.subscription_radio.setChecked(True)
            if hasattr(self.subscription_widget, "polling_interval_input"):
                self.subscription_widget.polling_interval_input.setText(str(polling_interval_ms))

        # Restore the active View (Scope). Applied BEFORE reconnect so the
        # tree is filtered correctly when on_nodes_loaded() fires. Falls
        # back gracefully if the saved view no longer exists on disk.
        if isinstance(view_name, str) and view_name.strip():
            available = [
                self.scope_combo.itemText(i)
                for i in range(self.scope_combo.count())
            ]
            if view_name in available:
                # setCurrentText triggers on_scope_selected which loads the
                # scope and refreshes the tree (no-op for now since the
                # cache is empty; effective after reconnect).
                self.scope_combo.setCurrentText(view_name)
            else:
                self.handle_log_message(
                    "WARNING",
                    f"Session view {view_name!r} not found on disk; "
                    f"keeping the currently selected view "
                    f"({self.active_scope.name if self.active_scope else 'Default'}).",
                )

        # Store subscriptions and plots to restore after connection
        self._pending_session_subscriptions = subscriptions
        self._pending_session_plots = plots

        # Connect if the session was connected
        if was_connected and uri:
            # Connect and restore subscriptions/plots after connection
            self.handle_log_message("INFO", f"Connecting to {uri} to restore session...")
            self._session_restore_pending = True
            self.connect_to_server()
        else:
            self.handle_log_message("INFO", "Session loaded (not connecting - session was not connected)")
            self._session_restore_pending = False

    def _restore_session_after_connect(self):
        """Called after successful connection to restore session subscriptions and plots."""
        if not getattr(self, "_session_restore_pending", False):
            return

        self._session_restore_pending = False
        subscriptions = getattr(self, "_pending_session_subscriptions", [])
        plots = getattr(self, "_pending_session_plots", [])

        # Restore subscriptions
        if subscriptions and self.worker:
            self.handle_log_message("INFO", f"Restoring {len(subscriptions)} subscriptions...")
            for sub in subscriptions:
                node_id = sub.get("node_id")
                display_name = sub.get("display_name", node_id)
                if node_id:
                    # Add to subscription table
                    self.subscription_widget.add_subscription(node_id, display_name)
                    # Subscribe via worker
                    self.worker.subscribe_to_node_async(node_id)

        # Restore plots
        if plots:
            self.handle_log_message("INFO", f"Restoring {len(plots)} plotted variables...")
            uri = self.uri_input.currentText().strip()
            server_name = str(self.server_names.get(uri, "")).strip()

            # Group variables by plot_title to restore them to the same plot
            plots_by_title: Dict[str, List[Dict[str, Any]]] = {}
            for pv in plots:
                title = pv.get("plot_title", "Restored Plot")
                if title not in plots_by_title:
                    plots_by_title[title] = []
                plots_by_title[title].append(pv)

            # Create each plot once and add all its variables
            for plot_title, plot_vars in plots_by_title.items():
                try:
                    ok, err = self.plot_bridge.ensure_running(uri, server_name)
                    if not ok:
                        self.handle_log_message("WARNING", f"Failed to start UaPlot: {err}")
                        continue

                    # Create the plot once
                    created, _ = self.plot_bridge.create_plot(plot_title)
                    plot_id = created.get("id") if created else None

                    # Add all variables to this plot
                    for pv in plot_vars:
                        node_id = pv.get("node_id")
                        display_name = pv.get("display_name", node_id)
                        monitor_mode = pv.get("monitor_mode", "subscription")
                        polling_interval_ms = pv.get("polling_interval_ms", 500)

                        if node_id:
                            node_data = {
                                "node_id": node_id,
                                "display_name": display_name,
                                "path": display_name,
                            }
                            self.plot_bridge.plot_variable(
                                node_data,
                                plot_id,
                                plot_title,
                                uri,
                                monitor_mode,
                                polling_interval_ms,
                                focus_on_plot=False,
                            )
                except Exception as e:
                    self.handle_log_message("WARNING", f"Failed to restore plot '{plot_title}': {e}")

        # Clear pending data
        self._pending_session_subscriptions = []
        self._pending_session_plots = []
        self.handle_log_message("INFO", "Session restore complete")


    def _on_reconnect_timer(self):
        """Periodic auto-reconnect tick.

        Policy (deliberately conservative, per user feedback that earlier
        behaviour retried every second on invalid URIs):

        1. Auto-reconnect is enabled ONLY after a successful connect
           (see on_connected). A URI that has never connected is never
           retried — typos don't spin the loop.

        2. Wait at least RECONNECT_INTERVAL_S (5 s) between attempts —
           both before the very first attempt and between subsequent
           ones. The timer fires every 1 s so the UI feels responsive
           (status bar etc.), but actual connect attempts are gated.

        3. The URI in the input field must still match the one that
           previously connected — if the user changes it, we stop
           retrying the old one (a fresh successful connect on the new
           URI will rearm the loop).
        """
        if not self.auto_reconnect_enabled or self._user_requested_disconnect:
            return

        if self.worker and self.worker.isRunning():
            return

        uri = self.uri_input.currentText().strip()
        if not uri:
            return

        # Only retry the URI we actually connected to.
        last_uri = getattr(self, "_last_uri", "") or ""
        if uri != last_uri:
            return

        now = time.monotonic()
        # Emit a WARNING heartbeat into the log every
        # RECONNECT_WARN_PERIOD_S so a long-running disconnect is visible
        # without flooding the log. First warning fires immediately when
        # the loop arms (i.e. on the same tick as we set _last_attempt
        # below).
        last_warn = self._reconnect_last_warn
        if last_warn is None or (now - last_warn) >= self.RECONNECT_WARN_PERIOD_S:
            self._reconnect_last_warn = now
            self.handle_log_message(
                "WARNING",
                f"Connection lost — attempting to reconnect to {uri}",
            )

        last = self._reconnect_last_attempt
        if last is not None and (now - last) < self.RECONNECT_INTERVAL_S:
            return
        if last is None:
            # First tick after disconnect: arm the cool-down so the
            # NEXT tick (5 s later) is the first attempt.
            self._reconnect_last_attempt = now
            self.statusBar().showMessage(
                f"Connection lost — retrying in {int(self.RECONNECT_INTERVAL_S)}s..."
            )
            return

        # Cool-down elapsed: attempt a reconnect and reset the timer.
        self._reconnect_last_attempt = now
        self.statusBar().showMessage("Auto-reconnect: trying to connect...")
        self.connect_to_server(
            show_progress=False,
            status_message="Auto-reconnect: trying to connect...",
        )

    def closeEvent(self, event):
        self.save_settings()
        if self.worker:
            self.worker.stop()
            self.worker.wait()
        if self.plot_bridge:
            try:
                self.plot_bridge.stop()
            except Exception:
                pass
        event.accept()


def parse_args(argv):
    parser = argparse.ArgumentParser(
        description="UA Explorer GUI",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-l",
        "--log-level",
        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        default="ERROR",
        help="Set logging level",
    )
    parser.add_argument(
        "--console-log",
        action="store_true",
        help="Enable logging to stdout (otherwise logging stays silent)",
    )
    parser.add_argument(
        "--home-dir",
        default=None,
        help="Override default home dir (~/.uatools/UaExplorer)",
    )
    # Strict parsing: reject unknown options (e.g. a mistyped or removed
    # flag such as -u/--uri) with a clear argparse error, rather than
    # silently ignoring them. UaExplorer does not accept Qt command-line
    # arguments, so there is nothing to pass through.
    return parser.parse_args(argv)


def configure_logging(level_str: str, console_log: bool):
    level = getattr(logging, level_str.upper(), logging.ERROR)
    root_logger = logging.getLogger()
    for handler in list(root_logger.handlers):
        root_logger.removeHandler(handler)
    root_logger.setLevel(level)
    root_logger.propagate = False

    if console_log:
        stream_handler = logging.StreamHandler(sys.stdout)
        stream_handler.setLevel(level)
        stream_handler.setFormatter(
            logging.Formatter("%(levelname)s:%(name)s:%(message)s")
        )
        root_logger.addHandler(stream_handler)
    else:
        root_logger.addHandler(logging.NullHandler())

    # Keep asyncua quiet unless explicitly requested
    for noisy_logger in ("asyncua", "opcua"):
        lg = logging.getLogger(noisy_logger)
        lg.setLevel(level)
        lg.propagate = console_log


def _single_instance_channel_name() -> str:
    """Per-user channel name for the single-instance guard.

    Including the username avoids collisions on multi-user hosts
    (server boxes, training machines). Sanitised to alphanumerics +
    underscore so Windows named-pipe rules are satisfied; capped to
    64 characters.
    """
    try:
        user = getpass.getuser()
    except Exception:
        user = "default"
    raw = f"uaexplorer_singleinstance_{user}"
    safe = "".join(c if c.isalnum() or c == "_" else "_" for c in raw)
    return safe[:64]


def check_single_instance(app: QApplication) -> bool:
    """Detect a parallel UaExplorer on the same per-user channel.

    Returns True if we successfully claimed the channel (no other
    instance is running, or the user chose to continue anyway). Returns
    False if the user chose to abort, in which case the caller should
    exit cleanly.

    Implementation notes:
      - Try connecting first; if connect succeeds within 200 ms an
        existing instance is alive and we surface the dialog.
      - Otherwise we call ``QLocalServer.removeServer`` to clear any
        orphan socket file from a previous crash, then ``listen`` to
        claim the channel.
      - The server is stashed on the QApplication so it stays alive
        for the whole process lifetime — letting it be garbage-collected
        would release the channel immediately and defeat the guard.
    """
    channel = _single_instance_channel_name()

    probe = QLocalSocket()
    probe.connectToServer(channel)
    if probe.waitForConnected(200):
        probe.disconnectFromServer()
        # Another instance is alive. Warn the user; default action is
        # "Quit" so the safe choice is one Enter away.
        box = QMessageBox(None)
        box.setIcon(QMessageBox.Icon.Warning)
        box.setWindowTitle("UA Explorer is already running")
        box.setText(
            "Another UA Explorer instance is already running for this user."
        )
        box.setInformativeText(
            "Both instances share the same files in "
            "~/.uatools/UaExplorer/ (settings, views, sessions, "
            "scripts). In practice the user only acts in one window at "
            "a time, so the risk of actual corruption is low — but if "
            "two instances quit close together the second one wins and "
            "the first one's edits to settings/etc. are lost.\n\n"
            "You may consider passing --home-dir <path> to give each "
            "additional instance its own state directory.\n\n"
            "Continue anyway, or quit?"
        )
        continue_btn = box.addButton(
            "Continue anyway", QMessageBox.ButtonRole.AcceptRole
        )
        quit_btn = box.addButton("Quit", QMessageBox.ButtonRole.RejectRole)
        box.setDefaultButton(quit_btn)
        box.exec()
        if box.clickedButton() is continue_btn:
            # User accepted the risk — don't try to claim the channel.
            # Settings races are still possible; warning was the only
            # contract we promised.
            return True
        return False

    # Nobody listening — clear any orphan socket file from a previous
    # crash, then claim the channel for this process.
    QLocalServer.removeServer(channel)
    server = QLocalServer()
    if not server.listen(channel):
        # Listen failed for some non-collision reason (permissions on
        # /tmp, etc.). Don't block the user — fail open.
        return True
    # Keep the server alive for the lifetime of the QApplication so it
    # keeps holding the channel.
    app._uaexplorer_single_instance_server = server
    return True


def main():
    args = parse_args(sys.argv[1:])
    configure_logging(args.log_level, args.console_log)

    # Silence noisy KDE/KIO categories that fire spurious "No node found"
    # warnings whenever the KDirModel sees files appear or vanish under
    # an open file dialog (typical on Linux/KDE when scripts finish and
    # drop a fresh .log into the script_output_dir while the picker is
    # open). The messages are harmless but flood the terminal.
    # Set both the env var (so Qt picks it up at startup) and the runtime
    # filter (so it applies even if the env was lost).
    kio_rules = "kf.kio.widgets.kdirmodel.warning=false"
    existing = os.environ.get("QT_LOGGING_RULES", "")
    os.environ["QT_LOGGING_RULES"] = (
        f"{existing};{kio_rules}" if existing else kio_rules
    )
    try:
        from PyQt6.QtCore import QLoggingCategory
        QLoggingCategory.setFilterRules(kio_rules)
    except Exception:
        pass

    app = QApplication([sys.argv[0]])

    # Graceful Ctrl+C handling
    signal.signal(signal.SIGINT, lambda sig, frame: app.quit())

    app.setOrganizationName("UaExplorer")
    app.setApplicationName("UaExplorer")

    # Apply the default theme up front so even the splash / connect-error
    # dialogs that appear before the main window is fully initialised use
    # the same palette. The user's saved theme (if any) is applied later,
    # in UaExplorer.load_settings().
    apply_theme_to_application(THEME.active)
    THEME.theme_changed.connect(apply_theme_to_application)

    # Single-instance guard. If another UA Explorer is already running
    # for this user, warn and let the user choose Continue / Quit.
    # When --home-dir is set the user is opting into a separate state
    # directory anyway, so the guard is bypassed (concurrent instances
    # with disjoint home dirs are intentional in that case).
    if not args.home_dir:
        if not check_single_instance(app):
            sys.exit(0)

    home_override = Path(args.home_dir).expanduser() if args.home_dir else None
    window = UaExplorer(override_home=home_override)
    window.show()

    try:
        sys.exit(app.exec())
    except KeyboardInterrupt:
        # Ensure clean shutdown without traceback on Ctrl+C
        pass


if __name__ == "__main__":
    main()
