using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

/// <summary>
/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library.
/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions.
/// </summary>
public class RealOpcUaClient : IOpcUaClient
{
    private ISession? _session;

    /// <summary>
    /// One data shard = one OPC UA Subscription holding at most
    /// <see cref="OpcUaConnectionOptions.MaxMonitoredItemsPerSubscription"/> monitored
    /// items. The count is tracked here rather than read back off the SDK object so
    /// placement decisions are made from state this class owns (and mutates only under
    /// <see cref="_applyGate"/>).
    /// </summary>
    private sealed class DataShard(Subscription subscription)
    {
        public Subscription Subscription { get; } = subscription;
        public int ItemCount { get; set; }
    }

    // Data-item shards, in creation order. A monitored item is placed on the first shard
    // with free capacity; when all are full a new shard is created, and a shard is
    // deleted once it empties. Mutated ONLY under _applyGate.
    private readonly List<DataShard> _dataShards = new();

    // Alarms & Conditions event items live on their own shard: TriggerConditionRefresh
    // needs a single subscription id to target, and QueueSize:1000 event items would
    // otherwise consume data-shard capacity. Created lazily, torn down on disconnect.
    private Subscription? _eventShard;

    // subscription handle → owning shard's Subscription, so a removal can group ONE
    // ApplyChanges per touched shard.
    private readonly ConcurrentDictionary<string, Subscription> _itemShard = new();

    // The OPC Foundation Subscription object is not safe under concurrent structural
    // mutation, and nothing else orders HandleSubscribe's background task, the
    // tag-resolution probe and an alarm subscribe against each other. Every
    // AddItem/RemoveItem/ApplyChanges/Create/Delete — and every shard-list mutation —
    // serializes behind this ONE per-client gate. Batching makes contention rare; a
    // single gate (rather than per-shard) also serializes shard creation and is the
    // simplest thing that is correct.
    private readonly AsyncSerialGate _applyGate = new();

    // These maps are read from the OPC Foundation SDK's
    // internal publish threads (the MonitoredItem.Notification handler reads
    // _callbacks) concurrently with subscribe/disconnect mutations that run on
    // thread-pool threads. Plain Dictionary access during a concurrent resize or
    // Clear() is undefined behaviour, so they must be ConcurrentDictionary.
    private readonly ConcurrentDictionary<string, MonitoredItem> _monitoredItems = new();
    private readonly ConcurrentDictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();

    // Native alarm (A&C) event subscriptions, keyed by handle.
    private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
    // Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
    private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
    // Per-handle last (active, acked) by source reference, to derive transition kind.
    private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
    // Int flag toggled with Interlocked.Exchange so the
    // once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible.
    // 0 = not fired, 1 = fired.
    private int _connectionLostFired;
    private OpcUaConnectionOptions _options = new();
    private readonly OpcUaGlobalOptions _globalOptions;
    private readonly ILogger<RealOpcUaClient> _logger;

    /// <summary>
    /// Initializes a new instance of the RealOpcUaClient class.
    /// </summary>
    /// <param name="globalOptions">Global OPC UA options, or null to use defaults.</param>
    /// <param name="logger">Logger instance, or null to use a null logger.</param>
    public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null, ILogger<RealOpcUaClient>? logger = null)
    {
        _globalOptions = globalOptions ?? new OpcUaGlobalOptions();
        _logger = logger ?? NullLogger<RealOpcUaClient>.Instance;
    }

    /// <inheritdoc />
    public bool IsConnected => _session?.Connected ?? false;

    /// <summary>Raised when the OPC UA connection is lost.</summary>
    public event Action? ConnectionLost;

    /// <inheritdoc />
    public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
    {
        var opts = options ?? new OpcUaConnectionOptions();

        var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch
        {
            "SIGN" => MessageSecurityMode.Sign,
            "SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt,
            _ => MessageSecurityMode.None
        };

        var appConfig = new ApplicationConfiguration
        {
            ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
                ? "ScadaBridge-DCL"
                : _globalOptions.ApplicationName,
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = new SecurityConfiguration
            {
                AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
                ApplicationCertificate = new CertificateIdentifier(),
                TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
                TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
                RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
            },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
            TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
        };

        await appConfig.ValidateAsync(ApplicationType.Client);
        if (opts.AutoAcceptUntrustedCerts)
        {
            // This accepts ANY server certificate, defeating
            // certificate trust enforcement. Surface a prominent warning so an operator
            // who has opted in is aware of the man-in-the-middle exposure on the link.
            _logger.LogWarning(
                "OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " +
                "server certificate is accepted unconditionally. This defeats Sign / " +
                "SignAndEncrypt protection against a man-in-the-middle.", endpointUrl);
            appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
        }

        // Discover endpoints from the server, pick the preferred security mode
        EndpointDescription? endpoint;
        try
        {
#pragma warning disable CS0618
            using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
#pragma warning restore CS0618
#pragma warning disable CS0618
            var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
            endpoint = endpoints
                .Where(e => e.SecurityMode == preferredSecurityMode)
                .FirstOrDefault() ?? endpoints.FirstOrDefault();
        }
        catch
        {
            // Fallback: construct endpoint description manually
            endpoint = new EndpointDescription(endpointUrl);
        }

        // The server advertises its own base address, which is frequently unroutable from here
        // (a 0.0.0.0 wildcard bind, or an internal container/NAT hostname). Swap it back to the
        // host the operator actually reached, or the session channel dials an unreachable address.
        RewriteEndpointHostForReachability(endpoint, endpointUrl);

        var endpointConfig = EndpointConfiguration.Create(appConfig);
        var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);

