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

"""
UA Plot GUI - Companion plotting window for UA Explorer.

Lightweight PyQt6/pyqtgraph viewer that listens for plot commands over a
QLocalSocket channel and subscribes to OPC UA variables for live plotting.
"""

import argparse
import asyncio
import csv
import json
import math
import os
import socket
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple

from PyQt6.QtCore import QObject, QPoint, QRect, QSize, Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
from PyQt6.QtWidgets import (
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLayout,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPlainTextEdit,
    QPushButton,
    QStyleFactory,
    QTabWidget,
    QToolButton,
    QVBoxLayout,
    QWidget,
    QSizePolicy,
    QInputDialog,
    QMenu,
    QStackedWidget,
    QGridLayout,
    QSpinBox,
    QTextEdit,
    QListWidget,
    QListWidgetItem,
    QAbstractItemView,
    QProgressDialog,
)
from PyQt6.QtGui import (
    QAction, QColor, QDoubleValidator, QFont, QIntValidator, QKeySequence,
    QPalette, QPixmap, QShortcut,
)

import pyqtgraph as pg
from asyncua import Client, ua

try:
    # asyncua <1.1.8
    from asyncua.common.subscription import SubHandler as _SubHandler
except Exception:
    try:
        # asyncua >=1.1.8
        from asyncua.common.subscription_handler import SubHandler as _SubHandler
    except Exception:
        class _SubHandler:
            def datachange_notification(self, node, val, data):
                pass

            def event_notification(self, event):
                pass


# ---------------------------------------------------------------------------
# Theme subsystem
#
# This is a VERBATIM copy of UaExplorer's Theme dataclass and the three
# built-in palettes (LIGHT_THEME / DARK_THEME / ATACAMA_THEME). Keeping the
# same tokens and the same hex values is the only way to guarantee the two
# windows look identical side-by-side: Qt's QPalette role mapping uses all
# of these (panel_alt_bg, disabled colors, border variants, etc.), so a
# truncated copy diverges visually even when window_bg/input_bg match.
#
# If you change a palette in UaExplorer, mirror the change here.
# ---------------------------------------------------------------------------

@dataclass
class Theme:
    """Named color tokens for one full UI theme — mirrors UaExplorer.Theme."""
    name: str

    # Surface colors
    window_bg: str
    input_bg: str
    panel_bg: str
    panel_alt_bg: str
    panel_hover_bg: str
    panel_pressed_bg: str
    panel_disabled_bg: str

    # Text colors
    text_primary: str
    text_muted: str
    text_disabled: str
    text_on_accent: str

    # Borders / lines
    border: str
    border_strong: str
    border_subtle: str

    # Accents
    accent: str
    accent_hover: str
    section_title: str

    # Node-class palette (not used by UaPlot but kept for parity)
    node_object: str
    node_variable: str
    node_method: str

    # Status indicators
    status_ok: str
    status_warn: str
    status_error: str

    # Recording button (unused in UaPlot — kept for parity)
    record_idle_bg: str
    record_idle_border: str
    record_active_bg: str
    record_active_border: str


LIGHT_THEME = Theme(
    name="Light",
    window_bg="#e8e8e8",
    input_bg="#ffffff",
    panel_bg="#dcdcdc",
    panel_alt_bg="#d4d8db",
    panel_hover_bg="#cfcfcf",
    panel_pressed_bg="#bfbfbf",
    panel_disabled_bg="#d8d8d8",
    text_primary="#202020",
    text_muted="#666666",
    text_disabled="#aaaaaa",
    text_on_accent="#ffffff",
    border="#888888",
    border_strong="#555555",
    border_subtle="#bbbbbb",
    accent="#3a6ea5",
    accent_hover="#2c5380",
    section_title="#2c4a5e",
    node_object="#b35a00",
    node_variable="#1a6e3f",
    node_method="#1a3f8f",
    status_ok="#2E7D32",
    status_warn="#C68400",
    status_error="#C62828",
    record_idle_bg="#b30000",
    record_idle_border="#7a0000",
    record_active_bg="#ff6666",
    record_active_border="#7a0000",
)

DARK_THEME = Theme(
    name="Dark",
    window_bg="#1f2226",
    input_bg="#15181c",
    panel_bg="#2b2f34",
    panel_alt_bg="#363c44",
    panel_hover_bg="#3a4048",
    panel_pressed_bg="#222629",
    panel_disabled_bg="#262a2e",
    text_primary="#e6e6e6",
    text_muted="#a8b0b8",
    text_disabled="#6a7079",
    text_on_accent="#ffffff",
    border="#4a4f55",
    border_strong="#6a7079",
    border_subtle="#3a3f44",
    accent="#5b9bd5",
    accent_hover="#80b6e6",
    section_title="#9fb8c8",
    node_object="#e08a3a",
    node_variable="#5fbd80",
    node_method="#7da6e6",
    status_ok="#5fbd80",
    status_warn="#e6a93b",
    status_error="#e06c6c",
    record_idle_bg="#a02020",
    record_idle_border="#601818",
    record_active_bg="#e25555",
    record_active_border="#601818",
)

ATACAMA_THEME = Theme(
    name="Atacama",
    window_bg="#c9a07a",
    input_bg="#b88a5f",
    panel_bg="#b88a5f",
    panel_alt_bg="#a37047",
    panel_hover_bg="#a87b50",
    panel_pressed_bg="#8b5a35",
    panel_disabled_bg="#b09075",
    text_primary="#2a1607",
    text_muted="#5a3622",
    text_disabled="#7a5945",
    text_on_accent="#ffffff",
    border="#5a3622",
    border_strong="#3a2010",
    border_subtle="#8b5a35",
    accent="#8a2e10",
    accent_hover="#6a1f08",
    section_title="#3a1a08",
    node_object="#8a2e10",
    node_variable="#4a5520",
    node_method="#3a1408",
    status_ok="#4a5520",
    status_warn="#a86a0a",
    status_error="#8a2e10",
    record_idle_bg="#8a2810",
    record_idle_border="#5a1808",
    record_active_bg="#c44a28",
    record_active_border="#5a1808",
)

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


def resolve_theme(name: Optional[str]) -> Theme:
    """Return the theme matching ``name`` (case-insensitive), Light fallback."""
    if not name:
        return LIGHT_THEME
    for key, theme in THEMES.items():
        if key.lower() == name.lower():
            return theme
    return LIGHT_THEME


def build_qpalette_for_theme(theme: Theme) -> QPalette:
    """VERBATIM copy of UaExplorer.build_qpalette_for_theme()."""
    pal = QPalette()
    pal.setColor(QPalette.ColorRole.Window, QColor(theme.window_bg))
    pal.setColor(QPalette.ColorRole.WindowText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Base, QColor(theme.input_bg))
    pal.setColor(QPalette.ColorRole.AlternateBase, QColor(theme.panel_alt_bg))
    pal.setColor(QPalette.ColorRole.Text, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Button, QColor(theme.panel_bg))
    pal.setColor(QPalette.ColorRole.ButtonText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Highlight, QColor(theme.accent))
    pal.setColor(QPalette.ColorRole.HighlightedText, QColor(theme.text_on_accent))
    pal.setColor(QPalette.ColorRole.ToolTipBase, QColor(theme.panel_alt_bg))
    pal.setColor(QPalette.ColorRole.ToolTipText, QColor(theme.text_primary))
    pal.setColor(QPalette.ColorRole.Link, QColor(theme.accent))
    pal.setColor(QPalette.ColorRole.PlaceholderText, QColor(theme.text_muted))
    pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text,
                 QColor(theme.text_disabled))
    pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText,
                 QColor(theme.text_disabled))
    return pal


def apply_theme_to_application(theme: Theme) -> None:
    """VERBATIM copy of UaExplorer.apply_theme_to_application()."""
    app = QApplication.instance()
    if app is None:
        return
    try:
        app.setStyle("Fusion")
    except Exception:
        pass
    app.setPalette(build_qpalette_for_theme(theme))


def _now_ts() -> float:
    """Return a unix timestamp in seconds.

    Uses time.time() instead of datetime.utcnow().timestamp() to avoid
    the Py3.12 DeprecationWarning on stderr at startup; the value is the
    same (system clock, seconds since the epoch, UTC).
    """
    return time.time()


