using System.Text;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;

namespace ZB.MOM.WW.OtOpcUa.Client.Shared;

/// <summary>
///     Full implementation of <see cref="IOpcUaClientService" /> using adapter abstractions for testability.
/// </summary>
public sealed class OpcUaClientService : IOpcUaClientService
{
    private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();

    // Guards all access to the subscription-bookkeeping state below
    // (_activeDataSubscriptions and _activeAlarmSubscription). The dictionary
    // and tuple are mutated from the caller thread, the keep-alive failover
    // path, and DisconnectAsync, so every read/write must be inside this lock.
    private readonly object _subscriptionLock = new();

    // Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers
    // cannot both pass the _alarmSubscription == null check and create duplicate event
    // subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block).
    private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1);

    // Track active data subscriptions for replay after failover
    private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();

    // Re-entry guard for HandleKeepAliveFailureAsync. The OPC UA stack raises
    // KeepAlive repeatedly while a session is down; only one failover loop may
    // run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
    private int _failoverInProgress;

    private readonly IApplicationConfigurationFactory _configFactory;
    private readonly IEndpointDiscovery _endpointDiscovery;

    private readonly ISessionFactory _sessionFactory;

    // Track alarm subscription state for replay after failover
    private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription;
    private ISubscriptionAdapter? _alarmSubscription;
    private string[]? _allEndpointUrls;
    private int _currentEndpointIndex;
    private ISubscriptionAdapter? _dataSubscription;
    private bool _disposed;

    private ISessionAdapter? _session;
    private ConnectionSettings? _settings;
    private ConnectionState _state = ConnectionState.Disconnected;

    /// <summary>
    ///     Creates a new OpcUaClientService with the specified adapter dependencies.
    /// </summary>
    /// <param name="configFactory">Builds the application configuration and certificate settings for the client session.</param>
    /// <param name="endpointDiscovery">Selects the best matching endpoint for the requested URL and security mode.</param>
    /// <param name="sessionFactory">Creates the underlying OPC UA session used for runtime operations.</param>
    internal OpcUaClientService(
        IApplicationConfigurationFactory configFactory,
        IEndpointDiscovery endpointDiscovery,
        ISessionFactory sessionFactory)
    {
        _configFactory = configFactory;
        _endpointDiscovery = endpointDiscovery;
        _sessionFactory = sessionFactory;
    }

    /// <summary>
    ///     Creates a new OpcUaClientService with default production adapters.
    /// </summary>
    public OpcUaClientService()
        : this(
            new DefaultApplicationConfigurationFactory(),
            new DefaultEndpointDiscovery(),
            new DefaultSessionFactory())
    {
    }

    /// <inheritdoc />
    public event EventHandler<DataChangedEventArgs>? DataChanged;

    /// <inheritdoc />
    public event EventHandler<AlarmEventArgs>? AlarmEvent;

    /// <inheritdoc />
    public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;

    /// <inheritdoc />
    public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;

    /// <inheritdoc />
    public ConnectionInfo? CurrentConnectionInfo { get; private set; }

    /// <inheritdoc />
    public async Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        settings.Validate();

        _settings = settings;
        _allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
        _currentEndpointIndex = 0;

        TransitionState(ConnectionState.Connecting, settings.EndpointUrl);

        try
        {
            var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
            _session = session;

            session.RegisterKeepAliveHandler(isGood =>
            {
                if (!isGood) _ = HandleKeepAliveFailureAsync();
            });

            CurrentConnectionInfo = BuildConnectionInfo(session);
            TransitionState(ConnectionState.Connected, session.EndpointUrl);
            Logger.Information("Connected to {EndpointUrl}", session.EndpointUrl);
            return CurrentConnectionInfo;
        }
        catch
        {
            TransitionState(ConnectionState.Disconnected, settings.EndpointUrl);
            throw;
        }
    }

    /// <inheritdoc />
    public async Task DisconnectAsync(CancellationToken ct = default)
    {
        if (_state == ConnectionState.Disconnected)
            return;

        var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;

        // Snapshot the subscription adapter references under the lock so a concurrent
        // RunFailoverAsync that nulls _dataSubscription / _alarmSubscription
        // cannot invalidate the references between the null-check and the DeleteAsync call.
        ISubscriptionAdapter? dataSubscription;
        ISubscriptionAdapter? alarmSubscription;
        lock (_subscriptionLock)
        {
            dataSubscription = _dataSubscription;
            alarmSubscription = _alarmSubscription;
            _dataSubscription = null;
            _alarmSubscription = null;
        }

        try
        {
            if (dataSubscription != null)
                await dataSubscription.DeleteAsync(ct);

            if (alarmSubscription != null)
                await alarmSubscription.DeleteAsync(ct);

            if (_session != null)
            {
                await _session.CloseAsync(ct);
                _session.Dispose();
                _session = null;
            }
        }
        catch (Exception ex)
        {
            Logger.Warning(ex, "Error during disconnect");
        }
        finally
        {
            lock (_subscriptionLock)
            {
                _activeDataSubscriptions.Clear();
                _activeAlarmSubscription = null;
            }

            CurrentConnectionInfo = null;
            TransitionState(ConnectionState.Disconnected, endpointUrl);
        }
    }

    /// <inheritdoc />
    public async Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();
        return await _session!.ReadValueAsync(nodeId, ct);
    }

    /// <inheritdoc />
    public async Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // Read current value for type coercion when value is a string
        var typedValue = value;
        if (value is string rawString)
        {
            var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
            if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
                throw new InvalidOperationException(
                    $"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
            typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
        }

        var dataValue = new DataValue(new Variant(typedValue));
        return await _session!.WriteValueAsync(nodeId, dataValue, ct);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null,
        CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
        var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
        var results = new List<BrowseResult>();

        var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);

        while (references.Count > 0)
        {
            foreach (var reference in references)
            {
                var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
                var hasChildren = reference.NodeClass == NodeClass.Object &&
                                  await _session.HasChildrenAsync(childNodeId, ct);

                results.Add(new BrowseResult(
                    reference.NodeId.ToString(),
                    reference.DisplayName?.Text ?? string.Empty,
                    reference.NodeClass.ToString(),
                    hasChildren));
            }

            if (continuationPoint != null && continuationPoint.Length > 0)
                (continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
            else
                break;
        }

        return results;
    }

    /// <inheritdoc />
    public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        var nodeIdStr = nodeId.ToString();
        lock (_subscriptionLock)
        {
            if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
                return; // Already subscribed
        }

        if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);

        var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
            nodeId, intervalMs, OnDataChangeNotification, ct);

        lock (_subscriptionLock)
        {
            _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
        }

        Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
    }

    /// <inheritdoc />
    public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default)
    {
        ThrowIfDisposed();

        var nodeIdStr = nodeId.ToString();
        (NodeId NodeId, int IntervalMs, uint Handle) sub;
        lock (_subscriptionLock)
        {
            if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
                return; // Not subscribed, safe to ignore
        }

        if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);

        lock (_subscriptionLock)
        {
            _activeDataSubscriptions.Remove(nodeIdStr);
        }

        Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
    }

    /// <inheritdoc />
    public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000,
        CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // Serialise check-and-create under the semaphore: concurrent
        // callers cannot both pass the null-check and create duplicate alarm subscriptions.
        // The semaphore is async-friendly; the subscription I/O runs inside the critical section.
        await _alarmSubscribeSemaphore.WaitAsync(ct);
        try
        {
            if (_alarmSubscription != null)
                return; // Already subscribed to alarms

            var monitorNode = sourceNodeId ?? ObjectIds.Server;
            _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);

            var filter = CreateAlarmEventFilter();
            await _alarmSubscription.AddEventMonitoredItemAsync(
                monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);

            lock (_subscriptionLock)
            {
                _activeAlarmSubscription = (sourceNodeId, intervalMs);
            }

            Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode);
        }
        finally
        {
            _alarmSubscribeSemaphore.Release();
        }
    }

    /// <inheritdoc />
    public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default)
    {
        ThrowIfDisposed();

        if (_alarmSubscription == null)
            return;

        await _alarmSubscription.DeleteAsync(ct);
        _alarmSubscription = null;

        lock (_subscriptionLock)
        {
            _activeAlarmSubscription = null;
        }

        Logger.Debug("Unsubscribed from alarm events");
    }

    /// <inheritdoc />
    public async Task RequestConditionRefreshAsync(CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        if (_alarmSubscription == null)
            throw new InvalidOperationException("No alarm subscription is active.");

        await _alarmSubscription.ConditionRefreshAsync(ct);
        Logger.Debug("Condition refresh requested");
    }

    /// <inheritdoc />
    public async Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
        CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // The Acknowledge method lives on the .Condition child node, not the source node itself
        var conditionObjId = conditionNodeId.EndsWith(".Condition")
            ? NodeId.Parse(conditionNodeId)
            : NodeId.Parse(conditionNodeId + ".Condition");
        var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;

        // CallMethodAsync throws ServiceResultException on a bad call result;
        // surface that as the returned StatusCode so callers using the documented
        // `Task<StatusCode>` contract (e.g. `if (StatusCode.IsBad(result))`) see
        // the failure instead of an uncaught exception they did not anticipate.
        try
        {
            await _session!.CallMethodAsync(
                conditionObjId,
                acknowledgeMethodId,
                [eventId, new LocalizedText(comment)],
                ct);
        }
        catch (ServiceResultException ex)
        {
            Logger.Warning(ex, "Failed to acknowledge alarm on {ConditionId} (status {Status})",
                conditionNodeId, ex.StatusCode);
            return ex.StatusCode;
        }

        Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId);
        return StatusCodes.Good;
    }

    /// <inheritdoc />
    public async Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
        CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // Confirm lives on the same .Condition child node as Acknowledge
        var conditionObjId = conditionNodeId.EndsWith(".Condition")
            ? NodeId.Parse(conditionNodeId)
            : NodeId.Parse(conditionNodeId + ".Condition");
        var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;

        try
        {
            await _session!.CallMethodAsync(
                conditionObjId,
                confirmMethodId,
                [eventId, new LocalizedText(comment)],
                ct);
        }
        catch (ServiceResultException ex)
        {
            Logger.Warning(ex, "Failed to confirm alarm on {ConditionId} (status {Status})",
                conditionNodeId, ex.StatusCode);
            return ex.StatusCode;
        }

        Logger.Debug("Confirmed alarm on {ConditionId}", conditionNodeId);
        return StatusCodes.Good;
    }

    /// <inheritdoc />
    public async Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind,
        double shelvingTimeSeconds = 0, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // The shelve methods live on the ShelvingState child of the condition node.
        // conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
        var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
            ? NodeId.Parse(conditionNodeId)
            : NodeId.Parse(conditionNodeId + ".ShelvingState");

        NodeId methodId;
        object[] inputArgs;

        switch (kind)
        {
            case ShelveKind.OneShot:
                methodId = MethodIds.AlarmConditionType_ShelvingState_OneShotShelve;
                inputArgs = [];
                break;
            case ShelveKind.Timed:
                if (shelvingTimeSeconds <= 0)
                    throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds),
                        "Timed shelving requires a positive shelvingTimeSeconds value.");
                methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve;
                // OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds.
                inputArgs = [shelvingTimeSeconds * 1000.0];
                break;
            case ShelveKind.Unshelve:
                methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve;
                inputArgs = [];
                break;
            default:
                throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ShelveKind value.");
        }

        try
        {
            await _session!.CallMethodAsync(shelvingStateNodeId, methodId, inputArgs, ct);
        }
        catch (ServiceResultException ex)
        {
            Logger.Warning(ex, "Failed to shelve alarm on {ConditionId} kind={Kind} (status {Status})",
                conditionNodeId, kind, ex.StatusCode);
            return ex.StatusCode;
        }

        Logger.Debug("Shelved alarm on {ConditionId} kind={Kind}", conditionNodeId, kind);
        return StatusCodes.Good;
    }

    /// <inheritdoc />
    public Task<StatusCode> EnableAsync(string conditionNodeId, CancellationToken ct = default) =>
        CallEnableDisableAsync(conditionNodeId, enabling: true, ct);

    /// <inheritdoc />
    public Task<StatusCode> DisableAsync(string conditionNodeId, CancellationToken ct = default) =>
        CallEnableDisableAsync(conditionNodeId, enabling: false, ct);

    /// <summary>
    /// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType
    /// <c>Enable</c>/<c>Disable</c> method (no input arguments) on the condition object node, mirroring
    /// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods live on the <c>.Condition</c>
    /// child node, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
    /// escaping as an uncaught <see cref="ServiceResultException"/>.
    /// </summary>
    /// <param name="conditionNodeId">The condition node associated with the alarm.</param>
    /// <param name="enabling"><c>true</c> to invoke Enable; <c>false</c> to invoke Disable.</param>
    /// <param name="ct">The cancellation token that aborts the request.</param>
    private async Task<StatusCode> CallEnableDisableAsync(string conditionNodeId, bool enabling, CancellationToken ct)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        // Enable/Disable live on the same .Condition child node as Acknowledge/Confirm.
        var conditionObjId = conditionNodeId.EndsWith(".Condition")
            ? NodeId.Parse(conditionNodeId)
            : NodeId.Parse(conditionNodeId + ".Condition");
        var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable;

        try
        {
            await _session!.CallMethodAsync(conditionObjId, methodId, [], ct);
        }
        catch (ServiceResultException ex)
        {
            Logger.Warning(ex, "Failed to {Operation} alarm on {ConditionId} (status {Status})",
                enabling ? "enable" : "disable", conditionNodeId, ex.StatusCode);
            return ex.StatusCode;
        }

        Logger.Debug("{Operation} alarm on {ConditionId}", enabling ? "Enabled" : "Disabled", conditionNodeId);
        return StatusCodes.Good;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
        NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();
        return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
        NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
        double intervalMs = 3600000, CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();
        var aggregateNodeId = AggregateTypeMapper.ToNodeId(aggregate);
        return await _session!.HistoryReadAggregateAsync(nodeId, startTime, endTime, aggregateNodeId, intervalMs, ct);
    }

    /// <inheritdoc />
    public async Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default)
    {
        ThrowIfDisposed();
        ThrowIfNotConnected();

        var redundancySupportValue =
            await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
        RedundancySupport redundancySupport;
        if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
            redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
        else
            redundancySupport = RedundancySupport.None;
        var redundancyMode = redundancySupport.ToString();

        var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
        var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
            ? Convert.ToByte(serviceLevelValue.Value)
            : (byte)0;

        string[] serverUris = [];
        try
        {
            var serverUriArrayValue =
                await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
            if (serverUriArrayValue.Value is string[] uris)
                serverUris = uris;
        }
        catch
        {
            // ServerUriArray may not be present when RedundancySupport is None
        }

        var applicationUri = string.Empty;
        try
        {
            var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
            if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
                applicationUri = serverArray[0];
        }
        catch
        {
            // Informational only
        }

        return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri);
    }

    /// <summary>
    /// Releases the current session and any active monitored-item subscriptions held by the client service.
    /// </summary>
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        _dataSubscription?.Dispose();
        _alarmSubscription?.Dispose();
        _session?.Dispose();
        _alarmSubscribeSemaphore.Dispose();

        lock (_subscriptionLock)
        {
            _activeDataSubscriptions.Clear();
            _activeAlarmSubscription = null;
        }

        CurrentConnectionInfo = null;
        _state = ConnectionState.Disconnected;
    }

    // --- Private helpers ---

    private async Task<ISessionAdapter> ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl,
        CancellationToken ct)
    {
        // Create a settings copy with the current endpoint URL
        var effectiveSettings = new ConnectionSettings
        {
            EndpointUrl = endpointUrl,
            SecurityMode = settings.SecurityMode,
            SessionTimeoutSeconds = settings.SessionTimeoutSeconds,
            AutoAcceptCertificates = settings.AutoAcceptCertificates,
            CertificateStorePath = settings.CertificateStorePath,
            Username = settings.Username,
            Password = settings.Password
        };

        var config = await _configFactory.CreateAsync(effectiveSettings, ct);
        var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
        var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);

        var identity = settings.Username != null
            ? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
            : new UserIdentity();

        var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
        return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
            ct);
    }

    private async Task HandleKeepAliveFailureAsync()
    {
        // Serialize failover: the OPC UA stack raises KeepAlive repeatedly
        // while a session is down, so multiple bad keep-alives can fire before
        // the first failover loop finishes. CompareExchange atomically claims
        // the failover slot; a re-entrant call sees 1 and returns immediately,
        // guaranteeing exactly one failover loop runs at a time.
        if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0)
            return;

        try
        {
            await RunFailoverAsync();
        }
        finally
        {
            Interlocked.Exchange(ref _failoverInProgress, 0);
        }
    }

    private async Task RunFailoverAsync()
    {
        if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
            return;

        var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
        TransitionState(ConnectionState.Reconnecting, oldEndpoint);
        Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);

        // Close old session
        if (_session != null)
        {
            try
            {
                _session.Dispose();
            }
            catch
            {
            }

            _session = null;
        }

        _dataSubscription = null;
        _alarmSubscription = null;

        if (_settings == null || _allEndpointUrls == null)
        {
            TransitionState(ConnectionState.Disconnected, oldEndpoint);
            return;
        }

        // Try each endpoint
        for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
        {
            _currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
            var url = _allEndpointUrls[_currentEndpointIndex];

            try
            {
                Logger.Information("Failover attempt to {EndpointUrl}", url);
                var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
                _session = session;

                session.RegisterKeepAliveHandler(isGood =>
                {
                    if (!isGood) _ = HandleKeepAliveFailureAsync();
                });

                CurrentConnectionInfo = BuildConnectionInfo(session);
                TransitionState(ConnectionState.Connected, url);
                Logger.Information("Failover succeeded to {EndpointUrl}", url);

                // Replay subscriptions
                await ReplaySubscriptionsAsync();
                return;
            }
            catch (Exception ex)
            {
                Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
            }
        }

        Logger.Error("All failover endpoints unreachable");
        TransitionState(ConnectionState.Disconnected, oldEndpoint);
    }

    private async Task ReplaySubscriptionsAsync()
    {
        // Snapshot the bookkeeping state under the lock, then clear it so the
        // replayed handles can be recorded fresh as each monitored item is
        // re-created. Awaited calls run outside the lock.
        List<KeyValuePair<string, (NodeId NodeId, int IntervalMs, uint Handle)>> subscriptions;
        (NodeId? SourceNodeId, int IntervalMs)? alarmSubscription;
        lock (_subscriptionLock)
        {
            subscriptions = _activeDataSubscriptions.ToList();
            alarmSubscription = _activeAlarmSubscription;
            _activeDataSubscriptions.Clear();
            _activeAlarmSubscription = null;
        }

        // Replay data subscriptions
        foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
            try
            {
                if (_dataSubscription == null)
                    _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);

                var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
                    nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);

                lock (_subscriptionLock)
                {
                    _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
                }
            }
            catch (Exception ex)
            {
                Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
            }

        // Replay alarm subscription
        if (alarmSubscription.HasValue)
        {
            var (sourceNodeId, intervalMs) = alarmSubscription.Value;
            try
            {
                var monitorNode = sourceNodeId ?? ObjectIds.Server;
                _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
                var filter = CreateAlarmEventFilter();
                await _alarmSubscription.AddEventMonitoredItemAsync(
                    monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);

                lock (_subscriptionLock)
                {
                    _activeAlarmSubscription = (sourceNodeId, intervalMs);
                }
            }
            catch (Exception ex)
            {
                Logger.Warning(ex, "Failed to replay alarm subscription");
            }
        }
    }

    private void OnDataChangeNotification(string nodeId, DataValue value)
    {
        DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
    }

    private void OnAlarmEventNotification(EventFieldList eventFields)
    {
        var fields = eventFields.EventFields;
        if (fields == null || fields.Count < 1)
            return;

        var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
        var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
        var time = DateTime.MinValue;
        if (fields.Count > 3 && fields[3].Value != null)
        {
            if (fields[3].Value is DateTime dt)
                time = dt;
            else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
                time = parsed;
        }
        var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
        var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
        var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
        var retain = fields.Count > 7 && ParseBool(fields[7].Value);
        var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;

        // Try standard OPC UA ActiveState/AckedState fields first
        bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
        bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;

        var ackedState = ackedField ?? false;
        var activeState = activeField ?? false;

        // Fallback: read InAlarm/Acked from condition node Galaxy attributes
        // when the server doesn't populate standard event fields.
        // Must run on a background thread to avoid deadlocking the notification thread.
        // Capture the session reference now; skip supplemental reads if the session has
        // been replaced by a concurrent failover before the Task.Run body executes.
        if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
        {
            var session = _session;
            var capturedConditionNodeId = conditionNodeId;
            var capturedMessage = message;
            _ = Task.Run(async () =>
            {
                // If the session was replaced by a failover before we started reading,
                // skip the supplemental reads to avoid hitting a disposed session.
                if (!ReferenceEquals(session, _session))
                    return;

                try
                {
                    var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
                    if (inAlarmValue.Value is bool inAlarm) activeState = inAlarm;

                    var ackedValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.Acked"));
                    if (ackedValue.Value is bool acked) ackedState = acked;

                    if (time == DateTime.MinValue && activeState)
                    {
                        var timeValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.TimeAlarmOn"));
                        if (timeValue.Value is DateTime alarmTime && alarmTime != DateTime.MinValue)
                            time = alarmTime;
                    }

                    // Read alarm description to use as message
                    try
                    {
                        var descValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.DescAttrName"));
                        if (descValue.Value is string desc && !string.IsNullOrEmpty(desc))
                            capturedMessage = desc;
                    }
                    catch { /* DescAttrName may not exist */ }
                }
                catch (ObjectDisposedException)
                {
                    // Session was disposed during supplemental reads (concurrent failover);
                    // skip the event rather than delivering stale/default states.
                    Logger.Debug("Supplemental alarm read skipped — session disposed during failover for {ConditionNodeId}", capturedConditionNodeId);
                    return;
                }
                catch
                {
                    // Other supplemental read failure; deliver event with defaults
                }

                // OperatorComment is intentionally left null (default) here.
                // Two concrete reasons make it unrecoverable in this Galaxy sub-attribute fallback path:
                //   (a) Galaxy's AckMsg attribute is WRITE-ONLY — there is no readable attribute that
                //       exposes the last operator comment, so no supplemental read can recover it.
                //   (b) The OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field;
                //       the OPC UA event notification itself never delivers a comment string.
                // The operator comment is only available via the gateway's native alarm feed
                // (the Galaxy driver path, GalaxyAlarmTransition.OperatorComment), not this
                // OpcUaClient sub-attribute fallback.
                AlarmEvent?.Invoke(this, new AlarmEventArgs(
                    sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
                    eventId, capturedConditionNodeId));
            });
            return;
        }

        AlarmEvent?.Invoke(this, new AlarmEventArgs(
            sourceName, conditionName, severity, message, retain, activeState, ackedState, time,
            eventId, conditionNodeId));
    }

    private static bool ParseBool(object? value)
    {
        if (value == null) return false;
        if (value is bool b) return b;
        try { return Convert.ToBoolean(value); }
        catch { return false; }
    }

    private static EventFilter CreateAlarmEventFilter()
    {
        var filter = new EventFilter();
        // 0: EventId
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
        // 1: EventType
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
        // 2: SourceName
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
        // 3: Time
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
        // 4: Message
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
        // 5: Severity
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
        // 6: ConditionName
        filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
        // 7: Retain
        filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
        // 8: AckedState/Id
        filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
        // 9: ActiveState/Id
        filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
        // 10: EnabledState/Id
        filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
        // 11: SuppressedOrShelved
        filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
        // 12: SourceNode (ConditionId for acknowledgment)
        filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
        return filter;
    }

    private static ConnectionInfo BuildConnectionInfo(ISessionAdapter session)
    {
        return new ConnectionInfo(
            session.EndpointUrl,
            session.ServerName,
            session.SecurityMode,
            session.SecurityPolicyUri,
            session.SessionId,
            session.SessionName);
    }

    private void TransitionState(ConnectionState newState, string endpointUrl)
    {
        var oldState = _state;
        if (oldState == newState) return;
        _state = newState;
        ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
    }

    private void ThrowIfDisposed()
    {
        if (_disposed) throw new ObjectDisposedException(nameof(OpcUaClientService));
    }

    private void ThrowIfNotConnected()
    {
        if (_state != ConnectionState.Connected || _session == null)
            throw new InvalidOperationException("Not connected to an OPC UA server.");
    }
}