#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
        var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
        var userIdentity = BuildUserIdentity(opts.UserIdentity);
        _session = await sessionFactory.CreateAsync(
            appConfig, configuredEndpoint, false,
            "ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);

        // Detect server going offline via keep-alive failures
        Interlocked.Exchange(ref _connectionLostFired, 0);
        _session.KeepAlive += OnSessionKeepAlive;

        // Store options for monitored item creation
        _options = opts;

        // Create the first data shard up front so a session is immediately usable;
        // further shards are added on demand as the item budget fills.
        await _applyGate.RunAsync(async () =>
        {
            _dataShards.Clear();
            _itemShard.Clear();
            _eventShard = null;
            await CreateDataShardAsync(cancellationToken);
        }, cancellationToken);
    }

    /// <summary>Item budget per subscription, guarding against a non-positive configured value.</summary>
    private int ItemsPerShard => _options.MaxMonitoredItemsPerSubscription > 0
        ? _options.MaxMonitoredItemsPerSubscription
        : 5000;

    /// <summary>
    /// Builds a Subscription from the current connection options. Callers must hold
    /// <see cref="_applyGate"/>.
    /// </summary>
    private Subscription NewSubscription(string displayName) =>
        new(_session!.DefaultSubscription)
        {
            DisplayName = displayName,
            Priority = _options.SubscriptionPriority,
            PublishingEnabled = true,
            PublishingInterval = _options.PublishingIntervalMs,
            KeepAliveCount = (uint)_options.KeepAliveCount,
            LifetimeCount = (uint)_options.LifetimeCount,
            MaxNotificationsPerPublish = (uint)_options.MaxNotificationsPerPublish
        };

    /// <summary>
    /// Adds and creates a new data shard. Callers must hold <see cref="_applyGate"/>.
    /// </summary>
    private async Task<DataShard> CreateDataShardAsync(CancellationToken cancellationToken)
    {
        var suffix = _dataShards.Count == 0 ? string.Empty : $"-{_dataShards.Count + 1}";
        var subscription = NewSubscription(_options.SubscriptionDisplayName + suffix);
        _session!.AddSubscription(subscription);
        await subscription.CreateAsync(cancellationToken);
        var shard = new DataShard(subscription);
        _dataShards.Add(shard);
        _logger.LogDebug("OPC UA data shard {Shard} created (budget {Budget} items)",
            subscription.DisplayName, ItemsPerShard);
        return shard;
    }

    /// <summary>
    /// Returns the dedicated Alarms &amp; Conditions event shard, creating it on first
    /// use. Callers must hold <see cref="_applyGate"/>.
    /// </summary>
    private async Task<Subscription> GetOrCreateEventShardAsync(CancellationToken cancellationToken)
    {
        if (_eventShard != null)
            return _eventShard;

        var subscription = NewSubscription(_options.SubscriptionDisplayName + "-events");
        _session!.AddSubscription(subscription);
        await subscription.CreateAsync(cancellationToken);
        _eventShard = subscription;
        return subscription;
    }

    /// <summary>
    /// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
    /// configured, when the server advertised a different (unroutable) one.
    /// </summary>
    /// <remarks>
    /// An OPC UA server advertises its base address from its OWN configuration, not the route the
    /// client took to reach it — so <c>GetEndpoints</c> routinely returns a wildcard bind
    /// (<c>opc.tcp://0.0.0.0:4840/…</c>) or an internal container/NAT hostname the client cannot
    /// dial. The OPC Foundation session opens its channel to <see cref="EndpointDescription.EndpointUrl"/>
    /// verbatim, so a 0.0.0.0 advertisement resolves to the client's OWN loopback and the connect
    /// fails. This swaps only the authority (host + port) to the reachable one, preserving the
    /// advertised scheme and path (e.g. <c>/OtOpcUa</c>). Mirrors <c>CoreClientUtils.SelectEndpoint</c>.
    /// A no-op when the advertised host/port is already reachable (so an opc-plc server that
    /// advertises its real container name is left untouched).
    /// </remarks>
    internal static void RewriteEndpointHostForReachability(EndpointDescription? endpoint, string discoveryUrl)
    {
        if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.EndpointUrl))
            return;
        if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var reachable))
            return;
        if (!Uri.TryCreate(endpoint.EndpointUrl, UriKind.Absolute, out var advertised))
            return;

        // Already reachable — leave it exactly as advertised.
        if (string.Equals(advertised.Host, reachable.Host, StringComparison.OrdinalIgnoreCase)
            && advertised.Port == reachable.Port)
            return;

        // An IPv6 literal must keep its brackets, or the reconstructed authority (`::1:4840`) is
        // ambiguous and unparseable. Uri.Host may or may not include them depending on the scheme,
        // so bracket only when missing (never double-wrap).
        var host = reachable.Host;
        if (reachable.HostNameType == UriHostNameType.IPv6 && !host.StartsWith('['))
            host = $"[{host}]";
        // opc.tcp has no default port known to Uri, so Uri.Port is -1 when the text omits one.
        // Prefer the reachable URL's port, fall back to the advertised one, and emit no ":port"
        // at all when neither carries one (rather than a malformed ":-1").
        var port = reachable.Port >= 0 ? reachable.Port : advertised.Port;
        var authority = port >= 0 ? $"{host}:{port}" : host;
        // Authority-only advertisements parse to AbsolutePath "/" — drop it so we don't append a
        // spurious trailing slash the server never advertised.
        var path = advertised.AbsolutePath == "/" ? string.Empty : advertised.AbsolutePath;
        endpoint.EndpointUrl = $"{advertised.Scheme}://{authority}{path}";
    }

    /// <summary>
    /// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
    /// long-lived connection — connect, capture the server certificate if it is untrusted,
    /// then disconnect. The probe is secure-by-default and READ-ONLY: it forces
    /// <c>AutoAcceptUntrustedCertificates = false</c> and a validation hook that captures an
    /// untrusted server certificate then REJECTS it (<c>e.Accept = false</c>). It never trusts
    /// the certificate — trusting is a separate, later operator action. The session is always
    /// disposed in a <c>finally</c>.
    /// </summary>
    /// <param name="config">The endpoint configuration to probe.</param>
    /// <param name="globalOptions">Deployment-wide OPC UA application identity / cert-store paths.</param>
    /// <param name="logger">Logger for diagnostics.</param>
    /// <param name="timeout">Wall-clock budget for the whole probe (discovery + session create).</param>
    /// <param name="ct">External cancellation token, linked with the timeout.</param>
    /// <returns>A structured <see cref="VerifyEndpointResult"/> classifying the outcome.</returns>
    public static async Task<VerifyEndpointResult> VerifyEndpointAsync(
        OpcUaEndpointConfig config,
        OpcUaGlobalOptions globalOptions,
        ILogger logger,
        TimeSpan timeout,
        CancellationToken ct)
    {
        // Captured by the certificate-validation hook below. A non-null value here means
        // the server presented an untrusted certificate; it dominates the outcome mapping.
        X509Certificate2? capturedCert = null;
        ISession? session = null;
        Exception? failure = null;

        var endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
            ? "opc.tcp://localhost:4840"
            : config.EndpointUrl;

        var preferredSecurityMode = config.SecurityMode switch
        {
            OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
            OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
            _ => MessageSecurityMode.None
        };

        // Secure-by-default — force AutoAccept=false so an untrusted server cert is
        // captured and rejected rather than silently accepted (defeating the whole probe).
        var appConfig = new ApplicationConfiguration
        {
            ApplicationName = string.IsNullOrWhiteSpace(globalOptions.ApplicationName)
                ? "ScadaBridge-DCL"
                : globalOptions.ApplicationName,
            ApplicationType = ApplicationType.Client,
            SecurityConfiguration = new SecurityConfiguration
            {
                AutoAcceptUntrustedCertificates = false,
                ApplicationCertificate = new CertificateIdentifier(),
                TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedIssuerStorePath, "issuers") },
                TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedPeerStorePath, "trusted") },
                RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.RejectedCertificateStorePath, "rejected") }
            },
            ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = config.SessionTimeoutMs },
            TransportQuotas = new TransportQuotas { OperationTimeout = config.OperationTimeoutMs }
        };

        // Capture the untrusted server cert, then REJECT it (e.Accept = false). The
        // validator runs on the SDK's connect thread; copying the cert is the only state we
        // keep. Never accept — this probe must not trust anything.
        appConfig.CertificateValidator.CertificateValidation += (_, e) =>
        {
            try
            {
                // Copy into a stable instance so disposing the SDK's chain doesn't invalidate it.
                capturedCert = X509CertificateLoader.LoadCertificate(e.Certificate.RawData);
            }
            catch
            {
                // Best-effort capture: fall back to the original reference if the copy fails.
                capturedCert = e.Certificate;
            }
            e.Accept = false;
        };

        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        linkedCts.CancelAfter(timeout);

        try
        {
            await appConfig.ValidateAsync(ApplicationType.Client);

            // Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
            EndpointDescription? endpoint;
            try
            {
#pragma warning disable CS0618
                using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
                var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
                endpoint = endpoints
                    .Where(ep => ep.SecurityMode == preferredSecurityMode)
                    .FirstOrDefault() ?? endpoints.FirstOrDefault();
            }
            catch
            {
                endpoint = new EndpointDescription(endpointUrl);
            }

            // Same reachability rewrite as ConnectAsync — a probe against a server advertising a
            // 0.0.0.0 / NAT base address must dial the reachable host, not the advertised one.
            RewriteEndpointHostForReachability(endpoint, endpointUrl);

            var endpointConfig = EndpointConfiguration.Create(appConfig);
            var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);