def _log(msg: str):
    """Debug logging helper."""
    if os.environ.get("UAPLOT_DEBUG"):
        print(f"[UaPlot] {msg}", file=sys.stderr, flush=True)


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

    Mirrors the helper in UaExplorer (CFGPATH -> parent-relative ->
    INTROOT / PREFIX -> standalone fallback). Returns the resolved
    absolute Path, or None if the file cannot be found.

    UaPlot ships without its own ``resource/`` tree — the shared
    IfwOpcUaTools logo lives in ``uaexplorer/resource/``. When running
    from the source tree, the fallback walk-up below picks up that
    sibling directory; in an installed layout (INTROOT/PREFIX), the
    resource is expected at ``$INTROOT/resource/image/uatools/``.
    """
    expanded = os.path.expanduser(os.path.expandvars(name))
    candidate = Path(expanded)

    if candidate.is_absolute():
        return candidate.resolve() if candidate.is_file() else None

    rel = Path(expanded)

    cfgpath = os.environ.get("CFGPATH", "")
    for root in [p for p in cfgpath.split(os.pathsep) if p]:
        hit = Path(root) / rel
        if hit.is_file():
            _log(f"find_file: {name!r} resolved via CFGPATH -> {hit}")
            return hit.resolve()

    if parent is not None:
        parent_dir = parent if parent.is_dir() else parent.parent
        hit = parent_dir / rel
        if hit.is_file():
            _log(f"find_file: {name!r} resolved relative to parent -> {hit}")
            return hit.resolve()

    for env_name in ("INTROOT", "PREFIX"):
        root = os.environ.get(env_name, "")
        if not root:
            continue
        hit = Path(root) / rel
        if hit.is_file():
            _log(f"find_file: {name!r} resolved via {env_name} -> {hit}")
            return hit.resolve()

    here = Path(__file__).resolve().parent
    fallbacks = [
        here,
        here.parent / "resource",
        here.parent,
        # UaPlot has no resource/ of its own; logo lives in uaexplorer/.
        # Source layout: uatools/uaplot/src/UaPlot.py -> uatools/uaexplorer/resource
        here.parent.parent / "uaexplorer" / "resource",
    ]
    for fallback_root in fallbacks:
        hit = fallback_root / rel
        if hit.is_file():
            _log(f"find_file: {name!r} resolved via standalone fallback -> {hit}")
            return hit.resolve()

    _log(f"find_file: {name!r} not found")
    return None


class PlotSubscriptionHandler(_SubHandler):
    """Forward asyncua subscription updates back to the worker thread."""

    def __init__(self, callback: Callable[[str, object, float], None]):
        super().__init__()
        self._callback = callback

    def datachange_notification(self, node, val, data):
        timestamp = _now_ts()
        try:
            notif = getattr(data, "MonitoredItemNotification", None)
            dv = getattr(notif, "Value", None) if notif else None
            if dv and getattr(dv, "SourceTimestamp", None):
                timestamp = dv.SourceTimestamp.timestamp()
        except Exception:
            pass
        try:
            node_id = str(node.nodeid)
            _log(f"[datachange] node_id={node_id}, val={val}")
        except Exception:
            node_id = ""
        self._callback(node_id, val, timestamp)


class UaPlotWorker(QThread):
    """Background asyncio worker to manage OPC UA connection and subscriptions."""

    data_update = pyqtSignal(str, object, float)
    connected = pyqtSignal(str)
    disconnected = pyqtSignal()
    error = pyqtSignal(str)
    # Result of a request_browse(): payload is a list of (node_id, path) tuples
    # for every Variable node found under the server's Objects folder.
    browse_result = pyqtSignal(object)
    browse_error = pyqtSignal(str)

    def __init__(self, uri: str):
        super().__init__()
        self.uri = uri
        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._stop_requested = False
        self._watched_nodes: set[str] = set()
        self._active_handles: Dict[str, object] = {}
        self.client: Optional[Client] = None
        self.subscription = None
        self.monitor_mode = "subscription"
        self.polling_interval_ms = 500
        self._poll_task: Optional[asyncio.Task] = None
        self._node_norm_cache: Dict[str, str] = {}

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

    def set_monitor_mode(self, mode: str, polling_interval_ms: int):
        self.monitor_mode = mode if mode in ("polling", "subscription") else "subscription"
        if polling_interval_ms > 0:
            self.polling_interval_ms = polling_interval_ms

    def add_node(self, node_id: str):
        _log(f"[add_node] Adding node: {node_id}, loop exists: {self._loop is not None}")
        self._watched_nodes.add(node_id)
        if self._loop:
            asyncio.run_coroutine_threadsafe(self._subscribe_node(node_id), self._loop)

    def remove_node(self, node_id: str):
        """Stop watching a node: drop it from the watch set and, in
        subscription mode, unsubscribe its data-change handle. Safe to call
        when not connected (no loop) — the node is simply forgotten."""
        _log(f"[remove_node] Removing node: {node_id}, loop exists: {self._loop is not None}")
        self._watched_nodes.discard(node_id)
        if self._loop:
            asyncio.run_coroutine_threadsafe(self._unsubscribe_node(node_id), self._loop)

    async def _unsubscribe_node(self, node_id: str):
        handle = self._active_handles.pop(node_id, None)
        if handle is None or not self.subscription:
            return
        try:
            await self.subscription.unsubscribe(handle)
            _log(f"[_unsubscribe_node] Unsubscribed {node_id}")
        except Exception as exc:
            _log(f"[_unsubscribe_node] Failed for {node_id}: {exc}")

    def request_browse(self) -> bool:
        """Ask the worker to browse the server's address space. Results come
        back asynchronously on the browse_result signal (or browse_error).

        Returns False if there is no event loop yet (the worker hasn't been
        started / hasn't begun connecting) — the caller should wait for the
        ``connected`` signal and retry. A live loop with no client yet is
        handled inside the coroutine (emits browse_error)."""
        if not self._loop:
            return False
        asyncio.run_coroutine_threadsafe(self._browse_address_space(), self._loop)
        return True

    # Bounds so a pathological / cyclic address space can't hang the browse.
    _BROWSE_MAX_NODES = 20000
    _BROWSE_MAX_DEPTH = 12

    async def _browse_address_space(self):
        """Walk the Objects folder and collect every Variable node.

        Emits browse_result with a list of (node_id_string, path_string).
        Per-node errors are swallowed (permission-denied nodes are common);
        a missing client emits browse_error so the GUI can tell the user."""
        if not self.client:
            self.browse_error.emit("Not connected to the server yet.")
            return
        results: List[Tuple[str, str]] = []
        visited: set[str] = set()
        truncated = False
        try:
            root = self.client.nodes.objects
            # Explicit stack of (node, path) to avoid recursion depth limits.
            stack: List[Tuple[object, str, int]] = [(root, "Objects", 0)]
            while stack:
                if len(results) >= self._BROWSE_MAX_NODES:
                    truncated = True
                    break
                node, path, depth = stack.pop()
                nid = node.nodeid.to_string()
                if nid in visited:
                    continue
                visited.add(nid)
                try:
                    nclass = await node.read_node_class()
                except Exception:
                    continue
                if nclass == ua.NodeClass.Variable:
                    results.append((nid, path))
                # Don't descend into Method nodes: their children are the
                # InputArguments/OutputArguments Variables (Argument structs),
                # which are not meaningful plot targets — they'd otherwise show
                # up as bogus "values" to add.
                if nclass == ua.NodeClass.Method:
                    continue
                if depth >= self._BROWSE_MAX_DEPTH:
                    continue
                try:
                    children = await node.get_children()
                except Exception:
                    children = []
                for child in children:
                    try:
                        bn = await child.read_browse_name()
                        seg = bn.Name
                    except Exception:
                        seg = child.nodeid.to_string()
                    stack.append((child, f"{path}/{seg}", depth + 1))
        except Exception as exc:
            self.browse_error.emit(f"Browse failed: {exc}")
            return
        # De-dup by node id, sort by path for a stable, readable list.
        seen: set[str] = set()
        deduped: List[Tuple[str, str]] = []
        for nid, path in sorted(results, key=lambda r: r[1].lower()):
            if nid in seen:
                continue
            seen.add(nid)
            deduped.append((nid, path))
        if truncated:
            _log(f"[_browse_address_space] truncated at {self._BROWSE_MAX_NODES} nodes")
        self.browse_result.emit(deduped)

    def run(self):
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)
        try:
            self._loop.run_until_complete(self._run_loop())
        finally:
            self._loop.close()

    def stop(self):
        # Setting the flag is sufficient: _run_loop polls it via
        # ``while not self._stop_requested`` between 0.1-0.2 s sleeps.
        # Do NOT call loop.stop() — that aborts pending awaits and
        # raises 'Event loop stopped before Future completed' on quit
        # when the worker is sitting in an asyncio.sleep (the standalone
        # UaPlot case, where no URI is set and the loop is idle).
        self._stop_requested = True

    async def _run_loop(self):
        while not self._stop_requested:
            if not self.uri:
                await asyncio.sleep(0.2)
                continue
            try:
                self.client = Client(self.uri)
                await self.client.connect()
                if self.monitor_mode == "subscription":
                    handler = PlotSubscriptionHandler(self._handle_update)
                    self.subscription = await self.client.create_subscription(250, handler)
                    # Small delay to allow main thread to finish adding nodes via add_node()
                    # This prevents a race condition where _resubscribe_all runs before
                    # all plot_variable commands have been processed
                    await asyncio.sleep(0.15)
                    await self._resubscribe_all()
                else:
                    await asyncio.sleep(0.15)  # Same delay for polling mode
                    await self._start_polling()
                self.connected.emit(self.uri)
                # Idle loop: also catch any watched nodes that are not yet
                # subscribed. add_node() calls that arrive while the worker is
                # still starting up (subscription not created yet) leave the
                # node in _watched_nodes but unsubscribed; the one-time
                # _resubscribe_all above can miss them due to timing. Polling
                # for unsubscribed watched nodes here makes node delivery
                # reliable regardless of startup race timing.
                while not self._stop_requested:
                    if self.monitor_mode == "subscription" and self.subscription:
                        pending = [n for n in list(self._watched_nodes)
                                   if n not in self._active_handles]
                        for node_id in pending:
                            await self._subscribe_node(node_id)
                    await asyncio.sleep(0.3)
            except Exception as exc:
                _log(f"[worker] error {exc}")
                self.error.emit(str(exc))
            finally:
                await self._cleanup()
                self.disconnected.emit()
                if not self._stop_requested:
                    await asyncio.sleep(1.0)

    async def _resubscribe_all(self):
        _log(f"[_resubscribe_all] Resubscribing to {len(self._watched_nodes)} nodes: {list(self._watched_nodes)}")
        for node_id in list(self._watched_nodes):
            await self._subscribe_node(node_id)

    async def _subscribe_node(self, node_id: str):
        _log(f"[_subscribe_node] Subscribing to: {node_id}, client={self.client is not None}, mode={self.monitor_mode}")
        if not self.client:
            _log(f"[_subscribe_node] No client, skipping")
            return
        if self.monitor_mode == "subscription":
            if not self.subscription or node_id in self._active_handles:
                _log(f"[_subscribe_node] Skipping: subscription={self.subscription is not None}, already_active={node_id in self._active_handles}")
                return
            try:
                node = self.client.get_node(node_id)
                handle = await self.subscription.subscribe_data_change(node, queuesize=1)
                self._active_handles[node_id] = handle
                _log(f"[_subscribe_node] Successfully subscribed to {node_id}")
            except Exception as exc:
                _log(f"[_subscribe_node] Failed: {exc}")
                self.error.emit(f"Subscribe failed for {node_id}: {exc}")
        else:
            # polling mode doesn't need subscription
            return

    async def _start_polling(self):
        if self._poll_task:
            try:
                self._poll_task.cancel()
            except Exception:
                pass
        self._poll_task = asyncio.create_task(self._poll_loop())

    async def _poll_loop(self):
        while not self._stop_requested and self.monitor_mode == "polling":
            if not self.client:
                break
            nodes = list(self._watched_nodes)
            for node_id in nodes:
                try:
                    node = self.client.get_node(node_id)
                    val = await node.read_value()
                    self._handle_update(node_id, val, _now_ts())
                except Exception as exc:
                    self.error.emit(f"Polling failed for {node_id}: {exc}")
            await asyncio.sleep(self.polling_interval_ms / 1000.0)

    async def _cleanup(self):
        if self._poll_task:
            try:
                self._poll_task.cancel()
            except Exception:
                pass
            self._poll_task = None
        if self.subscription:
            try:
                await self.subscription.delete()
            except Exception:
                pass
            self.subscription = None
        if self.client:
            try:
                await self.client.disconnect()
            except Exception:
                pass
            self.client = None
        self._active_handles.clear()

    def _handle_update(self, node_id: str, value: object, timestamp: float):
        norm = self._normalize_node_id(node_id)
        self.data_update.emit(norm, value, timestamp)

    def _normalize_node_id(self, node_id: str) -> str:
        if not node_id:
            return ""
        if node_id in self._node_norm_cache:
            return self._node_norm_cache[node_id]
        text = str(node_id)
        if hasattr(node_id, "to_string"):
            try:
                text = node_id.to_string()
            except Exception:
                text = str(node_id)
        if text.startswith("ns="):
            self._node_norm_cache[node_id] = text
            return text
        if "Identifier=" in text and "NamespaceIndex=" in text:
            try:
                ident = text.split("Identifier=")[1].split(",")[0].strip().strip("'\"")
                ns_part = text.split("NamespaceIndex=")[1].split(",")[0].strip()
                if ident and ns_part:
                    norm = f"ns={ns_part};s={ident}"
                    self._node_norm_cache[node_id] = norm
                    return norm
            except Exception:
                pass
        self._node_norm_cache[node_id] = text
        return text


@dataclass
class PlotInfo:
    plot_id: str
    title: str


class _TimeAxisItem(pg.AxisItem):
    """Custom X-axis that renders Unix-timestamp tick values as
    HH:MM:SS or HH:MM:SS.mmm strings.

    Reason: PlotTab plots data as (unix_seconds, value). pyqtgraph's
    default AxisItem renders large numeric ticks as scientific notation
    (``1.778e+09``) which is unreadable for time-series plots. Tick
    labels also overlap heavily because each one is 7+ characters wide.

    Resolution: pick HH:MM:SS for spans >= 1 s and HH:MM:SS.mmm for
    sub-second spans so the milliseconds only appear when they carry
    information. ``time.localtime`` is used so the displayed times
    match the user's wall clock.
    """

    def tickStrings(self, values, scale, spacing):  # noqa: N802 (Qt naming)
        # ``spacing`` is the distance between adjacent ticks in seconds.
        # When it's >= 1 s we don't need the milliseconds component;
        # below that we render .mmm to keep ticks distinguishable.
        include_ms = spacing < 1.0
        out: List[str] = []
        for v in values:
            try:
                t = time.localtime(v)
            except (ValueError, OSError, OverflowError):
                # Bad/extreme value (e.g. NaN): fall back to the raw
                # number so we never crash on weird input.
                out.append(str(v))
                continue
            if include_ms:
                ms = int((v - int(v)) * 1000) % 1000
                out.append(
                    f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}.{ms:03d}"
                )
            else:
                out.append(f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}")
        return out


class _FlowLayout(QLayout):
    """Layout that arranges children left-to-right and wraps to the next
    row when they exceed the available width.

    Standard Qt has no flow layout in PyQt6; this is the canonical
    pattern from the Qt examples, trimmed. We use it for the per-plot
    legend so series labels fold to multiple rows when the plot is
    narrow, instead of clipping to a single row.
    """

    def __init__(self, parent=None, margin: int = 2, h_spacing: int = 12, v_spacing: int = 4):
        super().__init__(parent)
        self.setContentsMargins(margin, margin, margin, margin)
        self._h_space = h_spacing
        self._v_space = v_spacing
        self._items: List = []
        # Width of the last setGeometry() pass; used so sizeHint() can report
        # the TRUE multi-row height the flow needs at that width (otherwise the
        # parent QVBoxLayout reserves only one row and dead space appears).
        self._last_width = 0

    def __del__(self):
        item = self.takeAt(0)
        while item is not None:
            item = self.takeAt(0)

    def addItem(self, item):  # noqa: N802 (Qt naming)
        self._items.append(item)

    def horizontalSpacing(self) -> int:  # noqa: N802
        return self._h_space

    def verticalSpacing(self) -> int:  # noqa: N802
        return self._v_space

    def count(self) -> int:
        return len(self._items)

    def itemAt(self, index: int):  # noqa: N802
        if 0 <= index < len(self._items):
            return self._items[index]
        return None

    def takeAt(self, index: int):  # noqa: N802
        if 0 <= index < len(self._items):
            return self._items.pop(index)
        return None

    def expandingDirections(self):  # noqa: N802
        return Qt.Orientation(0)

    def hasHeightForWidth(self) -> bool:  # noqa: N802
        return True

    def heightForWidth(self, width: int) -> int:  # noqa: N802
        return self._do_layout(QRect(0, 0, width, 0), test_only=True)

    def setGeometry(self, rect: QRect):  # noqa: N802
        super().setGeometry(rect)
        self._last_width = rect.width()
        self._do_layout(rect, test_only=False)

    def sizeHint(self) -> QSize:  # noqa: N802
        # Report the actual height the flow needs at the current width, so the
        # parent layout reserves room for ALL wrapped rows (not just one). Width
        # is taken as a hint; height is the wrapped height.
        width = self._last_width or self.minimumSize().width()
        return QSize(width, self.heightForWidth(width))

    def minimumSize(self) -> QSize:  # noqa: N802
        # Minimum HEIGHT = wrapped height at the current width so the legend is
        # never clipped to one row.
        #
        # Minimum WIDTH is deliberately small (a tile, not the widest item).
        # A single legend/stats entry can be very long (e.g. a full node path
        # plus 'last/min/max/mean' stats); if we reported the widest item as
        # the minimum width, several such tiles side-by-side would force the
        # whole mosaic - and thus the window - wider than the screen, so the
        # window could no longer be shrunk horizontally. Capping the minimum
        # width lets a long item be clipped within its tile instead of
        # dictating the tile (and window) width.
        size = QSize()
        for item in self._items:
            size = size.expandedTo(item.minimumSize())
        width = self._last_width or size.width()
        size.setHeight(max(size.height(), self.heightForWidth(width)))
        m = self.contentsMargins()
        size += QSize(m.left() + m.right(), m.top() + m.bottom())
        size.setWidth(min(size.width(), 80))
        return size

    def _do_layout(self, rect: QRect, test_only: bool) -> int:
        m = self.contentsMargins()
        effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())
        x = effective.x()
        y = effective.y()
        line_height = 0
        for item in self._items:
            wid = item.widget()
            space_x = self._h_space
            space_y = self._v_space
            if wid is not None:
                # Style-aware spacing falls back to fixed values above
                # if the widget can't supply a layout hint.
                style = wid.style()
                if style is not None:
                    try:
                        space_x = max(space_x, style.layoutSpacing(
                            QSizePolicy.ControlType.PushButton,
                            QSizePolicy.ControlType.PushButton,
                            Qt.Orientation.Horizontal,
                        ))
                        space_y = max(space_y, style.layoutSpacing(
                            QSizePolicy.ControlType.PushButton,
                            QSizePolicy.ControlType.PushButton,
                            Qt.Orientation.Vertical,
                        ))
                    except Exception:
                        pass
            next_x = x + item.sizeHint().width() + space_x
            if next_x - space_x > effective.right() and line_height > 0:
                x = effective.x()
                y = y + line_height + space_y
                next_x = x + item.sizeHint().width() + space_x
                line_height = 0
            if not test_only:
                item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
            x = next_x
            line_height = max(line_height, item.sizeHint().height())
        return y + line_height - rect.y() + m.bottom()


class _SeriesLegendItem(QLabel):
    """One clickable entry in the per-plot legend strip.

    The legend used to be a single QLabel rendering all series in one
    rich-text run. That made click-to-rename impossible because every
    series shares the same widget and there's no per-character hit
    region. Replacing the strip with one of these per series gives each
    label its own widget — and therefore its own mouseDoubleClickEvent —
    while keeping the visual identical (■ name).
    """

    def __init__(self, owner: "PlotTab", node_id: str, parent=None):
        super().__init__(parent)
        self._owner = owner
        self._node_id = node_id
        self.setTextFormat(Qt.TextFormat.RichText)
        # Pointer cursor so the rename affordance is discoverable.
        self.setCursor(Qt.CursorShape.IBeamCursor)
        self.setToolTip("Double-click to rename this curve")

    def mouseDoubleClickEvent(self, event):  # noqa: N802 (Qt naming)
        if event.button() == Qt.MouseButton.LeftButton:
            self._owner._rename_series(self._node_id)
            event.accept()
            return
        super().mouseDoubleClickEvent(event)


class PlotTab(QWidget):
    """Simple container for a pyqtgraph plot with multiple series."""

    def __init__(
        self,
        title: str,
        flush_interval_ms: int,
        legend_font_size_px: int = 11,
        history_samples: int = 1000,
        stats_config: Optional[dict] = None,
    ):
        super().__init__()
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.flush_interval_ms = max(20, int(flush_interval_ms)) if flush_interval_ms else 150
        self.legend_font_size_px = int(legend_font_size_px)
        self.history_samples = max(100, int(history_samples)) if history_samples else 1000
        # Stats config: dict with keys enabled, show_last, show_min,
        # show_max, show_mean. Defaults set if not supplied.
        self.stats_config = dict(stats_config) if stats_config else {
            "enabled": False,
            "show_last": True,
            "show_min": True,
            "show_max": True,
            "show_mean": True,
        }
        layout = QVBoxLayout()
        # Custom bottom axis: format the Unix-second X values as
        # HH:MM:SS (or HH:MM:SS.mmm at sub-second tick spacing). Without
        # this, ticks render as ``1.778e+09`` and overlap each other.
        self.plot_widget = pg.PlotWidget(
            axisItems={"bottom": _TimeAxisItem(orientation="bottom")}
        )
        self.plot_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.plot_widget.setMinimumSize(140, 110)
        self.plot_widget.showGrid(x=True, y=True, alpha=0.2)
        layout.addWidget(self.plot_widget)

        # Lightweight, custom legend replacement — one _SeriesLegendItem
        # per series, arranged with a flow layout so labels wrap to the
        # next row when the plot is narrower than the combined width.
        self.series_legend = QWidget()
        self.series_legend.setVisible(False)
        _legend_policy = QSizePolicy(
            QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
        # Honor the flow layout's heightForWidth so the legend's reserved height
        # matches the number of wrapped rows (prevents dead space / clipping).
        _legend_policy.setHeightForWidth(True)
        self.series_legend.setSizePolicy(_legend_policy)
        self.series_legend_layout = _FlowLayout(self.series_legend)
        self._series_label_color = "#444"
        layout.addWidget(self.series_legend)

        # Stats row: one QLabel per series below the legend, showing
        # last / min / max / mean for the buffered samples. Hidden when
        # statistics are disabled. Lives inside PlotTab so it works
        # equally in tabs view and mosaic view (PlotTab is reused
        # verbatim by both).
        self.stats_row = QWidget()
        self.stats_row.setVisible(bool(self.stats_config.get("enabled")))
        _stats_policy = QSizePolicy(
            QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
        _stats_policy.setHeightForWidth(True)
        self.stats_row.setSizePolicy(_stats_policy)
        self.stats_row_layout = _FlowLayout(self.stats_row)
        # Per-series stats labels, keyed by node_id.
        self.stats_labels: Dict[str, QLabel] = {}
        layout.addWidget(self.stats_row)

        self.setLayout(layout)
        self.series: Dict[str, Dict[str, object]] = {}
        self.series_order: List[str] = []
        self.title = title
        self._flush_timer = QTimer(self)
        self._flush_timer.timeout.connect(self.flush_pending)
        self._flush_timer.start(self.flush_interval_ms)

    def apply_theme(self, theme: "Theme") -> None:
        """Restyle plot background, axes, and series-label to match theme.

        Pyqtgraph canvas uses ``input_bg`` and ``text_primary`` so the plot
        surface matches UaExplorer's Node Browser / Subscriptions surface.
        """
        try:
            self.plot_widget.setBackground(theme.input_bg)
            plot_item = self.plot_widget.getPlotItem()
            for axis_name in ("left", "bottom", "top", "right"):
                axis = plot_item.getAxis(axis_name)
                if axis is None:
                    continue
                axis.setPen(theme.text_primary)
                axis.setTextPen(theme.text_primary)
        except Exception:
            # pyqtgraph version differences shouldn't crash the theme switch
            pass
        self._series_label_color = theme.text_muted
        # Restyle each existing legend item by re-rendering — every item
        # carries its own stylesheet so they all need the new color.
        self._update_series_label()
        # Stats labels also use _legend_item_stylesheet, so they need
        # the same refresh path. Rebuilding is cheap and idempotent.
        if self.stats_config.get("enabled"):
            self._rebuild_stats_row()
            self.update_stats()

    def _legend_item_stylesheet(self) -> str:
        """Stylesheet shared by every legend item label."""
        return (
            f"QLabel {{ color: {self._series_label_color}; "
            f"font-size: {self.legend_font_size_px}px; padding: 2px 4px; }}"
        )

    def set_legend_font_size(self, px: int) -> None:
        """Update legend font size and re-apply to every legend item."""
        self.legend_font_size_px = max(6, int(px))
        self._update_series_label()
        if self.stats_config.get("enabled"):
            self._rebuild_stats_row()
            self.update_stats()

    def add_series(self, node_id: str, label: str):
        if node_id in self.series:
            return
        color = pg.intColor(len(self.series))
        curve = self.plot_widget.plot(pen=color, name=label)
        self.series[node_id] = {
            "curve": curve,
            "x": [],
            "y": [],
            "label": label,
            "color": color,
            "buffer_x": [],
            "buffer_y": [],
        }
        self.series_order.append(node_id)
        self._update_series_label()
        if self.stats_config.get("enabled"):
            self._rebuild_stats_row()

    def remove_series(self, node_id: str) -> bool:
        """Remove a single curve from this plot. Returns True if it existed."""
        data = self.series.pop(node_id, None)
        if data is None:
            return False
        curve = data.get("curve")
        if curve is not None:
            try:
                self.plot_widget.removeItem(curve)
            except Exception:
                pass
        try:
            self.series_order.remove(node_id)
        except ValueError:
            pass
        self._update_series_label()
        if self.stats_config.get("enabled"):
            self._rebuild_stats_row()
            self.update_stats()
        return True

    def clear_size_limits(self):
        """Remove any imposed size limits (used when leaving mosaic)."""
        max_default = 16777215  # Qt default max
        self.setMaximumHeight(max_default)
        self.plot_widget.setMaximumHeight(max_default)
        self.setMinimumHeight(0)
        self.plot_widget.setMinimumHeight(0)

    def apply_size_limits(self, max_height: Optional[int]):
        """Cap the height of the plot tile when used in mosaic.

        Reserves a header band for the legend/stats row at the bottom
        of the tile. When stats are on, the band has to be bigger
        because the stats row carries more content per series; flow-
        wrapping in tiles with many series can otherwise hide the
        whole stats row behind the plot's lower edge.
        """
        if not max_height:
            return
        self.setMaximumHeight(max_height)
        # Roomier band when stats are on, otherwise the legend's 24 px
        # is enough. The values below are upper bounds — the actual
        # row will shrink (and flow-wrap) within them.
        header_band = 60 if self.stats_config.get("enabled") else 24
        widget_max = max(80, max_height - header_band)
        self.plot_widget.setMaximumHeight(widget_max)
        self.setMinimumHeight(80)
        self.plot_widget.setMinimumHeight(60)

    def append_point(self, node_id: str, value: object, timestamp: float):
        if node_id not in self.series:
            return
        try:
            y_val = float(value)
        except Exception:
            return

        self.series[node_id]["buffer_x"].append(timestamp)
        self.series[node_id]["buffer_y"].append(y_val)

    def flush_pending(self):
        """Push buffered points to the plot at a fixed cadence."""
        cap = max(100, int(self.history_samples or 1000))
        any_change = False
        for node_id, data in self.series.items():
            buf_x = data.get("buffer_x")
            buf_y = data.get("buffer_y")
            if not buf_x or not buf_y:
                continue
            xs = data["x"]
            ys = data["y"]
            xs.extend(buf_x)
            ys.extend(buf_y)
            data["buffer_x"].clear()
            data["buffer_y"].clear()
            if len(xs) > cap:
                xs[:] = xs[-cap:]
                ys[:] = ys[-cap:]
            data["curve"].setData(xs, ys)
            any_change = True
        if any_change and self.stats_config.get("enabled"):
            self.update_stats()

    def set_flush_interval(self, interval_ms: int):
        try:
            interval = int(interval_ms)
        except Exception:
            return
        if interval < 20:
            interval = 20
        self.flush_interval_ms = interval
        if self._flush_timer:
            self._flush_timer.setInterval(self.flush_interval_ms)

    def set_history_samples(self, n: int):
        """Update the per-series buffer cap. Trims existing buffers
        immediately so the new cap is visible right away."""
        n = max(100, int(n)) if n else 1000
        if n == self.history_samples:
            return
        self.history_samples = n
        for data in self.series.values():
            xs = data.get("x") or []
            ys = data.get("y") or []
            if len(xs) > n:
                xs[:] = xs[-n:]
                ys[:] = ys[-n:]
                curve = data.get("curve")
                if curve is not None:
                    curve.setData(xs, ys)
        if self.stats_config.get("enabled"):
            self.update_stats()

    def set_stats_config(self, config: dict):
        """Replace the stats configuration and rebuild the stats row.

        Rebuilds the row widgets so visibility flags take immediate
        effect — labels for hidden stats are simply not built. Called
        whenever the user toggles a stat checkbox in Preferences.

        Hides the legend strip when stats are enabled: the stats row
        already shows ``■ name  last=… min=… …`` per series, so the
        plain legend underneath would duplicate the name+swatch.
        """
        old_enabled = bool(self.stats_config.get("enabled"))
        self.stats_config = dict(config)
        enabled = bool(self.stats_config.get("enabled"))
        self._rebuild_stats_row()
        self.stats_row.setVisible(enabled)
        # If we're in mosaic and the enabled state flipped, re-apply the
        # current size limit so the header band is recomputed (it's 60 px
        # when stats are on vs 24 px when off — see apply_size_limits).
        if old_enabled != enabled and self.maximumHeight() < 16777215:
            self.apply_size_limits(self.maximumHeight())
        # When stats are on, the stats row carries the swatch + name +
        # values; the legend strip becomes redundant. Toggle it back on
        # when stats are off, but only if the plot actually has series
        # (the legend visibility logic in _update_series_label already
        # handles the "no series" case).
        if enabled:
            self.series_legend.setVisible(False)
        else:
            self.series_legend.setVisible(bool(self.series))
        # Force the parent layout to re-measure the rows; without this
        # the stats row's height-for-width can read as 0 until the
        # next user-driven resize, which looks like "stats is empty".
        self.stats_row.updateGeometry()
        self.layout().activate()
        if enabled:
            self.update_stats()

    def _rebuild_stats_row(self):
        """Drop and rebuild the per-series stats labels.

        Each new label gets initial text (``■ name``) immediately, so
        the stats row is non-empty even before the first data tick or
        in the absence of incoming samples — without this, enabling
        stats on a quiet plot produced an apparently invisible row.

        Also enforces the row's own visibility based on stats_config,
        so callers (e.g. add_series) don't have to remember.
        """
        # Clear existing labels
        item = self.stats_row_layout.takeAt(0)
        while item is not None:
            w = item.widget()
            if w is not None:
                w.setParent(None)
                w.deleteLater()
            item = self.stats_row_layout.takeAt(0)
        self.stats_labels.clear()
        enabled = bool(self.stats_config.get("enabled"))
        self.stats_row.setVisible(enabled)
        if not enabled:
            return
        stylesheet = self._legend_item_stylesheet()
        for node_id in self.series_order:
            data = self.series.get(node_id, {})
            name = str(data.get("label", node_id))
            color = data.get("color")
            color_hex = ""
            try:
                color_hex = color.name() if hasattr(color, "name") else str(color)
            except Exception:
                color_hex = ""
            swatch = (
                f'<span style="color:{color_hex}">■</span>'
                if color_hex else "■"
            )
            # Use _SeriesLegendItem (not a plain QLabel) so the curve name
            # in the stats row is double-click-renamable too. When stats are
            # enabled this row replaces the plain legend strip, so without
            # this the rename affordance would be unreachable. update_stats()
            # only calls setText(), which _SeriesLegendItem inherits.
            lbl = _SeriesLegendItem(self, node_id)
            lbl.setText(f"{swatch}&nbsp;{name}")
            lbl.setStyleSheet(stylesheet)
            self.stats_row_layout.addWidget(lbl)
            self.stats_labels[node_id] = lbl
        # Force the flow layout to reflow with the new content so the
        # row container takes its proper height immediately, not on the
        # next paint event.
        self.stats_row.updateGeometry()

    def update_stats(self):
        """Recompute and re-render the stats labels for every series.

        Called on every flush_pending tick when statistics are enabled,
        so it must stay cheap. Uses the buffered Python lists
        directly — typical sizes are <= history_samples, so min/max/sum
        in pure Python take a fraction of a millisecond per series.
        """
        if not self.stats_config.get("enabled"):
            return
        # If the series set changed since the row was built (a new
        # variable arrived, say) rebuild the labels to match.
        if set(self.stats_labels.keys()) != set(self.series.keys()):
            self._rebuild_stats_row()

        show_last = bool(self.stats_config.get("show_last", True))
        show_min = bool(self.stats_config.get("show_min", True))
        show_max = bool(self.stats_config.get("show_max", True))
        show_mean = bool(self.stats_config.get("show_mean", True))

        for node_id in self.series_order:
            data = self.series.get(node_id)
            lbl = self.stats_labels.get(node_id)
            if data is None or lbl is None:
                continue
            ys = data.get("y") or []
            name = data.get("label", node_id)
            color = data.get("color")
            color_hex = ""
            try:
                color_hex = color.name() if hasattr(color, "name") else str(color)
            except Exception:
                color_hex = ""
            swatch = (
                f'<span style="color:{color_hex}">■</span>'
                if color_hex else "■"
            )
            if not ys:
                lbl.setText(f"{swatch}&nbsp;{name}: (no data)")
                continue
            parts = [f"{swatch}&nbsp;{name}"]
            if show_last:
                parts.append(f"last={_fmt_stat(ys[-1])}")
            if show_min:
                parts.append(f"min={_fmt_stat(min(ys))}")
            if show_max:
                parts.append(f"max={_fmt_stat(max(ys))}")
            if show_mean:
                parts.append(f"mean={_fmt_stat(sum(ys) / len(ys))}")
            lbl.setText("&nbsp;&nbsp; ".join(parts))

    def _clear_legend_items(self):
        """Remove every widget from the legend strip's flow layout."""
        item = self.series_legend_layout.takeAt(0)
        while item is not None:
            w = item.widget()
            if w is not None:
                w.setParent(None)
                w.deleteLater()
            item = self.series_legend_layout.takeAt(0)

    def _update_series_label(self):
        """Rebuild the legend strip as one _SeriesLegendItem per series.

        Called whenever the series set or any per-series property
        (label, color, font size, theme) changes. The flow layout wraps
        items to additional rows when the plot is narrower than the
        combined width.
        """
        self._clear_legend_items()

        if not self.series:
            self.series_legend.setVisible(False)
            return

        stylesheet = self._legend_item_stylesheet()
        for node_id in self.series_order:
            data = self.series.get(node_id)
            if not data:
                continue
            name = data.get("label", node_id)
            color = data.get("color")
            color_hex = ""
            try:
                color_hex = color.name() if hasattr(color, "name") else str(color)
            except Exception:
                color_hex = ""
            swatch = (
                f'<span style="color:{color_hex}">\u25a0</span>'
                if color_hex else "\u25a0"
            )
            item = _SeriesLegendItem(self, node_id)
            item.setStyleSheet(stylesheet)
            item.setText(f"{swatch}&nbsp;{name}")
            self.series_legend_layout.addWidget(item)

        # Keep the legend hidden when stats are enabled — the stats
        # row already includes the swatch+name for each series.
        self.series_legend.setVisible(not self.stats_config.get("enabled"))
        # Trigger a layout pass so the flow's heightForWidth is consulted
        # immediately and the container resizes to fit any wrapped rows.
        self.series_legend.updateGeometry()

    def _rename_series(self, node_id: str):
        """Prompt the user for a new display label for the curve.

        Only the displayed text changes; the OPC UA node_id (the actual
        data binding) is untouched. The new label is persisted via
        self.series[node_id]["label"], which is what _build_session_payload
        reads when writing the session JSON \u2014 so a Save Session after a
        rename captures the edit.
        """
        data = self.series.get(node_id)
        if not data:
            return
        current = str(data.get("label", node_id))
        # Build the dialog explicitly (not QInputDialog.getText) so we can
        # size it to the CURRENT label: a long path-based label would
        # otherwise open in a too-narrow default field and be awkward to edit.
        dialog = QInputDialog(self)
        dialog.setWindowTitle("Rename curve")
        dialog.setLabelText("Legend text:")
        dialog.setTextValue(current)
        # Width = the label's rendered width + editing room, clamped to a
        # sane range so a very short name isn't cramped and a very long one
        # doesn't exceed the screen.
        from PyQt6.QtGui import QFontMetrics
        fm = QFontMetrics(dialog.font())
        text_w = fm.horizontalAdvance(current)
        dialog.resize(max(320, min(text_w + 120, 900)), dialog.sizeHint().height())
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        new_label = dialog.textValue().strip()
        if not new_label or new_label == current:
            return
        data["label"] = new_label
        # Keep pyqtgraph's internal name in sync so any tooltip / export
        # path that reads curve.opts["name"] sees the updated value.
        curve = data.get("curve")
        try:
            if curve is not None and hasattr(curve, "opts"):
                curve.opts["name"] = new_label
        except Exception:
            pass
        self._update_series_label()
        # When stats are enabled the legend strip is hidden and the stats
        # row shows the name instead — refresh it so the rename is visible
        # immediately rather than only on the next data tick.
        if self.stats_config.get("enabled"):
            self.update_stats()


class PlotCommandServer(QObject):
    """QLocalServer wrapper handling simple JSON line commands."""

    def __init__(self, channel: str, handler: Callable[[Dict[str, object]], Dict[str, object]], parent=None):
        super().__init__(parent)
        self.channel = channel
        self.handler = handler
        self.server = QLocalServer(self)
        QLocalServer.removeServer(channel)
        self.server.newConnection.connect(self._on_new_connection)
        if not self.server.listen(channel):
            raise RuntimeError(f"Could not listen on channel {channel}")

    def _on_new_connection(self):
        while self.server.hasPendingConnections():
            socket = self.server.nextPendingConnection()
            socket.readyRead.connect(lambda s=socket: self._on_ready_read(s))

    def _on_ready_read(self, socket: QLocalSocket):
        raw = bytes(socket.readAll()).decode()
        lines = [l for l in raw.splitlines() if l.strip()]
        for line in lines:
            try:
                payload = json.loads(line)
            except Exception:
                continue
            response = {}
            try:
                response = self.handler(payload) or {}
            except Exception as exc:
                response = {"type": "error", "message": str(exc)}
            if "id" in payload and "reply_to" not in response:
                response["reply_to"] = payload["id"]
            socket.write((json.dumps(response) + "\n").encode())
            socket.flush()
        socket.disconnectFromServer()


class _RecordingViewerDialog(QDialog):
    """Simple read-only viewer for a recording CSV file.

    Monospace QPlainTextEdit showing the raw file (comment header + CSV
    rows). Includes a Browse button so the user can open a different file
    without closing the dialog. Read-only because the file is data, not
    source — same pattern as UaExplorer's ScriptEditorDialog but stripped
    down to a viewer.
    """

    def __init__(self, initial: Path, parent=None):
        super().__init__(parent)
        self.setWindowTitle("View Recording")
        self.setSizeGripEnabled(True)
        # Many X11 / Wayland window managers honour Min/Max hints only
        # on top-level "Window" windows, not on "Dialog" — so we ask
        # Qt to use the standard Window flags + the full decoration set
        # (title bar, system menu, min, max, close). The window is
        # still modeless (we call .exec() to block, but the WM treats
        # it like a regular window so the user can maximise / minimise
        # it.
        self.setWindowFlags(
            Qt.WindowType.Window
            | Qt.WindowType.CustomizeWindowHint
            | Qt.WindowType.WindowTitleHint
            | Qt.WindowType.WindowSystemMenuHint
            | Qt.WindowType.WindowMinimizeButtonHint
            | Qt.WindowType.WindowMaximizeButtonHint
            | Qt.WindowType.WindowCloseButtonHint
        )
        layout = QVBoxLayout(self)

        # File picker row (path + Browse).
        picker_row = QHBoxLayout()
        picker_row.addWidget(QLabel("File:"))
        self.path_label = QLineEdit(str(initial))
        self.path_label.setReadOnly(True)
        picker_row.addWidget(self.path_label, 1)
        browse_btn = QPushButton("Browse…")
        browse_btn.clicked.connect(self._on_browse)
        picker_row.addWidget(browse_btn)
        layout.addLayout(picker_row)

        # Text viewer.
        self.text = QPlainTextEdit()
        self.text.setReadOnly(True)
        mono = QFont("Monospace")
        mono.setStyleHint(QFont.StyleHint.TypeWriter)
        self.text.setFont(mono)
        self.text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        layout.addWidget(self.text, 1)

        # Close button.
        bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
        bbox.rejected.connect(self.reject)
        bbox.accepted.connect(self.accept)
        layout.addWidget(bbox)

        self.resize(800, 560)
        self._load_file(initial)

    def _load_file(self, path: Path):
        try:
            content = path.read_text(encoding="utf-8")
        except Exception as exc:
            QMessageBox.warning(self, "View Recording",
                                f"Could not read file:\n{exc}")
            return
        self.path_label.setText(str(path))
        self.text.setPlainText(content)
        # Scroll to top after replacing content.
        cursor = self.text.textCursor()
        cursor.movePosition(cursor.MoveOperation.Start)
        self.text.setTextCursor(cursor)

    def _on_browse(self):
        start_dir = str(Path(self.path_label.text()).parent)
        dialog = QFileDialog(self, "View Recording", start_dir)
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["CSV files (*.csv)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if files:
            self._load_file(Path(files[0]))


def _filter_terms(text: str) -> List[str]:
    """Split a quick-filter string into lowercase AND terms on '+'.

    "sens+temp" -> ["sens", "temp"]; a row matches when it contains ALL terms.
    Mirrors UaExplorer's node-browser filter so the two tools behave alike.
    """
    return [t for t in (s.strip().lower() for s in text.split("+")) if t]


class _AddValueDialog(QDialog):
    """Pick one or more Variable nodes (from a live server browse) to add as
    curves to a plot. Flat list of "path  [node_id]" rows with a '+'-AND
    quick filter and multi-select."""

    def __init__(self, nodes: List[Tuple[str, str]], existing: set, parent=None):
        super().__init__(parent)
        self.setWindowTitle("Add Value")
        self.setWindowFlags(
            Qt.WindowType.Window
            | Qt.WindowType.CustomizeWindowHint
            | Qt.WindowType.WindowTitleHint
            | Qt.WindowType.WindowSystemMenuHint
            | Qt.WindowType.WindowMinimizeButtonHint
            | Qt.WindowType.WindowMaximizeButtonHint
            | Qt.WindowType.WindowCloseButtonHint
        )
        layout = QVBoxLayout(self)

        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("Filter (use + for AND, e.g. sens+temp)")
        self.filter_input.textChanged.connect(self._apply_filter)
        layout.addWidget(self.filter_input)

        self.list = QListWidget()
        self.list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        layout.addWidget(self.list, 1)

        for node_id, path in nodes:
            already = node_id in existing
            text = f"{path}  [{node_id}]"
            if already:
                text += "  (already plotted)"
            item = QListWidgetItem(text)
            item.setData(Qt.ItemDataRole.UserRole, node_id)
            # Use the full browse path as the curve label (not just the leaf)
            # so curves sharing a leaf name (e.g. two "Temperature" sensors)
            # stay distinguishable in the legend.
            item.setData(Qt.ItemDataRole.UserRole + 1, path or node_id)
            if already:
                item.setForeground(QColor("#888"))
            self.list.addItem(item)

        if self.list.count() == 0:
            self.list.addItem("(no variable nodes found)")
            self.list.setEnabled(False)

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

        self.resize(640, 480)
        self.filter_input.setFocus()

    def _apply_filter(self, text: str):
        terms = _filter_terms(text)
        for i in range(self.list.count()):
            item = self.list.item(i)
            if item.data(Qt.ItemDataRole.UserRole) is None:
                continue  # the "(no variable nodes found)" placeholder
            hay = item.text().lower()
            item.setHidden(bool(terms) and not all(t in hay for t in terms))

    def selected_nodes(self) -> List[Tuple[str, str]]:
        """(node_id, display_name) for each selected, visible row."""
        out: List[Tuple[str, str]] = []
        for item in self.list.selectedItems():
            node_id = item.data(Qt.ItemDataRole.UserRole)
            if node_id is None or item.isHidden():
                continue
            label = item.data(Qt.ItemDataRole.UserRole + 1) or node_id
            out.append((str(node_id), str(label)))
        return out


class _RemoveValueDialog(QDialog):
    """List the current curves on a plot (display name + node id) and let the
    user multi-select which to remove."""

    def __init__(self, curves: List[Tuple[str, str]], parent=None):
        super().__init__(parent)
        self.setWindowTitle("Remove Value")
        self.setWindowFlags(
            Qt.WindowType.Window
            | Qt.WindowType.CustomizeWindowHint
            | Qt.WindowType.WindowTitleHint
            | Qt.WindowType.WindowSystemMenuHint
            | Qt.WindowType.WindowCloseButtonHint
        )
        layout = QVBoxLayout(self)
        layout.addWidget(QLabel("Select values to remove:"))

        self.list = QListWidget()
        self.list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        layout.addWidget(self.list, 1)
        for node_id, label in curves:
            shown = f"{label}  [{node_id}]" if label and label != node_id else node_id
            item = QListWidgetItem(shown)
            item.setData(Qt.ItemDataRole.UserRole, node_id)
            self.list.addItem(item)

        bbox = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        ok_btn = bbox.button(QDialogButtonBox.StandardButton.Ok)
        if ok_btn is not None:
            ok_btn.setText("Remove")
        bbox.accepted.connect(self.accept)
        bbox.rejected.connect(self.reject)
        layout.addWidget(bbox)
        self.resize(480, 360)

    def selected_nodes(self) -> List[str]:
        return [
            str(item.data(Qt.ItemDataRole.UserRole))
            for item in self.list.selectedItems()
            if item.data(Qt.ItemDataRole.UserRole) is not None
        ]


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

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


class _PlotRecorder(QObject):
    """Periodic snapshotter that writes one proper-CSV row per tick from
    the current "latest value" of every plotted series.

    Sampling is independent of the worker's update cadence: each tick
    just reads each series' last known (x, y) from the PlotTab buffers
    + curve data. Series with no samples yet are written as empty cells.
    Series order is captured at Start time and held fixed for the whole
    file — variables added/removed after Start are ignored until the
    next Start.

    Three stop modes:
      - "Until stopped": runs until ``stop()`` is called.
      - "Period of time": auto-stops after ``limit`` seconds.
      - "Samples": auto-stops after ``limit`` rows.

    Output is RFC-4180 CSV written via ``csv.writer`` (values are quoted /
    escaped as needed), preceded by a block of ``#`` comment lines
    describing the recording (generator, UTC date, server URI, rate, mode).
    The filename always ends in ``.csv``.

    Output:
      - Default directory: ``<settings_dir>/recordings/``
      - Auto-timestamped filename (e.g. ``rec_20260514_142133.csv``)
        unless ``overwrite=True`` and ``filename`` is given.

    Signals are NOT used — the recorder talks back through callbacks
    passed by UaPlotWindow so the UI can update its status indicator.
    """

    def __init__(
        self,
        plots: Dict[str, "PlotTab"],
        on_started: Callable[[Path], None],
        on_stopped: Callable[[Optional[str]], None],
        on_tick: Callable[[int], None],
        parent: Optional[QObject] = None,
    ):
        super().__init__(parent)
        self._plots = plots
        self._on_started = on_started
        self._on_stopped = on_stopped
        self._on_tick = on_tick
        self._timer = QTimer(self)
        self._timer.timeout.connect(self._tick)
        self._file = None
        self._writer = None
        self._series_keys: List[str] = []
        self._series_labels: List[str] = []
        self._row_count = 0
        self._mode = "Until stopped"
        self._limit = 0
        self._t_start = 0.0
        self.path: Optional[Path] = None

    @property
    def is_running(self) -> bool:
        return self._timer.isActive()

    def start(
        self,
        rate_hz: float,
        mode: str,
        limit: int,
        directory: Path,
        filename: str,
        overwrite: bool,
        uri: str = "",
    ) -> Tuple[bool, Optional[str]]:
        """Begin recording. Returns (ok, error_message).

        Caller is responsible for confirming there's at least one
        series to record (see UaPlotWindow._on_record_start).
        """
        if self.is_running:
            return False, "Already recording."
        # Snapshot the series set so additions/removals during recording
        # don't desync rows.
        keys: List[str] = []
        labels: List[str] = []
        for plot in self._plots.values():
            for node_id in plot.series_order:
                if node_id in keys:
                    continue
                keys.append(node_id)
                data = plot.series.get(node_id, {})
                labels.append(str(data.get("label", node_id)))
        if not keys:
            return False, "No series to record."

        directory.mkdir(parents=True, exist_ok=True)
        # CSV-only: the on-disk name always ends in .csv regardless of what
        # the user typed.
        if overwrite and filename.strip():
            target = directory / _ensure_csv_suffix(filename.strip())
        else:
            stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            base = Path(filename.strip() or "rec").stem  # drop any extension
            target = directory / f"{base}_{stamp}.csv"

        try:
            # newline="" so csv.writer controls line endings itself (we set
            # lineterminator="\n" — we're on a Linux filesystem, so no \r).
            self._file = open(target, "w", encoding="utf-8", newline="")
            # Comment block first (lines start with '#'), then the proper
            # CSV column header. Same shape as UaExplorer's recordings so a
            # single reader handles both tools' output.
            now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            for line in (
                "# Generated by UaPlot",
                f"# Date (UTC): {now_utc}",
                f"# Server URI: {uri or 'N/A'}",
                f"# Recording rate (Hz): {rate_hz}",
                f"# Recording mode: {mode}",
                f"# Recording limit: {limit}",
                f"# Output file: {target}",
            ):
                self._file.write(line + "\n")
            self._writer = csv.writer(self._file, lineterminator="\n")
            self._writer.writerow(["timestamp"] + labels)
            self._file.flush()
        except Exception as exc:
            return False, f"Could not open output file: {exc}"

        self._series_keys = keys
        self._series_labels = labels
        self._row_count = 0
        self._mode = mode
        self._limit = max(0, int(limit))
        self._t_start = time.time()
        self.path = target

        # Convert rate (Hz) to interval (ms), clamped to a sane range
        # so a wild 0 Hz doesn't lock the timer. Floor at 1 ms so the full
        # 0.1..999.9 Hz range is honoured (999.9 Hz -> ~1 ms).
        rate = max(0.01, float(rate_hz))
        interval_ms = max(1, int(1000.0 / rate))
        self._timer.start(interval_ms)
        self._on_started(target)
        return True, None

    def stop(self, reason: Optional[str] = None):
        if not self.is_running and self._file is None:
            return
        self._timer.stop()
        if self._file is not None:
            try:
                self._file.close()
            except Exception:
                pass
            self._file = None
        self._writer = None
        self._on_stopped(reason)

    def _tick(self):
        if self._file is None:
            return
        cells = [datetime.now().isoformat(timespec="milliseconds")]
        # Find the latest value for each captured series across all
        # plots. Iteration cost is small for typical N (tens of series).
        latest: Dict[str, str] = {}
        for plot in self._plots.values():
            for node_id, data in plot.series.items():
                if node_id not in self._series_keys:
                    continue
                ys = data.get("y") or []
                buf_y = data.get("buffer_y") or []
                if buf_y:
                    latest[node_id] = _format_sample(buf_y[-1])
                elif ys:
                    latest[node_id] = _format_sample(ys[-1])
        for key in self._series_keys:
            cells.append(latest.get(key, ""))
        try:
            self._writer.writerow(cells)
            self._file.flush()
        except Exception as exc:
            self.stop(reason=f"Write error: {exc}")
            return
        self._row_count += 1
        self._on_tick(self._row_count)

        # Auto-stop conditions
        if self._mode == "Samples" and self._limit > 0 and self._row_count >= self._limit:
            self.stop(reason="Sample limit reached")
        elif self._mode == "Period of time" and self._limit > 0:
            elapsed = time.time() - self._t_start
            if elapsed >= self._limit:
                self.stop(reason="Time limit reached")


def _format_sample(v: object) -> str:
    """Render a numeric sample for CSV. Booleans -> 0/1, floats -> repr."""
    if isinstance(v, bool):
        return "1" if v else "0"
    if isinstance(v, (int, float)):
        return repr(v)
    return str(v)


def _fmt_stat(v: float) -> str:
    """Render a stat value for the per-plot stats row.

    Fixed 3 decimal places, always — trailing zeros are kept so the
    columns line up and a whole number still reads as ``5.000`` rather
    than ``5``. (Previously ``{:.3g}`` dropped the decimals, so e.g. a
    constant signal showed as a bare integer.)
    """
    try:
        return f"{float(v):.3f}"
    except (TypeError, ValueError):
        return str(v)


class UaPlotWindow(QMainWindow):
    """Main plotting window."""

    def __init__(self, uri: str, channel: str, theme: Optional[Theme] = None,
                 server_name: str = "", standalone: bool = True):
        super().__init__()
        self.uri = uri
        self.channel = channel
        # When a parent (UaShell/UaExplorer) drives us, it owns the connection,
        # so URI changes and session loading are disabled (standalone-only).
        self.standalone = standalone
        self.worker: Optional[UaPlotWorker] = None
        # Browse/connect state for the Add-Value flow.
        self._worker_connected = False
        self._pending_add_plot_id: Optional[str] = None
        self._browse_progress: Optional[QProgressDialog] = None
        # Namespace browse cache: scanned ONCE per server (in the background
        # on connect) and reused for every Add Value, rather than re-scanning
        # each time. Invalidated when the URI changes.
        self._browse_cache: Optional[List[Tuple[str, str]]] = None
        self._browse_cache_uri: Optional[str] = None
        self._browse_in_flight = False
        self.plots: Dict[str, PlotTab] = {}
        self.plot_order: List[str] = []
        self.plot_assignments: Dict[str, set[str]] = {}
        self.plot_counter = 1
        self.mosaic_view_enabled = False
        self.flush_interval_ms = 150
        # Per-plot rolling buffer size — caps the number of samples
        # each curve keeps, and also defines the window stats are
        # computed over. Configurable from Preferences > Performance.
        self.history_samples = 1000
        # Per-plot statistics config. enabled=False by default to
        # preserve the historical look; toggled on per stat to keep
        # the row compact in mosaic view.
        self.statistics = {
            "enabled": False,
            "show_last": True,
            "show_min": True,
            "show_max": True,
            "show_mean": True,
        }
        # Pixel size for the per-plot series label (custom legend).
        # User-tunable from Preferences; persisted to settings and to
        # sessions so a saved view looks identical on reload.
        self.legend_font_size_px = 11
        # Pixel size of the plot-title label shown above each plot in
        # mosaic view. Tabs view doesn't show a separate title (the tab
        # bar already carries the name) so the setting is effectively
        # mosaic-only. Persisted in settings and sessions.
        self.plot_title_font_size_px = 12
        # Recording config — persisted in settings.json and sessions.
        # Defaults match UaExplorer's recording defaults.
        self.recording_settings = {
            "visible": False,           # whether the recording row is shown
            "rate_hz": 1.0,
            "mode": "Until stopped",    # or "Period of time" / "Samples"
            "limit": 60,
            "filename": "",             # empty = auto-timestamped
            "directory": "",            # filled at _load_settings time
            "overwrite": False,
        }
        self._recorder: Optional[_PlotRecorder] = None
        # Per-URI display names. Maps OPC UA URI -> human-friendly name,
        # persisted to settings.json. Independent of sessions (sessions
        # reference a URI; the name comes from this dict at display
        # time). Empty / missing -> show "-".
        self.server_names: Dict[str, str] = {}
        # ``UAPLOT_HOME`` overrides the per-user storage root. Used by
        # integration tests so the test run doesn't read/write the real
        # user's ~/.uatools/UaPlot/. Falls back to the standard location
        # in the absence of the env var.
        override = os.environ.get("UAPLOT_HOME", "").strip()
        if override:
            self.settings_dir = Path(os.path.expanduser(os.path.expandvars(override)))
        else:
            self.settings_dir = Path.home() / ".uatools" / "UaPlot"
        self.settings_file = self.settings_dir / "settings.json"
        self.sessions_dir = self.settings_dir / "sessions"
        # Default recordings directory: $HOME (same convention as
        # UaExplorer's recording_dir). Recordings are user data, not
        # app state, so they don't belong under .uatools/. Created on
        # demand by the recorder.
        if not self.recording_settings["directory"]:
            self.recording_settings["directory"] = str(Path.home())
        # Path of the most recently loaded/saved session. Persisted to
        # settings so that Restart (and any subsequent standalone launch
        # without --uri) brings back the same plots automatically.
        self._last_session_path: Optional[str] = None
        # Whether a session is actually loaded/saved in THIS view. Distinct
        # from _last_session_path, which is merely "remembered" across runs
        # (a plain launch restores the path but does NOT load it). Only this
        # flag drives the header's "Session:" segment.
        self._session_loaded = False
        # Window geometry blob (raw bytes from QWidget.saveGeometry).
        # _load_settings populates this; _build_ui restores it after the
        # widgets exist so the platform-specific frame metrics resolve
        # correctly. Same pattern as UaExplorer.
        self._stored_geometry: Optional[bytes] = None
        self.mosaic_items: Dict[str, Dict[str, object]] = {}
        self.mosaic_columns = 2
        self.mosaic_min_tile_width = 240
        self._mosaic_refresh_pending = False
        self._mosaic_tile_height: Optional[int] = None
        # Track whether the theme was supplied by the caller (CLI/IPC) — if
        # so, _load_settings must NOT override it with the persisted value.
        self._theme_explicit = theme is not None
        self.theme: Theme = theme or LIGHT_THEME
        # Remember the exact startup script so Restart re-execs the same
        # entry point the user launched (e.g. INTROOT/bin/UaPlot), not the
        # source file. Mirrors UaExplorer.
        try:
            candidate = Path(sys.argv[0]).resolve()
            self._startup_script = candidate if candidate.exists() else Path(__file__).resolve()
        except Exception:
            self._startup_script = Path(__file__).resolve()

        # Start IPC server first so UaExplorer can connect before we finish building UI
        self.command_server = PlotCommandServer(channel, self._handle_command, self)

        self._load_settings()
        # A name supplied by the launching tool (UaExplorer via --server-name)
        # wins over the persisted name for this URI, the same way an explicit
        # --theme wins over the stored theme. _load_settings replaced
        # self.server_names wholesale, so apply the override afterwards and
        # persist it so a later standalone launch on the same URI keeps it.
        if server_name and uri:
            if self.server_names.get(uri, "") != server_name:
                self.server_names[uri] = server_name
                self._save_settings()
        self._build_ui()
        self._apply_theme_to_plots()
        self._start_worker()

    def _apply_theme_to_plots(self) -> None:
        """Push current theme colors into pyqtgraph plot widgets and the
        custom QLabel-based series legends. Safe to call before or after
        plots exist; new plots adopt the theme via PlotTab construction.

        The pyqtgraph canvas uses ``input_bg`` (same surface as UaExplorer's
        Node Browser/Subscriptions) and ``text_primary`` for the axes — this
        keeps the plot visually part of the same surface family rather than
        floating as a separate window."""
        pg.setConfigOption("background", self.theme.input_bg)
        pg.setConfigOption("foreground", self.theme.text_primary)
        for plot in self.plots.values():
            plot.apply_theme(self.theme)

    def set_theme(self, theme: Theme) -> None:
        """Switch theme live. Used both at startup (via __init__) and at
        runtime via the `set_theme` IPC command from UaExplorer."""
        self.theme = theme
        apply_theme_to_application(theme)
        self._apply_theme_to_plots()
        # The "=" menu button has an explicit stylesheet (to hide the
        # default drop-down arrow). Qt treats stylesheet'd widgets as
        # opaque for palette propagation, so a palette change does not
        # automatically restyle this button — it keeps the old theme's
        # colors. Re-applying the same stylesheet forces Qt to
        # re-resolve it against the new palette.
        if getattr(self, "menu_button", None) is not None:
            self.menu_button.setStyleSheet(
                "QToolButton::menu-indicator { image: none; width: 0px; }"
            )
        # Mosaic plot-title labels also have stylesheets (font-size +
        # color) so they share the same Qt limitation. Re-apply the
        # title stylesheet — it now embeds theme.text_primary, so this
        # is what actually picks up the new theme's text color.
        if hasattr(self, "mosaic_items"):
            stylesheet = self._plot_title_stylesheet()
            for entry in self.mosaic_items.values():
                label = entry.get("label")
                if label is not None:
                    label.setStyleSheet(stylesheet)
        # Same trap for the Name QLineEdit in the header — its
        # frameless-transparent stylesheet pins its text color to
        # whatever theme was active when the sheet was first set.
        # Re-apply with the new theme's text_primary baked in.
        if hasattr(self, "name_edit"):
            self.name_edit.setStyleSheet(self._name_edit_stylesheet())

    def _name_edit_stylesheet(self) -> str:
        """Stylesheet for the frameless Name QLineEdit in the header.

        Bakes theme.text_primary into the rule so the field's text
        color follows the active theme — Qt treats stylesheet'd
        widgets as opaque for palette propagation, so we have to do
        this explicitly on every theme switch.
        """
        color = self.theme.text_primary
        return (
            f"QLineEdit {{ background: transparent; padding: 0px; "
            f"color: {color}; }}"
        )

    def _build_ui(self):
        self.setWindowTitle("UA Plot")
        central = QWidget()
        layout = QVBoxLayout()

        header = QHBoxLayout()
        self.menu_button = QToolButton()
        self.menu_button.setText("=")
        # Match UaExplorer's panel-menu styling so the button picks up
        # the active theme: text-only, auto-raise (transparent until
        # hover/press) and a tooltip. Without setAutoRaise(True) Qt
        # paints the Button-role surface in a flat color that doesn't
        # blend with the themed header background.
        self.menu_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
        self.menu_button.setAutoRaise(True)
        self.menu_button.setToolTip("Menu")
        menu = QMenu(self)
        # Session management (top of menu, mirrors UaExplorer)
        save_session_action = QAction("Save Session...", self)
        save_session_action.triggered.connect(self._save_session_dialog)
        menu.addAction(save_session_action)

        load_session_action = QAction("Load Session...", self)
        load_session_action.triggered.connect(self._load_session_dialog)
        # Standalone-only: a parent-driven UaPlot must not replace the
        # launcher's plot set.
        load_session_action.setEnabled(self.standalone)
        if not self.standalone:
            load_session_action.setToolTip(
                "Available only when UaPlot is started standalone")
        menu.addAction(load_session_action)

        menu.addSeparator()

        about_action = QAction("About", self)
        about_action.triggered.connect(self._show_about)
        menu.addAction(about_action)

        prefs_action = QAction("Preferences", self)
        prefs_action.triggered.connect(self._show_preferences)
        menu.addAction(prefs_action)

        menu.addSeparator()

        # Mosaic View is UaPlot-specific (UaExplorer has the log-dock toggles
        # in roughly this slot). Keep it between Preferences and the bottom
        # Restart/Exit group so the spec'd ordering of Save/Load, About,
        # Preferences, Restart, Exit is preserved.
        mosaic_action = QAction("Mosaic View", self, checkable=True)
        mosaic_action.setChecked(self.mosaic_view_enabled)
        mosaic_action.toggled.connect(self._toggle_mosaic_view)
        menu.addAction(mosaic_action)
        self.mosaic_action = mosaic_action

        rec_action = QAction("Show Recording Controls", self, checkable=True)
        rec_action.setChecked(bool(self.recording_settings.get("visible")))
        rec_action.toggled.connect(self._toggle_recording_controls)
        menu.addAction(rec_action)
        self.rec_action = rec_action

        menu.addSeparator()

        # Ctrl+Shift+R restarts the panel. We rely on the window-level
        # QShortcut registered in _build_ui for the actual key binding;
        # binding setShortcut() here as well would produce a Qt
        # "Ambiguous shortcut overload" warning at every keypress because
        # this menu is built once and lives for the window lifetime
        # (unlike UaExplorer's, which is rebuilt per click). The hint in
        # the label is decorative — Qt won't try to register it.
        restart_action = QAction("Restart\tCtrl+Shift+R", self)
        restart_action.triggered.connect(self.restart_application)
        menu.addAction(restart_action)

        exit_action = QAction("Exit", self)
        exit_action.triggered.connect(self.close)
        menu.addAction(exit_action)

        self.menu_button.setMenu(menu)
        self.menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        # Hide the default drop-down arrow to match UaExplorer's "=" menu look
        self.menu_button.setStyleSheet("QToolButton::menu-indicator { image: none; width: 0px; }")
        header.addWidget(self.menu_button)

        # Slightly larger font for the Server / Name strip so it reads
        # as a header without dominating the window. 1.4x the default
        # is the sweet spot — noticeably bigger than secondary chrome
        # but not shouty.
        base_font = QApplication.font()
        base_pt = base_font.pointSizeF() if base_font.pointSizeF() > 0 else 10.0
        header_font = QFont(base_font)
        header_font.setPointSizeF(base_pt * 1.4)

        server_label = QLabel("Server:")
        server_label.setFont(header_font)
        header.addWidget(server_label)
        self.uri_label = QLabel(self.uri or "-")
        self.uri_label.setFont(header_font)
        self.uri_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
        header.addWidget(self.uri_label)

        # Visible separator between URI and Name so they read as two
        # distinct fields. The slash carries the same prominent font
        # as the surrounding labels.
        sep_label = QLabel(" / ")
        sep_label.setFont(header_font)
        header.addWidget(sep_label)

        # User-editable nickname for the current server URI, placed
        # immediately to the right of the URI output. Persisted in
        # self.server_names (URI -> name) so the same URI on a future
        # run / session load shows the same name automatically. Empty
        # stored name renders as the "-" placeholder.
        name_label = QLabel("Name:")
        name_label.setFont(header_font)
        header.addWidget(name_label)
        self.name_edit = QLineEdit()
        self.name_edit.setPlaceholderText("-")
        self.name_edit.setToolTip(
            "Friendly name for this server URI. Saved per URI; the same\n"
            "URI on a future run will show the same name automatically."
        )
        self.name_edit.setMaximumWidth(220)
        self.name_edit.setFont(header_font)
        # Width tracks the content so the field is only as wide as the name
        # (plus a little editing room) — a fixed-ish width left a big gap
        # before the "/ Session:" segment. Fixed size policy + an explicit
        # width recomputed in _resize_name_edit_to_content().
        self.name_edit.setSizePolicy(QSizePolicy.Policy.Fixed,
                                     QSizePolicy.Policy.Fixed)
        # Match the URI label's frameless look — the field is editable
        # on click but visually it's just text, like the URI to its left.
        # Drop the rounded box that QLineEdit gets by default and the
        # transparent background so the row reads as one unbroken band.
        self.name_edit.setFrame(False)
        # Zero the internal text margins so the field hugs its text — Qt
        # otherwise pads a couple px each side, widening the gap before
        # the "/ Session:" segment.
        self.name_edit.setTextMargins(0, 0, 0, 0)
        self.name_edit.setStyleSheet(self._name_edit_stylesheet())
        self.name_edit.editingFinished.connect(self._on_server_name_edited)
        # Re-fit while typing so the gap doesn't jump only on commit.
        self.name_edit.textChanged.connect(
            lambda _=None: self._resize_name_edit_to_content()
        )
        header.addWidget(self.name_edit)
        # Initial name display based on the current URI (if any).
        self._refresh_server_name_display()
        self._resize_name_edit_to_content()

        # Session segment: "/ Session: <stem>" shown only while a session
        # is loaded. The separator and both labels are hidden as a unit
        # when no session is active, so the header reads simply
        # "Server: <uri> / Name: <name>" until a session is opened.
        self.session_sep_label = QLabel(" / ")
        self.session_sep_label.setFont(header_font)
        header.addWidget(self.session_sep_label)
        self.session_caption_label = QLabel("Session:")
        self.session_caption_label.setFont(header_font)
        header.addWidget(self.session_caption_label)
        self.session_name_label = QLabel("")
        self.session_name_label.setFont(header_font)
        self.session_name_label.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
        )
        header.addWidget(self.session_name_label)
        # Populate / hide based on whatever session (if any) is already known.
        self._refresh_session_display()

        # Trailing stretch so the Server / Name pair sit on the left
        # and "New Plot" stays right-aligned.
        header.addStretch(1)

        self.new_plot_btn = QPushButton("New Plot")
        self.new_plot_btn.clicked.connect(self._create_plot_from_button)
        # "New Plot" only makes sense when UaPlot is driven from
        # UaExplorer over IPC: a user-clicked empty plot would otherwise
        # have no way to receive series (there's no Node Browser in
        # UaPlot itself). Hide it on standalone launches — detected by
        # the absence of a --uri argument, which UaExplorer always
        # passes. A later Load Session call brings plots back complete
        # with their series, so the button still isn't needed.
        if not self.uri:
            self.new_plot_btn.setVisible(False)
        header.addWidget(self.new_plot_btn)
        layout.addLayout(header)
        # Breathing room under the Server / Name strip so it reads as
        # its own band rather than blending into the plot area below.
        layout.addSpacing(8)

        # Recording row — hidden by default; toggled via the panel menu
        # "Show Recording Controls" item. Lives just under the Server
        # row so the controls feel like a second header strip rather
        # than something taking screen real estate from the plots.
        self.recording_row = self._build_recording_row()
        self.recording_row.setVisible(bool(self.recording_settings.get("visible")))
        layout.addWidget(self.recording_row)

        self.tabs = QTabWidget()
        self.tabs.tabBarDoubleClicked.connect(self._rename_tab)
        # Right-click on a tab to remove the plot (or rename it).
        tab_bar = self.tabs.tabBar()
        tab_bar.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        tab_bar.customContextMenuRequested.connect(self._on_tab_context_menu)
        # "Always fit" mosaic: the grid lives in a plain widget placed directly
        # in the view stack (NO QScrollArea). The grid's equal row/column
        # stretch then divides exactly the visible area, so tiles shrink to fit
        # the window and the mosaic never scrolls or clips. (A scroll area would
        # scroll once content exceeded the viewport - the opposite of fit.)
        self.mosaic_container = QWidget()
        self.mosaic_grid = QGridLayout()
        self.mosaic_grid.setContentsMargins(2, 2, 2, 2)
        self.mosaic_grid.setSpacing(4)
        self.mosaic_container.setLayout(self.mosaic_grid)

        self.view_stack = QStackedWidget()
        self.view_stack.addWidget(self.tabs)
        self.view_stack.addWidget(self.mosaic_container)
        layout.addWidget(self.view_stack, 1)

        central.setLayout(layout)
        self.setCentralWidget(central)
        # Restore the previously saved geometry if we have one; otherwise
        # fall back to a sensible default. Done here (after the central
        # widget is set) so Qt has the real frame metrics to work with.
        if self._stored_geometry:
            try:
                self.restoreGeometry(self._stored_geometry)
            except Exception:
                self.resize(900, 600)
        else:
            self.resize(900, 600)
        self._apply_view_mode()

        # Global Ctrl+Shift+R shortcut — also wired through the menu's
        # restart_action, but registering it as a QShortcut here makes it
        # trigger even when no menu is open (same pattern as UaExplorer).
        self.restart_shortcut = QShortcut(QKeySequence("Ctrl+Shift+R"), self)
        self.restart_shortcut.activated.connect(self.restart_application)

    def _on_flush_interval_changed(self, value: int):
        self.flush_interval_ms = int(value)
        for tab in self.plots.values():
            tab.set_flush_interval(self.flush_interval_ms)
        self._save_settings()

    def set_legend_font_size(self, px: int) -> None:
        """Apply a new legend font size to every existing plot and persist."""
        self.legend_font_size_px = max(6, int(px))
        for tab in self.plots.values():
            tab.set_legend_font_size(self.legend_font_size_px)
        self._save_settings()

    def set_history_samples(self, n: int) -> None:
        """Apply a new buffer cap to every existing plot and persist."""
        self.history_samples = max(100, int(n)) if n else 1000
        for tab in self.plots.values():
            tab.set_history_samples(self.history_samples)
        self._save_settings()

    def set_statistics(self, config: dict) -> None:
        """Push a new stats config to every existing plot and persist."""
        self.statistics = dict(config)
        for tab in self.plots.values():
            tab.set_stats_config(self.statistics)
        self._save_settings()

    def _plot_title_stylesheet(self) -> str:
        """Stylesheet for the mosaic-view plot-title QLabel.

        Embeds the theme's primary text color so the title follows the
        active theme — Qt treats stylesheet'd widgets as opaque for
        palette propagation, so a theme switch alone won't restyle them.
        """
        color = self.theme.text_primary
        return (
            f"QLabel {{ color: {color}; "
            f"font-size: {self.plot_title_font_size_px}px; }}"
        )

    def set_plot_title_font_size(self, px: int) -> None:
        """Apply a new plot-title font size to every mosaic tile and persist.

        Tabs view doesn't show a title above the plot (the tab bar does)
        so the change is only visible in mosaic — but we still persist
        the value so it's correct when the user switches to mosaic later.
        """
        self.plot_title_font_size_px = max(6, int(px))
        stylesheet = self._plot_title_stylesheet()
        for entry in self.mosaic_items.values():
            label = entry.get("label")
            if label is not None:
                label.setStyleSheet(stylesheet)
        self._save_settings()

    # ---- Recording ------------------------------------------------------
    #
    # The recording row is a horizontal strip displayed under the Server
    # row when toggled on via the panel menu. It captures the current
    # value of every series at a fixed rate to a tab-separated CSV.
    # Snapshot semantics match UaExplorer: each tick writes the LAST
    # known value per series, so slow-changing variables show flat
    # lines (this is the standard recording format).

    def _build_recording_row(self) -> QWidget:
        """Construct the recording controls strip."""
        row_widget = QWidget()
        row = QHBoxLayout(row_widget)
        row.setContentsMargins(2, 2, 2, 2)
        row.setSpacing(6)

        row.addWidget(QLabel("Recording"))

        # Rate is a free-text field, not a spinbox: nobody steps 0.1..999.9 Hz
        # by clicking arrows. Validated to 0.1..999.9 with one decimal.
        self.rec_rate_input = QLineEdit(
            self._fmt_rate(self.recording_settings.get("rate_hz") or 1.0)
        )
        self.rec_rate_input.setFixedWidth(56)
        self.rec_rate_input.setMaxLength(5)
        _rate_validator = QDoubleValidator(0.1, 999.9, 1, self.rec_rate_input)
        _rate_validator.setNotation(QDoubleValidator.Notation.StandardNotation)
        self.rec_rate_input.setValidator(_rate_validator)
        self.rec_rate_input.setToolTip("Sample rate (Hz): how many CSV rows per second.")
        self.rec_rate_input.editingFinished.connect(self._on_rec_rate_edited)
        row.addWidget(self.rec_rate_input)
        row.addWidget(QLabel("Hz"))

        self.rec_mode_combo = QComboBox()
        self.rec_mode_combo.addItems(["Until stopped", "Period of time", "Samples"])
        cur_mode = str(self.recording_settings.get("mode") or "Until stopped")
        if cur_mode in ("Until stopped", "Period of time", "Samples"):
            self.rec_mode_combo.setCurrentText(cur_mode)
        self.rec_mode_combo.setToolTip(
            "When to stop recording automatically:\n"
            "  Until stopped — runs until you click Stop\n"
            "  Period of time — stops after Limit seconds\n"
            "  Samples — stops after Limit rows"
        )
        self.rec_mode_combo.currentTextChanged.connect(self._on_rec_mode_changed)
        row.addWidget(self.rec_mode_combo)

        self.rec_limit_label = QLabel("Limit")
        row.addWidget(self.rec_limit_label)
        # Limit is an integer (samples or seconds — no fractional sample):
        # 1..9999, free-text for the same reason as Rate.
        self.rec_limit_input = QLineEdit(
            str(int(self.recording_settings.get("limit") or 60))
        )
        self.rec_limit_input.setFixedWidth(56)
        self.rec_limit_input.setMaxLength(4)
        self.rec_limit_input.setValidator(QIntValidator(1, 9999, self.rec_limit_input))
        self.rec_limit_input.editingFinished.connect(self._on_rec_limit_edited)
        row.addWidget(self.rec_limit_input)
        # Unit label after Limit: " s" for Period-of-time, blank for Samples.
        self.rec_limit_unit_label = QLabel("")
        row.addWidget(self.rec_limit_unit_label)
        self._on_rec_mode_changed(self.rec_mode_combo.currentText())

        row.addWidget(QLabel("Output"))
        self.rec_filename_edit = QLineEdit(
            str(self.recording_settings.get("filename") or "")
        )
        self.rec_filename_edit.setPlaceholderText("auto-timestamped")
        self.rec_filename_edit.setToolTip(
            "Output filename inside the recordings directory.\n"
            "Leave empty for an auto-timestamped name per run."
        )
        self.rec_filename_edit.editingFinished.connect(
            lambda: self._update_rec_setting("filename", self.rec_filename_edit.text())
        )
        row.addWidget(self.rec_filename_edit, 1)

        self.rec_browse_btn = QToolButton()
        self.rec_browse_btn.setText("…")
        self.rec_browse_btn.setToolTip("Choose output file")
        self.rec_browse_btn.clicked.connect(self._on_rec_browse)
        row.addWidget(self.rec_browse_btn)

        self.rec_overwrite_check = QCheckBox("Overwrite")
        self.rec_overwrite_check.setChecked(
            bool(self.recording_settings.get("overwrite"))
        )
        self.rec_overwrite_check.setToolTip(
            "When checked, write to the exact filename every run\n"
            "(overwriting the previous file). When unchecked, append\n"
            "a timestamp so each run goes to a fresh file."
        )
        self.rec_overwrite_check.toggled.connect(
            lambda b: self._update_rec_setting("overwrite", bool(b))
        )
        row.addWidget(self.rec_overwrite_check)

        self.rec_start_btn = QPushButton("Start")
        self.rec_start_btn.clicked.connect(self._on_rec_start)
        row.addWidget(self.rec_start_btn)
        self.rec_stop_btn = QPushButton("Stop")
        self.rec_stop_btn.setEnabled(False)
        self.rec_stop_btn.clicked.connect(self._on_rec_stop)
        row.addWidget(self.rec_stop_btn)
        self.rec_load_btn = QPushButton("Load")
        self.rec_load_btn.setToolTip(
            "Open a recorded CSV in a read-only viewer."
        )
        self.rec_load_btn.clicked.connect(self._on_rec_load)
        row.addWidget(self.rec_load_btn)

        self.rec_status_label = QLabel("● Idle")
        self.rec_status_label.setToolTip("Recording status.")
        row.addWidget(self.rec_status_label)

        return row_widget

    def _on_rec_mode_changed(self, mode: str):
        """Show/hide the Limit field based on the selected mode."""
        needs_limit = mode in ("Period of time", "Samples")
        self.rec_limit_label.setVisible(needs_limit)
        self.rec_limit_input.setVisible(needs_limit)
        self.rec_limit_unit_label.setVisible(needs_limit)
        # " s" for a time limit, nothing for a sample-count limit.
        self.rec_limit_unit_label.setText(" s" if mode == "Period of time" else "")
        self._update_rec_setting("mode", mode)

    @staticmethod
    def _fmt_rate(value) -> str:
        """Render a stored rate as a 1-decimal string (1 -> '1.0')."""
        try:
            return f"{float(value):.1f}"
        except (TypeError, ValueError):
            return "1.0"

    def _on_rec_rate_edited(self):
        """Persist the typed Rate, clamped to the validator range 0.1..999.9."""
        rate = self._rec_rate_value()
        self.rec_rate_input.setText(self._fmt_rate(rate))
        self._update_rec_setting("rate_hz", rate)

    def _on_rec_limit_edited(self):
        """Persist the typed Limit, clamped to the validator range 1..9999."""
        self._update_rec_setting("limit", self._rec_limit_value())
        self.rec_limit_input.setText(str(self._rec_limit_value()))

    def _rec_rate_value(self) -> float:
        """Current Rate field as a clamped float (0.1..999.9)."""
        try:
            rate = float(self.rec_rate_input.text())
        except ValueError:
            rate = float(self.recording_settings.get("rate_hz") or 1.0)
        return min(999.9, max(0.1, rate))

    def _rec_limit_value(self) -> int:
        """Current Limit field as a clamped int (1..9999)."""
        try:
            limit = int(self.rec_limit_input.text())
        except ValueError:
            limit = int(self.recording_settings.get("limit") or 60)
        return min(9999, max(1, limit))

    def _update_rec_setting(self, key: str, value):
        """Mutate one entry of recording_settings and persist."""
        self.recording_settings[key] = value
        self._save_settings()

    def _on_rec_browse(self):
        """File picker for the recording output filename."""
        rec_dir = Path(
            self.recording_settings.get("directory")
            or str(Path.home())
        )
        rec_dir.mkdir(parents=True, exist_ok=True)
        cur = self.recording_settings.get("filename") or ""
        start_path = str(rec_dir / cur) if cur else str(rec_dir)
        dialog = QFileDialog(self, "Recording output file", start_path)
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        # Recordings are CSV-only: no format choice in the picker.
        dialog.setNameFilters(["CSV files (*.csv)"])
        dialog.setDefaultSuffix("csv")
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        chosen = Path(files[0])
        # Force .csv regardless of what was typed (foo -> foo.csv,
        # foo.dat -> foo.csv).
        csv_name = _ensure_csv_suffix(chosen.name)
        self.recording_settings["directory"] = str(chosen.parent)
        self.recording_settings["filename"] = csv_name
        self.rec_filename_edit.setText(csv_name)
        self._save_settings()

    def _on_rec_load(self):
        """Open a recording CSV in the built-in read-only viewer.

        Pre-selects the most recent recording (if one was just made) or
        the configured filename, otherwise just opens the recordings
        directory.
        """
        rec_dir = Path(
            self.recording_settings.get("directory")
            or str(Path.home())
        )
        start_path = str(rec_dir)
        # Prefer the file path from the recorder if a recording just
        # finished — that's the most "fresh" candidate.
        if self._recorder is not None and self._recorder.path is not None \
                and self._recorder.path.exists():
            start_path = str(self._recorder.path)
        elif self.recording_settings.get("filename"):
            cand = rec_dir / str(self.recording_settings["filename"])
            if cand.exists():
                start_path = str(cand)

        dialog = QFileDialog(self, "View Recording", start_path)
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["CSV files (*.csv)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        viewer = _RecordingViewerDialog(Path(files[0]), parent=self)
        viewer.exec()

    def _on_rec_start(self):
        if self._recorder is None:
            self._recorder = _PlotRecorder(
                plots=self.plots,
                on_started=self._on_rec_started_ui,
                on_stopped=self._on_rec_stopped_ui,
                on_tick=self._on_rec_tick_ui,
                parent=self,
            )
        rec_dir = Path(
            self.recording_settings.get("directory")
            or str(Path.home())
        )
        ok, err = self._recorder.start(
            rate_hz=self._rec_rate_value(),
            mode=self.rec_mode_combo.currentText(),
            limit=self._rec_limit_value(),
            directory=rec_dir,
            filename=self.rec_filename_edit.text().strip(),
            overwrite=self.rec_overwrite_check.isChecked(),
            uri=self.uri,
        )
        if not ok:
            QMessageBox.warning(self, "Recording", err or "Failed to start.")

    def _on_rec_stop(self):
        if self._recorder is not None:
            self._recorder.stop(reason="User stopped")

    def _on_rec_started_ui(self, path: Path):
        self.rec_start_btn.setEnabled(False)
        self.rec_stop_btn.setEnabled(True)
        # Lock the config inputs while recording so they can't desync.
        for w in (
            self.rec_rate_input, self.rec_mode_combo, self.rec_limit_input,
            self.rec_filename_edit, self.rec_browse_btn, self.rec_overwrite_check,
        ):
            w.setEnabled(False)
        self.rec_status_label.setText(f"● Recording — {path.name}")

    def _on_rec_stopped_ui(self, reason: Optional[str]):
        self.rec_start_btn.setEnabled(True)
        self.rec_stop_btn.setEnabled(False)
        for w in (
            self.rec_rate_input, self.rec_mode_combo, self.rec_limit_input,
            self.rec_filename_edit, self.rec_browse_btn, self.rec_overwrite_check,
        ):
            w.setEnabled(True)
        suffix = f" — {reason}" if reason else ""
        rows = getattr(self._recorder, "_row_count", 0) if self._recorder else 0
        self.rec_status_label.setText(f"● Idle ({rows} rows{suffix})")

    def _on_rec_tick_ui(self, row_count: int):
        # Update the status to show progress without spamming a repaint.
        # Throttling: only refresh once per second worth of ticks.
        # Cheap: setText is fine at this rate.
        path = self._recorder.path if self._recorder else None
        name = path.name if path else ""
        self.rec_status_label.setText(f"● Recording — {name} ({row_count} rows)")

    def _toggle_recording_controls(self, enabled: bool):
        self.recording_row.setVisible(bool(enabled))
        self.recording_settings["visible"] = bool(enabled)
        self._save_settings()

    def _stop_recording_if_running(self, reason: str):
        """Called from session-load and similar transitions where the
        current recording would no longer match what's being plotted."""
        if self._recorder is not None and self._recorder.is_running:
            self._recorder.stop(reason=reason)

    def _sync_recording_row_from_settings(self):
        """Push self.recording_settings into the row widgets. Used after
        a session load. blockSignals so the setters don't re-trigger
        _update_rec_setting and write the same values back to disk
        repeatedly."""
        if not hasattr(self, "recording_row"):
            return
        visible = bool(self.recording_settings.get("visible"))
        self.recording_row.setVisible(visible)
        if hasattr(self, "rec_action"):
            self.rec_action.blockSignals(True)
            self.rec_action.setChecked(visible)
            self.rec_action.blockSignals(False)
        widgets_signals = [
            (self.rec_rate_input, "setText", self._fmt_rate(self.recording_settings.get("rate_hz") or 1.0)),
            (self.rec_mode_combo, "setCurrentText", str(self.recording_settings.get("mode") or "Until stopped")),
            (self.rec_limit_input, "setText", str(int(self.recording_settings.get("limit") or 60))),
            (self.rec_filename_edit, "setText", str(self.recording_settings.get("filename") or "")),
            (self.rec_overwrite_check, "setChecked", bool(self.recording_settings.get("overwrite"))),
        ]
        for widget, setter_name, value in widgets_signals:
            widget.blockSignals(True)
            try:
                getattr(widget, setter_name)(value)
            finally:
                widget.blockSignals(False)
        # Re-apply mode-dependent Limit visibility.
        self._on_rec_mode_changed(self.rec_mode_combo.currentText())

    def _toggle_mosaic_view(self, enabled: bool):
        self.mosaic_view_enabled = bool(enabled)
        self._apply_view_mode()
        self._save_settings()

    def _apply_view_mode(self):
        if getattr(self, "view_stack", None):
            self.view_stack.setCurrentWidget(
                self.mosaic_container if self.mosaic_view_enabled else self.tabs)
        if self.mosaic_view_enabled:
            self._schedule_mosaic_refresh()
        else:
            self._restore_tabs_from_mosaic()

    def _update_mosaic_columns(self) -> bool:
        """Recalculate column count based on available size and target aspect."""
        if not getattr(self, "mosaic_container", None):
            return False
        try:
            viewport_width = self.mosaic_container.width()
            viewport_height = self.mosaic_container.height()
        except Exception:
            viewport_width = self.width()
            viewport_height = max(1, self.height() - 100)

        plot_count = max(1, len(self.plot_order))
        min_width = max(160, self.mosaic_min_tile_width)
        target_ratio = 1.4  # prefer wider than tall

        # Never spread plots across more columns than a near-square grid needs.
        # Without this cap a wide window scores a single 4-wide row (4x1) as
        # well as 2x2 - and the old aspect/row tie-breakers then picked the
        # 4x1, whose total minimum width (cols x min_tile) could exceed the
        # window so it could no longer be shrunk horizontally. Capping at
        # ceil(sqrt(n)) keeps the grid balanced: 4->2 cols, 6->3, 9->3.
        balanced_cols = max(1, math.ceil(math.sqrt(plot_count)))

        best_cols = min(self.mosaic_columns, balanced_cols)
        best_score = float("inf")
        max_cols = balanced_cols

        for cols in range(1, max_cols + 1):
            rows = -(-plot_count // cols)
            tile_width = viewport_width / cols
            tile_height = max(1, viewport_height / rows)
            if tile_width < min_width and cols > 1:
                continue
            aspect = tile_width / tile_height
            score = abs(aspect - target_ratio)
            # Penalize empty trailing cells so the grid stays balanced: 4 plots
            # prefer 2x2 (0 empty) over 3-wide (2 empty), but 6 plots can still
            # use 3x2. Each empty cell adds a meaningful cost.
            empty_cells = cols * rows - plot_count
            score += empty_cells * 0.5
            if score < best_score:
                best_score = score
                best_cols = cols
                self._mosaic_tile_height = int(tile_height) if tile_height > 0 else None

        if best_cols != self.mosaic_columns:
            self.mosaic_columns = best_cols
            return True
        return False

    def _schedule_mosaic_refresh(self):
        if not self.mosaic_view_enabled:
            return
        if self._mosaic_refresh_pending:
            return
        self._mosaic_refresh_pending = True
        QTimer.singleShot(0, self._rebuild_mosaic_layout)

    def _rebuild_mosaic_layout(self):
        # Clear the pending flag at the START (not the end). A debounced refresh
        # is scheduled per _create_plot, but rapid ';'-chained 'plot' commands
        # arrive in a burst: if the singleShot fires mid-burst, later plots were
        # suppressed by the pending guard and never re-scheduled, so the mosaic
        # built with a stale (partial) plot set. Clearing pending here lets any
        # create that lands during/after this rebuild re-arm a fresh refresh, so
        # a final rebuild always reflects every plot.
        self._mosaic_refresh_pending = False
        self._update_mosaic_columns()
        # Clear existing containers
        for plot_id, item in list(self.mosaic_items.items()):
            tab = self.plots.get(plot_id)
            container = item.get("container")
            if container and tab:
                layout = container.layout()
                if layout:
                    layout.removeWidget(tab)
                tab.clear_size_limits()
            if container:
                self.mosaic_grid.removeWidget(container)
                container.deleteLater()
            if tab:
                tab.setParent(None)
            self.mosaic_items.pop(plot_id, None)

        while self.mosaic_grid.count():
            item = self.mosaic_grid.takeAt(0)
            widget = item.widget()
            if widget:
                widget.deleteLater()

        # Clear old stretch factors before rebuilding
        for col in range(self.mosaic_grid.columnCount()):
            self.mosaic_grid.setColumnStretch(col, 0)
        for row in range(self.mosaic_grid.rowCount()):
            self.mosaic_grid.setRowStretch(row, 0)

        for idx, plot_id in enumerate(self.plot_order):
            tab = self.plots.get(plot_id)
            if not tab:
                continue
            self._add_plot_to_mosaic(idx, plot_id, tab)

        # Set equal stretch for all columns and rows so plots share space uniformly
        plot_count = len(self.plot_order)
        if plot_count > 0:
            rows = -(-plot_count // self.mosaic_columns)  # ceiling division
            for col in range(self.mosaic_columns):
                self.mosaic_grid.setColumnStretch(col, 1)
            for row in range(rows):
                self.mosaic_grid.setRowStretch(row, 1)
        # Note: _mosaic_refresh_pending is cleared at the START of this method
        # (see comment there) so a create arriving during the rebuild re-arms.

    def _add_plot_to_mosaic(self, position: int, plot_id: str, tab: PlotTab):
        # Remove from tabs if present
        tab_index = self.tabs.indexOf(tab)
        if tab_index != -1:
            self.tabs.removeTab(tab_index)
        container = QWidget()
        container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        vbox = QVBoxLayout()
        vbox.setContentsMargins(2, 2, 2, 2)
        vbox.setSpacing(2)
        title = QLabel(tab.title)
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        title.setStyleSheet(self._plot_title_stylesheet())
        vbox.addWidget(title)
        vbox.addWidget(tab)
        # Make sure the plot widget is actually visible after the reparent
        tab.show()
        container.setLayout(vbox)
        container.show()
        # "Always fit" model: no height cap and a tiny minimum, so the grid's
        # equal row/column stretch divides the (non-scrolling) viewport exactly.
        # Tiles shrink to fit when there are many plots; they never force the
        # grid larger than the visible canvas. Use a small low minimum so the
        # plot widget doesn't impose its own larger minimum (which would push
        # the grid past the viewport and clip the bottom row).
        tab.clear_size_limits()
        tab.setMinimumHeight(40)
        # Explicitly low minimum WIDTH on the tile, its container and the title
        # label too. Otherwise the children's natural minimum widths add up
        # across a row: with several columns the grid's total minimum width can
        # exceed the window, so Qt refuses to shrink it horizontally (the bug
        # where the window got stuck wide with all plots in one row). A tiny
        # minimum lets the equal column stretch divide whatever width exists.
        tab.setMinimumWidth(40)
        tab.plot_widget.setMinimumSize(40, 30)
        container.setMinimumWidth(40)
        title.setMinimumWidth(0)
        tab.updateGeometry()
        row = position // self.mosaic_columns
        col = position % self.mosaic_columns
        self.mosaic_grid.addWidget(container, row, col)
        # Enable inline rename on double-click in mosaic view
        def _on_label_double_click(_event, pid=plot_id):
            self._rename_plot_by_id(pid)
        title.mouseDoubleClickEvent = _on_label_double_click
        # Right-click on the title only — NOT the whole container, so
        # pyqtgraph's own context menu (pan / zoom / export / view-all
        # / set range / etc.) on the plot canvas remains accessible.
        def _on_title_context_menu(pos, pid=plot_id, src=title):
            menu = QMenu(self)
            rename_action = menu.addAction("Rename Plot…")
            remove_action = menu.addAction("Remove Plot")
            menu.addSeparator()
            add_value_action = menu.addAction("Add Value…")
            remove_value_action = menu.addAction("Remove Value…")
            chosen = menu.exec(src.mapToGlobal(pos))
            if chosen is rename_action:
                self._rename_plot_by_id(pid)
            elif chosen is remove_action:
                self._remove_plot_by_id(pid)
            elif chosen is add_value_action:
                self._add_value_to_plot(pid)
            elif chosen is remove_value_action:
                self._remove_value_from_plot(pid)
        title.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        title.customContextMenuRequested.connect(_on_title_context_menu)

        self.mosaic_items[plot_id] = {"container": container, "label": title}

    def _restore_tabs_from_mosaic(self):
        # Remove all tabs to rebuild order cleanly
        while self.tabs.count():
            self.tabs.removeTab(0)

        for plot_id, item in list(self.mosaic_items.items()):
            tab = self.plots.get(plot_id)
            container = item.get("container")
            if container and tab:
                layout = container.layout()
                if layout:
                    layout.removeWidget(tab)
                tab.clear_size_limits()
            if container:
                self.mosaic_grid.removeWidget(container)
                container.deleteLater()
            if tab:
                tab.setParent(None)
            self.mosaic_items.pop(plot_id, None)

        while self.mosaic_grid.count():
            grid_item = self.mosaic_grid.takeAt(0)
            widget = grid_item.widget()
            if widget:
                widget.deleteLater()

        for plot_id in self.plot_order:
            tab = self.plots.get(plot_id)
            if not tab:
                continue
            self.tabs.addTab(tab, tab.title)
            tab.updateGeometry()
        if self.tabs.count():
            self.tabs.setCurrentIndex(self.tabs.count() - 1)

    def _bring_to_front(self):
        """Surface the window when a new plot is requested from UaExplorer."""
        self.show()
        if self.windowState() & Qt.WindowState.WindowMinimized:
            self.setWindowState(self.windowState() & ~Qt.WindowState.WindowMinimized)
        self.raise_()
        self.activateWindow()

    def _start_worker(self, monitor_mode: Optional[str] = None, polling_interval_ms: Optional[int] = None):
        if self.worker:
            self.worker.stop()
            self.worker.wait()
        # A fresh worker starts disconnected; the connected signal flips this.
        self._worker_connected = False
        # Any browse the old worker was running is gone with it.
        self._browse_in_flight = False
        self.worker = UaPlotWorker(self.uri)
        if monitor_mode:
            self.worker.set_monitor_mode(monitor_mode, polling_interval_ms or self.worker.polling_interval_ms)
        self.worker.data_update.connect(self._on_data_update)
        self.worker.error.connect(self._on_worker_error)
        self.worker.connected.connect(self._on_worker_connected)
        self.worker.disconnected.connect(self._on_worker_disconnected)
        self.worker.browse_result.connect(self._on_browse_result)
        self.worker.browse_error.connect(self._on_browse_error)
        self.worker.start()

    def _on_worker_disconnected(self):
        self._worker_connected = False

    def _restart_worker_with_nodes(self, monitor_mode: str, polling_interval_ms: int):
        nodes = set()
        for node_set in self.plot_assignments.values():
            nodes.update(node_set)
        self._start_worker(monitor_mode, polling_interval_ms)
        for node in nodes:
            self.worker.add_node(node)

    def _set_uri(self, uri: str):
        if uri and uri != self.uri:
            self.uri = uri
            self.uri_label.setText(uri)
            self._refresh_server_name_display()
            # New server -> the cached namespace browse no longer applies.
            self._invalidate_browse_cache()
            self._start_worker(self.worker.monitor_mode if self.worker else None, self.worker.polling_interval_ms if self.worker else None)

    def _refresh_server_name_display(self):
        """Sync the Name field with self.server_names for the current URI."""
        if not hasattr(self, "name_edit"):
            return
        name = ""
        if self.uri:
            name = str(self.server_names.get(self.uri, "")).strip()
        self.name_edit.blockSignals(True)
        self.name_edit.setText(name)
        self.name_edit.blockSignals(False)
        self._resize_name_edit_to_content()

    _NAME_EDIT_MAX_W = 220

    def _resize_name_edit_to_content(self):
        """Size the Name field to its text so the gap before '/ Session:'
        stays a single space. Falls back to the placeholder width when empty."""
        if not hasattr(self, "name_edit"):
            return
        from PyQt6.QtGui import QFontMetrics
        # QFontMetrics(font), not widget.fontMetrics(): the latter can report a
        # tiny advance before the widget is shown.
        fm = QFontMetrics(self.name_edit.font())
        text = self.name_edit.text() or self.name_edit.placeholderText() or "-"
        m = self.name_edit.textMargins()
        # Cap against a CONSTANT, never the widget's live maximumWidth():
        # setFixedWidth sets max==min, so reading maximumWidth() would clamp to
        # a previous short ("-" placeholder) width and permanently collapse the
        # field to one char. Only resize on an actual change to avoid a resize
        # feedback loop.
        width = min(fm.horizontalAdvance(text) + m.left() + m.right() + 4,
                    self._NAME_EDIT_MAX_W)
        final = max(width, 24)
        if self.name_edit.width() != final:
            self.name_edit.setFixedWidth(final)

    def _refresh_session_display(self):
        """Show 'Session: <stem>' in the header iff a session is loaded.

        The session name is the basename (without extension) of the loaded
        session file (``self._last_session_path``). When nothing is loaded the
        whole segment — separator, caption, value — is hidden so the header
        reads just 'Server: <uri> / Name: <name>'.
        """
        if not hasattr(self, "session_name_label"):
            return
        stem = ""
        if self._session_loaded and self._last_session_path:
            stem = Path(self._last_session_path).stem.strip()
        visible = bool(stem)
        self.session_sep_label.setVisible(visible)
        self.session_caption_label.setVisible(visible)
        self.session_name_label.setVisible(visible)
        self.session_name_label.setText(stem)

    def _on_server_name_edited(self):
        """Persist the entered name against the current URI.

        Empty input removes the entry to keep the dict tidy. No-op if
        we have no URI yet (the edit becomes a no-op until a server is
        connected — at which point the user can name it).
        """
        if not self.uri:
            self._refresh_server_name_display()
            return
        name = self.name_edit.text().strip()
        if name:
            self.server_names[self.uri] = name
        else:
            self.server_names.pop(self.uri, None)
        self._save_settings()

    def _show_about(self):
        """Show an overview of UA Tools and UA Plot.

        Pattern mirrors UaExplorer's About: logo on top if it can be
        located via find_file (else the dialog is text-only), then a
        selectable monospaced summary so users can paste into bug
        reports.
        """
        class _ScalingLogo(QLabel):
            """QLabel that rescales its source pixmap to its current
            width, keeping aspect ratio. Re-paints on every resize.
            Same widget UaExplorer uses for its About logo.
            """

            def __init__(self, source: QPixmap, parent=None):
                super().__init__(parent)
                self._source = source
                self.setAlignment(Qt.AlignmentFlag.AlignCenter)
                self.setMinimumHeight(1)
                policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
                policy.setHeightForWidth(True)
                self.setSizePolicy(policy)
                self._rescale()

            def _rescale(self):
                width = max(self.width(), 1)
                if self._source.isNull():
                    return
                target_h = self.heightForWidth(width)
                scaled = self._source.scaled(
                    width,
                    target_h,
                    Qt.AspectRatioMode.KeepAspectRatio,
                    Qt.TransformationMode.SmoothTransformation,
                )
                self.setPixmap(scaled)
                self.setFixedHeight(target_h)

            def resizeEvent(self, event):  # noqa: N802
                self._rescale()
                super().resizeEvent(event)

            def hasHeightForWidth(self):  # noqa: N802
                return True

            def heightForWidth(self, w):  # noqa: N802
                if self._source.isNull() or self._source.width() == 0:
                    return 0
                return int(w * self._source.height() / self._source.width())

        LBL = 25  # same column width as UaExplorer's About
        summary = (
            "ESO IFW — UA Tools\n"
            "\n"
            "UA Tools is the ESO Instrument Framework collection of utilities\n"
            "for working with OPC UA servers used in instrument control. It\n"
            "provides client-side tools for browsing namespaces, exercising\n"
            "methods, recording data, scripting, and live plotting — used both\n"
            "during the development of OPC UA control servers and for\n"
            "diagnostics on deployed instruments. Preparing first release\n"
            "under the UA Tools project name.\n"
            "\n"
            "UA Plot\n"
            "\n"
            "A PyQt6 live-charting GUI for OPC UA variables. Can be launched\n"
            "on demand by UA Explorer (via local IPC), or run standalone with\n"
            "a saved session — same plots, same data, no UA Explorer needed.\n"
            "\n"
            "Capabilities\n"
            "\n"
            "  Plotting\n"
            "    - Multiple plot tabs, each holding one or more series\n"
            "    - Mosaic View: all plots tiled in a grid (column count\n"
            "      auto-adapts to the window width)\n"
            "    - pyqtgraph canvas: pan / zoom, range auto-fit, fast\n"
            "      incremental redraws\n"
            "    - Configurable UI flush interval (20..5000 ms)\n"
            "\n"
            "  Data sources\n"
            "    - OPC UA subscriptions (push) or periodic polling\n"
            "    - Monitor mode and polling interval driven by the session\n"
            "      or by the controlling UA Explorer process\n"
            "    - Reconnects automatically if the worker is restarted\n"
            "\n"
            "  Sessions\n"
            "    - Save / Load full session: URI, theme, view mode, flush\n"
            "      interval, monitor mode, plots, series assignments\n"
            "    - Session JSON is compatible with UA Explorer sessions —\n"
            "      a session authored in UA Explorer opens directly here\n"
            "    - Standalone load is fully autonomous (no UA Explorer)\n"
            "\n"
            "  IPC (when launched from UA Explorer)\n"
            "    - QLocalSocket channel; commands: ping, quit, set_theme,\n"
            "      create_plot, plot_variable, list_plots, get_plot_details\n"
            "    - Theme follows UA Explorer live\n"
            "\n"
            "  Appearance\n"
            "    - Three themes: Light / Dark / Atacama, switchable from\n"
            "      Preferences; persisted per user\n"
            "    - Plot canvas uses the same surface color as UA Explorer's\n"
            "      Node Browser / Subscriptions for a unified look\n"
            "\n"
            "Storage\n"
            "\n"
            f"{'Working/home directory:':<{LBL}}$HOME/.uatools/UaPlot\n"
            f"{'Settings file:':<{LBL}}$HOME/.uatools/UaPlot/settings.json\n"
            f"{'Sessions:':<{LBL}}$HOME/.uatools/UaPlot/sessions/\n"
            "\n"
            "Runtime\n"
            "\n"
            f"{'Server URI:':<{LBL}}{self.uri or '(none)'}\n"
            f"{'IPC channel:':<{LBL}}{self.channel or '(none)'}\n"
            f"{'Active theme:':<{LBL}}{self.theme.name}\n"
            "\n"
            "Keyboard\n"
            "\n"
            f"{'Restart panel:':<{LBL}}Ctrl+Shift+R\n"
            "\n"
            "Version\n"
            "\n"
            f"{'Source file:':<{LBL}}{Path(__file__).resolve()}"
        )

        dlg = QDialog(self)
        dlg.setWindowTitle("About UA Plot")
        dlg.setSizeGripEnabled(True)
        layout = QVBoxLayout(dlg)

        logo_widget = None
        logo_path = find_file(
            "image/uatools/EsoOpcUaToolsLogo.png",
            parent=Path(__file__).resolve().parent.parent / "resource",
        )
        if logo_path is not None:
            pixmap = QPixmap(str(logo_path))
            if not pixmap.isNull():
                logo_widget = _ScalingLogo(pixmap)
                layout.addWidget(logo_widget)
            else:
                _log(f"About dialog: QPixmap failed to load {logo_path}")

        text = QTextEdit()
        text.setReadOnly(True)
        # Monospace so the column-aligned Storage / Runtime / Version
        # rows actually line up.
        mono_font = QFont("Monospace")
        mono_font.setStyleHint(QFont.StyleHint.TypeWriter)
        text.setFont(mono_font)
        text.setPlainText(summary)
        text.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse
            | Qt.TextInteractionFlag.TextSelectableByKeyboard
        )
        # Horizontal minimum keeps the column-aligned Storage / Runtime
        # blocks from wrapping. Vertical minimum is set MODEST (200 px)
        # so the dialog still shows a sensible amount of text on short
        # screens without forcing the layout minimum above the screen
        # height. The earlier 380 px (copied from UaExplorer) was the
        # cause of "dialog goes off-screen" on shorter displays.
        text.setMinimumSize(480, 200)
        # Stretch=1 so any extra dialog height goes to the text widget,
        # not to the logo above it.
        layout.addWidget(text, 1)

        # Same default size as UaExplorer's About dialog. The two clamps
        # below cap it to the available screen so the dialog can't open
        # below the screen edge on short displays — UaExplorer mostly
        # gets away without this because its window is taller and the
        # dialog opens near the top of the screen anyway.
        dlg.resize(560, 760)

        bbox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
        bbox.accepted.connect(dlg.accept)
        layout.addWidget(bbox)

        # Clamp the final size to the available screen geometry BEFORE
        # exec(), then center the dialog on the parent window if any
        # part of it would land off-screen. This is the only divergence
        # from UaExplorer's About implementation — needed because UaPlot
        # windows are often shorter and the centered-on-parent placement
        # then puts the bottom of a 760 px dialog below the screen edge.
        screen = self.screen() if hasattr(self, "screen") else None
        if screen is not None:
            avail = screen.availableGeometry()
            max_h = max(280, avail.height() - 60)
            max_w = max(360, avail.width() - 60)
            # If the screen is shorter than the default 760, cap the
            # logo so it doesn't push the layout's height minimum above
            # the screen. Picking ~25% of the height budget keeps the
            # logo legible without dominating.
            if logo_widget is not None and max_h < 760:
                logo_widget.setMaximumHeight(max(60, int(max_h * 0.25)))
            cur = dlg.size()
            clamp_w = min(cur.width(), max_w)
            clamp_h = min(cur.height(), max_h)
            if clamp_w != cur.width() or clamp_h != cur.height():
                dlg.resize(clamp_w, clamp_h)
            # Re-center on the parent, then nudge inside the screen rect.
            parent_geo = self.frameGeometry()
            x = parent_geo.center().x() - clamp_w // 2
            y = parent_geo.center().y() - clamp_h // 2
            x = max(avail.left(), min(x, avail.right() - clamp_w))
            y = max(avail.top(), min(y, avail.bottom() - clamp_h))
            dlg.move(x, y)

        dlg.exec()

    def _show_preferences(self):
        """Preferences dialog.

        Currently exposes the theme picker and the legend font size.
        The dialog mirrors UaExplorer's grouped layout so the two apps
        feel consistent when both are open.
        """
        dlg = QDialog(self)
        dlg.setWindowTitle("Preferences")
        dlg.setMinimumWidth(360)
        outer = QVBoxLayout()
        outer.setSpacing(8)

        app_group = QGroupBox("Appearance")
        app_form = QFormLayout()
        theme_combo = QComboBox()
        theme_combo.addItems(list(THEMES.keys()))
        theme_combo.setCurrentText(self.theme.name)
        app_form.addRow("Theme:", theme_combo)
        legend_spin = QSpinBox()
        legend_spin.setRange(6, 32)
        legend_spin.setSuffix(" px")
        legend_spin.setValue(self.legend_font_size_px)
        legend_spin.setToolTip(
            "Pixel size of the per-plot series-name legend.\n"
            "Applied live to every plot."
        )
        app_form.addRow("Legend font size:", legend_spin)
        title_spin = QSpinBox()
        title_spin.setRange(6, 32)
        title_spin.setSuffix(" px")
        title_spin.setValue(self.plot_title_font_size_px)
        title_spin.setToolTip(
            "Pixel size of the plot-title label shown above each plot in\n"
            "mosaic view. Tabs view shows the title in the tab bar, which\n"
            "is sized by the desktop style and not affected by this."
        )
        app_form.addRow("Plot title font size:", title_spin)
        app_group.setLayout(app_form)
        outer.addWidget(app_group)

        perf_group = QGroupBox("Performance")
        perf_form = QFormLayout()
        flush_spin = QSpinBox()
        flush_spin.setRange(20, 5000)
        flush_spin.setSingleStep(20)
        flush_spin.setSuffix(" ms")
        flush_spin.setValue(self.flush_interval_ms)
        flush_spin.setToolTip(
            "How often each plot redraws to show new samples. Lower values\n"
            "give smoother live updates but cost CPU when many plots are\n"
            "active; higher values are cheaper but lag visibly. The default\n"
            "of 150 ms suits most cases."
        )
        perf_form.addRow("Plot refresh interval:", flush_spin)
        history_spin = QSpinBox()
        history_spin.setRange(100, 50000)
        history_spin.setSingleStep(100)
        history_spin.setValue(self.history_samples)
        history_spin.setToolTip(
            "Number of samples each plot keeps in its rolling buffer.\n"
            "Older samples are dropped as new ones arrive. Statistics\n"
            "below are computed over this same buffer. Bigger = longer\n"
            "history & more memory; smaller = snappier zoom/pan."
        )
        perf_form.addRow("Plot history (samples):", history_spin)
        perf_group.setLayout(perf_form)
        outer.addWidget(perf_group)

        # ---- Statistics ----
        stats_group = QGroupBox("Statistics")
        stats_form = QFormLayout()
        stats_enabled_check = QCheckBox("Show statistics below each plot")
        stats_enabled_check.setChecked(bool(self.statistics.get("enabled")))
        stats_form.addRow("", stats_enabled_check)
        stats_last_check = QCheckBox("Last")
        stats_last_check.setChecked(bool(self.statistics.get("show_last", True)))
        stats_min_check = QCheckBox("Min")
        stats_min_check.setChecked(bool(self.statistics.get("show_min", True)))
        stats_max_check = QCheckBox("Max")
        stats_max_check.setChecked(bool(self.statistics.get("show_max", True)))
        stats_mean_check = QCheckBox("Mean")
        stats_mean_check.setChecked(bool(self.statistics.get("show_mean", True)))
        # Group the per-stat toggles horizontally so the dialog stays
        # compact — they're logically related so a single visual row
        # reads more cleanly than four form rows.
        per_stat_row = QHBoxLayout()
        per_stat_row.setSpacing(12)
        per_stat_row.addWidget(stats_last_check)
        per_stat_row.addWidget(stats_min_check)
        per_stat_row.addWidget(stats_max_check)
        per_stat_row.addWidget(stats_mean_check)
        per_stat_row.addStretch(1)
        per_stat_container = QWidget()
        per_stat_container.setLayout(per_stat_row)
        stats_form.addRow("Show:", per_stat_container)
        stats_group.setLayout(stats_form)
        outer.addWidget(stats_group)

        # ---- Recording (editable mirror of the inline recording row) ----
        # Same pattern as UaExplorer's Preferences/Recording group: the
        # toolbar row remains the canonical state, the dialog seeds from
        # it and writes back on Apply. Both surfaces stay in sync via
        # _sync_recording_row_from_settings.
        rec_group = QGroupBox("Recording")
        rec_form = QFormLayout()
        # Free-text inputs (not spinboxes) for the same reason as the toolbar:
        # nobody steps 0.1..999.9 Hz / 1..9999 by clicking arrows.
        rec_rate_input = QLineEdit(self._fmt_rate(self.recording_settings.get("rate_hz") or 1.0))
        rec_rate_input.setMaxLength(5)
        _pref_rate_validator = QDoubleValidator(0.1, 999.9, 1, rec_rate_input)
        _pref_rate_validator.setNotation(QDoubleValidator.Notation.StandardNotation)
        rec_rate_input.setValidator(_pref_rate_validator)
        rec_form.addRow("Rate (Hz):", rec_rate_input)
        rec_mode_combo = QComboBox()
        rec_mode_combo.addItems(["Until stopped", "Period of time", "Samples"])
        rec_mode_combo.setCurrentText(
            str(self.recording_settings.get("mode") or "Until stopped")
        )
        rec_form.addRow("Mode:", rec_mode_combo)
        rec_limit_input = QLineEdit(str(int(self.recording_settings.get("limit") or 60)))
        rec_limit_input.setMaxLength(4)
        rec_limit_input.setValidator(QIntValidator(1, 9999, rec_limit_input))
        rec_form.addRow("Limit:", rec_limit_input)
        rec_filename_edit = QLineEdit(
            str(self.recording_settings.get("filename") or "")
        )
        rec_filename_edit.setPlaceholderText("auto-timestamped")
        rec_form.addRow("File:", rec_filename_edit)
        rec_dir_edit = QLineEdit(
            str(self.recording_settings.get("directory") or "")
        )
        rec_dir_edit.setToolTip(
            "Directory under which recordings are written.\n"
            "Default: $HOME/.uatools/UaPlot/recordings/"
        )
        rec_form.addRow("Directory:", rec_dir_edit)
        rec_overwrite_check = QCheckBox(
            "Overwrite (use exact filename, no timestamp)"
        )
        rec_overwrite_check.setChecked(
            bool(self.recording_settings.get("overwrite"))
        )
        rec_form.addRow("", rec_overwrite_check)
        rec_visible_check = QCheckBox("Show recording controls in main window")
        rec_visible_check.setChecked(bool(self.recording_settings.get("visible")))
        rec_form.addRow("", rec_visible_check)
        rec_group.setLayout(rec_form)
        outer.addWidget(rec_group)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok |
            QDialogButtonBox.StandardButton.Cancel |
            QDialogButtonBox.StandardButton.Apply
        )
        outer.addWidget(buttons)
        dlg.setLayout(outer)

        def _apply():
            new_theme = THEMES.get(theme_combo.currentText())
            if new_theme is not None and new_theme is not self.theme:
                self.set_theme(new_theme)
            new_legend_px = int(legend_spin.value())
            new_title_px = int(title_spin.value())
            new_flush_ms = int(flush_spin.value())
            new_history = int(history_spin.value())
            new_stats = {
                "enabled": bool(stats_enabled_check.isChecked()),
                "show_last": bool(stats_last_check.isChecked()),
                "show_min": bool(stats_min_check.isChecked()),
                "show_max": bool(stats_max_check.isChecked()),
                "show_mean": bool(stats_mean_check.isChecked()),
            }
            changed_legend = new_legend_px != self.legend_font_size_px
            changed_title = new_title_px != self.plot_title_font_size_px
            changed_flush = new_flush_ms != self.flush_interval_ms
            changed_history = new_history != self.history_samples
            changed_stats = new_stats != self.statistics
            if changed_legend:
                self.set_legend_font_size(new_legend_px)
            if changed_title:
                self.set_plot_title_font_size(new_title_px)
            if changed_flush:
                self._on_flush_interval_changed(new_flush_ms)
            if changed_history:
                self.set_history_samples(new_history)
            if changed_stats:
                self.set_statistics(new_stats)
            # Recording group — write all fields back and re-sync the
            # inline row. _save_settings happens via the sync (which goes
            # through the standard widget setters); we also call it at
            # the end to capture theme / sizes regardless.
            # Parse + clamp the free-text Rate/Limit (the validator already
            # restricts typing, but a partial/empty field could slip through).
            try:
                pref_rate = min(999.9, max(0.1, float(rec_rate_input.text())))
            except ValueError:
                pref_rate = float(self.recording_settings.get("rate_hz") or 1.0)
            try:
                pref_limit = min(9999, max(1, int(rec_limit_input.text())))
            except ValueError:
                pref_limit = int(self.recording_settings.get("limit") or 60)
            self.recording_settings.update({
                "rate_hz": pref_rate,
                "mode": rec_mode_combo.currentText(),
                "limit": pref_limit,
                "filename": rec_filename_edit.text().strip(),
                "directory": rec_dir_edit.text().strip()
                    or str(Path.home()),
                "overwrite": bool(rec_overwrite_check.isChecked()),
                "visible": bool(rec_visible_check.isChecked()),
            })
            self._sync_recording_row_from_settings()
            # Always persist — the recording fields may have changed even
            # if none of the size/theme/flush checks above tripped.
            self._save_settings()

        buttons.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(_apply)
        buttons.accepted.connect(lambda: (_apply(), dlg.accept()))
        buttons.rejected.connect(dlg.reject)
        dlg.exec()

    # ---- Session save/load ------------------------------------------------
    #
    # File format is compatible with UaExplorer sessions: same JSON shape,
    # same keys for the fields UaPlot cares about (uri, monitor_mode,
    # polling_interval_ms, plots). This means a session authored in
    # UaExplorer loads correctly in standalone UaPlot — the UaExplorer-only
    # fields (subscriptions, view, etc.) are simply ignored.
    #
    # When run standalone, loading a session does NOT involve UaExplorer at
    # any point. UaPlot owns its own asyncua client (the UaPlotWorker) and
    # is fully self-sufficient.

    def _build_session_payload(self) -> dict:
        """Snapshot current state into a JSON-serializable session dict.

        Format mirrors UaExplorer's session JSON so the two apps can read
        each other's session files. The ``plots`` array uses the same flat
        "one row per (node, plot) assignment" shape as UaExplorer.
        """
        monitor_mode = self.worker.monitor_mode if self.worker else "subscription"
        polling_interval_ms = self.worker.polling_interval_ms if self.worker else 500

        plots_data = []
        for plot_id in self.plot_order:
            plot = self.plots.get(plot_id)
            if not plot:
                continue
            for node_id in plot.series_order:
                series_data = plot.series.get(node_id, {})
                plots_data.append({
                    "node_id": node_id,
                    "display_name": series_data.get("label", node_id),
                    "plot_id": plot_id,
                    "plot_title": plot.title,
                    "monitor_mode": monitor_mode,
                    "polling_interval_ms": polling_interval_ms,
                })

        return {
            "version": "1.1",
            "timestamp": datetime.now().isoformat(),
            "source": "UaPlot",
            "uri": self.uri or "",
            "theme": self.theme.name,
            "view_mode": "mosaic" if self.mosaic_view_enabled else "tabs",
            "flush_interval_ms": self.flush_interval_ms,
            "history_samples": self.history_samples,
            "statistics": dict(self.statistics),
            "legend_font_size_px": self.legend_font_size_px,
            "plot_title_font_size_px": self.plot_title_font_size_px,
            "recording_settings": dict(self.recording_settings),
            "monitor_mode": monitor_mode,
            "polling_interval_ms": polling_interval_ms,
            "plots": plots_data,
        }

    def _save_session_dialog(self):
        """Show dialog to save current session to a JSON file.

        Uses an explicit non-native QFileDialog at 720x480 to dodge the
        KDE-remembered-width issue (same approach as UaExplorer)."""
        self.sessions_dir.mkdir(parents=True, exist_ok=True)
        default_name = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        start_path = str(self.sessions_dir / default_name)
        dialog = QFileDialog(self, "Save Session", start_path)
        dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
        dialog.setNameFilters(["Session Files (*.json)", "All Files (*)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        filepath = files[0]
        if not filepath.lower().endswith(".json"):
            filepath += ".json"
        try:
            payload = self._build_session_payload()
            tmp = Path(filepath).with_suffix(".tmp")
            tmp.write_text(json.dumps(payload, indent=2))
            tmp.replace(filepath)
            self._last_session_path = filepath
            self._session_loaded = True
            self._refresh_session_display()
            self._save_settings()
        except Exception as exc:
            QMessageBox.critical(self, "Save Session", f"Failed to save session:\n{exc}")

    def _load_session_dialog(self):
        """Show dialog to load a session JSON file. Standalone-only — a
        parent-driven UaPlot must not replace the launcher's plot set."""
        if not self.standalone:
            return
        self.sessions_dir.mkdir(parents=True, exist_ok=True)
        dialog = QFileDialog(self, "Load Session", str(self.sessions_dir))
        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        dialog.setNameFilters(["Session Files (*.json)", "All Files (*)"])
        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
        dialog.resize(720, 480)
        if dialog.exec() != QDialog.DialogCode.Accepted:
            return
        files = dialog.selectedFiles()
        if not files:
            return
        try:
            self._load_session_from_file(files[0])
        except Exception as exc:
            QMessageBox.critical(self, "Load Session", f"Failed to load session:\n{exc}")

    def _clear_all_plots(self):
        """Remove every plot from the UI and clear assignments.

        Called from the load path before restoring plots from a session.
        Worker stays alive (we'll restart it with the session's URI and
        monitor mode); just the plot widgets and assignments are reset.
        """
        # Remove tabs / mosaic items
        if hasattr(self, "tabs"):
            while self.tabs.count() > 0:
                self.tabs.removeTab(0)
        if hasattr(self, "mosaic_grid"):
            while self.mosaic_grid.count() > 0:
                item = self.mosaic_grid.takeAt(0)
                w = item.widget() if item else None
                if w is not None:
                    w.setParent(None)
        self.plots.clear()
        self.plot_order.clear()
        self.plot_assignments.clear()
        self.mosaic_items.clear()
        self.plot_counter = 1

    def _load_session_from_file(self, filepath: str):
        """Restore UaPlot state from a session JSON file.

        Tolerant of sessions authored by UaExplorer (extra keys ignored) and
        of partially-broken sessions (each plot is restored independently;
        failures are logged but don't abort the whole load).

        Wraps the rebuild in setUpdatesEnabled(False) so the user doesn't
        see every plot pop in individually — without this, a 9-plot
        session on a freshly restarted UaPlot flashes for ~1 s as the
        layout reflows after each plot/series addition.
        """
        with open(filepath, "r") as f:
            session = json.load(f)

        # An active recording wouldn't make sense once the plots are
        # replaced — its captured series set would no longer match the
        # data being plotted. Stop cleanly first.
        self._stop_recording_if_running(reason="Session loaded")

        self.setUpdatesEnabled(False)
        self._loading_session = True
        try:
            self._load_session_body(filepath, session)
        finally:
            self._loading_session = False
            # One final layout pass before re-enabling paints, so the
            # user only sees the finished view. Done SYNCHRONOUSLY (not
            # via QTimer.singleShot) so the layout is built before
            # main() calls window.show() — otherwise the deferred timer
            # fires after the window is mapped and the user sees plots
            # popping in one by one.
            if self.mosaic_view_enabled:
                self._apply_view_mode()
                self._mosaic_refresh_pending = False
                self._rebuild_mosaic_layout()
            elif self.tabs.count() > 0:
                self.tabs.setCurrentIndex(0)
            self.setUpdatesEnabled(True)

    def _load_session_body(self, filepath: str, session: dict):
        """Inner load implementation — kept separate from the updates-
        disabled wrapper above so the cleanup is guaranteed even if a
        nested call raises."""

        uri = str(session.get("uri") or "").strip()
        monitor_mode = str(session.get("monitor_mode") or "subscription")
        if monitor_mode not in ("subscription", "polling"):
            monitor_mode = "subscription"
        try:
            polling_interval_ms = int(session.get("polling_interval_ms") or 500)
        except (TypeError, ValueError):
            polling_interval_ms = 500
        theme_name = session.get("theme")
        view_mode = session.get("view_mode")
        try:
            flush_ms = int(session.get("flush_interval_ms") or self.flush_interval_ms)
        except (TypeError, ValueError):
            flush_ms = self.flush_interval_ms
        try:
            legend_px = max(6, int(
                session.get("legend_font_size_px") or self.legend_font_size_px
            ))
        except (TypeError, ValueError):
            legend_px = self.legend_font_size_px
        try:
            title_px = max(6, int(
                session.get("plot_title_font_size_px") or self.plot_title_font_size_px
            ))
        except (TypeError, ValueError):
            title_px = self.plot_title_font_size_px
        try:
            history_n = max(100, int(
                session.get("history_samples") or self.history_samples
            ))
        except (TypeError, ValueError):
            history_n = self.history_samples
        stats_session = session.get("statistics")
        plots = session.get("plots") or []

        # Apply theme first so the rebuilt plots adopt it immediately.
        if isinstance(theme_name, str) and theme_name in THEMES:
            self.set_theme(THEMES[theme_name])

        # Flush interval — propagate to existing tabs (cleared below).
        # The Preferences dialog reads self.flush_interval_ms when opened,
        # so there's no widget to keep in sync here anymore.
        self.flush_interval_ms = flush_ms

        # Legend font size — picked up by new plots created below.
        self.legend_font_size_px = legend_px
        # Plot title font size — applied to mosaic title labels as they
        # are (re)created when _add_plot_to_mosaic runs after the rebuild.
        self.plot_title_font_size_px = title_px

        # History buffer size — applies to new plots created below.
        self.history_samples = history_n
        # Statistics config — push into self.statistics so new plots
        # adopt it. Existing plots are cleared below anyway.
        if isinstance(stats_session, dict):
            for k in ("enabled", "show_last", "show_min", "show_max", "show_mean"):
                if k in stats_session:
                    self.statistics[k] = bool(stats_session[k])

        # Recording settings — push the saved values back into both the
        # in-memory dict and the row widgets so the UI reflects the
        # session config (visibility, rate, mode, filename, etc.).
        rec = session.get("recording_settings")
        if isinstance(rec, dict):
            for k in (
                "visible", "rate_hz", "mode", "limit",
                "filename", "directory", "overwrite",
            ):
                if k in rec:
                    self.recording_settings[k] = rec[k]
            self._sync_recording_row_from_settings()

        # View mode (tabs vs mosaic)
        if view_mode in ("tabs", "mosaic"):
            self.mosaic_view_enabled = (view_mode == "mosaic")
            if hasattr(self, "mosaic_action"):
                self.mosaic_action.blockSignals(True)
                self.mosaic_action.setChecked(self.mosaic_view_enabled)
                self.mosaic_action.blockSignals(False)
            self._apply_view_mode()

        # Wipe current plots before restoring.
        self._clear_all_plots()

        # Set URI and (re)start worker with the session's monitor mode. The
        # worker connects autonomously via its own asyncua client — no
        # contact with UaExplorer at any point.
        if uri:
            self.uri = uri
            if hasattr(self, "uri_label"):
                self.uri_label.setText(uri)
            self._refresh_server_name_display()
        self._start_worker(monitor_mode, polling_interval_ms)

        # Restore plots. The flat "one row per (node, plot)" layout means
        # we group rows by plot_id, create the plot once, then add each
        # node to it.
        plots_by_id: Dict[str, Dict[str, object]] = {}
        plot_order: List[str] = []
        for row in plots:
            pid = str(row.get("plot_id") or "")
            if not pid:
                continue
            if pid not in plots_by_id:
                plots_by_id[pid] = {
                    "title": str(row.get("plot_title") or ""),
                    "nodes": [],
                }
                plot_order.append(pid)
            node_id = str(row.get("node_id") or "")
            if not node_id:
                continue
            plots_by_id[pid]["nodes"].append({
                "node_id": node_id,
                "display_name": str(row.get("display_name") or node_id),
            })

        for pid in plot_order:
            entry = plots_by_id[pid]
            try:
                info = self._create_plot(str(entry["title"]) or None)
            except Exception:
                continue
            new_plot_id = info.plot_id
            plot = self.plots.get(new_plot_id)
            if not plot:
                continue
            for node_entry in entry["nodes"]:
                node_norm = self._normalize_node_id(node_entry["node_id"])
                if not node_norm:
                    continue
                plot.add_series(node_norm, node_entry["display_name"])
                self.plot_assignments.setdefault(new_plot_id, set()).add(node_norm)
                if self.worker:
                    self.worker.add_node(node_norm)

        # Mosaic refresh is scheduled by the wrapper once updates are
        # re-enabled — doing it here too would cause a double reflow.

        # Remember this session so Restart (and the next standalone launch
        # without --uri) re-opens it automatically.
        self._last_session_path = filepath
        self._session_loaded = True
        self._refresh_session_display()
        # Persist top-level settings (theme, flush, view mode, last_session).
        self._save_settings()

    def _create_plot(self, title: Optional[str] = None) -> PlotInfo:
        if not title:
            title = f"Plot-{self.plot_counter}"
        self.plot_counter += 1
        plot_id = f"plot-{len(self.plots) + 1}"
        tab = PlotTab(
            title,
            self.flush_interval_ms,
            self.legend_font_size_px,
            history_samples=self.history_samples,
            stats_config=self.statistics,
        )
        self.plots[plot_id] = tab
        self.plot_order.append(plot_id)
        # During session load, suppress per-plot mosaic reflows and tab
        # switches — the wrapper in _load_session_from_file does one
        # final layout pass after every plot exists, which prevents the
        # "many small panels popping up" flicker on restart.
        loading = getattr(self, "_loading_session", False)
        if self.mosaic_view_enabled:
            if not loading:
                self._apply_view_mode()
                self._schedule_mosaic_refresh()
        else:
            self.tabs.addTab(tab, title)
            if not loading:
                self.tabs.setCurrentWidget(tab)
        return PlotInfo(plot_id=plot_id, title=title)

    def _create_plot_from_button(self):
        # Just create the plot; no pop-up needed
        self._create_plot()

    def _normalize_node_id(self, node_id: str) -> str:
        """Best-effort normalization so subscription/poll identifiers match."""
        if not node_id:
            return ""
        text = str(node_id)
        if text.startswith("ns="):
            return text
        # Parse strings like "NodeId(Identifier='X', NamespaceIndex=2, ...)"
        if "Identifier=" in text and "NamespaceIndex=" in text:
            try:
                ident = text.split("Identifier=")[1].split(",")[0].strip().strip("'\"")
                ns_part = text.split("NamespaceIndex=")[1].split(",")[0].strip()
                if ident and ns_part:
                    return f"ns={ns_part};s={ident}"
            except Exception:
                pass
        return text

    # ----- Add / Remove value (curve) on a plot -----------------------------

    def _ensure_connected_for_browse(self) -> bool:
        """Make sure there is a server URI to browse. In standalone mode
        (no URI yet) prompt for one and start connecting. Returns False if
        the user cancels. The actual browse is deferred until the worker
        signals ``connected`` (see _add_value_to_plot / _on_worker_connected)."""
        if self.uri:
            return True
        if not self.standalone:
            # Parent-driven: the launcher owns the connection; don't prompt.
            QMessageBox.information(
                self, "Add Value",
                "This UaPlot is driven by its launcher; connect from there.")
            return False
        uri, ok = QInputDialog.getText(
            self, "Connect to server",
            "OPC UA endpoint to browse for values:",
            text="opc.tcp://",
        )
        if not ok or not uri.strip():
            return False
        # _set_uri builds + starts a fresh worker (signals are wired in
        # _start_worker). Connection proceeds asynchronously.
        self._set_uri(uri.strip())
        return True

    def _browse_cache_valid(self) -> bool:
        """True if we hold a node list for the current URI already."""
        return (
            self._browse_cache is not None
            and self._browse_cache_uri == self.uri
            and bool(self.uri)
        )

    def _start_background_browse(self):
        """Kick off a browse to fill the cache, with no UI. Called on connect
        so the address space is ready before the user ever opens Add Value."""
        if not self.uri or self._browse_in_flight:
            return
        if self._browse_cache_valid():
            return  # already have it for this URI
        if self._worker_connected and self.worker:
            self._browse_in_flight = True
            self.worker.request_browse()

    def _invalidate_browse_cache(self):
        self._browse_cache = None
        self._browse_cache_uri = None

    def _add_value_to_plot(self, plot_id: str):
        """Let the user pick variable nodes to add as curves on ``plot_id``.

        Uses the cached namespace browse when available (instant) — the scan
        is done once per server, in the background on connect — and only
        triggers a fresh browse if the cache isn't ready yet."""
        if plot_id not in self.plots:
            return
        if self._pending_add_plot_id is not None:
            return  # a browse is already in flight for a dialog
        if not self._ensure_connected_for_browse():
            return

        # Fast path: cache already holds this server's nodes -> open at once.
        if self._browse_cache_valid():
            self._open_add_value_dialog(plot_id, self._browse_cache)
            return

        # Slow path: need a browse first (first Add Value before the
        # background scan finished, or standalone just-connected). Show the
        # busy indicator and wait for the result / connection.
        self._pending_add_plot_id = plot_id
        self._browse_progress = QProgressDialog(
            "Browsing server address space…", "Cancel", 0, 0, self
        )
        self._browse_progress.setWindowTitle("Add Value")
        self._browse_progress.setMinimumDuration(0)
        self._browse_progress.canceled.connect(self._cancel_pending_browse)
        self._browse_progress.show()
        QTimer.singleShot(10000, self._browse_timeout)
        if self._worker_connected and self.worker and not self._browse_in_flight:
            self._browse_in_flight = True
            self.worker.request_browse()
        # else: a browse is already running, or we wait for _on_worker_connected.

    def _open_add_value_dialog(self, plot_id: str, nodes):
        plot = self.plots.get(plot_id)
        if plot is None:
            return
        existing = self.plot_assignments.get(plot_id, set())
        dlg = _AddValueDialog(list(nodes or []), existing, self)
        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        for node_id, display_name in dlg.selected_nodes():
            node_norm = self._normalize_node_id(node_id)
            plot.add_series(node_norm, display_name)
            self.plot_assignments.setdefault(plot_id, set()).add(node_norm)
            if self.worker:
                self.worker.add_node(node_norm)

    def _cancel_pending_browse(self):
        self._pending_add_plot_id = None
        self._close_browse_progress()

    def _browse_timeout(self):
        if self._pending_add_plot_id is None:
            return  # already resolved
        self._cancel_pending_browse()
        QMessageBox.warning(
            self, "Add Value",
            f"Could not browse the server (no response from {self.uri or 'server'}).",
        )

    def _close_browse_progress(self):
        prog = getattr(self, "_browse_progress", None)
        if prog is not None:
            prog.close()
            self._browse_progress = None

    def _on_worker_connected(self, uri: str):
        self._worker_connected = True
        # Pre-fetch the namespace in the background so Add Value is instant.
        self._start_background_browse()
        # A user-initiated browse may have been queued while connecting.
        if self._pending_add_plot_id is not None and self.worker \
                and not self._browse_in_flight:
            self._browse_in_flight = True
            self.worker.request_browse()

    def _on_browse_result(self, nodes):
        # Always refresh the cache — this result serves both the background
        # pre-fetch and any user-initiated Add Value.
        node_list = list(nodes or [])
        self._browse_cache = node_list
        self._browse_cache_uri = self.uri
        self._browse_in_flight = False
        plot_id = self._pending_add_plot_id
        self._pending_add_plot_id = None
        self._close_browse_progress()
        if plot_id:
            self._open_add_value_dialog(plot_id, node_list)

    def _on_browse_error(self, message: str):
        self._browse_in_flight = False
        had_pending = self._pending_add_plot_id is not None
        self._pending_add_plot_id = None
        self._close_browse_progress()
        # Only nag the user if THEY asked for a browse; a failed background
        # pre-fetch stays silent (Add Value will retry on demand).
        if had_pending:
            QMessageBox.warning(self, "Add Value", message or "Browse failed.")

    def _remove_value_from_plot(self, plot_id: str):
        """List the plot's current curves and remove the selected ones."""
        plot = self.plots.get(plot_id)
        if plot is None or not plot.series_order:
            return
        curves = [
            (nid, str(plot.series.get(nid, {}).get("label") or nid))
            for nid in plot.series_order
        ]
        dlg = _RemoveValueDialog(curves, self)
        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        for node_id in dlg.selected_nodes():
            node_norm = self._normalize_node_id(node_id)
            plot.remove_series(node_norm)
            assigned = self.plot_assignments.get(plot_id)
            if assigned is not None:
                assigned.discard(node_norm)
            # Only stop watching the node if no OTHER plot still shows it —
            # otherwise we'd kill the subscription feeding that other plot.
            still_used = any(
                node_norm in nodes for nodes in self.plot_assignments.values()
            )
            if not still_used and self.worker:
                self.worker.remove_node(node_norm)

    def _rename_plot_by_id(self, plot_id: str):
        """Rename a plot and update all views (tabs + mosaic)."""
        current = self.plots.get(plot_id)
        if not current:
            return
        text, ok = QInputDialog.getText(self, "Rename plot", "Title:", text=current.title)
        if ok and text:
            current.title = text
            tab_index = self.tabs.indexOf(current)
            if tab_index != -1:
                self.tabs.setTabText(tab_index, text)
            item = self.mosaic_items.get(plot_id)
            if item and item.get("label"):
                item["label"].setText(text)

    def _rename_tab(self, index: int):
        if index < 0 or index >= len(self.plot_order):
            return
        plot_id = self.plot_order[index]
        self._rename_plot_by_id(plot_id)

    def _on_tab_context_menu(self, pos):
        """Right-click on a tab bar tab: Rename / Remove."""
        tab_bar = self.tabs.tabBar()
        index = tab_bar.tabAt(pos)
        if index < 0 or index >= len(self.plot_order):
            return
        plot_id = self.plot_order[index]
        menu = QMenu(self)
        rename_action = menu.addAction("Rename Plot…")
        remove_action = menu.addAction("Remove Plot")
        menu.addSeparator()
        add_value_action = menu.addAction("Add Value…")
        remove_value_action = menu.addAction("Remove Value…")
        chosen = menu.exec(tab_bar.mapToGlobal(pos))
        if chosen is rename_action:
            self._rename_plot_by_id(plot_id)
        elif chosen is remove_action:
            self._remove_plot_by_id(plot_id)
        elif chosen is add_value_action:
            self._add_value_to_plot(plot_id)
        elif chosen is remove_value_action:
            self._remove_value_from_plot(plot_id)

    def _remove_plot_by_id(self, plot_id: str):
        """Drop a plot from both views and from all internal bookkeeping.

        Asks the user to confirm — removal is irreversible within the
        current session (the plot would have to be reauthored from
        UaExplorer or loaded from a Save Session that pre-dated the
        removal). The next Save Session reflects the remaining plots
        only; the removed plot is forgotten.

        The worker is left alone: any node IDs that were exclusive to
        this plot will continue arriving as data updates but find no
        consumer in _on_data_update and are silently dropped. We could
        unsubscribe to save a few CPU cycles, but the worker has no
        per-plot subscription tracking today and the cost is negligible.
        """
        tab = self.plots.get(plot_id)
        if tab is None:
            return

        confirm = QMessageBox.question(
            self,
            "Remove Plot",
            f"Remove the plot \"{tab.title}\"?\n\n"
            "All series on this plot will be removed from the view.\n"
            "Subsequent Save Session will not include this plot.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        if confirm != QMessageBox.StandardButton.Yes:
            return

        self._remove_plot_silent(plot_id)

    def _remove_plot_silent(self, plot_id: str) -> bool:
        """Tear down a plot WITHOUT a confirmation dialog.

        Shared by the interactive _remove_plot_by_id (after it confirms) and by
        the 'remove_plot' IPC command (driven by UaShell's 'plot rm', where the
        user already confirmed by typing the command). Returns True if removed.
        """
        tab = self.plots.get(plot_id)
        if tab is None:
            return False

        # Tabs view: remove from the tab strip.
        tab_index = self.tabs.indexOf(tab)
        if tab_index != -1:
            self.tabs.removeTab(tab_index)

        # Mosaic view: drop the container that wraps this PlotTab.
        mitem = self.mosaic_items.pop(plot_id, None)
        if mitem is not None:
            container = mitem.get("container")
            if container is not None:
                self.mosaic_grid.removeWidget(container)
                container.setParent(None)
                container.deleteLater()

        # Detach the PlotTab itself so Qt frees it on the next event loop.
        tab.setParent(None)
        tab.deleteLater()

        # Internal bookkeeping.
        self.plots.pop(plot_id, None)
        try:
            self.plot_order.remove(plot_id)
        except ValueError:
            pass
        self.plot_assignments.pop(plot_id, None)

        # If a recording is active, its captured series set may have
        # included variables that only lived in the removed plot.
        # Stop cleanly so the file isn't left with half-empty rows.
        self._stop_recording_if_running(reason="Plot removed")

        # Refresh the mosaic so the remaining tiles repack.
        if self.mosaic_view_enabled:
            self._schedule_mosaic_refresh()
        return True

    def _on_worker_error(self, message: str):
        # Keep it quiet for transient errors; show in status bar
        _log(f"[_on_worker_error] {message}")
        self.statusBar().showMessage(message, 5000)

    def _on_data_update(self, node_id: str, value: object, timestamp: float):
        norm = self._normalize_node_id(node_id)
        targets = [pid for pid, nodes in self.plot_assignments.items() if norm in nodes or node_id in nodes]
        _log(f"[_on_data_update] node_id={node_id}, norm={norm}, targets={targets}, plot_assignments={dict(self.plot_assignments)}")
        for plot_id in targets:
            plot = self.plots.get(plot_id)
            if plot:
                plot.append_point(norm, value, timestamp)

    def _handle_command(self, payload: Dict[str, object]) -> Dict[str, object]:
        cmd = payload.get("command")
        if cmd == "ping":
            return {"type": "pong"}
        if cmd == "quit":
            # Schedule close after returning response
            QTimer.singleShot(100, self.close)
            return {"type": "ack", "message": "quitting"}
        if cmd == "remove_plot":
            # Remove a plot by id, no confirmation dialog (the controlling
            # client, e.g. UaShell 'plot rm', already confirmed via the command).
            plot_id = str(payload.get("plot_id") or "")
            # Guard the teardown: an exception mid-removal must return a clean
            # error reply (the command server would otherwise serialize the
            # raise), so the caller can retry rather than hang.
            try:
                removed = self._remove_plot_silent(plot_id) if plot_id else False
            except Exception as exc:
                import traceback
                _log(f"[remove_plot] _remove_plot_silent raised: {exc}\n"
                     f"{traceback.format_exc()}")
                return {"type": "error", "message": f"remove failed: {exc}"}
            if removed:
                return {"type": "ack", "plot_id": plot_id}
            return {"type": "error", "message": f"No such plot: {plot_id}"}
        if cmd == "remove_series":
            plot_id = str(payload.get("plot_id") or "")
            node_id = str(payload.get("node_id") or "")
            plot = self.plots.get(plot_id)
            if plot is None:
                return {"type": "error", "message": f"No such plot: {plot_id}"}
            node_norm = self._normalize_node_id(node_id)
            removed = plot.remove_series(node_norm) or plot.remove_series(node_id)
            if not removed:
                return {"type": "error",
                        "message": f"No such curve: {node_id}"}
            # Stop feeding this node to the plot. Leave the worker
            # subscription untouched — the node may still be plotted
            # elsewhere, and a stale feed simply finds no target plot.
            nodes = self.plot_assignments.get(plot_id)
            if nodes:
                nodes.discard(node_norm)
                nodes.discard(node_id)
            # If the plot is now empty, drop it entirely so the user
            # isn't left with a blank tile.
            if not plot.series_order:
                self._remove_plot_silent(plot_id)
            return {"type": "ack", "plot_id": plot_id, "node_id": node_id}
        if cmd == "set_theme":
            name = str(payload.get("name") or "")
            theme = resolve_theme(name)
            self.set_theme(theme)
            return {"type": "ack", "theme": theme.name}
        if cmd == "list_plots":
            return {
                "type": "plots",
                "plots": [{"id": pid, "title": self.plots[pid].title} for pid in self.plot_order],
            }
        if cmd == "get_plot_details":
            # Return details of all plots including their variables
            details = []
            for pid in self.plot_order:
                plot = self.plots.get(pid)
                if not plot:
                    continue
                variables = []
                for node_id in plot.series_order:
                    series_data = plot.series.get(node_id, {})
                    variables.append({
                        "node_id": node_id,
                        "display_name": series_data.get("label", node_id),
                    })
                details.append({
                    "id": pid,
                    "title": plot.title,
                    "variables": variables,
                })
            return {
                "type": "plot_details",
                "plots": details,
                "monitor_mode": self.worker.monitor_mode if self.worker else "subscription",
                "polling_interval_ms": self.worker.polling_interval_ms if self.worker else 500,
            }
        if cmd == "create_plot":
            title = str(payload.get("title") or "")
            info = self._create_plot(title if title else None)
            return {"type": "plot_created", "plot": {"id": info.plot_id, "title": info.title}}
        if cmd == "plot_variable":
            node_id = str(payload.get("node_id") or "")
            display_name = str(payload.get("display_name") or node_id)
            uri = str(payload.get("uri") or "")
            server_name = str(payload.get("server_name") or "").strip()
            monitor_mode = str(payload.get("monitor_mode") or "subscription")
            polling_interval_ms = int(payload.get("polling_interval_ms") or 500)
            plot_id = payload.get("plot_id")
            title = payload.get("title")
            # Adopt the name UaExplorer holds for this URI before switching to
            # it, so _set_uri's display refresh picks it up. CLI/IPC-supplied
            # name wins over any persisted one for this URI.
            if uri and server_name and self.server_names.get(uri, "") != server_name:
                self.server_names[uri] = server_name
                self._save_settings()
                # _set_uri only refreshes the Name field when the URI actually
                # changes; refresh here too so a name arriving for the current
                # URI is shown immediately.
                self._refresh_server_name_display()
            if uri:
                self._set_uri(uri)
            if not self.worker or self.worker.monitor_mode != monitor_mode or self.worker.polling_interval_ms != polling_interval_ms:
                self._restart_worker_with_nodes(monitor_mode, polling_interval_ms)
            else:
                self.worker.set_monitor_mode(monitor_mode, polling_interval_ms)
            if not plot_id:
                info = self._create_plot(title if title else None)
                plot_id = info.plot_id
            plot = self.plots.get(plot_id)
            if not plot:
                info = self._create_plot(title if title else None)
                plot_id = info.plot_id
                plot = self.plots[plot_id]
            node_norm = self._normalize_node_id(node_id)
            plot.add_series(node_norm, display_name)
            nodes = self.plot_assignments.setdefault(plot_id, set())
            nodes.add(node_norm)
            if self.worker:
                self.worker.add_node(node_norm)
            if payload.get("focus"):
                self._bring_to_front()
            return {"type": "ack", "plot_id": plot_id}
        return {"type": "error", "message": f"Unknown command: {cmd}"}

    def closeEvent(self, event):
        # Flush and close the recording file if one is active.
        self._stop_recording_if_running(reason="UA Plot closing")
        self._save_settings()
        if self.worker:
            self.worker.stop()
            self.worker.wait()
        return super().closeEvent(event)

    def restart_application(self):
        """Restart the UaPlot process via os.execl, preserving CLI args.

        Mirrors UaExplorer.restart_application: persists current settings,
        stops the worker cleanly, then re-execs the original startup
        script (typically ``$INTROOT/bin/UaPlot``) with the same argv so
        the new instance comes up with the same URI / channel / theme.
        """
        # Stop any active recording so the file is properly flushed and
        # closed before the process re-execs.
        self._stop_recording_if_running(reason="UA Plot restarting")
        try:
            self._save_settings()
        except Exception:
            pass

        if self.worker:
            try:
                self.worker.stop()
                self.worker.wait()
            except Exception:
                pass

        script_path = getattr(self, "_startup_script", None)
        if not script_path or not Path(script_path).exists():
            script_path = Path(__file__).resolve()

        # Mark this exec as a Restart so the new process knows to auto-load
        # the remembered session. Skip the flag if it's already present
        # (chained restarts) so we don't accumulate duplicates.
        extra_args = list(sys.argv[1:])
        if "--restart" not in extra_args:
            extra_args.append("--restart")

        python = sys.executable
        os.execl(python, python, str(script_path), *extra_args)

    def resizeEvent(self, event):
        """Reflow mosaic when the window size changes.

        Always reschedule a rebuild (debounced) so the per-tile minimum heights
        are recomputed against the new viewport - this keeps the grid fitting
        the visible canvas (no forced scrolling) even when the column count
        itself didn't change.
        """
        if self.mosaic_view_enabled:
            self._update_mosaic_columns()
            self._schedule_mosaic_refresh()
        return super().resizeEvent(event)

    def _load_settings(self):
        try:
            if self.settings_file.exists():
                data = json.loads(self.settings_file.read_text())
                self.mosaic_view_enabled = bool(data.get("mosaic_view", False))
                self.flush_interval_ms = int(data.get("flush_interval_ms", self.flush_interval_ms))
                try:
                    self.history_samples = max(100, int(
                        data.get("history_samples", self.history_samples)
                    ))
                except (TypeError, ValueError):
                    pass
                stats = data.get("statistics")
                if isinstance(stats, dict):
                    for k in ("enabled", "show_last", "show_min", "show_max", "show_mean"):
                        if k in stats:
                            self.statistics[k] = bool(stats[k])
                try:
                    self.legend_font_size_px = max(6, int(
                        data.get("legend_font_size_px", self.legend_font_size_px)
                    ))
                except (TypeError, ValueError):
                    pass
                try:
                    self.plot_title_font_size_px = max(6, int(
                        data.get("plot_title_font_size_px", self.plot_title_font_size_px)
                    ))
                except (TypeError, ValueError):
                    pass
                rec = data.get("recording_settings")
                if isinstance(rec, dict):
                    for k in (
                        "visible", "rate_hz", "mode", "limit",
                        "filename", "directory", "overwrite",
                    ):
                        if k in rec:
                            self.recording_settings[k] = rec[k]
                names = data.get("server_names")
                if isinstance(names, dict):
                    # Filter to string keys/values defensively in case the
                    # file was hand-edited.
                    self.server_names = {
                        str(k): str(v).strip()
                        for k, v in names.items()
                        if isinstance(k, str) and isinstance(v, str)
                        and str(v).strip()
                    }
                # Theme from settings is only used in the standalone case.
                # If the caller (UaExplorer launch / --theme CLI arg) gave
                # us an explicit theme, that wins — any later set_theme IPC
                # can still override at runtime.
                stored_theme = data.get("theme")
                if stored_theme and not self._theme_explicit:
                    resolved = THEMES.get(stored_theme)
                    if resolved is not None:
                        self.theme = resolved
                last_session = data.get("last_session")
                if isinstance(last_session, str) and last_session:
                    self._last_session_path = last_session
                geometry_hex = data.get("geometry")
                if isinstance(geometry_hex, str) and geometry_hex:
                    try:
                        self._stored_geometry = bytes.fromhex(geometry_hex)
                    except (ValueError, TypeError):
                        # Malformed value — fall back to default size.
                        self._stored_geometry = None
        except Exception:
            self.mosaic_view_enabled = False

    def _save_settings(self):
        try:
            self.settings_dir.mkdir(parents=True, exist_ok=True)
            # Capture geometry as hex (same encoding UaExplorer uses) so
            # close + restart both restore the same window position and
            # size. saveGeometry returns a QByteArray; .toHex() yields a
            # printable ASCII representation.
            try:
                geometry_hex = bytes(self.saveGeometry().toHex().data()).decode("ascii")
            except Exception:
                geometry_hex = ""
            payload = {
                "mosaic_view": self.mosaic_view_enabled,
                "flush_interval_ms": self.flush_interval_ms,
                "history_samples": self.history_samples,
                "statistics": dict(self.statistics),
                "theme": self.theme.name,
                "legend_font_size_px": self.legend_font_size_px,
                "plot_title_font_size_px": self.plot_title_font_size_px,
                "recording_settings": dict(self.recording_settings),
                "server_names": dict(self.server_names),
                "geometry": geometry_hex,
                # Empty string serializes as a "no session loaded" marker —
                # distinguishable from "key absent" (older settings file).
                "last_session": self._last_session_path or "",
            }
            tmp = self.settings_file.with_suffix(".tmp")
            tmp.write_text(json.dumps(payload, indent=2))
            tmp.replace(self.settings_file)
        except Exception:
            pass


def _ipc_channel_safe(name: str) -> str:
    """Make channel name safe for QLocalServer on Windows (named pipes)."""
    safe = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
    return safe[:64] if len(safe) > 64 else safe


def parse_args():
    parser = argparse.ArgumentParser(description="UA Plot companion GUI")
    parser.add_argument("--uri", default="", help="OPC UA server URI")
    parser.add_argument(
        "--server-name",
        default="",
        dest="server_name",
        help="Friendly name for --uri, propagated by UaExplorer so both tools "
             "show the same server name. Takes precedence over the persisted "
             "name for this URI; empty leaves the persisted name untouched.",
    )
    parser.add_argument(
        "--channel",
        default=f"uaplot_{socket.gethostname()}_{os.getpid()}",
        help="QLocalSocket channel name",
    )
    parser.add_argument(
        "--socket",
        default="",
        help="Absolute local-socket PATH to listen on. When given, used "
             "verbatim instead of --channel (lets a non-Qt controller such as "
             "UaShell dictate the exact socket path).",
    )
    parser.add_argument(
        "--theme",
        default="Light",
        help="UI theme name (Light, Dark, Atacama). Unknown values fall back to Light.",
    )
    # Internal flag: set by restart_application() when the process is
    # re-exec'ing itself. Causes main() to auto-load the previously
    # remembered session so the user comes back to the same plots.
    # A fresh command-line launch deliberately does NOT auto-load —
    # the user typed `UaPlot`, they get an empty UaPlot.
    parser.add_argument(
        "--restart",
        action="store_true",
        help=argparse.SUPPRESS,
    )
    return parser.parse_args()


def main():
    args = parse_args()
    # An explicit --socket PATH is used verbatim (a controller dictating the
    # exact socket path); otherwise derive a safe channel name as before.
    if args.socket:
        channel = args.socket
    else:
        channel = _ipc_channel_safe(args.channel)
    theme = resolve_theme(args.theme)
    app = QApplication(sys.argv)
    apply_theme_to_application(theme)
    # Standalone = a plain `UaPlot` launch. When a parent (UaShell via --socket,
    # or UaExplorer/UaShell via --uri) drives us, the parent owns the server
    # connection, so changing the URI and loading sessions are disabled.
    standalone = not (bool(args.socket) or bool(args.uri))
    try:
        window = UaPlotWindow(uri=args.uri, channel=channel, theme=theme,
                              server_name=args.server_name, standalone=standalone)
    except RuntimeError as e:
        QMessageBox.critical(None, "UaPlot", str(e))
        sys.exit(1)

    # On Restart with a remembered session, load the session BEFORE
    # showing the window. Otherwise the user sees an empty shell while
    # plots and series are added one by one — even with setUpdatesEnabled
    # the WM has already painted the empty window before the load runs.
    # Loading first means the window appears in its final, populated
    # state. Plain command-line launches and UaExplorer-driven launches
    # never auto-load, so they show immediately as before.
    restart_load_path: Optional[Path] = None
    if args.restart and not args.uri and window._last_session_path:
        candidate = Path(window._last_session_path)
        if candidate.is_file():
            restart_load_path = candidate

    if restart_load_path is not None:
        try:
            window._load_session_from_file(str(restart_load_path))
        except Exception as exc:
            _log(f"auto-load of last session failed: {exc}")

    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
