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

"""
UaShell - Complete Enhanced Version with Real-time Subscriptions
An asyncio-based command-line interface for OPC UA operations with Tkinter monitoring window.
"""

# Part 1: Begin - Imports, Data Classes, and Core Subscription Infrastructure

import asyncio
import argparse
import sys
import shlex
import re
import os
import time
import fnmatch
import sqlite3
from urllib.parse import urlparse
from typing import Optional, List, Any, Dict, Callable
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass, field, asdict
from queue import Queue
import logging
import pickle
import json
from pathlib import Path

try:
    from asyncua import Client, ua
    from asyncua.common.node import Node
    from asyncua.common.methods import call_method
    from asyncua.common.subscription import Subscription
except ImportError:
    print("Error: asyncua library not found. Install with: pip install asyncua")
    sys.exit(1)

# Base class for subscription handlers. asyncua dispatches datachange
# notifications to the handler attached to a subscription; subclassing the
# library's SubHandler ensures asyncua recognizes/dispatches to it. The import
# path moved across asyncua versions, so try both, with a no-op fallback.
try:
    from asyncua.common.subscription import SubHandler as _SubHandler
except Exception:
    try:
        from asyncua.common.subscription_handler import SubHandler as _SubHandler
    except Exception:
        class _SubHandler:
            def datachange_notification(self, node, val, data):
                pass

try:
    import readline
    READLINE_AVAILABLE = True
except ImportError:
    READLINE_AVAILABLE = False
    print("Warning: readline not available. Command history will be limited.")


# Version of UaShell. For now a literal; may later be sourced from the project
# build metadata (wtools declare_project version).
__version__ = "1.0.0-pre1"


def about_text() -> str:
    """Plain-text 'about' for UaShell (no Qt; printed to the terminal).

    UaExplorer shows a graphical about dialog with the UA Tools logo; UaShell
    stays Qt-free and prints this text instead.
    """
    return (
        "ESO IFW - UA Tools\n"
        "\n"
        "UA Tools is the ESO Instrument Framework collection of utilities for\n"
        "working with OPC UA servers used in instrument control. It provides\n"
        "client-side tools for browsing namespaces, exercising methods,\n"
        "recording data, and scripting - used both during development of OPC UA\n"
        "control servers and for diagnostics on deployed instruments.\n"
        "\n"
        "UaShell\n"
        "\n"
        "An asyncio command-line client for any OPC UA server, with a unix/bash\n"
        "feel: navigate the namespace like a filesystem (cd/ls/tree/find),\n"
        "read/write/call nodes, pipe and redirect output, glob paths, and run\n"
        "scripts (.uas command playback or .py Python automation). Live data is\n"
        "shown in a separate UaSubscription window, and values can be charted in\n"
        "the companion UaPlot - both launched on demand if installed.\n"
        "\n"
        f"Version: {__version__}\n"
        f"Source:  {Path(__file__).resolve()}\n"
    )


def _configure_logging(log_level: Optional[str], verbose: bool):
    """Configure logging from the CLI flags.

    -v/--verbose: route logs to stdout (otherwise UaShell suppresses them).
    -l/--log-level LEVEL or logger:LEVEL: set the level for the root logger or a
    specific logger. With neither flag, logging stays at CRITICAL so asyncua's
    reconnection noise doesn't clutter the prompt.
    """
    if verbose:
        # Send logs to stdout so they interleave with command output.
        logging.basicConfig(level=logging.INFO, stream=sys.stdout, force=True)

    if not log_level:
        return

    # Accept "LEVEL" (root) or "logger:LEVEL".
    if ":" in log_level:
        logger_name, _, level_name = log_level.partition(":")
    else:
        logger_name, level_name = "", log_level
    level = getattr(logging, level_name.strip().upper(), None)
    if not isinstance(level, int):
        print(f"Warning: unknown log level '{level_name}', ignoring.")
        return
    logging.getLogger(logger_name.strip() or None).setLevel(level)
    if verbose is False:
        # User asked for a level but not -v; still need a handler to see output.
        logging.basicConfig(level=level, stream=sys.stdout)


def nodeid_to_string(node_id) -> str:
    """Return the parseable OPC UA node-id string ('ns=2;s=...') for a NodeId.

    asyncua's str(NodeId) returns the object repr (e.g.
    "NodeId(Identifier='...', NamespaceIndex=2, ...)") which client.get_node()
    cannot parse back. NodeId.to_string() produces the canonical wire form.
    Falls back to str() if to_string() is unavailable.
    """
    to_string = getattr(node_id, "to_string", None)
    if callable(to_string):
        try:
            return to_string()
        except Exception:
            pass
    return str(node_id)


# ---------------------------------------------------------------------------
# Views (namespace scoping) — format-compatible with UaExplorer so a view
# defined in the GUI works here and vice versa. View files live under
# ~/.uatools/UaShell/views/<name>.json (own) and ~/.uatools/UaExplorer/views/
# (shared). Own views win on a name clash.
#
# Filter pipeline per node (INCLUDE WINS over EXCLUDE, subtree-aware):
#   - if include_patterns has enabled patterns, a node must match one (or live
#     under an included subtree) to be shown; an included node ignores excludes.
#   - with no includes, everything passes includes and excludes hide matches.
#
# Path convention gotcha: UaExplorer view patterns are LEADING-SLASH
# (/Objects/system/...), while UaShell cache keys have NO leading slash
# (Objects/system/...). We canonicalise both to a leading-slash form before
# matching, so UaExplorer view files apply unchanged.
# ---------------------------------------------------------------------------

@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):
                    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 _view_canon(path: str) -> str:
    """Canonicalise a path/pattern to the leading-slash form used for matching.

    UaShell cache keys have no leading slash ('Objects/system'); UaExplorer view
    patterns do ('/Objects/system'). Both map to '/Objects/system' here.
    """
    p = (path or "").strip()
    if not p:
        return ""
    return p if p.startswith("/") else "/" + p


def _view_glob_match(path: str, pattern: str) -> bool:
    """Glob-match a canonical path against a (canonicalised) pattern.

    A bare word (no '/' and no glob metachar) becomes a substring match.
    """
    if not pattern:
        return False
    raw = pattern.strip()
    has_glob = any(c in raw for c in "*?[]")
    if "/" not in raw and not has_glob:
        return fnmatch.fnmatchcase(path, f"*{raw}*")
    return fnmatch.fnmatchcase(path, _view_canon(raw))


def _view_ancestors(path: str):
    """Yield a canonical path and each ancestor: /a/b/c -> /a/b/c, /a/b, /a."""
    parts = path.split("/")
    for i in range(len(parts), 1, -1):
        yield "/".join(parts[:i])


def _view_path_included(path: str, enabled_includes) -> bool:
    """True if a canonical `path` matches an enabled include OR is a descendant
    of a node that does (subtree-aware)."""
    return any(_view_glob_match(anc, p)
               for anc in _view_ancestors(path)
               for p in enabled_includes)


def _view_include_prefix(pattern: str) -> Optional[str]:
    """If `pattern` is a prefix-style include (a fixed subtree root, optionally
    ending /* or /**), return the canonical fixed prefix; else None. Only
    prefix-style includes are safely prunable at browse time."""
    raw = (pattern or "").strip()
    if not raw:
        return None
    p = _view_canon(raw)
    if p.endswith("/**"):
        p = p[:-3]
    elif p.endswith("/*"):
        p = p[:-2]
    if any(c in p for c in "*?[]"):
        return None
    return p.rstrip("/") or None


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


def scope_matches_node(scope: Optional[Scope], path: str) -> bool:
    """True if the node at `path` is visible under `scope` (None = show all).

    INCLUDE WINS over EXCLUDE, subtree-aware (matches UaExplorer 2026-07-08).
    Namespace-URI filtering is not applied here (UaShell's cache doesn't carry
    per-node ns URIs); namespace_uris in a view file is tolerated but ignored.
    """
    if scope is None:
        return True
    cpath = _view_canon(path)
    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:
        if not _view_path_included(cpath, enabled_includes):
            return False
        return True  # include wins over exclude
    if any(_view_glob_match(cpath, p) for p in enabled_excludes):
        return False
    return True


@dataclass
class SubscriptionData:
    """Data structure for subscription updates"""
    node_id: str
    display_name: str
    value: Any
    timestamp: datetime
    data_type: str
    quality: str = "Good"

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for easy serialization"""
        return {
            'node_id': self.node_id,
            'display_name': self.display_name,
            'value': str(self.value),
            'timestamp': self.timestamp.strftime('%H:%M:%S.%f')[:-3],  # Include milliseconds
            'data_type': self.data_type,
            'quality': self.quality
        }


class SubscriptionHandler(_SubHandler):
    """Single asyncua subscription callback for ALL monitored nodes.

    asyncua attaches one handler to the subscription (passed to
    create_subscription); subscribe_data_change(node, ...) does NOT take a
    per-node handler. We therefore resolve the node from the callback's `node`
    argument and look up its display name in a shared map. Subclassing
    asyncua's SubHandler ensures the library dispatches notifications to us.
    """

    def __init__(self, data_queue: Queue, name_map: dict):
        super().__init__()
        self.data_queue = data_queue
        self.name_map = name_map  # node_id_str -> display_name
        self.logger = logging.getLogger(__name__)

    def datachange_notification(self, node: Node, val: Any, data):
        """Called when any subscribed node's data changes."""
        try:
            try:
                node_id = nodeid_to_string(node.nodeid)
            except Exception:
                node_id = str(node)
            display_name = self.name_map.get(node_id, node_id)

            timestamp = datetime.now()
            if hasattr(data, 'MonitoredItemNotification') and data.MonitoredItemNotification:
                server_timestamp = getattr(data.MonitoredItemNotification, 'Value', None)
                if server_timestamp and hasattr(server_timestamp, 'ServerTimestamp'):
                    timestamp = server_timestamp.ServerTimestamp or timestamp

            data_type = val.__class__.__name__ if hasattr(val, '__class__') else "Unknown"

            quality = "Good"
            if hasattr(data, 'MonitoredItemNotification') and data.MonitoredItemNotification:
                status_code = getattr(data.MonitoredItemNotification.Value, 'StatusCode', None)
                if status_code:
                    quality = "Good" if status_code.is_good() else "Bad"

            sub_data = SubscriptionData(
                node_id=node_id, display_name=display_name, value=val,
                timestamp=timestamp, data_type=data_type, quality=quality)

            try:
                self.data_queue.put_nowait(sub_data)
            except Exception:
                pass  # queue full - drop this update

        except Exception as e:
            self.logger.debug(f"Error in datachange_notification: {e}")

    def event_notification(self, event):
        """Called when subscribed events occur (future use)."""
        pass


class SubscriptionManager:
    """Manages OPC UA subscriptions and data flow"""

    def __init__(self, client: Client):
        self.client = client
        self.subscription = None
        self.subscribed_nodes = {}  # node_id -> (handle, display_name)
        self.name_map = {}          # node_id -> display_name (for the handler)
        self.data_queue = Queue(maxsize=1000)  # Limit queue size
        self.subscription_interval = 500  # 500ms
        self.logger = logging.getLogger(__name__)
        self._lock = asyncio.Lock()
        # Last failure reason, so the caller can surface it to the user even
        # when the root logger is at CRITICAL (the default interactive level).
        self.last_error: str = ""

    async def _create_subscription_locked(self) -> bool:
        """Create the OPC UA subscription. Caller MUST hold self._lock.

        Kept lock-free so it can be called both from initialize_subscription()
        (which takes the lock) and from add_node_subscription() (which already
        holds it). asyncio.Lock is NOT reentrant, so re-acquiring it here would
        deadlock.
        """
        try:
            if self.subscription:
                await self._cleanup_subscription()
            # One handler for the whole subscription (asyncua model). It reads
            # the node from each callback and looks up the name in name_map.
            handler = SubscriptionHandler(self.data_queue, self.name_map)
            self.subscription = await self.client.create_subscription(
                self.subscription_interval, handler)
            self.logger.debug("OPC UA subscription initialized")
            return True
        except Exception as e:
            self.logger.error(f"Failed to initialize subscription: {e}")
            return False

    async def initialize_subscription(self):
        """Initialize the OPC UA subscription (acquires the lock)."""
        async with self._lock:
            return await self._create_subscription_locked()

    async def add_node_subscription(self, node_id_str: str, node: Node, display_name: str = None) -> bool:
        """Add a node to the subscription"""
        try:
            async with self._lock:
                # Check if already subscribed
                if node_id_str in self.subscribed_nodes:
                    self.logger.debug(f"Node {node_id_str} already subscribed")
                    return True

                # Ensure subscription exists (lock-free helper - we hold the lock)
                if not self.subscription:
                    if not await self._create_subscription_locked():
                        return False

                # Get display name if not provided
                if not display_name:
                    try:
                        display_name_obj = await node.read_display_name()
                        display_name = display_name_obj.Text
                    except:
                        display_name = node_id_str

                # Register the name so the shared subscription handler can label
                # this node's updates.
                self.name_map[node_id_str] = display_name

                # Subscribe to the node. The handler is attached to the
                # subscription (not per-node); this call's positional args are
                # (nodes, attr, queuesize) - do NOT pass a handler here.
                handle = await self.subscription.subscribe_data_change(
                    node, queuesize=1)

                # Store subscription info
                self.subscribed_nodes[node_id_str] = (handle, display_name)

                self.logger.debug(f"Added subscription for {node_id_str} ({display_name})")
                return True

        except Exception as e:
            self.last_error = f"{type(e).__name__}: {e}"
            self.logger.error(f"Failed to add subscription for {node_id_str}: {e}")
            return False

    async def remove_node_subscription(self, node_id_str: str) -> bool:
        """Remove a node from subscription"""
        try:
            async with self._lock:
                if node_id_str not in self.subscribed_nodes:
                    return False

                handle, display_name = self.subscribed_nodes[node_id_str]

                # Unsubscribe from the node
                if self.subscription:
                    await self.subscription.unsubscribe(handle)

                # Remove from our tracking
                del self.subscribed_nodes[node_id_str]
                self.name_map.pop(node_id_str, None)

                self.logger.debug(f"Removed subscription for {node_id_str} ({display_name})")
                return True

        except Exception as e:
            self.logger.error(f"Failed to remove subscription for {node_id_str}: {e}")
            return False

    async def remove_all_subscriptions(self) -> int:
        """Remove all subscriptions and return count removed"""
        try:
            async with self._lock:
                count = len(self.subscribed_nodes)

                if self.subscription:
                    # Unsubscribe all nodes
                    for node_id_str, (handle, _) in self.subscribed_nodes.items():
                        try:
                            await self.subscription.unsubscribe(handle)
                        except:
                            pass

                # Clear tracking
                self.subscribed_nodes.clear()
                self.name_map.clear()

                self.logger.debug(f"Removed all {count} subscriptions")
                return count

        except Exception as e:
            self.logger.error(f"Failed to remove all subscriptions: {e}")
            return 0

    async def restore_subscriptions(self, node_parser_func: Callable[[str], Optional[Node]]):
        """Restore all subscriptions after reconnection"""
        try:
            if not self.subscribed_nodes:
                return 0

            # Store current subscriptions
            nodes_to_restore = list(self.subscribed_nodes.keys())
            display_names = {node_id: display_name for node_id, (_, display_name) in self.subscribed_nodes.items()}

            # Clear current subscriptions
            await self.remove_all_subscriptions()

            # Re-initialize subscription
            if not await self.initialize_subscription():
                self.logger.error("Failed to re-initialize subscription during restore")
                return 0

            # Restore each subscription
            restored_count = 0
            for node_id_str in nodes_to_restore:
                try:
                    node = await node_parser_func(node_id_str)
                    if node:
                        display_name = display_names.get(node_id_str, node_id_str)
                        if await self.add_node_subscription(node_id_str, node, display_name):
                            restored_count += 1
                        else:
                            self.logger.warning(f"Failed to restore subscription for {node_id_str}")
                    else:
                        self.logger.warning(f"Could not parse node ID during restore: {node_id_str}")
                except Exception as e:
                    self.logger.warning(f"Error restoring subscription for {node_id_str}: {e}")

            self.logger.info(f"Restored {restored_count}/{len(nodes_to_restore)} subscriptions")
            return restored_count

        except Exception as e:
            self.logger.error(f"Failed to restore subscriptions: {e}")
            return 0

    async def _cleanup_subscription(self):
        """Clean up existing subscription"""
        try:
            if self.subscription:
                # Remove all subscriptions first
                await self.remove_all_subscriptions()

                # Delete the subscription
                await self.subscription.delete()
                self.subscription = None

                self.logger.debug("Subscription cleaned up")

        except Exception as e:
            self.logger.debug(f"Error during subscription cleanup: {e}")

    async def cleanup(self):
        """Clean up all resources"""
        await self._cleanup_subscription()

        # Clear the data queue
        try:
            while not self.data_queue.empty():
                self.data_queue.get_nowait()
        except:
            pass

    def get_subscription_count(self) -> int:
        """Get number of active subscriptions"""
        return len(self.subscribed_nodes)

    def get_subscribed_nodes(self) -> List[str]:
        """Get list of subscribed node IDs"""
        return list(self.subscribed_nodes.keys())

    def get_latest_data(self, max_items: int = 100) -> List[SubscriptionData]:
        """Get latest data from queue (non-blocking)"""
        data_items = []
        count = 0

        try:
            while count < max_items and not self.data_queue.empty():
                data_items.append(self.data_queue.get_nowait())
                count += 1
        except:
            pass

        return data_items

# Part 1: End


# Part 2: Begin - Node Cache Classes
#
# (The former Tkinter SubscriptionWindow was removed: live subscription
#  display now lives in the separate UaSubscription viewer process, which
#  UaShell controls over a local socket. See _SubViewerClient below.)

@dataclass
class CachedNode:
    """Represents a cached OPC UA node with all its metadata"""
    path: str                    # Full path like "Objects/Server/ServerStatus"
    display_name: str            # Display name from server
    node_class: str              # 'object', 'variable', 'method'
    node_id_str: str             # OPC UA node ID string (e.g., "ns=0;i=2259")
    parent_path: str             # Parent path for tree building
    method_signature: str = ""   # For methods: input argument signature


class NamespaceCache:
    """High-performance cache for the entire OPC UA namespace.

    Stores all nodes indexed by path for instant lookups.
    Built once on connect/rebrowse using parallel browsing.
    """

    def __init__(self):
        # Primary index: path -> CachedNode
        self.nodes_by_path: Dict[str, CachedNode] = {}
        # Secondary index: parent_path -> list of child paths (for ls/tree)
        self.children_by_parent: Dict[str, List[str]] = {}
        # Track cache state
        self.is_populated = False
        self.cache_time: Optional[datetime] = None
        self.node_counts = {'objects': 0, 'variables': 0, 'methods': 0}

    def clear(self):
        """Clear all cached data"""
        self.nodes_by_path.clear()
        self.children_by_parent.clear()
        self.is_populated = False
        self.cache_time = None
        self.node_counts = {'objects': 0, 'variables': 0, 'methods': 0}

    def add_node(self, path: str, display_name: str, node_class: str, 
                 node_id_str: str, method_signature: str = ""):
        """Add a node to the cache"""
        # Normalize path (use / as separator)
        path = path.replace(".", "/")

        # Compute parent path
        if "/" in path:
            parent_path = path.rsplit("/", 1)[0]
        else:
            parent_path = ""

        # Create cached node
        node = CachedNode(
            path=path,
            display_name=display_name,
            node_class=node_class,
            node_id_str=node_id_str,
            parent_path=parent_path,
            method_signature=method_signature
        )

        # Add to primary index
        self.nodes_by_path[path] = node

        # Add to parent->children index
        if parent_path not in self.children_by_parent:
            self.children_by_parent[parent_path] = []
        if path not in self.children_by_parent[parent_path]:
            self.children_by_parent[parent_path].append(path)

        # Update counts
        if node_class == 'object':
            self.node_counts['objects'] += 1
        elif node_class == 'variable':
            self.node_counts['variables'] += 1
        elif node_class == 'method':
            self.node_counts['methods'] += 1

    def get_node(self, path: str) -> Optional[CachedNode]:
        """Get a node by path"""
        path = path.replace(".", "/")
        return self.nodes_by_path.get(path)

    def get_children(self, parent_path: str) -> List[CachedNode]:
        """Get all children of a path"""
        parent_path = parent_path.replace(".", "/")
        child_paths = self.children_by_parent.get(parent_path, [])
        return [self.nodes_by_path[p] for p in child_paths if p in self.nodes_by_path]

    def path_exists(self, path: str) -> bool:
        """Check if a path exists in cache"""
        path = path.replace(".", "/")
        return path in self.nodes_by_path

    def find_nodes(self, name_pattern: str = None, path_pattern: str = None,
                   type_filter: str = None, start_path: str = "", 
                   max_depth: int = None, name_case_sensitive: bool = True,
                   path_case_sensitive: bool = True) -> List[CachedNode]:
        """Search for nodes matching criteria (works entirely from cache)

        Args:
            name_pattern: Wildcard pattern to match against display name
            path_pattern: Wildcard pattern to match against full path
            type_filter: Filter by node class ('object', 'variable', 'method')
            start_path: Only search under this path
            max_depth: Maximum search depth
            name_case_sensitive: Whether name_pattern matching is case-sensitive
            path_case_sensitive: Whether path_pattern matching is case-sensitive
        """
        import fnmatch

        start_path = start_path.replace(".", "/")
        results = []

        # Prepare pattern matching
        def matches_name(name: str) -> bool:
            if not name_pattern:
                return True
            pattern = name_pattern
            check_name = name
            if not name_case_sensitive:
                pattern = pattern.lower()
                check_name = name.lower()
            return fnmatch.fnmatch(check_name, pattern)

        def matches_path(full_path: str) -> bool:
            if not path_pattern:
                return True
            pattern = path_pattern
            check_path = full_path
            if not path_case_sensitive:
                pattern = pattern.lower()
                check_path = full_path.lower()
            return fnmatch.fnmatch(check_path, pattern)

        def matches_type(node_class: str) -> bool:
            if not type_filter:
                return True
            return node_class == type_filter

        # Iterate through all cached nodes
        for path, node in self.nodes_by_path.items():
            # Check if under start_path
            if start_path:
                if not path.startswith(start_path + "/") and path != start_path:
                    continue

            # Check depth
            if max_depth is not None:
                if start_path:
                    relative_path = path[len(start_path):].lstrip("/")
                else:
                    relative_path = path
                depth = relative_path.count("/") + 1 if relative_path else 0
                if depth > max_depth:
                    continue

            # Check name, path, and type
            if matches_name(node.display_name) and matches_path(path) and matches_type(node.node_class):
                results.append(node)

        return results

    def get_summary(self) -> str:
        """Get a summary of cached nodes"""
        total = (self.node_counts['objects']
                 + self.node_counts['variables']
                 + self.node_counts['methods'])
        return (f"{self.node_counts['objects']} objects, "
                f"{self.node_counts['variables']} variables, "
                f"{self.node_counts['methods']} methods "
                f"({total} nodes total)")


class NodeCache:
    """Legacy cache wrapper - delegates to NamespaceCache for compatibility"""
    def __init__(self):
        self.nodes = {}
        self.cached = False
        self.cache_time = None

    def add_node(self, node_id_str: str, display_name: str, node_class: str, friendly_name: str, extra_info: str = ""):
        """Add a node to the cache"""
        self.nodes[node_id_str] = {
            'display_name': display_name,
            'node_class': node_class,
            'friendly_name': friendly_name,
            'extra_info': extra_info
        }

    def search_pattern(self, pattern: str) -> List[Dict]:
        """Search cached nodes by pattern"""
        matches = []
        regex_pattern = pattern.replace('*', '.*').replace('?', '.')

        for node_id, node_info in self.nodes.items():
            if (re.search(regex_pattern, node_info['display_name'], re.IGNORECASE) or
                re.search(regex_pattern, node_info['friendly_name'], re.IGNORECASE) or
                re.search(regex_pattern, node_id, re.IGNORECASE)):
                matches.append({
                    'node_id': node_id,
                    **node_info
                })

        return matches

    def clear(self):
        """Clear the cache"""
        self.nodes.clear()
        self.cached = False
        self.cache_time = None

# Part 2: End


# Part 3: Begin - Main UaShell Class (Core Methods)

class UaShell:
    def __init__(self, url: str, home_dir: str = None):
        self.url = url
        self.client = Client(url)
        self.connected = False
        self.node_cache = NodeCache()
        self.namespace_cache = NamespaceCache()  # New high-performance namespace cache
        self.reconnect_enabled = True
        self.reconnect_interval = 5  # seconds
        self.max_reconnect_attempts = -1  # -1 for infinite
        self.connection_lost_time = None
        self.reconnect_task = None

        # Subscription management
        self.subscription_manager = None
        # Separate-process subscription viewer (UaSubscription); created lazily
        # on first successful subscribe. None until then / if unavailable.
        self.sub_viewer = None
        self._sub_pump_task = None  # asyncio task forwarding updates to viewer
        # Separate-process plot window (UaPlot); created lazily on first 'plot'.
        self.plot_client = None

        # Cache of live plot references ('uaplot_<title>') used by the
        # SYNCHRONOUS tab completer to offer candidates for 'plot rm
        # <Tab>' without blocking on plot_details(). Refreshed by
        # _plot_list / _plot_rm (which already hit the plot endpoint).
        self._plot_refs_cache: List[str] = []

        # Per-completion hint from _completer to _display_matches_hook
        # so the latter can colour candidates by SEMANTIC role (command,
        # subcommand, flag, plot ref, ...) when the role isn't obvious
        # from the candidate string alone. None == let the hook fall
        # back to its per-candidate heuristics.
        self._completion_kind: Optional[str] = None

        # Completion cache for tab completion of node paths
        # Maps path -> node_class ('object', 'variable', 'method')
        self.completion_cache = {}

        # Top-level nodes discovered from server (e.g., Objects, Types, Views, Server)
        # This is populated dynamically - not hardcoded
        self.top_level_nodes = set()

        # Current working path in OPC UA namespace (for cd command)
        self.current_path = ""
        self.previous_path = ""  # For 'cd -' support

        # Output routing for pipes/redirection.
        # When _sink is a list, data-producing commands append lines to it
        # (via _emit) instead of printing. Status/error messages keep using
        # print() directly - that is the stderr-like channel and must not flow
        # through pipes. _flush_sink() decides where the captured lines go.
        self._sink: Optional[List[str]] = None

        # suffix -> saved-name hints for 'connect' tab completion; populated by
        # _completer and read by _display_matches_hook to annotate named URIs.
        self._connect_name_hints: Dict[str, str] = {}

        # Color control. Colors are emitted only to an interactive terminal and
        # never while capturing into a sink (so ANSI codes don't leak into
        # grep/files). Honour the NO_COLOR convention (https://no-color.org/).
        self._color_enabled = os.environ.get("NO_COLOR") is None

        # Parse URL for prompt
        parsed = urlparse(url)
        self.host = parsed.hostname or "unknown"
        self.port = parsed.port or 4840

        # Setup logging - suppress asyncua internal errors during reconnection.
        # Only force the quiet default if nothing has configured logging yet
        # (the CLI's _configure_logging, when -v/-l were given, runs first and
        # must not be overridden here).
        if not logging.getLogger().handlers:
            logging.basicConfig(level=logging.CRITICAL)  # Only show critical errors

        # Always keep the noisy asyncua reconnection loggers quiet.
        logging.getLogger('asyncua.client.client').setLevel(logging.CRITICAL)
        logging.getLogger('asyncua.client.ua_client').setLevel(logging.CRITICAL)
        logging.getLogger('asyncua.client.ua_client.UaClient').setLevel(logging.CRITICAL)

        # Setup home directory - use provided home_dir or default to ~/.uatools/UaShell/
        if home_dir:
            self.home_dir = Path(home_dir)
        else:
            self.home_dir = Path.home() / ".uatools" / "UaShell"

        # Setup history directory and file
        self.history_dir = self.home_dir
        self.history_file = self.history_dir / "history"
        self.uri_history_file = self.history_dir / "uri_history"
        self.settings_file = self.history_dir / "settings.json"
        # Views directory (UaShell's own view definitions). UaExplorer-defined
        # views are also honoured when present — see _resolve_view().
        self.views_dir = self.home_dir / "views"
        self.uri_history: List[str] = []  # List of recently used URIs
        # URI -> friendly name (mirrors UaExplorer's server_names). Persisted
        # in settings.json. Shown in the prompt and usable with 'connect <name>'.
        self.server_names: Dict[str, str] = {}
        # Per-URI lazy-loading policy: {uri: {"enabled": bool, "limit": int}}.
        # A URI not present here gets the new-URI default (lazy on, 10000).
        self.lazy_by_uri: Dict[str, Dict[str, Any]] = {}
        # Per-URI active View name (or "" for none).
        self.view_by_uri: Dict[str, str] = {}
        # Per-URI remembered stats of the last FULL COMPLETE load, for ETA:
        # {uri: {"count": int, "seconds": float}}. Scoped/partial loads excluded.
        self.load_stats_by_uri: Dict[str, Dict[str, Any]] = {}
        # Live load state (set by the browse): whether the namespace is partial
        # (lazy cap hit, more loadable on demand) — surfaced in the prompt.
        self._namespace_partial = False
        # Frontier: nodes SEEN during a capped browse but not expanded. Keyed by
        # path -> (node_id_str). Phase 3 loads a frontier subtree on demand when
        # the user navigates (cd/ls/find) into it, or via 'scan <path>'.
        self.frontier_nodes: Dict[str, str] = {}
        # Persistent namespace cache (SQLite): on connect, load the last full
        # browse of this URI from disk for instant navigation, then revalidate
        # in the background. Global on/off, stored in settings.json.
        self.nscache_file = self.history_dir / "nscache.db"
        self.nscache_enabled = True
        # True once the in-memory cache was populated from the DB this session
        # (so the prompt/status can note it's from cache pending revalidation).
        self._nscache_from_disk = False
        # Background full-rebrowse task (revalidates a from-disk cache).
        self._nscache_revalidate_task = None
        self._load_uri_history()
        self._load_settings()
        self._setup_history()

        # Initialize subscription manager
        self._initialize_subscription_manager()

    def _initialize_subscription_manager(self):
        """Initialize the subscription manager"""
        try:
            self.subscription_manager = SubscriptionManager(self.client)
        except Exception as e:
            print(f"Warning: Could not initialize subscription manager: {e}")
            self.subscription_manager = None


    def _setup_history(self):
        """Setup command history with persistent storage and tab completion"""
        # Create history directory (and parent, e.g. ~/.uatools) if missing — works on first run / Windows
        self.history_dir.mkdir(parents=True, exist_ok=True)

        if READLINE_AVAILABLE:
            # Basic tab completion binding
            readline.parse_and_bind("tab: complete")
            # Unix-style line editing shortcuts
            readline.parse_and_bind(r'"\C-u": unix-line-discard')  # Ctrl+U: clear line
            readline.parse_and_bind(r'"\C-k": kill-line')          # Ctrl+K: kill to end of line
            readline.parse_and_bind(r'"\C-a": beginning-of-line')  # Ctrl+A: go to beginning
            readline.parse_and_bind(r'"\C-e": end-of-line')        # Ctrl+E: go to end
            readline.parse_and_bind(r'"\C-w": unix-word-rubout')   # Ctrl+W: delete word backward
            readline.set_history_length(1000)

            # Keep default delimiters - / will be a word boundary
            # This gives Unix-like behavior where "cd Objects/" + TAB shows just child names

            # Our custom completer
            try:
                readline.set_completer(self._completer)
            except Exception:
                # On some platforms/combinations this may fail; ignore
                pass

            # Custom display hook to show method signatures
            try:
                readline.set_completion_display_matches_hook(self._display_matches_hook)
            except Exception:
                pass

            # Load existing history
            if self.history_file.exists():
                try:
                    readline.read_history_file(str(self.history_file))
                except Exception as e:
                    print(f"Warning: Could not load command history: {e}")


    def _save_history(self):
        """Save command history to file"""
        if READLINE_AVAILABLE:
            try:
                readline.write_history_file(str(self.history_file))
            except Exception as e:
                print(f"Warning: Could not save command history: {e}")

    def _load_uri_history(self):
        """Load URI history from file"""
        self.uri_history = []
        if self.uri_history_file.exists():
            try:
                with open(self.uri_history_file, 'r') as f:
                    for line in f:
                        uri = line.strip()
                        if uri and uri not in self.uri_history:
                            self.uri_history.append(uri)
                # Keep only last 20
                self.uri_history = self.uri_history[-20:]
            except Exception as e:
                pass  # Silently ignore errors loading URI history

    def _save_uri_history(self):
        """Save URI history to file"""
        try:
            self.history_dir.mkdir(parents=True, exist_ok=True)
            with open(self.uri_history_file, 'w') as f:
                for uri in self.uri_history:
                    f.write(f"{uri}\n")
        except Exception as e:
            pass  # Silently ignore errors saving URI history

    # Lazy-loading defaults applied to a URI that has no saved policy yet.
    LAZY_DEFAULT_ENABLED = True
    LAZY_DEFAULT_LIMIT = 10000

    def _load_settings(self):
        """Load persisted settings (URI->name, lazy policy, view, load stats)."""
        if not self.settings_file.exists():
            return
        try:
            with open(self.settings_file, "r") as f:
                data = json.load(f)
            names = data.get("server_names", {})
            if isinstance(names, dict):
                self.server_names = {str(k): str(v) for k, v in names.items()}
            lazy = data.get("lazy_by_uri", {})
            if isinstance(lazy, dict):
                self.lazy_by_uri = {str(k): v for k, v in lazy.items()
                                    if isinstance(v, dict)}
            views = data.get("view_by_uri", {})
            if isinstance(views, dict):
                self.view_by_uri = {str(k): str(v) for k, v in views.items()}
            stats = data.get("load_stats_by_uri", {})
            if isinstance(stats, dict):
                self.load_stats_by_uri = {str(k): v for k, v in stats.items()
                                          if isinstance(v, dict)}
            if "nscache_enabled" in data:
                self.nscache_enabled = bool(data.get("nscache_enabled"))
        except Exception:
            pass  # Corrupt/old settings shouldn't stop startup

    def _save_settings(self):
        """Persist all settings to settings.json.

        IMPORTANT: dump the full dict — writing only one key would wipe the
        others on the next save.
        """
        try:
            self.history_dir.mkdir(parents=True, exist_ok=True)
            with open(self.settings_file, "w") as f:
                json.dump({
                    "server_names":      self.server_names,
                    "lazy_by_uri":       self.lazy_by_uri,
                    "view_by_uri":       self.view_by_uri,
                    "load_stats_by_uri": self.load_stats_by_uri,
                    "nscache_enabled":   self.nscache_enabled,
                }, f, indent=2)
        except Exception:
            pass  # Best-effort; never fail a command over settings I/O

    # -- per-URI lazy / view accessors --------------------------------------

    def _lazy_for_uri(self, uri: str) -> Dict[str, Any]:
        """Return the {enabled, limit} lazy policy for a URI.

        A URI with no saved policy gets the new-URI default (lazy on, 10000).
        Returned dict is normalised (bool enabled, int limit >= 0).
        """
        raw = self.lazy_by_uri.get((uri or "").strip())
        if not isinstance(raw, dict):
            return {"enabled": self.LAZY_DEFAULT_ENABLED,
                    "limit": self.LAZY_DEFAULT_LIMIT}
        try:
            limit = max(0, int(raw.get("limit", self.LAZY_DEFAULT_LIMIT)))
        except (TypeError, ValueError):
            limit = self.LAZY_DEFAULT_LIMIT
        # limit 0 means "no cap" -> lazy effectively off (matches UaExplorer).
        enabled = bool(raw.get("enabled", self.LAZY_DEFAULT_ENABLED)) and limit > 0
        return {"enabled": enabled, "limit": limit}

    def _set_lazy_for_uri(self, uri: str, *, enabled: bool = None,
                          limit: int = None):
        """Update and persist the lazy policy for a URI (partial update)."""
        uri = (uri or "").strip()
        if not uri:
            return
        cur = self._lazy_for_uri(uri)
        if enabled is not None:
            cur["enabled"] = bool(enabled)
        if limit is not None:
            cur["limit"] = max(0, int(limit))
        self.lazy_by_uri[uri] = cur
        self._save_settings()

    def _view_for_uri(self, uri: str) -> str:
        """Return the active View name bound to a URI, or '' if none."""
        return str(self.view_by_uri.get((uri or "").strip(), "")).strip()

    # -- Views (namespace scoping) ------------------------------------------

    def _view_dirs(self) -> List[Path]:
        """View source directories, in precedence order: UaShell's own first
        (wins on name clash), then UaExplorer's (shared, read-only to us)."""
        return [self.views_dir,
                self.home_dir.parent / "UaExplorer" / "views"]

    def _list_views(self) -> Dict[str, Path]:
        """Map view name -> file path, own views shadowing UaExplorer's.

        The name is the JSON's "name" field if present, else the file stem.
        """
        found: Dict[str, Path] = {}
        # Iterate LOWEST precedence first so higher-precedence dirs overwrite.
        for d in reversed(self._view_dirs()):
            try:
                if not d.is_dir():
                    continue
                for f in sorted(d.glob("*.json")):
                    try:
                        with open(f, "r") as fh:
                            data = json.load(fh)
                        nm = str(data.get("name") or f.stem).strip()
                    except Exception:
                        nm = f.stem
                    if nm:
                        found[nm] = f
            except Exception:
                continue
        return found

    def _load_view(self, name: str) -> Optional[Scope]:
        """Load a named view as a Scope, or None if not found/unreadable."""
        path = self._list_views().get((name or "").strip())
        if not path:
            return None
        try:
            with open(path, "r") as fh:
                return Scope.from_dict(json.load(fh))
        except Exception:
            return None

    def _active_scope(self) -> Optional[Scope]:
        """The Scope for the current URI's active view, or None (no scoping)."""
        name = self._view_for_uri(self.url)
        return self._load_view(name) if name else None

    def _save_view(self, scope: Scope) -> Path:
        """Persist a Scope to UaShell's own views/ dir as <name>.json."""
        self.views_dir.mkdir(parents=True, exist_ok=True)
        # Sanitise the file stem; the JSON "name" field keeps the real name.
        stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", scope.name).strip("_") or "view"
        path = self.views_dir / f"{stem}.json"
        with open(path, "w") as fh:
            json.dump(scope.to_dict(), fh, indent=2)
        return path

    def _loaded_count(self) -> int:
        """Live number of nodes currently in the namespace cache.

        Read straight from the cache so it stays truthful as nodes are added
        on demand (lazy frontier loads) rather than tracking a separate
        counter that could drift.
        """
        try:
            return len(self.namespace_cache.nodes_by_path)
        except Exception:
            return 0

    # -- Persistent namespace cache (SQLite) --------------------------------

    NSCACHE_SCHEMA_VERSION = 1

    def _nscache_connect(self) -> Optional["sqlite3.Connection"]:
        """Open (creating if needed) the nscache DB, or None on any failure.

        All DB access is best-effort: a missing/corrupt/locked DB must never
        block the shell — callers fall back to a live browse.
        """
        try:
            self.history_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,"
                " partial INTEGER, view TEXT, fingerprint TEXT,"
                " schema_version INTEGER)")
            conn.execute(
                "CREATE TABLE IF NOT EXISTS nodes ("
                " uri TEXT, path TEXT, display_name TEXT, node_class TEXT,"
                " node_id_str TEXT, parent_path TEXT, method_sig TEXT)")
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_nodes_uri_path "
                " ON nodes(uri, path)")
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_nodes_uri_parent "
                " ON nodes(uri, parent_path)")
            return conn
        except Exception:
            return None

    def _nscache_save(self, uri: str, *, cache=None, fingerprint: str = "") -> bool:
        """Persist a namespace cache for `uri` (default: the live one).

        Only called for a FULL, COMPLETE, UNSCOPED browse — the canonical
        namespace (see the browse finaliser). `cache` lets the background
        revalidate persist its freshly-built cache rather than the live one.
        Returns True on success.
        """
        if not self.nscache_enabled:
            return False
        uri = (uri or "").strip()
        if not uri:
            return False
        cache = cache if cache is not None else self.namespace_cache
        conn = self._nscache_connect()
        if conn is None:
            return False
        try:
            rows = []
            for path, node in cache.nodes_by_path.items():
                rows.append((uri, path, node.display_name, node.node_class,
                             node.node_id_str, node.parent_path,
                             getattr(node, "method_signature", "") or ""))
            with conn:  # single transaction (atomic)
                conn.execute("DELETE FROM nodes WHERE uri = ?", (uri,))
                conn.execute("DELETE FROM meta WHERE uri = ?", (uri,))
                conn.executemany(
                    "INSERT INTO nodes (uri, path, display_name, node_class,"
                    " node_id_str, parent_path, method_sig)"
                    " VALUES (?,?,?,?,?,?,?)", rows)
                conn.execute(
                    "INSERT INTO meta (uri, node_count, captured_at, partial,"
                    " view, fingerprint, schema_version)"
                    " VALUES (?,?,?,?,?,?,?)",
                    (uri, len(rows), datetime.now().isoformat(), 0, "",
                     fingerprint, self.NSCACHE_SCHEMA_VERSION))
            return True
        except Exception:
            return False
        finally:
            try:
                conn.close()
            except Exception:
                pass

    def _nscache_load(self, uri: str) -> bool:
        """Load `uri`'s persisted namespace into the in-memory cache.

        Returns True if a cache was found and loaded. Best-effort: any failure
        returns False and leaves the in-memory cache as-is.
        """
        if not self.nscache_enabled:
            return False
        uri = (uri or "").strip()
        if not uri:
            return False
        conn = self._nscache_connect()
        if conn is None:
            return False
        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 False
            cur = conn.execute(
                "SELECT path, display_name, node_class, node_id_str,"
                " parent_path, method_sig FROM nodes WHERE uri = ?", (uri,))
            fetched = cur.fetchall()
            if not fetched:
                return False
            self.namespace_cache.clear()
            self.completion_cache.clear()
            self.top_level_nodes.clear()
            self.frontier_nodes.clear()
            for path, dname, nclass, nid, parent, msig in fetched:
                self.namespace_cache.add_node(
                    path=path, display_name=dname, node_class=nclass,
                    node_id_str=nid, method_signature=msig or "")
                if not parent:  # top-level node
                    self.top_level_nodes.add(dname)
                completion_path = path.replace("/", ".")
                if completion_path.startswith("Objects."):
                    completion_path = completion_path[8:]
                self.completion_cache[completion_path] = (
                    ('method', msig or "") if nclass == 'method' else nclass)
            self.namespace_cache.is_populated = True
            self.namespace_cache.cache_time = datetime.now()
            self._namespace_partial = False
            return True
        except Exception:
            return False
        finally:
            try:
                conn.close()
            except Exception:
                pass

    def _name_for_uri(self, uri: str) -> str:
        """Return the friendly name bound to a URI, or '' if none."""
        return str(self.server_names.get((uri or "").strip(), "")).strip()

    def _resolve_uri_alias(self, text: str) -> str:
        """If `text` matches a saved name, return its URI; else return `text`.

        Lets 'connect <alias>' work alongside 'connect <url>'. Case-insensitive
        on the alias; an exact name match wins over treating it as a raw URL.
        """
        t = (text or "").strip()
        if not t:
            return t
        for uri, name in self.server_names.items():
            if name.lower() == t.lower():
                return uri
        return t

    def _maybe_prompt_for_uri_name(self):
        """Interactively offer to name the current URI if it has no name yet.

        Called only from interactive flows (run_interactive / cmd_connect) -
        NEVER from connect() itself, so scripted (-s) and test runs that call
        connect() directly are never blocked on input. No-op without a TTY.
        """
        uri = (self.url or "").strip()
        if not uri or self._name_for_uri(uri):
            return
        try:
            if not sys.stdin.isatty():
                return
        except Exception:
            return
        try:
            name = input(f"Name for {uri} (Enter to skip): ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return
        if name:
            self.server_names[uri] = name
            self._save_settings()
            print(f"✅ Named '{uri}' -> '{name}'")

    def _add_uri_to_history(self, uri: str):
        """Add a URI to history (removes duplicates, keeps last 20)"""
        # Remove if already exists (to move to end)
        if uri in self.uri_history:
            self.uri_history.remove(uri)
        # Add to end
        self.uri_history.append(uri)
        # Keep only last 20
        if len(self.uri_history) > 20:
            self.uri_history = self.uri_history[-20:]
        # Save to file
        self._save_uri_history()

    async def _check_connection(self) -> bool:
        """Check if the connection is still alive"""
        try:
            # Try to read a basic server node to test connection
            server_node = self.client.get_node(ua.NodeId(2259, 0))  # Server_ServerStatus
            await asyncio.wait_for(server_node.read_value(), timeout=3.0)
            return True
        except Exception:
            return False

    async def _reconnect_loop(self):
        """Background task to monitor connection and reconnect if needed"""
        attempt = 0
        consecutive_failures = 0

        while self.reconnect_enabled:
            try:
                await asyncio.sleep(2)  # Check every 2 seconds

                if self.connected:
                    # Check if connection is still alive
                    if not await self._check_connection():
                        print(f"\n🔌 Connection lost to {self.url}")
                        self.connected = False
                        self.connection_lost_time = time.time()
                        self.node_cache.clear()  # Clear cache on disconnect
                        attempt = 0
                        consecutive_failures = 0
                else:
                    # Try to reconnect
                    if self.max_reconnect_attempts == -1 or attempt < self.max_reconnect_attempts:
                        attempt += 1
                        print(f"🔄 Reconnection attempt {attempt}...")

                        # Temporarily suppress asyncua logging during reconnection
                        asyncua_logger = logging.getLogger('asyncua')
                        ua_client_logger = logging.getLogger('asyncua.client')
                        original_level = asyncua_logger.level
                        original_client_level = ua_client_logger.level

                        try:
                            # Suppress all asyncua logging during reconnection
                            asyncua_logger.setLevel(logging.CRITICAL)
                            ua_client_logger.setLevel(logging.CRITICAL)

                            # Properly disconnect old client first
                            try:
                                await self.client.disconnect()
                            except:
                                pass

                            # Create new client instance
                            self.client = Client(self.url)

                            # Re-initialize subscription manager with new client
                            if self.subscription_manager:
                                self.subscription_manager.client = self.client

                            # Try to connect with timeout
                            await asyncio.wait_for(self.client.connect(), timeout=10.0)

                            # Test the connection immediately
                            if await self._check_connection():
                                self.connected = True
                                self.connection_lost_time = None
                                print(f"✅ Reconnected to {self.url}")

                                # Restore subscriptions if any existed
                                if self.subscription_manager and self.subscription_manager.get_subscription_count() > 0:
                                    print("🔄 Restoring subscriptions...")
                                    restored_count = await self.subscription_manager.restore_subscriptions(
                                        self.parse_node_id
                                    )
                                    if restored_count > 0:
                                        print(f"✅ Restored {restored_count} subscriptions")
                                    else:
                                        print("⚠️  Could not restore subscriptions")

                                attempt = 0
                                consecutive_failures = 0
                            else:
                                raise Exception("Connection test failed after connect")

                        except asyncio.TimeoutError:
                            print(f"❌ Reconnection attempt {attempt} timed out")
                            consecutive_failures += 1
                        except Exception as e:
                            # Only show user-friendly error messages
                            error_msg = str(e)
                            if "Connection refused" in error_msg:
                                print(f"❌ Reconnection attempt {attempt} failed: Server not available")
                            elif "timeout" in error_msg.lower():
                                print(f"❌ Reconnection attempt {attempt} failed: Connection timeout")
                            elif "Connection test failed" in error_msg:
                                print(f"❌ Reconnection attempt {attempt} failed: Connection unstable")
                            else:
                                print(f"❌ Reconnection attempt {attempt} failed: {error_msg}")
                            consecutive_failures += 1
                        finally:
                            # Restore original logging levels
                            asyncua_logger.setLevel(original_level)
                            ua_client_logger.setLevel(original_client_level)

                        # If we've had many consecutive failures, increase wait time
                        if consecutive_failures > 5:
                            print(f"⏰ Many failures detected, waiting longer before next attempt...")
                            await asyncio.sleep(self.reconnect_interval * 2)
                        else:
                            await asyncio.sleep(self.reconnect_interval)
                    else:
                        print(f"❌ Max reconnection attempts ({self.max_reconnect_attempts}) reached")
                        break

            except asyncio.CancelledError:
                break
            except Exception as e:
                print(f"Error in reconnection loop: {e}")
                await asyncio.sleep(self.reconnect_interval)

    async def connect(self):
        """Connect to the OPC UA server"""
        try:
            # Configure timeouts through client parameters
            self.client.set_connect_timeout(10.0) if hasattr(self.client, 'set_connect_timeout') else None
            self.client.set_session_timeout(30.0) if hasattr(self.client, 'set_session_timeout') else None

            await asyncio.wait_for(self.client.connect(), timeout=10.0)
            self.connected = True
            print(f"Connected to {self.url}")

            # Add URI to history after successful connection
            self._add_uri_to_history(self.url)

            # Namespace cache. If a persisted cache exists for this URI, load it
            # from disk for INSTANT navigation, then revalidate in the
            # background (a full, unscoped rebrowse that atomically replaces the
            # cache and re-persists). Otherwise browse now (foreground).
            self._nscache_from_disk = False
            loaded_from_disk = False
            if self.nscache_enabled:
                loaded_from_disk = self._nscache_load(self.url)
            if loaded_from_disk:
                self._nscache_from_disk = True
                print(f"  Loaded {self._loaded_count()} nodes from cache "
                      f"(revalidating in background)")
                self._nscache_revalidate_task = asyncio.create_task(
                    self._nscache_revalidate())
            else:
                # Build the namespace cache using parallel browsing (honours
                # the active lazy/view for this first, foreground load).
                await self._browse_namespace_fast(show_progress=True)

            # Start reconnection monitoring
            if self.reconnect_enabled:
                self.reconnect_task = asyncio.create_task(self._reconnect_loop())

        except asyncio.TimeoutError:
            print(f"Connection failed: Connection timeout")
            return False
        except Exception as e:
            print(f"Connection failed: {e}")
            return False
        return True

    async def _populate_completion_cache(self, max_depth: int = 3):
        """Pre-populate completion cache with Objects children for tab completion"""
        try:
            root = self.client.get_root_node()
            children = await root.get_children()

            # Add top-level nodes to cache and recurse into Objects
            for child in children:
                try:
                    display_name = await child.read_display_name()
                    name = display_name.Text
                    node_class = await child.read_node_class()

                    # Add top-level nodes (discovered dynamically from server) to cache
                    if node_class == ua.NodeClass.Object:
                        self.completion_cache[name] = 'object'
                        self.top_level_nodes.add(name)  # Track as top-level node

                    # Recurse into Objects for deeper completion
                    if name == "Objects":
                        await self._populate_cache_recursive(child, "", max_depth)
                except:
                    continue
        except Exception:
            pass  # Silently fail - completion just won't be available

    async def _populate_cache_recursive(self, node: Node, prefix: str, depth: int):
        """Recursively populate completion cache"""
        if depth <= 0:
            return

        try:
            children = await node.get_children()
            for child in children:
                try:
                    node_class = await child.read_node_class()
                    display_name = await child.read_display_name()
                    name = display_name.Text

                    # Build full path
                    child_path = f"{prefix}.{name}" if prefix else name

                    # Store with node type
                    if node_class == ua.NodeClass.Object:
                        self.completion_cache[child_path] = 'object'
                        await self._populate_cache_recursive(child, child_path, depth - 1)
                    elif node_class == ua.NodeClass.Variable:
                        self.completion_cache[child_path] = 'variable'
                        # Structured Variables (PLC DataBlocks) can have children
                        # too — recurse so their fields show in completion.
                        await self._populate_cache_recursive(child, child_path, depth - 1)
                    elif node_class == ua.NodeClass.Method:
                        # Check for input arguments
                        input_sig = await self._get_method_input_signature(child)
                        self.completion_cache[child_path] = ('method', input_sig)
                    else:
                        self.completion_cache[child_path] = 'other'
                except:
                    continue
        except:
            pass

    async def _get_method_input_signature(self, method_node: Node) -> str:
        """Get the input argument signature for a method, e.g., 'intensity:Int32' or '' if no args"""
        try:
            children = await method_node.get_children()
            for child in children:
                bn = await child.read_browse_name()
                # Handle bn.Name being bytes or string
                bn_name = bn.Name.decode('utf-8') if isinstance(bn.Name, bytes) else bn.Name
                if bn_name == "InputArguments":
                    input_args = await child.read_value()
                    if input_args:
                        parts = []
                        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'
                        }
                        for arg in input_args:
                            # Get arg name
                            if hasattr(arg, 'Name') and arg.Name is not None:
                                if isinstance(arg.Name, bytes):
                                    arg_name = arg.Name.decode('utf-8', errors='replace')
                                else:
                                    arg_name = str(arg.Name)
                            else:
                                arg_name = '?'
                            # Get arg type
                            try:
                                type_id = arg.DataType.Identifier
                                arg_type = type_names.get(type_id, f'Type{type_id}')
                            except:
                                arg_type = '?'
                            parts.append(f"{arg_name}:{arg_type}")
                        return ", ".join(parts)
                    break
        except:
            pass
        return ''

    async def _populate_cache_for_path(self, path: str, depth: int = 3):
        """Populate completion cache for a specific path and its children"""
        try:
            # Convert path to dot notation
            opcua_path = path.replace("/", ".")

            # Find the node
            node = await self._find_node_by_path(opcua_path)
            if not node:
                return

            # Add this path to cache
            if opcua_path.startswith("Objects."):
                cache_path = opcua_path[8:]  # Remove "Objects." prefix
            else:
                cache_path = opcua_path
            self.completion_cache[cache_path] = 'object'  # cd target is always an object

            # Recursively add children
            await self._populate_cache_recursive(node, cache_path, depth)
        except Exception:
            pass  # Silently fail - completion just won't be available

    async def _browse_namespace_fast(self, show_progress: bool = True,
                                     force_full: bool = False,
                                     into: Optional[tuple] = None) -> bool:
        """Efficiently browse and cache the entire OPC UA namespace using parallel requests.

        This method uses asyncio.gather to browse multiple nodes in parallel, dramatically
        reducing the time needed compared to sequential browsing.

        force_full: ignore the active lazy limit AND view (full, unscoped
        browse). Used by the background revalidation so the persisted cache is
        always the complete namespace.

        into: (namespace_cache, completion_cache, top_level_nodes,
        frontier_nodes) to fill INSTEAD of self.*. The background revalidate
        passes fresh, unpublished containers so it can build a full cache
        without touching the user-visible one, then swaps atomically. When None,
        the browse fills self.* (the normal foreground behaviour).

        Returns True if successful, False otherwise.
        """
        if not self.connected:
            return False

        if into is not None:
            nsc, comp, tops, front = into
            nsc.clear(); comp.clear(); tops.clear(); front.clear()
        else:
            # Clear existing cache (foreground browse fills self.*).
            self.namespace_cache.clear()
            self.completion_cache.clear()
            self.top_level_nodes.clear()
            self.frontier_nodes.clear()
            self._namespace_partial = False
            nsc = self.namespace_cache
            comp = self.completion_cache
            tops = self.top_level_nodes
            front = self.frontier_nodes

        start_time = time.time()

        # Lazy policy for this URI. When lazy is off (or limit 0) we browse the
        # whole namespace exactly as before. When on, we browse breadth-first up
        # to `limit` real nodes, then divert the rest to the frontier (loaded on
        # demand later). Cap enforced MID-level: a single BFS level can be
        # 100k+ wide, so a between-levels check alone would overshoot.
        # force_full overrides both lazy and view (full, unscoped browse).
        lazy = self._lazy_for_uri(self.url)
        node_limit = 0 if force_full else (lazy["limit"] if lazy["enabled"] else 0)
        cap_hit = [False]  # mutable flag shared with process_level

        # Active View (scoping). When set, the browse is pruned/filtered:
        #  - scope_matches_node decides whether a node is CACHED at all.
        #  - prefix-style includes let us PRUNE the browse (don't descend into
        #    subtrees that can't lead to an included one) — the big win on large
        #    servers. Non-prefix includes/excludes can't prune, so we browse
        #    fully but still filter what gets cached.
        scope = None if force_full else self._active_scope()
        scope_prefixes = []
        if scope:
            enabled_inc = [p.pattern for p in scope.include_patterns
                           if p.enabled and p.pattern]
            prefixes = [_view_include_prefix(p) for p in enabled_inc]
            # Prune ONLY when every enabled include is prefix-style; a single
            # non-prefix include (substring/mid-glob) could match anywhere, so
            # pruning would wrongly drop nodes. Then we browse fully and filter.
            if enabled_inc and all(prefixes):
                scope_prefixes = [p for p in prefixes if p]

        def _view_keep_browse(child_path: str) -> bool:
            """Should we DESCEND into child_path given the active view?"""
            if not scope_prefixes:
                return True  # no prunable prefixes -> browse everything
            return _view_browse_keep(_view_canon(child_path), scope_prefixes)

        # ETA source: remembered {count, seconds} of the last FULL COMPLETE load
        # of this URI. Used to show "~Ns left" while browsing. We extrapolate by
        # TIME (fraction-of-nodes-done x known total seconds), NOT by the live
        # n/elapsed rate — the browse is bursty (shallow levels are slow, then
        # 150-way concurrency kicks in), so a live rate gives a wildly wrong ETA
        # early on. Anchoring to the remembered wall-clock is stable.
        prior = self.load_stats_by_uri.get((self.url or "").strip()) or {}
        eta_total = int(prior.get("count", 0)) if isinstance(prior, dict) else 0
        eta_seconds = float(prior.get("seconds", 0)) if isinstance(prior, dict) else 0.0

        # Concurrency limit - higher value for better performance
        MAX_CONCURRENT = 150
        semaphore = asyncio.Semaphore(MAX_CONCURRENT)

        # Progress tracking. Refresh the single (carriage-return-rewritten)
        # progress line every PROGRESS_INTERVAL seconds — often enough that the
        # ETA counts down smoothly, but not per-node (which would be thousands
        # of prints/sec and flicker).
        nodes_processed = [0]
        last_progress_time = [time.time()]
        PROGRESS_INTERVAL = 0.2

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

        async def browse_node(node: Node, path: str) -> List[tuple]:
            """Browse a node using get_children_descriptions() for efficiency.

            This returns ReferenceDescription objects which include DisplayName,
            NodeClass, and NodeId all in one OPC UA call instead of 3 separate calls.
            """
            results = []
            try:
                # get_children_descriptions() returns ReferenceDescription objects
                # which contain DisplayName, NodeClass, NodeId in ONE request
                refs = await node.get_children_descriptions()

                for ref in refs:
                    try:
                        # Extract info directly from ReferenceDescription
                        name = ref.DisplayName.Text if ref.DisplayName else str(ref.BrowseName.Name)
                        node_class = ref.NodeClass
                        # Store the PARSEABLE node-id form ("ns=2;s=...") so it
                        # round-trips through client.get_node(). str(NodeId)
                        # yields the repr, which get_node() cannot parse.
                        node_id_str = nodeid_to_string(ref.NodeId)

                        # Create a Node object for the child (for next level browsing)
                        child_node = self.client.get_node(ref.NodeId)

                        # Disambiguate path by node class to handle cases where a Variable and Method
                        # have the same browse name (e.g., "State" variable vs "State()" method)
                        path_name = name
                        if node_class == ua.NodeClass.Method:
                            path_name = f"{name}()"

                        child_path = f"{path}/{path_name}" if path else path_name
                        results.append((child_node, child_path, name, node_class, node_id_str))
                    except:
                        continue

            except Exception:
                pass
            return results

        async def process_level(nodes_with_paths: List[tuple]) -> List[tuple]:
            """Process a level of nodes in parallel and return children to process next"""
            if not nodes_with_paths:
                return []

            # Browse all nodes at this level in parallel
            browse_tasks = [browse_node_with_semaphore(node, path) for node, path in nodes_with_paths]
            all_results = await asyncio.gather(*browse_tasks)

            next_level = []
            for results in all_results:
                for child, child_path, name, node_class, node_id_str in results:
                    # Cap enforcement (mid-level): once the limit is reached,
                    # every further node this browse produced becomes a FRONTIER
                    # entry (recorded, not cached/expanded) and the BFS stops
                    # queueing deeper work. node_limit 0 = no cap.
                    if node_limit and len(nsc.nodes_by_path) >= node_limit:
                        cap_hit[0] = True
                        front[child_path] = node_id_str
                        continue

                    # View pruning: if the active view has prunable prefixes and
                    # this path can't lead to an included subtree, skip it
                    # entirely (don't cache, don't descend). This is where a
                    # scoped view saves the big browse cost.
                    keep_browse = _view_keep_browse(child_path)
                    if not keep_browse:
                        continue

                    # Map node class to string
                    if node_class == ua.NodeClass.Object:
                        class_str = 'object'
                    elif node_class == ua.NodeClass.Variable:
                        class_str = 'variable'
                    elif node_class == ua.NodeClass.Method:
                        class_str = 'method'
                    else:
                        class_str = 'other'

                    # Skip method signature fetching during bulk load (lazy load later)
                    method_sig = ""

                    # View filtering: cache the node only if it matches the
                    # scope OR is an ancestor we must keep to reach an included
                    # subtree (a prunable-prefix ancestor is always kept so the
                    # path to the wanted subtree stays intact). With no view,
                    # scope is None and everything matches.
                    cache_it = scope is None or scope_matches_node(scope, child_path)
                    if not cache_it and scope_prefixes:
                        # Keep ancestors of a wanted prefix so the subtree is
                        # reachable (they passed _view_keep_browse above).
                        cache_it = True
                    if cache_it:
                        # Add to namespace cache
                        nsc.add_node(
                            path=child_path,
                            display_name=name,
                            node_class=class_str,
                            node_id_str=node_id_str,
                            method_signature=method_sig
                        )

                        # Also update completion_cache for tab completion.
                        # Use dot notation without "Objects." prefix.
                        completion_path = child_path.replace("/", ".")
                        if completion_path.startswith("Objects."):
                            completion_path = completion_path[8:]
                        if class_str == 'method':
                            comp[completion_path] = ('method', method_sig)
                        else:
                            comp[completion_path] = class_str

                    # Queue for next-level browsing. Objects (folders) always,
                    # and Variables too — structured Variables (PLC DataBlocks
                    # like CCS_CMD_DB/DATA) carry child nodes that would
                    # otherwise be invisible. Childless nodes add nothing to the
                    # next level, so this is cheap. Methods stay leaves.
                    if class_str in ('object', 'variable'):
                        next_level.append((child, child_path))

                    nodes_processed[0] += 1

                    # Refresh the progress line at a steady cadence.
                    if show_progress and time.time() - last_progress_time[0] > PROGRESS_INTERVAL:
                        elapsed = time.time() - start_time
                        n = nodes_processed[0]
                        rate = n / elapsed if elapsed > 0 else 0
                        # ETA from the remembered full-load: fraction of nodes
                        # done x known total seconds, minus time already spent.
                        # Time-anchored so the bursty browse rate doesn't skew
                        # it. Only for uncapped loads with a prior full stat.
                        if eta_total and eta_seconds > 0 and not node_limit:
                            frac = min(1.0, n / eta_total)
                            eta = max(0.0, eta_seconds * (1.0 - frac))
                            print(f"\r  Browsing... {n}/{eta_total} nodes "
                                  f"(~{eta:.0f}s left)   ", end="", flush=True)
                        else:
                            print(f"\r  Browsing... {n} nodes ({rate:.0f}/sec)",
                                  end="", flush=True)
                        last_progress_time[0] = time.time()

            return next_level

        try:
            if show_progress:
                print("Building namespace cache (parallel browsing)...")

            # Start from root - use get_children_descriptions for efficiency
            root = self.client.get_root_node()
            root_refs = await root.get_children_descriptions()

            # Process top-level nodes
            initial_nodes = []
            for ref in root_refs:
                try:
                    name = ref.DisplayName.Text if ref.DisplayName else str(ref.BrowseName.Name)
                    node_class = ref.NodeClass
                    node_id_str = nodeid_to_string(ref.NodeId)
                    child = self.client.get_node(ref.NodeId)

                    # View pruning at the root: with prunable prefixes, skip a
                    # top-level branch that can't lead to an included subtree
                    # (e.g. a '/Objects/...' view prunes Server/Types/Views).
                    if not _view_keep_browse(name):
                        continue

                    if node_class == ua.NodeClass.Object:
                        class_str = 'object'
                        tops.add(name)
                        comp[name] = 'object'
                    else:
                        class_str = 'other'

                    # Add to namespace cache
                    nsc.add_node(
                        path=name,
                        display_name=name,
                        node_class=class_str,
                        node_id_str=node_id_str
                    )

                    # Queue for recursive browsing (top-level objects and any
                    # structured Variables).
                    if class_str in ('object', 'variable'):
                        initial_nodes.append((child, name))

                except:
                    continue

            # Process levels breadth-first with parallel browsing. Stop early
            # once the cap is hit — remaining nodes were diverted to the
            # frontier inside process_level, so there is nothing more to expand.
            current_level = initial_nodes
            level = 0
            while current_level and not cap_hit[0]:
                level += 1
                current_level = await process_level(current_level)

            # Mark cache as populated
            nsc.is_populated = True
            nsc.cache_time = datetime.now()

            elapsed = time.time() - start_time
            partial = cap_hit[0] or bool(front)
            # Only touch the live partial flag for a foreground browse; an
            # 'into' browse fills a separate cache and must not disturb it.
            if into is None:
                self._namespace_partial = partial

            # Remember the timing of a FULL COMPLETE load (no cap, nothing
            # deferred, NO active view) so future browses of this URI can show
            # an ETA. Never record a partial/capped/SCOPED load — a scoped load
            # covers fewer nodes and would poison the full-load estimate.
            if not partial and not node_limit and scope is None:
                uri = (self.url or "").strip()
                if uri:
                    self.load_stats_by_uri[uri] = {
                        "count": len(nsc.nodes_by_path),
                        "seconds": round(elapsed, 1),
                    }
                    self._save_settings()
                    # Persist the full namespace for instant startup next time.
                    self._nscache_save(uri, cache=nsc)

            if show_progress:
                summary = nsc.get_summary()
                if partial:
                    tail = (f"PARTIAL (lazy cap {node_limit}; "
                            f"{len(front)} more loadable on demand)")
                else:
                    tail = "COMPLETE"
                view_name = self._view_for_uri(self.url)
                view_tail = f" [view: {view_name}]" if view_name else ""
                print(f"\r  Cached {summary} in {elapsed:.1f}s — {tail}{view_tail}"
                      + " " * 10)

            return True

        except Exception as e:
            if show_progress:
                print(f"\nError browsing namespace: {e}")
            return False

    async def _nscache_revalidate(self):
        """Background: rebuild the FULL namespace and atomically swap it in.

        Runs after a from-disk cache load so a stale cache self-heals. Builds
        into FRESH, unpublished containers (via the browse's `into=`), so a
        mid-rebuild command still sees the complete from-disk cache. On success,
        publishes the fresh cache in one step and re-persists. Best-effort and
        silent on failure — the from-disk cache stays usable.
        """
        try:
            fresh_ns = NamespaceCache()
            fresh_comp: Dict[str, Any] = {}
            fresh_tops: set = set()
            fresh_front: Dict[str, str] = {}
            ok = await self._browse_namespace_fast(
                show_progress=False, force_full=True,
                into=(fresh_ns, fresh_comp, fresh_tops, fresh_front))
            if not ok or not self.connected:
                return
            changed = (len(fresh_ns.nodes_by_path)
                       != len(self.namespace_cache.nodes_by_path))
            # Atomic publish of the freshly-built full cache.
            self.namespace_cache = fresh_ns
            self.completion_cache = fresh_comp
            self.top_level_nodes = fresh_tops
            self.frontier_nodes = fresh_front
            self._namespace_partial = False
            self._nscache_from_disk = False
            # The browse finaliser already persisted the fresh full cache to
            # SQLite (full, unscoped => canonical), so no extra save here.
            if changed:
                print(f"\n  (namespace refreshed: "
                      f"{len(fresh_ns.nodes_by_path)} nodes)")
        except asyncio.CancelledError:
            raise
        except Exception:
            pass  # keep the from-disk cache

    # ------------------------------------------------------------------
    # Lazy frontier loading (Phase 3): expand deferred subtrees on demand
    # ------------------------------------------------------------------

    def _frontier_roots_for(self, cache_path: str, subtree: bool = False) -> List[str]:
        """Frontier paths relevant to `cache_path`.

        Default (navigation): frontiers that are `cache_path` itself or an
        ANCESTOR of it — the ones that must load to make the target and its
        immediate children real. This deliberately does NOT pull in deferred
        descendants of `cache_path`, so 'ls /system' doesn't drag in every
        deferred sub-subtree.

        subtree=True ('scan'): ALSO include frontiers that live UNDER
        `cache_path`, so 'scan /system' loads everything deferred below it.
        """
        if not self.frontier_nodes:
            return []
        target = (cache_path or "").strip("/")
        roots = []
        for fpath in self.frontier_nodes:
            f = fpath.strip("/")
            is_self_or_ancestor = (target == f or target.startswith(f + "/"))
            is_descendant = bool(target) and f.startswith(target + "/")
            if is_self_or_ancestor or (subtree and (is_descendant or target == "")):
                roots.append(fpath)
        return roots

    async def _load_frontier(self, roots: List[str], show_progress: bool = True) -> int:
        """Browse the given frontier subtree roots into the cache (cap-aware).

        Mirrors the connect-time BFS but seeded from frontier nodes. Honours
        the per-URI lazy limit: if the limit is reached again, newly seen nodes
        become fresh frontier entries (deeper on-demand loading still works).
        Returns the number of nodes added.
        """
        if not roots or not self.connected:
            return 0

        lazy = self._lazy_for_uri(self.url)
        node_limit = lazy["limit"] if lazy["enabled"] else 0
        before = self._loaded_count()
        cap_hit = [False]

        MAX_CONCURRENT = 150
        semaphore = asyncio.Semaphore(MAX_CONCURRENT)

        async def browse_children(node: Node, path: str) -> List[tuple]:
            out = []
            try:
                refs = await node.get_children_descriptions()
                for ref in refs:
                    try:
                        name = ref.DisplayName.Text if ref.DisplayName else str(ref.BrowseName.Name)
                        node_class = ref.NodeClass
                        node_id_str = nodeid_to_string(ref.NodeId)
                        child_node = self.client.get_node(ref.NodeId)
                        path_name = f"{name}()" if node_class == ua.NodeClass.Method else name
                        child_path = f"{path}/{path_name}" if path else path_name
                        out.append((child_node, child_path, name, node_class, node_id_str))
                    except Exception:
                        continue
            except Exception:
                pass
            return out

        async def browse_with_sem(node, path):
            async with semaphore:
                return await browse_children(node, path)

        # Seed the BFS with the frontier roots. Each root's node_id_str is what
        # we stored when it was deferred; resolve it to a Node.
        current = []
        seed_ids = {}  # path -> node_id_str, to cache the root with its real id
        for r in roots:
            nid = self.frontier_nodes.pop(r, None)
            if nid is None:
                continue
            try:
                current.append((self.client.get_node(nid), r))
                seed_ids[r] = nid
            except Exception:
                continue
        # The frontier root itself was recorded but never cached — add it now so
        # it becomes a real, navigable node, carrying its real node id. It had
        # children (that's why it was deferred), so it is an Object or a
        # structured Variable; we label it 'object' so it is cd-able. (The
        # class isn't stored on the frontier; 'object' is the safe navigable
        # default and only affects the type glyph, not correctness.)
        for node, path in current:
            if not self.namespace_cache.path_exists(path):
                leaf = path.rsplit("/", 1)[-1]
                self.namespace_cache.add_node(path=path, display_name=leaf,
                                              node_class='object',
                                              node_id_str=seed_ids.get(path, ''))

        while current and not cap_hit[0]:
            tasks = [browse_with_sem(n, p) for n, p in current]
            results = await asyncio.gather(*tasks)
            nxt = []
            for res in results:
                for child, child_path, name, node_class, node_id_str in res:
                    if node_limit and self._loaded_count() >= node_limit:
                        cap_hit[0] = True
                        self.frontier_nodes[child_path] = node_id_str
                        continue
                    if node_class == ua.NodeClass.Object:
                        class_str = 'object'
                    elif node_class == ua.NodeClass.Variable:
                        class_str = 'variable'
                    elif node_class == ua.NodeClass.Method:
                        class_str = 'method'
                    else:
                        class_str = 'other'
                    self.namespace_cache.add_node(
                        path=child_path, display_name=name,
                        node_class=class_str, node_id_str=node_id_str)
                    completion_path = child_path.replace("/", ".")
                    if completion_path.startswith("Objects."):
                        completion_path = completion_path[8:]
                    self.completion_cache[completion_path] = (
                        ('method', "") if class_str == 'method' else class_str)
                    if class_str in ('object', 'variable'):
                        nxt.append((child, child_path))
            current = nxt

        # Namespace stays PARTIAL if any frontier remains.
        self._namespace_partial = bool(self.frontier_nodes)
        return self._loaded_count() - before

    async def _ensure_loaded(self, cache_path: str) -> bool:
        """If `cache_path` is behind a lazy frontier, load that subtree now.

        Called by navigation commands (cd/ls/find) so a not-yet-loaded target
        becomes available transparently. Returns True if anything was loaded.
        """
        roots = self._frontier_roots_for(cache_path)
        if not roots:
            return False
        added = await self._load_frontier(roots, show_progress=False)
        if added:
            print(f"  (lazily loaded {added} node(s) under "
                  f"{roots[0].rsplit('/', 1)[0] or '/'})")
        return added > 0

    async def cmd_scan(self, args: List[str]):
        """Load a deferred (lazy) subtree into the cache on demand.

        Usage:
          scan <path>          - Browse and cache the subtree at <path>
          scan                 - Load ALL currently deferred frontier subtrees

        With lazy loading, a large namespace is only browsed up to the limit;
        the rest is deferred. 'scan' pulls a specific subtree (or everything
        still deferred) into the cache without turning lazy off.
        """
        if not self.connected:
            print("❌ Not connected. Use 'connect' first.")
            return
        if not self.frontier_nodes:
            print("Nothing deferred — the namespace is fully loaded.")
            return

        if args:
            cache_path = self._to_cache_path(args[0]) or args[0].strip("/")
            roots = self._frontier_roots_for(cache_path, subtree=True)
            if not roots:
                print(f"No deferred nodes under '{args[0]}'.")
                return
        else:
            roots = list(self.frontier_nodes.keys())

        print(f"Scanning {len(roots)} deferred subtree(s)...")
        added = await self._load_frontier(roots, show_progress=True)
        state = ("still PARTIAL, {} deferred".format(len(self.frontier_nodes))
                 if self.frontier_nodes else "COMPLETE")
        print(f"  Loaded {added} node(s) — {state}. "
              f"{self._loaded_count()} nodes total.")

    async def cmd_view(self, args: List[str]):
        """Create, inspect and manage namespace Views (scoping).

        Usage:
          view                       - show the active view for the current URI
          view list                  - list all available views (own + UaExplorer)
          view show [<name>]         - show a view's include/exclude patterns
          view create <name>         - create a new (empty) view in UaShell's views/
          view include <pattern>     - add an include pattern to the active view
          view exclude <pattern>     - add an exclude pattern to the active view
          view rm <name>             - delete one of UaShell's own views

        Apply a view with 'set view <name>' (or 'set view -' to clear). A view
        prunes the namespace browse to the included subtrees. Views are shared
        with UaExplorer; on a name clash UaShell's own view wins.
        """
        sub = args[0].lower() if args else ""

        if not args:
            name = self._view_for_uri(self.url)
            print(f"active view = {name if name else '(none)'}  ({self.url})")
            return

        if sub == "list":
            views = self._list_views()
            if not views:
                print("No views defined.")
                return
            active = self._view_for_uri(self.url)
            for nm in sorted(views):
                src = "own" if self.views_dir in views[nm].parents else "UaExplorer"
                mark = " *" if nm == active else ""
                print(f"  {nm}{mark}   [{src}]")
            return

        if sub == "show":
            name = args[1] if len(args) > 1 else self._view_for_uri(self.url)
            if not name:
                print("No view specified and no active view.")
                return
            scope = self._load_view(name)
            if not scope:
                print(f"View '{name}' not found.")
                return
            print(f"View: {scope.name}")
            if scope.description:
                print(f"  {scope.description}")
            inc = [p.pattern for p in scope.include_patterns if p.enabled]
            exc = [p.pattern for p in scope.exclude_patterns if p.enabled]
            print(f"  include: {inc if inc else '(all)'}")
            print(f"  exclude: {exc if exc else '(none)'}")
            if scope.namespace_uris:
                print(f"  namespace_uris: {scope.namespace_uris} "
                      f"(note: not applied by UaShell)")
            return

        if sub == "create":
            if len(args) < 2:
                print("Usage: view create <name>")
                return
            name = args[1].strip()
            if self._load_view(name):
                print(f"View '{name}' already exists (use 'view show {name}').")
                return
            path = self._save_view(Scope(name=name))
            print(f"✅ Created view '{name}' -> {path}")
            print("ℹ️  Add patterns with 'view include <pat>' / 'view exclude <pat>' "
                  f"after 'set view {name}'.")
            return

        if sub in ("include", "exclude"):
            if len(args) < 2:
                print(f"Usage: view {sub} <pattern>")
                return
            name = self._view_for_uri(self.url)
            if not name:
                print("No active view. Use 'set view <name>' (or 'view create') first.")
                return
            scope = self._load_view(name)
            if not scope:
                print(f"Active view '{name}' not found.")
                return
            pat = args[1].strip()
            target = scope.include_patterns if sub == "include" else scope.exclude_patterns
            if any(p.pattern == pat for p in target):
                print(f"'{pat}' already in {sub} patterns.")
                return
            target.append(ScopePattern(pattern=pat, enabled=True))
            path = self._save_view(scope)
            print(f"✅ Added {sub} '{pat}' to view '{name}'.")
            print(f"ℹ️  'rebrowse' to apply the updated view.")
            return

        if sub == "rm":
            if len(args) < 2:
                print("Usage: view rm <name>")
                return
            name = args[1].strip()
            views = self._list_views()
            path = views.get(name)
            if not path:
                print(f"View '{name}' not found.")
                return
            if self.views_dir not in path.parents:
                print(f"'{name}' is a UaExplorer view — not removed by UaShell.")
                return
            try:
                path.unlink()
                print(f"✅ Removed view '{name}'.")
            except Exception as e:
                print(f"Could not remove view '{name}': {e}")
            return

        print(f"Unknown view subcommand: {sub}")
        print("Use: list, show, create, include, exclude, rm  (or 'help view')")

    async def disconnect(self):
        """Disconnect from the OPC UA server with subscription cleanup"""
        self.reconnect_enabled = False

        # Cancel reconnection task
        if self.reconnect_task:
            self.reconnect_task.cancel()
            try:
                await self.reconnect_task
            except asyncio.CancelledError:
                pass

        # Cancel a running background namespace revalidation
        if self._nscache_revalidate_task:
            self._nscache_revalidate_task.cancel()
            try:
                await self._nscache_revalidate_task
            except asyncio.CancelledError:
                pass
            self._nscache_revalidate_task = None

        # Clean up subscriptions
        if self.subscription_manager:
            try:
                await self.subscription_manager.cleanup()
            except Exception as e:
                print(f"Warning: Error cleaning up subscriptions: {e}")

        # Stop the update pump and close the subscription viewer process
        if self._sub_pump_task is not None:
            self._sub_pump_task.cancel()
            try:
                await self._sub_pump_task
            except asyncio.CancelledError:
                pass
            self._sub_pump_task = None
        if self.sub_viewer is not None:
            try:
                await self.sub_viewer.quit()
            except Exception:
                pass
            self.sub_viewer = None

        # Close the plot window process too
        if self.plot_client is not None:
            try:
                await self.plot_client.quit()
            except Exception:
                pass
            self.plot_client = None

        if self.connected:
            try:
                await self.client.disconnect()
                self.connected = False
                print("Disconnected")
            except Exception as e:
                print(f"Disconnect error: {e}")

    def _display_matches_hook(self, substitution, matches, longest_match_length):
        """Custom display hook to show method signatures when completing methods.

        Organizes output by type: Objects (folders), then Methods, then Variables.
        """
        import readline as _rl

        # Print matches with extra info for methods
        print()  # Move to next line

        # Check if we're completing methods and show signatures
        current_dot = self.current_path.replace("/", ".") if self.current_path else ""
        # Strip "Objects." prefix since cache stores paths without it
        if current_dot.lower().startswith("objects."):
            current_dot = current_dot[8:]

        # Categorize matches by type
        folders = []
        methods = []
        variables = []
        other = []

        for match in matches:
            if match.endswith("/"):
                folders.append(match)
            elif match.endswith("()") or match.endswith("("):
                methods.append(match)
            else:
                # Check cache to determine type
                # Build cache key from match
                match_name = match.rstrip("/()( ")
                if current_dot:
                    cache_key = f"{current_dot}.{match_name}"
                else:
                    cache_key = match_name
                node_info = self.completion_cache.get(cache_key)
                node_type = None
                if isinstance(node_info, tuple):
                    node_type = node_info[0] if node_info else None
                else:
                    node_type = node_info

                if node_type == 'variable':
                    variables.append(match)
                elif node_type == 'object':
                    folders.append(match)
                elif node_type == 'method':
                    methods.append(match)
                else:
                    other.append(match)

        # Sort each category alphabetically
        folders.sort(key=str.lower)
        methods.sort(key=str.lower)
        variables.sort(key=str.lower)
        other.sort(key=str.lower)

        # 'connect' completions: render two aligned columns - the saved name
        # (left, blank when the URI is unnamed) and the URI. The completion
        # value stays the URI; only the display carries the name column.
        hints = getattr(self, "_connect_name_hints", {})
        # Drop the empty sentinel (added to suppress prefix auto-insert).
        conn_matches = [m for m in matches if m != ""]
        if hints and conn_matches and all(m in hints for m in conn_matches):
            name_w = max((len(n) for n in hints.values() if n), default=0)
            for match in sorted(conn_matches, key=str.lower):
                nm = hints.get(match) or ""
                name_col = f"\033[90m{nm.ljust(name_w)}\033[0m" if name_w else ""
                if name_w:
                    print(f"  {name_col}  {match}")
                else:
                    print(f"  {match}")
            print(self.get_prompt() + _rl.get_line_buffer(), end="", flush=True)
            return

        # Per-cycle hint from the completer telling us when ALL
        # candidates share one semantic role. When set, it overrides
        # the per-candidate heuristics below.
        kind_hint = getattr(self, "_completion_kind", None)
        # The completer's 'commands' list is local to that function;
        # it stashes a snapshot on self for us so we can recognise
        # command-name candidates by exact match.
        cmd_name_set = getattr(self, "_completer_command_set", None) or set()

        def _classify(match: str) -> str:
            """Return a colour category for one candidate string.

            Categories: 'folder', 'method', 'variable', 'flag',
            'command', 'subcommand', 'plot_ref', 'sub_node',
            'server_name', 'uri', 'other'. _colour_completion()
            turns the category into an ANSI code via the
            _COMPLETION_PALETTE dict.
            """
            # Honour the completer's explicit per-cycle hint first.
            if kind_hint == "command":
                return "command"
            if kind_hint == "subcommand":
                return "subcommand"
            if kind_hint == "flag":
                return "flag"
            if kind_hint == "plot_ref":
                return "plot_ref"
            if kind_hint == "sub_node":
                return "sub_node"

            # Heuristics from the candidate string.
            stripped = match.rstrip()
            if stripped.startswith("-"):
                return "flag"
            if stripped.endswith("/"):
                return "folder"
            if stripped.endswith("(") or stripped.endswith("()"):
                return "method"
            # Top-level: a candidate that exactly matches a known
            # command (possibly with trailing space from cmd
            # completions) is a command.
            if stripped in cmd_name_set:
                return "command"
            # Otherwise look up the namespace cache as before.
            match_name = stripped.rstrip("/()( ")
            if current_dot:
                cache_key = f"{current_dot}.{match_name}"
            else:
                cache_key = match_name
            node_info = self.completion_cache.get(cache_key)
            node_type = node_info[0] if isinstance(node_info, tuple) else node_info
            if node_type == "variable":
                return "variable"
            if node_type == "object":
                return "folder"
            if node_type == "method":
                return "method"
            return "other"

        # Print in order: folders, methods, variables, other.
        for match in folders + methods + variables + other:
            extra_info = ""
            # Check if this is a method with arguments
            if match.endswith("("):
                # Method with input arguments - look up signature
                method_name = match[:-1]  # Remove trailing "("
                if current_dot:
                    cache_key = f"{current_dot}.{method_name}"
                else:
                    cache_key = method_name
                node_info = self.completion_cache.get(cache_key)
                if isinstance(node_info, tuple) and len(node_info) >= 2 and node_info[1]:
                    extra_info = f"  \033[90m← {node_info[1]}\033[0m"  # Gray signature hint

            category = _classify(match)
            coloured = self._colour_completion(match, category)
            print(f"  {coloured}{extra_info}")

        # Redisplay prompt and current input
        print(self.get_prompt() + _rl.get_line_buffer(), end="", flush=True)

    def _completer(self, text, state):
        """Tab-completion for commands and simple arguments.

        Examples:
          fi<Tab>            -> find
          ls Obje<Tab>       -> ls Objects/
          Init(); Ena<Tab>   -> Init(); Enable()
        """
        if not READLINE_AVAILABLE:
            return None

        import readline as _rl  # local alias to avoid confusion
        buffer = _rl.get_line_buffer()
        line = buffer.lstrip()
        stripped = buffer[:_rl.get_endidx()]

        # Handle multiple commands separated by ;
        # Complete only the current segment (after last ;)
        if ";" in stripped:
            last_semi = stripped.rfind(";")
            prefix_before_semi = stripped[:last_semi + 1]  # Keep the "Init(); " part
            current_segment = stripped[last_semi + 1:].lstrip()
            # Adjust text for the current segment
            segment_text = text
        else:
            prefix_before_semi = ""
            current_segment = stripped
            segment_text = text

        # Known commands
        commands = [
            "help",
            "about",
            "tree",
            "find",
            "ls",
            "ll",
            "cd",
            "pwd",
            "read",
            "write",
            "call",
            "subscribe",
            "plot",
            "info",
            "connect",
            "disconnect",
            "rebrowse",
            "export",
            "history",
            "h",
            "script",
            "run",
            "set",
            "settings",
            "scan",
            "view",
            "exit",
            "quit",
        ]
        # Snapshot the command set so _display_matches_hook (called
        # from readline after we return) can recognise command-name
        # candidates and colour them appropriately.
        self._completer_command_set = set(commands)

        def get_node_type(node_info) -> str:
            """Extract node type from cache entry (can be string or tuple for methods)"""
            if isinstance(node_info, tuple) and len(node_info) >= 1:
                return node_info[0]
            return node_info

        # For tree: filter to show only direct children at the appropriate level
        def get_tree_completions(prefix: str, node_filter: str = None) -> List[str]:
            """Get completions showing only direct children of the given prefix.

            Args:
                prefix: The path prefix to find children for
                node_filter: Filter by node type - 'object', 'variable', 'method', or None for all
            """
            if not self.completion_cache:
                # Fallback to standard OPC UA nodes if cache not populated
                return list(self.top_level_nodes) if self.top_level_nodes else ["Objects", "Types", "Views", "Server"]

            prefix_lower = prefix.lower().rstrip('.')  # Remove trailing dot

            # Use dynamically discovered top-level nodes
            top_level_lower = {n.lower() for n in self.top_level_nodes}

            # Special handling for top-level nodes
            # Their children are stored without the top-level prefix in the cache
            if prefix_lower in top_level_lower:
                # For Objects, children are stored as "system", "Aliases", etc.
                # Return all depth-0 items (direct children) EXCLUDING top-level nodes
                matches = []
                for path, ntype in self.completion_cache.items():
                    # Return items with no dots (depth 0 = direct children of Objects)
                    # but exclude the top-level nodes themselves
                    if "." not in path and path.lower() not in top_level_lower:
                        if node_filter is None or get_node_type(ntype) == node_filter:
                            matches.append(path)
                return sorted(set(matches))

            prefix_depth = prefix_lower.count('.') if prefix_lower else -1

            # Use dynamically discovered top-level nodes
            root_top_level = {n.lower() for n in self.top_level_nodes} if self.top_level_nodes else {"objects", "types", "views", "server"}

            matches = []
            for path, ntype in self.completion_cache.items():
                # Apply node type filter
                if node_filter is not None and get_node_type(ntype) != node_filter:
                    continue

                path_lower = path.lower()
                path_depth = path.count('.')

                if not prefix_lower:
                    # No prefix: show only ROOT top-level items (Objects, Types, Views, Server)
                    # NOT the children of Objects that are also at depth 0
                    if path_depth == 0 and path_lower in root_top_level:
                        matches.append(path)
                elif path_lower.startswith(prefix_lower + '.') or path_lower == prefix_lower:
                    # Show only direct children (one level deeper than prefix)
                    if path_depth == prefix_depth + 1:
                        matches.append(path)
                    # Also include exact match
                    elif path_lower == prefix_lower:
                        matches.append(path)
                elif path_lower.startswith(prefix_lower):
                    # Partial match at same level
                    if path_depth == prefix_depth:
                        matches.append(path)

            return sorted(set(matches))

        def get_relative_completions(text: str, full_arg: str, current_path: str, node_filter: str = None, add_suffix: bool = False) -> List[str]:
            """Get completions for path-based commands.

            Args:
                text: The word being completed (after last / delimiter)
                full_arg: The full argument typed so far (e.g., "Objects/sys")
                current_path: The current working path in the shell
                node_filter: Filter by node type - 'object', 'variable', 'method', or None for all
                add_suffix: If True, add '/' for objects, '()' or '(' for methods

            Returns just the child names (Unix-style), optionally with suffixes.
            """
            # Convert current path from / to . notation
            current_dot = current_path.replace("/", ".") if current_path else ""

            # Handle absolute paths starting with /
            # Strip leading / and treat as absolute path from root
            if full_arg.startswith("/"):
                full_arg = full_arg[1:]  # Remove leading /
                current_dot = ""  # Ignore current_path for absolute paths

            # Use dynamically discovered top-level nodes
            top_level_lower = {n.lower() for n in self.top_level_nodes} if self.top_level_nodes else {"objects", "types", "views", "server"}

            # Determine the parent path from full_arg
            # e.g., "Objects/" -> parent is "Objects", text is ""
            # e.g., "Objects/sys" -> parent is "Objects", text is "sys"
            if "/" in full_arg:
                # There's a path - find the parent
                last_slash = full_arg.rfind("/")
                parent_path = full_arg[:last_slash]  # e.g., "Objects" or "Objects/system"
                partial = full_arg[last_slash + 1:]   # e.g., "" or "sys"
            else:
                # No slash - completing at current level
                parent_path = ""
                partial = text

            # Handle .. in parent_path - resolve it relative to current_path
            def resolve_path_with_dotdot(base_path: str, rel_path: str) -> str:
                """Resolve a relative path that may contain .. against a base path."""
                if not rel_path:
                    return base_path

                # Start with base path components
                if base_path:
                    components = base_path.replace("/", ".").split(".")
                else:
                    components = []

                # Process relative path - split by / first to handle .. properly
                # rel_path could be ".." or "../.." or "../foo" etc.
                for part in rel_path.split("/"):
                    if part == "..":
                        if components:
                            components.pop()
                    elif part == ".":
                        pass  # Current dir, ignore
                    elif part:
                        components.append(part)

                return ".".join(components)

            # Check if parent_path contains ..
            if ".." in parent_path:
                # Resolve .. relative to current_path
                resolved = resolve_path_with_dotdot(current_dot, parent_path)
                parent_normalized = resolved
                # Don't use current_dot again since we've already resolved against it
                current_dot = ""
            else:
                # Normalize parent path to dot notation for cache lookup
                parent_normalized = parent_path.replace("/", ".") if parent_path else ""

            # Build the full parent path (considering current_path)
            if parent_normalized:
                # Check if it's an absolute path (starts with a known top-level node)
                first_component = parent_normalized.split(".")[0].lower()
                if first_component in top_level_lower:
                    # Absolute path
                    full_parent = parent_normalized
                elif current_dot:
                    # Relative to current path
                    full_parent = f"{current_dot}.{parent_normalized}"
                else:
                    full_parent = parent_normalized
            else:
                full_parent = current_dot

            # For cache lookups, handle top-level nodes specially
            # and strip "Objects." prefix since cache stores relative paths
            cache_parent = full_parent
            is_top_level_parent = full_parent.lower() in top_level_lower
            if cache_parent.lower().startswith("objects."):
                cache_parent = cache_parent[8:]  # Remove "Objects."

            # Get children from cache (with node type filter)
            if is_top_level_parent:
                # Looking for children of Objects/Types/Views/Server
                children = get_tree_completions(full_parent, node_filter)
            elif cache_parent:
                children = get_tree_completions(cache_parent, node_filter)
            elif not full_parent:
                # At root - show top-level nodes (always objects)
                children = get_tree_completions("", node_filter)
            else:
                children = []

            # Filter and return child names
            result = []
            partial_lower = partial.lower() if partial else ""

            def add_node_suffix(name: str, cache_key: str) -> str:
                """Add appropriate suffix based on node type."""
                if not add_suffix:
                    return name
                # Don't add suffix if name already has one (methods stored with () in path)
                if name.endswith("()") or name.endswith("("):
                    return name
                if name.endswith("/"):
                    return name
                node_info = self.completion_cache.get(cache_key)
                if isinstance(node_info, tuple) and len(node_info) >= 2 and node_info[0] == 'method':
                    return name + "(" if node_info[1] else name + "()"
                elif node_info == 'method':
                    return name + "()"
                elif node_info == 'object':
                    return name + "/"
                return name

            if is_top_level_parent:
                # Children are stored without prefix (e.g., "system", "Aliases")
                for child in children:
                    if "." not in child:  # Direct child only
                        if not partial_lower or child.lower().startswith(partial_lower):
                            result.append(add_node_suffix(child, child))
            elif not full_parent:
                # At root - return top-level nodes filtered by partial
                for child in children:
                    if not partial_lower or child.lower().startswith(partial_lower):
                        result.append(add_node_suffix(child, child))
            else:
                # Children are stored as "parent.child" - extract child name
                for child in children:
                    if child.lower().startswith(cache_parent.lower() + "."):
                        name = child[len(cache_parent) + 1:]
                        if "." not in name:  # Direct child only
                            if not partial_lower or name.lower().startswith(partial_lower):
                                result.append(add_node_suffix(name, child))
                    # Note: Don't include depth-0 items here - they are siblings/parent, not children

            return sorted(set(result))

        def get_methods_in_current_dir() -> List[str]:
            """Get method names in the current directory for direct execution completion"""
            if not self.current_path or not self.completion_cache:
                return []

            current_dot = self.current_path.replace("/", ".")
            # Strip "Objects." prefix since cache stores paths without it
            if current_dot.lower().startswith("objects."):
                current_dot = current_dot[8:]

            methods = []

            # Look for methods in cache under current path
            for path, node_info in self.completion_cache.items():
                # Check for method (can be tuple with signature or just 'method' string)
                is_method = False
                input_sig = ''
                if isinstance(node_info, tuple) and len(node_info) >= 2 and node_info[0] == 'method':
                    is_method = True
                    input_sig = node_info[1]
                elif node_info == 'method':
                    is_method = True

                if is_method:
                    # Check if this method is directly under current_path
                    if path.startswith(current_dot + "."):
                        name = path[len(current_dot) + 1:]
                        if "." not in name:  # Direct child only
                            # Don't add suffix if name already has one (from browse disambiguation)
                            if name.endswith("()") or name.endswith("("):
                                methods.append(name)
                            elif input_sig:
                                # Method has input args - complete to "Name(" and show signature
                                methods.append(name + "(")
                            else:
                                # No input args - complete to "Name()"
                                methods.append(name + "()")

            return sorted(methods)

        def get_method_arg_completion(line: str, current_text: str) -> tuple:
            """Check if we're inside a method call and provide argument completion.

            Args:
                line: The full line buffer
                current_text: The word currently being completed (will be replaced by completion)

            Returns: (should_handle, options, help_text) where:
                - should_handle: True if this is a method call context
                - options: list of completion options
                - help_text: signature help to display (or None)
            """
            # Check if line contains "(" but not ")" - we're inside a method call
            if "(" not in line or ")" in line:
                return (False, [], None)

            # Parse method name and current arguments
            paren_idx = line.index("(")
            method_path = line[:paren_idx].strip()
            args_part = line[paren_idx + 1:]  # Everything after "("

            # Count COMPLETED arguments (not including the one being typed)
            # "100" -> typing 1st arg, completed = 0
            # "100," -> 1st complete, typing 2nd, completed = 1
            # "100, " -> 1st complete, typing 2nd (empty), completed = 1
            # "100, 5" -> 1st complete, typing 2nd, completed = 1
            # "100, 5," -> 2 complete, typing 3rd, completed = 2
            if not args_part.strip():
                # Nothing after "(" - typing first arg
                completed_arg_count = 0
            else:
                # Count commas to see completed args
                # The argument after the last comma is the one being typed
                completed_arg_count = args_part.count(",")

            # Look up the method signature
            # Handle both absolute paths (/Objects/...) and relative paths
            if method_path.startswith("/"):
                # Absolute path - convert to cache key format
                # /Objects/system/subsys1/fcs/motor1/MoveAbs -> system.subsys1.fcs.motor1.MoveAbs
                abs_path = method_path[1:]  # Remove leading /
                if abs_path.lower().startswith("objects/"):
                    abs_path = abs_path[8:]  # Remove "Objects/"
                cache_key = abs_path.replace("/", ".")
            elif "/" in method_path:
                # Relative path with slashes
                current_dot = self.current_path.replace("/", ".") if self.current_path else ""
                if current_dot.lower().startswith("objects."):
                    current_dot = current_dot[8:]
                rel_path = method_path.replace("/", ".")
                if current_dot:
                    cache_key = f"{current_dot}.{rel_path}"
                else:
                    cache_key = rel_path
            else:
                # Simple method name - relative to current path
                current_dot = self.current_path.replace("/", ".") if self.current_path else ""
                if current_dot.lower().startswith("objects."):
                    current_dot = current_dot[8:]

                if current_dot:
                    cache_key = f"{current_dot}.{method_path}"
                else:
                    cache_key = method_path

            node_info = self.completion_cache.get(cache_key)
            # Also check for cache key with () suffix (methods are stored with () for disambiguation)
            if node_info is None:
                node_info = self.completion_cache.get(cache_key + "()")
            if not isinstance(node_info, tuple) or len(node_info) < 2:
                # Not a method with known signature - but we're still inside parens
                # Return empty options to prevent path completion from taking over
                if "(" in line and ")" not in line:
                    return (True, [")"], None)  # Just offer to close the parens
                return (False, [], None)

            input_sig = node_info[1]
            if not input_sig:
                # Method has no input args - just close it
                # Preserve current_text and append )
                return (True, [current_text + ")"], None)

            # Parse the signature to get argument list
            # input_sig is like "intensity:Int32" or "position:Double, velocity:Double"
            arg_specs = [a.strip() for a in input_sig.split(",")]
            total_args = len(arg_specs)

            # Current position: completed_arg_count args done, typing the next one
            current_typing_arg = completed_arg_count  # 0-indexed arg being typed

            if completed_arg_count >= total_args:
                # All arguments provided - close with )
                # Preserve current_text and append )
                return (True, [current_text + ")"], None)
            elif current_typing_arg == 0 and not current_text.strip():
                # At the start - show full signature
                return (True, [], f"Args: {input_sig}")
            elif current_typing_arg == total_args - 1:
                # Typing the last argument - next completion should close with )
                # Only close if user has typed something for this arg
                if current_text.strip():
                    return (True, [current_text + ")"], None)
                else:
                    # Still need to type the last arg - show help with remaining
                    remaining = ", ".join(arg_specs[current_typing_arg:])
                    return (True, [], f"Arg {current_typing_arg + 1}/{total_args}: {remaining}")
            else:
                # More arguments needed after current
                if current_text.strip():
                    # User typed something - add comma for next arg
                    remaining = ", ".join(arg_specs[current_typing_arg + 1:])
                    return (True, [current_text + ", "], f"Arg {current_typing_arg + 2}/{total_args}: {remaining}")
                else:
                    # Current arg position is empty - show remaining args
                    remaining = ", ".join(arg_specs[current_typing_arg:])
                    return (True, [], f"Arg {current_typing_arg + 1}/{total_args}: {remaining}")

        # Check if we're inside a method call first
        in_method_call, method_options, method_help = get_method_arg_completion(current_segment, text)
        if in_method_call:
            # Only show help when there's no completion to return
            # (otherwise the print messes up readline's display)
            if state == 0 and method_help and not method_options:
                import readline as _rl
                print(f"\n  \033[90m{method_help}\033[0m")
                print(self.get_prompt() + _rl.get_line_buffer(), end="", flush=True)
            try:
                return method_options[state]
            except IndexError:
                return None

        # Check if line ends with complete method call () - no further completion needed
        if current_segment.rstrip().endswith("()"):
            return None

        # Split current segment into tokens
        parts = current_segment.split()

        # Check if we're starting a new token (trailing space)
        completing_new_arg = current_segment.endswith(" ") or not text

        # Check if we're typing an absolute path (starts with /) - treat as path completion
        is_absolute_path = current_segment.lstrip().startswith("/")

        # Reset the per-cycle semantic hint; we set it below when a
        # branch produces a homogeneous semantic group. None == let
        # the display hook fall back to per-candidate heuristics.
        self._completion_kind = None

        # No tokens yet: complete command names + methods in current directory
        if not parts:
            method_completions = get_methods_in_current_dir()
            cmd_completions = [cmd + " " for cmd in commands if cmd.startswith(text)]  # Add space after commands
            # Filter methods by text if provided
            if text:
                method_completions = [m for m in method_completions if m.lower().startswith(text.lower())]
            options = cmd_completions + method_completions
            # If the user has typed at least one character, the matches
            # are exclusively command names (method names start at any
            # letter too, but with no text typed we show both). We don't
            # set the kind here to avoid mis-colouring methods cyan.
        elif is_absolute_path:
            # Absolute path without command prefix - treat as path completion
            # Strip leading / and use it as the full_arg
            full_arg = current_segment.lstrip()[1:]  # Remove leading /
            # Get completions with suffixes (/ for objects, () for methods)
            options = get_relative_completions(text, full_arg, "", None, add_suffix=True)
        elif len(parts) == 1 and not completing_new_arg:
            # Still completing the command itself - also include methods
            method_completions = get_methods_in_current_dir()
            cmd_completions = [cmd + " " for cmd in commands if cmd.startswith(text)]  # Add space after commands
            # Filter methods by text
            method_completions = [m for m in method_completions if m.lower().startswith(text.lower())]
            options = cmd_completions + method_completions
        else:
            # Completing arguments based on command
            cmd = parts[0].lower()
            if cmd in ("tree", "cd", "ls", "ll", "read", "write", "subscribe", "call", "info", "find", "plot"):
                # For path-based commands, we need to check if user typed a path
                # Since / is a delimiter, after "cd Objects/" text will be empty
                # We need to look at buffer to find the path context

                # Find the argument being completed by looking at buffer
                # Buffer might be "cd Objects/" or "cd Objects/sys"
                arg_start = len(parts[0]) + 1  # After command and space
                if arg_start < len(current_segment):
                    full_arg = current_segment[arg_start:].lstrip()
                    # Remove -l flag from full_arg if present
                    if full_arg.startswith("-l "):
                        full_arg = full_arg[3:].lstrip()
                    elif full_arg == "-l":
                        full_arg = ""
                else:
                    full_arg = ""

                # Now full_arg is something like "Objects/" or "Objects/sys" or ""

                # Determine node type filter based on command
                if cmd == "cd":
                    node_filter = "object"  # cd only works with folders
                elif cmd in ("read", "write", "subscribe"):
                    node_filter = "variable"  # Only show variables
                elif cmd == "call":
                    node_filter = "method"  # Only show methods
                elif cmd == "info":
                    node_filter = None  # Show all - info works on any node
                else:
                    node_filter = None  # ls, ll, tree - show everything

                if cmd == "tree":
                    has_L = "-L" in parts
                    # For tree, we need to handle "-L N" arguments specially
                    # Check if we're right after -L (need to complete the number)
                    if len(parts) >= 2 and parts[-1] == "-L":
                        # User typed "tree -L" - no completions (they need to type a number)
                        options = []
                    elif text.startswith("-") and not has_L:
                        options = ["-L"]
                    else:
                        # Strip -L and its argument from full_arg for path completion
                        path_arg = full_arg
                        if "-L " in path_arg:
                            # Remove "-L N " from path_arg
                            import re
                            path_arg = re.sub(r'-L\s+\d+\s*', '', path_arg).strip()
                        elif path_arg == "-L":
                            path_arg = ""

                        if not path_arg and not has_L:
                            options = ["-L"] + get_relative_completions("", "", self.current_path, node_filter, add_suffix=True)
                        else:
                            options = get_relative_completions(text, path_arg, self.current_path, node_filter, add_suffix=True)
                elif cmd == "cd":
                    if not full_arg:
                        options = ["..", "-"] + get_relative_completions("", "", self.current_path, node_filter, add_suffix=True)
                    elif text == ".":
                        options = [".."]
                    elif text == "-":
                        options = ["-"]
                    else:
                        options = get_relative_completions(text, full_arg, self.current_path, node_filter, add_suffix=True)
                elif cmd == "ls":
                    # ls supports -l flag
                    has_l = "-l" in parts
                    if text.startswith("-") and not has_l:
                        options = ["-l"]
                    elif not full_arg and not has_l:
                        options = ["-l"] + get_relative_completions("", "", self.current_path, node_filter, add_suffix=True)
                    else:
                        options = get_relative_completions(text, full_arg, self.current_path, node_filter, add_suffix=True)
                elif cmd == "write":
                    # write supports both "write node value" and "write node=value"
                    # If full_arg contains "=", user is typing value - no completion
                    if "=" in full_arg:
                        options = []
                    else:
                        options = get_relative_completions(text, full_arg, self.current_path, node_filter, add_suffix=True)
                elif cmd == "subscribe":
                    # 'subscribe [path]'  : subcommands + variable paths
                    # 'subscribe list'    : no further args
                    # 'subscribe rm <X>'  : completes from CURRENTLY-
                    #                       SUBSCRIBED nodes only.
                    sub_cmds = ["list", "rm"]
                    first_arg = parts[1] if len(parts) >= 2 else ""
                    completing_first = (
                        (len(parts) == 1 and completing_new_arg) or
                        (len(parts) == 2 and not completing_new_arg)
                    )
                    completing_second_of_rm = (
                        first_arg.lower() == "rm" and
                        ((len(parts) == 2 and completing_new_arg) or
                         (len(parts) >= 3))
                    )
                    if completing_first:
                        # Subcommands first, then variables AND folders
                        # so the user can navigate down to a variable.
                        # node_filter is 'variable' (set above) — that
                        # would hide folders and leave the user stuck
                        # at the root with nothing to descend into.
                        sub_opts = [s for s in sub_cmds
                                    if s.startswith(text.lower())]
                        var_opts = get_relative_completions(
                            text, full_arg, self.current_path,
                            "variable", add_suffix=True)
                        folder_opts = get_relative_completions(
                            text, full_arg, self.current_path,
                            "object", add_suffix=True)
                        options = sub_opts + folder_opts + var_opts
                    elif completing_second_of_rm and self.subscription_manager:
                        # Complete from the live subscription set.
                        subs = []
                        try:
                            subs = list(
                                self.subscription_manager.subscribed_nodes.keys())
                        except Exception:
                            subs = []
                        options = [s for s in subs
                                   if s.lower().startswith(text.lower())]
                        self._completion_kind = "sub_node"
                    else:
                        options = get_relative_completions(
                            text, full_arg, self.current_path,
                            node_filter, add_suffix=True)
                elif cmd == "plot":
                    # 'plot [name] [path...]' : subcommands + paths
                    # 'plot list'             : no further args
                    # 'plot rm <ref>'         : completes from CACHED
                    #                           plot refs only.
                    plot_cmds = ["list", "rm"]
                    first_arg = parts[1] if len(parts) >= 2 else ""
                    completing_first = (
                        (len(parts) == 1 and completing_new_arg) or
                        (len(parts) == 2 and not completing_new_arg)
                    )
                    completing_second_of_rm = (
                        first_arg.lower() == "rm" and
                        ((len(parts) == 2 and completing_new_arg) or
                         (len(parts) >= 3))
                    )
                    if completing_first:
                        sub_opts = [s for s in plot_cmds
                                    if s.startswith(text.lower())]
                        path_opts = get_relative_completions(
                            text, full_arg, self.current_path,
                            node_filter, add_suffix=True)
                        options = sub_opts + path_opts
                    elif completing_second_of_rm:
                        # Use cached refs from the last 'plot list' /
                        # 'plot rm' call. Mild staleness is acceptable —
                        # the completer is synchronous and we cannot
                        # call plot_details() from here without blocking
                        # the readline loop.
                        options = [r for r in self._plot_refs_cache
                                   if r.lower().startswith(text.lower())]
                        self._completion_kind = "plot_ref"
                    else:
                        options = get_relative_completions(
                            text, full_arg, self.current_path,
                            node_filter, add_suffix=True)
                elif cmd == "find":
                    # 'find [path] -name <pat> -type <t> -maxdepth <n>'
                    # The natural input order is path first, then flags
                    # (e.g. 'find Objects/system/ -type m'), which the
                    # default path-completion branch handles correctly.
                    # We only add two narrow special cases on top:
                    #   - prev token == '-type' -> complete o/v/m
                    #   - current token starts with '-' -> complete flag
                    find_flags = [
                        "-name", "-iname", "-path", "-ipath",
                        "-type", "-maxdepth",
                    ]
                    find_types = ["object", "variable", "method",
                                  "o", "v", "m"]
                    prev_token = parts[-2] if (len(parts) >= 2 and
                                               not completing_new_arg) \
                                            else (parts[-1] if completing_new_arg
                                                  else "")
                    typed = current_segment[len(parts[0]):].lstrip()
                    if prev_token == "-type":
                        options = [t for t in find_types
                                   if t.startswith(text.lower())]
                        self._completion_kind = "subcommand"  # enum-like value
                    elif typed.endswith("-") or text.startswith("-"):
                        # readline splits on '-' so when the user has
                        # typed '-' text is empty and the dash sits in
                        # current_segment. Strip the typed dashes from
                        # each candidate, same trick as 'export'.
                        last_tok = typed.split()[-1] if typed.split() else ""
                        if last_tok.startswith("-"):
                            dash_frag = last_tok
                        else:
                            dash_frag = ""
                        options = []
                        for f in find_flags:
                            if f.startswith(dash_frag or "-"):
                                options.append(f[len(dash_frag):])
                        self._completion_kind = "flag"
                    else:
                        options = get_relative_completions(
                            text, full_arg, self.current_path,
                            node_filter, add_suffix=True)
                else:  # ll, read, call, info
                    options = get_relative_completions(text, full_arg, self.current_path, node_filter, add_suffix=True)
            elif cmd == "set":
                # 'set '             : Tab right after the space; parts is
                #                      still ["set"] because split drops
                #                      trailing whitespace. Offer the
                #                      option names.
                # 'set <X>'          : option name (color, name, lazy, view)
                # 'set color <X>'    : on, off
                # 'set lazy <X>'     : on, off, limit
                # 'set name <X>'     : no completion (free-form alias)
                # 'set view <X>'     : no completion (free-form view name)
                set_opts = ["color", "name", "lazy", "view", "nscache"]
                if len(parts) == 1 and completing_new_arg:
                    options = list(set_opts)
                    self._completion_kind = "subcommand"
                elif len(parts) == 2 and not completing_new_arg:
                    options = [o for o in set_opts
                               if o.startswith(text.lower())]
                    self._completion_kind = "subcommand"
                elif len(parts) >= 2 and parts[1].lower() in ("color", "nscache"):
                    onoff = ["on", "off"]
                    options = [v for v in onoff
                               if v.startswith(text.lower())]
                    self._completion_kind = "subcommand"  # enum-like
                elif len(parts) >= 2 and parts[1].lower() == "lazy":
                    lazy_vals = ["on", "off", "limit"]
                    options = [v for v in lazy_vals
                               if v.startswith(text.lower())]
                    self._completion_kind = "subcommand"  # enum-like
                elif len(parts) >= 2 and parts[1].lower() == "view":
                    # 'set view <X>' : complete existing view names (+ '-').
                    names = ["-"] + sorted(self._list_views().keys())
                    options = [v for v in names
                               if v.startswith(text)]
                    self._completion_kind = "subcommand"
                else:
                    options = []
            elif cmd == "view":
                # 'view '            : sub-verb (list/show/create/include/...)
                # 'view show <X>'    : existing view name
                # 'view rm <X>'      : existing view name
                view_verbs = ["list", "show", "create",
                              "include", "exclude", "rm"]
                if len(parts) == 1 and completing_new_arg:
                    options = list(view_verbs)
                    self._completion_kind = "subcommand"
                elif len(parts) == 2 and not completing_new_arg:
                    options = [v for v in view_verbs
                               if v.startswith(text.lower())]
                    self._completion_kind = "subcommand"
                elif len(parts) >= 2 and parts[1].lower() in ("show", "rm"):
                    options = [n for n in sorted(self._list_views().keys())
                               if n.startswith(text)]
                    self._completion_kind = "subcommand"
                else:
                    options = []
            elif cmd == "help":
                # 'help <X>' : complete command names. We reuse the
                # 'commands' list defined above so it stays in sync.
                options = [c for c in commands
                           if c.startswith(text.lower())]
                self._completion_kind = "command"
            elif cmd == "export":
                # Complete export flags. readline's default delimiters include
                # '-', so when the user has typed "--" the `text` fragment is
                # empty and the cursor sits after the dashes. We must return the
                # part of the flag AFTER what is already on the line (the dashes
                # in current_segment), not the whole "--flag" - otherwise repeated
                # tabs accrete dashes ("--------"). Compute the typed flag token
                # from the segment and strip it from each candidate.
                export_flags = [
                    "--full", "--quick", "--txt", "--csv", "--json",
                    "--yaml", "--xml", "--path",
                ]
                # The flag fragment already on the line (e.g. "", "-", "--", "--x").
                typed = current_segment[len(parts[0]):].lstrip()
                typed_l = typed.lower()
                options = []
                for f in export_flags:
                    if f.startswith(typed_l):
                        # Return only the suffix beyond what is already typed,
                        # so readline appends rather than re-inserts the prefix.
                        options.append(f[len(typed):])
                self._completion_kind = "flag"
            elif cmd == "run":
                # Complete script names
                script_names = self._list_scripts()
                options = [s for s in script_names if s.lower().startswith(text.lower())]
            elif cmd == "script":
                # First arg: subcommands + script names (back-compat). Later
                # args: script names (for cat/edit/rm/mv/run targets).
                script_subcmds = ["list", "cat", "edit", "rm", "mv", "run", "save"]
                script_names = self._list_scripts()
                # Are we completing the first arg or a later one?
                if len(parts) <= 1 or (len(parts) == 2 and not completing_new_arg):
                    candidates = script_subcmds + script_names
                else:
                    candidates = script_names
                options = [s for s in candidates if s.lower().startswith(text.lower())]
            elif cmd == "connect":
                # Complete from URI history (+ saved names).
                # Use full_arg (the complete text after "connect ") instead of
                # text because text is broken by delimiters like : and /
                arg_start = len(parts[0]) + 1  # After "connect "
                if arg_start < len(current_segment):
                    uri_text = current_segment[arg_start:].lstrip()
                else:
                    uri_text = ""

                self._connect_name_hints = {}
                looks_like_url = (":" in uri_text or "/" in uri_text
                                  or uri_text.lower().startswith("opc"))

                # Case 1: the user is typing a NAME (bare word, no URL chars)
                # AND it matches a saved name. Complete to the saved NAME itself
                # (inserted verbatim; the connect command resolves a name to its
                # URI). This makes 'connect My<Tab>' -> 'connect MyLocalTstSrv'.
                name_opts = []
                if uri_text and not looks_like_url:
                    name_opts = sorted(
                        (n for n in self.server_names.values()
                         if n.lower().startswith(text.lower())),
                        key=str.lower)

                if name_opts:
                    options = name_opts
                else:
                    # Case 2: completing a URI (or nothing typed / bare word
                    # that matches no name - still try host-prefix URI matches).
                    matches = get_uri_completions(uri_text, self.uri_history)

                    # readline replaces `text` with our completion, so return
                    # the part of the URI after the prefix preceding `text`.
                    if text and uri_text.lower().endswith(text.lower()):
                        prefix = uri_text[:-len(text)]
                    elif not text:
                        prefix = uri_text
                    else:
                        prefix = ""

                    options = []
                    prefix_lower = prefix.lower()
                    for uri in matches:
                        suffix = uri[len(prefix):] if (prefix and uri.lower().startswith(prefix_lower)) else uri
                        options.append(suffix)
                        self._connect_name_hints[suffix] = self._name_for_uri(uri)
                    # On a bare 'connect <Tab>' (nothing typed) all candidates
                    # share the 'opc.tcp://' prefix, which readline would auto-
                    # insert. We only want to SHOW the list, not modify input.
                    # An empty sentinel makes the common prefix empty (line
                    # untouched); the display hook drops it.
                    if not uri_text and len(options) > 1:
                        options.append("")
            else:
                options = []

        # Check if we should print argument help for a method with input args
        # Do this only on first completion attempt (state==0) and when there's exactly one match
        if state == 0 and len(options) == 1 and options[0].endswith("(") and not options[0].endswith("()"):
            # Single method with input arguments - print signature help
            method_name = options[0][:-1]  # Remove trailing "("

            # Build cache key - handle absolute paths differently
            if is_absolute_path:
                # For absolute paths, we already computed cache_parent above
                full_arg = current_segment.lstrip()[1:]  # Remove leading /
                if "/" in full_arg:
                    last_slash = full_arg.rfind("/")
                    parent_path = full_arg[:last_slash]
                else:
                    parent_path = ""

                if parent_path.lower().startswith("objects/"):
                    cache_parent = parent_path[8:].replace("/", ".")
                elif parent_path.lower() == "objects":
                    cache_parent = ""
                else:
                    cache_parent = parent_path.replace("/", ".")

                if cache_parent:
                    cache_key = f"{cache_parent}.{method_name}"
                else:
                    cache_key = method_name
            else:
                # Relative path - use current_path
                current_dot = self.current_path.replace("/", ".") if self.current_path else ""
                if current_dot.lower().startswith("objects."):
                    current_dot = current_dot[8:]
                if current_dot:
                    cache_key = f"{current_dot}.{method_name}"
                else:
                    cache_key = method_name

            node_info = self.completion_cache.get(cache_key)
            if isinstance(node_info, tuple) and len(node_info) >= 2 and node_info[1]:
                import readline as _rl
                # Print help - readline will handle the rest
                print(f"\n  \033[90mArgs: {node_info[1]}\033[0m")
                # Redisplay current buffer (readline will add the completion)
                print(self.get_prompt() + _rl.get_line_buffer(), end="", flush=True)

        # When there is exactly one completion and it is a "final" token,
        # append a space so the user can immediately type the next word
        # (bash-like behavior). We do NOT add a space when the match signals
        # that more typing follows on the same token:
        #   - ends in "/"  -> a folder; user descends further
        #   - ends in "("  -> a method with args; user types arguments
        # A completed "()" method, plain names, subcommands, and flags all
        # get the trailing space.
        if len(options) == 1:
            only = options[0]
            # "more typing follows" if it ends in "/" (descend) or a bare "("
            # (method args). A completed "()" is final, so it is NOT pending.
            pending = only.endswith("/") or (only.endswith("(") and not only.endswith("()"))
            if only and not only.endswith(" ") and not pending:
                options = [only + " "]

        # Return the state-th completion
        try:
            return options[state]
        except IndexError:
            return None


    def get_prompt(self) -> str:
        """Get the command prompt with connection status and subscription count"""
        status = "●" if self.connected else "○"

        # Add subscription count if subscriptions are active
        sub_count = ""
        if self.subscription_manager:
            count = self.subscription_manager.get_subscription_count()
            if count > 0:
                sub_count = f"[{count}]"

        # Lazy-load indicator: when lazy is on for this URI, show the loaded
        # node count in angle brackets, with a '+' suffix if the namespace is
        # PARTIAL (the cap was hit and more nodes are loadable on demand).
        # e.g. '⟨10000+⟩' partial, '⟨1523⟩' complete. Nothing when lazy is off.
        lazy_str = ""
        if self.connected and self._lazy_for_uri(self.url)["enabled"]:
            mark = "+" if self._namespace_partial else ""
            lazy_str = f"⟨{self._loaded_count()}{mark}⟩"

        # Active View, in braces, e.g. '{plc-core}'. Nothing when no View.
        view = self._view_for_uri(self.url)
        view_str = f"{{{view}}}" if view else ""

        # Show current path if not at root (using / separator)
        path_str = ""
        if self.current_path:
            path_str = f":/{self.current_path}"

        # Prefer the friendly name bound to this URI, falling back to host:port.
        # The name is bracketed so it never runs into the status glyph
        # (e.g. '[MyLocalTstSrv]●>' not 'MyLocalTstSrv●>').
        name = self._name_for_uri(self.url)
        location = f"[{name}]" if name else f"({self.host}):{self.port}"
        return f"{location}{status}{sub_count}{lazy_str}{view_str}{path_str}> "

    # ------------------------------------------------------------------
    # Output routing: color, capture sink, pipes, redirection
    # ------------------------------------------------------------------

    # ANSI codes for the tab-completion display palette.
    # Matches the colours UaShell already uses elsewhere (folder=bold
    # blue, variable=green) plus a few additions for semantic groups
    # the existing ls/tree path doesn't have (commands, subcommands,
    # flags, plot/subscription refs).
    _COMPLETION_PALETTE = {
        "folder":      "1;34",  # bold blue
        "method":      "1;32",  # bold green
        "variable":    "0;32",  # green
        "flag":        "0;33",  # yellow
        "command":     "1;36",  # bold cyan
        "subcommand":  "1;35",  # bold magenta
        "plot_ref":    "1;35",  # bold magenta (same family as subcmd)
        "sub_node":    "0;32",  # green (it's a node, even if filtered)
        "server_name": "1;33",  # bold yellow
        "uri":         "",      # default (no colour)
        "other":       "",      # default
    }

    def _colour_completion(self, text: str, category: str) -> str:
        """Wrap a completion candidate in ANSI for display.

        Honours the same suppression rules as _color() (env NO_COLOR,
        'set color off', non-TTY stdout, capture sink active). The
        wrapped string is for DISPLAY only — readline never sees these
        escapes (the unmodified candidate is what gets inserted into
        the buffer when the user picks one).
        """
        code = self._COMPLETION_PALETTE.get(category, "")
        if not code:
            return text
        return self._color(text, code)

    def _color(self, text: str, code: str) -> str:
        """Wrap text in an ANSI color escape, unless color is disabled.

        Color is suppressed when: the user turned it off, stdout is not a
        terminal, or we are currently capturing output into a sink (a pipe or
        redirect). This keeps ANSI codes out of grep input and files.
        """
        if not self._color_enabled:
            return text
        if self._sink is not None:
            return text
        try:
            if not sys.stdout.isatty():
                return text
        except Exception:
            return text
        return f"\033[{code}m{text}\033[0m"

    def _emit(self, line: str = ""):
        """Emit a line of command DATA output.

        Goes to the capture sink when one is active (pipe/redirect), otherwise
        straight to stdout. Use this for the "stdout" of data-producing commands
        (ls, tree, find, read, info, ...). Status/progress/error messages should
        keep using print() directly - that is the "stderr" channel and must not
        be captured into pipes.
        """
        if self._sink is not None:
            self._sink.append(line)
        else:
            print(line)

    def _parse_xargs(self, pipe_cmd: str) -> Optional[str]:
        """Detect the UaShell-internal `xargs` form on the right side of a pipe.

        Recognises:
            xargs CMD [ARG...]

        Returns the trimmed `CMD [ARG...]` string (which is itself an
        internal-command invocation to be re-dispatched after stdin
        tokens are appended). Returns None for anything else, in
        which case the caller falls back to running pipe_cmd as an
        external shell command.

        Detection is whitespace-sensitive: bare 'xargs' (no command
        after it) returns None — that's a usage error, not an xargs
        invocation, and we let the external-command branch fail noisily.
        """
        stripped = pipe_cmd.strip()
        if not stripped:
            return None
        # Tokenise the head to see if it is exactly 'xargs'.
        head, _, rest = stripped.partition(" ")
        if head != "xargs":
            return None
        rest = rest.strip()
        if not rest:
            return None  # bare 'xargs' — usage error, let it fall through
        return rest

    def _parse_io_redirection(self, segment: str):
        """Split a command segment into (command, pipe_target, redirect).

        Recognizes (only when unquoted, outside parentheses):
          cmd | external      -> pipe captured output to a shell command
          cmd > file          -> write captured output to file (truncate)
          cmd >> file         -> append captured output to file

        A pipe and a redirect can be combined: `find ... | grep x > out.txt`.

        Returns a tuple: (command_str, pipe_cmd_or_None, (path, append) or None).
        """
        # Walk the string tracking quote and paren state so we don't treat a
        # pipe inside a method call or quoted string as a real pipe.
        in_single = in_double = False
        depth = 0
        pipe_idx = redir_idx = -1
        redir_append = False
        i = 0
        while i < len(segment):
            ch = segment[i]
            if ch == "'" and not in_double:
                in_single = not in_single
            elif ch == '"' and not in_single:
                in_double = not in_double
            elif not in_single and not in_double:
                if ch == "(":
                    depth += 1
                elif ch == ")":
                    depth = max(0, depth - 1)
                elif depth == 0 and ch == "|" and pipe_idx == -1 and redir_idx == -1:
                    pipe_idx = i
                elif depth == 0 and ch == ">" and redir_idx == -1:
                    redir_idx = i
                    if i + 1 < len(segment) and segment[i + 1] == ">":
                        redir_append = True
                        i += 1
            i += 1

        # No special operators - plain command.
        if pipe_idx == -1 and redir_idx == -1:
            return segment.strip(), None, None

        # Determine command end (first operator encountered).
        first_op = min(x for x in (pipe_idx, redir_idx) if x != -1)
        command_str = segment[:first_op].strip()

        pipe_cmd = None
        redirect = None

        if pipe_idx != -1 and (redir_idx == -1 or pipe_idx < redir_idx):
            # Pipe comes first: cmd | <pipe...> [> file]
            if redir_idx != -1:
                pipe_cmd = segment[pipe_idx + 1:redir_idx].strip()
                path = segment[redir_idx + (2 if redir_append else 1):].strip()
                redirect = (path, redir_append)
            else:
                pipe_cmd = segment[pipe_idx + 1:].strip()
        else:
            # Redirect only (or redirect before any pipe, which we treat as
            # the redirect target capturing everything after it).
            path = segment[redir_idx + (2 if redir_append else 1):].strip()
            redirect = (path, redir_append)

        return command_str, pipe_cmd, redirect

    # ------------------------------------------------------------------
    # Glob expansion (bash-style) against the namespace cache
    # ------------------------------------------------------------------

    @staticmethod
    def _has_glob(token: str) -> bool:
        """True if token contains an unescaped shell glob metacharacter."""
        return any(c in token for c in "*?[")

    def _glob_match_paths(self, pattern: str, node_filter: str = None) -> List[str]:
        """Expand a glob pattern into matching namespace paths.

        ``node_filter`` (optional: 'variable' | 'object' | 'method') restricts
        matches to that node class. Used so 'subscribe'/'plot' globs expand to
        only the (subscribable/plottable) VARIABLE children, not the sibling
        methods/objects - subscribing to a method's value is invalid
        (BadAttributeIdInvalid).

        Bash-faithful semantics:
          - Matching is segment-wise: '*' and '?' never cross '/'.
          - '[abc]' / '[a-z]' character classes are honored (via fnmatch).
          - Patterns resolve relative to the current path, unless they are
            absolute (leading '/' or starting at a top-level node such as
            Objects/Server/Types/Views).
          - Returned paths are in the SAME notation the user typed: relative
            patterns yield relative results, absolute patterns yield results
            with their top-level prefix preserved (leading '/' re-attached if
            the user used one).
          - Methods are matched by their bare name too (the cache stores them
            with a trailing '()' for disambiguation, which we strip for the
            comparison so 'Stop*' matches the 'Stop()' method).

        Returns matches sorted; empty list if nothing matches (caller then
        keeps the literal token, exactly like bash 'nullglob' being off).
        """
        if not self.namespace_cache.is_populated:
            return []

        import fnmatch

        had_leading_slash = pattern.startswith("/")
        pat = pattern.lstrip("/")

        top_level = {n.lower() for n in self.top_level_nodes} or \
            {"objects", "server", "types", "views"}
        first_seg = pat.split("/", 1)[0].lower()
        is_absolute = had_leading_slash or first_seg in top_level

        # Build the full cache-relative pattern (cache keys have no leading /).
        if is_absolute:
            full_pattern = pat
        elif self.current_path:
            full_pattern = f"{self.current_path}/{pat}"
        else:
            full_pattern = pat

        pat_segments = full_pattern.split("/")
        n_segs = len(pat_segments)

        matches = []
        for path, node in self.namespace_cache.nodes_by_path.items():
            if node_filter is not None and node.node_class != node_filter:
                continue
            path_segments = path.split("/")
            if len(path_segments) != n_segs:
                continue
            ok = True
            for pseg, cseg in zip(pat_segments, path_segments):
                # Strip a trailing '()' from method path segments so a bare
                # name pattern (e.g. 'Stop*') still matches 'Stop()'.
                bare = cseg[:-2] if cseg.endswith("()") else cseg
                if not (fnmatch.fnmatch(bare, pseg) or fnmatch.fnmatch(cseg, pseg)):
                    ok = False
                    break
            if ok:
                matches.append(path)

        if not matches:
            return []

        # Re-express results in the user's notation.
        result = []
        for m in sorted(matches):
            if is_absolute:
                result.append("/" + m if had_leading_slash else m)
            else:
                # Strip the current_path prefix to keep it relative.
                if self.current_path and m.startswith(self.current_path + "/"):
                    result.append(m[len(self.current_path) + 1:])
                else:
                    result.append(m)
        return result

    def _expand_globs(self, args: List[str], node_filter: str = None) -> List[str]:
        """Expand glob patterns in a command's argument list (bash-style).

        Each arg that contains glob metacharacters is replaced by its sorted
        matches from the namespace cache. Args with no glob chars, node-id
        args (ns=...), and patterns that match nothing are passed through
        unchanged (bash default: an unmatched glob stays literal).

        ``node_filter`` restricts glob matches to a node class (e.g. 'variable'
        for subscribe/plot, so a '.../*' glob expands only to subscribable
        variables, not the sibling methods that would error on subscribe).
        """
        if not self.namespace_cache.is_populated:
            return args

        expanded = []
        for arg in args:
            if arg.startswith("ns=") or not self._has_glob(arg):
                expanded.append(arg)
                continue
            matches = self._glob_match_paths(arg, node_filter=node_filter)
            if matches:
                expanded.extend(matches)
            else:
                expanded.append(arg)  # nullglob off: keep literal
        return expanded

    async def _execute_segment(self, segment: str, dispatch, verbose: bool = False):
        """Execute one command segment, honoring pipes and redirection.

        `dispatch` is an async callable taking the bare command string; it does
        the actual cmd_* routing. We wrap it here with capture + flush so the
        same machinery serves both the interactive loop and script execution.
        """
        command_str, pipe_cmd, redirect = self._parse_io_redirection(segment)

        # Fast path: no pipe, no redirect - run normally, output goes to stdout.
        if pipe_cmd is None and redirect is None:
            return await dispatch(command_str)

        # Capture the command's data output into a sink.
        prev_sink = self._sink
        self._sink = []
        dispatch_result = None
        try:
            dispatch_result = await dispatch(command_str)
            captured = self._sink
        finally:
            self._sink = prev_sink

        text = "\n".join(captured)
        if captured:
            text += "\n"

        # If piping, decide between the two routes:
        #
        #   1. INTERNAL: `xargs <internal_cmd> [args...]` — feed the
        #      captured stdin tokens as positional args to an internal
        #      UaShell command (subscribe, plot, read, write, call, ...).
        #      Re-dispatches through the same routing as if the user had
        #      typed the expanded command. No subprocess involved.
        #
        #   2. EXTERNAL: anything else — run as a shell command with
        #      stdin = captured text. Same as Unix shell piping.
        #
        # The xargs form is what makes `find -type v -name '*Temp*' |
        # xargs subscribe` work without the user having to retype each
        # node path. See `help xargs` for the full contract.
        if pipe_cmd:
            xargs_cmd = self._parse_xargs(pipe_cmd)
            if xargs_cmd is not None:
                # INTERNAL route — split stdin into tokens and
                # re-dispatch.
                tokens = text.split()
                if not tokens:
                    # Empty input → nothing to do. Mirror Unix xargs,
                    # which is a silent no-op by default on empty stdin.
                    text = None
                else:
                    expanded = xargs_cmd + " " + " ".join(tokens)
                    try:
                        await dispatch(expanded)
                    except Exception as e:
                        print(f"xargs error: {e}")
                    # The dispatched command wrote directly to stdout
                    # (or to a deeper sink if we are in a script
                    # capture context). Nothing left for us to emit.
                    text = None
            else:
                try:
                    import subprocess
                    result = subprocess.run(
                        pipe_cmd, shell=True, input=text,
                        capture_output=(redirect is not None),
                        text=True,
                    )
                    if redirect is not None:
                        text = result.stdout
                    else:
                        text = None  # already written to terminal by subprocess
                except Exception as e:
                    print(f"Pipe error: {e}")
                    return dispatch_result

        # Redirect (final) output to a file.
        if redirect is not None and text is not None:
            path, append = redirect
            if not path:
                print("Error: missing redirection target filename")
                return dispatch_result
            try:
                with open(os.path.expanduser(path), "a" if append else "w",
                          encoding="utf-8") as f:
                    f.write(text)
                if verbose:
                    print(f"{'Appended' if append else 'Wrote'} output to {path}")
            except Exception as e:
                print(f"Redirection error: {e}")
        elif text is not None:
            # No pipe consumed it and no redirect - emit to terminal.
            if self._sink is not None:
                self._sink.extend(text.splitlines())
            elif text:
                print(text, end="" if text.endswith("\n") else "\n")

        return dispatch_result

    async def _ensure_connected(self) -> bool:
        """Ensure we have a valid connection before executing commands"""
        if not self.connected:
            print("⚠️  Not connected to server. Waiting for reconnection...")
            # Wait a bit for reconnection to happen
            for i in range(10):  # Wait up to 10 seconds
                await asyncio.sleep(1)
                if self.connected:
                    return True
            return False

        # Double-check connection is still alive
        if not await self._check_connection():
            print("⚠️  Connection lost. Reconnection in progress...")
            self.connected = False
            # Wait a bit for reconnection to happen
            for i in range(10):  # Wait up to 10 seconds
                await asyncio.sleep(1)
                if self.connected:
                    return True
            return False

        return True

    async def parse_node_id(self, node_str: str) -> Optional[Node]:
        """Parse a node ID string and return a Node object"""
        if not await self._ensure_connected():
            return None

        try:
            # Handle different node ID formats
            if node_str.startswith("ns="):
                # e.g., "ns=2;i=1234" or "ns=2;s=MyVariable"
                node_id = ua.NodeId.from_string(node_str)
            elif node_str.isdigit():
                # Simple numeric ID in namespace 0
                node_id = ua.NodeId(int(node_str), 0)
            elif "." in node_str and not node_str.startswith("ns="):
                # Hierarchical string like "MAIN.Motor1.ctrl" - try common namespaces
                for ns in [0, 1, 2, 3, 4, 5]:
                    try:
                        node_id = ua.NodeId(node_str, ns)
                        test_node = self.client.get_node(node_id)
                        # Test if node exists by trying to read its display name
                        await test_node.read_display_name()
                        return test_node
                    except:
                        continue
                return None
            else:
                # Try as string ID in namespace 0, then other common namespaces
                for ns in [0, 1, 2, 3, 4, 5]:
                    try:
                        node_id = ua.NodeId(node_str, ns)
                        test_node = self.client.get_node(node_id)
                        # Test if node exists
                        await test_node.read_display_name()
                        return test_node
                    except:
                        continue
                return None

            return self.client.get_node(node_id)
        except Exception as e:
            return None

    async def resolve_node_path(self, node_str: str, prefer_method: bool = False) -> Optional[Node]:
        """Resolve a node path relative to current working directory.

        This tries multiple resolution strategies:
        1. If it starts with ns=, treat as absolute node ID
        2. If it starts with / or a top-level node (Objects, Server, etc.), treat as absolute path
        3. Otherwise, try relative to current_path
        4. Fall back to parse_node_id for other formats

        Args:
            node_str: The node path or ID to resolve
            prefer_method: If True and node_str ends with (), prefer Method nodes
        """
        if not await self._ensure_connected():
            return None

        # Check if user explicitly wants a method (ends with ())
        wants_method = node_str.endswith("()")

        # Clean up the input (remove trailing parentheses for methods)
        clean_str = node_str.rstrip("()")

        # Handle leading / for absolute paths
        is_absolute = clean_str.startswith("/")
        if is_absolute:
            clean_str = clean_str.lstrip("/")

        # Handle absolute node IDs
        if clean_str.startswith("ns="):
            return await self.parse_node_id(clean_str)

        # Check if it's an absolute path (starts with top-level node or had leading /)
        top_level = {"Objects", "Server", "Types", "Views"}
        first_component = clean_str.split("/")[0].split(".")[0]

        if is_absolute or first_component in top_level:
            # Absolute path - use _find_node_by_path
            opcua_path = clean_str.replace("/", ".")
            node = await self._find_node_by_path(opcua_path, prefer_method=wants_method or prefer_method)
            return node

        # Try relative to current path
        if self.current_path:
            # Build full path
            relative_path = clean_str.replace("/", ".")
            full_path = f"{self.current_path.replace('/', '.')}.{relative_path}"
            node = await self._find_node_by_path(full_path, prefer_method=wants_method or prefer_method)
            if node:
                return node

        # Fall back to parse_node_id for other formats (numeric IDs, etc.)
        return await self.parse_node_id(node_str)

# Part 3: End



# Part 4: Begin - Command Methods, Interactive Loop, and Main Function

    async def cmd_cd(self, args: List[str]):
        """Change current working path in OPC UA namespace

        Uses local namespace cache for path validation (instant).
        """
        # No args: go to root (no server access needed)
        if not args:
            self.previous_path = self.current_path
            self.current_path = ""
            print("/")
            return

        # Normalize input: strip leading/trailing slashes
        # IMPORTANT: Don't blindly replace "." with "/" as that breaks ".." paths
        raw_target = args[0].strip("/")

        # Smart normalization: convert path separators to /
        # but preserve ".." (parent directory references)
        # Temporarily protect ".." sequences
        protected = raw_target.replace("..", "\x00DOTDOT\x00")
        # Now safely replace single dots as path separators
        normalized = protected.replace(".", "/")
        # Restore ".." sequences
        target = normalized.replace("\x00DOTDOT\x00", "..")

        # Handle "cd -" - go to previous directory (no server access needed)
        if target == "-":
            if self.previous_path is not None:
                # Swap current and previous
                old_current = self.current_path
                self.current_path = self.previous_path
                self.previous_path = old_current
                print(f"/{self.current_path}" if self.current_path else "/")
            else:
                print("No previous directory")
            return

        # Helper function to resolve .. in paths
        def resolve_dotdot(base_path: str, rel_path: str) -> str:
            """Resolve a relative path that may contain .. against a base path."""
            if base_path:
                components = base_path.split("/")
            else:
                components = []

            for part in rel_path.split("/"):
                if part == "..":
                    if components:
                        components.pop()
                elif part == ".":
                    pass
                elif part:
                    components.append(part)

            return "/".join(components)

        # Handle ".." - go up one level
        if target == "..":
            if self.current_path:
                parts = self.current_path.split("/")
                parts.pop()
                self.previous_path = self.current_path
                self.current_path = "/".join(parts)
                print(f"/{self.current_path}" if self.current_path else "/")
            else:
                print("/")
            return

        # Resolve the full path
        if ".." in target:
            full_path = resolve_dotdot(self.current_path, target)
        elif target.startswith("Objects") or target.startswith("Server") or target.startswith("Types") or target.startswith("Views"):
            # Absolute path
            full_path = target
        elif self.current_path:
            # Relative path
            full_path = f"{self.current_path}/{target}"
        else:
            full_path = target

        # Check if cache is populated
        if self.namespace_cache.is_populated:
            # If the target is behind a lazy frontier, load that subtree first
            # so navigation into a deferred part of the namespace just works.
            if not self.namespace_cache.get_node(full_path):
                await self._ensure_loaded(full_path)
            # Use cache for instant validation
            cached_node = self.namespace_cache.get_node(full_path)
            if cached_node:
                if cached_node.node_class != 'object':
                    print(f"Error: '{target}' is not an Object (folder)")
                    return

                self.previous_path = self.current_path
                self.current_path = full_path
                print(f"/{self.current_path}")
            else:
                print(f"Error: Path '{target}' not found")
        else:
            # Fall back to server validation if cache not populated
            if not await self._ensure_connected():
                return

            opcua_path = full_path.replace("/", ".")
            node = await self._find_node_by_path(opcua_path)
            if node:
                try:
                    node_class = await node.read_node_class()
                    if node_class != ua.NodeClass.Object:
                        print(f"Error: '{target}' is not an Object (folder)")
                        return
                except:
                    pass

                self.previous_path = self.current_path
                self.current_path = full_path
                print(f"/{self.current_path}")
            else:
                print(f"Error: Path '{target}' not found")

    def cmd_set(self, args: List[str]):
        """Set or show shell options.

        Usage:
          set                    - show current option values
          set color on|off       - enable/disable ANSI color output
          set name <alias>       - name the current server URI (use 'set name -'
                                   to clear); the name shows in the prompt and
                                   works with 'connect <alias>'
          set lazy on|off        - enable/disable lazy namespace loading for the
                                   current URI (persisted per URI)
          set lazy limit <N>     - set the lazy node cap for the current URI
                                   (0 = no cap = lazy off)
          set view <name>|-      - apply a View to the current URI ('-' clears);
                                   'set view' shows the active View

        Use 'settings' to list all options and their persisted per-URI values.
        """
        if not args:
            name = self._name_for_uri(self.url)
            lazy = self._lazy_for_uri(self.url)
            view = self._view_for_uri(self.url)
            print(f"color = {'on' if self._color_enabled else 'off'}")
            print(f"name  = {name if name else '(none)'}")
            print(f"lazy  = {'on' if lazy['enabled'] else 'off'} (limit {lazy['limit']})")
            print(f"view  = {view if view else '(none)'}")
            print(f"nscache = {'on' if self.nscache_enabled else 'off'}")
            return

        option = args[0].lower()
        if option == "color":
            if len(args) < 2:
                print(f"color = {'on' if self._color_enabled else 'off'}")
                return
            val = args[1].lower()
            if val in ("on", "true", "1", "yes"):
                self._color_enabled = True
                print("color = on")
            elif val in ("off", "false", "0", "no"):
                self._color_enabled = False
                print("color = off")
            else:
                print("Usage: set color on|off")
        elif option == "name":
            self._set_name(args[1:])
        elif option == "lazy":
            self._set_lazy(args[1:])
        elif option == "view":
            self._set_view(args[1:])
        elif option == "nscache":
            self._set_nscache(args[1:])
        else:
            print(f"Unknown option: {option}")
            print("Available options: color, name, lazy, view, nscache")

    def _set_nscache(self, args: List[str]):
        """Back 'set nscache [on|off]': global persistent-namespace-cache toggle."""
        if not args:
            print(f"nscache = {'on' if self.nscache_enabled else 'off'}")
            return
        val = args[0].lower()
        if val in ("on", "true", "1", "yes"):
            self.nscache_enabled = True
            self._save_settings()
            print("nscache = on")
        elif val in ("off", "false", "0", "no"):
            self.nscache_enabled = False
            self._save_settings()
            print("nscache = off  (namespace will be browsed live each connect)")
        else:
            print("Usage: set nscache on|off")

    def _set_lazy(self, args: List[str]):
        """Back 'set lazy [on|off | limit <N>]' for the current URI."""
        uri = (self.url or "").strip()
        if not uri:
            print("ℹ️  No current URI")
            return
        if not args:
            lazy = self._lazy_for_uri(uri)
            print(f"lazy = {'on' if lazy['enabled'] else 'off'} "
                  f"(limit {lazy['limit']})  ({uri})")
            return
        sub = args[0].lower()
        if sub in ("on", "true", "1", "yes"):
            self._set_lazy_for_uri(uri, enabled=True)
            print(f"lazy = on (limit {self._lazy_for_uri(uri)['limit']})")
        elif sub in ("off", "false", "0", "no"):
            self._set_lazy_for_uri(uri, enabled=False)
            print("lazy = off")
        elif sub == "limit":
            if len(args) < 2:
                print("Usage: set lazy limit <N>   (0 = no cap)")
                return
            try:
                n = max(0, int(args[1]))
            except ValueError:
                print("Usage: set lazy limit <N>   (N must be an integer)")
                return
            self._set_lazy_for_uri(uri, limit=n)
            lazy = self._lazy_for_uri(uri)
            print(f"lazy = {'on' if lazy['enabled'] else 'off'} (limit {lazy['limit']})")
        else:
            print("Usage: set lazy on|off | set lazy limit <N>")

    def _set_view(self, args: List[str]):
        """Back 'set view [<name>|-]': apply/show/clear the current URI's View."""
        uri = (self.url or "").strip()
        if not uri:
            print("ℹ️  No current URI")
            return
        if not args:
            view = self._view_for_uri(uri)
            print(f"view = {view if view else '(none)'}  ({uri})")
            return
        name = args[0].strip()
        if name == "-":
            if self.view_by_uri.pop(uri, None) is not None:
                self._save_settings()
                print(f"✅ Cleared view for {uri}")
            else:
                print(f"ℹ️  {uri} has no active view")
            return
        if not self._load_view(name):
            print(f"❌ No view named '{name}'. Use 'view list' to see available "
                  f"views, or 'view create {name}' to make one.")
            return
        self.view_by_uri[uri] = name
        self._save_settings()
        print(f"✅ Applied view '{name}' to {uri}")
        print("ℹ️  'rebrowse' to re-browse the namespace scoped to this view.")

    def cmd_settings(self, args: List[str]):
        """List all shell options and their persisted per-URI values.

        Read-only companion to 'set'. Shows global options (color) and the
        settings bound to the current URI (name, lazy on/off + limit, view,
        and the remembered full-load time used for the load ETA).
        """
        uri = (self.url or "").strip()
        print("Global:")
        print(f"  color   = {'on' if self._color_enabled else 'off'}")
        print(f"  nscache = {'on' if self.nscache_enabled else 'off'}")
        if not uri:
            print("No current URI (connect to see per-URI settings).")
            return
        name = self._name_for_uri(uri)
        lazy = self._lazy_for_uri(uri)
        view = self._view_for_uri(uri)
        stats = self.load_stats_by_uri.get(uri)
        print(f"Current URI: {uri}")
        print(f"  name  = {name if name else '(none)'}")
        print(f"  lazy  = {'on' if lazy['enabled'] else 'off'} (limit {lazy['limit']})")
        print(f"  view  = {view if view else '(none)'}")
        if self.connected:
            state = "partial" if self._namespace_partial else "complete"
            src = " from cache" if self._nscache_from_disk else ""
            print(f"  loaded = {self._loaded_count()} nodes ({state}){src}")
        if isinstance(stats, dict) and stats.get("count"):
            print(f"  last full load = {stats.get('count')} nodes "
                  f"in {float(stats.get('seconds', 0)):.1f}s")

    def _set_name(self, args: List[str]):
        """Back 'set name [<alias>|-]': bind/show/clear the current URI's name."""
        uri = (self.url or "").strip()
        if not uri:
            print("ℹ️  No current URI")
            return
        if not args:
            name = self._name_for_uri(uri)
            print(f"name = {name if name else '(none)'}  ({uri})")
            return
        alias = args[0].strip()
        if alias == "-":
            if self.server_names.pop(uri, None) is not None:
                self._save_settings()
                print(f"✅ Cleared name for {uri}")
            else:
                print(f"ℹ️  {uri} has no name")
            return
        self.server_names[uri] = alias
        self._save_settings()
        print(f"✅ Named '{uri}' -> '{alias}'")

    def cmd_pwd(self, args: List[str]):
        """Print current working path in OPC UA namespace"""
        if self.current_path:
            self._emit(f"/{self.current_path}")
        else:
            self._emit("/")

    async def _show_node_info(self, cached_node, long_format: bool = False,
                              show_path: bool = False):
        """Show information for a single leaf node (variable or method).

        Used when ls/ll is called on a variable or method instead of a
        directory. ``show_path`` prefixes the line with the node's full path
        — used when listing several glob-matched leaves that share a name
        (e.g. five 'Temperature' nodes under subsys00*)."""
        item = {
            "name": cached_node.display_name,
            "type": cached_node.node_class,
            "node_id": cached_node.node_id_str,
            "path": cached_node.path,
            "method_sig": cached_node.method_signature,
            "access": "",
            "data_type": "",
            "value": ""
        }

        if cached_node.node_class == 'variable':
            item["access"] = "rw--"
            item["data_type"] = "Variable"
        elif cached_node.node_class == 'method':
            item["access"] = "--x-"
            item["data_type"] = "Method"

        # Fetch live data from server if connected
        if self.connected and cached_node.node_class == 'variable':
            try:
                node = self.client.get_node(cached_node.node_id_str)

                # Get data type
                try:
                    dt_node = await node.read_data_type()
                    dt_browse = await self.client.get_node(dt_node).read_browse_name()
                    item["data_type"] = dt_browse.Name if isinstance(dt_browse.Name, str) else dt_browse.Name.decode('utf-8', errors='replace')
                except:
                    item["data_type"] = "Unknown"

                # Get access level
                try:
                    access_level = await node.read_access_level()
                    readable = access_level & 1
                    writable = access_level & 2
                    item["access"] = f"{'r' if readable else '-'}{'w' if writable else '-'}--"
                except:
                    item["access"] = "----"

                # Get current value
                try:
                    val = await node.read_value()
                    if isinstance(val, float):
                        item["value"] = f"{val:.4g}"
                    elif isinstance(val, str) and len(val) > 40:
                        item["value"] = f'"{val[:37]}..."'
                    elif isinstance(val, str):
                        item["value"] = f'"{val}"'
                    else:
                        item["value"] = str(val)
                    if len(item["value"]) > 50:
                        item["value"] = item["value"][:47] + "..."
                except:
                    item["value"] = "N/A"
            except:
                pass

        # Display output
        access = item["access"] or "----"
        dtype = item["data_type"]

        # When disambiguating glob matches, show the full path instead of the
        # bare name so identically-named leaves are tellable apart.
        display_text = item["path"] if show_path else item["name"]
        if item["type"] == "variable":
            name_display = self._color(display_text, "0;32")
        elif item["type"] == "method":
            if item["method_sig"]:
                name_display = self._color(f"{display_text}({item['method_sig']})", "0;33")
            else:
                name_display = self._color(f"{display_text}()", "0;33")
        else:
            name_display = display_text

        if item["value"]:
            self._emit(f"{access}  {dtype}  {name_display}  {item['value']}")
        else:
            self._emit(f"{access}  {dtype}  {name_display}")

    def _resolve_ls_path(self, path_arg: str) -> str:
        """Resolve an ls/ll path argument to a cache key (no leading slash).

        Absolute (Objects/Server/Types/Views or leading '/') stays as-is;
        otherwise it's taken relative to the current path. Mirrors the
        resolution cd/ls have always used."""
        target = path_arg.strip("/").replace(".", "/")
        if target.startswith(("Objects", "Server", "Types", "Views")):
            return target
        if self.current_path:
            return f"{self.current_path}/{target}"
        return target

    async def cmd_ls(self, args: List[str], long_format: bool = False):
        """List contents of current or specified path in OPC UA namespace

        Uses local namespace cache for instant results. For values in long format,
        goes to server to read current values.

        Args:
            args: Command arguments (path and/or flags)
            long_format: If True, show detailed listing (like ls -l)
        """
        # Check if cache is populated
        if not self.namespace_cache.is_populated:
            print("Namespace cache not built. Building now...")
            if not await self._browse_namespace_fast(show_progress=True):
                print("Error: Could not build namespace cache")
                return

        # Parse arguments. A glob (e.g. /a/subsys00*/b) expands upstream into
        # SEVERAL path args, so collect them all rather than keeping only the
        # last — the old code did `path_arg = arg` in the loop and silently
        # listed just one match.
        path_args = []
        for arg in args:
            if arg == "-l":
                long_format = True
            elif not arg.startswith("-"):
                path_args.append(arg)

        # Multiple targets (typically a glob that expanded to several paths):
        # handle each. For leaf nodes (variable/method) we show one line each,
        # prefixed with the path so identically-named leaves (e.g. five
        # 'Temperature's under subsys00*) are distinguishable. For directory
        # targets we print a "path:" header then its children, like coreutils
        # `ls dir1 dir2`. The old code kept only the LAST path arg, so a glob
        # listed just one match.
        if len(path_args) > 1:
            first = True
            for p in path_args:
                node = self.namespace_cache.get_node(self._resolve_ls_path(p))
                is_leaf = node is not None and node.node_class in ('variable', 'method')
                if is_leaf:
                    await self._show_node_info(node, long_format, show_path=True)
                else:
                    if not first:
                        self._emit("")
                    self._emit(f"{p}:")
                    sub_args = [p, "-l"] if long_format else [p]
                    await self.cmd_ls(sub_args, long_format)
                first = False
            return

        path_arg = path_args[0] if path_args else None

        # Determine target path
        if path_arg:
            list_path = self._resolve_ls_path(path_arg)
        else:
            # Use current path, or root if at root
            list_path = self.current_path if self.current_path else ""

        # If listing a deferred (lazy) subtree, load it first so its children
        # appear. Covers 'ls <frontier>' and 'ls' when the current dir itself
        # was deferred.
        if list_path:
            await self._ensure_loaded(list_path)

        # Get children from cache
        if list_path:
            # First check if the path exists at all
            target_node = self.namespace_cache.get_node(list_path)
            if not target_node:
                print(f"No such node: {list_path}")
                return

            # If the target is a leaf node (variable/method), show its info instead of children
            if target_node.node_class in ('variable', 'method'):
                # Display info for the leaf node
                await self._show_node_info(target_node, long_format)
                return

            children = self.namespace_cache.get_children(list_path)
        else:
            # At root - get top-level nodes
            children = [n for n in self.namespace_cache.nodes_by_path.values() 
                       if n.parent_path == ""]

        if not children:
            print("(empty)")
            return

        # Sort: Objects (folders) first, then Methods, then Variables, each alphabetically
        def sort_key(n):
            if n.node_class == 'object':
                return (0, n.display_name.lower())
            elif n.node_class == 'method':
                return (1, n.display_name.lower())
            elif n.node_class == 'variable':
                return (2, n.display_name.lower())
            else:
                return (3, n.display_name.lower())

        children.sort(key=sort_key)

        # Build items list from cache
        items = []
        for child in children:
            item = {
                "name": child.display_name,
                "type": child.node_class,
                "node_id": child.node_id_str,
                "path": child.path,
                "method_sig": child.method_signature,
                "access": "",
                "data_type": "",
                "value": ""
            }

            if long_format:
                if child.node_class == 'variable':
                    item["access"] = "rw--"  # Assume readable/writable by default
                    item["data_type"] = "Variable"
                elif child.node_class == 'method':
                    item["access"] = "--x-"
                    item["data_type"] = "Method"
                elif child.node_class == 'object':
                    item["access"] = "drwx"
                    item["data_type"] = "Object"
                else:
                    item["access"] = "----"

            items.append(item)

        # For long format, fetch current values from server for variables
        if long_format and self.connected:
            for item in items:
                if item["type"] == "variable":
                    try:
                        # Get the actual node to read value
                        node = self.client.get_node(item["node_id"])

                        # Get data type
                        try:
                            dt_node = await node.read_data_type()
                            dt_browse = await self.client.get_node(dt_node).read_browse_name()
                            item["data_type"] = dt_browse.Name if isinstance(dt_browse.Name, str) else dt_browse.Name.decode('utf-8', errors='replace')
                        except:
                            item["data_type"] = "Unknown"

                        # Get access level
                        try:
                            access_level = await node.read_access_level()
                            readable = access_level & 1
                            writable = access_level & 2
                            item["access"] = f"{'r' if readable else '-'}{'w' if writable else '-'}--"
                        except:
                            item["access"] = "----"

                        # Get current value
                        try:
                            val = await node.read_value()
                            if isinstance(val, float):
                                item["value"] = f"{val:.4g}"
                            elif isinstance(val, str) and len(val) > 40:
                                item["value"] = f'"{val[:37]}..."'
                            elif isinstance(val, str):
                                item["value"] = f'"{val}"'
                            else:
                                item["value"] = str(val)
                            if len(item["value"]) > 50:
                                item["value"] = item["value"][:47] + "..."
                        except:
                            item["value"] = "N/A"
                    except:
                        pass

        if long_format:
            # Long format output
            if items:
                # Calculate column widths
                max_type_width = max(len(item["data_type"]) for item in items) if items else 8
                max_name_width = max(len(item["name"]) + 2 for item in items) if items else 10

                for item in items:
                    access = item["access"] or "----"
                    dtype = item["data_type"].ljust(max_type_width)

                    # Color the name
                    if item["type"] == "object":
                        name_display = self._color(f"{item['name']}/", "1;34")
                    elif item["type"] == "variable":
                        name_display = self._color(item['name'], "0;32")
                    elif item["type"] == "method":
                        if item["method_sig"]:
                            name_display = self._color(f"{item['name']}({item['method_sig']})", "0;33")
                        else:
                            name_display = self._color(f"{item['name']}()", "0;33")
                    else:
                        name_display = item["name"]

                    if item["value"]:
                        self._emit(f"{access}  {dtype}  {name_display.ljust(max_name_width + 10)}  {item['value']}")
                    else:
                        self._emit(f"{access}  {dtype}  {name_display}")
        elif self._sink is not None:
            # Captured (pipe/redirect): one plain name per line so it's
            # grep/sort-friendly. Suffixes preserved for type cues.
            for item in items:
                if item["type"] == "object":
                    self._emit(f"{item['name']}/")
                elif item["type"] == "method":
                    self._emit(f"{item['name']}()")
                else:
                    self._emit(item["name"])
        else:
            # Short format - columns (interactive terminal only)
            try:
                import shutil
                term_width = shutil.get_terminal_size().columns
            except:
                term_width = 80

            if items:
                max_width = max(len(item["name"]) + 2 for item in items)
                cols = max(1, term_width // (max_width + 2))

                for i, item in enumerate(items):
                    # Color the name
                    if item["type"] == "object":
                        display = self._color(f"{item['name']}/", "1;34")
                    elif item["type"] == "variable":
                        display = self._color(item['name'], "0;32")
                    elif item["type"] == "method":
                        display = self._color(f"{item['name']}()", "0;33")
                    else:
                        display = item["name"]

                    end = "\n" if (i + 1) % cols == 0 else "  "
                    print(f"{display:<{max_width}}", end=end)

                if len(items) % cols != 0:
                    print()

    def _resolve_path(self, path: str) -> str:
        """Resolve a path relative to current_path, returns dot-notation for OPC UA"""
        if not path:
            # Return current path in dot notation
            return self.current_path.replace("/", ".") if self.current_path else ""

        # Normalize: accept both / and . as separators, convert to .
        normalized = path.replace("/", ".").strip(".")

        # Absolute path (starts with known top-level or contains namespace)
        if normalized.startswith("ns=") or normalized.startswith("Objects") or \
           normalized.startswith("Server") or normalized.startswith("Types") or normalized.startswith("Views"):
            return normalized

        # Relative path
        if self.current_path:
            current_dot = self.current_path.replace("/", ".")
            return f"{current_dot}.{normalized}"
        else:
            return normalized

    async def cmd_subscribe(self, args: List[str]):
        """Subscribe to nodes for real-time monitoring (verb with subcommands).

        Usage:
          subscribe <node>[,<node>...]   Add subscription(s) (live window)
          subscribe list                 Show active subscriptions
          subscribe rm <node>[,<node>]   Remove specific subscription(s)
          subscribe close                Remove all and close the viewer window
        """
        if not args:
            print("Usage: subscribe <node>[,<node>...] | list | rm <node>... | close")
            return

        # Subcommand vs. node-path: bare reserved words are subcommands; a
        # node reference contains '/', '.', ',' or starts with 'ns='.
        sub = args[0].lower()
        if sub in ("list", "rm", "close", "help"):
            if sub == "list":
                await self._subscribe_list()
            elif sub == "rm":
                await self._subscribe_rm(args[1:])
            elif sub == "close":
                await self._subscribe_close()
            elif sub == "help":
                self.cmd_help(["subscribe"])
            return

        # Otherwise: treat all args as node references to add.
        await self._subscribe_add(args)

    async def _subscribe_add(self, args: List[str]):
        """Add one or more node subscriptions."""
        if not await self._ensure_connected():
            print("❌ Cannot subscribe: Not connected to server")
            return

        if not self.subscription_manager:
            print("❌ Subscription manager not available")
            return

        # Parse node list (comma-separated)
        node_list = []
        for arg in args:
            nodes = [node.strip() for node in arg.split(',')]
            node_list.extend(nodes)

        if not node_list:
            print("❌ No valid nodes specified")
            return

        # Subscribe to each node
        success_count = 0
        for user_ref in node_list:
            try:
                # Resolve the same way read/write/call do, so slash-paths and
                # 'Objects/...' work - not just node-id strings.
                node = await self.resolve_node_path(user_ref)
                if not node:
                    print(f"❌ Node '{user_ref}' not found")
                    continue

                # Track by the canonical node-id string so reconnection can
                # re-resolve it (a slash-path would not survive restore, which
                # re-parses the tracking key).
                node_id_str = nodeid_to_string(node.nodeid)

                # Get display name
                try:
                    display_name_obj = await node.read_display_name()
                    display_name = display_name_obj.Text
                except:
                    display_name = user_ref

                # Add subscription
                self.subscription_manager.last_error = ""
                if await self.subscription_manager.add_node_subscription(node_id_str, node, display_name):
                    print(f"✅ Subscribed to: {display_name} ({node_id_str})")
                    success_count += 1
                else:
                    err = self.subscription_manager.last_error
                    print(f"❌ Failed to subscribe to: {node_id_str}"
                          f"{(' — ' + err) if err else ''}")

            except Exception as e:
                print(f"❌ Error subscribing to {user_ref}: {e}")

        if success_count > 0:
            print(f"📊 {success_count} subscription(s) added")
            # Ensure the viewer is up, then push the current value of each
            # subscribed node so the window shows something immediately.
            await self._ensure_sub_viewer()
            await self._push_current_values()
        else:
            print("❌ No subscriptions were added")

    async def _ensure_sub_viewer(self) -> bool:
        """Ensure the UaSubscription viewer process is running. Returns alive."""
        if self.sub_viewer is not None and self.sub_viewer.is_alive():
            return True

        if _SubViewerClient.find_viewer() is None:
            print("ℹ️  Subscription viewer (UaSubscription) not found on PATH; "
                  "live window unavailable.")
            return False

        self.sub_viewer = _SubViewerClient(
            channel=f"uasub_{self.host}_{self.port}_{os.getpid()}",
            title=f"OPC UA Subscriptions - {self.host}:{self.port}",
            uri=(self.url or "").strip(),
            server_name=self._name_for_uri(self.url or ""))
        if await self.sub_viewer.launch():
            print("🖥️  Subscription viewer opened")
            # Start the pump that forwards live datachange updates to the viewer.
            if self._sub_pump_task is None or self._sub_pump_task.done():
                self._sub_pump_task = asyncio.create_task(self._sub_pump_loop())
            return True
        print("⚠️  Could not start subscription viewer "
              "(is PyQt6 installed where UaSubscription runs?)")
        self.sub_viewer = None
        return False

    async def _sub_pump_loop(self):
        """Forward queued datachange updates to the viewer until it closes.

        The OPC UA subscription callbacks enqueue SubscriptionData into the
        manager's thread-safe queue; here we drain it on the asyncio side and
        push each change to the viewer over the socket.
        """
        viewer_died = False
        try:
            while self.sub_viewer is not None and self.sub_viewer.is_alive():
                items = self.subscription_manager.get_latest_data(max_items=100) \
                    if self.subscription_manager else []
                for d in items:
                    try:
                        await self.sub_viewer.send_update(
                            node=d.node_id, name=d.display_name,
                            value=str(d.value), dtype=d.data_type,
                            quality=d.quality,
                            ts=d.timestamp.strftime('%H:%M:%S.%f')[:-3])
                    except Exception:
                        pass
                await asyncio.sleep(0.2)
            # Fell out of the loop without being cancelled -> the viewer
            # window went away on its own (user closed/killed it). The viewer
            # is the master for subscriptions, so its disappearance means
            # "unsubscribe everything" (matches 'subscribe close' semantics).
            viewer_died = self.sub_viewer is not None
        except asyncio.CancelledError:
            # Cancelled by _subscribe_close (it does its own teardown) - do
            # NOT tear down again here.
            raise
        if viewer_died:
            await self._on_sub_viewer_closed()

    async def _on_sub_viewer_closed(self):
        """Viewer window disappeared on its own: drop all subscriptions so the
        client state (and the prompt's [N] count) matches reality."""
        self.sub_viewer = None
        self._sub_pump_task = None
        if self.subscription_manager:
            try:
                count = await self.subscription_manager.remove_all_subscriptions()
            except Exception:
                count = 0
            if count:
                print(f"\nℹ️  Subscription viewer closed — "
                      f"removed {count} subscription(s).")

    async def _push_current_values(self):
        """Read each subscribed node once and push its value to the viewer."""
        if self.sub_viewer is None or not self.sub_viewer.is_alive():
            return
        for node_id_str, (_handle, display_name) in \
                list(self.subscription_manager.subscribed_nodes.items()):
            try:
                node = await self.parse_node_id(node_id_str)
                if not node:
                    continue
                value = await node.read_value()
                ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
                await self.sub_viewer.send_update(
                    node=node_id_str, name=display_name, value=str(value),
                    dtype=type(value).__name__, quality="Good", ts=ts)
            except Exception:
                continue

    async def _subscribe_rm(self, args: List[str]):
        """Remove specific node subscriptions ('subscribe rm <node>...')."""
        if not args:
            print("Usage: subscribe rm <node>[,<node>...]")
            return
        if not self.subscription_manager:
            print("❌ Subscription manager not available")
            return

        node_list = []
        for arg in args:
            node_list.extend(n.strip() for n in arg.split(','))
        node_list = [n for n in node_list if n]
        if not node_list:
            print("❌ No valid nodes specified")
            return

        success_count = 0
        for user_ref in node_list:
            try:
                # Tracked by canonical node-id; user may type a slash-path.
                key = user_ref
                if user_ref not in self.subscription_manager.subscribed_nodes:
                    node = await self.resolve_node_path(user_ref)
                    if node is not None:
                        key = nodeid_to_string(node.nodeid)
                if await self.subscription_manager.remove_node_subscription(key):
                    print(f"✅ Unsubscribed from: {key}")
                    success_count += 1
                    if self.sub_viewer is not None and self.sub_viewer.is_alive():
                        await self.sub_viewer.send_remove(key)
                else:
                    print(f"⚠️  Not subscribed to: {key}")
            except Exception as e:
                print(f"❌ Error unsubscribing from {user_ref}: {e}")

        if success_count > 0:
            print(f"📊 {success_count} subscription(s) removed")
        if self.subscription_manager.get_subscription_count() == 0:
            print("ℹ️  No active subscriptions remaining")

    async def _subscribe_close(self):
        """Remove all subscriptions and close the viewer window."""
        if not self.subscription_manager:
            print("❌ Subscription manager not available")
            return
        count = await self.subscription_manager.remove_all_subscriptions()
        # Stop the pump and close the viewer process.
        if self._sub_pump_task is not None:
            self._sub_pump_task.cancel()
            try:
                await self._sub_pump_task
            except asyncio.CancelledError:
                pass
            self._sub_pump_task = None
        if self.sub_viewer is not None:
            try:
                await self.sub_viewer.quit()
            except Exception:
                pass
            self.sub_viewer = None
        print(f"✅ Removed {count} subscription(s) and closed the viewer"
              if count else "ℹ️  Subscription viewer closed")

    async def _subscribe_list(self):
        """Show active subscriptions (pipeable)."""
        if not self.subscription_manager:
            print("❌ Subscription manager not available")
            return
        count = self.subscription_manager.get_subscription_count()
        if count == 0:
            print("ℹ️  No active subscriptions")
            return

        print(f"📊 Active Subscriptions ({count}):")
        # Node list is data (pipeable); header/footer are status lines.
        for node_id in self.subscription_manager.get_subscribed_nodes():
            self._emit(node_id)
        if self._sink is None:
            if self.sub_viewer is not None and self.sub_viewer.is_alive():
                print(f"\n🖥️  Subscription viewer: running")
            else:
                print(f"\n🖥️  Subscription viewer: not running")

    # --- plot (UaPlot) -------------------------------------------------
    async def cmd_plot(self, args: List[str]):
        """Plot node values in a UaPlot window (verb with subcommands).

        Usage:
          plot [<plot name>] <node>[,<node>...]   Plot node(s) (adds to <name> if it exists)
          plot list                               List plots (<ref>  [variables])
          plot rm <ref>                           Remove a plot by its reference
          plot rm <ref> <node>[,<node>...]        Remove curve(s) from a plot
          plot close                              Close the plot window

        Plots are referenced as '<instance>_<title>' (e.g. uaplot_temps) - a
        single reference that stays unique once multiple UaPlot instances are
        supported. 'plot list' shows the reference; 'plot rm' takes it.
        """
        if not args:
            print("Usage: plot [<name>] <node>[,<node>...] | list | "
                  "rm <ref> [<node>...] | close")
            return

        sub = args[0].lower()
        if sub == "help":
            self.cmd_help(["plot"])
            return
        if sub == "close":
            await self._plot_close()
            return
        if sub == "list":
            await self._plot_list()
            return
        if sub == "rm":
            await self._plot_rm(args[1:])
            return

        # Determine optional plot name (first arg with no path chars and not a
        # node) vs. the node list.
        plot_name = None
        node_args = args
        first = args[0]
        if not any(c in first for c in "/.,") and not first.startswith("ns="):
            # Bare word -> treat as the plot/title name.
            plot_name = first
            node_args = args[1:]
        if not node_args:
            print("Usage: plot [<name>] <node>[,<node>...]")
            return

        await self._plot_add(plot_name, node_args)

    async def _ensure_plot_client(self) -> bool:
        if self.plot_client is not None and self.plot_client.is_alive():
            return True
        if _PlotClient.find_viewer() is None:
            print("ℹ️  UaPlot not found on PATH; plot command unavailable.")
            return False
        self.plot_client = _PlotClient(
            channel=f"uaplot_{self.host}_{self.port}_{os.getpid()}", uri=self.url,
            server_name=self._name_for_uri(self.url or ""))
        if await self.plot_client.launch():
            print("🖥️  UaPlot opened")
            return True
        print("⚠️  Could not start UaPlot (is PyQt6 installed where it runs?)")
        self.plot_client = None
        return False

    @staticmethod
    def _plot_label(user_ref: str, node_id_str: str) -> str:
        """Build a UNIQUE legend label for a plotted node.

        Sibling nodes share a leaf name (e.g. 'Temperature'), so a bare display
        name - or even the last few path segments - can collide (two different
        subsystems both end '.../fcs/sensor2/Temperature'). Use the full
        structured path so the label is unique by construction, dropping only a
        redundant leading 'Objects'/'system' prefix for brevity.
        """
        # Prefer the structured identifier from the node-id (ns=2;s=a.b.c...).
        if node_id_str.startswith("ns=") and ";s=" in node_id_str:
            source = node_id_str.split(";s=", 1)[1]
        else:
            source = user_ref.lstrip("/")
        parts = [p for p in source.replace(".", "/").split("/") if p]
        # Drop a leading 'Objects' (namespace root) for brevity; keep the rest
        # so the label stays unique across subsystems.
        if parts and parts[0].lower() == "objects":
            parts = parts[1:]
        return "/".join(parts) or user_ref

    async def _plot_add(self, plot_name, node_args: List[str]):
        if not await self._ensure_connected():
            print("❌ Cannot plot: Not connected to server")
            return
        node_list = []
        for arg in node_args:
            node_list.extend(n.strip() for n in arg.split(','))
        node_list = [n for n in node_list if n]
        if not node_list:
            print("❌ No valid nodes specified")
            return
        if not await self._ensure_plot_client():
            return

        # If a plot with this title already exists, add to it instead of
        # creating a duplicate. The first plot_variable call below would
        # otherwise spawn a fresh plot whenever plot_name is reused.
        plot_id = None
        if plot_name:
            resp = await self.plot_client.plot_details()
            for p in (resp or {}).get("plots", []):
                if p.get("title") == plot_name:
                    plot_id = p.get("id")
                    break
        added = 0
        for user_ref in node_list:
            try:
                node = await self.resolve_node_path(user_ref)
                if not node:
                    print(f"❌ Node '{user_ref}' not found")
                    continue
                node_id_str = nodeid_to_string(node.nodeid)
                # Distinguishing legend label (sibling nodes share a leaf name).
                dn = self._plot_label(user_ref, node_id_str)
                resp = await self.plot_client.plot_variable(
                    node_id=node_id_str, display_name=dn,
                    plot_id=plot_id, title=plot_name)
                # Reuse the plot UaPlot created so all nodes land in one plot.
                if resp and resp.get("plot_id"):
                    plot_id = resp["plot_id"]
                print(f"✅ Plotting: {dn} ({node_id_str})")
                added += 1
            except Exception as e:
                print(f"❌ Error plotting {user_ref}: {e}")
        if added:
            print(f"📈 {added} node(s) plotted")

    async def _plot_list(self):
        if self.plot_client is None or not self.plot_client.is_alive():
            print("ℹ️  UaPlot is not running")
            self._plot_refs_cache = []
            return
        resp = await self.plot_client.plot_details()
        plots = (resp or {}).get("plots", [])
        # Refresh the tab-completion cache while we have the data.
        self._plot_refs_cache = [
            self.plot_client.plot_ref(p.get("title", "")) for p in plots
        ]
        if not plots:
            print("ℹ️  No plots")
            return
        # One unique reference per plot ('<instance>_<title>') + its variables.
        # The internal plot-id is intentionally hidden from the user.
        for p in plots:
            ref = self.plot_client.plot_ref(p.get("title", ""))
            variables = p.get("variables", [])
            names = [v.get("display_name") or v.get("node_id", "") for v in variables]
            if names:
                self._emit(f"{ref}  [{', '.join(names)}]")
            else:
                self._emit(f"{ref}  (no variables)")

    async def _plot_rm(self, args: List[str]):
        """Remove a whole plot, or single curves from a plot.

        Usage:
          plot rm <ref>              remove the whole plot
          plot rm <ref> <node>...    remove one or more curves from a plot
        """
        if not args:
            print("Usage: plot rm <ref> [<node>...]   (see 'plot list' for references)")
            return
        if self.plot_client is None or not self.plot_client.is_alive():
            print("ℹ️  UaPlot is not running")
            return
        ref = args[0]
        node_args = args[1:]

        # Resolve the reference -> internal plot-id via the current plot list.
        # Retry briefly: when UaPlot has only just been launched (cold start)
        # the plot it is creating for a preceding 'plot <name> <node>' may not
        # be queryable yet on this fresh per-request connection. A human typing
        # never hits this; a script firing 'plot ...; plot rm ...' back-to-back
        # against a just-launched UaPlot can. Polling a few times costs nothing
        # once the plot is present (loop exits on the first hit).
        target = None
        for attempt in range(10):
            resp = await self.plot_client.plot_details()
            plots = (resp or {}).get("plots", [])
            for p in plots:
                if self.plot_client.plot_ref(p.get("title", "")) == ref:
                    target = p
                    break
            if target is not None:
                break
            await asyncio.sleep(0.1)
        if target is None:
            print(f"❌ No plot with reference '{ref}'. Use 'plot list' to see them.")
            return
        target_id = target.get("id")

        if not node_args:
            # Same cold-start rationale: retry the removal until it acks (or the
            # plot is gone). remove_plot is idempotent, so a retry is safe.
            result = None
            for attempt in range(10):
                result = await self.plot_client.remove_plot(target_id)
                if result and result.get("type") == "ack":
                    break
                # Maybe it's already gone (a prior attempt landed): treat a
                # now-absent plot as success.
                resp = await self.plot_client.plot_details()
                still = any(self.plot_client.plot_ref(p.get("title", "")) == ref
                            for p in (resp or {}).get("plots", []))
                if not still:
                    result = {"type": "ack"}
                    break
                await asyncio.sleep(0.1)
            if result and result.get("type") == "ack":
                print(f"✅ Removed plot: {ref}")
            else:
                msg = (result or {}).get("message", "")
                print(f"❌ Could not remove plot '{ref}'"
                      f"{(': ' + msg) if msg else ''}")
            return

        # Remove specific curves. Match each user-supplied node against the
        # plot's series by node-id or display name; resolve a path if needed.
        node_refs = []
        for arg in node_args:
            node_refs.extend(n.strip() for n in arg.split(','))
        node_refs = [n for n in node_refs if n]
        variables = target.get("variables", [])
        removed = 0
        for user_ref in node_refs:
            node_id = await self._match_plot_series(user_ref, variables)
            if node_id is None:
                print(f"❌ No curve '{user_ref}' in plot '{ref}'")
                continue
            result = await self.plot_client.remove_series(target_id, node_id)
            if result and result.get("type") == "ack":
                print(f"✅ Removed curve '{user_ref}' from {ref}")
                removed += 1
            else:
                msg = (result or {}).get("message", "")
                print(f"❌ Could not remove curve '{user_ref}'{(': ' + msg) if msg else ''}")
        if removed:
            print(f"➖ {removed} curve(s) removed from {ref}")

    async def _match_plot_series(self, user_ref: str, variables: List[dict]):
        """Resolve a user-supplied curve reference to a series node-id.

        Matches against the plot's existing series by exact node-id, exact
        display name, then by resolving the reference to a node on the server.
        Returns the series node-id string, or None if no match.
        """
        for v in variables:
            if user_ref == v.get("node_id") or user_ref == v.get("display_name"):
                return v.get("node_id")
        # Fall back to resolving the path and matching on node-id.
        try:
            node = await self.resolve_node_path(user_ref)
        except Exception:
            node = None
        if node:
            node_id_str = nodeid_to_string(node.nodeid)
            for v in variables:
                if v.get("node_id") == node_id_str:
                    return v.get("node_id")
        return None

    async def _plot_close(self):
        if self.plot_client is not None:
            try:
                await self.plot_client.quit()
            except Exception:
                pass
            self.plot_client = None
            print("ℹ️  UaPlot closed")
        else:
            print("ℹ️  UaPlot is not running")

    async def cmd_read(self, args: List[str]):
        """Read value from a node"""
        if not args:
            print("Usage: read <node_id>")
            return

        if not await self._ensure_connected():
            return

        node = await self.resolve_node_path(args[0])
        if not node:
            print(f"Error: Node '{args[0]}' not found. Check the node ID format and verify it exists on the server.")
            return

        try:
            value = await node.read_value()
            data_type = await node.read_data_type_as_variant_type()
            self._emit(f"Value: {value}")
            self._emit(f"Type: {data_type}")
        except Exception as e:
            print(f"Read error: {e}")
            if "BadSecureChannelClosed" in str(e) or "BadConnectionClosed" in str(e):
                self.connected = False

    async def cmd_write(self, args: List[str]):
        """Write value to a node

        Usage:
          write <node> <value>      - Standard syntax
          write <node>=<value>      - Alternative syntax
        """
        if not args:
            print("Usage: write <node> <value>  or  write <node>=<value>")
            return

        # Support both "write node value" and "write node=value" syntax
        if len(args) == 1 and "=" in args[0]:
            # Handle "node=value" syntax
            eq_idx = args[0].index("=")
            node_path = args[0][:eq_idx]
            value_str = args[0][eq_idx + 1:]
            if not node_path or not value_str:
                print("Usage: write <node>=<value>")
                return
        elif len(args) >= 2:
            # Handle "node value" syntax
            node_path = args[0]
            value_str = " ".join(args[1:])
        else:
            print("Usage: write <node> <value>  or  write <node>=<value>")
            return

        if not await self._ensure_connected():
            return

        node = await self.resolve_node_path(node_path)
        if not node:
            print(f"Error: Could not find node '{node_path}'")
            return

        try:
            # Get the current data type to convert the value appropriately
            data_type = await node.read_data_type_as_variant_type()

            # Enhanced type conversion based on OPC UA data types
            if data_type == ua.VariantType.Boolean:
                value = value_str.lower() in ('true', '1', 'yes', 'on')
            elif data_type == ua.VariantType.SByte:
                value = int(value_str)
                if value < -128 or value > 127:
                    raise ValueError(f"Value {value} out of range for SByte (-128 to 127)")
            elif data_type == ua.VariantType.Byte:
                value = int(value_str)
                if value < 0 or value > 255:
                    raise ValueError(f"Value {value} out of range for Byte (0 to 255)")
            elif data_type == ua.VariantType.Int16:
                value = int(value_str)
                if value < -32768 or value > 32767:
                    raise ValueError(f"Value {value} out of range for Int16 (-32768 to 32767)")
            elif data_type == ua.VariantType.UInt16:
                value = int(value_str)
                if value < 0 or value > 65535:
                    raise ValueError(f"Value {value} out of range for UInt16 (0 to 65535)")
            elif data_type == ua.VariantType.Int32:
                value = int(value_str)
                if value < -2147483648 or value > 2147483647:
                    raise ValueError(f"Value {value} out of range for Int32 (-2147483648 to 2147483647)")
            elif data_type == ua.VariantType.UInt32:
                value = int(value_str)
                if value < 0 or value > 4294967295:
                    raise ValueError(f"Value {value} out of range for UInt32 (0 to 4294967295)")
            elif data_type == ua.VariantType.Int64:
                value = int(value_str)
            elif data_type == ua.VariantType.UInt64:
                value = int(value_str)
                if value < 0:
                    raise ValueError(f"Value {value} cannot be negative for UInt64")
            elif data_type == ua.VariantType.Float:
                value = float(value_str)
            elif data_type == ua.VariantType.Double:
                value = float(value_str)
            elif data_type == ua.VariantType.String:
                value = value_str
            elif data_type == ua.VariantType.DateTime:
                # Try to parse common datetime formats
                import datetime
                try:
                    # Try ISO format first
                    value = datetime.datetime.fromisoformat(value_str)
                except ValueError:
                    try:
                        # Try common format
                        value = datetime.datetime.strptime(value_str, "%Y-%m-%d %H:%M:%S")
                    except ValueError:
                        raise ValueError(f"Could not parse datetime '{value_str}'. Use ISO format (YYYY-MM-DD HH:MM:SS)")
            elif data_type == ua.VariantType.ByteString:
                # Convert string to bytes
                value = value_str.encode('utf-8')
            else:
                # For unknown types, try smart conversion
                try:
                    if '.' not in value_str:
                        value = int(value_str)
                    else:
                        value = float(value_str)
                except ValueError:
                    value = value_str

            print(f"Writing value: {value} (type: {type(value).__name__})")

            # Create a proper OPC UA Variant with the exact data type
            variant = ua.Variant(value, data_type)
            print(f"Created variant: {variant} (variant type: {variant.VariantType})")

            # Write the variant
            await node.write_value(variant)
            print(f"✓ Successfully wrote '{value}' to {args[0]}")

        except ValueError as e:
            print(f"Type conversion error: {e}")
            print(f"Node expects {data_type}, but could not convert '{value_str}'")
        except Exception as e:
            print(f"Write error: {e}")
            if "BadSecureChannelClosed" in str(e) or "BadConnectionClosed" in str(e):
                self.connected = False

    async def cmd_call(self, args: List[str]):
        """Call a method"""
        if not args:
            print("Usage: call <method_node_id> [arg1] [arg2] ...")
            return

        if not await self._ensure_connected():
            return

        # Use prefer_method=True since we're calling a method
        method_node = await self.resolve_node_path(args[0], prefer_method=True)
        if not method_node:
            print(f"Error: Method '{args[0]}' not found.")
            return

        try:
            # Verify this is actually a method
            node_class = await method_node.read_node_class()
            if node_class != ua.NodeClass.Method:
                node_class_name = node_class.name if hasattr(node_class, 'name') else str(node_class)
                print(f"Error: '{args[0]}' is not a Method (it's a {node_class_name})")
                print("Use 'read' for Variables or 'info' to inspect the node.")
                return

            # Get parent object node (methods are typically called on their parent)
            parent = await method_node.get_parent()

            # Convert arguments
            method_args = []
            for arg in args[1:]:
                # Simple type conversion
                try:
                    if '.' in arg:
                        method_args.append(float(arg))
                    else:
                        method_args.append(int(arg))
                except ValueError:
                    method_args.append(arg)

            result = await parent.call_method(method_node, *method_args)
            if result is not None:
                print(f"Result: {result}")
            else:
                print("Method executed successfully (no return value)")
        except Exception as e:
            error_str = str(e)
            if "BadSecureChannelClosed" in error_str or "BadConnectionClosed" in error_str:
                print(f"Error: Connection lost during method call")
                self.connected = False
            elif "BadArgumentsMissing" in error_str:
                print(f"Error: Missing required arguments. Use 'info {args[0]}' to see method signature.")
            elif "BadTypeMismatch" in error_str:
                print(f"Error: Argument type mismatch. Use 'info {args[0]}' to see expected types.")
            elif "BadInvalidArgument" in error_str:
                print(f"Error: Invalid argument value. Use 'info {args[0]}' to see method signature.")
            else:
                print(f"Method call error: {e}")

    async def _execute_method(self, method_node: Node, method_name: str, args: List[str], command_display: str = None):
        """Execute a method node directly (used when method is typed as a command)

        Args:
            method_node: The method node to execute
            method_name: Name of the method for error messages
            args: Arguments to pass to the method
            command_display: If set, show this command before the result (used in script execution)
        """
        try:
            # Get parent object node (methods are typically called on their parent)
            parent = await method_node.get_parent()

            # Try to get input argument types and names from method signature
            input_arg_types = []
            input_arg_names = []
            try:
                children = await method_node.get_children()
                for child in children:
                    bn = await child.read_browse_name()
                    bn_name = bn.Name.decode('utf-8') if isinstance(bn.Name, bytes) else bn.Name
                    if bn_name == "InputArguments":
                        input_args_val = await child.read_value()
                        if input_args_val:
                            for arg in input_args_val:
                                try:
                                    type_id = arg.DataType.Identifier
                                    input_arg_types.append(type_id)
                                    # Get argument name
                                    arg_name = arg.Name.decode('utf-8') if isinstance(arg.Name, bytes) else (arg.Name or f"arg{len(input_arg_names)+1}")
                                    input_arg_names.append(arg_name)
                                except:
                                    input_arg_types.append(None)
                                    input_arg_names.append(f"arg{len(input_arg_names)+1}")
                        break
            except:
                pass

            # Check argument count
            expected_count = len(input_arg_types)
            provided_count = len(args)
            if expected_count > 0 and provided_count < expected_count:
                # Build a helpful signature string
                type_names = {1: "Boolean", 4: "Int16", 6: "Int32", 8: "Int64", 10: "Float", 11: "Double", 12: "String"}
                sig_parts = []
                for i, (name, tid) in enumerate(zip(input_arg_names, input_arg_types)):
                    tname = type_names.get(tid, "?")
                    sig_parts.append(f"{name}:{tname}")
                sig = ", ".join(sig_parts)
                print(f"Error: {method_name} requires {expected_count} argument(s), but {provided_count} provided.")
                print(f"  Usage: {method_name}({sig})")
                return

            # Type ID mappings for common OPC UA types
            # 1=Boolean, 4=Int16, 6=Int32, 8=Int64, 10=Float, 11=Double, 12=String
            type_id_to_variant = {
                1: ua.VariantType.Boolean,
                4: ua.VariantType.Int16,
                6: ua.VariantType.Int32,
                8: ua.VariantType.Int64,
                10: ua.VariantType.Float,
                11: ua.VariantType.Double,
                12: ua.VariantType.String,
            }

            # Convert arguments with proper type handling and wrap in ua.Variant
            method_args = []
            for i, arg in enumerate(args):
                expected_type = input_arg_types[i] if i < len(input_arg_types) else None
                variant_type = type_id_to_variant.get(expected_type) if expected_type else None

                try:
                    if expected_type == 11:  # Double
                        val = float(arg)
                        method_args.append(ua.Variant(val, ua.VariantType.Double))
                    elif expected_type == 10:  # Float
                        val = float(arg)
                        method_args.append(ua.Variant(val, ua.VariantType.Float))
                    elif expected_type == 4:  # Int16
                        val = int(arg)
                        method_args.append(ua.Variant(val, ua.VariantType.Int16))
                    elif expected_type == 6:  # Int32
                        val = int(arg)
                        method_args.append(ua.Variant(val, ua.VariantType.Int32))
                    elif expected_type == 8:  # Int64
                        val = int(arg)
                        method_args.append(ua.Variant(val, ua.VariantType.Int64))
                    elif expected_type == 1:  # Boolean
                        val = arg.lower() in ('true', '1', 'yes')
                        method_args.append(ua.Variant(val, ua.VariantType.Boolean))
                    elif expected_type == 12:  # String
                        method_args.append(ua.Variant(arg, ua.VariantType.String))
                    else:
                        # Fallback: try to infer type from value
                        if '.' in arg:
                            method_args.append(float(arg))
                        else:
                            try:
                                method_args.append(int(arg))
                            except ValueError:
                                method_args.append(arg)
                except ValueError:
                    method_args.append(arg)

            result = await parent.call_method(method_node, *method_args)
            if result is not None:
                if command_display:
                    print(f"{command_display} -> {result}")
                else:
                    print(f"Result: {result}")
            else:
                if command_display:
                    print(f"{command_display} -> (ok)")
                else:
                    print("Method executed successfully (no return value)")
        except Exception as e:
            error_str = str(e)
            if "BadSecureChannelClosed" in error_str or "BadConnectionClosed" in error_str:
                print(f"Error: Connection lost during method call")
                self.connected = False
            elif "BadArgumentsMissing" in error_str:
                print(f"Error: Missing required arguments. Use 'info {method_name}' to see method signature.")
            elif "BadTypeMismatch" in error_str:
                print(f"Error: Argument type mismatch. Use 'info {method_name}' to see expected types.")
            elif "BadInvalidArgument" in error_str:
                print(f"Error: Invalid argument value. Use 'info {method_name}' to see method signature.")
            elif "BadInvalidState" in error_str:
                print(f"Error: Invalid state - the method cannot be called in the current device state.")
            elif "BadInternalError" in error_str:
                print(f"Error: Server internal error while executing method.")
            elif "BadUnexpectedError" in error_str:
                print(f"Error: Unexpected server error. Check server logs for details.")
            elif "BadNotExecutable" in error_str:
                print(f"Error: Method is not executable in current context.")
            else:
                print(f"Method call error: {e}")


    async def _show_method_arguments(self, method_node: Node, indent: str):
        """Display input and output arguments for a method node"""
        try:
            # Get the method's parent to find InputArguments and OutputArguments
            # Method arguments are typically stored as properties of the method node
            children = await method_node.get_children()

            input_args = None
            output_args = None

            for child in children:
                try:
                    display_name = await child.read_display_name()
                    name = display_name.Text if hasattr(display_name, 'Text') else str(display_name)

                    if name == "InputArguments":
                        input_args = await child.read_value()
                    elif name == "OutputArguments":
                        output_args = await child.read_value()
                except:
                    continue

            # Display input arguments
            if input_args:
                self._emit(f"{indent}📥 Input Arguments:")
                for arg in input_args:
                    # Name can be bytes or string
                    if hasattr(arg, 'Name') and arg.Name is not None:
                        if isinstance(arg.Name, bytes):
                            arg_name = arg.Name.decode('utf-8', errors='replace')
                        else:
                            arg_name = str(arg.Name)
                    else:
                        arg_name = 'unknown'
                    arg_type = arg.DataType if hasattr(arg, 'DataType') else 'unknown'
                    arg_desc = arg.Description.Text if hasattr(arg, 'Description') and arg.Description else ''

                    # Try to get a friendly type name
                    type_str = str(arg_type)
                    if 'Identifier=' in type_str:
                        # Extract just the type identifier
                        try:
                            type_id = arg_type.Identifier
                            # Common OPC UA type mappings
                            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'
                            }
                            type_str = type_names.get(type_id, f'Type({type_id})')
                        except:
                            pass

                    desc_str = f" - {arg_desc}" if arg_desc else ""
                    self._emit(f"{indent}  • {arg_name}: {type_str}{desc_str}")
            else:
                self._emit(f"{indent}📥 Input Arguments: (none)")

            # Display output arguments
            if output_args:
                self._emit(f"{indent}📤 Output Arguments:")
                for arg in output_args:
                    # Name can be bytes or string
                    if hasattr(arg, 'Name') and arg.Name is not None:
                        if isinstance(arg.Name, bytes):
                            arg_name = arg.Name.decode('utf-8', errors='replace')
                        else:
                            arg_name = str(arg.Name)
                    else:
                        arg_name = 'unknown'
                    arg_type = arg.DataType if hasattr(arg, 'DataType') else 'unknown'
                    arg_desc = arg.Description.Text if hasattr(arg, 'Description') and arg.Description else ''

                    type_str = str(arg_type)
                    if 'Identifier=' in type_str:
                        try:
                            type_id = arg_type.Identifier
                            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'
                            }
                            type_str = type_names.get(type_id, f'Type({type_id})')
                        except:
                            pass

                    desc_str = f" - {arg_desc}" if arg_desc else ""
                    self._emit(f"{indent}  • {arg_name}: {type_str}{desc_str}")
            else:
                self._emit(f"{indent}📤 Output Arguments: (none)")

        except Exception as e:
            print(f"{indent}(could not read method arguments: {e})")


    async def cmd_info(self, args: List[str]):
        """Get information about one or more nodes.

        A glob (e.g. /a/subsys00*/b) is expanded upstream into several path
        args; show info for each (with a blank line between blocks)."""
        if not args:
            print("Usage: info <node_id>")
            return

        if not await self._ensure_connected():
            return

        # Only the non-flag args are node targets.
        targets = [a for a in args if not a.startswith("-")]
        if not targets:
            print("Usage: info <node_id>")
            return

        for idx, target in enumerate(targets):
            if idx:
                self._emit("")
            await self._info_one(target)

    async def _info_one(self, target: str):
        node = await self.resolve_node_path(target)
        if not node:
            print(f"Error: Node '{target}' not found.")
            return

        try:
            display_name = await node.read_display_name()
            node_class = await node.read_node_class()

            self._emit(f"Node Information:")
            self._emit(f"  Display Name: {display_name.Text}")
            # Clean canonical id (ns=2;s=...) rather than the noisy
            # NodeId(Identifier=..., NamespaceIndex=..., NodeIdType=...) repr.
            self._emit(f"  Node ID: {node.nodeid.to_string()}")
            self._emit(f"  Node Class: {node_class.name}")

            # Additional info based on node class
            if node_class == ua.NodeClass.Variable:
                try:
                    value = await node.read_value()
                    data_type = await node.read_data_type_as_variant_type()
                    # VariantType enum -> readable name (e.g. Double), not "11".
                    dt_name = getattr(data_type, "name", str(data_type))
                    self._emit(f"  Value: {value}")
                    self._emit(f"  Data Type: {dt_name}")
                except Exception:
                    self._emit("  (Unable to read value)")

            # Show method arguments
            if node_class == ua.NodeClass.Method:
                await self._show_method_arguments(node, "  ")

        except Exception as e:
            print(f"Info error: {e}")

    async def cmd_find(self, args: List[str]):
        """Search for nodes in the OPC UA namespace (like Linux 'find')

        Uses the local namespace cache for instant results.

        Usage:
          find [path] [options]

        Options:
          -name <pattern>   Search by node name (supports * and ? wildcards)
          -iname <pattern>  Case-insensitive name search
          -path <pattern>   Search by full path (supports * and ? wildcards)
          -ipath <pattern>  Case-insensitive path search
          -type <type>      Filter by type: object, variable, method (or o, v, m)
          -maxdepth <n>     Limit search depth

        Examples:
          find -name "*Temperature*"           - Find nodes containing 'Temperature'
          find -path "*PLC1*Motor*"            - Find nodes with PLC1 and Motor in path
          find -type variable                  - Find all variables
          find Server -name "*Status*"         - Find 'Status' nodes under Server
          find -iname "enable*" -type method   - Find methods starting with 'enable'
          find -maxdepth 2                     - Find all nodes up to depth 2
        """
        # Check if cache is populated
        if not self.namespace_cache.is_populated:
            print("Namespace cache not built. Building now...")
            if not await self._browse_namespace_fast(show_progress=True):
                print("Error: Could not build namespace cache")
                return

        # Parse arguments
        start_path = None
        name_pattern = None
        name_case_sensitive = True
        path_pattern = None
        path_case_sensitive = True
        type_filter = None  # 'object', 'variable', 'method'
        max_depth = None

        i = 0
        while i < len(args):
            arg = args[i]

            if arg == "-name" and i + 1 < len(args):
                name_pattern = args[i + 1]
                name_case_sensitive = True
                i += 2
            elif arg == "-iname" and i + 1 < len(args):
                name_pattern = args[i + 1]
                name_case_sensitive = False
                i += 2
            elif arg == "-path" and i + 1 < len(args):
                path_pattern = args[i + 1]
                path_case_sensitive = True
                i += 2
            elif arg == "-ipath" and i + 1 < len(args):
                path_pattern = args[i + 1]
                path_case_sensitive = False
                i += 2
            elif arg == "-type" and i + 1 < len(args):
                type_arg = args[i + 1].lower()
                if type_arg in ("o", "object", "objects"):
                    type_filter = "object"
                elif type_arg in ("v", "var", "variable", "variables"):
                    type_filter = "variable"
                elif type_arg in ("m", "method", "methods"):
                    type_filter = "method"
                else:
                    print(f"Error: Unknown type '{args[i + 1]}'. Use: object, variable, method (or o, v, m)")
                    return
                i += 2
            elif arg == "-maxdepth" and i + 1 < len(args):
                try:
                    max_depth = int(args[i + 1])
                    if max_depth < 1:
                        print("Error: maxdepth must be at least 1")
                        return
                except ValueError:
                    print(f"Error: Invalid maxdepth '{args[i + 1]}'")
                    return
                i += 2
            elif not arg.startswith("-"):
                # Positional argument = starting path
                if start_path is None:
                    start_path = arg
                else:
                    print(f"Error: Unexpected argument '{arg}'")
                    return
                i += 1
            else:
                print(f"Error: Unknown option '{arg}'")
                return

        # Determine base path for search.
        # Bash-style: '.', './', and no arg all mean "current directory";
        # a leading './' or '../' is resolved relative to the current path.
        def _resolve_find_base(raw: Optional[str]) -> Optional[str]:
            cur = self.current_path  # may be "" at root
            if raw is None:
                return cur if cur else "Objects"

            s = raw.strip()
            # Normalize "." / "./" -> current dir.
            if s in (".", "./", ""):
                return cur if cur else "Objects"

            # Leading "./" just means "relative to here" - drop it.
            if s.startswith("./"):
                s = s[2:]

            # Handle ".." segments relative to current path.
            if s == ".." or s.startswith("../") or "/" in s and ".." in s.split("/"):
                base_components = cur.split("/") if cur else []
                for part in s.split("/"):
                    if part in ("", "."):
                        continue
                    if part == "..":
                        if base_components:
                            base_components.pop()
                    else:
                        base_components.append(part)
                return "/".join(base_components)

            # Plain path: absolute (top-level node) or relative to current dir.
            norm = s.replace(".", "/").strip("/")
            top_level = {n for n in self.top_level_nodes} or \
                {"Objects", "Server", "Types", "Views"}
            first = norm.split("/", 1)[0]
            if first in top_level:
                return norm
            if cur:
                return f"{cur}/{norm}"
            return norm

        base_path = _resolve_find_base(start_path)

        # If searching under a specific base that is behind a lazy frontier,
        # load that subtree first so the search covers it.
        if base_path:
            await self._ensure_loaded(base_path)

        # An empty base_path means "search the whole namespace from root".
        if base_path and not self.namespace_cache.path_exists(base_path):
            print(f"Error: Path '{start_path}' not found in cache")
            return

        # Honesty: a partial (lazy) cache may miss matches in deferred subtrees.
        # Warn once, but still search what IS loaded.
        if self.frontier_nodes:
            print(f"⚠️  namespace is partial ({len(self.frontier_nodes)} subtree(s) "
                  f"not loaded) — results may be incomplete. Use 'scan' to load more.")

        # Use the cache to search (instant!)
        results = self.namespace_cache.find_nodes(
            name_pattern=name_pattern,
            path_pattern=path_pattern,
            type_filter=type_filter,
            start_path=base_path,
            max_depth=max_depth,
            name_case_sensitive=name_case_sensitive,
            path_case_sensitive=path_case_sensitive
        )

        # Print results
        if not results:
            print("No matches found.")
            return

        # Sort by path
        results.sort(key=lambda x: x.path.lower())

        # Print results with colors
        for node in results:
            if node.node_class == 'object':
                self._emit(self._color(f"/{node.path}/", "34"))  # Blue for objects
            elif node.node_class == 'variable':
                self._emit(f"/{node.path}")  # Normal for variables
            elif node.node_class == 'method':
                self._emit(self._color(f"/{node.path}()", "32"))  # Green for methods
            else:
                self._emit(f"/{node.path}")

        # Print summary (status line - stays on print, not part of piped data)
        count_objects = sum(1 for n in results if n.node_class == 'object')
        count_variables = sum(1 for n in results if n.node_class == 'variable')
        count_methods = sum(1 for n in results if n.node_class == 'method')

        parts = []
        if count_objects > 0:
            parts.append(f"{count_objects} object{'s' if count_objects != 1 else ''}")
        if count_variables > 0:
            parts.append(f"{count_variables} variable{'s' if count_variables != 1 else ''}")
        if count_methods > 0:
            parts.append(f"{count_methods} method{'s' if count_methods != 1 else ''}")

        if self._sink is None:
            print()
            print(f"Found: {', '.join(parts)}" if parts else "Found: 0 matches")

    async def cmd_tree(self, args: List[str]):
        """Display OPC UA namespace in a tree structure like Linux 'tree' command

        Uses the local namespace cache for instant results.
        """
        # Check if cache is populated
        if not self.namespace_cache.is_populated:
            print("Namespace cache not built. Building now...")
            if not await self._browse_namespace_fast(show_progress=True):
                print("Error: Could not build namespace cache")
                return

        # Parse arguments
        max_depth = None  # None means unlimited
        start_path_arg = None

        i = 0
        while i < len(args):
            if args[i] == "-L" and i + 1 < len(args):
                try:
                    max_depth = int(args[i + 1])
                    if max_depth < 1:
                        print("Error: Level must be at least 1")
                        return
                except ValueError:
                    print(f"Error: Invalid level '{args[i + 1]}'")
                    return
                i += 2
            else:
                if start_path_arg is None:
                    start_path_arg = args[i]
                i += 1

        # Determine starting path
        if start_path_arg:
            start_path = start_path_arg.replace(".", "/")
            if not self.namespace_cache.path_exists(start_path):
                print(f"Error: Path '{start_path_arg}' not found in cache")
                return
            start_node = self.namespace_cache.get_node(start_path)
            start_name = start_node.display_name
        elif self.current_path:
            start_path = self.current_path
            start_node = self.namespace_cache.get_node(start_path)
            start_name = start_node.display_name if start_node else start_path.split("/")[-1]
        else:
            start_path = "Objects"
            start_name = "Objects"

        # Print the root of the tree
        self._emit(start_name)

        # Track counts
        counts = {"objects": 0, "variables": 0, "methods": 0}

        # Build and print the tree from cache
        def print_tree_from_cache(parent_path: str, prefix: str, current_depth: int):
            if max_depth is not None and current_depth > max_depth:
                return

            children = self.namespace_cache.get_children(parent_path)
            children.sort(key=lambda n: (n.node_class != 'object', n.display_name.lower()))

            for i, child in enumerate(children):
                is_last = (i == len(children) - 1)

                # Determine tree characters
                if is_last:
                    branch = "└── "
                    next_prefix = prefix + "    "
                else:
                    branch = "├── "
                    next_prefix = prefix + "│   "

                # Format name based on type
                if child.node_class == 'object':
                    formatted_name = self._color(f"{child.display_name}/", "34")
                    counts["objects"] += 1
                elif child.node_class == 'variable':
                    formatted_name = child.display_name
                    counts["variables"] += 1
                elif child.node_class == 'method':
                    if child.method_signature:
                        formatted_name = self._color(f"{child.display_name}({child.method_signature})", "32")
                    else:
                        formatted_name = self._color(f"{child.display_name}()", "32")
                    counts["methods"] += 1
                else:
                    formatted_name = child.display_name

                self._emit(f"{prefix}{branch}{formatted_name}")

                # Recurse for objects
                if child.node_class == 'object':
                    print_tree_from_cache(child.path, next_prefix, current_depth + 1)

        # Build and print the tree
        print_tree_from_cache(start_path, "", 1)

        # Print summary (status line - not part of piped data)
        if self._sink is None:
            print()
            parts = []
            if counts["objects"] > 0:
                parts.append(f"{counts['objects']} object{'s' if counts['objects'] != 1 else ''}")
            if counts["variables"] > 0:
                parts.append(f"{counts['variables']} variable{'s' if counts['variables'] != 1 else ''}")
            if counts["methods"] > 0:
                parts.append(f"{counts['methods']} method{'s' if counts['methods'] != 1 else ''}")

            if parts:
                print(", ".join(parts))
            else:
                print("(empty)")

    async def _find_node_by_path(self, path: str, prefer_method: bool = False) -> Optional[Node]:
        """Find a node by browsing path from Root

        Supports:
          - Top-level names: "Objects", "Types", "Views", "Server"
          - Simple name: "Server" (looks in top-level first, then under Objects)
          - Dot path: "Server.ServerStatus" or "system.subsys1"
          - Slash path: "Server/ServerStatus"
          - Objects prefix: "Objects.Server" (starts from Objects)

        Args:
            path: The node path to find
            prefer_method: If True and multiple nodes have the same name, prefer Method
        """
        # Normalize path separators
        path = path.replace("/", ".")
        parts = [p for p in path.split(".") if p]  # Remove empty parts

        if not parts:
            return None

        try:
            root = self.client.get_root_node()
            root_children = await root.get_children()

            # Build a map of top-level node names
            top_level_nodes = {}
            for child in root_children:
                try:
                    display_name = await child.read_display_name()
                    top_level_nodes[display_name.Text] = child
                except:
                    continue

            # Check if first part is a top-level node
            if parts[0] in top_level_nodes:
                current_node = top_level_nodes[parts[0]]
                parts = parts[1:]  # Consume the first part

                # If no more parts, return this top-level node
                if not parts:
                    return current_node
            else:
                # Not a top-level node - try under Objects first
                if "Objects" in top_level_nodes:
                    current_node = top_level_nodes["Objects"]
                else:
                    return None

            # Navigate through remaining path parts
            for i, part in enumerate(parts):
                if not part:
                    continue
                found = None
                method_found = None
                children = await current_node.get_children()

                # On the last part, if prefer_method, look for methods specifically
                is_last_part = (i == len(parts) - 1)

                for child in children:
                    try:
                        display_name = await child.read_display_name()
                        if display_name.Text == part:
                            if is_last_part and prefer_method:
                                # Check node class
                                node_class = await child.read_node_class()
                                if node_class == ua.NodeClass.Method:
                                    method_found = child
                                elif found is None:
                                    found = child
                            else:
                                found = child
                                break
                    except:
                        continue

                # Prefer method if found and requested
                if is_last_part and prefer_method and method_found:
                    current_node = method_found
                elif found:
                    current_node = found
                else:
                    return None

            return current_node

        except Exception:
            return None

    async def _tree_recursive(self, node: Node, prefix: str, counts: Dict[str, int], 
                               current_depth: int = 1, max_depth: int = None,
                               path_prefix: str = ""):
        """Recursively build tree output"""
        # Check depth limit
        if max_depth is not None and current_depth > max_depth:
            return

        try:
            children = await node.get_children()
        except Exception:
            return

        # Filter and sort children
        child_info = []
        for child in children:
            try:
                display_name = await child.read_display_name()
                node_class = await child.read_node_class()
                name = display_name.Text if hasattr(display_name, 'Text') else str(display_name)
                child_info.append((child, name, node_class))
            except Exception:
                continue

        # Sort: Objects first, then alphabetically
        child_info.sort(key=lambda x: (0 if x[2] == ua.NodeClass.Object else 1, x[1].lower()))

        total = len(child_info)
        for idx, (child, name, node_class) in enumerate(child_info):
            is_last = (idx == total - 1)

            # Build path for completion cache
            child_path = f"{path_prefix}.{name}" if path_prefix else name
            # Determine cache value based on node class
            if node_class == ua.NodeClass.Object:
                self.completion_cache[child_path] = 'object'
            elif node_class == ua.NodeClass.Variable:
                self.completion_cache[child_path] = 'variable'
            elif node_class == ua.NodeClass.Method:
                self.completion_cache[child_path] = ('method', '')  # No signature info from tree

            # Choose connector
            connector = "└── " if is_last else "├── "

            # Update counts based on node class
            if node_class == ua.NodeClass.Object:
                counts["objects"] += 1
            elif node_class == ua.NodeClass.Variable:
                counts["variables"] += 1
            elif node_class == ua.NodeClass.Method:
                counts["methods"] += 1

            # Print this node
            print(f"{prefix}{connector}{name}")

            # Recurse into children (only for Objects)
            if node_class == ua.NodeClass.Object:
                new_prefix = prefix + ("    " if is_last else "│   ")
                await self._tree_recursive(child, new_prefix, counts, 
                                          current_depth + 1, max_depth, child_path)

    async def cmd_connect(self, args: List[str]):
        """Connect to an OPC UA server

        Usage:
          connect              - Reconnect to the current server URL
          connect <url>        - Connect to a new server URL

        The URL can be specified as:
          - Full URL: opc.tcp://host:port
          - Host:port: host:port (adds opc.tcp:// automatically)
          - Host only: host (will prompt for port)
        """
        if self.connected:
            print(f"Already connected to {self.url}")
            print("Use 'disconnect' first, or 'rebrowse' to refresh the cache.")
            return

        # If a URL is provided, use it. A bare word may be a saved server name,
        # so resolve aliases first ('connect atacama-sim' -> its URI).
        if args:
            resolved = self._resolve_uri_alias(args[0])
            new_url = normalize_opc_url(resolved, prompt_for_port=True)
            if new_url is None:
                print("Cancelled.")
                return
            self.url = new_url
            self.client = Client(self.url)
            # Update host/port for prompt
            parsed = urlparse(self.url)
            self.host = parsed.hostname or "unknown"
            self.port = parsed.port or 4840

        print(f"Connecting to {self.url}...")
        if await self.connect():
            # Offer to name this server if it's a new (unnamed) URI.
            self._maybe_prompt_for_uri_name()

    async def cmd_disconnect(self, args: List[str]):
        """Disconnect from the OPC UA server

        Usage:
          disconnect           - Disconnect from the current server
        """
        if not self.connected:
            print("Not connected.")
            return

        await self.disconnect()
        # Clear the cache since we're disconnected
        self.completion_cache.clear()
        self.current_path = ""
        self.previous_path = ""

    async def cmd_rebrowse(self, args: List[str]):
        """Refresh the namespace cache by re-browsing the server

        Usage:
          rebrowse             - Re-browse and refresh the full namespace cache
        """
        # Don't wait for reconnection - require active connection
        if not self.connected:
            print("❌ Not connected. Use 'connect' first.")
            return

        # Cancel any in-flight background revalidation first — otherwise its
        # atomic swap could land on top of this fresh foreground rebrowse.
        if self._nscache_revalidate_task:
            self._nscache_revalidate_task.cancel()
            try:
                await self._nscache_revalidate_task
            except asyncio.CancelledError:
                pass
            self._nscache_revalidate_task = None
        self._nscache_from_disk = False

        # Use the fast parallel browsing to rebuild the cache (honours the
        # current lazy/view settings; re-persists to the DB when full+unscoped).
        success = await self._browse_namespace_fast(show_progress=True)

        if success:
            print(f"Cache refreshed: {len(self.completion_cache)} paths indexed for tab completion.")
        else:
            print("Failed to refresh cache.")

    async def cmd_export(self, args: List[str]):
        """Export the namespace to a file for analysis

        Usage:
          export [options] [filename]

        Options:
          -f, --full        Full export with values (slower, reads each variable)
          -q, --quick       Quick export, structure only (default, fast)
          -t, --txt         Text format (default) - human readable
          -c, --csv         CSV format - spreadsheet compatible
          -j, --json        JSON format - machine readable
          -y, --yaml        YAML format - human readable hierarchical
          -x, --xml         OPC UA NodeSet2 XML (interop with other UA tools)
          -p, --path <path> Export only nodes under this path

        Examples:
          export                            - Quick export to auto-named .txt file
          export -f                         - Full export with values
          export -c mydata.csv              - Quick export to CSV
          export -f -j                      - Full export to JSON
          export -x nodes.xml               - NodeSet2 XML (structure + types)
          export -p Objects/DeviceSet/PLC1  - Export only PLC1 subtree
        """
        if not self.connected:
            print("❌ Not connected. Use 'connect' first.")
            return

        # Ensure namespace cache is populated
        if not self.namespace_cache.is_populated:
            print("Building namespace cache...")
            if not await self._browse_namespace_fast(show_progress=True):
                print("Error: Could not build namespace cache")
                return

        # Parse arguments
        full_dump = None
        output_format = None
        filename = None
        filter_path = None

        i = 0
        while i < len(args):
            arg = args[i]
            if arg in ("-f", "--full"):
                full_dump = True
            elif arg in ("-q", "--quick"):
                full_dump = False
            elif arg in ("-t", "--txt"):
                output_format = "txt"
            elif arg in ("-c", "--csv"):
                output_format = "csv"
            elif arg in ("-j", "--json"):
                output_format = "json"
            elif arg in ("-y", "--yaml"):
                output_format = "yaml"
            elif arg in ("-x", "--xml"):
                output_format = "xml"
            elif arg in ("-p", "--path"):
                if i + 1 < len(args):
                    # Normalize: dots->slashes and drop a leading '/' so the
                    # filter matches the cache keys (which start at 'Objects').
                    filter_path = args[i + 1].replace(".", "/").lstrip("/")
                    i += 1
                else:
                    print("Error: -p requires a path argument")
                    return
            elif not arg.startswith("-"):
                filename = arg
            else:
                print(f"Unknown option: {arg}")
                return
            i += 1

        # Interactive mode if no options provided
        if full_dump is None and output_format is None and filename is None:
            # Ask for export type
            print("\nExport type:")
            print("  [1] Quick - structure only (fast)")
            print("  [2] Full  - with values (slower)")
            try:
                choice = input("Select [1]: ").strip()
                full_dump = (choice == "2")
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

            # Ask for format
            print("\nOutput format:")
            print("  [1] TXT  - human readable")
            print("  [2] CSV  - spreadsheet compatible")
            print("  [3] JSON - machine readable")
            print("  [4] YAML - hierarchical")
            print("  [5] XML  - OPC UA NodeSet2 (interop: UaModeler/Prosys/asyncua)")
            try:
                choice = input("Select [1]: ").strip()
                format_map = {"1": "txt", "2": "csv", "3": "json", "4": "yaml",
                              "5": "xml", "": "txt"}
                output_format = format_map.get(choice, "txt")
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

            # Generate default filename and ask for confirmation
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            host_safe = self.host.replace(".", "_")
            default_filename = f"namespace_{host_safe}_{self.port}_{timestamp}.{output_format}"

            try:
                filename = input(f"Filename [{default_filename}]: ").strip()
                if not filename:
                    filename = default_filename
                elif not filename.endswith(f".{output_format}"):
                    filename = f"{filename}.{output_format}"
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

            print()  # Blank line before output
        else:
            # Set defaults for non-interactive mode
            if full_dump is None:
                full_dump = False
            if output_format is None:
                output_format = "txt"

        # Generate default filename if still not set
        if not filename:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            host_safe = self.host.replace(".", "_")
            filename = f"namespace_{host_safe}_{self.port}_{timestamp}.{output_format}"
        elif not filename.endswith(f".{output_format}"):
            filename = f"{filename}.{output_format}"

        # Collect nodes to dump
        if filter_path:
            nodes_to_dump = [n for p, n in self.namespace_cache.nodes_by_path.items()
                           if p.startswith(filter_path + "/") or p == filter_path]
            if not nodes_to_dump:
                print(f"No nodes found under path: {filter_path}")
                return
        else:
            nodes_to_dump = list(self.namespace_cache.nodes_by_path.values())

        # Sort by path for readability
        nodes_to_dump.sort(key=lambda n: n.path.lower())

        print(f"Exporting {len(nodes_to_dump)} nodes...")

        # XML (NodeSet2) needs per-node attributes the structure cache does not
        # hold (DataType, ValueRank, AccessLevel, BrowseName, ns index). Read
        # them live, in parallel batches, like the full-export value read. A
        # full export (-f) additionally writes <Value> for variables.
        if output_format == "xml":
            try:
                xml_attrs = await self._read_xml_attributes(nodes_to_dump,
                                                             with_values=bool(full_dump))
                self._export_xml(filename, nodes_to_dump, xml_attrs)
                print(f"✓ Exported to: {filename}")
            except Exception as e:
                print(f"Error writing export: {e}")
            return

        # If full dump, read values for variables
        values_data = {}
        if full_dump:
            variables = [n for n in nodes_to_dump if n.node_class == 'variable']
            if variables:
                print(f"Reading values for {len(variables)} variables...")
                values_data = await self._read_values_for_dump(variables)

        # Write output
        try:
            if output_format == "txt":
                await self._dump_txt(filename, nodes_to_dump, values_data, full_dump)
            elif output_format == "csv":
                await self._dump_csv(filename, nodes_to_dump, values_data, full_dump)
            elif output_format == "json":
                await self._dump_json(filename, nodes_to_dump, values_data, full_dump)
            elif output_format == "yaml":
                await self._dump_yaml(filename, nodes_to_dump, values_data, full_dump)

            print(f"✓ Exported to: {filename}")
        except Exception as e:
            print(f"Error writing export: {e}")

    async def _read_values_for_dump(self, variables: List[CachedNode]) -> Dict[str, Dict]:
        """Read values for all variables in parallel batches"""
        # Standard OPC UA data type names
        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",
        }

        results = {}
        total = len(variables)
        BATCH_SIZE = 100

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

            # Show progress
            print(f"\r  Reading... {batch_end}/{total}", end="", flush=True)

            # Read values in parallel
            async def read_var(cached_node: CachedNode):
                try:
                    node = self.client.get_node(cached_node.node_id_str)
                    value = await node.read_value()

                    # Get data type
                    data_type_name = "?"
                    try:
                        dt_nodeid = await node.read_data_type()
                        if hasattr(dt_nodeid, 'NamespaceIndex') and dt_nodeid.NamespaceIndex == 0:
                            data_type_name = DATA_TYPE_NAMES.get(dt_nodeid.Identifier, str(dt_nodeid.Identifier))
                        else:
                            # Read browse name for custom types
                            type_node = self.client.get_node(dt_nodeid)
                            browse_name = await type_node.read_browse_name()
                            data_type_name = browse_name.Name
                    except Exception:
                        pass

                    # Get access level
                    access = 0
                    try:
                        access_result = await node.read_attribute(ua.AttributeIds.AccessLevel)
                        access_val = access_result.Value.Value if hasattr(access_result.Value, 'Value') else 0
                        access = int(access_val) if access_val is not None else 0
                    except Exception:
                        pass

                    return cached_node.path, {
                        'value': str(value) if value is not None else None,
                        'data_type': data_type_name,
                        'access_level': access,
                    }
                except Exception as e:
                    return cached_node.path, {
                        'value': f"<error: {e}>",
                        'data_type': "?",
                        'access_level': 0,
                    }

            # Execute batch
            batch_results = await asyncio.gather(*[read_var(v) for v in batch], return_exceptions=True)

            for result in batch_results:
                if isinstance(result, tuple):
                    path, data = result
                    results[path] = data

        print()  # Newline after progress
        return results

    async def _read_xml_attributes(self, nodes: List[CachedNode],
                                   with_values: bool = False) -> Dict[str, Dict]:
        """Read the per-node attributes needed for a NodeSet2 XML export.

        The structure cache only holds path/display_name/node_class/node_id, so
        the NodeSet2-specific attributes (BrowseName, DataType NodeId, ValueRank,
        AccessLevel, Description, and the value when ``with_values``) are read
        live from the server in parallel batches. Returns path -> attr dict.
        """
        results: Dict[str, Dict] = {}
        total = len(nodes)
        BATCH_SIZE = 100

        # Namespace array for the NodeSet2 <NamespaceUris> block. Read once here
        # (the only place we touch the live server for the XML path).
        try:
            self._xml_namespace_array = list(await self.client.get_namespace_array())
        except Exception:
            self._xml_namespace_array = []

        async def read_one(cn: CachedNode):
            attrs: Dict[str, object] = {}
            try:
                node = self.client.get_node(cn.node_id_str)
                # BrowseName carries the namespace index + name.
                try:
                    bn = await node.read_browse_name()
                    attrs["browse_name"] = bn.Name
                    attrs["ns_index"] = int(bn.NamespaceIndex)
                except Exception:
                    attrs["browse_name"] = cn.display_name
                    attrs["ns_index"] = 0
                try:
                    desc = await node.read_attribute(ua.AttributeIds.Description)
                    dv = desc.Value.Value
                    if dv is not None and getattr(dv, "Text", None):
                        attrs["description"] = dv.Text
                except Exception:
                    pass
                if cn.node_class == "variable":
                    # DataType as a NodeId string (e.g. 'i=11', 'ns=2;s=...').
                    try:
                        dt = await node.read_data_type()
                        attrs["data_type"] = dt.to_string()
                    except Exception:
                        pass
                    try:
                        vr = await node.read_attribute(ua.AttributeIds.ValueRank)
                        attrs["value_rank"] = int(vr.Value.Value)
                    except Exception:
                        pass
                    try:
                        al = await node.read_attribute(ua.AttributeIds.UserAccessLevel)
                        attrs["access_level"] = int(al.Value.Value)
                    except Exception:
                        try:
                            al = await node.read_attribute(ua.AttributeIds.AccessLevel)
                            attrs["access_level"] = int(al.Value.Value)
                        except Exception:
                            pass
                    if with_values:
                        try:
                            attrs["raw_value"] = await node.read_value()
                        except Exception:
                            pass
            except Exception:
                pass
            return cn.path, attrs

        for start in range(0, total, BATCH_SIZE):
            batch = nodes[start:start + BATCH_SIZE]
            print(f"\r  Reading attributes... {min(start + BATCH_SIZE, total)}/{total}",
                  end="", flush=True)
            batch_results = await asyncio.gather(
                *[read_one(n) for n in batch], return_exceptions=True)
            for r in batch_results:
                if isinstance(r, tuple):
                    results[r[0]] = r[1]
        if total:
            print()
        return results

    # --- NodeSet2 XML export -------------------------------------------
    # Emits an OPC UA NodeSet2 document (UANodeSet.xsd, OPC UA Spec Part 6) -
    # the format UaModeler / Prosys / asyncua's import_xml consume. Built
    # solely from UaShell's own cache + live attribute reads (self-contained,
    # no dependency on any other tool). It is a CLIENT-SIDE projection: it
    # carries the nodes, hierarchy (HasComponent), and scalar values we can
    # see, not the full type model a server-authored NodeSet2 would have.

    _XML_NODECLASS_TO_ELEMENT = {
        "object": "UAObject",
        "variable": "UAVariable",
        "method": "UAMethod",
    }

    # Standard OPC UA scalar DataType NodeId -> uax Value element tag.
    _XML_VARIANT_TAG = {
        "i=1": "Boolean", "i=2": "SByte", "i=3": "Byte", "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",
    }

    @staticmethod
    def _xml_escape(s) -> str:
        """Minimal escape for XML attribute / text content."""
        return (str(s).replace("&", "&amp;").replace("<", "&lt;")
                .replace(">", "&gt;").replace('"', "&quot;").replace("'", "&apos;"))

    def _export_xml(self, filename: str, nodes: List[CachedNode],
                    attrs: Dict[str, Dict]) -> None:
        """Write the given nodes as a NodeSet2 XML document."""
        esc = self._xml_escape
        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 (skip index 0 - the standard OPC UA namespace, implicit).
        ns_array = getattr(self, "_xml_namespace_array", None) or []
        if len(ns_array) > 1:
            lines.append('  <NamespaceUris>')
            for ns in ns_array[1:]:
                lines.append(f'    <Uri>{esc(ns)}</Uri>')
            lines.append('  </NamespaceUris>')

        ts = datetime.now().isoformat()
        lines.append(f'  <!-- Exported by UaShell from {esc(self.url)} '
                     f'at {esc(ts)}; {len(nodes)} nodes -->')

        # Build a quick path -> node-id lookup for emitting child references.
        by_path = {n.path: n for n in nodes}

        for n in nodes:
            a = attrs.get(n.path, {})
            element = self._XML_NODECLASS_TO_ELEMENT.get(n.node_class, "UAObject")
            ns_index = a.get("ns_index", 0) or 0
            browse_name = a.get("browse_name") or n.display_name or n.path.rsplit("/", 1)[-1]
            browse_attr = f"{ns_index}:{browse_name}" if ns_index else browse_name

            attr_str = (f'NodeId="{esc(n.node_id_str)}" '
                        f'BrowseName="{esc(browse_attr)}"')
            if element == "UAVariable":
                dt = a.get("data_type")
                if dt:
                    attr_str += f' DataType="{esc(dt)}"'
                vr = a.get("value_rank")
                if vr is not None:
                    attr_str += f' ValueRank="{int(vr)}"'
                al = a.get("access_level")
                if al is not None:
                    attr_str += f' AccessLevel="{int(al)}" UserAccessLevel="{int(al)}"'

            lines.append(f"  <{element} {attr_str}>")
            lines.append(f"    <DisplayName>{esc(n.display_name or browse_name)}</DisplayName>")
            if a.get("description"):
                lines.append(f"    <Description>{esc(a['description'])}</Description>")

            # HasComponent references to known direct children.
            child_paths = self.namespace_cache.children_by_parent.get(n.path, [])
            child_nodes = [by_path[c] for c in child_paths if c in by_path]
            if child_nodes:
                lines.append("    <References>")
                for c in child_nodes:
                    lines.append(
                        f'      <Reference ReferenceType="HasComponent">'
                        f'{esc(c.node_id_str)}</Reference>')
                lines.append("    </References>")

            # Scalar value for variables (only when a full export was requested).
            if element == "UAVariable" and "raw_value" in a:
                raw = a.get("raw_value")
                tag = self._XML_VARIANT_TAG.get(a.get("data_type"))
                if raw is not None and tag:
                    lines.append("    <Value>")
                    lines.append(f"      <uax:{tag}>{esc(raw)}</uax:{tag}>")
                    lines.append("    </Value>")

            lines.append(f"  </{element}>")

        lines.append('</UANodeSet>')
        with open(filename, "w", encoding="utf-8", newline="\n") as f:
            f.write("\n".join(lines))
            f.write("\n")

    def _get_access_str(self, access_level: int) -> str:
        """Convert access level bitmask to rwh string"""
        r = 'r' if (access_level & 1) else '-'
        w = 'w' if (access_level & 2) else '-'
        h = 'h' if (access_level & 4) else '-'
        return f"{r}{w}{h}"

    async def _dump_txt(self, filename: str, nodes: List[CachedNode], 
                        values_data: Dict, full_dump: bool):
        """Dump to human-readable text format"""
        lines = []
        lines.append(f"# OPC UA Namespace Export")
        lines.append(f"# URI: {self.url}")
        lines.append(f"# Timestamp: {datetime.now().isoformat()}")
        lines.append(f"# Nodes: {len(nodes)}")
        lines.append(f"# Export 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")
            lines.append(f"#   access: r=readable, w=writable, h=history")
        else:
            lines.append(f"# Format: /path  # node_type")
        lines.append(f"#")

        for node in nodes:
            path = "/" + node.path

            if full_dump and node.node_class == 'variable':
                val_info = values_data.get(node.path, {})
                value = val_info.get('value', '<null>')
                data_type = val_info.get('data_type', '?')
                access = self._get_access_str(val_info.get('access_level', 0))
                lines.append(f"{path}={value}  # {data_type}, Variable, {access}")
            elif node.node_class == 'method':
                sig = f"({node.method_signature})" if node.method_signature else "()"
                lines.append(f"{path}{sig}  # Method")
            else:
                node_type = node.node_class.capitalize()
                lines.append(f"{path}  # {node_type}")

        with open(filename, 'w', encoding='utf-8') as f:
            f.write('\n'.join(lines) + '\n')

    async def _dump_csv(self, filename: str, nodes: List[CachedNode], 
                        values_data: Dict, full_dump: bool):
        """Dump to CSV format"""
        import csv

        with open(filename, 'w', encoding='utf-8', newline='') as f:
            if full_dump:
                writer = csv.writer(f)
                writer.writerow(['path', 'display_name', 'node_class', 'node_id', 
                                'value', 'data_type', 'access'])
                for node in nodes:
                    val_info = values_data.get(node.path, {})
                    writer.writerow([
                        node.path,
                        node.display_name,
                        node.node_class,
                        node.node_id_str,
                        val_info.get('value', ''),
                        val_info.get('data_type', ''),
                        self._get_access_str(val_info.get('access_level', 0)),
                    ])
            else:
                writer = csv.writer(f)
                writer.writerow(['path', 'display_name', 'node_class', 'node_id'])
                for node in nodes:
                    writer.writerow([
                        node.path,
                        node.display_name,
                        node.node_class,
                        node.node_id_str,
                    ])

    async def _dump_json(self, filename: str, nodes: List[CachedNode], 
                         values_data: Dict, full_dump: bool):
        """Dump to JSON format"""
        import json

        output = {
            "metadata": {
                "uri": self.url,
                "timestamp": datetime.now().isoformat(),
                "node_count": len(nodes),
                "dump_type": "full" if full_dump else "quick",
            },
            "nodes": []
        }

        for node in nodes:
            node_data = {
                "path": node.path,
                "display_name": node.display_name,
                "node_class": node.node_class,
                "node_id": node.node_id_str,
            }
            if full_dump and node.node_class == 'variable':
                val_info = values_data.get(node.path, {})
                node_data["value"] = val_info.get('value')
                node_data["data_type"] = val_info.get('data_type', '?')
                node_data["access"] = self._get_access_str(val_info.get('access_level', 0))
            if node.node_class == 'method' and node.method_signature:
                node_data["signature"] = node.method_signature
            output["nodes"].append(node_data)

        with open(filename, 'w', encoding='utf-8') as f:
            json.dump(output, f, indent=2, ensure_ascii=False)

    async def _dump_yaml(self, filename: str, nodes: List[CachedNode], 
                         values_data: Dict, full_dump: bool):
        """Dump to YAML format"""
        try:
            import yaml
        except ImportError:
            print("PyYAML not installed. Install with: pip install pyyaml")
            print("Falling back to JSON format.")
            new_filename = filename.rsplit('.', 1)[0] + '.json'
            await self._dump_json(new_filename, nodes, values_data, full_dump)
            return

        output = {
            "metadata": {
                "uri": self.url,
                "timestamp": datetime.now().isoformat(),
                "node_count": len(nodes),
                "dump_type": "full" if full_dump else "quick",
            },
            "nodes": []
        }

        for node in nodes:
            node_data = {
                "path": node.path,
                "display_name": node.display_name,
                "node_class": node.node_class,
                "node_id": node.node_id_str,
            }
            if full_dump and node.node_class == 'variable':
                val_info = values_data.get(node.path, {})
                node_data["value"] = val_info.get('value')
                node_data["data_type"] = val_info.get('data_type', '?')
                node_data["access"] = self._get_access_str(val_info.get('access_level', 0))
            if node.node_class == 'method' and node.method_signature:
                node_data["signature"] = node.method_signature
            output["nodes"].append(node_data)

        with open(filename, 'w', encoding='utf-8') as f:
            yaml.dump(output, f, default_flow_style=False, allow_unicode=True, sort_keys=False)

    def cmd_help(self, args: List[str]):
        """Show help information"""
        if not args:
            print("""
\033[1mUaShell - Interactive OPC UA Client\033[0m

\033[1mNavigation:\033[0m
  cd <path>         Change to path (supports cd .., cd -, dot notation)
  pwd               Print current path
  ls [-l] [path]    List directory contents
  ll [path]         Detailed listing (alias for 'ls -l')
  tree [-L n] [p]   Show tree structure with max depth

\033[1mRead/Write:\033[0m
  read <node>       Read variable value
  write <node> <v>  Write value to variable
  call <method> [a] Call method with optional arguments
  info <node>       Show detailed node information

\033[1mSearch:\033[0m
  find [options]    Search namespace (like Linux find)

\033[1mMonitoring:\033[0m
  subscribe <n,...>   Subscribe to nodes (live window). subcommands:
                      list | rm <n,...> | close
  plot [name] <n,...> Plot nodes in UaPlot window. subcommands:
                      list | rm <name> | close

\033[1mHistory & Scripts:\033[0m
  history [pat]     Show command history (h is alias)
  script [sub]      Manage scripts (list/cat/edit/rm/mv/run/save)
  run [-v] <name>   Execute a script (-v for verbose)

\033[1mConnection:\033[0m
  connect [url]     Connect to server
  disconnect        Disconnect from server
  rebrowse          Refresh namespace cache
  export [opts] [f] Export namespace to file

\033[1mOther:\033[0m
  set [opt val]     Show/set options ('set color off', 'set name <alias>')
  about             Show version and about information
  help [cmd]        Show help (detailed if cmd given)
  exit / quit       Exit UaShell

\033[1mDirect Execution:\033[0m
  MethodName()      Execute method in current directory
  VariableName      Read variable in current directory
  /abs/path/Method()  Execute method by absolute path

\033[1mShortcuts:\033[0m
  !N      Re-run command N from history
  cmd1;cmd2   Chain multiple commands
  <Tab>   Auto-complete paths, commands, methods

\033[1mGlobbing (bash-style):\033[0m
  *  ?  [abc]        Wildcards expand against the namespace (per path segment)
  e.g.  ls Objects/*           List each child of Objects
        read system/*/position Read 'position' under every child of system
        ll Server/Ua[AB]*      Char classes work too
  (Unmatched patterns are kept literal. 'find'/'browse' use their own
   pattern syntax, so quote globs you want them to interpret.)

\033[1mPipes & Redirection (bash-style):\033[0m
  cmd | prog        Pipe command output to an external program
  cmd | xargs CMD   Feed pipe tokens as args to an internal UaShell cmd
  cmd > file        Write command output to file (truncate)
  cmd >> file       Append command output to file
  e.g.  find -type v | grep -i temp
        ls Objects | sort
        tree Server > server.txt
        find -name '*Motor*' | wc -l
        find -name '*Temp*' | xargs subscribe   (subscribe to all matches)
        find -type m | xargs -n1 ...   (not supported in v1; punt to v2)
  (Color is auto-disabled when piping/redirecting; toggle with 'set color')

Type 'help <command>'  (or '<command> help', '<command> -h') for detailed
help on a specific command.
            """)
            return

        command = args[0].lower()

        if command == "subscribe":
            print("""
subscribe - Real-time monitoring of nodes (verb with subcommands)

Usage:
  subscribe <node>[,<node>...]   Add subscription(s)
  subscribe list                 Show active subscriptions
  subscribe rm <node>[,<node>]   Remove specific subscription(s)
  subscribe close                Remove all and close the viewer window

Adding nodes accepts the same path forms as read/write/call (slash paths,
Objects/..., node-ids) and expands globs (e.g. subscribe motor1/*).

A separate live window (the UaSubscription viewer) displays values pushed from
UaShell as they change. The viewer requires the 'UaSubscription' program on
PATH (and PyQt6 where it runs); if unavailable, subscriptions are still tracked
but no window appears. Subscriptions restore after reconnection.

Examples:
  subscribe Objects/system/subsys001/fcs/motor1/State
  subscribe system/subsys001/fcs/sensor1/*
  subscribe a/b/Temperature,c/d/Pressure
  subscribe list
  subscribe rm system/subsys001/fcs/motor1/State
  subscribe close
            """)

        elif command == "plot":
            print("""
plot - Plot node values in a UaPlot window (verb with subcommands)

Usage:
  plot [<plot name>] <node>[,<node>...]   Plot node(s) (into one plot)
  plot list                               List plots: '<ref>  [variables]'
  plot rm <ref>                           Remove a plot by its reference
  plot rm <ref> <node>[,<node>...]        Remove curve(s) from a plot
  plot close                              Close the plot window

Drives the separate UaPlot application. Requires the 'UaPlot' program on PATH
(and PyQt6 where it runs); if unavailable, the plot command is not available.
Multiple comma-separated nodes are plotted as separate curves in one plot;
legend labels use the node path so sibling variables don't collide.

Reusing an existing plot name adds curves to that plot rather than creating a
duplicate. Removing the last curve of a plot removes the (now empty) plot too.
A curve can be removed by its node path, node-id, or legend label.

Each plot has a unique reference '<instance>_<title>' (e.g. 'uaplot_temps').
'plot list' shows the reference; 'plot rm' takes it. The instance prefix keeps
references unique once multiple UaPlot windows are supported.

Examples:
  plot system/subsys001/fcs/sensor1/Temperature
  plot temps system/subsys001/fcs/sensor1/Temperature,.../sensor2/Temperature
  plot list                          -> uaplot_temps  [.../sensor1/Temperature]
  plot temps .../sensor2/Temperature   (adds a curve to the existing 'temps' plot)
  plot rm uaplot_temps .../sensor2/Temperature   (removes one curve)
  plot rm uaplot_temps                 (removes the whole plot)
  plot close
            """)

        elif command == "read":
            print("""
read - Read value from a node

Usage: read <node_id>

Reads the current value and data type of the specified node.
Automatically reconnects if connection is lost.

Example:
  read ns=2;i=1001
  read MyTemperatureSensor
            """)

        elif command == "write":
            print("""
write - Write value to a node

Usage: write <node_id> <value>

Writes a value to the specified node. The value will be automatically
converted to the appropriate data type based on the node's data type.
Automatically reconnects if connection is lost.

Example:
  write ns=2;i=1001 42.5
  write MySetpoint 100
  write MyBooleanFlag true
            """)

        elif command == "call":
            print("""
call - Call a method

Usage: call <method_node_id> [arg1] [arg2] ...

Calls a method on the OPC UA server. Arguments are optional and will be
automatically converted to appropriate types.
Automatically reconnects if connection is lost.

Examples:
  call ns=2;i=2001
  call ns=2;i=2001 42 3.14 "hello"
  call MyMethod
            """)

        elif command == "about":
            print("""
about - Show UA Tools / UaShell version and description

Usage: about

Prints the version and an overview of UaShell. (UaShell is text-only here;
UaExplorer shows a graphical About dialog with the UA Tools logo.)
            """)

        elif command == "info":
            print("""
info - Get detailed information about a node

Usage: info <node_id>

Displays detailed information about a node including its display name,
node class, data type (for variables), and method arguments (for methods).
Automatically reconnects if connection is lost.

Example:
  info ns=2;i=1001
  info MyTemperatureSensor
            """)

        elif command == "tree":
            print("""
tree - Display OPC UA namespace in a tree structure

Usage: tree [-L level] [node_id]

Displays the OPC UA namespace structure like the Linux 'tree' command.
Without arguments, starts from the Objects folder.
With a node_id, starts from that specific node.

Options:
  -L level    Descend only level directories deep

Examples:
  tree                    - Show tree from Objects folder
  tree -L 2               - Show tree with max depth of 2
  tree -L 3 Server        - Show Server tree, 3 levels deep
  tree ns=2;s=MAIN        - Show tree starting from MAIN node
            """)

        elif command == "ls":
            print("""
ls - List contents of current or specified path

Usage: ls [-l] [path]

Lists the children of the current directory or specified path,
similar to the Unix 'ls' command. Shows color-coded output:
  - Blue with /     : Objects (directories)
  - Green           : Variables
  - Yellow with ()  : Methods

Options:
  -l    Long format showing permissions, data type, and value

Permissions format: rwx-
  r = readable, w = writable, x = executable (method), d = directory

Examples:
  ls                      - List current directory
  ls -l                   - Detailed listing with values
  ls Server               - List contents of Server
  ls -l system/fcs        - Detailed listing of system/fcs
            """)

        elif command == "ll":
            print("""
ll - Long listing (alias for 'ls -l')

Usage: ll [path]

Displays detailed listing of the current directory or specified path,
showing permissions, data type, and current value for variables.

Output format:
  access  type      name          value
  drwx    Object    subsys1/
  -r--    Double    Temperature   23.5
  -rw-    Boolean   Enabled       True
  --x-    Method    Init()

Examples:
  ll                      - Detailed list of current directory
  ll Server               - Detailed list of Server contents
            """)

        elif command == "find":
            print("""
find - Search for nodes in the OPC UA namespace

Usage: find [path] [options]

Recursively search for nodes matching criteria, starting from the
current directory (or specified path).

Options:
  -name <pattern>     Search by name (supports * and ? wildcards)
  -iname <pattern>    Case-insensitive name search
  -type <type>        Filter by type: object, variable, method (or o, v, m)
  -maxdepth <n>       Limit search depth

Examples:
  find                              - List all nodes from current dir
  find -name "*Temperature*"        - Find nodes containing 'Temperature'
  find -type variable               - Find all variables
  find -type m                      - Find all methods (short form)
  find Server -name "*Status*"      - Find 'Status' nodes under Server
  find -iname "enable*" -type method - Find methods starting with 'enable'
  find -maxdepth 2                  - Find all nodes up to depth 2
  find -name "*.Value" -type v      - Find variables ending with 'Value'
            """)

        elif command == "connect":
            print("""
connect - Connect to an OPC UA server

Usage: connect [url]

Without arguments, reconnects to the current server URL.
With a URL, connects to a new server.

The URL can be specified in several formats:
  - Full URL: opc.tcp://host:port
  - Host:port: 192.168.1.100:4840 (opc.tcp:// added automatically)
  - Host only: 192.168.1.100 (will prompt for port)

Examples:
  connect                           - Reconnect to current server
  connect 192.168.1.100:4840        - Connect using host:port
  connect 192.168.1.100             - Connect, prompts for port
  connect opc.tcp://localhost:4840  - Full URL format
  connect atacama-sim               - Connect using a saved name (see 'set name')

Naming servers: 'set name <alias>' binds a name to the current URI (shown in
the prompt and usable as 'connect <alias>'). Connecting to a new URI
interactively also offers to name it. See 'help set'.
            """)

        elif command == "disconnect":
            print("""
disconnect - Disconnect from the OPC UA server

Usage: disconnect

Closes the connection to the current server, cleans up subscriptions,
and clears the navigation cache.
            """)

        elif command == "rebrowse":
            print("""
rebrowse - Refresh the namespace cache

Usage: rebrowse

Re-browses the entire server namespace using parallel requests and rebuilds
the local cache. This is done automatically on connect, but you can use
this command to refresh after server-side changes (new nodes added/removed).

The parallel browsing is much faster than sequential browsing - typically
completing in seconds even for large namespaces (thousands of nodes).

After rebrowse, commands like 'find', 'tree', 'ls', and 'cd' work instantly
from the local cache without server round-trips.

Examples:
  rebrowse            - Refresh the namespace cache
            """)

        elif command == "script":
            print("""
script - Manage saved scripts (git-style subcommands)

Usage:
  script                  List all scripts (shortcut for 'list')
  script list             List all scripts
  script <name>           Show contents (shortcut for 'cat <name>')
  script cat <name> [-n]  Print contents (-n adds line numbers)
  script edit <name>      Open in $EDITOR (created if it doesn't exist)
  script rm <name>        Delete a script (asks for confirmation)
  script mv <old> <new>   Rename a script
  script run <name>       Run a script (alias for the 'run' command)
  script save [from] [to] [name]
                          Save a range of history commands as a new script
                          (interactive prompts if args omitted)

Scripts are stored in: ~/.uatools/UaShell/scripts/
The editor is chosen from $EDITOR, then $VISUAL, then 'vi'.

Script types (by extension):
  .uas   Command playback - a sequence of UaShell commands (the default).
  .py    Python script - runs against the live connection; defines
         'def main(sh):'. Provides UaExplorer-style built-ins (synchronous,
         no await): Read/Write/Execute, Wait/WaitUntil, Log, Store/Save,
         Iso, Verbose, Abort (a new .py stub lists them all in its header).

A bare name (no extension) means '.uas'. Create a Python script by giving
the '.py' extension explicitly: 'script edit myscript.py'.

Examples:
  script                       - List all saved scripts (shows extensions)
  script cat my_script         - Show contents (resolves .uas or .py)
  script cat my_script -n      - Show contents with line numbers
  script edit my_script        - Edit (or create) 'my_script.uas'
  script edit my_script.py     - Edit (or create) a Python script
  script rm old_script         - Delete 'old_script'
  script mv tmp my_script      - Rename 'tmp' to 'my_script'
  script list | grep tst       - List and filter (output is pipeable)
            """)

        elif command == "run":
            print("""
run - Execute a saved script

Usage: run [options] <script_name_or_path>

Runs a script. Two kinds, chosen by extension:
  .uas   Command playback - executes a sequence of UaShell commands.
  .py    Python script - executes 'def main(sh):' against the live connection.
         Provides UaExplorer-style built-ins (synchronous, no await: Read/
         Write/Execute, Wait/WaitUntil, Log, Store, Iso, Verbose, Abort).
A bare name resolves to '.uas' first, then '.py'.

Options:
  -v    Verbose mode (show each command / extra detail as it runs)
  -n    Dry run - show the script without executing it

Script locations (checked in order):
  1. If the name contains / or \\, it is treated as a file path.
  2. Scripts directory: ~/.uatools/UaShell/scripts/<name>.uas (or .py)

Examples:
  run my_script              - Run 'my_script.uas' (or .py) from scripts dir
  run my_script.py           - Run a Python script explicitly
  run -v my_script           - Run with verbose output
  run -n my_script           - Show what would run (dry run)
  run /path/to/script.uas    - Run a script by path

Tips:
  - Use 'script' to list available scripts (extensions are shown)
  - Use 'script cat <name>' to view a script before running
  - In .uas scripts, meta commands (save, script, run, exit) are skipped
  - .py scripts require an active connection
            """)

        elif command == "export":
            print("""
export - Export namespace to a file for analysis

Usage: export [options] [filename]

Exports the namespace structure (and optionally values) to a file.
Supports multiple output formats for different use cases.

Options:
  -f, --full        Full export - read actual values for all variables (slower)
  -q, --quick       Quick export - structure only, no values (default, fast)
  -t, --txt         Text format - human readable (default)
  -c, --csv         CSV format - spreadsheet compatible
  -j, --json        JSON format - machine readable
  -y, --yaml        YAML format - human readable hierarchical
  -x, --xml         OPC UA NodeSet2 XML (UANodeSet.xsd)
  -p, --path <path> Export only nodes under this path

If no filename is provided, auto-generates: namespace_<host>_<port>_<timestamp>.<format>

Output formats:
  TXT:  /path=value  # data_type, Variable, rwh   (full export)
        /path  # Object                            (quick export)
  CSV:  path, display_name, node_class, node_id, [value, data_type, access]
  JSON: Structured with metadata and nodes array
  YAML: Same as JSON but in YAML format
  XML:  OPC UA NodeSet2 - loadable by UaModeler, Prosys, asyncua import_xml.
        Always reads node attributes (BrowseName, DataType, ValueRank,
        AccessLevel) live from the server; -f also writes scalar <Value>s.
        This is a client-side projection (nodes + HasComponent hierarchy +
        values), not the full type model a server-authored NodeSet2 carries.

Examples:
  export                            - Quick export to auto-named .txt file
  export -f                         - Full export with values to .txt
  export -c output.csv              - Quick export to CSV
  export -f -j values.json          - Full export to JSON with values
  export -x nodes.xml               - NodeSet2 XML (structure + types)
  export -f -x nodes.xml            - NodeSet2 XML including scalar values
  export -p Objects/PLC1            - Export only PLC1 subtree
  export -f -p Objects/DeviceSet    - Full export of DeviceSet only
            """)

        elif command == "set":
            print("""
set - Show or change shell options

Usage: set [option value]

Without arguments, shows all current option values.

Options:
  color on|off      Enable or disable ANSI color in output
  name <alias>      Name the current server URI ('set name -' clears it)
  lazy on|off       Enable/disable lazy namespace loading for the current URI
  lazy limit <N>    Set the lazy node cap for the current URI (0 = no cap)
  view <name>|-     Apply a View to the current URI ('-' clears it)
  nscache on|off    Enable/disable the persistent (on-disk) namespace cache
                    (global; on by default)

Notes:
  - Color is automatically suppressed when output is piped or redirected,
    and when stdout is not a terminal, so 'set color' only affects normal
    interactive display.
  - The NO_COLOR environment variable disables color at startup.
  - A named URI shows in the prompt instead of host:port and can be used
    with 'connect <alias>'. Names persist in ~/.uatools/UaShell/settings.json.
    Connecting to a new (unnamed) URI interactively also offers to name it.
  - Lazy loading, the active View and the last full-load time are remembered
    PER URI in settings.json. A new URI defaults to lazy on, limit 10000.
    The loaded node count (and a '+' when the namespace is partial) shows in
    the prompt, e.g. [srv]●⟨10000+⟩:/path>.
  - With nscache on (the default), each full browse is saved to an on-disk
    SQLite cache (~/.uatools/UaShell/nscache.db). Reconnecting to that server
    then loads the namespace instantly from disk and revalidates it with a
    full rebrowse in the background. 'set nscache off' disables this globally.
  - Use 'settings' to list every option and its persisted per-URI value.

Examples:
  set                 - Show current options (color, name, lazy, view)
  set color off       - Disable colored output
  set name atacama    - Name the current server 'atacama'
  set lazy on         - Enable lazy loading for the current URI
  set lazy limit 5000 - Cap the lazy browse at 5000 nodes
  set view plc-core   - Apply the 'plc-core' view to the current URI
            """)

        elif command == "settings":
            print("""
settings - List all shell options and their persisted per-URI values

Usage: settings

Read-only companion to 'set'. Shows the global options (color) and the
settings bound to the CURRENT URI: name, lazy on/off + limit, active view,
the currently loaded node count (complete/partial), and the remembered
full-load time used to compute the load ETA.
            """)

        elif command == "scan":
            print("""
scan - Load a deferred (lazy) subtree into the cache on demand

Usage:
  scan <path>     Browse and cache the subtree at <path>
  scan            Load ALL currently-deferred subtrees

With lazy loading on, a large namespace is browsed only up to the limit
and the rest is deferred (shown as 'PARTIAL' with a '+' in the prompt).
Navigating into a deferred subtree with cd/ls/find loads it automatically;
'scan' pulls a specific subtree (or everything still deferred) into the
cache explicitly, without turning lazy loading off.

Examples:
  scan Objects/PLC1        - Load the PLC1 subtree now
  scan                     - Load everything still deferred
            """)

        elif command == "view":
            print("""
view - Create, inspect and manage namespace Views (scoping)

Usage:
  view                    Show the active view for the current URI
  view list               List all views (UaShell's own + UaExplorer's)
  view show [<name>]      Show a view's include/exclude patterns
  view create <name>      Create a new (empty) view in UaShell's views/
  view include <pattern>  Add an include pattern to the ACTIVE view
  view exclude <pattern>  Add an exclude pattern to the ACTIVE view
  view rm <name>          Delete one of UaShell's own views

A View prunes the namespace browse to the included subtrees, so a scoped
View loads far fewer nodes on a big server. Apply one with 'set view <name>'
(or 'set view -' to clear), then 'rebrowse'. The active view shows in the
prompt in braces, e.g. [srv]●{plc-core}:/path>.

Views are format-compatible with UaExplorer and shared: a view defined in
the GUI appears in 'view list'. On a name clash, UaShell's own view wins.
Own views live in ~/.uatools/UaShell/views/; UaExplorer's in
~/.uatools/UaExplorer/views/.

Pattern notes:
  - A bare word (e.g. 'Motor') is a substring match.
  - A leading-slash path (e.g. '/Objects/system/subsys001') scopes to that
    subtree and is efficiently pruned at browse time; add '/*' or '/**' to
    the same effect. Mid-path globs (e.g. '/Objects/subsys*') still filter
    but cannot prune the browse (the whole namespace is walked).

Examples:
  view create plc-core
  set view plc-core
  view include /Objects/PLC1
  view exclude /Objects/PLC1/Diagnostics
  rebrowse
            """)

        elif command == "xargs":
            print("""
xargs - Feed pipe tokens as positional arguments to an internal command

Usage:
  <producer> | xargs <CMD> [<arg>...]

Reads the upstream pipe's whitespace-separated output and runs

  <CMD> [<arg>...] <tok1> <tok2> ...

as if you had typed it. <CMD> must be an internal UaShell command
(subscribe, plot, read, write, call, info, ...). External commands
(grep, awk, ...) are reachable directly through the pipe — there is
no need to wrap them in xargs.

Examples:
  find -type v -name '*Temp*' | xargs subscribe
      Subscribe to every variable whose name contains 'Temp'.

  find -name 'sensor1' -type o | xargs ls
      List children of every object named 'sensor1'.

  find -type m -name 'Init' | xargs -n1 call          (NOT SUPPORTED v1)
      'xargs -n1' is on the roadmap; for now use a script-level loop
      if you need per-item invocation.

Notes:
  - Empty input is a silent no-op (mirrors Unix xargs).
  - Tokens are split on whitespace (newline + space + tab).
  - Quoting of tokens with spaces is NOT supported in v1 — keep
    NodeId paths free of spaces (the standard form is anyway).
            """)

        elif command == "cd":
            print("""
cd - Change current directory in the OPC UA namespace

Usage: cd [path]

Navigate the OPC UA namespace like a filesystem.

Path formats:
  /Objects/system       Absolute path (starts with /)
  Objects/system        Absolute (starts with top-level node)
  Objects.system        Dot notation (auto-converted to /)
  subsys1/fcs           Relative to current location
  ..                    Go up one level (parent)
  ../..                 Go up two levels
  -                     Return to previous directory
  (empty)               Return to root

Examples:
  cd Objects/system/subsys1    - Absolute path
  cd fcs/sensor1               - Relative from current
  cd ..                        - Go up one level
  cd -                         - Return to previous directory
  cd                           - Return to root
            """)

        elif command == "pwd":
            print("""
pwd - Print current working directory

Usage: pwd

Shows the current location in the OPC UA namespace.
            """)

        elif command in ("ls", "ll"):
            print("""
ls / ll - List directory contents

Usage: ls [-l] [path]
       ll [path]        (same as ls -l)

Lists children of current directory or specified path.
Output is color-coded and sorted: Objects first, then Methods, then Variables.

Colors:
  Blue with /     Objects (directories)
  Yellow with ()  Methods
  Green           Variables

Options:
  -l    Long format with permissions, type, and value

Permissions (long format): rwxd
  r = readable, w = writable, x = executable (method), d = directory

Examples:
  ls                      - List current directory
  ll                      - Detailed listing
  ls Server               - List Server contents
  ls -l system/fcs        - Detailed listing of path
            """)

        elif command in ("history", "h"):
            print("""
history / h - Show command history

Usage: history [pattern]
       h [pattern]

Shows recent commands from the session. Optionally filter by pattern.

Examples:
  history           - Show last 100 commands
  history read      - Show commands containing 'read'
  h browse          - Short form
  !N                - Re-run command number N
            """)

        else:
            # External commands are reachable through the pipe operator
            # (e.g. `find ... | grep -i temp`), but they're not UaShell
            # built-ins, so there's no built-in help to print. Tell the
            # user where the documentation lives instead of leaving them
            # confused when 'help grep' returns "no help".
            common_externals = {"grep", "awk", "sed", "sort", "uniq",
                                "head", "tail", "wc", "cut", "tee",
                                "xargs", "less", "cat", "tr"}
            if command.lower() in common_externals:
                print(
                    f"'{command}' is not a UaShell built-in. It's the "
                    f"system /usr/bin/{command}, reachable as a pipe "
                    f"target — e.g. `find -name '*Temp*' | {command} -i "
                    f"sensor`. See 'help' for UaShell pipeline syntax "
                    f"(`man {command}` for {command}'s own options).")
            else:
                print(f"No specific help available for '{command}'. "
                      f"Use 'help' for general information.")
    def _expand_history_recalls(self, command_line: str):
        """Expand '!N' history-recall tokens within a (possibly chained) line.

        Splits on ';' and replaces any segment that is exactly '!N' with the
        recalled history command, leaving other segments untouched. This lets
        chains like '!285;!286;!287' work, as well as a bare '!N'.

        Returns (expanded_line, ok). ok is False if a '!N' references a missing
        history entry or history is unavailable (a message is printed).
        """
        segments = [s.strip() for s in command_line.split(";")]
        out = []
        for seg in segments:
            if seg.startswith("!") and seg[1:].isdigit():
                if not READLINE_AVAILABLE:
                    print("History recall is not available on this platform.")
                    return command_line, False
                idx = int(seg[1:])
                try:
                    recalled = readline.get_history_item(idx)
                except Exception:
                    recalled = None
                if not recalled:
                    print(f"No history entry {idx}")
                    return command_line, False
                out.append(recalled.strip())
            else:
                out.append(seg)
        return "; ".join(out), True

    def cmd_history(self, args: List[str]):
        """Show command history, optionally filtered by a pattern.

        Examples:
          history              - show last 100 commands
          history read         - show last 100 commands containing 'read'
          h read               - same as 'history read'
        """
        if not READLINE_AVAILABLE:
            print("History is not available on this platform.")
            return

        try:
            length = readline.get_current_history_length()
        except Exception:
            print("History is not available.")
            return

        if length == 0:
            print("No commands in history.")
            return

        pattern = args[0].lower() if args else None

        start = max(1, length - 99)
        for idx in range(start, length + 1):
            try:
                item = readline.get_history_item(idx)
            except Exception:
                continue
            if not item:
                continue
            if pattern and pattern not in item.lower():
                continue
            self._emit(f"{idx:4d}  {item}")

    def _get_server_folder_name(self) -> str:
        """Get a sanitized folder name from the server URI.

        Converts opc.tcp://134.171.12.101:4840 -> 134.171.12.101_4840
        """
        # Use host and port from the parsed URL
        # Sanitize: replace non-alphanumeric chars with underscore
        folder_name = f"{self.host}_{self.port}"
        # Additional sanitization - remove any problematic characters
        folder_name = re.sub(r'[^\w\-.]', '_', folder_name)
        return folder_name

    def _get_scripts_dir(self) -> Path:
        """Get the scripts directory path, creating it if needed.

        All scripts are stored in a flat structure: <home_dir>/scripts/
        Script names can optionally include a URI prefix: <host>_<port>_<name>.uas
        """
        scripts_dir = self.home_dir / "scripts"
        scripts_dir.mkdir(parents=True, exist_ok=True)
        return scripts_dir

    # Recognized script types: extension -> kind. '.uas' is command playback
    # (the default), '.py' is a Python script run against the live connection.
    SCRIPT_EXTENSIONS = (".uas", ".py")

    def _list_scripts(self) -> List[str]:
        """List available scripts as full filenames (with extension), sorted."""
        scripts_dir = self._get_scripts_dir()
        names = []
        for ext in self.SCRIPT_EXTENSIONS:
            names.extend(f.name for f in scripts_dir.glob(f"*{ext}"))
        return sorted(names)

    def cmd_save(self, args: List[str]):
        """Save commands from history to a script file.

        Usage:
          save                           - Interactive mode (prompts for all inputs)
          save <from> <to> <name>        - Save commands from-to as script name
          save <from> <to>               - Prompts for script name

        Scripts are saved to ~/.uatools/UaShell/scripts/<name>.uas
        """
        if not READLINE_AVAILABLE:
            print("Error: History not available - cannot save scripts.")
            return

        try:
            history_length = readline.get_current_history_length()
        except Exception:
            print("Error: Cannot access command history.")
            return

        if history_length == 0:
            print("No commands in history to save.")
            return

        # Parse arguments or prompt interactively
        from_idx = None
        to_idx = None
        script_name = None

        if len(args) >= 3:
            # All arguments provided
            try:
                from_idx = int(args[0])
                to_idx = int(args[1])
                script_name = args[2]
            except ValueError:
                print("Error: from and to must be numbers.")
                return
        elif len(args) == 2:
            # from and to provided, prompt for name
            try:
                from_idx = int(args[0])
                to_idx = int(args[1])
            except ValueError:
                print("Error: from and to must be numbers.")
                return
        elif len(args) == 1:
            print("Usage: save <from> <to> <name>")
            print("       save  (interactive mode)")
            return

        # Interactive prompts for missing values
        if from_idx is None:
            # Show recent history to help user choose
            print("\nRecent history:")
            start = max(1, history_length - 20)
            for idx in range(start, history_length + 1):
                try:
                    item = readline.get_history_item(idx)
                    if item:
                        print(f"  {idx:4d}  {item}")
                except Exception:
                    continue
            print()

            try:
                from_str = input("From command number: ").strip()
                if not from_str:
                    print("Cancelled.")
                    return
                from_idx = int(from_str)
            except (ValueError, EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

        if to_idx is None:
            try:
                to_str = input("To command number: ").strip()
                if not to_str:
                    print("Cancelled.")
                    return
                to_idx = int(to_str)
            except (ValueError, EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

        # Validate range
        if from_idx < 1 or to_idx < 1:
            print("Error: Command numbers must be positive.")
            return
        if from_idx > history_length or to_idx > history_length:
            print(f"Error: Command numbers must be <= {history_length}.")
            return
        if from_idx > to_idx:
            print("Error: 'from' must be <= 'to'.")
            return

        # Collect commands from history
        commands = []
        for idx in range(from_idx, to_idx + 1):
            try:
                item = readline.get_history_item(idx)
                if item:
                    # Skip save/load/exec commands themselves
                    cmd_lower = item.strip().lower()
                    if cmd_lower.startswith("save ") or cmd_lower == "save":
                        continue
                    if cmd_lower.startswith("load ") or cmd_lower == "load":
                        continue
                    if cmd_lower.startswith("exec ") or cmd_lower == "exec":
                        continue
                    if cmd_lower.startswith("script"):
                        continue
                    commands.append(item)
            except Exception:
                continue

        if not commands:
            print("No commands found in the specified range.")
            return

        # Show commands to be saved
        print(f"\nCommands to save ({len(commands)}):")
        for i, cmd in enumerate(commands, 1):
            print(f"  {i:3d}  {cmd}")
        print()

        # Get script name if not provided
        if script_name is None:
            existing = self._list_scripts()
            if existing:
                print(f"Existing scripts: {', '.join(existing)}")

            try:
                script_name = input("Script name: ").strip()
                if not script_name:
                    print("Cancelled.")
                    return
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

        # Validate script name (alphanumeric, underscore, hyphen)
        if not re.match(r'^[\w\-]+$', script_name):
            print("Error: Script name can only contain letters, numbers, underscore, and hyphen.")
            return

        # Ask if user wants to include URI prefix in filename
        server_prefix = self._get_server_folder_name()
        try:
            include_uri = input(f"Include server prefix in filename? ({server_prefix}_) [y/N]: ").strip().lower()
            if include_uri == 'y':
                script_name = f"{server_prefix}_{script_name}"
        except (EOFError, KeyboardInterrupt):
            print("\nCancelled.")
            return

        # Check if script exists
        scripts_dir = self._get_scripts_dir()
        script_path = scripts_dir / f"{script_name}.uas"

        if script_path.exists():
            try:
                overwrite = input(f"Script '{script_name}' already exists. Overwrite? [y/N]: ").strip().lower()
                if overwrite != 'y':
                    # Ask for new name
                    try:
                        new_name = input("Enter new name (or empty to cancel): ").strip()
                        if not new_name:
                            print("Cancelled.")
                            return
                        if not re.match(r'^[\w\-]+$', new_name):
                            print("Error: Invalid script name.")
                            return
                        script_name = new_name
                        script_path = scripts_dir / f"{script_name}.uas"
                    except (EOFError, KeyboardInterrupt):
                        print("\nCancelled.")
                        return
            except (EOFError, KeyboardInterrupt):
                print("\nCancelled.")
                return

        # Write the script
        try:
            with open(script_path, 'w') as f:
                f.write(f"# UaShell script: {script_name}\n")
                f.write(f"# Server: {self._get_server_folder_name()}\n")
                f.write(f"# Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write(f"# Commands: {from_idx}-{to_idx}\n")
                f.write("#\n")
                for cmd in commands:
                    f.write(f"{cmd}\n")

            print(f"✅ Saved {len(commands)} commands to: {script_path}")
            print(f"   Server: {self._get_server_folder_name()}")
        except Exception as e:
            print(f"Error saving script: {e}")

    @staticmethod
    def _valid_script_name(name: str) -> bool:
        """Validate a script name for CREATION.

        The only hard requirement is that the name stays inside the scripts
        directory - so we forbid path separators and '..' traversal. Dots are
        allowed because 'save' produces server-prefixed names from IP literals
        (e.g. '127.0.0.1_7777_tst1'). Existing scripts are NOT re-validated;
        this guards new names only.
        """
        if not name:
            return False
        if "/" in name or "\\" in name or name in (".", ".."):
            return False
        if name.startswith(".."):
            return False
        return True

    def _script_path(self, name: str):
        """Path for a script name, defaulting to '.uas' when no known extension.

        A name carrying a recognized extension ('.uas'/'.py') is kept as-is;
        any other name gets '.uas' appended. Used when CREATING a script or
        building the canonical path for a given name.
        """
        if name.endswith(self.SCRIPT_EXTENSIONS):
            return self._get_scripts_dir() / name
        return self._get_scripts_dir() / f"{name}.uas"

    def _resolve_script(self, name: str):
        """Find an EXISTING script file for a (possibly extensionless) name.

        Resolution order:
          1. Exact name as given (honors an explicit .uas/.py).
          2. name + '.uas'  (command-playback default).
          3. name + '.py'   (Python script).
        Returns the Path if found, else None.
        """
        scripts_dir = self._get_scripts_dir()
        # Explicit extension: only that file counts.
        if name.endswith(self.SCRIPT_EXTENSIONS):
            p = scripts_dir / name
            return p if p.exists() else None
        # Bare name: try each extension in priority order.
        for ext in self.SCRIPT_EXTENSIONS:
            p = scripts_dir / f"{name}{ext}"
            if p.exists():
                return p
        return None

    def _script_kind(self, path) -> str:
        """Return 'python' for .py scripts, else 'command'."""
        return "python" if str(path).endswith(".py") else "command"

    def _to_cache_path(self, path: str) -> Optional[str]:
        """Normalize a user/script path to a namespace-cache key, or None.

        Cache keys are slash-separated and carry their top-level prefix
        (e.g. 'Objects/system/subsys1'). A user/script may give a path that is:
          - absolute with a leading '/' or a top-level node (Objects/Server/...),
          - relative to a top-level (e.g. 'system/...', which lives under
            'Objects'),
          - dot-separated instead of slash.
        We try the obvious candidates and return the first that exists in the
        cache (so 'system/subsys1/fcs/motor1' resolves to the real key under
        'Objects/...'). Returns None if nothing matches.
        """
        raw = path.strip().lstrip("/").replace(".", "/")
        if not raw:
            return None
        # Method paths are cached with a trailing '()'; tolerate either form.
        raw = raw[:-2] if raw.endswith("()") else raw

        bases = [raw]
        # If it doesn't already start at a known top-level node, try under each.
        top_level = self.top_level_nodes or {"Objects", "Server", "Types", "Views"}
        first = raw.split("/", 1)[0]
        if first not in top_level:
            for tl in ("Objects", *sorted(top_level)):
                bases.append(f"{tl}/{raw}")

        # For each base, try the plain key and the method-suffixed key.
        for base in bases:
            for cand in (base, f"{base}()"):
                if self.namespace_cache.path_exists(cand):
                    return cand
        return None

    @staticmethod
    def _python_script_stub(filename: str) -> str:
        """Starter content for a new .py script.

        Convention (matches UaExplorer): define a plain `def main(sh):` and use
        the SYNCHRONOUS built-ins below (no 'await'). `sh` is the live UaShell
        scripting API. The script body runs on a worker thread, so the I/O ops
        block normally - exactly like UaExplorer.
        """
        ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        return (
            f"# {filename}\n"
            f"# Generated by UaShell on {ts}\n"
            "#\n"
            "# Entry point: 'def main(sh):' (runs against the live connection).\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"
            "#   WaitUntil(node_id, predicate, timeout=None)\n"
            "#   Log(\"msg\", level=\"INFO\")\n"
            "#       human-readable status -> stdout\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 (local time, or UTC)\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"
            "# Also available on the 'sh' object passed to main():\n"
            "#   sh.find(name=, type=, path=) -> list[str];  sh.exists(path) -> bool\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"
            "def main(sh):\n"
            f"    Log(\"Hello from {filename}\")\n"
        )

    async def cmd_script(self, args: List[str]):
        """Manage saved scripts (git-style subcommands).

        Usage:
          script                  - List all scripts (shortcut for 'list')
          script list             - List all scripts
          script <name>           - Show contents (shortcut for 'cat <name>')
          script cat <name> [-n]  - Print contents (-n: with line numbers)
          script edit <name>      - Open in $EDITOR (created if absent)
          script rm <name>        - Delete a script (asks for confirmation)
          script mv <old> <new>   - Rename a script
          script run <name>       - Run a script (alias for the 'run' command)
        """
        subcommands = {"list", "cat", "edit", "rm", "mv", "run", "save"}

        # Returns True on success / False on failure (used for CLI exit codes;
        # interactive callers ignore it).
        # No args -> list. First arg that is NOT a known subcommand is treated
        # as a script name (shortcut: 'script <name>' == 'script cat <name>').
        if not args:
            self._scripts_list()
            return True

        sub = args[0].lower()
        if sub not in subcommands:
            # Shortcut: 'script <name>' shows contents.
            return self._scripts_cat(args)

        rest = args[1:]
        if sub == "list":
            self._scripts_list()
            return True
        elif sub == "cat":
            return self._scripts_cat(rest)
        elif sub == "edit":
            self._scripts_edit(rest)
        elif sub == "rm":
            self._scripts_rm(rest)
        elif sub == "mv":
            self._scripts_mv(rest)
        elif sub == "save":
            self.cmd_save(rest)
        elif sub == "run":
            await self.cmd_run(rest)
        return True

    def _scripts_list(self):
        """List all saved scripts (both .uas and .py) with type, size, mtime."""
        scripts_dir = self._get_scripts_dir()
        scripts = []
        for ext in self.SCRIPT_EXTENSIONS:
            scripts.extend(scripts_dir.glob(f"*{ext}"))

        if not scripts:
            print("No scripts saved yet.")
            print(f"Scripts directory: {scripts_dir}")
            return

        # Header/footer are status lines (print); the rows are data (_emit)
        # so 'scripts list | grep ...' works. Rows show the FULL filename.
        print(f"Saved scripts ({len(scripts)}):")
        for script in sorted(scripts, key=lambda p: p.name):
            kind = self._script_kind(script)
            try:
                with open(script, 'r') as f:
                    lines = [l for l in f.readlines() if l.strip() and not l.strip().startswith('#')]
                    line_count = len(lines)
            except:
                line_count = '?'
            try:
                mtime = datetime.fromtimestamp(script.stat().st_mtime)
                mtime_str = mtime.strftime('%Y-%m-%d %H:%M')
            except:
                mtime_str = '?'
            # 'commands' for .uas, 'lines' for .py (more accurate label).
            unit = "commands" if kind == "command" else "lines"
            tag = "[py] " if kind == "python" else "[cmd]"
            self._emit(f"  {tag} {script.name:<34}  {line_count:>3} {unit:<8}  {mtime_str}")

        if self._sink is None:
            print(f"\nScripts directory: {scripts_dir}")
            print("Use 'script cat <name>' to view, 'script edit <name>' to edit.")

    def _scripts_cat(self, args: List[str]) -> bool:
        """Print a script's contents (optionally numbered). False if not found."""
        line_numbers = False
        name = None
        for a in args:
            if a in ("-n", "--number"):
                line_numbers = True
            elif name is None:
                name = a
        if not name:
            print("Usage: script cat <name> [-n]")
            return False

        script_path = self._resolve_script(name)
        if not script_path:
            print(f"Error: Script '{name}' not found.")
            available = self._list_scripts()
            if available:
                print(f"Available scripts: {', '.join(available)}")
            return False

        try:
            with open(script_path, 'r') as f:
                content = f.read()
        except Exception as e:
            print(f"Error reading script: {e}")
            return False

        lines = content.splitlines()
        if line_numbers:
            for i, line in enumerate(lines, 1):
                self._emit(f"{i:4d}  {line}")
        else:
            for line in lines:
                self._emit(line)
        return True

    def _scripts_edit(self, args: List[str]):
        """Open a script in $EDITOR (creating it if it does not exist)."""
        if not args:
            print("Usage: script edit <name>")
            return
        name = args[0]

        # Open an existing script if one matches (either extension); otherwise
        # create a new one. The target extension is taken from the name (.py),
        # defaulting to .uas.
        script_path = self._resolve_script(name)
        created = False
        if script_path is None:
            script_path = self._script_path(name)  # adds .uas if no known ext
            # Validate the name (stem) for creation - guard path traversal.
            stem = script_path.stem
            if not self._valid_script_name(stem):
                print("Error: Invalid script name (no '/', '\\', or '..').")
                return
            is_python = script_path.suffix == ".py"
            try:
                with open(script_path, 'w') as f:
                    if is_python:
                        f.write(self._python_script_stub(script_path.name))
                    else:
                        f.write(f"# UaShell script: {script_path.name}\n")
                        f.write(f"# Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                        f.write("#\n")
                created = True
            except Exception as e:
                print(f"Error creating script: {e}")
                return

        editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
        try:
            import subprocess
            subprocess.run([editor, str(script_path)])
        except FileNotFoundError:
            print(f"Error: editor '{editor}' not found. Set $EDITOR to your preferred editor.")
            if created:
                # Leave the stub; user can edit later.
                pass
            return
        except Exception as e:
            print(f"Error launching editor: {e}")
            return

        # Restore our readline completer/bindings, which the editor may have
        # disturbed while it owned the terminal.
        if READLINE_AVAILABLE:
            try:
                readline.set_completer(self._completer)
                readline.parse_and_bind("tab: complete")
            except Exception:
                pass

        print(f"{'Created and edited' if created else 'Edited'}: {script_path.name}")

    def _scripts_rm(self, args: List[str]):
        """Delete a script after confirmation."""
        if not args:
            print("Usage: script rm <name>")
            return
        name = args[0]
        script_path = self._resolve_script(name)
        if not script_path:
            print(f"Error: Script '{name}' not found.")
            return
        try:
            confirm = input(f"Delete script '{script_path.name}'? [y/N]: ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            print("\nCancelled.")
            return
        if confirm != "y":
            print("Cancelled.")
            return
        try:
            script_path.unlink()
            print(f"Deleted: {script_path.name}")
        except Exception as e:
            print(f"Error deleting script: {e}")

    def _scripts_mv(self, args: List[str]):
        """Rename a script (extension preserved unless the new name sets one)."""
        if len(args) < 2:
            print("Usage: script mv <old> <new>")
            return
        old_name, new_name = args[0], args[1]

        old_path = self._resolve_script(old_name)
        if not old_path:
            print(f"Error: Script '{old_name}' not found.")
            return

        # If the new name carries no recognized extension, keep the old one.
        if new_name.endswith(self.SCRIPT_EXTENSIONS):
            new_path = self._get_scripts_dir() / new_name
        else:
            new_path = self._get_scripts_dir() / f"{new_name}{old_path.suffix}"

        if not self._valid_script_name(new_path.stem):
            print("Error: Invalid new name (no '/', '\\', or '..').")
            return
        if new_path.exists():
            print(f"Error: Script '{new_path.name}' already exists.")
            return
        try:
            old_path.rename(new_path)
            print(f"Renamed: {old_path.name} -> {new_path.name}")
        except Exception as e:
            print(f"Error renaming script: {e}")

    async def _run_python_script(self, script_path, dry_run: bool = False,
                                 verbose: bool = False):
        """Execute a .py script against the live connection.

        Convention (same as UaExplorer): the file defines a plain
        'def main(sh):' and uses SYNCHRONOUS built-ins - Read()/Write()/
        Execute()/Wait()/WaitUntil() with NO 'await'. The script body runs on a
        worker thread (via asyncio.to_thread) while the asyncio event loop keeps
        running on the main thread; each built-in bridges its OPC UA I/O back to
        the loop with run_coroutine_threadsafe(...).result(). This makes UaShell
        scripts identical to UaExplorer's - no async surface for the author.

        Arbitrary Python is allowed - this is a local power-user tool, not a
        sandbox.
        """
        try:
            with open(script_path, "r") as f:
                source = f.read()
        except Exception as e:
            print(f"Error reading script: {e}")
            return

        if dry_run:
            print(f"Dry run of '{script_path.name}' (Python script):")
            self._emit(source)
            return

        if not self.connected:
            print("❌ Not connected. Python scripts run against a live connection.")
            return

        loop = asyncio.get_running_loop()
        api = _ScriptApi(self, verbose=verbose, script_path=script_path,
                         loop=loop)

        try:
            import math as _math
        except Exception:
            _math = None
        try:
            import csv as _csv
        except Exception:
            _csv = None
        try:
            import numpy as _np
        except Exception:
            _np = None  # numpy is optional; scripts that need it must handle None

        # Namespace the script body executes in: built-ins + preloaded modules.
        # All built-ins are SYNCHRONOUS (no await) - same as UaExplorer.
        script_globals = {
            "__name__": "__uashell_script__",
            "__file__": str(script_path),
            # Preloaded modules (mirror UaExplorer's set; np may be None).
            "os": os, "sys": sys, "time": time, "math": _math,
            "datetime": datetime, "timedelta": timedelta, "re": re,
            "json": json, "csv": _csv, "Path": Path, "np": _np,
            # Built-in operations.
            "Read": api.read_op,
            "Write": api.write_op,
            "Execute": api.execute_op,
            "Method": api.execute_op,   # alias
            "Wait": api.wait,
            "WaitUntil": api.wait_until,
            "Log": api.log,
            "log": api.log,             # tolerate lowercase
            "Store": api.store,
            "Save": api.store,          # alias
            "Iso": api.iso,
            "Verbose": api.verbose_op,
            "Abort": api.abort,
            "ScriptAborted": ScriptAborted,
            # Level constants for Verbose() / Log().
            "OFF": "OFF", "ERROR": "ERROR", "WARNING": "WARNING",
            "INFO": "INFO", "DEBUG": "DEBUG",
            # Kept for power users.
            "ua": ua,
        }

        def _run_body():
            """Runs on a worker thread. exec() the file, then call main(sh)."""
            code = compile(source, str(script_path), "exec")
            exec(code, script_globals)  # defines main(), helpers, etc.
            main_fn = script_globals.get("main")
            # main(sh) is the entry point. A script with only top-level calls
            # (no main) is also valid - its work happened during exec().
            if main_fn is not None:
                main_fn(api)

        print(f"Running '{script_path.name}' (Python)...")
        print()
        try:
            # to_thread keeps the loop spinning on the main thread, so the
            # built-ins' run_coroutine_threadsafe bridges can complete.
            await asyncio.to_thread(_run_body)
        except ScriptAborted as e:
            print(f"\n⏹️  Script aborted: {e}")
            return
        except Exception:
            import traceback
            print("❌ Script raised an exception:")
            traceback.print_exc()
            return
        finally:
            api._close_data_file()

        print(f"\n✅ Script '{script_path.name}' completed.")

    async def cmd_run(self, args: List[str]):
        """Run a saved script.

        Usage:
          run <script>         - Execute a script (.uas command playback or .py)
          run -v <script>      - Execute with verbose output (show each command)
          run -n <script>      - Dry run (show commands without executing)
        """
        if not args:
            print("Usage: run [-v|-n] <script>")
            scripts = self._list_scripts()
            if scripts:
                print(f"Available scripts: {', '.join(scripts)}")
            return

        # Parse options
        verbose = False
        dry_run = False
        script_name = None

        for arg in args:
            if arg == "-v":
                verbose = True
            elif arg == "-n":
                dry_run = True
            elif not arg.startswith("-"):
                script_name = arg

        if not script_name:
            print("Error: No script name provided.")
            return

        # Resolve the script path. Priority:
        #   1. A literal path (absolute/relative, contains a separator).
        #   2. A name in the scripts dir (either .uas or .py, via _resolve_script).
        script_path = None
        if "/" in script_name or "\\" in script_name:
            p = Path(script_name)
            if p.exists():
                script_path = p
            elif p.with_suffix(".uas").exists():
                script_path = p.with_suffix(".uas")
            elif p.with_suffix(".py").exists():
                script_path = p.with_suffix(".py")
        else:
            script_path = self._resolve_script(script_name)

        if not script_path or not script_path.exists():
            print(f"Error: Script '{script_name}' not found.")
            available = self._list_scripts()
            if available:
                print(f"Available scripts: {', '.join(available)}")
            return

        # Dispatch by type: Python scripts run through the Python executor;
        # everything else is command playback.
        if str(script_path).endswith(".py"):
            await self._run_python_script(script_path, dry_run=dry_run, verbose=verbose)
            return

        # Read and parse script
        try:
            with open(script_path, 'r') as f:
                lines = f.readlines()
        except Exception as e:
            print(f"Error reading script: {e}")
            return

        # Filter out comments and empty lines
        commands = []
        for line in lines:
            line = line.strip()
            if line and not line.startswith('#'):
                commands.append(line)

        if not commands:
            print(f"Script '{script_name}' is empty.")
            return

        if dry_run:
            print(f"Dry run of '{script_name}' ({len(commands)} commands):")
            for i, cmd in enumerate(commands, 1):
                print(f"  {i:3d}  {cmd}")
            return

        print(f"Running '{script_name}' ({len(commands)} commands)...")
        print()

        # Execute each command
        executed = 0
        failed = 0

        for i, command_line in enumerate(commands, 1):
            # Always show the command being executed
            print(f"\033[36m[{command_line}]\033[0m")  # Cyan color for command

            try:
                # Split by semicolon for multiple commands on one line
                command_segments = [seg.strip() for seg in command_line.split(";") if seg.strip()]

                for segment in command_segments:
                    # _execute_segment handles pipes/redirection; _dispatch_command
                    # routes the bare command. script_mode skips meta commands and
                    # shows method results as "cmd -> result".
                    ok = await self._execute_segment(
                        segment,
                        lambda cmd: self._dispatch_command(
                            cmd, script_mode=True, verbose=verbose),
                        verbose=verbose,
                    )
                    if ok is False:
                        failed += 1
                    else:
                        executed += 1

                # Add newline after each command's output for readability
                print()

            except Exception as e:
                print(f"  Error: {e}")
                failed += 1
                print()

        # Summary
        status = "✅" if failed == 0 else "⚠️"
        print(f"{status} Script '{script_name}' completed: {executed} executed, {failed} failed")


    async def _dispatch_command(self, segment: str, script_mode: bool = False,
                                verbose: bool = False) -> bool:
        """Route a single bare command segment to the right cmd_* handler.

        Shared by the interactive loop and script execution so the command set
        stays in one place. `segment` must already have pipes/redirection and
        variable expansion stripped off by the caller.

        In script_mode, meta commands (history/save/scripts/run/exit) are
        skipped and method results are shown with a "cmd -> result" display.

        Returns True if a command was recognized and executed, False otherwise.
        """
        try:
            parts = shlex.split(segment)
        except ValueError as e:
            print(f"Command parsing error: {e}")
            return False

        if not parts:
            return False

        command = parts[0].lower()
        args = parts[1:]

        # Universal per-command help: '<cmd> help', '<cmd> -h', '<cmd> --help'
        # all route to the detailed help for that command. This mirrors the
        # 'help <cmd>' form so users can discover options either way.
        # ('help' itself is excluded so 'help help' / 'help -h' behave normally.)
        known_commands = (
            "subscribe", "plot", "cd", "pwd", "ls",
            "ll", "read", "write", "call", "tree", "find", "info",
            "connect", "disconnect", "rebrowse", "export", "set", "settings",
            "scan", "view", "history", "h", "script", "run", "about",
        )
        if command in known_commands and args and args[0] in ("help", "-h", "--help"):
            self.cmd_help([command])
            return True

        # Bash-style glob expansion for path-based commands. 'find' is excluded:
        # it takes wildcard patterns as its own documented argument syntax, so
        # its '*' must reach it literally. subscribe/plot only operate on
        # variables, so their globs expand to variable nodes only - otherwise a
        # '.../*' glob would also match sibling methods and error on subscribe
        # (BadAttributeIdInvalid).
        if command in ("subscribe", "plot"):
            args = self._expand_globs(args, node_filter="variable")
        elif command in ("cd", "ls", "ll", "read", "write", "call", "info",
                         "tree"):
            args = self._expand_globs(args)

        if command == "subscribe":
            await self.cmd_subscribe(args)
        elif command == "plot":
            await self.cmd_plot(args)
        elif command == "cd":
            await self.cmd_cd(args)
        elif command == "pwd":
            self.cmd_pwd(args)
        elif command == "ls":
            await self.cmd_ls(args)
        elif command == "ll":
            await self.cmd_ls(args, long_format=True)
        elif command == "read":
            await self.cmd_read(args)
        elif command == "write":
            await self.cmd_write(args)
        elif command == "call":
            await self.cmd_call(args)
        elif command == "tree":
            await self.cmd_tree(args)
        elif command == "find":
            await self.cmd_find(args)
        elif command == "info":
            await self.cmd_info(args)
        elif command == "help":
            self.cmd_help(args)
        elif command == "connect":
            await self.cmd_connect(args)
        elif command == "disconnect":
            await self.cmd_disconnect(args)
        elif command == "rebrowse":
            await self.cmd_rebrowse(args)
        elif command == "export":
            await self.cmd_export(args)
        elif command == "set":
            self.cmd_set(args)
        elif command == "settings":
            self.cmd_settings(args)
        elif command == "scan":
            await self.cmd_scan(args)
        elif command == "view":
            await self.cmd_view(args)
        elif command == "about":
            self._emit(about_text())
        elif command in ("history", "h"):
            if script_mode:
                if verbose:
                    print(f"  (skipped: {command})")
                return True
            self.cmd_history(args)
        elif command == "script":
            if script_mode:
                if verbose:
                    print(f"  (skipped: {command})")
                return True
            await self.cmd_script(args)
        elif command == "run":
            if script_mode:
                if verbose:
                    print(f"  (skipped: {command})")
                return True
            await self.cmd_run(args)
        elif command in ("exit", "quit"):
            if script_mode:
                print(f"  (skipped: {command})")
                return True
            # In interactive mode exit/quit are handled by the caller; reaching
            # here means it slipped through - treat as no-op.
            return True
        else:
            # Not a known command - try to interpret as a method call,
            # variable read, or object inspection by path.
            return await self._dispatch_node_expression(
                segment, parts, args, script_mode=script_mode)

        return True

    async def _dispatch_node_expression(self, segment: str, parts: List[str],
                                        args: List[str], script_mode: bool = False) -> bool:
        """Handle a bare node expression typed as a command.

        Examples: `MoveAbs(100)`, `Temperature`, `/Objects/sys/motor1/Stop()`.
        Resolves the path against the namespace and reads/calls/inspects it.
        Returns True if it resolved to something, False if unknown.
        """
        # For method calls with parens, use the full segment (shlex breaks on
        # spaces inside the parentheses).
        if "(" in segment:
            raw_cmd = segment.strip()
        else:
            raw_cmd = parts[0]
        method_args = args

        if "(" in raw_cmd:
            paren_idx = raw_cmd.index("(")
            method_name = raw_cmd[:paren_idx]
            close_idx = raw_cmd.rfind(")")
            # A method call must be well-formed: a ')' that closes the '(' with
            # nothing but whitespace after it. Reject typos like 'Init()h' or
            # 'Init(' instead of silently calling the method.
            if close_idx < paren_idx or raw_cmd[close_idx + 1:].strip():
                if script_mode:
                    print(f"  Unknown command: {parts[0].lower()}")
                else:
                    print(f"Unknown command: {parts[0].lower()}")
                    print("Type 'help' for available commands.")
                return False
            args_str = raw_cmd[paren_idx + 1:close_idx]
            if args_str.strip():
                method_args = [a.strip().strip('"\'') for a in args_str.split(",")]
            else:
                method_args = []
        else:
            method_name = raw_cmd

        looks_like_path = method_name.startswith("/") or "/" in method_name or \
            method_name.startswith("Objects") or method_name.startswith("Server") or \
            method_name.startswith("Types") or method_name.startswith("Views")

        if self.connected and (self.current_path or looks_like_path):
            node = await self.resolve_node_path(method_name, prefer_method=True)
            if node:
                try:
                    node_class = await node.read_node_class()
                    if node_class == ua.NodeClass.Method:
                        cmd_display = None
                        if script_mode:
                            if method_args:
                                cmd_display = f"{method_name}({', '.join(str(a) for a in method_args)})"
                            else:
                                cmd_display = f"{method_name}()"
                        await self._execute_method(node, method_name, method_args,
                                                   command_display=cmd_display)
                        return True
                    elif node_class == ua.NodeClass.Variable:
                        await self.cmd_read([method_name])
                        return True
                    elif node_class == ua.NodeClass.Object:
                        await self.cmd_info([method_name])
                        return True
                except Exception as e:
                    if script_mode:
                        print(f"  Error: {e}")
                    return False

        # xargs is only meaningful on the right side of a pipe; if it
        # reaches the dispatcher unpiped, the user almost certainly
        # meant `... | xargs <CMD>`.
        if parts[0].lower() == "xargs":
            msg = ("xargs only works as a pipe target. "
                   "Usage: <producer> | xargs <CMD> [<arg>...].  "
                   "See 'help xargs'.")
            if script_mode:
                print(f"  {msg}")
            else:
                print(msg)
            return False

        if script_mode:
            print(f"  Unknown command: {parts[0].lower()}")
        else:
            print(f"Unknown command: {parts[0].lower()}")
            print("Type 'help' for available commands.")
        return False

    async def run_interactive(self):
        """Run the interactive command loop"""
        if not await self.connect():
            return

        # Offer to name this server if it's a new (unnamed) URI.
        self._maybe_prompt_for_uri_name()

        print("\nUaShell - Interactive OPC UA Client")
        print("Type 'help' for commands or 'exit' to quit.")
        if READLINE_AVAILABLE:
            print("Use arrow keys to navigate command history.")
        print()

        try:
            while True:
                try:
                    # Run the blocking readline input() on a worker thread so it
                    # does NOT block the asyncio event loop. Otherwise background
                    # tasks (the subscription pump, asyncua datachange delivery)
                    # would freeze while sitting at the prompt - they could only
                    # run between commands.
                    command_line = (await asyncio.to_thread(
                        input, self.get_prompt())).strip()

                    if not command_line:
                        continue

                    # History recall: expand any '!N' tokens into their recalled
                    # command. Done PER SEMICOLON-SEGMENT (after the split) so
                    # chains like '!285;!286;!287' work, not just a bare '!N'.
                    if "!" in command_line:
                        expanded, ok = self._expand_history_recalls(command_line)
                        if not ok:
                            continue  # an invalid !N - message already printed
                        if expanded != command_line:
                            print(expanded)  # echo the expanded line
                            command_line = expanded

                    # Add to history (avoid consecutive duplicates)
                    if READLINE_AVAILABLE and command_line:
                        history_length = readline.get_current_history_length()
                        last_command = ""
                        if history_length > 0:
                            last_command = readline.get_history_item(history_length)

                        if command_line != last_command:
                            readline.add_history(command_line)

                    if command_line.lower() in ['exit', 'quit']:
                        break

                    # Split by semicolon for multiple commands
                    command_segments = [seg.strip() for seg in command_line.split(";") if seg.strip()]

                    for segment in command_segments:
                        # _execute_segment handles pipes (| external) and
                        # redirection (>, >>); _dispatch_command does the
                        # actual cmd_* routing for the bare command.
                        await self._execute_segment(
                            segment,
                            lambda cmd: self._dispatch_command(cmd, script_mode=False),
                        )

                except KeyboardInterrupt:
                    print("\nUse 'exit' to quit.")
                except EOFError:
                    break
                except Exception as e:
                    print(f"Error: {e}")

        finally:
            # Save command history before disconnecting
            self._save_history()
            await self.disconnect()


class _SubViewerClient:
    """Launches and talks to the UaSubscription viewer process.

    UaShell owns the OPC UA subscription; this client pushes value-change
    updates to the separate viewer over its QLocalServer (a unix-domain socket
    on Linux). We use plain asyncio for the socket so UaShell needs no Qt
    dependency. The viewer is only available if the 'UaSubscription' executable
    is found on PATH (and it imports PyQt6); otherwise subscription display is
    simply unavailable.
    """

    # Subclass hooks: executable name on PATH, dev source subdir, socket prefix,
    # and a human label used in messages.
    VIEWER_EXECUTABLE = "UaSubscription"
    DEV_TOOL_DIR = "uasubscription"
    DEV_SCRIPT = "UaSubscription.py"
    SOCKET_PREFIX = "uasubscription"
    LABEL = "subscription viewer"

    def __init__(self, channel: str, title: str = "OPC UA Subscriptions",
                 uri: str = "", server_name: str = ""):
        self.channel = self._safe_channel(channel)
        self.title = title
        # Passed to the viewer so its header shows Server/Name. The viewer is
        # driven by our push path (--socket), so the URI here is DISPLAY-ONLY:
        # the viewer must not open its own connection (we own it).
        self.uri = uri
        self.server_name = server_name
        self._proc = None
        self._reader = None
        self._writer = None
        self._socket_path = None
        self._lock = asyncio.Lock()

    def _safe_channel(self, name: str) -> str:
        safe = re.sub(r"[^A-Za-z0-9_]", "_", name or "")
        return safe[:64] or self.SOCKET_PREFIX

    @classmethod
    def find_viewer(cls) -> Optional[str]:
        """Locate the viewer executable on PATH (or a dev .py copy).

        Returns a path string, or None if the viewer is not available.
        """
        import shutil
        found = shutil.which(cls.VIEWER_EXECUTABLE)
        if found:
            return found
        # Dev fallback: the source next to this tool's tree.
        try:
            here = Path(__file__).resolve()
            # .../tools/uashell/src/UaShell.py -> .../tools/<tool>/src/<Script>.py
            candidate = here.parents[2] / cls.DEV_TOOL_DIR / "src" / cls.DEV_SCRIPT
            if candidate.exists():
                return str(candidate)
        except Exception:
            pass
        return None

    def _launch_cmd(self, executable: str, socket_path: str) -> list:
        """Build the argv to launch the viewer. Subclasses override to pass
        viewer-specific flags (UaSubscription: --title; UaPlot: --uri)."""
        base = [sys.executable, executable] if executable.endswith(".py") else [executable]
        cmd = base + ["--socket", socket_path, "--title", self.title]
        # Display-only URI + friendly name so the viewer header shows
        # "Server: <uri> / Name: <name>". --socket keeps the viewer in
        # push mode (it won't open its own connection).
        if self.uri:
            cmd += ["--display-uri", self.uri]
        if self.server_name:
            cmd += ["--server-name", self.server_name]
        return cmd

    async def launch(self, timeout: float = 8.0) -> bool:
        """Spawn the viewer on a controller-dictated socket path, then connect.

        UaShell chooses the socket path and passes it to the viewer, so both
        ends agree on the exact path with no guessing and no stdout handshake.
        We then poll-connect to that path and confirm with a ping.
        """
        viewer = self.find_viewer()
        if not viewer:
            return False

        # Controller-chosen socket path (unique per shell process).
        tmp = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
        self._socket_path = os.path.join(tmp, f"{self.channel}.sock")
        try:
            if os.path.exists(self._socket_path):
                os.unlink(self._socket_path)
        except Exception:
            pass

        import subprocess
        cmd = self._launch_cmd(viewer, self._socket_path)
        try:
            self._proc = subprocess.Popen(cmd)
        except Exception as e:
            self.logger_warn(f"Could not launch {self.LABEL}: {e}")
            return False

        # Wait for the socket to appear and accept a connection (bounded).
        deadline = time.time() + timeout
        while time.time() < deadline:
            if self._proc.poll() is not None:
                self.logger_warn(
                    f"{self.LABEL.capitalize()} exited during startup "
                    f"(rc={self._proc.returncode}).")
                return False
            if os.path.exists(self._socket_path) and await self._connect():
                resp = await self._request({"command": "ping", "id": "1"})
                if resp and resp.get("type") == "pong":
                    return True
            await asyncio.sleep(0.2)
        self.logger_warn(f"{self.LABEL.capitalize()} did not become ready in time.")
        return False

    def logger_warn(self, msg: str):
        print(f"⚠️  {msg}")

    async def _connect(self) -> bool:
        if self._writer is not None:
            return True
        if not self._socket_path:
            return False
        try:
            self._reader, self._writer = \
                await asyncio.open_unix_connection(self._socket_path)
            return True
        except Exception:
            return False

    async def _request(self, payload: dict, expect_reply: bool = True):
        """Send a JSON command; optionally read a one-line JSON reply."""
        async with self._lock:
            if self._writer is None and not await self._connect():
                return None
            try:
                self._writer.write((json.dumps(payload) + "\n").encode())
                await self._writer.drain()
                if not expect_reply:
                    return {}
                line = await asyncio.wait_for(self._reader.readline(), timeout=2.0)
                if not line:
                    return None
                return json.loads(line.decode())
            except Exception:
                # Connection dropped (viewer closed); reset so a later send
                # can detect the viewer is gone.
                self._reader = self._writer = None
                return None

    def is_alive(self) -> bool:
        return self._proc is not None and self._proc.poll() is None

    def terminate_process(self):
        """Synchronously kill the viewer process. Safe to call without a
        running event loop (used as a last-resort cleanup on exit)."""
        proc = self._proc
        if proc is not None and proc.poll() is None:
            try:
                proc.terminate()
                proc.wait(timeout=2.0)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        self._proc = None
        if self._socket_path:
            try:
                if os.path.exists(self._socket_path):
                    os.unlink(self._socket_path)
            except Exception:
                pass

    async def send_update(self, node, name, value, dtype, quality, ts):
        await self._request({
            "command": "update", "node": node, "name": name,
            "value": value, "type": dtype, "quality": quality, "ts": ts,
        }, expect_reply=False)

    async def send_remove(self, node):
        await self._request({"command": "remove", "node": node}, expect_reply=False)

    async def send_clear(self):
        await self._request({"command": "clear"}, expect_reply=False)

    async def quit(self):
        # Politely ask the viewer to close...
        if self.is_alive():
            await self._request({"command": "quit"}, expect_reply=False)
        if self._writer is not None:
            try:
                self._writer.close()
            except Exception:
                pass
        self._reader = self._writer = None

        # ...then make sure the process is actually gone. The 'quit' command
        # is best-effort; if it didn't take effect, terminate (then kill).
        proc = self._proc
        if proc is not None and proc.poll() is None:
            await asyncio.sleep(0.3)
            if proc.poll() is None:
                try:
                    proc.terminate()
                except Exception:
                    pass
                try:
                    await asyncio.wait_for(asyncio.to_thread(proc.wait), timeout=2.0)
                except Exception:
                    try:
                        proc.kill()
                    except Exception:
                        pass
        self._proc = None

        # Clean up the socket file we created.
        if self._socket_path:
            try:
                if os.path.exists(self._socket_path):
                    os.unlink(self._socket_path)
            except Exception:
                pass


class _PlotClient(_SubViewerClient):
    """Launches and talks to the UaPlot application.

    Same transport as _SubViewerClient (controller-dictated --socket path,
    plain-asyncio JSON), but UaPlot connects to the OPC UA server ITSELF, so we
    pass --uri and send 'plot_variable' commands (UaPlot reads the values).
    Available only if 'UaPlot' is on PATH.
    """

    VIEWER_EXECUTABLE = "UaPlot"
    DEV_TOOL_DIR = "uaplot"
    DEV_SCRIPT = "UaPlot.py"
    SOCKET_PREFIX = "uaplot"
    LABEL = "UaPlot"

    def __init__(self, channel: str, uri: str, instance_name: str = "uaplot",
                 server_name: str = ""):
        super().__init__(channel, uri=uri, server_name=server_name)
        self.uri = uri
        # Name of this UaPlot instance. Plots are referenced shell-side as
        # "<instance_name>_<plot title>" so the reference stays unique once
        # multiple UaPlot instances are supported. Today there is one instance.
        self.instance_name = instance_name

    def _launch_cmd(self, executable: str, socket_path: str) -> list:
        base = [sys.executable, executable] if executable.endswith(".py") else [executable]
        cmd = base + ["--socket", socket_path]
        if self.uri:
            cmd += ["--uri", self.uri]
        # Propagate the friendly name so UaPlot's header shows it too.
        if self.server_name:
            cmd += ["--server-name", self.server_name]
        return cmd

    async def _request(self, payload: dict, expect_reply: bool = True):
        """Send one command on a FRESH connection.

        UaPlot's command server calls disconnectFromServer() after each
        request batch, so a reused connection is dead for the next command -
        only every other command would land (the bug). Unlike UaSubscription
        (which keeps the connection open and is push-driven), UaPlot expects a
        connect-per-request client (same as UaExplorer's Qt client). So we open
        a new connection for every request and close it after.
        """
        async with self._lock:
            try:
                reader, writer = await asyncio.open_unix_connection(self._socket_path)
            except Exception:
                return None
            try:
                writer.write((json.dumps(payload) + "\n").encode())
                await writer.drain()
                result = {}
                if expect_reply:
                    line = await asyncio.wait_for(reader.readline(), timeout=2.0)
                    result = json.loads(line.decode()) if line else None
                return result
            except Exception:
                return None
            finally:
                try:
                    writer.close()
                except Exception:
                    pass

    async def plot_variable(self, node_id: str, display_name: str,
                            plot_id=None, title=None, focus: bool = True):
        """Add a node to a plot (UaPlot creates the plot if plot_id is None).

        IMPORTANT: send monitor_mode/polling_interval_ms on EVERY call (matching
        UaExplorer's client). UaPlot restarts its data worker whenever these
        differ from the running worker's values; omitting them lets UaPlot's
        defaults drift, which triggers a worker restart on each call and drops
        previously-added series. Consistent values => no restart => all series
        persist.
        """
        payload = {
            "command": "plot_variable", "id": "p",
            "node_id": node_id, "display_name": display_name,
            "uri": self.uri, "focus": focus,
            "monitor_mode": "subscription", "polling_interval_ms": 500,
        }
        if plot_id:
            payload["plot_id"] = plot_id
        if title:
            payload["title"] = title
        return await self._request(payload)

    async def create_plot(self, title: str = None):
        payload = {"command": "create_plot", "id": "c"}
        if title:
            payload["title"] = title
        return await self._request(payload)

    async def plot_details(self):
        """Get all plots with their plotted variables."""
        return await self._request({"command": "get_plot_details", "id": "d"})

    async def remove_plot(self, plot_id: str):
        """Remove a plot by its internal id (no confirmation in UaPlot)."""
        return await self._request(
            {"command": "remove_plot", "id": "r", "plot_id": plot_id})

    async def remove_series(self, plot_id: str, node_id: str):
        """Remove a single curve (series) from a plot."""
        return await self._request(
            {"command": "remove_series", "id": "rs",
             "plot_id": plot_id, "node_id": node_id})

    def plot_ref(self, title: str) -> str:
        """Build the shell-facing unique reference for a plot title."""
        return f"{self.instance_name}_{title}"


class ScriptAborted(Exception):
    """Raised by the Abort() built-in to stop a .py script cleanly.

    Caught by the script runner so an Abort() reads as a normal stop
    (not a crash), mirroring UaExplorer's ScriptAborted.
    """


class _ScriptApi:
    """Live API handed to Python (.py) scripts as `sh`.

    Thin async facade over the connected UaShell. Methods RETURN values
    (unlike the interactive cmd_* methods which print), so scripts can use
    the results in normal Python control flow.

    Also backs the UaExplorer-style top-level built-ins (Read/Write/Execute/
    Wait/WaitUntil/Log/Store/Iso/Verbose/Abort) injected into script globals
    by _run_python_script. The script body runs on a WORKER THREAD, so these
    built-ins are SYNCHRONOUS (no await) - identical to UaExplorer's. Each I/O
    built-in bridges its coroutine back to the asyncio event loop (running on
    the main thread) via run_coroutine_threadsafe(...).result(). The async
    read/write/call methods below are the actual coroutines; read_op/write_op/
    execute_op are the synchronous bridges the script calls.
    """

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

    def __init__(self, shell: "UaShell", verbose: bool = False,
                 script_path: "Path" = None, use_utc: bool = False,
                 loop: "asyncio.AbstractEventLoop" = None):
        self._shell = shell
        self.verbose = verbose
        # The event loop the OPC UA client lives on. Synchronous built-ins
        # called from the worker thread bridge to it via _run_coro.
        self._loop = loop
        # Verbose() auto-logging level (None = silent). Distinct from the
        # legacy `verbose` flag, which only governed the old sh.* prints.
        self._verbose_level = None
        self._use_utc = use_utc
        self._script_path = Path(script_path) if script_path else None
        # Store() lazily opens one .dat file per run, next to the script
        # (or in the scripts dir if the path is unknown).
        self._data_fp = None
        self._data_file = None
        self._data_filename_ts = None

    def _run_coro(self, coro):
        """Run a coroutine on the OPC UA event loop, block for the result.

        Called from the script worker thread. Mirrors UaExplorer's bridge so
        the built-ins can be synchronous. Requires _loop to be set (it always
        is when a script runs); falls back to asyncio.run only as a safety net.
        """
        if self._loop is not None:
            return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
        return asyncio.run(coro)

    async def _resolve(self, path: str, prefer_method: bool = False):
        """Resolve a script path to a Node.

        Prefers the namespace cache (path -> node_id), which reliably handles
        top-level-relative paths like 'system/subsys1/fcs/motor1/State'. Falls
        back to the shell's resolver for node-id strings and other formats.
        """
        cache_path = self._shell._to_cache_path(path)
        if cache_path is not None:
            cached = self._shell.namespace_cache.get_node(cache_path)
            if cached is not None:
                return self._shell.client.get_node(cached.node_id_str)
        return await self._shell.resolve_node_path(path, prefer_method=prefer_method)

    # --- node access ---------------------------------------------------
    async def read(self, path: str):
        """Read and return a node's value. Raises if the node is not found."""
        node = await self._resolve(path)
        if node is None:
            raise LookupError(f"Node not found: {path}")
        return await node.read_value()

    async def write(self, path: str, value):
        """Write a value to a node, converting to the node's data type."""
        node = await self._resolve(path)
        if node is None:
            raise LookupError(f"Node not found: {path}")
        data_type = await node.read_data_type_as_variant_type()
        await node.write_value(ua.Variant(value, data_type))
        if self.verbose:
            print(f"write {path} = {value}")

    async def call(self, path: str, *call_args):
        """Call a method node and return its result."""
        node = await self._resolve(path, prefer_method=True)
        if node is None:
            raise LookupError(f"Method not found: {path}")
        parent = await node.get_parent()
        result = await parent.call_method(node, *call_args)
        if self.verbose:
            print(f"call {path}({', '.join(map(str, call_args))}) -> {result}")
        return result

    # --- synchronous built-in bridges (called from the worker thread) --
    def read_op(self, path: str):
        """Read() built-in: synchronous, bridges to the event loop."""
        value = self._run_coro(self.read(path))
        self._auto_log(f"Read({path!r}) -> {value!r}")
        return value

    def write_op(self, path: str, value) -> None:
        """Write() built-in: synchronous, bridges to the event loop."""
        self._auto_log(f"Write({path!r}, {value!r})")
        self._run_coro(self.write(path, value))

    def execute_op(self, path: str, *args):
        """Execute()/Method() built-in: synchronous, bridges to the loop."""
        result = self._run_coro(self.call(path, *args))
        self._auto_log(f"Execute({path!r}, {args}) -> {result!r}")
        return result

    # --- discovery -----------------------------------------------------
    def find(self, name: str = None, type: str = None, path: str = None,
             start: str = "") -> List[str]:
        """Return matching node paths from the namespace cache.

        type: 'object' | 'variable' | 'method' (or None for all).
        name/path: fnmatch-style wildcard patterns (path matched against the
        full cache path, e.g. 'Objects/system/.../motor1').
        start: limit the search to this subtree (resolved like a normal path).
        """
        start_path = ""
        if start:
            start_path = self._shell._to_cache_path(start) or start
        nodes = self._shell.namespace_cache.find_nodes(
            name_pattern=name, path_pattern=path, type_filter=type,
            start_path=start_path,
        )
        return [n.path for n in nodes]

    def exists(self, path: str) -> bool:
        """True if the path exists in the namespace cache.

        Resolves relative paths (e.g. 'system/subsys1/fcs/motor1') against the
        top-level prefixes the cache uses, so scripts can use the same paths
        they type interactively.
        """
        return self._shell._to_cache_path(path) is not None

    # --- UaExplorer-style built-ins ------------------------------------
    # These back the top-level Read/Write/Execute/... names injected into
    # script globals. Read/Write/Execute reuse read/write/call above (with
    # auto-logging); the rest are new.

    def _auto_log(self, msg: str) -> None:
        """Emit a status line when Verbose() is on (mirrors UaExplorer)."""
        if self._verbose_level is not None:
            self.log(msg, level=self._verbose_level)

    def verbose_op(self, level: str = "INFO") -> None:
        """Enable/disable auto-logging of script ops. Verbose(OFF) silences."""
        level_name = "INFO" if level is None else 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
        self.log(f"Verbose: {self._verbose_level or 'OFF'}")

    def wait(self, seconds: float) -> None:
        """Wait() built-in: synchronous sleep on the worker thread.

        Sleeps in small slices so the runner thread stays responsive (and a
        future Stop/cancel can interrupt it). Does not block the event loop -
        the loop keeps running on the main thread.
        """
        self._auto_log(f"Wait({seconds!r})")
        deadline = time.monotonic() + max(0.0, float(seconds))
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return
            time.sleep(min(0.1, remaining))

    def wait_until(self, path: str, predicate, timeout: float = None,
                   poll_interval: float = 0.2):
        """WaitUntil() built-in: poll `path` until `predicate(value)` is truthy.

        Synchronous (worker thread). Returns the matching value; raises
        TimeoutError if `timeout` (seconds) elapses first.
        """
        self._auto_log(f"WaitUntil({path!r}, predicate, timeout={timeout!r})")
        deadline = None if timeout is None else (time.monotonic() + float(timeout))
        while True:
            value = self.read_op(path)
            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({path!r}) -> {value!r}")
                return value
            if deadline is not None and time.monotonic() >= deadline:
                raise TimeoutError(
                    f"WaitUntil({path!r}) timed out after {timeout}s, "
                    f"last value={value!r}")
            time.sleep(poll_interval)

    def log(self, msg: str, level: str = "INFO") -> None:
        """Human-readable status line, prefixed with the level."""
        print(f"[{str(level).upper()}] {msg}")

    def iso(self) -> str:
        """ISO-8601 timestamp, millisecond precision. UTC if use_utc set."""
        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 abort(self, msg: str = "") -> None:
        raise ScriptAborted(msg or "Script aborted by Abort()")

    @staticmethod
    def _format_store_value(v) -> str:
        """Convert one Store() argument to a tab-safe text token."""
        s = "" if v is None else str(v)
        return s.replace("\t", " ").replace("\n", " ")

    def _ensure_data_file(self):
        """Lazily open the per-run .dat file (<script>_<iso-ms>.dat)."""
        if self._data_fp is not None:
            return self._data_fp
        if not self._data_filename_ts:
            self._data_filename_ts = datetime.now().strftime(
                "%Y-%m-%dT%H-%M-%S.%f")[:-3]
        if self._script_path is not None:
            base_dir = self._script_path.parent
            stem = self._script_path.stem
        else:
            base_dir = self._shell.home_dir / "scripts"
            stem = "script"
        self._data_file = base_dir / f"{stem}_{self._data_filename_ts}.dat"
        try:
            self._data_file.parent.mkdir(parents=True, exist_ok=True)
            self._data_fp = open(self._data_file, "w", encoding="utf-8")
            self.log(f"Store: writing to {self._data_file}")
        except Exception as e:
            self.log(f"Store: could not open {self._data_file}: {e}", level="ERROR")
            self._data_file = None
            self._data_fp = None
        return self._data_fp

    def store(self, *values) -> None:
        """Append one tab-separated record to the per-run .dat file.

        Echoes the row to stdout as 'INFO [DATA] ...' so it is visible live
        and grep-able offline.
        """
        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.log(f"[DATA] {line}")
        except Exception as e:
            self.log(f"Store: write failed: {e}", level="ERROR")

    def _close_data_file(self) -> None:
        """Close the .dat file if Store() opened one. Called by the runner."""
        if self._data_fp is not None:
            try:
                self._data_fp.close()
            except Exception:
                pass
            self._data_fp = None

    # --- escape hatch --------------------------------------------------
    @property
    def shell(self) -> "UaShell":
        """The underlying UaShell instance, for advanced use."""
        return self._shell


def normalize_opc_url(url_input: str, prompt_for_port: bool = True) -> str:
    """Normalize an OPC UA URL input.

    Accepts various formats:
      - Full URL: opc.tcp://host:port (case-insensitive)
      - Host:port: host:port (adds opc.tcp://)
      - Bare port (digits only): treated as 127.0.0.1:<port>
        (e.g. "7777" -> "opc.tcp://127.0.0.1:7777").
        We use the literal IP rather than the name "localhost" so the
        shorthand works on hosts whose /etc/hosts is misconfigured or
        which resolve "localhost" to IPv6 ::1 first while the OPC UA
        server only listens on IPv4.
      - Host only: host (prompts for port if prompt_for_port=True, else uses 4840)

    Returns the normalized URL in format: opc.tcp://host:port
    """
    url_input = url_input.strip()

    # Bare port number (digits only) -> 127.0.0.1:<port>. Has to come
    # BEFORE the "Just host" branch below or "7777" would be treated as
    # a hostname and prompt for a port.
    if url_input.isdigit():
        return f"opc.tcp://127.0.0.1:{url_input}"

    # Already has opc.tcp:// prefix (case-insensitive check)
    if url_input.lower().startswith("opc.tcp://"):
        # Normalize the prefix to lowercase
        url_input = "opc.tcp://" + url_input[10:]
        # Check if port is specified
        remainder = url_input[10:]  # After "opc.tcp://"
        if ":" in remainder:
            return url_input  # Full URL with port
        else:
            # No port specified
            if prompt_for_port:
                try:
                    port_str = input(f"Port [4840]: ").strip()
                    port = int(port_str) if port_str else 4840
                except (EOFError, KeyboardInterrupt):
                    print()
                    return None
                except ValueError:
                    print("Invalid port number, using 4840")
                    port = 4840
            else:
                port = 4840
            return f"{url_input}:{port}"

    # No opc.tcp:// prefix - check for host:port or just host
    if ":" in url_input:
        # host:port format
        return f"opc.tcp://{url_input}"
    else:
        # Just host - need port
        if prompt_for_port:
            try:
                port_str = input(f"Port [4840]: ").strip()
                port = int(port_str) if port_str else 4840
            except (EOFError, KeyboardInterrupt):
                print()
                return None
            except ValueError:
                print("Invalid port number, using 4840")
                port = 4840
        else:
            port = 4840
        return f"opc.tcp://{url_input}:{port}"


def load_uri_history() -> List[str]:
    """Load URI history from file (standalone function for use before UaShell is instantiated)"""
    uri_history_file = Path.home() / ".uatools" / "UaShell" / "uri_history"
    uri_history = []
    if uri_history_file.exists():
        try:
            with open(uri_history_file, 'r') as f:
                for line in f:
                    uri = line.strip()
                    if uri and uri not in uri_history:
                        uri_history.append(uri)
            # Keep only last 20
            uri_history = uri_history[-20:]
        except Exception:
            pass  # Silently ignore errors loading URI history
    return uri_history


def load_server_names() -> Dict[str, str]:
    """Load URI->name bindings from settings.json (standalone, pre-instance)."""
    settings_file = Path.home() / ".uatools" / "UaShell" / "settings.json"
    if not settings_file.exists():
        return {}
    try:
        with open(settings_file, "r") as f:
            data = json.load(f)
        names = data.get("server_names", {})
        if isinstance(names, dict):
            return {str(k): str(v) for k, v in names.items()}
    except Exception:
        pass
    return {}


def get_uri_completions(typed_text: str, uri_history: List[str],
                        server_names: Dict[str, str] = None) -> List[str]:
    """Get URI completions matching what the user has typed.

    Completions are always URIs (so Tab inserts a URI). A typed NAME also
    matches its URI - so typing a saved name and pressing Tab completes to the
    corresponding URI. Names are not returned as separate candidates; the
    caller's display hook shows the name in an aligned column next to its URI.

    Args:
        typed_text: text typed (e.g. "opc.tcp://192", "192", or a saved name)
        uri_history: List of previously used URIs
        server_names: Optional URI->name map (for name-prefix matching).

    Returns:
        List of matching URIs (most recent first).
    """
    server_names = server_names or {}
    if not uri_history:
        return []

    typed_lower = typed_text.lower()
    # URIs whose saved name starts with the typed text also match, so a typed
    # name Tab-completes to its URI.
    name_uri_match = {
        uri for uri, nm in server_names.items()
        if typed_text and nm.lower().startswith(typed_lower)
    }

    # Most recent first
    uri_options = list(reversed(uri_history))
    if not typed_text:
        return uri_options

    matches = []
    for uri in uri_options:
        uri_lower = uri.lower()
        if uri in name_uri_match:
            matches.append(uri)
        elif uri_lower.startswith(typed_lower):
            matches.append(uri)
        # Allow matching without opc.tcp:// prefix
        # e.g., typing "192" should match "opc.tcp://192.168.1.100:4840"
        elif not typed_lower.startswith("opc.tcp://"):
            if uri_lower.startswith("opc.tcp://"):
                host_port = uri_lower[10:]  # After "opc.tcp://"
                if host_port.startswith(typed_lower):
                    matches.append(uri)
    return matches


def prompt_for_uri_with_completion(prompt_text: str, uri_history: List[str],
                                   server_names: Dict[str, str] = None) -> str:
    """Prompt for a URI or saved name, with tab completion (vertical display).

    A saved name (from 'set name') may be entered instead of a URI; the caller
    resolves it. The completion list shows saved names and annotates named URIs
    with '← name'.
    """
    server_names = server_names or {}
    # Reverse map (uri -> name) for display annotation.
    uri_to_name = {u: n for u, n in server_names.items()}

    if not READLINE_AVAILABLE:
        # No readline - just use regular input
        return input(prompt_text).strip()

    if not uri_history and not server_names:
        # Nothing to complete - disable tab to prevent cursor skip
        old_completer = readline.get_completer()
        try:
            readline.set_completer(lambda text, state: None)
            readline.parse_and_bind("tab: complete")
            result = input(prompt_text).strip()
            return result
        finally:
            readline.set_completer(old_completer)

    def uri_completer(text, state):
        # Use shared function for URI matching (a typed name matches its URI).
        matches = get_uri_completions(text, uri_history, server_names)
        # On empty input every URI shares the 'opc.tcp://' prefix, which
        # readline would auto-insert. Append an empty sentinel so the longest
        # common prefix is empty (line untouched); the display hook drops it.
        if not text.strip() and len(matches) > 1:
            matches = matches + [""]
        if state < len(matches):
            return matches[state]
        return None

    def uri_display_hook(substitution, matches, longest_match_length):
        """Display matches as two aligned columns: name (left, blank if
        unnamed) and URI. Completion still inserts the URI."""
        print()  # Move to next line
        shown = [m for m in matches if m != ""]  # drop the prefix sentinel
        name_w = max((len(uri_to_name.get(m, "")) for m in shown), default=0)
        for match in shown:
            name = uri_to_name.get(match, "")
            if name_w:
                name_col = f"\033[90m{name.ljust(name_w)}\033[0m"
                print(f"  {name_col}  {match}")
            else:
                print(f"  {match}")
        # Redisplay prompt and current input
        print(prompt_text + readline.get_line_buffer(), end="", flush=True)

    # Save current completer, delimiters, and display hook (hook not available on Windows readline)
    old_completer = readline.get_completer()
    old_display_hook = None
    has_display_hook = getattr(readline, "set_completion_display_matches_hook", None) is not None
    try:
        old_delims = readline.get_completer_delims()
    except Exception:
        old_delims = None
    if has_display_hook:
        try:
            old_display_hook = readline.get_completion_display_matches_hook()
        except Exception:
            pass

    try:
        # Set up URI completion (with vertical display hook on Linux; completion only on Windows)
        readline.set_completer(uri_completer)
        readline.set_completer_delims(' \t\n')  # Don't break on : and /
        if has_display_hook:
            readline.set_completion_display_matches_hook(uri_display_hook)
        readline.parse_and_bind("tab: complete")

        # Get input
        result = input(prompt_text).strip()
        return result
    finally:
        # Restore original settings
        readline.set_completer(old_completer)
        if old_delims is not None:
            try:
                readline.set_completer_delims(old_delims)
            except Exception:
                pass
        if has_display_hook:
            if old_display_hook is not None:
                try:
                    readline.set_completion_display_matches_hook(old_display_hook)
                except Exception:
                    pass
            else:
                try:
                    readline.set_completion_display_matches_hook(None)
                except Exception:
                    pass


def main():
    parser = argparse.ArgumentParser(
        description="UaShell - Interactive OPC UA Client",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  UaShell                                    # Prompts for server address
  UaShell -u 192.168.1.100:4840              # Connect using host:port
  UaShell --url 192.168.1.100                # Prompts for port
  UaShell --uri opc.tcp://localhost:4840     # --uri is an alias for --url
  UaShell -s                                 # List saved scripts (.uas + .py)
  UaShell -s cat my_script                   # Show script contents
  UaShell -s edit my_script.py               # Create/edit a script
  UaShell -s rm old_script                   # Delete a script
  UaShell -u localhost:4840 -s run my_script # Run a script in batch and exit
  UaShell --version                          # Print version and exit
        """
    )

    parser.add_argument(
        "-u", "--url", "--uri",
        dest="url",
        help="OPC UA server address (e.g., localhost:4840, 192.168.1.100, or opc.tcp://host:port)"
    )

    parser.add_argument(
        "-s", "--script",
        dest="script",
        nargs="*",
        default=None,  # None => option not given; [] => given with no args
        metavar="ARG",
        help="Manage scripts: same subcommands as the interactive 'script' "
             "command (list/cat/edit/rm/mv/run). No arg lists scripts. "
             "'run' requires -u/--url."
    )

    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Output diagnostic logs to stdout"
    )

    parser.add_argument(
        "-l", "--log-level",
        dest="log_level",
        metavar="LEVEL",
        default=None,
        help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) or "
             "logger:LEVEL"
    )

    parser.add_argument(
        "--version",
        action="version",
        version=f"UaShell {__version__}",
    )

    parser.add_argument(
        "--about",
        action="store_true",
        help="Print version and about information, then exit"
    )

    args = parser.parse_args()

    # --about: print version + about text and exit.
    if args.about:
        print(about_text())
        sys.exit(0)

    # Configure logging from -l/--log-level and -v/--verbose. -v routes logs to
    # stdout; --log-level sets the level (default CRITICAL keeps the UI quiet).
    _configure_logging(args.log_level, args.verbose)

    # Handle --script / -s: forward to the SAME 'script' subcommand interface
    # used interactively (list/cat/edit/rm/mv/run), so the CLI and the shell
    # behave identically (including .py scripts). 'run' executes a script in
    # batch and needs a -u/--url connection; all other subcommands are offline
    # file operations.
    if args.script is not None:
        sub_args = args.script  # e.g. [], ["list"], ["cat","x"], ["run","x"]
        needs_connection = bool(sub_args) and sub_args[0].lower() == "run"

        if needs_connection:
            url = args.url
            if not url:
                print("Error: -u/--url is required for 'script run'")
                sys.exit(1)
            url = normalize_opc_url(url, prompt_for_port=True)
            if url is None:
                print("Cancelled.")
                sys.exit(0)
            cli = UaShell(url)

            async def _script_run():
                if not await cli.connect():
                    return 1
                try:
                    run_args = list(sub_args[1:])
                    if args.verbose:
                        run_args.insert(0, "-v")
                    await cli.cmd_run(run_args)
                    return 0
                finally:
                    await cli.disconnect()

            try:
                sys.exit(asyncio.run(_script_run()))
            except KeyboardInterrupt:
                print("\nAborted.")
                sys.exit(130)
            finally:
                if getattr(cli, "sub_viewer", None) is not None:
                    cli.sub_viewer.terminate_process()
                if getattr(cli, "plot_client", None) is not None:
                    cli.plot_client.terminate_process()

        # Offline file operations (list/cat/edit/rm/mv) - no server needed.
        # Use a placeholder URL; we never connect. Exit code reflects success.
        cli = UaShell("opc.tcp://localhost:4840")
        ok = asyncio.run(cli.cmd_script(sub_args))
        sys.exit(0 if ok else 1)

    # URL is required for interactive mode and script execution
    url = args.url

    if not url:
        # Load URI history + saved names for tab completion
        uri_history = load_uri_history()
        server_names = load_server_names()

        # Prompt for URL interactively with tab completion
        print("UaShell - Interactive OPC UA Client")
        if uri_history:
            print(f"  ({len(uri_history)} server(s) in history - use Tab to complete)")
        if server_names:
            print(f"  ({len(server_names)} named server(s) - type a name or use Tab)")
        print()
        try:
            url = prompt_for_uri_with_completion(
                "Server address (host:port, host, or name): ",
                uri_history, server_names)
            if not url:
                print("No server address provided.")
                sys.exit(1)
        except (EOFError, KeyboardInterrupt):
            print("\nExiting.")
            sys.exit(0)

        # If the user typed a saved name, resolve it to its URI.
        for uri, nm in server_names.items():
            if nm.lower() == url.strip().lower():
                url = uri
                break

    # Normalize the URL (add opc.tcp:// prefix if needed, prompt for port if missing)
    url = normalize_opc_url(url, prompt_for_port=True)
    if url is None:
        print("Cancelled.")
        sys.exit(0)

    # Create the CLI
    cli = UaShell(url)

    # Interactive mode
    try:
        asyncio.run(cli.run_interactive())
    except KeyboardInterrupt:
        print("\nExiting...")
    except Exception as e:
        print(f"Fatal error: {e}")
        sys.exit(1)
    finally:
        # Last-resort: make sure the viewer processes never outlive us,
        # even if disconnect() did not fully run (e.g. interrupted teardown).
        if getattr(cli, "sub_viewer", None) is not None:
            cli.sub_viewer.terminate_process()
        if getattr(cli, "plot_client", None) is not None:
            cli.plot_client.terminate_process()


if __name__ == "__main__":
    main()

# Part 4: End