#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
            var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618

            var userIdentity = BuildUserIdentity(config.UserIdentity is { } ui
                ? new OpcUaUserIdentityOptions(
                    ui.TokenType.ToString(), ui.Username, ui.Password,
                    ui.CertificatePath, ui.CertificatePassword)
                : null);

            session = await sessionFactory.CreateAsync(
                appConfig, configuredEndpoint, false,
                "ScadaBridge-DCL-Verify", (uint)config.SessionTimeoutMs,
                userIdentity, null, linkedCts.Token);
        }
        catch (Exception ex)
        {
            // OperationCanceledException from the linked CTS firing on timeout is mapped to
            // VerifyFailureKind.Timeout inside MapVerifyOutcome.
            failure = ex;
            logger.LogDebug(ex, "OPC UA verify of {Endpoint} failed.", endpointUrl);
        }
        finally
        {
            // ALWAYS dispose the probe session — never leave a connection open.
            if (session != null)
            {
                try { await session.CloseAsync(CancellationToken.None); }
                catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
                session.Dispose();
            }
        }

        return MapVerifyOutcome(failure, capturedCert);
    }

    /// <summary>
    /// Pure mapping of a probe outcome — an optional exception plus an optionally
    /// captured untrusted server certificate — to a <see cref="VerifyEndpointResult"/>.
    /// Factored out so the classification is unit-testable WITHOUT a live OPC UA server.
    /// Precedence: a captured certificate ALWAYS yields
    /// <see cref="VerifyFailureKind.UntrustedCertificate"/>; otherwise the exception is
    /// classified; null exception + null cert means the session was created (success).
    /// </summary>
    /// <param name="failure">The exception thrown during the probe, or null on success.</param>
    /// <param name="capturedCert">The untrusted server certificate captured by the validation hook, or null.</param>
    /// <returns>The classified verification result.</returns>
    internal static VerifyEndpointResult MapVerifyOutcome(Exception? failure, X509Certificate2? capturedCert)
    {
        // An untrusted server certificate dominates — regardless of how the connect failed,
        // this is the actionable case (the operator may choose to trust it later).
        if (capturedCert != null)
        {
            var info = new ServerCertInfo(
                capturedCert.Thumbprint,
                capturedCert.Subject,
                capturedCert.Issuer,
                capturedCert.NotBefore.ToUniversalTime(),
                capturedCert.NotAfter.ToUniversalTime(),
                Convert.ToBase64String(capturedCert.RawData));
            return new VerifyEndpointResult(
                false, VerifyFailureKind.UntrustedCertificate,
                "The server certificate is not trusted by this site.", info);
        }

        if (failure is null)
            return new VerifyEndpointResult(true, null, null, null);

        // Timeout / cancellation (the linked CTS fired, or the SDK reported a request timeout).
        if (failure is TimeoutException or OperationCanceledException)
            return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, failure.Message, null);

        if (failure is ServiceResultException sre)
        {
            // A socket cause wrapped inside the SDK exception means the host is unreachable.
            if (HasSocketCause(sre))
                return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);

            switch (sre.StatusCode)
            {
                case StatusCodes.BadRequestTimeout:
                case StatusCodes.BadTimeout:
                    return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
                case StatusCodes.BadUserAccessDenied:
                case StatusCodes.BadIdentityTokenRejected:
                case StatusCodes.BadIdentityTokenInvalid:
                    return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
                case StatusCodes.BadConnectionRejected:
                case StatusCodes.BadNotConnected:
                case StatusCodes.BadConnectionClosed:
                case StatusCodes.BadNoCommunication:
                case StatusCodes.BadServerNotConnected:
                    return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
                default:
                    return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
            }
        }

        // A bare socket failure (DNS / connection refused) before the SDK wrapped it.
        if (HasSocketCause(failure))
            return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, failure.Message, null);

        return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, failure.Message, null);
    }

    /// <summary>
    /// Walks the exception's <c>InnerException</c> chain looking for a
    /// <see cref="System.Net.Sockets.SocketException"/> — the signature of a DNS-resolution
    /// or connection-refused failure that means the endpoint host is unreachable.
    /// </summary>
    private static bool HasSocketCause(Exception ex)
    {
        for (var cur = ex; cur != null; cur = cur.InnerException)
        {
            if (cur is System.Net.Sockets.SocketException)
                return true;
        }
        return false;
    }

    /// <inheritdoc />
    public async Task DisconnectAsync(CancellationToken cancellationToken = default)
    {
        await _applyGate.RunAsync(async () =>
        {
            foreach (var shard in _dataShards)
                await shard.Subscription.DeleteAsync(true);
            _dataShards.Clear();
            _itemShard.Clear();
            if (_eventShard != null)
            {
                await _eventShard.DeleteAsync(true);
                _eventShard = null;
            }
        }, cancellationToken);

        if (_session != null)
        {
            _session.KeepAlive -= OnSessionKeepAlive;
            await _session.CloseAsync(cancellationToken);
            _session = null;
        }
        _monitoredItems.Clear();
        _callbacks.Clear();
    }

    /// <inheritdoc />
    public async Task<string> CreateSubscriptionAsync(
        string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default)
    {
        // Batch-of-one delegation: the single-node entry point is kept (heartbeat monitor,
        // interactive paths) but shares the batch implementation so there is exactly one
        // placement / ApplyChanges code path. A per-item failure throws here — preserving
        // the caller's historical "create throws on failure" contract AND the exception
        // TYPE, so DataConnectionActor.IsConnectionLevelFailure still classifies a
        // resolution failure as a tag-resolution failure rather than a connection fault.
        var (outcomes, errors) = await CreateSubscriptionsCoreAsync(
            [nodeId], onValueChanged, cancellationToken);

        var outcome = outcomes[0];
        if (outcome.Success)
            return outcome.SubscriptionHandle!;

        if (errors[0] is { } captured)
            System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(captured).Throw();
        throw new ServiceResultException(StatusCodes.BadNodeIdUnknown,
            outcome.Error ?? $"Monitored item for '{nodeId}' could not be created.");
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<OpcUaSubscribeOutcome>> CreateSubscriptionsAsync(
        IReadOnlyList<string> nodeIds,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken = default)
    {
        var (outcomes, _) = await CreateSubscriptionsCoreAsync(nodeIds, onValueChanged, cancellationToken);
        return outcomes;
    }

    /// <summary>
    /// Creates monitored items for every requested node with ONE ApplyChanges per touched
    /// shard — the core of the batch subscribe seam. Node-id resolution runs outside the
    /// apply lock (it is pure over the session's namespace table); placement, AddItem and
    /// ApplyChanges run under it. Returns per-node outcomes plus the captured exception
    /// (when any) per node, so the single-node entry point can rethrow the original.
    /// </summary>
    private async Task<(OpcUaSubscribeOutcome[] Outcomes, Exception?[] Errors)> CreateSubscriptionsCoreAsync(
        IReadOnlyList<string> nodeIds,
        Action<string, object?, DateTime, uint> onValueChanged,
        CancellationToken cancellationToken)
    {
        var session = _session;
        if (session == null)
            throw new InvalidOperationException("Not connected.");

        var outcomes = new OpcUaSubscribeOutcome[nodeIds.Count];
        var errors = new Exception?[nodeIds.Count];

        // Resolve first: a bad node id is a per-tag fault and must not abort the batch.
        var resolved = new List<(int Index, string NodeId, NodeId Resolved)>(nodeIds.Count);
        for (var i = 0; i < nodeIds.Count; i++)
        {
            try
            {
                resolved.Add((i, nodeIds[i], OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris)));
            }
            catch (Exception ex)
            {
                outcomes[i] = new OpcUaSubscribeOutcome(nodeIds[i], false, null, ex.Message);
                errors[i] = ex;
            }
        }

        if (resolved.Count == 0)
            return (outcomes, errors);

        return await _applyGate.RunAsync(async () =>
        {
            var created = new List<(int Index, string NodeId, string Handle, MonitoredItem Item)>(resolved.Count);
            var touched = new List<DataShard>();

            foreach (var (index, nodeId, startNode) in resolved)
            {
                var shard = await PlaceOnDataShardAsync(cancellationToken);
                var handle = Guid.NewGuid().ToString();
                var monitoredItem = new MonitoredItem(shard.Subscription.DefaultItem)
                {
                    DisplayName = nodeId,
                    StartNodeId = startNode,
                    AttributeId = Attributes.Value,
                    SamplingInterval = _options.SamplingIntervalMs,
                    QueueSize = (uint)_options.QueueSize,
                    DiscardOldest = _options.DiscardOldest,
                    Filter = BuildDataChangeFilter(_options.Deadband)
                };

                _callbacks[handle] = onValueChanged;
                monitoredItem.Notification += (_, e) =>
                {
                    if (e.NotificationValue is MonitoredItemNotification notification)
                    {
                        var value = notification.Value?.Value;
                        var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
                        var statusCode = notification.Value?.StatusCode.Code ?? 0;

                        if (_callbacks.TryGetValue(handle, out var cb))
                        {
                            cb(nodeId, value, timestamp, statusCode);
                        }
                    }
                };

                shard.Subscription.AddItem(monitoredItem);
                shard.ItemCount++;
                _itemShard[handle] = shard.Subscription;
                created.Add((index, nodeId, handle, monitoredItem));
                if (!touched.Contains(shard))
                    touched.Add(shard);
            }

            // ONE ApplyChanges per touched shard — the whole point of the batch seam.
            foreach (var shard in touched)
                await shard.Subscription.ApplyChangesAsync(cancellationToken);

            foreach (var (index, nodeId, handle, item) in created)
            {
                var error = item.Status?.Error;
                if (error != null && ServiceResult.IsBad(error))
                {
                    // Per-item create failure (unknown node, access denied): release the
                    // half-created item so the shard budget stays honest, and report a
                    // failed row. The actor treats it as an unresolved tag and retries it
                    // on the backoff timer.
                    ReleaseFailedItem(handle, item);
                    outcomes[index] = new OpcUaSubscribeOutcome(nodeId, false, null, error.ToString());
                }
                else
                {
                    _monitoredItems[handle] = item;
                    outcomes[index] = new OpcUaSubscribeOutcome(nodeId, true, handle, null);
                }
            }

            return (outcomes, errors);
        }, cancellationToken);
    }

    /// <summary>
    /// Returns the shard this item belongs on per
    /// <see cref="MonitoredItemShardPlanner"/> — first shard with free capacity, else a
    /// freshly created one. Callers must hold <see cref="_applyGate"/>.
    /// </summary>
    private async Task<DataShard> PlaceOnDataShardAsync(CancellationToken cancellationToken)
    {
        var index = MonitoredItemShardPlanner.Plan(
            _dataShards.Select(s => s.ItemCount).ToList(), ItemsPerShard, 1)[0];
        return index < _dataShards.Count
            ? _dataShards[index]
            : await CreateDataShardAsync(cancellationToken);
    }

    /// <summary>
    /// Drops a monitored item that failed to create from the shard it was placed on.
    /// Callers must hold <see cref="_applyGate"/>. No ApplyChanges is issued: the server
    /// never created the item, so removing it locally keeps the budget honest.
    /// </summary>
    private void ReleaseFailedItem(string handle, MonitoredItem item)
    {
        _callbacks.TryRemove(handle, out _);
        if (!_itemShard.TryRemove(handle, out var subscription))
            return;

        subscription.RemoveItem(item);
        var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
        if (shard is { ItemCount: > 0 })
            shard.ItemCount--;
    }

    /// <inheritdoc />
    public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
        => RemoveSubscriptionsAsync([subscriptionHandle], cancellationToken);

    /// <inheritdoc />
    public async Task RemoveSubscriptionsAsync(
        IReadOnlyList<string> subscriptionHandles, CancellationToken cancellationToken = default)
    {
        if (subscriptionHandles.Count == 0)
            return;

        await _applyGate.RunAsync(async () =>
        {
            var touched = new List<DataShard>();
            foreach (var handle in subscriptionHandles)
            {
                if (!_monitoredItems.TryGetValue(handle, out var item))
                    continue;
                if (!_itemShard.TryGetValue(handle, out var subscription))
                    continue;

                subscription.RemoveItem(item);
                _monitoredItems.TryRemove(handle, out _);
                _callbacks.TryRemove(handle, out _);
                _itemShard.TryRemove(handle, out _);

                var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
                if (shard == null)
                    continue;
                if (shard.ItemCount > 0)
                    shard.ItemCount--;
                if (!touched.Contains(shard))
                    touched.Add(shard);
            }

            // ONE ApplyChanges per touched shard, then drop the shards that emptied.
            foreach (var shard in touched)
            {
                await shard.Subscription.ApplyChangesAsync(cancellationToken);
                if (shard.ItemCount != 0)
                    continue;

                _dataShards.Remove(shard);
                try
                {
                    await shard.Subscription.DeleteAsync(true);
                }
                catch (Exception ex)
                {
                    // Best-effort: an empty shard the server already dropped must not fail
                    // the unsubscribe path.
                    _logger.LogDebug(ex, "Deleting emptied OPC UA data shard {Shard} failed (ignored).",
                        shard.Subscription.DisplayName);
                }
            }
        }, cancellationToken);
    }

    // ── Native alarm (Alarms & Conditions) subscription ──
    // Behavioral correctness verified against a live A&C server; only
    // the OpcUaAlarmMapper value→state logic is unit-tested.

    // Fixed select-clause order; parsed by index in HandleAlarmEvent.
    private static readonly string[] AlarmStateFields =
        ["EventType", "SourceNode", "SourceName", "Time", "Message", "Severity"];

    /// <inheritdoc />
    public async Task<string> CreateAlarmSubscriptionAsync(
        string? sourceNodeId, string? conditionFilter,
        Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
    {
        if (_session == null)
            throw new InvalidOperationException("Not connected.");

        var handle = Guid.NewGuid().ToString();
        _alarmInRefresh[handle] = false;
        _alarmLastState[handle] = new Dictionary<string, (bool, bool)>(StringComparer.Ordinal);

        var startNode = string.IsNullOrEmpty(sourceNodeId)
            ? ObjectIds.Server
            : OpcUaNodeReference.Resolve(sourceNodeId, _session.NamespaceUris);

        // A&C event items live on their own shard (see _eventShard): ConditionRefresh
        // needs one subscription id to target, and QueueSize:1000 event items must not
        // occupy data-shard capacity.
        var eventShard = await _applyGate.RunAsync(
            () => GetOrCreateEventShardAsync(cancellationToken), cancellationToken);

        var item = new MonitoredItem(eventShard.DefaultItem)
        {
            DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
            StartNodeId = startNode,
            AttributeId = Attributes.EventNotifier,
            MonitoringMode = MonitoringMode.Reporting,
            SamplingInterval = 0,
            QueueSize = 1000,
            // Server-side WhereClause is a bandwidth optimisation only — the
            // authoritative condition-type gate lives in DataConnectionActor.
            Filter = BuildAlarmEventFilter(AlarmConditionFilter.Parse(conditionFilter))
        };

        item.Notification += (_, e) =>
        {
            if (e.NotificationValue is EventFieldList efl)
                // sourceNodeId is the binding this feed was subscribed under; every
                // transition on the feed is routed under it verbatim (see #17).
                HandleAlarmEvent(handle, sourceNodeId, efl, onTransition);
        };

        await _applyGate.RunAsync(async () =>
        {
            eventShard.AddItem(item);
            await eventShard.ApplyChangesAsync(cancellationToken);
        }, cancellationToken);
        _alarmItems[handle] = item;

        // Replay currently-active conditions as a Snapshot…SnapshotComplete sequence.
        await TriggerConditionRefreshAsync(handle, eventShard, cancellationToken);
        return handle;
    }

    /// <inheritdoc />
    public async Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
    {
        if (_alarmItems.TryRemove(subscriptionHandle, out var item))
        {
            await _applyGate.RunAsync(async () =>
            {
                if (_eventShard != null)
                {
                    _eventShard.RemoveItem(item);
                    await _eventShard.ApplyChangesAsync(cancellationToken);
                }
            }, cancellationToken);
        }
        _alarmInRefresh.TryRemove(subscriptionHandle, out _);
        _alarmLastState.TryRemove(subscriptionHandle, out _);
    }

    /// <summary>
    /// Maps the standard OPC UA Alarms &amp; Conditions type names (case-insensitive)
    /// to their well-known <see cref="ObjectTypeIds"/> NodeIds, for building the
    /// optional server-side WhereClause. Only standard types appear
    /// here; vendor/custom type names cannot be mapped without browsing the server
    /// type tree, so they are handled by the client-side gate alone.
    /// <para>
    /// Single source of truth for both directions: <see cref="ConditionTypeNamesById"/>
    /// is derived from this map, so the friendly-name and NodeId sides cannot drift.
    /// </para>
    /// </summary>
    internal static readonly IReadOnlyDictionary<string, NodeId> KnownConditionTypeIds =
        new Dictionary<string, NodeId>(StringComparer.OrdinalIgnoreCase)
        {
            ["ConditionType"] = ObjectTypeIds.ConditionType,
            ["AcknowledgeableConditionType"] = ObjectTypeIds.AcknowledgeableConditionType,
            ["AlarmConditionType"] = ObjectTypeIds.AlarmConditionType,
            ["LimitAlarmType"] = ObjectTypeIds.LimitAlarmType,
            ["ExclusiveLimitAlarmType"] = ObjectTypeIds.ExclusiveLimitAlarmType,
            ["NonExclusiveLimitAlarmType"] = ObjectTypeIds.NonExclusiveLimitAlarmType,
            ["ExclusiveLevelAlarmType"] = ObjectTypeIds.ExclusiveLevelAlarmType,
            ["NonExclusiveLevelAlarmType"] = ObjectTypeIds.NonExclusiveLevelAlarmType,
            ["ExclusiveDeviationAlarmType"] = ObjectTypeIds.ExclusiveDeviationAlarmType,
            ["NonExclusiveDeviationAlarmType"] = ObjectTypeIds.NonExclusiveDeviationAlarmType,
            ["ExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.ExclusiveRateOfChangeAlarmType,
            ["NonExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.NonExclusiveRateOfChangeAlarmType,
            ["DiscreteAlarmType"] = ObjectTypeIds.DiscreteAlarmType,
            ["OffNormalAlarmType"] = ObjectTypeIds.OffNormalAlarmType,
            ["SystemOffNormalAlarmType"] = ObjectTypeIds.SystemOffNormalAlarmType,
            ["TripAlarmType"] = ObjectTypeIds.TripAlarmType,
            ["DiscrepancyAlarmType"] = ObjectTypeIds.DiscrepancyAlarmType,
            ["InstrumentDiagnosticAlarmType"] = ObjectTypeIds.InstrumentDiagnosticAlarmType,
            ["SystemDiagnosticAlarmType"] = ObjectTypeIds.SystemDiagnosticAlarmType,
            ["CertificateExpirationAlarmType"] = ObjectTypeIds.CertificateExpirationAlarmType,
        };

    /// <summary>
    /// Inverse of <see cref="KnownConditionTypeIds"/> (NodeId → friendly name), derived
    /// from it so the two cannot drift. Used by <see cref="ResolveAlarmTypeName"/>
    /// to translate the event-type NodeId an OPC UA server sends back into the friendly
    /// type name the conditionFilter gate and server-side WhereClause both key off.
    /// </summary>
    private static readonly IReadOnlyDictionary<NodeId, string> ConditionTypeNamesById =
        KnownConditionTypeIds.ToDictionary(kv => kv.Value, kv => kv.Key);

    /// <summary>
    /// Resolves an event-type <see cref="NodeId"/> to the friendly condition-type name the
    /// <c>conditionFilter</c> gate (and the server-side WhereClause) use.
    ///
    /// <para>
    /// Standard A&amp;C types are returned as their friendly name (e.g. <c>i=9341</c> →
    /// <c>"ExclusiveLevelAlarmType"</c>) so the client-side gate — which compares against
    /// the friendly names in <see cref="KnownConditionTypeIds"/> — actually matches the
    /// events the server delivers. Vendor/custom subtypes that are not in the map fall back
    /// to the NodeId string; that is consistent because the WhereClause is likewise omitted
    /// for unmapped names, so such a filter can only be expressed (and matched) as the NodeId
    /// string. A <c>null</c> event type yields the empty string.
    /// </para>
    /// </summary>
    /// <param name="eventType">The event-type NodeId from the A&amp;C notification, or <c>null</c>.</param>
    /// <returns>The friendly type name when known; otherwise the NodeId string (or "" when null).</returns>
    internal static string ResolveAlarmTypeName(NodeId? eventType)
    {
        if (eventType is null)
            return "";
        return ConditionTypeNamesById.TryGetValue(eventType, out var friendly)
            ? friendly
            : eventType.ToString();
    }

    /// <summary>
    /// Builds the event filter selecting the base event fields plus the
    /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror,
    /// and — when <paramref name="conditionFilter"/> is non-empty and every requested
    /// type maps to a standard A&amp;C type — a server-side <see cref="ContentFilter"/>
    /// WhereClause (OfType, OR'd) as a bandwidth optimisation.
    ///
    /// <para>
    /// Conservative by design: if <em>any</em> requested type name cannot be mapped to
    /// a standard <see cref="ObjectTypeIds"/> NodeId, the WhereClause is omitted entirely
    /// rather than partially applied — a partial server-side filter would silently drop
    /// the unmapped types' events, and the server cannot send what it filtered out. The
    /// client-side gate in DataConnectionActor enforces the full filter regardless, so
    /// omitting the WhereClause only forgoes the bandwidth saving, never correctness.
    /// </para>
    /// </summary>
    /// <param name="conditionFilter">The parsed condition-type filter (allow-all when empty).</param>
    /// <returns>The configured <see cref="EventFilter"/>.</returns>
    internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
    {
        var filter = new EventFilter();
        foreach (var name in AlarmStateFields)
            filter.SelectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));

        // Two-state sub-condition /Id booleans + shelving current-state + identity.
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id"));            // 6
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id"));   // 7
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id"));        // 9
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName"));                     // 11
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment"));                           // 12

        // APPENDED fields (indices 13+): optional — only present on specific derived types.
        // Guard all reads with fields.Count > N so base-ConditionType events still process.

        // 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
        //     last flipped to TRUE.  Mapped to OriginalRaiseTime; absent on non-AlarmCondition
        //     events (ConditionType base events rarely carry it).  CAVEAT: during a
        //     ConditionRefresh replay the server MAY re-stamp this to the current/restart time
        //     rather than the historical raise instant (OPC UA Part 9 §5.5.2 makes it advisory),
        //     so a snapshot-derived OriginalRaiseTime can look like the refresh time — it is
        //     display-only and not treated as authoritative.
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13

        // 14–17: LimitAlarmType limit thresholds — configuration-time set-points exposed as
        //        event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
        //        Level/Deviation/RateOfChange).  Absent on non-limit alarm types (e.g. discrete,
        //        off-normal) — guarded by fields.Count > N below.
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit"));  // 14
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit"));      // 15
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit"));       // 16
        filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit"));    // 17

        // 18: AcknowledgeableConditionType/AckedState/TransitionTime — the UTC instant the
        //     acked-state last flipped.  When the condition is currently ACKED that instant IS
        //     the moment of acknowledgement, so it is the true source ack time mapped to
        //     NativeAlarmTransition.AckTime (MES alarm-status API §6.4).  Optional — absent on
        //     base ConditionType events and on servers that do not expose it, in which case
        //     OpcUaAlarmMapper.DeriveAckTime falls back to the event's own Time field.  Same
        //     ConditionRefresh caveat as index 13: a replayed snapshot may re-stamp it.
        filter.SelectClauses.Add(
            SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "TransitionTime")); // 18

        // UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
        // maintainers know these were considered, not overlooked):
        //   Category     — not a standard event field; server-specific extensions only.
        //   Description  — NativeAlarmTransition.Description is a static template description;
        //                  OPC UA events carry dynamic Message text (index 4, mapped) but no
        //                  static template description in the notification, so this stays empty.
        //   OperatorUser — not available on the standard ConditionRefresh replay stream;
        //                  present on Acknowledge/Confirm method call results, but those do
        //                  not flow through the monitored-item subscription.
        //   CurrentValue — the live process variable value is NOT a standard A&C event field;
        //                  it would require a separate data subscription on the source node.

        ApplyServerSideTypeWhereClause(filter, conditionFilter);
        return filter;
    }

    /// <summary>
    /// Attaches an OfType(-OR'd) WhereClause to <paramref name="filter"/> when every
    /// requested condition type maps to a standard A&amp;C type NodeId; otherwise leaves
    /// the WhereClause empty (see <see cref="BuildAlarmEventFilter"/> rationale).
    /// </summary>
    private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter)
    {
        if (conditionFilter.IsEmpty)
            return;

        var typeIds = new List<NodeId>();
        foreach (var name in conditionFilter.Names)
        {
            if (!KnownConditionTypeIds.TryGetValue(name, out var id))
                return; // unmapped type → omit the WhereClause entirely (client gate covers it)
            typeIds.Add(id);
        }

        if (typeIds.Count == 0)
            return;

        var where = filter.WhereClause;
        if (typeIds.Count == 1)
        {
            where.Push(FilterOperator.OfType, typeIds[0]);
            return;
        }

        // OR together each OfType element so an event of ANY listed type passes.
        var element = where.Push(FilterOperator.OfType, typeIds[0]);
        for (var i = 1; i < typeIds.Count; i++)
        {
            var next = where.Push(FilterOperator.OfType, typeIds[i]);
            element = where.Push(FilterOperator.Or, element, next);
        }
    }

    private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
    {
        var path = new QualifiedNameCollection();
        foreach (var b in browse)
            path.Add(new QualifiedName(b));
        return new SimpleAttributeOperand
        {
            TypeDefinitionId = typeDefinitionId,
            BrowsePath = path,
            AttributeId = Attributes.Value
        };
    }

    private async Task TriggerConditionRefreshAsync(
        string handle, Subscription eventShard, CancellationToken cancellationToken)
    {
        try
        {
            // ConditionRefresh replays active conditions; RefreshStart/End events
            // bracket the replay so HandleAlarmEvent can mark them Snapshot. It targets
            // the EVENT shard's subscription id — the shard every A&C item is pinned to.
            await _session!.CallAsync(
                ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh,
                cancellationToken, eventShard.Id);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "ConditionRefresh failed for alarm subscription {Handle}", handle);
        }
    }

    private void HandleAlarmEvent(
        string handle, string? subscriptionSourceReference, EventFieldList efl,
        Action<NativeAlarmTransition> onTransition)
    {
        var fields = efl.EventFields;
        if (fields == null || fields.Count < AlarmStateFields.Length)
            return;

        var eventType = fields[0].Value as NodeId;

        // RefreshStart/End bracket the snapshot replay.
        if (eventType == ObjectTypeIds.RefreshStartEventType)
        {
            _alarmInRefresh[handle] = true;
            return;
        }
        if (eventType == ObjectTypeIds.RefreshEndEventType)
        {
            _alarmInRefresh[handle] = false;
            onTransition(SnapshotComplete());
            return;
        }

        // Field layout (AlarmStateFields): [1]=SourceNode (NodeId), [2]=SourceName (string).
        // The routing identity is the binding this feed was subscribed under (not the
        // event's name), so DataConnectionActor's NodeId-keyed routing matches — see #17
        // and OpcUaAlarmMapper.BuildIdentity. SourceName seeds the readable per-condition
        // key; fall back to the SourceNode NodeId string only when it is absent.
        var sourceName = fields[2].Value as string;
        if (string.IsNullOrEmpty(sourceName))
            sourceName = (fields[1].Value as NodeId)?.ToString() ?? "";
        var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
        var (sourceRef, sourceObjectRef) =
            OpcUaAlarmMapper.BuildIdentity(subscriptionSourceReference, sourceName, conditionName);
        if (string.IsNullOrEmpty(sourceRef))
            return; // not a condition event we can key

        var time = fields[3].Value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
        var message = (fields[4].Value as LocalizedText)?.Text ?? "";
        var severity = fields[5].Value is null ? 0 : Convert.ToInt32(fields[5].Value);

        var active = fields.Count > 6 && fields[6].Value is bool a && a;
        var acked = fields.Count <= 7 || fields[7].Value is not bool ak || ak; // default acked when absent
        bool? confirmed = fields.Count > 8 && fields[8].Value is bool cf ? cf : null;
        var suppressed = fields.Count > 9 && fields[9].Value is bool sp && sp;
        var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
        var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";

        // Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
        // transitioned to TRUE).  Absent on non-AlarmCondition events → guard + null fallback.
        DateTimeOffset? originalRaiseTime = null;
        if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
            // OPC UA mandates UTC for DateTime fields; a TimeSpan.Zero offset treats an
            // Unspecified Kind as UTC (consistent with the Time→TransitionTime mapping above).
            originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);

        // Indices 14–17: LimitAlarmType set-point thresholds (HighHighLimit/HighLimit/
        // LowLimit/LowLowLimit).  Absent on non-limit alarm types → null when missing.
        // Pick the first non-null value in priority order (HiHi > Hi > Lo > LoLo) as a
        // display-only representative limit; the caller is responsible for interpreting
        // which limit is active using AlarmTypeName or ConditionName.
        var limitValue = OpcUaAlarmMapper.PickLimitValue(
            fields.Count > 14 ? fields[14].Value : null,
            fields.Count > 15 ? fields[15].Value : null,
            fields.Count > 16 ? fields[16].Value : null,
            fields.Count > 17 ? fields[17].Value : null);

        // Index 18: AckedState/TransitionTime → the true source ack instant when the condition
        // is currently acked.  Absent on non-acknowledgeable events / servers that omit it →
        // guard + null fallback, which DeriveAckTime resolves to the event time below.
        DateTimeOffset? sourceAckTime = null;
        if (fields.Count > 18 && fields[18].Value is DateTime ackTransitionTime)
            // OPC UA mandates UTC for DateTime fields; TimeSpan.Zero treats an Unspecified
            // Kind as UTC (consistent with the Time and ActiveState/TransitionTime mappings).
            sourceAckTime = new DateTimeOffset(ackTransitionTime, TimeSpan.Zero);

        var inRefresh = _alarmInRefresh.GetValueOrDefault(handle);
        var lastState = _alarmLastState.GetValueOrDefault(handle);
        var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true);
        var kind = inRefresh
            ? AlarmTransitionKind.Snapshot
            : OpcUaAlarmMapper.DeriveKind(prevAcked, acked, prevActive, active);
        lastState?.TryAdd(sourceRef, (active, acked));
        if (lastState != null) lastState[sourceRef] = (active, acked);

        onTransition(new NativeAlarmTransition(
            SourceReference: sourceRef,
            SourceObjectReference: sourceObjectRef,
            // Resolve the event-type NodeId (e.g. "i=9341") to the friendly type name
            // the conditionFilter gate keys off; NodeId-string for custom types.
            AlarmTypeName: ResolveAlarmTypeName(eventType),
            Kind: kind,
            Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity),
            // UNAVAILABLE via standard OPC UA A&C event fields — see BuildAlarmEventFilter comments.
            Category: "",
            Description: "",
            Message: message,
            // UNAVAILABLE: OperatorUser not on refresh stream — see BuildAlarmEventFilter comments.
            OperatorUser: "",
            OperatorComment: comment,
            OriginalRaiseTime: originalRaiseTime,
            TransitionTime: time,
            // UNAVAILABLE: CurrentValue not a standard A&C event field — see BuildAlarmEventFilter.
            CurrentValue: "",
            LimitValue: limitValue,
            // MES alarm-status API §6.4: true source ack instant (index 18) when supplied,
            // else the observed event time; null unless the condition is active AND acked.
            AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time)));
    }

    private static NativeAlarmTransition SnapshotComplete() => new(
        "", "", "", AlarmTransitionKind.SnapshotComplete,
        new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
        "", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");

    /// <summary>
    /// Nodes per OPC UA Read/Write service call. Stays under the
    /// <c>MaxNodesPerRead</c> / <c>MaxNodesPerWrite</c> operation limits typical servers
    /// advertise, so a 37,500-tag re-seed never builds one oversized request.
    /// </summary>
    private const int MaxNodesPerServiceCall = 1000;

    /// <inheritdoc />
    public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
        string nodeId, CancellationToken cancellationToken = default)
    {
        // Batch-of-one delegation; a resolution failure still surfaces as the thrown
        // exception the single-node callers expect.
        var outcomes = await ReadValuesAsync([nodeId], cancellationToken);
        var outcome = outcomes[0];
        if (outcome.Error != null)
            throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
        return (outcome.Value, outcome.SourceTimestamp, outcome.StatusCode);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<OpcUaReadOutcome>> ReadValuesAsync(
        IReadOnlyList<string> nodeIds, CancellationToken cancellationToken = default)
    {
        var session = _session;
        if (session == null) throw new InvalidOperationException("Not connected.");

        var outcomes = new OpcUaReadOutcome[nodeIds.Count];

        // Resolve first — an unresolvable node id is a per-node fault, not a batch abort.
        var resolvable = new List<(int Index, string NodeId, ReadValueId Read)>(nodeIds.Count);
        for (var i = 0; i < nodeIds.Count; i++)
        {
            try
            {
                resolvable.Add((i, nodeIds[i], new ReadValueId
                {
                    NodeId = OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris),
                    AttributeId = Attributes.Value
                }));
            }
            catch (Exception ex)
            {
                outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown, ex.Message);
            }
        }

        for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
        {
            var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
            var collection = new ReadValueIdCollection(chunk.Select(c => c.Read));
            var response = await session.ReadAsync(
                null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), collection, cancellationToken);

            for (var i = 0; i < chunk.Count; i++)
            {
                var (index, nodeId, _) = chunk[i];
                if (response.Results is { Count: > 0 } results && i < results.Count)
                {
                    var result = results[i];
                    outcomes[index] = new OpcUaReadOutcome(
                        nodeId, result.Value, result.SourceTimestamp, result.StatusCode.Code, null);
                }
                else
                {
                    // Non-conformant server: fewer results than requested nodes.
                    outcomes[index] = new OpcUaReadOutcome(
                        nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError,
                        "OPC UA read returned no result for this node.");
                }
            }
        }

        return outcomes;
    }

    /// <inheritdoc />
    public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
    {
        // Batch-of-one delegation, mirroring ReadValueAsync.
        var outcomes = await WriteValuesAsync([(nodeId, value)], cancellationToken);
        var outcome = outcomes[0];
        if (outcome.Error != null)
            throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
        return outcome.StatusCode;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<OpcUaWriteOutcome>> WriteValuesAsync(
        IReadOnlyList<(string NodeId, object? Value)> values, CancellationToken cancellationToken = default)
    {
        var session = _session;
        if (session == null) throw new InvalidOperationException("Not connected.");

        var outcomes = new OpcUaWriteOutcome[values.Count];

        var resolvable = new List<(int Index, string NodeId, WriteValue Write)>(values.Count);
        for (var i = 0; i < values.Count; i++)
        {
            try
            {
                resolvable.Add((i, values[i].NodeId, new WriteValue
                {
                    NodeId = OpcUaNodeReference.Resolve(values[i].NodeId, session.NamespaceUris),
                    AttributeId = Attributes.Value,
                    Value = new DataValue(new Variant(values[i].Value))
                }));
            }
            catch (Exception ex)
            {
                outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown, ex.Message);
            }
        }

        for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
        {
            var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
            var collection = new WriteValueCollection(chunk.Select(c => c.Write));
            var response = await session.WriteAsync(null, collection, cancellationToken);

            for (var i = 0; i < chunk.Count; i++)
            {
                var (index, nodeId, _) = chunk[i];
                if (response.Results is { Count: > 0 } results && i < results.Count)
                {
                    outcomes[index] = new OpcUaWriteOutcome(nodeId, results[i].Code, null);
                }
                else
                {
                    outcomes[index] = new OpcUaWriteOutcome(
                        nodeId, StatusCodes.BadUnexpectedError,
                        "OPC UA write returned no result for this node.");
                }
            }
        }

        return outcomes;
    }

    /// <summary>
    /// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
    /// When CurrentState is bad, the server is unreachable. The once-only guard is an
    /// atomic compare-and-set, so a burst of failed keep-alives raises
    /// <see cref="ConnectionLost"/> exactly once.
    /// </summary>
    private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
    {
        if (ServiceResult.IsBad(e.Status))
        {
            if (Interlocked.Exchange(ref _connectionLostFired, 1) != 0) return;
            ConnectionLost?.Invoke();
        }
    }

    /// <summary>
    /// Asynchronously disposes the OPC UA client, disconnecting from the server.
    /// </summary>
    /// <returns>A task representing the asynchronous disposal.</returns>
    public async ValueTask DisposeAsync()
    {
        await DisconnectAsync();
    }

    private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options)
    {
        if (options is null) return null;
        return options.TokenType.ToUpperInvariant() switch
        {
            "USERNAMEPASSWORD" => new UserIdentity(
                options.Username,
                System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")),
            "X509CERTIFICATE" => new UserIdentity(
                X509CertificateLoader.LoadPkcs12FromFile(
                    options.CertificatePath, options.CertificatePassword)),
            _ => null
        };
    }

    private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband)
    {
        if (deadband is null) return null;
        var deadbandType = deadband.Type.ToUpperInvariant() switch
        {
            "PERCENT" => DeadbandType.Percent,
            _ => DeadbandType.Absolute
        };
        return new DataChangeFilter
        {
            Trigger = DataChangeTrigger.StatusValue,
            DeadbandType = (uint)deadbandType,
            DeadbandValue = deadband.Value
        };
    }

    private static TimestampsToReturn MapTimestampsToReturn(string mode) =>
        mode.ToUpperInvariant() switch
        {
            "SERVER" => TimestampsToReturn.Server,
            "BOTH" => TimestampsToReturn.Both,
            _ => TimestampsToReturn.Source
        };

    private static string ResolveStorePath(string configured, string fallbackLeaf) =>
        string.IsNullOrWhiteSpace(configured)
            ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
            : configured;

    // requestedMaxReferencesPerNode: cap the server's per-call references so a
    // huge flat folder cannot return an unbounded set. 500 leaves headroom for
    // the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
    // even with long string NodeIds; a non-empty continuation point surfaces as
    // a ContinuationToken so the caller can page via BrowseNext.
    private const uint BrowseMaxReferencesPerNode = 500u;

    // NodeClassMask intentionally excludes ReferenceType, View, Variable-
    // Type, ObjectType, DataType. UI only needs Objects (navigable),
    // Variables (selectable), Methods (display-only).
    private const uint BrowseNodeClassMask =
        (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);

    /// <inheritdoc />
    public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
        string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
    {
        // Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
        // reference once, fail fast with a typed exception if the link is
        // down, then call the SDK's async API directly (no Task.Run wrap —
        // the OPC Foundation SDK already provides true async I/O).
        var session = _session;
        if (session is null || !session.Connected)
        {
            throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
                "OPC UA session is not connected.");
        }

        // ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty
        // / null input means "browse the root"; anything else is parsed as
        // an absolute NodeId expression.
        var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
            ? ObjectIds.ObjectsFolder
            : OpcUaNodeReference.Resolve(parentNodeId, session.NamespaceUris);

        // No token → fresh browse of the node. A non-empty token → continue a
        // prior browse via BrowseNext, falling back to a fresh browse if the
        // server has invalidated the continuation point.
        if (string.IsNullOrEmpty(continuationToken))
        {
            return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
        }

        try
        {
            // SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
            // continuationPoints, bool releaseContinuationPoint, ct) → returns
            // (ResponseHeader, ByteStringCollection revisedContinuationPoints,
            //  IList<ReferenceDescriptionCollection> results, IList<ServiceResult> errors).
            // releaseContinuationPoint:false keeps the point alive so the next page
            // can be fetched; we pass back exactly the one point we were handed.
            var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
                null,
                new ByteStringCollection { Convert.FromBase64String(continuationToken) },
                false,
                cancellationToken).ConfigureAwait(false);

            var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
            var refs = results is { Count: > 0 } ? results[0] : null;
            return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
        }
        catch (ServiceResultException ex) when (
            ex.StatusCode == StatusCodes.BadContinuationPointInvalid ||
            ex.StatusCode == StatusCodes.BadInvalidArgument)
        {
            // The continuation point expired or was rejected (e.g. a fresh
            // session, or the server timed it out). Recover by re-browsing the
            // parent from the start and returning its first page.
            _logger.LogDebug(ex,
                "OPC UA BrowseNext rejected the continuation point (status {Status:X8}); " +
                "falling back to a fresh browse of {Node}.", ex.StatusCode, nodeToBrowse);
            return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
        }
    }

    /// <inheritdoc />
    public Task<Commons.Interfaces.Protocol.AddressSpaceSearchResult> SearchAddressSpaceAsync(
        string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
    {
        // Fail fast with the typed exception when the link is down — mirrors the
        // BrowseChildrenAsync guard. (BrowseChildrenAsync would also throw on the
        // first page, but guarding here keeps the empty-query / zero-cap short-circuit
        // from masking a disconnected session.)
        var session = _session;
        if (session is null || !session.Connected)
        {
            throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
                "OPC UA session is not connected.");
        }

        // Bounded BFS over this client's OWN BrowseChildrenAsync — the shared
        // helper pages each node's continuation (BrowseNext) to the end, so a
        // big folder's later pages are searched too, not just the first page.
        return AddressSpaceSearch.SearchAsync(
            BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
    }

    /// <summary>
    /// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
    /// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
    /// continuation point as a token for paging.
    /// </summary>
    private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> FreshBrowseAsync(
        ISession session, NodeId nodeToBrowse, CancellationToken cancellationToken)
    {
        var (_, continuationPoint, references) = await session.BrowseAsync(
            null,
            null,
            nodeToBrowse,
            BrowseMaxReferencesPerNode,
            BrowseDirection.Forward,
            ReferenceTypeIds.HierarchicalReferences,
            true,
            BrowseNodeClassMask,
            cancellationToken).ConfigureAwait(false);

        return await BuildResultAsync(session, references, continuationPoint, cancellationToken)
            .ConfigureAwait(false);
    }

    /// <summary>
    /// Shared "build children + B1 type enrichment + continuation token" logic
    /// used by both the initial browse and the BrowseNext paths so the
    /// enrichment is never duplicated. A non-empty
    /// <paramref name="continuationPoint"/> is surfaced as a Base64
    /// <c>ContinuationToken</c> with <c>Truncated=true</c>; otherwise the result
    /// is exhausted (<c>ContinuationToken=null, Truncated=false</c>).
    /// </summary>
    private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
        ISession session,
        ReferenceDescriptionCollection? references,
        byte[]? continuationPoint,
        CancellationToken cancellationToken)
    {
        var refs = references ?? new ReferenceDescriptionCollection();
        var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
        foreach (var r in refs)
        {
            children.Add(new Commons.Interfaces.Protocol.BrowseNode(
                // Durable nsu= form, not r.NodeId.ToString(): the raw ExpandedNodeId
                // string can carry a namespace URI or server index that Resolve cannot
                // read back, and a bare ns= index rots the moment the server's
                // namespace table changes. What the picker shows is what gets stored.
                NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
                DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
                NodeClass: MapNodeClass(r.NodeClass),
                HasChildren: r.NodeClass == NodeClass.Object));
        }

        // B1 type-info: enrich Variable rows with DataType / ValueRank /
        // Writable so the node picker can show types. Best-effort: ONE batched
        // ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
        // Variable child; on ANY failure we leave the three fields null and
        // return the children exactly as built above. Non-Variable nodes are
        // never read and keep null type info. Runs identically on the browse
        // and browse-next pages.
        children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
            .ConfigureAwait(false);

        // A non-empty continuation point means the server has more refs than
        // were returned on this page. Surface it as an opaque Base64 token the
        // caller passes back to fetch the next page via BrowseNext.
        var hasMore = continuationPoint != null && continuationPoint.Length > 0;
        var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
        return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
    }

    private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
    {
        NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object,
        NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable,
        NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method,
        _ => Commons.Interfaces.Protocol.BrowseNodeClass.Other
    };

    // Type-info: best-effort enrichment of Variable rows with DataType,
    // ValueRank, and Writable. Reads all three attributes for every Variable
    // child in ONE ReadAsync round-trip; on any failure (or zero variables)
    // returns the input children unchanged. Caller has already built the
    // BrowseNode list, so a swallowed failure simply means no type columns.
    private async Task<List<Commons.Interfaces.Protocol.BrowseNode>> EnrichVariableTypeInfoAsync(
        ISession session,
        ReferenceDescriptionCollection refs,
        List<Commons.Interfaces.Protocol.BrowseNode> children,
        CancellationToken cancellationToken)
    {
        try
        {
            // Build a flat read of (DataType, ValueRank, UserAccessLevel) per
            // Variable child, remembering which children-list slot each triple
            // maps back to so the read values can be re-stitched in order.
            var readIds = new ReadValueIdCollection();
            var variableSlots = new List<int>();
            for (var i = 0; i < refs.Count; i++)
            {
                if (refs[i].NodeClass != NodeClass.Variable)
                {
                    continue;
                }

                var nodeId = ExpandedNodeId.ToNodeId(refs[i].NodeId, session.NamespaceUris);
                if (nodeId is null)
                {
                    continue;
                }

                variableSlots.Add(i);
                readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType });
                readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank });
                readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.UserAccessLevel });
            }

            if (variableSlots.Count == 0)
            {
                return children;
            }

            var readResponse = await session.ReadAsync(
                null,
                0,
                TimestampsToReturn.Neither,
                readIds,
                cancellationToken).ConfigureAwait(false);

            var results = readResponse.Results;
            if (results is null || results.Count != readIds.Count)
            {
                // Defensive: a non-conformant server. Skip enrichment entirely
                // rather than risk mis-aligning values to the wrong nodes.
                return children;
            }

            for (var v = 0; v < variableSlots.Count; v++)
            {
                var baseIdx = v * 3;
                var dataTypeResult = results[baseIdx];
                var valueRankResult = results[baseIdx + 1];
                var accessLevelResult = results[baseIdx + 2];

                string? dataType = null;
                if (StatusCode.IsGood(dataTypeResult.StatusCode) && dataTypeResult.Value is NodeId dtNodeId)
                {
                    dataType = OpcUaBuiltInTypeNames.Resolve(dtNodeId);
                }

                int? valueRank = null;
                if (StatusCode.IsGood(valueRankResult.StatusCode) && valueRankResult.Value is int vr)
                {
                    valueRank = vr;
                }

                bool? writable = null;
                if (StatusCode.IsGood(accessLevelResult.StatusCode) && accessLevelResult.Value is byte accessLevel)
                {
                    writable = ((AccessLevelType)accessLevel).HasFlag(AccessLevelType.CurrentWrite);
                }

                var slot = variableSlots[v];
                children[slot] = children[slot] with
                {
                    DataType = dataType,
                    ValueRank = valueRank,
                    Writable = writable
                };
            }

            return children;
        }
        catch (OperationCanceledException)
        {
            // Honour cancellation — the caller's BrowseAsync already completed,
            // but a cancelled type-read should propagate, not be swallowed as a
            // best-effort miss.
            throw;
        }
        catch (Exception ex)
        {
            // Best-effort: any other failure (server quirk, transient read
            // error, type surprise) must NOT fail the browse. Return the
            // children as originally built, with null type info.
            _logger.LogDebug(ex, "Best-effort OPC UA variable type-info read failed; returning browse results without type columns.");
            return children;
        }
    }
}

