using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

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

/// <summary>
/// OPC UA adapter implementing IDataConnection.
/// Maps IDataConnection methods to OPC UA concepts via IOpcUaClient abstraction.
///
/// OPC UA mapping:
/// - TagPath → NodeId (durable form, e.g. "nsu=https://server/ns;s=MyDevice.Temperature";
///   the legacy "ns=2;s=..." index form is still accepted — see OpcUaNodeReference)
/// - Subscribe → MonitoredItem with DataChangeNotification
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection
    : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable,
      IBatchSubscribableConnection
{
    private readonly IOpcUaClientFactory _clientFactory;
    private readonly ILogger<OpcUaDataConnection> _logger;
    private IOpcUaClient? _client;
    private string _endpointUrl = string.Empty;
    private ConnectionHealth _status = ConnectionHealth.Disconnected;

    // The previous _subscriptionHandles Dictionary was
    // dead state — written by SubscribeAsync, removed by UnsubscribeAsync, but
    // never read anywhere. Plain Dictionary writes from thread-pool continuations
    // after await are racy (concurrent resize is undefined: InvalidOperationException,
    // bucket corruption, or silently lost entries). Bookkeeping for subscriptions
    // lives at two genuine layers: RealOpcUaClient._monitoredItems/_callbacks
    // (already ConcurrentDictionary) at the device adapter, and
    // DataConnectionActor._subscriptionIds at the actor — both authoritative.
    // The adapter has no need for a third copy; the field is removed rather than
    // converted to ConcurrentDictionary because there is no reader.
    private StaleTagMonitor? _staleMonitor;
    private string? _heartbeatSubscriptionId;

    /// <summary>
    /// Initializes a new instance of <see cref="OpcUaDataConnection"/>.
    /// </summary>
    /// <param name="clientFactory">Factory used to create OPC UA client instances.</param>
    /// <param name="logger">Logger instance.</param>
    public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
    {
        _clientFactory = clientFactory;
        _logger = logger;
    }

    // An int flag toggled with Interlocked.Exchange so the
    // "only the first caller fires Disconnected" guard in RaiseDisconnected is genuinely
    // atomic. A plain volatile bool gives visibility but not atomicity — two threads
    // (e.g. the keep-alive thread and a ReadAsync failure path) could both observe it
    // false and both raise the event. 0 = not fired, 1 = fired.
    private int _disconnectFired;

    /// <inheritdoc />
    public ConnectionHealth Status => _status;
    /// <summary>Raised when the OPC UA connection is lost.</summary>
    public event Action? Disconnected;

    /// <inheritdoc />
    public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
    {
        var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);

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

        var options = new OpcUaConnectionOptions(
            SessionTimeoutMs: config.SessionTimeoutMs,
            OperationTimeoutMs: config.OperationTimeoutMs,
            PublishingIntervalMs: config.PublishingIntervalMs,
            KeepAliveCount: config.KeepAliveCount,
            LifetimeCount: config.LifetimeCount,
            MaxNotificationsPerPublish: config.MaxNotificationsPerPublish,
            SamplingIntervalMs: config.SamplingIntervalMs,
            QueueSize: config.QueueSize,
            SecurityMode: config.SecurityMode.ToString(),
            AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts,
            DiscardOldest: config.DiscardOldest,
            SubscriptionPriority: config.SubscriptionPriority,
            SubscriptionDisplayName: config.SubscriptionDisplayName,
            TimestampsToReturn: config.TimestampsToReturn.ToString(),
            Deadband: config.Deadband is { } db
                ? new OpcUaDeadbandOptions(db.Type.ToString(), db.Value)
                : null,
            UserIdentity: config.UserIdentity is { } ui
                ? new OpcUaUserIdentityOptions(
                    ui.TokenType.ToString(), ui.Username, ui.Password,
                    ui.CertificatePath, ui.CertificatePassword)
                : null,
            MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription);

        _status = ConnectionHealth.Connecting;

        // Reconnect reuses this adapter instance (DataConnectionActor re-calls
        // ConnectAsync on auto-reconnect): detach + dispose the previous client so
        // (a) its session/keep-alive/socket do not leak per flap and (b) its late
        // ConnectionLost cannot flap the NEW healthy session after _disconnectFired
        // is reset below. Also stop the previous heartbeat monitor so the old
        // StaleTagMonitor cannot fire against the new session. Dispose is best-effort
        // fire-and-forget (CloseAsync against a dead server can block for the
        // operation timeout); faults are logged, never thrown into the connect path.
        StopHeartbeatMonitor();
        if (_client is { } previousClient)
        {
            previousClient.ConnectionLost -= OnClientConnectionLost;
            _ = previousClient.DisposeAsync().AsTask().ContinueWith(
                t => _logger.LogWarning(t.Exception?.GetBaseException(),
                    "Disposing previous OPC UA client failed for {Endpoint}", _endpointUrl),
                TaskContinuationOptions.OnlyOnFaulted);
        }

        _client = _clientFactory.Create();
        _client.ConnectionLost += OnClientConnectionLost;
        await _client.ConnectAsync(_endpointUrl, options, cancellationToken);

        _status = ConnectionHealth.Connected;
        Interlocked.Exchange(ref _disconnectFired, 0);
        _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);

        await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
    }

    private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken)
    {
        if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath))
            return;

        _staleMonitor?.Dispose();
        _staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
        _staleMonitor.Stale += () =>
        {
            _logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s",
                heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
            RaiseDisconnected();
        };

        try
        {
            _heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
                (_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
            _staleMonitor.Start();
            _logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence",
                heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active",
                heartbeat.TagPath);
            _staleMonitor.Dispose();
            _staleMonitor = null;
        }
    }

    private void OnClientConnectionLost()
    {
        RaiseDisconnected();
    }

    /// <inheritdoc />
    public async Task DisconnectAsync(CancellationToken cancellationToken = default)
    {
        StopHeartbeatMonitor();
        if (_client != null)
        {
            _client.ConnectionLost -= OnClientConnectionLost;
            await _client.DisconnectAsync(cancellationToken);
            _status = ConnectionHealth.Disconnected;
            _logger.LogInformation("OPC UA disconnected from {Endpoint}", _endpointUrl);
        }
    }

    /// <inheritdoc />
    public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        // SubscriptionId is returned directly to the
        // caller (DataConnectionActor stores it in _subscriptionIds). No local
        // bookkeeping is kept here — see the field-deletion note above.
        return await _client!.CreateSubscriptionAsync(
            tagPath,
            (nodeId, value, timestamp, statusCode) =>
            {
                var quality = MapStatusCode(statusCode);
                callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
            },
            cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
        IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        // ONE shared callback for the whole batch — the client hands back the node id
        // (== tag path for OPC UA), so no per-tag closure is needed.
        var outcomes = await _client!.CreateSubscriptionsAsync(
            tagPaths,
            (nodeId, value, timestamp, statusCode) =>
            {
                var quality = MapStatusCode(statusCode);
                callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
            },
            cancellationToken);

        return outcomes
            .Select(o => new TagSubscribeResult(o.NodeId, o.Success, o.SubscriptionHandle, o.Error))
            .ToList();
    }

    /// <inheritdoc />
    public async Task UnsubscribeBatchAsync(
        IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
    {
        if (_client != null)
            await _client.RemoveSubscriptionsAsync(subscriptionIds, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<string> SubscribeAlarmsAsync(
        string sourceReference, string? conditionFilter,
        AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
    {
        EnsureConnected();
        // The client maps OPC UA A&C event fields → NativeAlarmTransition via
        // OpcUaAlarmMapper and replays a snapshot on (re)subscribe.
        return await _client!.CreateAlarmSubscriptionAsync(
            sourceReference, conditionFilter,
            transition => callback(transition),
            cancellationToken);
    }

    /// <inheritdoc />
    public async Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
    {
        if (_client != null)
            await _client.RemoveAlarmSubscriptionAsync(subscriptionId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
    {
        if (_client != null)
        {
            await _client.RemoveSubscriptionAsync(subscriptionId, cancellationToken);
        }
    }

    /// <inheritdoc />
    public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        try
        {
            var (value, timestamp, statusCode) = await _client!.ReadValueAsync(tagPath, cancellationToken);
            var quality = MapStatusCode(statusCode);

            if (quality == QualityCode.Bad)
                return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");

            return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogWarning(ex, "OPC UA read failed for {TagPath} — connection may be lost", tagPath);
            RaiseDisconnected();
            throw;
        }
    }

    /// <inheritdoc />
    public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        // TRUE bulk: one OPC UA Read service call (chunked inside the client against the
        // server's operation limits), not a loop over ReadAsync. A single failing tag
        // still comes back as a failed ReadResult row rather than aborting the batch;
        // OperationCanceledException still aborts the whole batch.
        var requested = tagPaths as IReadOnlyList<string> ?? tagPaths.ToList();
        var results = new Dictionary<string, ReadResult>(requested.Count);
        if (requested.Count == 0)
            return results;

        try
        {
            var outcomes = await _client!.ReadValuesAsync(requested, cancellationToken);
            foreach (var outcome in outcomes)
            {
                if (outcome.Error != null)
                {
                    results[outcome.NodeId] = new ReadResult(false, null, outcome.Error);
                    continue;
                }

                var quality = MapStatusCode(outcome.StatusCode);
                results[outcome.NodeId] = quality == QualityCode.Bad
                    ? new ReadResult(false, null, $"OPC UA read returned bad status: 0x{outcome.StatusCode:X8}")
                    : new ReadResult(true,
                        new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)),
                        null);
            }
        }
        catch (OperationCanceledException)
        {
            // Cancellation aborts the whole batch — propagate it.
            throw;
        }
        catch (Exception ex)
        {
            // A batch-level fault (session dropped mid-call) mirrors ReadAsync: signal the
            // disconnect, then report every requested tag as failed so the caller still
            // receives a complete map instead of an exception.
            _logger.LogWarning(ex, "OPC UA batch read failed — connection may be lost");
            RaiseDisconnected();
            foreach (var tagPath in requested)
                results[tagPath] = new ReadResult(false, null, ex.Message);
        }

        // Defensive: a non-conformant client/server that omits a requested tag.
        foreach (var tagPath in requested)
        {
            if (!results.ContainsKey(tagPath))
                results[tagPath] = new ReadResult(false, null, "Tag missing from OPC UA read result.");
        }

        return results;
    }

    /// <inheritdoc />
    public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        var statusCode = await _client!.WriteValueAsync(tagPath, value, cancellationToken);
        if (statusCode != 0)
            return new WriteResult(false, $"OPC UA write failed with status: 0x{statusCode:X8}");

        return new WriteResult(true, null);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
    {
        EnsureConnected();

        // TRUE bulk: one OPC UA Write service call (chunked inside the client). A mid-batch
        // fault must not abort the batch — every requested tag gets a WriteResult row —
        // while OperationCanceledException still aborts as a whole.
        var requested = values.ToList();
        var results = new Dictionary<string, WriteResult>(requested.Count);
        if (requested.Count == 0)
            return results;

        try
        {
            var outcomes = await _client!.WriteValuesAsync(
                requested.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken);
            foreach (var outcome in outcomes)
            {
                results[outcome.NodeId] = outcome.Error != null
                    ? new WriteResult(false, outcome.Error)
                    : outcome.StatusCode != 0
                        ? new WriteResult(false, $"OPC UA write failed with status: 0x{outcome.StatusCode:X8}")
                        : new WriteResult(true, null);
            }
        }
        catch (OperationCanceledException)
        {
            // Cancellation aborts the whole batch — propagate it.
            throw;
        }
        catch (Exception ex)
        {
            foreach (var (tagPath, _) in requested)
                results[tagPath] = new WriteResult(false, ex.Message);
        }

        foreach (var (tagPath, _) in requested)
        {
            if (!results.ContainsKey(tagPath))
                results[tagPath] = new WriteResult(false, "Tag missing from OPC UA write result.");
        }

        return results;
    }

    /// <inheritdoc />
    public Task<BrowseChildrenResult> BrowseChildrenAsync(
        string? parentNodeId,
        string? continuationToken = null,
        CancellationToken cancellationToken = default)
        => _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);

    /// <inheritdoc />
    public Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
        string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
    {
        // Guard connection state with the typed ConnectionNotConnectedException so
        // the site-side handler can map it uniformly (mirrors BrowseChildrenAsync,
        // whose underlying client throws the same type when the session is down).
        if (_client == null || !_client.IsConnected)
            throw new ConnectionNotConnectedException("OPC UA client is not connected.");

        return _client.SearchAddressSpaceAsync(query, maxDepth, maxResults, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<bool> WriteBatchAndWaitAsync(
        IDictionary<string, object?> values, string flagPath, object? flagValue,
        string responsePath, object? responseValue, TimeSpan timeout,
        CancellationToken cancellationToken = default)
    {
        // Write all values including the flag
        var allValues = new Dictionary<string, object?>(values) { [flagPath] = flagValue };
        var writeResults = await WriteBatchAsync(allValues, cancellationToken);

        if (writeResults.Values.Any(r => !r.Success))
            return false;

        // Poll for response value within timeout
        var deadline = DateTimeOffset.UtcNow + timeout;
        while (DateTimeOffset.UtcNow < deadline)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var readResult = await ReadAsync(responsePath, cancellationToken);
            if (readResult.Success && readResult.Value != null && Equals(readResult.Value.Value, responseValue))
                return true;

            await Task.Delay(100, cancellationToken);
        }

        return false;
    }

    private void StopHeartbeatMonitor()
    {
        _staleMonitor?.Dispose();
        _staleMonitor = null;
        _heartbeatSubscriptionId = null;
    }

    /// <summary>
    /// Asynchronously disposes the OPC UA connection, releasing the client and stopping the heartbeat monitor.
    /// </summary>
    /// <returns>A <see cref="ValueTask"/> that completes when the client has been released and the heartbeat monitor stopped.</returns>
    public async ValueTask DisposeAsync()
    {
        StopHeartbeatMonitor();
        if (_client != null)
        {
            _client.ConnectionLost -= OnClientConnectionLost;
            await _client.DisposeAsync();
            _client = null;
        }
        _status = ConnectionHealth.Disconnected;
    }

    private void EnsureConnected()
    {
        if (_client == null || !_client.IsConnected)
            throw new InvalidOperationException("OPC UA client is not connected.");
    }

    /// <summary>
    /// Marks the connection as disconnected and fires the Disconnected event once.
    /// Thread-safe: the firing guard is an atomic compare-and-set
    /// (<see cref="Interlocked.Exchange(ref int, int)"/>), so when several threads race
    /// here — e.g. the keep-alive thread via <see cref="OnClientConnectionLost"/> and a
    /// <c>ReadAsync</c> failure path — exactly one of them observes the 0→1 transition
    /// and invokes <see cref="Disconnected"/>.
    /// </summary>
    private void RaiseDisconnected()
    {
        if (Interlocked.Exchange(ref _disconnectFired, 1) != 0) return;
        _status = ConnectionHealth.Disconnected;
        _logger.LogWarning("OPC UA connection to {Endpoint} lost", _endpointUrl);
        Disconnected?.Invoke();
    }

    /// <summary>
    /// Maps OPC UA StatusCode to QualityCode.
    /// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
    /// </summary>
    private static QualityCode MapStatusCode(uint statusCode)
    {
        if (statusCode == 0) return QualityCode.Good;
        if ((statusCode & 0x80000000) != 0) return QualityCode.Bad;
        return QualityCode.Uncertain;
    }
}
