using System.Collections.Concurrent;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
///     the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
///     under the manager's namespace; subsequent writes update the existing node's Value +
///     StatusCode + SourceTimestamp and notify subscribed clients via the standard
///     <c>ClearChangeMasks</c> path.
///
///     This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
///     seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
///     materialise as real OPC UA Variable updates that clients can browse + subscribe to.
///
///     Node-id encoding uses the manager's default namespace + the caller-supplied string id
///     as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Beyond lazily-created flat
///     variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy
///     (<see cref="EnsureFolder"/>), typed equipment-tag variables with the correct built-in
///     <c>DataType</c> / array shape / access levels (<see cref="EnsureVariable"/>), historized
///     nodes (the <c>HistoryRead</c> access bit + HistoryRead overrides), and real Part 9
///     <see cref="AlarmConditionState"/> nodes (<see cref="MaterialiseAlarmCondition"/>). The
///     <c>AddressSpaceApplier</c> drives these passes from the composed deployment artifact;
///     the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
///     (composer → applier → sink → node-manager) chain.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
    /// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
    /// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
    /// registered FIRST (namespace index <see cref="NamespaceIndexes"/>[0]) and
    /// <see cref="V3NodeIds.UnsNamespaceUri"/> SECOND ([1]); <see cref="NamespaceIndexForRealm"/> maps a realm
    /// to its index. Which realm a node belongs to travels with each sink call as an
    /// <see cref="AddressSpaceRealm"/> — the namespace is never parsed back out of the id string.</summary>
    private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };

    /// <summary>Transitional alias kept so the pre-v3 single-namespace integration tests
    /// (<c>SubscriptionSurvivalTests</c>) still resolve a namespace index. It points at
    /// <see cref="V3NodeIds.UnsNamespaceUri"/> — the same realm as the transitional default of every sink
    /// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two
    /// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.</summary>
    public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;

    private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
    /// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
    /// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
    /// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
    /// must know which conditions are native. Maintained in lock-step with <see cref="_alarmConditions"/>:
    /// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
    /// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
    private readonly HashSet<string> _nativeAlarmNodeIds = new(StringComparer.Ordinal);
    /// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
    /// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
    /// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
    /// <see cref="RebuildAddressSpace"/>.</summary>
    private readonly ConcurrentDictionary<string, string> _historizedTagnames = new(StringComparer.Ordinal);
    /// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
    /// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
    /// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
    /// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
    private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
    /// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
    /// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
    /// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
    /// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
    /// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
    /// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
    /// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
    /// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
    /// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
    private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
    /// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
    /// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
    /// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
    /// the map's presence (not its value) is what makes a folder an event-history source. Populated by
    /// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
    /// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
    /// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
    /// <see cref="RebuildAddressSpace"/>.</summary>
    private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
    private FolderState? _root;

    /// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
    /// <param name="server">The OPC UA server instance.</param>
    /// <param name="configuration">The application configuration.</param>
    public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
        : base(server, configuration, RegisteredNamespaceUris)
    {
        // SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
        // indexes captured (in the order passed) in NamespaceIndexes.
    }

    /// <summary>Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
    /// first (<see cref="NamespaceIndexes"/>[0]), UNS second ([1]) — see <see cref="RegisteredNamespaceUris"/>.</summary>
    /// <param name="realm">The address-space realm.</param>
    /// <returns>The namespace index for the realm.</returns>
    internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch
    {
        AddressSpaceRealm.Raw => NamespaceIndexes[0],
        AddressSpaceRealm.Uns => NamespaceIndexes[1],
        _ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
    };

    /// <summary>Recover the realm of an already-resolved SDK <see cref="NodeId"/> from its namespace index —
    /// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId.
    /// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only
    /// ones this manager mints into).</summary>
    /// <param name="nodeId">The resolved SDK node id.</param>
    /// <returns>The realm the node belongs to.</returns>
    private AddressSpaceRealm RealmOf(NodeId nodeId) =>
        nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;

    /// <summary>Build the internal map key for a realm-qualified string id. The key is the node's full
    /// namespace-qualified NodeId string (e.g. <c>ns=2;s=&lt;id&gt;</c>) so a Raw node and a UNS node that
    /// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
    /// the two namespaces. Matches <see cref="MapKey(NodeId)"/> for the same node.</summary>
    /// <param name="realm">The realm the id lives in.</param>
    /// <param name="id">The bare <c>s=</c> identifier.</param>
    /// <returns>The namespace-qualified map key.</returns>
    private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();

    /// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
    /// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node.</summary>
    /// <param name="nodeId">The resolved SDK node id.</param>
    /// <returns>The namespace-qualified map key.</returns>
    private static string MapKey(NodeId nodeId) => nodeId.ToString();

    /// <summary>Gets the count of variable nodes currently managed.</summary>
    public int VariableCount => _variables.Count;
    /// <summary>Gets the count of folder nodes currently managed.</summary>
    public int FolderCount => _folders.Count;
    /// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
    public int AlarmConditionCount => _alarmConditions.Count;

    /// <summary>
    ///     Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
    ///     materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
    ///     handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
    ///     <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
    ///     delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
    ///     <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
    ///     <para>
    ///         This is the ONLY reverse coupling out of the node manager — by design it is a plain
    ///         <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
    ///         delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
    ///         (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
    ///         handler a safe no-op — it still gates + returns, just routes nowhere.
    ///     </para>
    /// </summary>
    public Action<AlarmCommand>? AlarmCommandRouter { get; set; }

    /// <summary>
    ///     H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
    ///     Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
    ///     Acknowledges one, the condition's <c>OnAcknowledge</c> handler (wired in
    ///     <see cref="MaterialiseAlarmCondition"/>) branches on <see cref="IsNativeAlarmNode"/> and — after
    ///     the same <c>AlarmAck</c> role gate as <see cref="AlarmCommandRouter"/> — invokes THIS delegate
    ///     with a <see cref="NativeAlarmAck"/> instead of routing an <see cref="AlarmCommand"/> to the
    ///     scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
    ///     (a later task wires the driver linkage).
    ///     <para>
    ///         Like <see cref="AlarmCommandRouter"/>, the handler delegate runs under the manager's
    ///         <c>Lock</c>, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
    ///         makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
    ///         Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
    ///         native condition stay on the scripted <see cref="AlarmCommandRouter"/> path (H6c scope is the
    ///         Acknowledge → driver path only).
    ///     </para>
    /// </summary>
    public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }

    private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;

    /// <summary>
    ///     Reverse-path gateway for inbound OPC UA operator writes to a writable equipment-tag variable node.
    ///     When a client writes such a node, the node's <see cref="BaseDataVariableState.OnWriteValue"/>
    ///     handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
    ///     variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
    ///     role and, when allowed, calls <see cref="IOpcUaNodeWriteGateway.WriteAsync"/> with the node's
    ///     string id + the written value to route the write to the backing driver.
    ///     <para>
    ///         This is the write-side twin of <see cref="AlarmCommandRouter"/>; the gateway abstraction keeps
    ///         this assembly Akka-free (the host wires an <c>ActorNodeWriteGateway</c> that Asks the local
    ///         <c>DriverHostActor</c>). The handler delegates run under the node-manager <c>Lock</c> (the OPC
    ///         UA SDK's <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>),
    ///         so the dispatch is FIRE-AND-FORGET — the handler kicks off <c>WriteAsync</c> and returns
    ///         <c>Good</c> immediately so the SDK applies the client value optimistically; it MUST NOT block
    ///         on the device round-trip. When the asynchronous <see cref="NodeWriteOutcome"/> comes back
    ///         FAILED, an off-Lock continuation self-corrects: it re-takes <c>Lock</c> and reverts the node to
    ///         its real pre-write value — but only while the node still holds the optimistic value, so a fresh
    ///         driver poll that has already moved the node on is not clobbered (see
    ///         <see cref="ShouldRevert"/> / <see cref="RevertOptimisticWriteIfNeeded"/>).
    ///     </para>
    ///     <para>
    ///         Set by the host at <c>StartAsync</c>; the <see cref="NullOpcUaNodeWriteGateway"/> default
    ///         (assigning <c>null</c> restores it) makes every write resolve to a "writes unavailable"
    ///         failure. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make the
    ///         startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
    ///         and the SDK reads it on Write request threads.
    ///     </para>
    /// </summary>
    public IOpcUaNodeWriteGateway NodeWriteGateway
    {
        get => _nodeWriteGateway;
        set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
    }

    private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;

    /// <summary>
    ///     Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
    ///     When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
    ///     <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> registered), the
    ///     HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
    ///     this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
    ///     independent of any driver's lifecycle.
    ///     <para>
    ///         Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
    ///         default (assigning <c>null</c> restores it) means "no historian wired" → every read
    ///         returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
    ///         faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
    ///         the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
    ///         the start thread and the SDK reads it on HistoryRead request threads. Unlike
    ///         <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
    ///         node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
    ///     </para>
    /// </summary>
    public IHistorianDataSource HistorianDataSource
    {
        get => _historianDataSource;
        set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
    }

    /// <summary>
    ///     The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
    ///     oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
    ///     cap. When a resume read stalls on such a cluster (the boundary-tie trim empties the page), the
    ///     paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
    ///     <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
    ///     A cluster strictly larger than this still surfaces <c>BadHistoryOperationUnsupported</c> for that
    ///     node (the absurd-burst backstop). Mirrors the configured
    ///     <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
    ///     default (65536) survives when the historian section is absent.
    /// </summary>
    public int MaxTieClusterOverfetch { get; set; } = 65536;

    /// <summary>
    ///     Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
    ///     Mirrors <c>ServerHistorianOptions.HistoryReadBatchParallelism</c>; the Host sets it at
    ///     <c>StartAsync</c>. <c>≤ 1</c> falls back to sequential serving. The default (4) survives when the
    ///     ServerHistorian section is absent.
    /// </summary>
    public int HistoryReadBatchParallelism { get; set; } = 4;

    /// <summary>
    ///     Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that
    ///     cannot acquire a slot within its (bounded) wait budget fast-fails every handle with
    ///     <c>BadTooManyOperations</c>. Mirrors <c>ServerHistorianOptions.MaxConcurrentHistoryReadBatches</c>;
    ///     the Host sets it at <c>StartAsync</c>. The default (16) survives when the section is absent.
    /// </summary>
    public int MaxConcurrentHistoryReadBatches { get; set; } = 16;

    /// <summary>
    ///     Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in
    ///     flight at the deadline surfaces <c>BadTimeout</c>. Non-positive = unbounded. Mirrors
    ///     <c>ServerHistorianOptions.HistoryReadDeadline</c>; the Host sets it at <c>StartAsync</c>. The
    ///     default (60 s) survives when the section is absent.
    /// </summary>
    public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);

    /// <summary>Lazily-created process-wide HistoryRead-batch limiter (see <see cref="MaxConcurrentHistoryReadBatches"/>).</summary>
    private volatile SemaphoreSlim? _historyReadBatchLimiter;
    private readonly object _historyReadBatchLimiterLock = new();

    private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();

    /// <summary>
    ///     The store that holds the server-side resume state behind an opaque HistoryRead continuation
    ///     point for the count-capped variable-history arms (Raw / Processed). The default
    ///     <see cref="SessionHistoryContinuationStore"/> binds points to the OPC UA session — so they are
    ///     capped (<c>ServerConfiguration.MaxHistoryContinuationPoints</c>, SDK default 100, oldest-evicted)
    ///     and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
    ///     inject an <see cref="InMemoryHistoryContinuationStore"/> and exercise the full multi-page round
    ///     trip through the same dispatch path. Assigning <c>null</c> restores the session-backed default.
    /// </summary>
    internal IHistoryContinuationStore HistoryContinuationStore
    {
        get => _historyContinuationStore;
        set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
    }

    /// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
    /// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
    /// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
    /// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
    public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
        _alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null;

    /// <summary>Phase C: look up the resolved historian tagname registered for a historized variable
    /// node, or null when the node is not historized. The (later) HistoryRead override resolves an
    /// inbound HistoryRead request's NodeId against this map. Exposed for tests + the override.</summary>
    /// <param name="nodeId">The variable node identifier.</param>
    /// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
    /// <returns>True when the node is registered as historized; otherwise false.</returns>
    public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; }
        tagname = null;
        return false;
    }

    /// <summary>NodeId-typed overload used by the HistoryRead override, which holds a request's full
    /// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first.
    /// A historized UNS reference node and its backing raw node register the SAME tagname under distinct
    /// NodeIds, so a read against either resolves the one series.</summary>
    /// <param name="nodeId">The resolved SDK node id from the HistoryRead request.</param>
    /// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
    /// <returns>True when the node is registered as historized; otherwise false.</returns>
    private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname)
    {
        if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; }
        tagname = null;
        return false;
    }

    /// <summary>Look up a materialised variable node by its NodeId string, or null if not present.
    /// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes.</summary>
    /// <param name="nodeId">The variable node identifier.</param>
    /// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
    internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
        _variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null;

    /// <summary>Look up a materialised folder node by its NodeId string, or null if not present.
    /// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
    /// node a HistoryReadEvents request targets).</summary>
    /// <param name="nodeId">The folder node identifier.</param>
    /// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
    internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
        _folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;

    /// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
    /// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
    /// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
    /// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
    /// across redeploys.</summary>
    /// <param name="alarmNodeId">The alarm condition node identifier.</param>
    /// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
    /// <returns>The number of notifier entries on the condition.</returns>
    internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
    {
        lock (Lock)
        {
            if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
            var list = new List<NodeState.Notifier>();
            alarm.GetNotifiers(SystemContext, list);
            return list.Count;
        }
    }

    /// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
    /// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
    /// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
    /// <param name="folderNodeId">The folder node identifier.</param>
    /// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
    /// <returns>The number of notifier entries on the folder.</returns>
    internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        lock (Lock)
        {
            if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
            var list = new List<NodeState.Notifier>();
            folder.GetNotifiers(SystemContext, list);
            return list.Count;
        }
    }

    /// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
    /// registered as a root (Server-object) event notifier — i.e. it was promoted via
    /// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
    /// <param name="folderNodeId">The folder node identifier.</param>
    /// <param name="realm">The realm the folder lives in.</param>
    /// <returns>True when the folder is a registered root notifier.</returns>
    internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        lock (Lock)
            return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
                   && _notifierFolders.ContainsKey(folder.NodeId);
    }

    /// <summary>
    ///     Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
    ///     variable node on first call; subsequent calls update Value + StatusCode +
    ///     SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
    /// </summary>
    /// <param name="nodeId">The node identifier of the variable.</param>
    /// <param name="value">The new value to write.</param>
    /// <param name="quality">The OPC UA quality status code.</param>
    /// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
    public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(nodeId);
        EnsureAddressSpaceCreated();

        var ns = NamespaceIndexForRealm(realm);
        var key = MapKey(realm, nodeId);
        lock (Lock)
        {
            // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
            // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
            if (!_variables.TryGetValue(key, out var variable))
            {
                variable = CreateVariable(nodeId, ns);
                _variables[key] = variable;
            }

            variable.Value = value;
            variable.StatusCode = StatusFromQuality(quality);
            variable.Timestamp = sourceTimestampUtc;
            variable.ClearChangeMasks(SystemContext, includeChildren: false);
        }
    }

    /// <summary>
    ///     Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
    ///     been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
    ///     this projects the whole <paramref name="state"/> snapshot
    ///     (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
    ///     node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
    ///     Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
    ///     <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
    ///     materialised (and the existing unit tests) keep working.
    /// </summary>
    /// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
    /// <param name="state">The full condition state to project onto the node.</param>
    /// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
    public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        ArgumentNullException.ThrowIfNull(state);
        EnsureAddressSpaceCreated();

        var ns = NamespaceIndexForRealm(realm);
        var key = MapKey(realm, alarmNodeId);
        // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
        // _alarmConditions / detach the condition node between the lookup and the Set* calls.
        lock (Lock)
        {
            if (_alarmConditions.TryGetValue(key, out var condition))
            {
                // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
                // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
                // snapshot to that current state. We gate against the NODE's state, NOT a "last written"
                // cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
                // SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
                // last-written cache would be stale and wrongly report a delta. By the time the engine
                // re-projects that ack here, the node already holds the acked state → no delta → suppress.
                var current = ReadConditionDelta(condition);
                var incoming = ToConditionDelta(state, condition);
                bool fire = ShouldFireConditionEvent(current, incoming);

                // EnabledState / AckedState / ActiveState are mandatory children — always present after
                // Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
                // that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
                // future SDK that builds a leaner child set) may leave them null. Null-guard each optional
                // child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
                // no-op rather than an NRE.
                condition.SetEnableState(SystemContext, state.Enabled);
                condition.SetActiveState(SystemContext, state.Active);
                condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
                if (condition.ConfirmedState is not null)
                {
                    condition.SetConfirmedState(SystemContext, state.Confirmed);
                }
                if (condition.ShelvingState is not null)
                {
                    // SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
                    // (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
                    // SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
                    condition.SetShelvingState(
                        SystemContext,
                        shelved: state.Shelving != AlarmShelvingKind.Unshelved,
                        oneShot: state.Shelving == AlarmShelvingKind.OneShot,
                        shelvingTime: 0);
                }
                condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
                condition.Message.Value = new LocalizedText(state.Message);

                // #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
                // change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
                // it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
                // null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
                if (condition.Quality is not null)
                {
                    condition.Quality.Value = StatusFromQuality(state.Quality);
                    if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
                }

                // Part 9: retain the condition while it is active OR unacknowledged so a client's
                // ConditionRefresh replays it. The event firing below also depends on this Retain being
                // correct (a non-retained inactive+acked condition still fires its transition event, but
                // won't be replayed on a later ConditionRefresh).
                condition.Retain.Value = state.Active || !state.Acknowledged;
                condition.Time.Value = sourceTimestampUtc;
                condition.ReceiveTime.Value = sourceTimestampUtc;

                // T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
                // change (the delta-gate decided above, against the node's pre-projection state). A
                // genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
                // engine-side ack, etc.) differs from the node's current state → fire. The re-projection
                // of a client ack the SDK already applied equals the node's current state → no delta →
                // suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
                // stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
                if (fire)
                {
                    ReportConditionEvent(condition, sourceTimestampUtc);
                }
                return;
            }

            // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
            // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
            // the SDK address space, so it MUST run under Lock (see WriteValue).
            if (!_variables.TryGetValue(key, out var variable))
            {
                variable = CreateVariable(alarmNodeId, ns);
                _variables[key] = variable;
            }

            variable.Value = new[] { state.Active, state.Acknowledged };
            variable.StatusCode = StatusCodes.Good;
            variable.Timestamp = sourceTimestampUtc;
            variable.ClearChangeMasks(SystemContext, includeChildren: false);
        }
    }

    /// <summary>
    ///     #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
    ///     from any alarm transition. Used by the driver-connectivity path (comms lost → Bad, restored →
    ///     Good) so a native condition whose device is unreachable stops reporting the accidentally-Good
    ///     default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
    ///     Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
    ///     quality bucket is a genuine Part 9 condition change, so it fires one condition event carrying the
    ///     new Quality; an unchanged bucket suppresses (no spurious event). Unknown / unmaterialised node ⇒
    ///     safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
    /// </summary>
    /// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
    /// <param name="quality">The source-data quality to annotate.</param>
    /// <param name="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
    /// <param name="realm">The condition's address-space realm.</param>
    public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        EnsureAddressSpaceCreated();

        var key = MapKey(realm, alarmNodeId);
        lock (Lock)
        {
            // Only materialised conditions carry a Quality child; a bare value variable or a missing node is
            // a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
            // concurrent rebuild may have just cleared).
            if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
                return;

            var newCode = StatusFromQuality(quality);
            var changed = condition.Quality.Value.Code != newCode.Code;

            condition.Quality.Value = newCode;
            if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;

            // Fire ONLY on a real bucket change so a steady-state connectivity re-assert doesn't spam events.
            if (changed)
            {
                condition.Time.Value = sourceTimestampUtc;
                condition.ReceiveTime.Value = sourceTimestampUtc;
                ReportConditionEvent(condition, sourceTimestampUtc);
            }
        }
    }

    /// <summary>
    ///     Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
    ///     materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
    ///     have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
    ///     <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
    ///     <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
    ///     so a subscribing client sees the values at this instant even if the live node mutates after).
    ///     <para>
    ///         A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
    ///         AddComment calls are correlated back to a specific event by this id (the SDK matches it via
    ///         <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
    ///         per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
    ///         materialise) — no branch creation here.
    ///     </para>
    ///     <para>
    ///         <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
    ///         goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
    ///         state to the node and auto-fires its own condition event (E2) — directly on the node,
    ///         BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
    ///         transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
    ///         event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
    ///         incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
    ///         pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
    ///         engine-driven transitions still differ from the node's current state, so they fire here as
    ///         before.
    ///     </para>
    /// </summary>
    /// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
    /// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
    private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
    {
        // Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
        alarm.EventId.Value = Guid.NewGuid().ToByteArray();
        // Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
        // immediately above; the assignment here is a locality repeat (same value, no behavioral change)
        // so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
        alarm.Time.Value = ts;
        alarm.ReceiveTime.Value = ts;

        // Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
        // the InstanceStateSnapshot captures the just-projected values.
        alarm.ClearChangeMasks(SystemContext, includeChildren: true);

        try
        {
            // InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
            // time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
            // in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
            // event reaches subscribed monitored items.
            var snapshot = new InstanceStateSnapshot();
            snapshot.Initialize(SystemContext, alarm);
            alarm.ReportEvent(SystemContext, snapshot);
        }
        catch (Exception ex)
        {
            // A failed event report must NOT break the state projection or the calling actor: the node's
            // state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
            // change; only the event delivery is lost. This CustomNodeManager2 carries no ILogger, so log
            // through the SDK's static trace (Utils.LogError) instead of swallowing silently — a recurring
            // failure here is then visible in the server log rather than invisible. T19's live Client.CLI
            // run is the integration proof that the happy path delivers.
            // Utils.LogError routes to the SDK's trace sink. It's [Obsolete] in 1.5.378 in favour of an
            // ITelemetryContext/ILogger this CustomNodeManager2 doesn't have wired — suppress the
            // deprecation here (wiring the telemetry logger through is a separate follow-up); the point is
            // that a recurring failure is visible in the server trace rather than silently swallowed.
#pragma warning disable CS0618 // Type or member is obsolete
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {0}", alarm.NodeId);
#pragma warning restore CS0618
        }
    }

    /// <summary>
    ///     The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
    ///     condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
    ///     instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
    ///     <para>
    ///         <b>Severity</b> is stored as the MAPPED <see cref="EventSeverity"/> bucket (a
    ///         <see cref="ushort"/>) — the same value the node holds after <c>SetSeverity</c> — so two
    ///         raw severities that fall in the same bucket are correctly treated as "no change". The
    ///         <b>Shelving</b> kind is read back from / mapped to the SDK's shelving state machine so the
    ///         live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
    ///     </para>
    ///     <para>
    ///         <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
    ///         <c>EmissionKind.CommentAdded</c> is produced only by
    ///         <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
    ///         <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
    ///         <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
    ///         <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
    ///         <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
    ///         <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
    ///         and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
    ///         engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
    ///         arrives here, the delta-gate sees no change in any compared field (the snapshot carries
    ///         no comments list) and correctly suppresses a second event (E3). Force-firing for
    ///         <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
    ///         comment path, so suppression never drops a needed event.
    ///     </para>
    ///     <para>
    ///         <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
    ///         <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
    ///         <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
    ///         changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
    ///         <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
    ///         future engine were to set <c>Retain</c> independently — without touching
    ///         <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
    ///     </para>
    /// </summary>
    internal readonly record struct AlarmConditionDelta(
        bool Active,
        bool Acknowledged,
        bool Confirmed,
        bool Enabled,
        AlarmShelvingKind Shelving,
        ushort MappedSeverity,
        string Message,
        // #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
        // going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
        // StatusCode has value equality, so the record struct's == still holds.
        StatusCode Quality);

    /// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
    /// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
    /// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns <c>true</c> iff
    /// any gate-relevant field differs. An inbound client ack the SDK already applied makes
    /// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
    /// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
    /// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
    /// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
    /// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
    internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
        current != incoming;

    /// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
    /// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
    /// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-mapped
    /// <see cref="EventSeverity"/> bucket the node stores, and shelving is mapped from the shelving
    /// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
    private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
        Active: condition.ActiveState?.Id?.Value ?? false,
        Acknowledged: condition.AckedState?.Id?.Value ?? true,
        Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
        Enabled: condition.EnabledState?.Id?.Value ?? true,
        Shelving: ReadShelvingKind(condition),
        MappedSeverity: condition.Severity?.Value ?? (ushort)0,
        Message: condition.Message?.Value?.Text ?? string.Empty,
        // Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
        // and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
        Quality: condition.Quality?.Value ?? StatusCodes.Good);

    /// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
    /// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
    /// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</c>), and an
    /// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
    /// node's read-back default so it never spuriously registers as a delta.</summary>
    private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
        Active: state.Active,
        Acknowledged: state.Acknowledged,
        // If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
        // read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
        Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
        Enabled: state.Enabled,
        // Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
        // node's read-back default (Unshelved).
        Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
        MappedSeverity: (ushort)MapSeverity(state.Severity),
        Message: state.Message ?? string.Empty,
        // If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
        // node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
        Quality: condition.Quality is not null ? StatusFromQuality(state.Quality) : StatusCodes.Good);

    /// <summary>Map the live shelving state machine's CurrentState back to our 3-way
    /// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
    /// a shelving sub-state machine (or an unrecognised/unset state) reads as
    /// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
    /// unsupported shelving snapshot to, so the two stay comparable.</summary>
    private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
    {
        var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
        if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
        if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
        return AlarmShelvingKind.Unshelved;
    }

    /// <summary>
    ///     Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
    ///     folder so clients can browse it as a proper condition (and subscribe to its events). The node
    ///     id is the alarm node id (the ScriptedAlarmId) so subsequent
    ///     <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
    ///     <para>
    ///         This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
    ///         node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
    ///         <b>no</b> events itself.
    ///     </para>
    ///     Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
    ///     node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
    /// </summary>
    /// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
    /// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
    /// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
    /// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
    /// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
    /// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
    /// <remarks>
    ///     <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
    ///     setpoint values, so any limit-style subtype would have unset limit children. We therefore
    ///     map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
    ///     <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
    ///     <c>LimitAlarm</c>, which has no script-supplied limits) → the base
    ///     <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
    ///     notes — a script alarm carries no High/Low limits to populate.</para>
    /// </remarks>
    public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);
        EnsureAddressSpaceCreated();

        var ns = NamespaceIndexForRealm(realm);
        var conditionKey = MapKey(realm, alarmNodeId);
        lock (Lock)
        {
            // R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
            // MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
            // an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
            // existing AlarmConditionState instance alive so every MonitoredItem on that condition node
            // survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
            // re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
            // first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
            // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
            if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
                return;

            // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
            // type/severity on redeploy) reflects cleanly instead of leaking the old node.
            if (_alarmConditions.TryRemove(conditionKey, out var existing))
            {
                // Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
                // referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
                // AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
                // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
                // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
                UnwireAlarmNotifiers(conditionKey);
                existing.Parent?.RemoveChild(existing);
                PredefinedNodes?.Remove(existing.NodeId);
            }

            // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
            // kind, so always drop the stale native flag first and only re-add it below when isNative.
            _nativeAlarmNodeIds.Remove(conditionKey);

            var parent = ResolveParentFolder(equipmentNodeId, realm);

            AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
            alarm.SymbolicName = displayName;
            // HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
            alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;

            // Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
            // ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
            // type's embedded definition; we do not hand-build them.
            alarm.Create(
                SystemContext,
                new NodeId(alarmNodeId, ns),
                new QualifiedName(displayName, ns),
                new LocalizedText(displayName),
                assignNodeIds: true);

            // Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
            // UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
            // Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
            // (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
            if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;

            // #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
            // the type's embedded definition but leaves them UNSET (the init string declares them mandatory
            // with NO default), and nothing downstream fills them in: the auto-filling
            // BaseEventState.Initialize(context, source, severity, message) overload is only used for
            // transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
            // here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
            //   SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
            //                alarm-bearing raw tag materialises ONLY this condition, with no sibling value
            //                variable (see AddressSpaceApplier's alarm branch), so there is no other node.
            //   SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
            //                Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
            //                is already carried by ConditionName below. See docs/AlarmTracking.md.
            alarm.EventType.Value = alarm.TypeDefinitionId;
            alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
            alarm.SourceName.Value = alarmNodeId;

            // #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
            // the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
            // text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
            // BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
            // classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
            // would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
            // upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
            // AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
            // authored composition this deploy-time seam sees — a separate feature, not a default picked here.
            if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
            if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");

            // #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
            // UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
            // unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
            // has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
            // BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
            // driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
            // SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
            // annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
            if (alarm.Quality is not null)
            {
                alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
                if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
            }

            // Initial state via the SDK setters (T14: basic state only, NO event firing).
            alarm.SetEnableState(SystemContext, true);
            alarm.SetActiveState(SystemContext, false);
            alarm.SetAcknowledgedState(SystemContext, true);
            alarm.SetSeverity(SystemContext, MapSeverity(severity));
            alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
            alarm.Message.Value = new LocalizedText(displayName);
            if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;

            // T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
            // AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
            // routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
            // state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
            // routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
            // SDK applies its node state + auto-fires its own event (E2).
            // T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
            // delta-gate (compares against the node's current state, which the SDK already pre-applied)
            // sees no change and suppresses the would-be second event (E3) — so no double-emit.
            // H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
            // scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
            // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
            // Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
            alarm.OnAcknowledge = (context, condition, _, comment) =>
                IsNativeAlarmNode(alarmNodeId, realm)
                    ? HandleNativeAlarmAck(context, condition, comment)
                    : HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
            alarm.OnConfirm = (context, condition, _, comment) =>
                HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
            alarm.OnAddComment = (context, condition, _, comment) =>
                HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
            alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
            {
                // SDK invocation shapes (verified against the decompiled AlarmConditionState):
                //   OneShotShelve → (shelving:true, oneShot:true, 0.0)   ⇒ OneShotShelve, no expiry
                //   TimedShelve   → (shelving:true, oneShot:false, ms)   ⇒ TimedShelve, expiry = UtcNow + ms
                //   Unshelve      → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
                // shelvingTime is an OPC UA Duration (milliseconds).
                var (operation, unshelveAt) =
                    !shelving ? ("Unshelve", (DateTime?)null)
                    : oneShot ? ("OneShotShelve", null)
                    : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
                return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
            };
            // The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
            // fires it with the node manager's system context — there is NO session and NO user identity.
            // Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
            // leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
            // same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
            // state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
            alarm.OnTimedUnshelve = (context, condition) =>
            {
                var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
                // User MUST be non-empty: the engine's Part9StateMachine.ApplyUnshelve rejects a
                // null/whitespace user (ArgumentException "User required."), and that exception is swallowed
                // downstream — an empty user would make the timed auto-unshelve silently no-op, so a
                // TimedShelve would never auto-expire. There is no client principal on a system-timer
                // unshelve, so label it the canonical engine-internal "system" user (matching the engine's
                // own AutoUnshelve audit user and the codebase-wide "system" system-actor convention). The
                // AlarmAck gate bypass is intentional and preserved — this is a session-less SDK timer.
                AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", "system", null, null));
                return ServiceResult.Good;
            };

            // H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
            // here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
            // (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
            // "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
            // AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
            // engine enable/disable surface — they short-circuit to BadNotSupported.
            alarm.OnEnableDisable = (context, condition, enabling) =>
            {
                if (IsNativeAlarmNode(alarmNodeId, realm))
                    return new ServiceResult(StatusCodes.BadNotSupported);
                return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
            };

            parent.AddChild(alarm);

            // Promote the equipment folder to an event notifier + register it as a root notifier so
            // T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
            // materialise under the same folder doesn't double-add the root notifier.
            EnsureFolderIsEventNotifier(parent);

            AddPredefinedNode(SystemContext, alarm);
            _alarmConditions[conditionKey] = alarm;
            // H6a: record native (driver-fed) conditions so a later task can route their inbound
            // Acknowledge to the driver rather than the scripted engine.
            if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
        }
    }

    /// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
    /// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
    /// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
    /// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
    /// the instance changes).</summary>
    private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);

    /// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
    /// <remarks>
    ///     Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
    ///     previously-tracked folder whose id is no longer in <paramref name="notifierFolderNodeIds"/>
    ///     (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always
    ///     passes the COMPLETE referencing-equipment set for the condition, and a de-reference arrives as a
    ///     <c>ChangedRawTags</c> full rebuild (which clears the wiring), so the unwire leg is a no-op on the
    ///     current paths — but it makes this method correct for a FUTURE surgical <c>ChangedRawTags</c> path
    ///     (see <c>AddressSpaceChangeClassifier</c>'s <c>ChangedRawTags → Rebuild</c> branch, which carries the
    ///     matching binding guard).
    /// </remarks>
    public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
        IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
        if (notifierFolderNodeIds.Count == 0) return;
        EnsureAddressSpaceCreated();

        var alarmKey = MapKey(alarmRealm, alarmNodeId);
        lock (Lock)
        {
            // The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
            // apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
            // not fault on the notifier wiring.
            if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
            {
                // This CustomNodeManager2 carries no ILogger; log through the SDK's static trace (see the
                // ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
                // favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
#pragma warning disable CS0618 // Type or member is obsolete
                Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
#pragma warning restore CS0618
                return;
            }

            // Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
            // the entry (the old folder list referenced the discarded instance) when the instance differs.
            if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
            {
                wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
                _alarmNotifierWiring[alarmKey] = wiring;
            }

            // The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller
            // supplied, regardless of whether each is currently materialised. Reconcile against it below.
            var desiredKeys = notifierFolderNodeIds
                .Select(id => MapKey(notifierFolderRealm, id))
                .ToHashSet(StringComparer.Ordinal);

            // Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
            // genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
            // folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder
            // (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too.
            foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList())
            {
                wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true);
                wiring.Folders.Remove(wired);
            }

            foreach (var folderNodeId in notifierFolderNodeIds)
            {
                if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
                {
                    // Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
#pragma warning disable CS0618 // Type or member is obsolete
                    Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
                        folderNodeId, alarmKey);
#pragma warning restore CS0618
                    continue;
                }

                // Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
                // ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
                // entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
                // root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
                //   alarm.AddNotifier(isInverse:true, folder)   — the upward bubble to the equipment folder
                //   folder.AddNotifier(isInverse:false, alarm)  — the downward AreEventsMonitored link
                //   EnsureFolderIsEventNotifier(folder)         — SubscribeToEvents + AddRootNotifier (idempotent)
                // AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
                // updates rather than duplicates.
                alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
                folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
                EnsureFolderIsEventNotifier(folder);

                if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
            }
        }
    }

    /// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
    /// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
    /// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
    private void UnwireAlarmNotifiers(string alarmKey)
    {
        if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
        foreach (var folder in wiring.Folders)
        {
            // bidirectional:true also removes the inverse entry the folder holds back to the condition.
            wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
        }
        _alarmNotifierWiring.Remove(alarmKey);
    }

    /// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
    /// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
    /// inbound Acknowledge to the driver instead of the scripted engine.</summary>
    /// <param name="alarmNodeId">The alarm condition node id.</param>
    /// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
    internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        // _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
        // RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read.
        lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
    }

    /// <summary>
    ///     Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
    ///     principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
    ///     (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
    ///     mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
    /// </summary>
    /// <param name="context">The SDK context the handler delegate was invoked with — a
    /// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
    /// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
    /// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
    /// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
    /// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
    /// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
    /// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
    /// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
    /// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
    private ServiceResult HandleAlarmCommand(
        ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
    {
        // Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
        // role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
        var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
        if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
        {
            // Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
            // state change and surfaces the status to the client; we never route or mutate.
            return new ServiceResult(StatusCodes.BadUserAccessDenied);
        }

        var cmd = new AlarmCommand(
            AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
            Operation: operation,
            User: identity.DisplayName ?? string.Empty,
            Comment: comment?.Text,
            UnshelveAtUtc: unshelveAt);

        // Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
        AlarmCommandRouter?.Invoke(cmd);

        // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
        return ServiceResult.Good;
    }

    /// <summary>
    ///     H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
    ///     scripted engine does not own native conditions, so this routes a <see cref="NativeAlarmAck"/> to
    ///     <see cref="NativeAlarmAckRouter"/> (the driver-bound seam) rather than a scripted
    ///     <see cref="AlarmCommand"/> to <see cref="AlarmCommandRouter"/>. The calling principal is resolved
    ///     and the <c>AlarmAck</c> role gate applied EXACTLY as in <see cref="HandleAlarmCommand"/>
    ///     (<b>fails closed</b>: a missing identity or missing role is denied — no route, no state mutation).
    /// </summary>
    /// <param name="context">The SDK context the handler delegate was invoked with — a
    /// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session identity
    /// (T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>).</param>
    /// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
    /// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
    /// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
    /// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its event);
    /// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
    private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
    {
        // Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
        // contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
        var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
        if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
        {
            return new ServiceResult(StatusCodes.BadUserAccessDenied);
        }

        // Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
        // for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
        // (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
        // Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
        NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
            ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
            Comment: comment?.Text,
            OperatorUser: identity.DisplayName ?? string.Empty));

        // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
        return ServiceResult.Good;
    }

    /// <summary>
    ///     The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
    ///     <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
    ///     node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
    ///     SAME way <see cref="HandleAlarmCommand"/> does, gates on the
    ///     <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
    ///     (<b>fails closed</b>: a missing identity / missing role ⇒ <c>BadUserAccessDenied</c>; no gateway ⇒
    ///     <c>BadNotWritable</c>) via the pure <see cref="EvaluateEquipmentWriteGate"/>, and on pass dispatches
    ///     the value through <see cref="NodeWriteGateway"/>.
    ///     <para>
    ///         The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
    ///         manager <c>Lock</c> while invoking this handler, so a blocking driver round-trip here would
    ///         freeze every address-space operation (reads, subscription notifications, the publish path) for
    ///         the duration. The gateway only kicks off the asynchronous route. Returning
    ///         <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically.
    ///     </para>
    ///     <para>
    ///         <b>Item A — synchronous structural fail-fast.</b> After the authz gate passes but BEFORE the
    ///         optimistic dispatch, a pure <see cref="EvaluateEquipmentWriteStructure"/> pre-check rejects a
    ///         structurally-invalid write (e.g. a <c>null</c> payload, or a confidently-detected built-in-type
    ///         mismatch) INLINE — returning Bad synchronously so the SDK never applies it, avoiding the
    ///         optimistic-Good-then-revert round-trip + a pointless device dispatch.
    ///     </para>
    ///     <para>
    ///         <b>Write-outcome self-correction.</b> Before returning Good (which makes the SDK overwrite the
    ///         node with <paramref name="value"/>) we capture both the optimistic value AND the node's REAL
    ///         prior value/status — at handler entry the node still holds the prior value — plus the writing
    ///         principal's user-id (threaded to the audit event). An off-Lock continuation on the
    ///         <see cref="NodeWriteOutcome"/> then, on a FAILED outcome and ONLY while the node still holds the
    ///         optimistic value (so a fresh driver poll that already republished the confirmed register value is
    ///         not clobbered): surfaces a transient <b>Bad-quality blip</b> (Item B), reverts the node to its
    ///         prior value/status, and raises a Part 8 <b>AuditWriteUpdateEvent</b> (Item C) recording the
    ///         rejected write (<see cref="RevertOptimisticWriteIfNeeded"/> / <see cref="ShouldRevert"/>). On
    ///         success the optimistic value stands and the next poll re-confirms it via the normal
    ///         <see cref="WriteValue"/> path.
    ///     </para>
    /// </summary>
    private ServiceResult OnEquipmentTagWrite(
        ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
        ref object value, ref StatusCode statusCode, ref DateTime timestamp)
    {
        var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
        var gateway = _nodeWriteGateway;
        var gate = EvaluateEquipmentWriteGate(identity, gateway is not NullOpcUaNodeWriteGateway);
        if (gate is not null) return gate;

        // Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
        // synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
        // device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
        // caller) but BEFORE the optimistic dispatch below.
        var structure = EvaluateEquipmentWriteStructure(value, node);
        if (structure is not null) return structure;

        // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
        // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
        var optimisticValue = value;
        // v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS
        // write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its
        // (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm.
        var nodeKey = node.NodeId.ToString();
        var nodeRealm = RealmOf(node.NodeId);
        var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty;
        object? priorValue = null;
        StatusCode priorStatus = StatusCodes.Good;
        if (node is BaseDataVariableState variable)
        {
            priorValue = variable.Value;
            priorStatus = variable.StatusCode;
        }
        // Item C: thread the writing principal's user-id string into the failure continuation so the audit
        // event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
        // session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
        // practice non-null on this path, but kept defensive).
        var clientUserId = identity?.DisplayName;

        // Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
        // continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
        // sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
        // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
        // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
        // re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
        _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
            .ContinueWith(
                t =>
                {
                    var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
                    RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
                },
                CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);

        return ServiceResult.Good;
    }

    /// <summary>
    ///     Pure role + availability gate for an inbound equipment-tag write, extracted off
    ///     <see cref="OnEquipmentTagWrite"/> so it is unit-testable without booting an SDK server. Fails closed:
    ///     a null identity or an identity missing the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role ⇒
    ///     <c>BadUserAccessDenied</c>. When the gate passes but no real gateway is wired
    ///     (<paramref name="gatewayWired"/> is false) ⇒ <c>BadNotWritable</c> ("writes unavailable"). A
    ///     <c>null</c> return means "proceed" (the caller dispatches + returns Good). Role comparison is
    ///     case-insensitive (the role set is built with <see cref="StringComparer.OrdinalIgnoreCase"/>),
    ///     matching the alarm gate.
    /// </summary>
    /// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
    /// session is anonymous / carries no role-carrying identity.</param>
    /// <param name="gatewayWired">True when a non-Null <see cref="IOpcUaNodeWriteGateway"/> is wired; false
    /// for the Null default (no route — e.g. admin-only nodes / pre-boot).</param>
    /// <returns><c>null</c> to proceed (gate passed); otherwise the veto <see cref="ServiceResult"/>
    /// (<c>BadUserAccessDenied</c> on a failed role gate, <c>BadNotWritable</c> when no gateway is wired).</returns>
    internal static ServiceResult? EvaluateEquipmentWriteGate(RoleCarryingUserIdentity? identity, bool gatewayWired)
    {
        if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
        {
            // Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
            // write and surfaces the status to the client; we never route.
            return new ServiceResult(StatusCodes.BadUserAccessDenied);
        }

        if (!gatewayWired)
        {
            // Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
            return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
        }

        return null;
    }

    /// <summary>
    ///     <b>Item A — synchronous structural fail-fast.</b> Pure structural pre-check for an inbound
    ///     equipment-tag write, run AFTER <see cref="EvaluateEquipmentWriteGate"/> passes but BEFORE the
    ///     optimistic device dispatch in <see cref="OnEquipmentTagWrite"/>. A structurally-invalid write is
    ///     rejected INLINE (a Bad <see cref="ServiceResult"/> is returned synchronously, so the SDK never
    ///     applies the value) instead of being optimistically applied and later reverted — saving both the
    ///     phantom value-blip and a pointless device round-trip.
    ///     <para>
    ///         <b>Interpretation / tradeoff (the item was deliberately under-specified).</b> The minimum
    ///         sensible structural check is a <c>null</c> value write to a value variable ⇒
    ///         <c>BadTypeMismatch</c> (a value node always holds a typed scalar/array; a null payload is never a
    ///         valid value write here). On top of that, when the node is a <see cref="BaseDataVariableState"/>
    ///         whose <see cref="BaseVariableState.DataType"/> resolves to a concrete built-in type AND the
    ///         written value's runtime built-in type is also resolvable, a CHEAP built-in-type compatibility
    ///         check is applied: a clear mismatch ⇒ <c>BadTypeMismatch</c>. The check is intentionally
    ///         conservative — it only rejects when BOTH the expected and actual built-in types are confidently
    ///         resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
    ///         allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
    ///         <see cref="Variant"/>/array payload whose element type isn't cheaply known) is allowed through so
    ///         the SDK's own (authoritative) type coercion in <c>BaseVariableState.WriteValue</c> remains the
    ///         final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
    ///         here — that is left to the SDK.
    ///     </para>
    ///     Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
    ///     <c>DataType</c>, so it is unit-testable in isolation.
    /// </summary>
    /// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
    /// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
    /// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
    /// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
    internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node)
    {
        // Minimum sensible check: a null payload is never a valid value write to a value variable.
        if (value is null)
        {
            return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
        }

        // Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
        // with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
        // resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
        if (node is not BaseDataVariableState variable) return null;
        var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
        if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard

        // SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
        // actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
        // StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
        // a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
        if (!IsCheckableExpectedType(expected)) return null;

        // TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
        var actual = TypeInfo.Construct(value).BuiltInType;
        if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK

        if (!IsBuiltInTypeCompatible(expected, actual))
        {
            return new ServiceResult(
                StatusCodes.BadTypeMismatch,
                $"value built-in type {actual} is not compatible with node data type {expected}");
        }

        return null;
    }

    /// <summary>
    ///     Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
    ///     Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
    ///     numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
    ///     <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
    ///     <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
    ///     mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
    /// </summary>
    private static bool IsBuiltInTypeCompatible(BuiltInType expected, BuiltInType actual)
    {
        if (expected == actual) return true;
        // Any side a wildcard/unclassified ⇒ defer (compatible) — the caller already filtered most of these.
        if (expected is BuiltInType.Variant or BuiltInType.Null) return true;
        if (actual is BuiltInType.Variant or BuiltInType.Null) return true;
        // Numeric family widening/narrowing is the SDK's job; treat numeric↔numeric as compatible.
        if (IsNumeric(expected) && IsNumeric(actual)) return true;
        // ByteString and Byte are routinely interchangeable on the wire; don't reject that pairing.
        if ((expected, actual) is (BuiltInType.ByteString, BuiltInType.Byte) or (BuiltInType.Byte, BuiltInType.ByteString)) return true;
        return false;
    }

    /// <summary>True for the OPC UA numeric built-in types (the integer + floating families).</summary>
    private static bool IsNumeric(BuiltInType t) => t is
        BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 or
        BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
        BuiltInType.Float or BuiltInType.Double;

    /// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
    /// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
    /// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
    /// deferred to the SDK so a coercible write (Int32→Enumeration, etc.) is never false-rejected.</summary>
    private static bool IsCheckableExpectedType(BuiltInType t) =>
        IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;

    /// <summary>
    ///     Pure decision for the write-outcome self-correction: revert the node to its pre-write value ONLY on
    ///     a FAILED outcome AND only while the node still holds the optimistic value. The
    ///     still-holds-the-optimistic-value check is what stops a revert from clobbering a fresh driver poll
    ///     that already republished the confirmed register value over the optimistic write. Pure (value
    ///     comparison via <see cref="object.Equals(object?, object?)"/>) so it is unit-testable without an SDK
    ///     server.
    /// </summary>
    /// <param name="outcome">The device-write outcome routed back by the gateway.</param>
    /// <param name="currentNodeValue">The node's current Value at revert time.</param>
    /// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
    /// <returns><c>true</c> to revert (failed outcome and node unchanged since the optimistic write);
    /// <c>false</c> on success, or when a poll has already moved the node off the optimistic value.</returns>
    internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
        !outcome.Success && Equals(currentNodeValue, optimisticValue);

    /// <summary>
    ///     Off-Lock continuation body for the write-outcome self-correction: re-takes <c>Lock</c> and, when
    ///     <see cref="ShouldRevert"/> says so, surfaces the device-write rejection to subscribed clients in three
    ///     ways, then leaves the node holding its captured pre-write value/status (same node-update shape as
    ///     <see cref="WriteValue"/>). A no-op when the node was rebuilt/removed in the interim, when the outcome
    ///     succeeded, or when a fresh poll already moved the node off the optimistic value.
    ///     <list type="number">
    ///         <item>
    ///             <b>Item B — Bad-quality blip.</b> Before restoring the prior value, the node is published
    ///             once holding the (still-applied) optimistic value but with StatusCode
    ///             <c>BadDeviceFailure</c>, then published again with the prior value/status restored. A
    ///             value-subscribed client therefore sees the rejection (a Bad quality) rather than the value
    ///             silently snapping back. <b>Caveat:</b> the two <c>ClearChangeMasks</c> calls happen
    ///             back-to-back within one server publishing interval, so the SDK's monitored-item queue may
    ///             COALESCE them — a slow / queue-size-1 subscriber can see only the final restored value and
    ///             miss the transient Bad blip. The blip is a best-effort live signal; the
    ///             <see cref="AuditWriteUpdateEventState"/> raised below (Item C) is the reliable, durable record
    ///             of the rejected write.
    ///         </item>
    ///         <item>
    ///             <b>Item C — AuditWriteUpdateEvent.</b> A Part 8 <see cref="AuditWriteUpdateEventState"/> is
    ///             raised through the SDK <see cref="CustomNodeManager2.Server"/> so an auditing client gets a
    ///             durable record of the rejected write (OldValue = prior, NewValue = the attempted optimistic
    ///             value, the boolean AuditEvent Status = false ⇒ failed, and the device's
    ///             <paramref name="outcome"/>.Reason carried in the event Message). It is reported
    ///             OUTSIDE <c>Lock</c> (the event-state is built under Lock, then reported after release) to keep
    ///             the lock hold short and avoid any re-entrancy risk via the server's event path. Auditing being
    ///             disabled / no subscribers is handled gracefully — <c>ReportEvent</c> simply reaches no monitored
    ///             items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
    ///         </item>
    ///     </list>
    ///     Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
    ///     and the SDK trace captures any audit-report failure.
    ///     <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
    ///     against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
    /// </summary>
    /// <param name="nodeId">The string id of the written variable node.</param>
    /// <param name="outcome">The device-write outcome routed back by the gateway.</param>
    /// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
    /// <param name="priorValue">The node's real value captured before the optimistic write.</param>
    /// <param name="priorStatus">The node's real status captured before the optimistic write.</param>
    /// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
    /// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
    internal void RevertOptimisticWriteIfNeeded(
        string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
        string? clientUserId, AddressSpaceRealm realm)
    {
        // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
        AuditWriteUpdateEventState? auditEvent = null;

        var key = MapKey(realm, nodeId);
        lock (Lock)
        {
            if (!_variables.TryGetValue(key, out var variable)) return;          // rebuilt/removed ⇒ no-op
            if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on

            // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
            // the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
            // node-update shape). See the method remarks for the queue-coalescing caveat.
            variable.StatusCode = StatusCodes.BadDeviceFailure;
            variable.Timestamp = DateTime.UtcNow;
            variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip

            variable.Value = priorValue;
            variable.StatusCode = priorStatus;
            variable.Timestamp = DateTime.UtcNow;
            variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior

            // Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet.
            // Guarded like ReportAuditEvent: the revert above has ALREADY happened, so a surprise from the SDK
            // child-population path (e.g. a SetChildValue on a null OldValue/NewValue) must be swallowed + logged,
            // never thrown out of this fire-and-forget continuation.
            try
            {
                auditEvent = BuildWriteFailureAuditEvent(variable, outcome, optimisticValue, priorValue, clientUserId);
            }
            catch (Exception ex)
            {
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
                Utils.LogError(ex, "OtOpcUaNodeManager: failed to build AuditWriteUpdateEvent for {0}", nodeId);
#pragma warning restore CS0618
                auditEvent = null;
            }
        }

        // Report OUTSIDE Lock — keeps the hold short and sidesteps any re-entrancy through the server event path.
        if (auditEvent is not null) ReportAuditEvent(auditEvent);
    }

    /// <summary>
    ///     Item C — build (but do not report) a Part 8 <see cref="AuditWriteUpdateEventState"/> recording a
    ///     rejected device write on <paramref name="variable"/>. The caller holds <c>Lock</c> (the node is read
    ///     here); reporting happens after Lock release. The standard AuditEvent envelope (EventId, EventType,
    ///     Time/ReceiveTime, ServerId, Severity, Message, SourceNode/SourceName, Status, ActionTimeStamp) is
    ///     stamped by the SDK's <see cref="AuditEventState.Initialize(ISystemContext, NodeState, EventSeverity,
    ///     LocalizedText, bool, DateTime)"/> helper; this method then fills the write-specific fields
    ///     (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
    /// </summary>
    /// <param name="variable">The written node (the audit event's SourceNode).</param>
    /// <param name="outcome">The failed device-write outcome (its Reason goes into the event Message + Status).</param>
    /// <param name="optimisticValue">The value the client attempted (the audit NewValue).</param>
    /// <param name="priorValue">The node's real pre-write value (the audit OldValue).</param>
    /// <param name="clientUserId">The writing principal's user-id string; null when unknown.</param>
    /// <returns>A populated, unreported <see cref="AuditWriteUpdateEventState"/>.</returns>
    /// <remarks><c>internal</c> (not private) so <c>NodeManagerWriteRevertTests</c> can assert the populated
    /// audit fields at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would
    /// need a subscribed event monitored-item to observe).</remarks>
    internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
        BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
        string? clientUserId)
    {
        var reason = string.IsNullOrEmpty(outcome.Reason) ? "device write rejected" : outcome.Reason!;

        var audit = new AuditWriteUpdateEventState(null);
        // Initialize stamps EventId (fresh GUID bytes), EventType, Time/ReceiveTime, ServerId, Severity, Message,
        // SourceNode/SourceName (from `variable`), Status and ActionTimeStamp. The AuditEvent `Status` field is a
        // PropertyState<bool> (true=succeeded / false=failed), so the failed device write is recorded as `false`;
        // the device's textual Reason is carried in the Message (the StatusCode itself has no audit-field home).
        audit.Initialize(
            SystemContext,
            source: variable,
            severity: EventSeverity.Medium,
            message: new LocalizedText($"Inbound write rejected by device: {reason}"),
            status: false,
            actionTimestamp: DateTime.UtcNow);

        // Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write);
        // OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate
        // child PropertyStates are NOT instantiated by Initialize — SetChildValue lazily creates + sets them
        // (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null).
        audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false);
        audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false);
        audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false);
        audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false);

        // Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the
        // handler); ClientAuditEntryId carries the SDK context's audit entry id when present.
        if (clientUserId is not null) audit.SetChildValue(SystemContext, BrowseNames.ClientUserId, clientUserId, false);
        var auditEntryId = SystemContext.AuditEntryId;
        if (!string.IsNullOrEmpty(auditEntryId))
            audit.SetChildValue(SystemContext, BrowseNames.ClientAuditEntryId, auditEntryId, false);

        return audit;
    }

    /// <summary>
    ///     Item C — report a built audit event through the SDK server, guarding against auditing being disabled
    ///     / no subscribers / a transient server-event-path failure. A failure here MUST NOT break the revert
    ///     that already happened, so it is swallowed and logged to the SDK trace (this node manager has no
    ///     <c>ILogger</c>) rather than propagated. Reported with the node manager's <c>SystemContext</c>, which
    ///     is what the alarm event path uses too.
    /// </summary>
    /// <param name="auditEvent">The populated audit event-state to report.</param>
    private void ReportAuditEvent(AuditWriteUpdateEventState auditEvent)
    {
        try
        {
            Server.ReportEvent(SystemContext, auditEvent);
        }
        catch (Exception ex)
        {
            // Auditing disabled / no monitored items / server shutting down ⇒ ReportEvent may no-op or throw;
            // either way the revert already stands. Surface a recurring failure in the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report AuditWriteUpdateEvent for {0}", auditEvent.SourceNode?.Value);
#pragma warning restore CS0618
        }
    }

    /// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
    /// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
    /// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
    private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
    {
        "OffNormalAlarm" => new OffNormalAlarmState(parent),
        "DiscreteAlarm" => new DiscreteAlarmState(parent),
        // "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
        // to populate, so the limit subtypes would carry unset High/Low children.
        _ => new AlarmConditionState(parent),
    };

    /// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
    /// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
    /// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
    /// <remarks>
    ///     Phase C: when a real historian is wired at promotion time (the source is NOT the
    ///     <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
    ///     <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
    ///     its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
    ///     accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at
    ///     the moment of promotion: the Host wires the source at <c>StartAsync</c> — BEFORE any deployment
    ///     materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder
    ///     promoted while the source is still Null advertises live-event subscription but NOT event history
    ///     until the next <see cref="RebuildAddressSpace"/> re-promotes it (acceptable, documented). The
    ///     HistoryRead bit + source registration happen inside the same first-time
    ///     <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
    /// </remarks>
    private void EnsureFolderIsEventNotifier(FolderState folder)
    {
        if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
        folder.EventNotifier = EventNotifiers.SubscribeToEvents;
        if (_historianDataSource is not NullHistorianDataSource)
        {
            // A historian is wired: advertise event history on this notifier and register it as a source.
            // The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
            folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
            // Key by the full (namespace-qualified) NodeId string so a Raw and a UNS folder that share the
            // same bare id are distinct sources; the VALUE stays the bare id — that is the historian
            // ReadEventsAsync sourceName (== the equipment id).
            var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
            _eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
        }
        AddRootNotifier(folder);
        folder.ClearChangeMasks(SystemContext, includeChildren: false);
    }

    /// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
    /// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
    private static EventSeverity MapSeverity(int severity) => severity switch
    {
        < 200 => EventSeverity.Low,
        < 400 => EventSeverity.MediumLow,
        < 600 => EventSeverity.Medium,
        < 800 => EventSeverity.MediumHigh,
        _ => EventSeverity.High,
    };

    /// <summary>
    ///     Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
    ///     name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
    ///     Used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
    ///     folder hierarchy. Idempotent: the second call with the same id returns the cached
    ///     folder so adding child variables under it still works.
    /// </summary>
    /// <param name="folderNodeId">The node identifier of the folder.</param>
    /// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
    /// <param name="displayName">The display name of the folder.</param>
    public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);
        EnsureAddressSpaceCreated();

        var ns = NamespaceIndexForRealm(realm);
        var key = MapKey(realm, folderNodeId);
        if (_folders.ContainsKey(key)) return;

        lock (Lock)
        {
            if (_folders.ContainsKey(key)) return;

            var parent = ResolveParentFolder(parentNodeId, realm);
            var folder = new FolderState(parent)
            {
                NodeId = new NodeId(folderNodeId, ns),
                BrowseName = new QualifiedName(folderNodeId, ns),
                DisplayName = displayName,
                EventNotifier = EventNotifiers.None,
                TypeDefinitionId = ObjectTypeIds.FolderType,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
            };
            parent.AddChild(folder);
            AddPredefinedNode(SystemContext, folder);
            _folders[key] = folder;
        }
    }

    /// <summary>
    ///     Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
    ///     subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
    ///     UNS Area / Line rename. <see cref="EnsureFolder"/> early-returns for an
    ///     already-present folder id and never touches an existing folder's display name, so a
    ///     rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
    ///     cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
    ///     friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
    ///     <c>ClearChangeMasks</c> is called so subscribed clients see the new DisplayName immediately,
    ///     mirroring the <see cref="UpdateTagAttributes"/> surgical path. Returns false when the folder id
    ///     is unknown (caller falls back to a full rebuild).
    /// </summary>
    /// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
    /// <param name="displayName">The new display name to apply.</param>
    /// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
    public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);
        lock (Lock)
        {
            if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false;
            folder.DisplayName = displayName;
            folder.ClearChangeMasks(SystemContext, includeChildren: false);
            return true;
        }
    }

    /// <summary>
    ///     Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
    ///     <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
    ///     timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
    ///     Idempotent. Materialises equipment-namespace tags so they're browseable before drivers
    ///     issue SubscribeBulk. Note: because of the early <c>_variables.ContainsKey</c> return, a
    ///     re-apply of an EXISTING node with a changed historize intent (e.g. non-historized →
    ///     historized) is silently ignored — a historize-intent change only takes effect after a
    ///     <see cref="RebuildAddressSpace"/> (which the planner triggers on an equipment-tag delta).
    /// </summary>
    /// <param name="variableNodeId">The node identifier of the variable.</param>
    /// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
    /// <param name="displayName">The display name of the variable.</param>
    /// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
    /// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
    /// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
    /// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
    /// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
    /// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
    /// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
    /// <c>HistoryRead</c> access bit OR-ed into both <c>AccessLevel</c> and <c>UserAccessLevel</c>, and the
    /// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
    /// resolves against.</param>
    /// <param name="isArray">Phase 4c: when true the node is materialised as a 1-D array
    /// (<c>ValueRank=OneDimension</c> with <c>ArrayDimensions=[arrayLength]</c>); when false (default) it
    /// stays scalar (<c>ValueRank=Scalar</c>). Array elements share the scalar's base
    /// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
    /// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
    /// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
    public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
    {
        ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);
        EnsureAddressSpaceCreated();

        var ns = NamespaceIndexForRealm(realm);
        var key = MapKey(realm, variableNodeId);
        // If already present, leave it alone (idempotent re-applies).
        if (_variables.ContainsKey(key)) return;

        lock (Lock)
        {
            if (_variables.ContainsKey(key)) return;

            var parent = ResolveParentFolder(parentFolderNodeId, realm);
            // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
            // access bit (on top of the writable composite) so clients can browse + HistoryRead it.
            var historized = historianTagname is not null;
            // The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
            // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
            var access = ComposeAccessLevel(writable, historized);
            var variable = new BaseDataVariableState(parent)
            {
                NodeId = new NodeId(variableNodeId, ns),
                BrowseName = new QualifiedName(variableNodeId, ns),
                DisplayName = displayName,
                TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
                DataType = ResolveBuiltInDataType(dataType),
                ValueRank       = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
                ArrayDimensions = isArray ? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
                AccessLevel = access,
                UserAccessLevel = access,
                Historizing = historized,
                Value = null,
                StatusCode = StatusCodes.BadWaitingForInitialData,
                Timestamp = DateTime.MinValue,
            };
            // A writable equipment tag owns an inbound-write handler. The SDK invokes
            // OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
            // driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
            // rejected by the SDK's own AccessLevel check before it ever reaches a handler.
            if (writable)
            {
                variable.OnWriteValue = OnEquipmentTagWrite;
            }
            parent.AddChild(variable);
            AddPredefinedNode(SystemContext, variable);
            _variables[key] = variable;
            // Phase C: register the resolved historian tagname so the HistoryRead override can map this
            // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
            // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
            if (historized) _historizedTagnames[key] = historianTagname!;
        }
    }

    /// <summary>Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
    /// Historizing flags — the single source of truth shared by <see cref="EnsureVariable"/> (node
    /// creation) and <see cref="UpdateTagAttributes"/> (F10b surgical in-place update). The SDK exposes
    /// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite,
    /// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back.</summary>
    private static byte ComposeAccessLevel(bool writable, bool historized)
    {
        byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
        if (historized) access = (byte)(access | AccessLevels.HistoryRead);
        return access;
    }

    /// <summary>F10b surgical counterpart of <see cref="EnsureVariable"/>: update an EXISTING tag variable's
    /// Writable (AccessLevel + <see cref="OnEquipmentTagWrite"/> handler), Historizing (+ historian-tagname
    /// binding), and presentation shape (<paramref name="dataType"/> → <c>DataType</c>;
    /// <paramref name="isArray"/>/<paramref name="arrayLength"/> → <c>ValueRank</c> + <c>ArrayDimensions</c>)
    /// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
    /// Returns false when the node id is unknown (caller falls back to a full rebuild).
    /// <para>
    ///     When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
    ///     happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (Value=null) so no value
    ///     still typed to the OLD DataType is exposed between this update and the driver's next publish — the
    ///     same fresh-node state <see cref="EnsureVariable"/> creates, which closes the "brief value-type
    ///     mismatch" window. (2) A Part 3 <c>GeneralModelChangeEvent</c> (verb=<c>DataTypeChanged</c>) is
    ///     raised from the Server object so model-aware clients re-read the node definition. A Writable /
    ///     Historizing-only change (DataType + array-ness unchanged) skips both — its value stays valid and
    ///     there is no model change to report (preserves the original surgical behaviour exactly).
    /// </para>
    /// <para>
    ///     The model-change event is built under <c>Lock</c> but reported AFTER the lock is released —
    ///     mirroring <see cref="RevertOptimisticWriteIfNeeded"/>: <c>Server.ReportEvent</c> re-enters the
    ///     server's own subscription/event path, so holding the node <c>Lock</c> across it risks a
    ///     lock-order inversion.
    /// </para></summary>
    /// <param name="variableNodeId">The folder-scoped node id of the variable to update in place.</param>
    /// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
    /// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
    /// <param name="dataType">The OPC UA built-in data type name to apply (e.g. "Boolean", "Int32", "Float").</param>
    /// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
    /// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
    /// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
    public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
        ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType)
        var key = MapKey(realm, variableNodeId);
        BaseDataVariableState? shapeChangedNode = null;
        lock (Lock)
        {
            if (!_variables.TryGetValue(key, out var v)) return false;
            var historized = historianTagname is not null;
            var access = ComposeAccessLevel(writable, historized);
            v.AccessLevel = access;
            v.UserAccessLevel = access;
            v.Historizing = historized;
            v.OnWriteValue = writable ? OnEquipmentTagWrite : null;
            if (historized) _historizedTagnames[key] = historianTagname!;
            else _historizedTagnames.TryRemove(key, out _);

            // Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
            // from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
            // reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
            var newDataType = ResolveBuiltInDataType(dataType);
            var newValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar;
            if (v.DataType != newDataType || v.ValueRank != newValueRank || ArrayLengthDiffers(v, isArray, arrayLength))
            {
                v.DataType = newDataType;
                v.ValueRank = newValueRank;
                v.ArrayDimensions = isArray
                    ? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u }))
                    : null;
                // Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
                // never reads a value whose runtime type contradicts the just-changed DataType.
                v.Value = null;
                v.StatusCode = StatusCodes.BadWaitingForInitialData;
                v.Timestamp = DateTime.MinValue;
                shapeChangedNode = v;
            }

            v.ClearChangeMasks(SystemContext, includeChildren: false);
        }
        // Report OUTSIDE Lock (see the method remarks) — only when the shape actually changed.
        if (shapeChangedNode is not null) ReportNodeShapeChangedEvent(shapeChangedNode);
        return true;
    }

    /// <summary>True when an array target's requested 1-D length differs from the node's current one. This is
    /// the array-to-array length-edit case: the caller's <c>ValueRank != newValueRank</c> check already catches
    /// any scalar↔array transition, so this runs to decide whether an already-array node's dimension changed
    /// (and treats absent/empty current dimensions as "differs", forcing the in-place update).</summary>
    private static bool ArrayLengthDiffers(BaseDataVariableState v, bool isArray, uint? arrayLength)
    {
        if (!isArray) return false;                       // scalar target ⇒ ValueRank check owns the diff
        if (v.ArrayDimensions is not { Count: > 0 }) return true; // no current dimension ⇒ shape differs
        return v.ArrayDimensions[0] != (arrayLength ?? 0u);
    }

    /// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that
    /// <paramref name="variable"/>'s definition changed (DataType / ValueRank / ArrayDimensions). Verb =
    /// <c>DataTypeChanged</c> — the model-change verb the SDK exposes for an attribute-shape change (there is
    /// no separate ValueRank verb). <c>internal</c> (not private) so a node-manager test can assert the
    /// populated Changes structure at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c>
    /// dispatch would need a subscribed event monitored-item to observe).</summary>
    /// <param name="variable">The variable whose shape changed.</param>
    /// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
    internal GeneralModelChangeEventState BuildNodeShapeChangedEvent(BaseDataVariableState variable)
    {
        var e = new GeneralModelChangeEventState(null);
        e.Initialize(
            SystemContext,
            source: null,
            severity: EventSeverity.Medium,
            message: new LocalizedText($"Node {variable.NodeId} definition changed (DataType/ValueRank)"));
        // Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
        // to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
        // so conformant clients that filter events by SourceNode still match this one.
        e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
        e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
        var change = new ModelChangeStructureDataType
        {
            Affected = variable.NodeId,
            AffectedType = variable.TypeDefinitionId,
            Verb = (byte)ModelChangeStructureVerbMask.DataTypeChanged,
        };
        // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
        // relies on for its child PropertyStates).
        e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
        return e;
    }

    /// <summary>Report the built <c>GeneralModelChangeEvent</c> through the SDK server, guarding against the
    /// event path being disabled / having no subscribers / a transient failure — a surprise here MUST NOT
    /// break the in-place update that already happened (mirrors <see cref="ReportAuditEvent"/>).</summary>
    /// <param name="variable">The variable whose shape changed.</param>
    private void ReportNodeShapeChangedEvent(BaseDataVariableState variable)
    {
        try
        {
            Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
        }
        catch (Exception ex)
        {
            // Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
            // no-op or throw; either way the in-place update already stands. Log to the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {0}", variable.NodeId);
#pragma warning restore CS0618
        }
    }

    /// <summary>
    ///     Emit a Part 3 <c>GeneralModelChangeEvent</c> (verb <c>NodeAdded</c>) announcing that one or more
    ///     nodes were added UNDER <paramref name="affectedNodeId"/> at runtime — so already-connected,
    ///     model-aware OPC UA clients re-browse the affected node and discover the new children. This is the
    ///     runtime-add counterpart of the shape-changed reporter (<see cref="ReportNodeShapeChangedEvent"/>):
    ///     when a driver discovers FixedTree nodes AFTER the server is up and they are materialised into the
    ///     served Equipment address space (Tasks 4/5), an attribute notification alone is invisible to a
    ///     subscribed client — only a model-change event tells it the address space grew.
    ///     <para>
    ///         The event is built under <c>Lock</c> but reported AFTER the lock is released, mirroring
    ///         <see cref="ReportNodeShapeChangedEvent"/> / <see cref="RevertOptimisticWriteIfNeeded"/>:
    ///         <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding the node
    ///         <c>Lock</c> across it risks a lock-order inversion with a client that has event subscriptions.
    ///         The report is wrapped in try/catch so it is tolerant when eventing is disabled / there are no
    ///         monitored items / the server is shutting down — the same swallow-and-log tolerance as the
    ///         write-revert path (<see cref="ReportAuditEvent"/>). The nodes have already been materialised, so
    ///         a surprise from the event path MUST NOT propagate out of this announcement.
    ///     </para>
    /// </summary>
    /// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
    public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
        GeneralModelChangeEventState e;
        lock (Lock)
        {
            e = BuildNodesAddedModelChange(affectedNodeId, realm);
        }
        // Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding
        // Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
        try
        {
            Server.ReportEvent(SystemContext, e);
        }
        catch (Exception ex)
        {
            // Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
            // no-op or throw; either way the node add already stands. Log to the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId);
#pragma warning restore CS0618
        }
    }

    /// <inheritdoc cref="IOpcUaAddressSpaceSink.AddReference" />
    public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
                             string targetNodeId, AddressSpaceRealm targetRealm,
                             string referenceType = "Organizes")
    {
        ArgumentException.ThrowIfNullOrEmpty(sourceNodeId);
        ArgumentException.ThrowIfNullOrEmpty(targetNodeId);
        EnsureAddressSpaceCreated();

        var refType = ResolveReferenceType(referenceType);
        var sourceKey = MapKey(sourceRealm, sourceNodeId);
        var targetKey = MapKey(targetRealm, targetNodeId);
        lock (Lock)
        {
            var source = ResolveNodeState(sourceKey);
            var target = ResolveNodeState(targetKey);
            // A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a
            // concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire.
            if (source is null || target is null)
            {
#pragma warning disable CS0618 // Utils.LogWarning is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
                Utils.LogWarning(
                    "OtOpcUaNodeManager: AddReference({0}) skipped — {1} node missing (source '{2}' present={3}, target '{4}' present={5}).",
                    refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null);
#pragma warning restore CS0618
                return;
            }

            // Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it
            // bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse
            // resolves both ways. Only ClearChangeMasks a side we actually mutated.
            var sourceChanged = false;
            var targetChanged = false;
            if (!source.ReferenceExists(refType, isInverse: false, target.NodeId))
            {
                source.AddReference(refType, isInverse: false, target.NodeId);
                sourceChanged = true;
            }
            if (!target.ReferenceExists(refType, isInverse: true, source.NodeId))
            {
                target.AddReference(refType, isInverse: true, source.NodeId);
                targetChanged = true;
            }
            if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
            if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
        }
    }

    /// <summary>Resolve a materialised node (variable, folder, or alarm condition) by its full
    /// (namespace-qualified) map key, or null when no node is registered under that key. Used by
    /// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
    /// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
    /// <returns>The node state, or null when unknown.</returns>
    private NodeState? ResolveNodeState(string key)
    {
        if (_variables.TryGetValue(key, out var variable)) return variable;
        if (_folders.TryGetValue(key, out var folder)) return folder;
        if (_alarmConditions.TryGetValue(key, out var condition)) return condition;
        return null;
    }

    /// <summary>Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
    /// id) using the manager's own <c>SystemContext</c>, or an empty list when the node is unknown. Exposed
    /// so <c>SdkAddressSpaceSinkTests</c> can assert the cross-tree <c>Organizes</c> edge
    /// <see cref="AddReference"/> wires without needing the (protected) SDK system context.</summary>
    /// <param name="nodeId">The node's <c>s=</c> id.</param>
    /// <param name="realm">The realm the node lives in.</param>
    /// <returns>The node's references, or an empty list when the node is not registered.</returns>
    internal IReadOnlyList<IReference> GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        lock (Lock)
        {
            var node = ResolveNodeState(MapKey(realm, nodeId));
            if (node is null) return Array.Empty<IReference>();
            var list = new List<IReference>();
            node.GetReferences(SystemContext, list);
            return list;
        }
    }

    /// <summary>Map a hierarchical reference-type name to its SDK <see cref="NodeId"/>. Unknown names fall
    /// back to <see cref="ReferenceTypeIds.Organizes"/> (the UNS→Raw cross-tree link).</summary>
    /// <param name="referenceType">The reference-type name (e.g. "Organizes").</param>
    /// <returns>The reference-type NodeId.</returns>
    private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
    {
        "HasComponent" => ReferenceTypeIds.HasComponent,
        "HasProperty" => ReferenceTypeIds.HasProperty,
        _ => ReferenceTypeIds.Organizes,
    };

    /// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that nodes were
    /// added under <paramref name="affectedNodeId"/>. MIRRORS <see cref="BuildNodeShapeChangedEvent"/> exactly —
    /// the only differences are <c>Verb = NodeAdded</c> (vs <c>DataTypeChanged</c>) and <c>Affected</c> = the
    /// passed parent node id (vs the variable's own NodeId). <c>AffectedType</c> carries the affected node's
    /// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the
    /// variable), defaulting to <see cref="NodeId.Null"/> when the id is not (yet) materialised. <c>internal</c>
    /// (not private) so a node-manager test can assert the populated Changes structure at the nearest
    /// deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would need a subscribed event
    /// monitored-item to observe).</summary>
    /// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
    /// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
    internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
        var e = new GeneralModelChangeEventState(null);
        e.Initialize(
            SystemContext,
            source: null,
            severity: EventSeverity.Medium,
            message: new LocalizedText($"Nodes added under {affected}"));
        // Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
        // to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
        // so conformant clients that filter events by SourceNode still match this one.
        e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
        e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
        var change = new ModelChangeStructureDataType
        {
            Affected = affected,
            // The affected node is the parent the children were added under; carry its TypeDefinition (a Folder
            // for an equipment parent) just as the shape-changed builder carries the variable's. Null when the
            // id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless.
            AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
            Verb = (byte)ModelChangeStructureVerbMask.NodeAdded,
        };
        // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
        // relies on for its child PropertyStates).
        e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
        return e;
    }

    /// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that the node
    /// at <paramref name="affectedNodeId"/> was DELETED. Mirrors <see cref="BuildNodesAddedModelChange"/>
    /// exactly — the only differences are <c>Verb = NodeDeleted</c> and that the affected node has already
    /// been dropped from the maps, so <c>AffectedType</c> resolves to <see cref="NodeId.Null"/> (a valid Part 3
    /// "type not applicable"; clients re-browse the parent regardless). <c>internal</c> so a node-manager test
    /// can assert the populated Changes structure at the nearest deterministic seam.</summary>
    /// <param name="affectedNodeId">The node id of the deleted node.</param>
    /// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
    internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
        var e = new GeneralModelChangeEventState(null);
        e.Initialize(
            SystemContext,
            source: null,
            severity: EventSeverity.Medium,
            message: new LocalizedText($"Node deleted: {affected}"));
        // Part 3 §8.7.4: emitted by the Server object — set SourceNode/SourceName to Server explicitly
        // (mirrors BuildNodesAddedModelChange).
        e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
        e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
        var change = new ModelChangeStructureDataType
        {
            Affected = affected,
            // The node is already gone from the maps, so its TypeDefinition is not applicable (Null).
            AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
            Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
        };
        e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
        return e;
    }

    /// <summary>Report a pre-built <see cref="GeneralModelChangeEventState"/> OUTSIDE <c>Lock</c> —
    /// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding <c>Lock</c>
    /// across it risks a lock-order inversion (mirrors <see cref="RaiseNodesAddedModelChange"/> /
    /// <c>ReportNodeShapeChangedEvent</c>). Tolerant: swallow-and-log when eventing is disabled / there are no
    /// monitored items / the server is shutting down — the node mutation already stands.</summary>
    /// <param name="e">The pre-built event to report.</param>
    /// <param name="affectedNodeId">The affected node id (for the diagnostic only).</param>
    private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
    {
        try
        {
            Server.ReportEvent(SystemContext, e);
        }
        catch (Exception ex)
        {
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {0}", affectedNodeId);
#pragma warning restore CS0618
        }
    }

    /// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
    /// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
    /// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
    private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
    {
        var key = MapKey(realm, nodeId);
        if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId;
        if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId;
        return NodeId.Null;
    }

    /// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
    /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
    /// (matches CreateVariable's default for lazy-created nodes).</summary>
    private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
    {
        "Boolean" => DataTypeIds.Boolean,
        "SByte" => DataTypeIds.SByte,
        "Byte" => DataTypeIds.Byte,
        "Int16" => DataTypeIds.Int16,
        "UInt16" => DataTypeIds.UInt16,
        "Int32" => DataTypeIds.Int32,
        "UInt32" => DataTypeIds.UInt32,
        "Int64" => DataTypeIds.Int64,
        "UInt64" => DataTypeIds.UInt64,
        "Float" => DataTypeIds.Float,
        "Double" => DataTypeIds.Double,
        "String" => DataTypeIds.String,
        "DateTime" => DataTypeIds.DateTime,
        _ => DataTypeIds.BaseDataType,
    };

    /// <summary>Clear every registered variable + folder from the address space. AddressSpaceApplier
    /// calls this when Equipment/Alarm topology changes; the populator then re-adds via
    /// EnsureFolder + WriteValue on the next pass.</summary>
    public void RebuildAddressSpace()
    {
        lock (Lock)
        {
            foreach (var v in _variables.Values)
            {
                v.Parent?.RemoveChild(v);
                PredefinedNodes?.Remove(v.NodeId);
            }
            _variables.Clear();
            // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
            _historizedTagnames.Clear();

            foreach (var alarm in _alarmConditions.Values)
            {
                alarm.Parent?.RemoveChild(alarm);
                PredefinedNodes?.Remove(alarm.NodeId);
            }
            // v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
            // BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
            // rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
            // detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
            // tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
            foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
                UnwireAlarmNotifiers(alarmKey);

            _alarmConditions.Clear();
            // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
            // re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
            _nativeAlarmNodeIds.Clear();

            foreach (var f in _folders.Values)
            {
                f.Parent?.RemoveChild(f);
                PredefinedNodes?.Remove(f.NodeId);
            }
            _folders.Clear();

            // Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
            // guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
            // object. RemoveRootNotifier just severs that link, so its order relative to the folder
            // teardown above doesn't matter — but it must run under this same Lock.
            foreach (var folder in _notifierFolders.Values)
            {
                RemoveRootNotifier(folder);
            }

            // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
            // equipment folders to event notifiers.
            _notifierFolders.Clear();
            // Phase C: drop the event-history source registrations alongside the notifier folders
            // they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
            _eventNotifierSources.Clear();
        }
    }

    /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
    public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
        EnsureAddressSpaceCreated();

        var key = MapKey(realm, variableNodeId);
        GeneralModelChangeEventState e;
        lock (Lock)
        {
            // Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
            // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
            // teardown inside RebuildAddressSpace, scoped to this one id.
            if (!_variables.TryRemove(key, out var variable)) return false;
            variable.Parent?.RemoveChild(variable);
            PredefinedNodes?.Remove(variable.NodeId);
            // Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
            _historizedTagnames.TryRemove(key, out _);
            e = BuildNodesRemovedModelChange(variableNodeId, realm);
        }
        ReportModelChangeOutsideLock(e, variableNodeId);
        return true;
    }

    /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
    public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        EnsureAddressSpaceCreated();

        var key = MapKey(realm, alarmNodeId);
        GeneralModelChangeEventState e;
        lock (Lock)
        {
            // Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
            // RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
            // promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
            // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
            // equipment goes).
            if (!_alarmConditions.TryRemove(key, out var condition)) return false;
            // v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
            // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
            // this they would keep a dangling inverse-notifier entry to the removed condition.
            UnwireAlarmNotifiers(key);
            condition.Parent?.RemoveChild(condition);
            PredefinedNodes?.Remove(condition.NodeId);
            _nativeAlarmNodeIds.Remove(key);
            e = BuildNodesRemovedModelChange(alarmNodeId, realm);
        }
        ReportModelChangeOutsideLock(e, alarmNodeId);
        return true;
    }

    /// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
    public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
    {
        ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
        EnsureAddressSpaceCreated();

        var equipKey = MapKey(realm, equipmentNodeId);
        GeneralModelChangeEventState e;
        lock (Lock)
        {
            // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
            if (!_folders.ContainsKey(equipKey)) return false;

            // Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s=<equipmentId>"). Every
            // descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (sub-folders,
            // variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a
            // node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each
            // map's in-scope keys first (ToList) so we don't mutate while enumerating.
            var prefix = equipKey + "/";
            bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal);

            // Value variables (+ their historized-tagname registrations).
            foreach (var id in _variables.Keys.Where(InScope).ToList())
            {
                if (_variables.TryRemove(id, out var v))
                {
                    v.Parent?.RemoveChild(v);
                    PredefinedNodes?.Remove(v.NodeId);
                }
                _historizedTagnames.TryRemove(id, out _);
            }

            // Part 9 condition nodes (+ their native flags).
            foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
            {
                if (_alarmConditions.TryRemove(id, out var c))
                {
                    c.Parent?.RemoveChild(c);
                    PredefinedNodes?.Remove(c.NodeId);
                }
                _nativeAlarmNodeIds.Remove(id);
            }

            // v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
            // UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
            // event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
            // else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
            // condition's own scoped removal — if its raw tag is also removed this apply — runs later via
            // RemoveAlarmConditionNode; this only reaches surviving conditions.)
            foreach (var wiring in _alarmNotifierWiring.Values)
            {
                foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
                {
                    wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
                    wiring.Folders.Remove(folder);
                }
            }

            // Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
            // every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
            // alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
            // _notifierFolders is keyed by the folder's NodeId; match InScope on its full (ns-qualified) string.
            foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList())
            {
                RemoveRootNotifier(kvp.Value);
                _notifierFolders.Remove(kvp.Key);
            }
            // Drop the event-history source registrations for the subtree (keyed by the folder id string).
            foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
                _eventNotifierSources.TryRemove(src, out _);

            // Sub-folders + the equipment folder itself (children already detached above).
            foreach (var id in _folders.Keys.Where(InScope).ToList())
            {
                if (_folders.TryRemove(id, out var f))
                {
                    f.Parent?.RemoveChild(f);
                    PredefinedNodes?.Remove(f.NodeId);
                }
            }

            e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
        }
        ReportModelChangeOutsideLock(e, equipmentNodeId);
        return true;
    }

    private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
    {
        EnsureAddressSpaceCreated();
        if (string.IsNullOrEmpty(parentNodeId)) return _root!;
        // A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment
        // folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root.
        return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!;
    }

    /// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
    /// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
    /// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
    /// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
    /// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
    /// If one is invoked before the server has started (a sink wired or a publish replayed before
    /// <c>StartAsync</c> completes), <c>_root</c> is null and the dereference would NRE; throw a legible
    /// <see cref="InvalidOperationException"/> instead. Happy-path behaviour is unchanged.</summary>
    /// <exception cref="InvalidOperationException">When the address space has not been created yet.</exception>
    private void EnsureAddressSpaceCreated()
    {
        if (_root is null)
        {
            throw new InvalidOperationException(
                "OPC UA address space has not been created yet (server not started).");
        }
    }

    // ---------------------------------------------------------------------------------------------
    // Phase C — OPC UA HistoryRead over historized variable nodes.
    //
    // The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
    // it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
    // that carry the HistoryRead access bit), validates the timestamp args, handles
    // `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
    // protected virtuals below. We override all four arms: the three variable-history virtuals
    // (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
    // receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
    // handle.Index is the original index into the service-level results/errors lists, seeded by the
    // base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
    // we don't recognise stays "unsupported" by default.
    //
    // NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
    // block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
    // address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
    // backend throw) never throws out of the batch.
    // ---------------------------------------------------------------------------------------------

    /// <inheritdoc />
    protected override void HistoryReadRawModified(
        ServerSystemContext context,
        ReadRawModifiedDetails details,
        TimestampsToReturn timestampsToReturn,
        IList<HistoryReadValueId> nodesToRead,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        var session = context.OperationContext?.Session;
        WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
        {
            using var deadline = CreateDeadline();
            var ct = deadline?.Token ?? CancellationToken.None;
            var work = new List<Func<Task>>(nodesToProcess.Count);
            foreach (var handle in nodesToProcess)
            {
                if (details.IsReadModified)
                {
                    // We never serve modified-value history; mark this node unsupported and move on.
                    errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
                    continue;
                }

                work.Add(() => ServeRawPagedAsync(
                    handle, session, nodesToRead, results, errors,
                    details.StartTime, details.EndTime, details.NumValuesPerNode,
                    ct));
            }

            RunBounded(work, HistoryReadBatchParallelism);
        });
    }

    /// <inheritdoc />
    protected override void HistoryReadProcessed(
        ServerSystemContext context,
        ReadProcessedDetails details,
        TimestampsToReturn timestampsToReturn,
        IList<HistoryReadValueId> nodesToRead,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        // OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
        var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
        WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
        {
            using var deadline = CreateDeadline();
            var ct = deadline?.Token ?? CancellationToken.None;
            var work = new List<Func<Task>>(nodesToProcess.Count);
            foreach (var handle in nodesToProcess)
            {
                // AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
                // the base dispatcher). handle.Index is the node's position in that collection.
                var aggregateNodeId = details.AggregateType[handle.Index];
                var aggregate = MapAggregate(aggregateNodeId);
                if (aggregate is null)
                {
                    errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
                    continue;
                }

                // Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
                // NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
                // and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
                // maybe more" signal to page on. Returning the complete aggregate result with a null CP is
                // spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
                var agg = aggregate.Value;
                work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
                    tagname,
                    details.StartTime,
                    details.EndTime,
                    interval,
                    agg,
                    token), ct));
            }

            RunBounded(work, HistoryReadBatchParallelism);
        });
    }

    /// <inheritdoc />
    protected override void HistoryReadAtTime(
        ServerSystemContext context,
        ReadAtTimeDetails details,
        TimestampsToReturn timestampsToReturn,
        IList<HistoryReadValueId> nodesToRead,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        // Snapshot the requested timestamps once — the same list is read for every node.
        var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
        WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
        {
            using var deadline = CreateDeadline();
            var ct = deadline?.Token ?? CancellationToken.None;
            var work = new List<Func<Task>>(nodesToProcess.Count);
            foreach (var handle in nodesToProcess)
            {
                work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
                    tagname,
                    timestamps,
                    token), ct));
            }

            RunBounded(work, HistoryReadBatchParallelism);
        });
    }

    /// <inheritdoc />
    protected override void HistoryReadEvents(
        ServerSystemContext context,
        ReadEventDetails details,
        TimestampsToReturn timestampsToReturn,
        IList<HistoryReadValueId> nodesToRead,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        // Snapshot the select clauses once — the same filter projects every node's events.
        var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();

        WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
        {
            using var deadline = CreateDeadline();
            var ct = deadline?.Token ?? CancellationToken.None;
            var work = new List<Func<Task>>(nodesToProcess.Count);
            foreach (var handle in nodesToProcess)
            {
                // Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a
                // bare id resolve to their own source registration.
                if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName))
                {
                    // Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
                    // Set both errors and results explicitly on every bad path — don't rely on the SDK base
                    // pre-seeding results[i], so every path is self-contained and the contract is obvious.
                    errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
                    results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
                    continue;
                }

                var sn = sourceName;
                work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, ct));
            }

            RunBounded(work, HistoryReadBatchParallelism);
        });
    }

    /// <summary>
    ///     Serve one registered event-history notifier handle: reads the source's events over the request
    ///     window, projects them onto the select clauses, and fills the service-level results/errors slots.
    ///     Per-node error isolation matches <see cref="ServeNodeAsync"/> — a backend throw becomes a Bad
    ///     status for THIS node only and never throws out of the batch; a deadline cancellation surfaces
    ///     <c>BadTimeout</c>.
    /// </summary>
    /// <param name="handle">The notifier node handle; <c>handle.Index</c> indexes results/errors.</param>
    /// <param name="sourceName">The registered historian event-source name for this notifier.</param>
    /// <param name="details">The event-read details (window + count cap).</param>
    /// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
    /// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
    /// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
    /// <param name="ct">Per-request deadline token.</param>
    private async Task ServeEventsAsync(
        NodeHandle handle,
        string? sourceName,
        ReadEventDetails details,
        SimpleAttributeOperandCollection selectClauses,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        CancellationToken ct)
    {
        try
        {
            // NOT under the node-manager Lock — awaiting the async source is safe here.
            var sourceResult = await _historianDataSource.ReadEventsAsync(
                sourceName,
                details.StartTime,
                details.EndTime,
                // NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
                // Part 4/11, so translate it to UNBOUNDED (int.MaxValue) here. Passing the int<=0
                // backend-default-cap sentinel instead would silently truncate a "give me everything"
                // events read at the backend default. A positive cap passes through (clamped).
                EventMaxEvents(details.NumValuesPerNode),
                ct).ConfigureAwait(false);

            var historyEvent = ProjectEvents(sourceResult.Events, selectClauses);
            results[handle.Index] = new SdkHistoryReadResult
            {
                // No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
                StatusCode = sourceResult.Events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
                HistoryData = new ExtensionObject(historyEvent),
                // We never issue continuation points — every read returns the full window in one shot.
                ContinuationPoint = null,
            };
            errors[handle.Index] = ServiceResult.Good;
        }
        catch (OperationCanceledException)
        {
            // The per-request deadline elapsed while reading this notifier — BadTimeout for THIS node only.
            errors[handle.Index] = StatusCodes.BadTimeout;
            results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
        }
        catch (Exception ex)
        {
            // One node's backend failure must not throw out of the batch — surface Bad for THIS node
            // only. This manager carries no ILogger, so log via the SDK's static trace (see ServeNodeAsync).
#pragma warning disable CS0618 // Type or member is obsolete
            Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
            errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
            results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
        }
    }

    /// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
    /// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
    /// saturate to <see cref="int.MaxValue"/>.</summary>
    /// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
    /// <returns>The clamped non-negative int.</returns>
    private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;

    /// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
    /// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
    /// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
    /// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
    /// the backend's <c>maxEvents &lt;= 0</c> "use the default cap" sentinel, so a client asking for the
    /// whole window is not silently truncated at the backend default. A positive value passes through
    /// clamped to <see cref="int.MaxValue"/> (mirroring <see cref="ClampToInt"/>).</summary>
    /// <param name="numValuesPerNode">The request's <c>NumValuesPerNode</c> cap (0 ⇒ no limit).</param>
    /// <returns><see cref="int.MaxValue"/> when 0 (unbounded); otherwise the clamped non-negative int.</returns>
    internal static int EventMaxEvents(uint numValuesPerNode) =>
        numValuesPerNode == 0 ? int.MaxValue : ClampToInt(numValuesPerNode);

    /// <summary>
    ///     Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
    ///     one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
    ///     <paramref name="selectClauses"/>' fields in select-clause order (see
    ///     <see cref="ProjectEventField"/>).
    /// </summary>
    /// <param name="events">The historian's event rows.</param>
    /// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
    /// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
    private static HistoryEvent ProjectEvents(
        IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
    {
        var fieldLists = new HistoryEventFieldListCollection(events.Count);
        foreach (var evt in events)
        {
            var fields = new VariantCollection(selectClauses.Count);
            foreach (var operand in selectClauses)
            {
                fields.Add(ProjectEventField(evt, operand));
            }

            fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
        }

        return new HistoryEvent { Events = fieldLists };
    }

    /// <summary>
    ///     Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
    ///     into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
    ///     (case-sensitive, per the OPC UA BaseEventType field names) against the
    ///     <see cref="HistoricalEvent"/> shape:
    ///     <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
    ///     <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
    ///     leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
    ///     <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
    /// </summary>
    /// <param name="evt">The source event row.</param>
    /// <param name="operand">The select-clause operand naming the field to project.</param>
    /// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
    private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
    {
        var leaf = LeafFieldName(operand);
        return leaf switch
        {
            // BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
            "EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
            "SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
            "Time" => new Variant(evt.EventTimeUtc),
            "ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
            "Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
            "Severity" => new Variant(evt.Severity), // UInt16
            // EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
            _ => Variant.Null,
        };
    }

    /// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
    /// or <c>null</c> when the BrowsePath is null/empty.</summary>
    /// <param name="operand">The select-clause operand.</param>
    /// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
    private static string? LeafFieldName(SimpleAttributeOperand operand)
    {
        var path = operand.BrowsePath;
        if (path is null || path.Count == 0) return null;
        return path[^1].Name;
    }

    /// <summary>
    ///     Block-bridge to the historian source for one node handle and project the result onto the
    ///     service-level results/errors slots. Resolves the node's registered historian tagname first —
    ///     a single <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
    ///     to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
    ///     A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
    ///     (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
    ///     bit, but we guard explicitly). The <paramref name="read"/> callback receives the resolved
    ///     tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
    ///     THIS node without throwing out of the batch.
    /// </summary>
    /// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
    /// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
    /// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
    /// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
    /// once the tagname is confirmed present.</param>
    /// <summary>
    ///     Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
    ///     block-bridged ONCE at the arm boundary. <b>Contract:</b> every work item must be fully
    ///     self-contained — it fills its own results/errors slot and NEVER throws (all per-node handlers
    ///     catch every exception, including cancellation, and surface a Bad status for that node only). That
    ///     invariant is what makes the single <c>GetAwaiter().GetResult()</c> safe: <see cref="Task.WhenAll(Task[])"/>
    ///     can never fault, so it cannot throw out of the synchronous SDK override. A degree ≤ 1 (or a
    ///     single item) runs sequentially — no semaphore, no extra tasks.
    /// </summary>
    /// <param name="work">The per-node work items (index-disjoint writes into the shared results/errors).</param>
    /// <param name="degree">Max concurrent work items (<see cref="HistoryReadBatchParallelism"/>).</param>
    private static void RunBounded(IReadOnlyList<Func<Task>> work, int degree)
    {
        if (work.Count == 0) return;
        if (degree <= 1 || work.Count == 1)
        {
            foreach (var item in work)
                item().GetAwaiter().GetResult();
            return;
        }

        using var gate = new SemaphoreSlim(degree);
        var tasks = new Task[work.Count];
        for (var i = 0; i < work.Count; i++)
            tasks[i] = RunGatedAsync(gate, work[i]);

        // Safe: every body is self-contained + never throws, so WhenAll never faults (see contract above).
        Task.WhenAll(tasks).GetAwaiter().GetResult();
    }

    /// <summary>
    ///     Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
    ///     arm waits up to <c>min(HistoryReadDeadline, 5 s)</c> for a slot; on expiry every handle in the
    ///     batch gets <c>BadTooManyOperations</c> (fast-fail under flood, brief waits under mild contention)
    ///     and <paramref name="serve"/> is skipped. On acquisition the arm body runs and the slot is
    ///     released in a <c>finally</c>. Combined with <see cref="HistoryReadBatchParallelism"/> this caps
    ///     total in-flight gateway reads at
    ///     <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>.
    /// </summary>
    /// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
    /// <param name="results">The service-level results list.</param>
    /// <param name="errors">The service-level errors list.</param>
    /// <param name="serve">The arm body to run once a slot is acquired.</param>
    private void WithHistoryReadBatchLimiter(
        List<NodeHandle> nodesToProcess,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        Action serve)
    {
        var gate = GetHistoryReadBatchLimiter();

        // Bounded admission wait: cap the block at 5 s, and never exceed the per-request deadline.
        var waitMs = HistoryReadDeadline > TimeSpan.Zero
            ? Math.Min(HistoryReadDeadline.TotalMilliseconds, 5000.0)
            : 5000.0;

        if (!gate.Wait(TimeSpan.FromMilliseconds(waitMs)))
        {
            foreach (var handle in nodesToProcess)
            {
                errors[handle.Index] = StatusCodes.BadTooManyOperations;
                results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
            }
            return;
        }

        try
        {
            serve();
        }
        finally
        {
            gate.Release();
        }
    }

    /// <summary>
    ///     Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
    ///     <see cref="MaxConcurrentHistoryReadBatches"/> at first use so the Host's post-boot property set is
    ///     respected (the same benign wiring race as <see cref="MaxTieClusterOverfetch"/>).
    /// </summary>
    /// <returns>The shared batch limiter semaphore.</returns>
    private SemaphoreSlim GetHistoryReadBatchLimiter()
    {
        var gate = _historyReadBatchLimiter;
        if (gate is not null) return gate;
        lock (_historyReadBatchLimiterLock)
        {
            return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
        }
    }

    /// <summary>
    ///     Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
    ///     <see cref="CancellationTokenSource"/> that fires after <see cref="HistoryReadDeadline"/>, so a
    ///     single request can never hold an SDK request thread longer than the deadline regardless of node
    ///     count. Returns <c>null</c> when the deadline is non-positive (unbounded — the arm then passes
    ///     <see cref="CancellationToken.None"/>), matching the <c>ServerHistorianOptions</c> warning.
    /// </summary>
    /// <returns>The deadline source, or <c>null</c> for an unbounded (non-positive) deadline.</returns>
    private CancellationTokenSource? CreateDeadline() =>
        HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null;

    /// <summary>Awaits one work item under a semaphore permit, releasing it when the item completes.</summary>
    /// <param name="gate">The concurrency-limiting semaphore.</param>
    /// <param name="work">The per-node work item.</param>
    private static async Task RunGatedAsync(SemaphoreSlim gate, Func<Task> work)
    {
        await gate.WaitAsync().ConfigureAwait(false);
        try
        {
            await work().ConfigureAwait(false);
        }
        finally
        {
            gate.Release();
        }
    }

    private async Task ServeNodeAsync(
        NodeHandle handle,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        Func<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>> read,
        CancellationToken ct)
    {
        if (!TryGetHistorizedTagname(handle.NodeId, out var tagname))
        {
            // Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
            // status, but set it explicitly so the contract is local + obvious.)
            errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
            return;
        }

        try
        {
            // HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so awaiting
            // the async source here is safe and won't freeze the address space.
            var sourceResult = await read(HistorianDataSource, tagname!, ct).ConfigureAwait(false);
            var historyData = ToHistoryData(sourceResult);

            results[handle.Index] = new SdkHistoryReadResult
            {
                // No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
                StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
                HistoryData = new ExtensionObject(historyData),
                // Single-shot arms (Processed / AtTime) never page — the backend returns the complete
                // result in one read (no client count cap to detect a "full page" against), so no
                // continuation point. Raw pages via ServeRawPaged, not this helper.
                ContinuationPoint = null,
            };
            errors[handle.Index] = ServiceResult.Good;
        }
        catch (OperationCanceledException)
        {
            // The per-request deadline elapsed while this node was in flight — surface BadTimeout for
            // THIS node only (a spec-conformant history-read status), ahead of the generic catch.
            errors[handle.Index] = StatusCodes.BadTimeout;
            results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
        }
        catch (Exception ex)
        {
            // One node's backend failure (throw / timeout) must not throw out of the batch — surface a
            // Bad status for THIS node only. This CustomNodeManager2 carries no ILogger (see
            // ReportConditionEvent), so log through the SDK's static trace rather than swallowing silently.
            // Utils.LogError is [Obsolete] in 1.5.378 (favours an ITelemetryContext this manager doesn't
            // wire) — suppress the deprecation, matching the existing pattern.
#pragma warning disable CS0618 // Type or member is obsolete
            Utils.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
            errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
        }
    }

    /// <summary>
    ///     Serve one historized variable handle for a HistoryRead-Raw request WITH server-side
    ///     continuation-point paging. The single-shot Wonderware backend does not page, so paging is
    ///     synthesised time-based:
    ///     <list type="bullet">
    ///       <item><b>Fresh read</b> (no inbound continuation point): read the window from
    ///         <c>details.StartTime</c> to <paramref name="endUtc"/> capped at
    ///         <paramref name="numValuesPerNode"/>. If the page comes back FULL (exactly the cap, and the
    ///         cap is &gt; 0), store a resume cursor and emit a continuation point.</item>
    ///       <item><b>Resume read</b> (inbound continuation point present): take the stored cursor, read
    ///         the next page from the boundary forward, trim already-emitted boundary ties, and emit a
    ///         FRESH continuation point only if THIS page is also full — else null (done).</item>
    ///     </list>
    ///     The resume cursor is tie-safe (see <see cref="HistoryPaging.ComputeResumeCursor"/> /
    ///     <see cref="HistoryPaging.TrimBoundaryDuplicates"/>): the next page resumes from the boundary
    ///     timestamp INCLUSIVE and drops the head ties already returned, so samples sharing the boundary
    ///     SourceTimestamp are neither duplicated nor skipped. Continuation points live in
    ///     <see cref="HistoryContinuationStore"/> — session-bound + capped in production. Per-node error
    ///     isolation matches <see cref="ServeNode"/>: a backend throw / an unknown continuation point
    ///     becomes a Bad status for THIS node only and never throws out of the batch.
    /// </summary>
    /// <param name="handle">The pre-filtered node handle; <c>handle.Index</c> indexes results/errors.</param>
    /// <param name="session">The session the read runs under (null on the session-less in-process path).</param>
    /// <param name="nodesToRead">The per-node read list; <c>nodesToRead[handle.Index].ContinuationPoint</c>
    ///     carries the inbound continuation point (non-null ⇒ a resume read).</param>
    /// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
    /// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
    /// <param name="startTimeUtc">The request window's (inclusive) lower bound, used for a fresh read.</param>
    /// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
    /// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "all values, no paging".</param>
    private async Task ServeRawPagedAsync(
        NodeHandle handle,
        ISession? session,
        IList<HistoryReadValueId> nodesToRead,
        IList<SdkHistoryReadResult> results,
        IList<ServiceResult> errors,
        DateTime startTimeUtc,
        DateTime endUtc,
        uint numValuesPerNode,
        CancellationToken ct)
    {
        var inboundCp = nodesToRead[handle.Index].ContinuationPoint;

        try
        {
            DateTime startUtc;
            var boundarySkip = 0;

            string tagname;
            if (inboundCp is { Length: > 0 })
            {
                // Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
                // BadContinuationPointInvalid for THIS node.
                var state = _historyContinuationStore.TryTake(session, inboundCp);
                if (state is null)
                {
                    errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
                    results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
                    return;
                }

                tagname = state.Tagname;
                startUtc = state.NextStartUtc;
                boundarySkip = state.BoundarySkipCount;
                endUtc = state.EndUtc;
                numValuesPerNode = state.NumValuesPerNode;
            }
            else
            {
                // Fresh read: resolve the node's historian tagname (as ServeNode does).
                if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null)
                {
                    errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
                    return;
                }

                tagname = resolved;
                startUtc = startTimeUtc;
            }

            // HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
            var sourceResult = await HistorianDataSource
                .ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
                .ConfigureAwait(false);

            var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);

            // On a resume read, drop the boundary ties already returned on the prior page.
            var samples = inboundCp is { Length: > 0 }
                ? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
                : sourceResult.Samples;

            // Oversized tie cluster: a resume read whose FULL backend page does not advance past the resume
            // boundary timestamp `startUtc`. That happens when more samples share `startUtc` than the page
            // cap — a tie cluster larger than NumValuesPerNode. The fixed-(start,end,cap) backend can only
            // ever return the first `cap` of those ties, so the (timestamp, skip) cursor alone can never page
            // past (or even reliably within) the cluster: either the boundary-tie trim empties the page, or
            // it re-emits the same head ties forever. Detect BOTH stall shapes — an empty trimmed page, or a
            // non-empty page whose LAST sample is still at `startUtc` (no forward progress) — and, rather
            // than fail the read, over-fetch the WHOLE cluster with an explicit, BOUNDED cap (a start==end
            // read at the boundary timestamp) and page WITHIN the timestamp via SliceTieCluster.
            var stalledOnTieCluster = inboundCp is { Length: > 0 }
                && backendFull
                && (samples.Count == 0
                    || (samples[^1].SourceTimestampUtc ?? DateTime.MinValue) == startUtc);
            if (stalledOnTieCluster)
            {
                // The over-fetch cap MUST be explicit and non-zero: a cap of 0 falls back to the backend's
                // MaxValuesPerRead, which would re-introduce the very stall we're escaping. +1 over the bound
                // lets us DETECT a cluster strictly larger than the bound (the absurd-burst backstop below).
                // Compute in uint so +1 can never wrap: Math.Max(1,…) also guards against a zero/negative
                // config value slipping through (Validate() warns but does not block startup).
                var overfetchCap = (uint)Math.Max(1, MaxTieClusterOverfetch) + 1u;
                var cluster = (await HistorianDataSource
                    .ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
                    .ConfigureAwait(false)).Samples;

                // Absurd burst: more ties than we're willing to buffer in memory. Preserve today's loud-fail
                // for that node rather than over-fetch an unbounded cluster; the operator's remedy is a
                // larger ServerHistorian:MaxTieClusterOverfetch (or NumValuesPerNode).
                if (cluster.Count > MaxTieClusterOverfetch)
                {
#pragma warning disable CS0618 // Type or member is obsolete
                    Utils.LogError(
                        "OtOpcUaNodeManager: HistoryReadRaw tie cluster at {0:O} for tag '{1}' has {2} samples, " +
                        "exceeding MaxTieClusterOverfetch={3}; cannot page within it. Increase MaxTieClusterOverfetch.",
                        startUtc, tagname, cluster.Count, MaxTieClusterOverfetch);
#pragma warning restore CS0618
                    errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
                    results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
                    return;
                }

                HistoryPaging.SliceTieCluster(
                    cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
                    out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

                var slice = new List<DataValueSnapshot>(sliceCount);
                for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);

                // Emit a continuation point only when SliceTieCluster says the read continues (within the
                // cluster, or past it while the window remains). nextSkip is the boundary skip for the next
                // page — within the cluster it counts the ties already emitted at startUtc; past it it's 0.
                byte[]? clusterCp = null;
                if (nextStartUtc is { } resumeAt)
                {
                    var clusterState = new HistoryContinuationState(
                        tagname, resumeAt, endUtc, nextSkip, numValuesPerNode);
                    clusterCp = _historyContinuationStore.Save(session, clusterState);
                }

                results[handle.Index] = new SdkHistoryReadResult
                {
                    StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
                    HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)),
                    ContinuationPoint = clusterCp,
                };
                errors[handle.Index] = ServiceResult.Good;
                return;
            }

            // The "full page" test is against the RAW backend count (before trimming): the backend honoured
            // the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties.
            var historyData = ToHistoryDataFromSamples(samples);

            byte[]? outboundCp = null;
            if (backendFull && samples.Count > 0)
            {
                HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
                var nextState = new HistoryContinuationState(
                    tagname, nextStart, endUtc, skip, numValuesPerNode);
                // Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
                // Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
                outboundCp = _historyContinuationStore.Save(session, nextState);
            }

            results[handle.Index] = new SdkHistoryReadResult
            {
                // No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
                // degenerate-cluster guard above, a resumed empty page now only means the window/cluster is
                // genuinely drained — never silent data loss.
                StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
                HistoryData = new ExtensionObject(historyData),
                ContinuationPoint = outboundCp,
            };
            errors[handle.Index] = ServiceResult.Good;
        }
        catch (OperationCanceledException)
        {
            // The per-request deadline elapsed while paging this node — BadTimeout for THIS node only.
            errors[handle.Index] = StatusCodes.BadTimeout;
            results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
        }
        catch (Exception ex)
        {
            // One node's backend failure must not throw out of the batch — Bad for THIS node only.
#pragma warning disable CS0618 // Type or member is obsolete
            Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
            errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
        }
    }

    /// <inheritdoc />
    protected override void HistoryReleaseContinuationPoints(
        ServerSystemContext context,
        IList<HistoryReadValueId> nodesToRead,
        IList<ServiceResult> errors,
        List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        var session = context.OperationContext?.Session;
        foreach (var handle in nodesToProcess)
        {
            var cp = nodesToRead[handle.Index].ContinuationPoint;
            if (cp is { Length: > 0 })
            {
                _historyContinuationStore.Release(session, cp);
            }

            errors[handle.Index] = ServiceResult.Good;
        }
    }

    /// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
    /// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
    /// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
    /// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
    private static HistoryData ToHistoryDataFromSamples(IReadOnlyList<DataValueSnapshot> samples)
    {
        var values = new DataValueCollection(samples.Count);
        foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
        return new HistoryData { DataValues = values };
    }

    /// <summary>
    ///     Map an OPC UA Part 13 standard-aggregate function NodeId to our
    ///     <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
    ///     the caller can surface <c>BadAggregateNotSupported</c>.
    /// </summary>
    /// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
    /// <returns>The mapped aggregate, or <c>null</c> when unsupported.</returns>
    private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
    {
        if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
        return null;
    }

    /// <summary>
    ///     Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
    ///     SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
    ///     carrying value / status / source+server timestamps. A null SourceTimestamp maps to
    ///     <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
    /// </summary>
    /// <param name="sourceResult">The data source's read result.</param>
    /// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
    private static HistoryData ToHistoryData(HistorianRead sourceResult)
    {
        var values = new DataValueCollection(sourceResult.Samples.Count);
        foreach (var sample in sourceResult.Samples)
        {
            values.Add(ToSdkDataValue(sample));
        }

        return new HistoryData { DataValues = values };
    }

    /// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
    /// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
    /// <param name="snapshot">The source sample.</param>
    /// <returns>The equivalent SDK data value.</returns>
    private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
    {
        WrappedValue = new Variant(snapshot.Value),
        StatusCode = new StatusCode(snapshot.StatusCode),
        SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
        ServerTimestamp = snapshot.ServerTimestampUtc,
    };

    /// <inheritdoc />
    public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
    {
        lock (Lock)
        {
            base.CreateAddressSpace(externalReferences);

            // Create one root folder under Objects/ for every variable we mint to hang under.
            _root = new FolderState(null)
            {
                NodeId = new NodeId("OtOpcUa", NamespaceIndex),
                BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
                DisplayName = "OtOpcUa",
                EventNotifier = EventNotifiers.None,
                TypeDefinitionId = ObjectTypeIds.FolderType,
            };
            _root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);

            if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
            {
                refs = new List<IReference>();
                externalReferences[ObjectIds.ObjectsFolder] = refs;
            }
            refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));

            AddPredefinedNode(SystemContext, _root);
        }
    }

    private BaseDataVariableState CreateVariable(string nodeId, ushort ns)
    {
        var v = new BaseDataVariableState(_root)
        {
            NodeId = new NodeId(nodeId, ns),
            BrowseName = new QualifiedName(nodeId, ns),
            DisplayName = nodeId,
            TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
            ReferenceTypeId = ReferenceTypeIds.Organizes,
            DataType = DataTypeIds.BaseDataType,
            // Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no
            // array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's
            // scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true).
            ValueRank       = ValueRanks.Scalar,
            ArrayDimensions = null,
            AccessLevel = AccessLevels.CurrentRead,
            UserAccessLevel = AccessLevels.CurrentRead,
            Historizing = false,
        };
        _root?.AddChild(v);
        AddPredefinedNode(SystemContext, v);
        return v;
    }

    private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
    {
        OpcUaQuality.Good => StatusCodes.Good,
        OpcUaQuality.Uncertain => StatusCodes.Uncertain,
        _ => StatusCodes.Bad,
    };
}