/// <summary>
/// Type-info: maps well-known OPC UA built-in <c>DataType</c> NodeIds
/// (namespace 0 numeric ids in <see cref="DataTypeIds"/>) to friendly,
/// CLR-flavoured names for display in the node picker. Vendor / structured
/// DataTypes (anything not in the built-in table) fall back to the NodeId
/// string — the only thing meaningful the UI can render for an opaque type.
/// A null input yields the empty string so a missing DataType attribute on a
/// best-effort browse read never throws.
/// </summary>
internal static class OpcUaBuiltInTypeNames
{
    private static readonly IReadOnlyDictionary<NodeId, string> Names = new Dictionary<NodeId, string>
    {
        [DataTypeIds.Boolean] = "Boolean",
        [DataTypeIds.SByte] = "SByte",
        [DataTypeIds.Byte] = "Byte",
        [DataTypeIds.Int16] = "Int16",
        [DataTypeIds.UInt16] = "UInt16",
        [DataTypeIds.Int32] = "Int32",
        [DataTypeIds.UInt32] = "UInt32",
        [DataTypeIds.Int64] = "Int64",
        [DataTypeIds.UInt64] = "UInt64",
        [DataTypeIds.Float] = "Float",
        [DataTypeIds.Double] = "Double",
        [DataTypeIds.String] = "String",
        [DataTypeIds.DateTime] = "DateTime",
        [DataTypeIds.Guid] = "Guid",
        [DataTypeIds.ByteString] = "ByteString",
        [DataTypeIds.XmlElement] = "XmlElement",
        [DataTypeIds.NodeId] = "NodeId",
        [DataTypeIds.ExpandedNodeId] = "ExpandedNodeId",
        [DataTypeIds.StatusCode] = "StatusCode",
        [DataTypeIds.QualifiedName] = "QualifiedName",
        [DataTypeIds.LocalizedText] = "LocalizedText",
        [DataTypeIds.DataValue] = "DataValue",
        [DataTypeIds.Number] = "Number",
        [DataTypeIds.Integer] = "Integer",
        [DataTypeIds.UInteger] = "UInteger",
        [DataTypeIds.Enumeration] = "Enumeration",
        [DataTypeIds.BaseDataType] = "BaseDataType"
    };

    /// <summary>
    /// Resolves a DataType NodeId to a friendly built-in name, or the NodeId
    /// string for unknown / vendor types. Null returns <see cref="string.Empty"/>.
    /// </summary>
    /// <param name="dataTypeNodeId">The DataType attribute value read from the server, or null.</param>
    /// <returns>Friendly name for built-ins; NodeId string for unknowns; empty string for null.</returns>
    public static string Resolve(NodeId? dataTypeNodeId)
    {
        if (dataTypeNodeId is null)
        {
            return string.Empty;
        }

        return Names.TryGetValue(dataTypeNodeId, out var name)
            ? name
            : dataTypeNodeId.ToString();
    }
}

/// <summary>
/// Factory that creates real OPC UA client instances using the OPC Foundation SDK.
/// </summary>
public class RealOpcUaClientFactory : IOpcUaClientFactory
{
    private readonly OpcUaGlobalOptions _globalOptions;

    // A real logger must be threaded through to every
    // RealOpcUaClient this factory builds, otherwise the auto-accept-certificate
    // warning emitted in RealOpcUaClient.ConnectAsync sinks into NullLogger and is never
    // seen in production. The factory is constructed by DataConnectionFactory, which has
    // an ILoggerFactory available.
    private readonly ILoggerFactory _loggerFactory;

    /// <summary>
    /// Initializes a new instance of the RealOpcUaClientFactory class with default options.
    /// </summary>
    public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { }

    /// <summary>
    /// Initializes a new instance of the RealOpcUaClientFactory class with global options.
    /// </summary>
    /// <param name="globalOptions">Global OPC UA options.</param>
    public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions)
        : this(globalOptions, NullLoggerFactory.Instance) { }

    /// <summary>
    /// Initializes a new instance of the RealOpcUaClientFactory class with options and logger factory.
    /// </summary>
    /// <param name="globalOptions">Global OPC UA options.</param>
    /// <param name="loggerFactory">Logger factory for creating loggers.</param>
    public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions, ILoggerFactory loggerFactory)
    {
        _globalOptions = globalOptions;
        _loggerFactory = loggerFactory;
    }

    /// <inheritdoc />
    public IOpcUaClient Create() =>
        new RealOpcUaClient(_globalOptions, _loggerFactory.CreateLogger<RealOpcUaClient>());
}
