using System.Collections.Concurrent;
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.Enums;
using ZB.MOM.WW.Secrets.Abstractions;

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

/// <summary>
/// MxGateway adapter implementing <see cref="IDataConnection"/> + <see cref="IBrowsableDataConnection"/>.
/// Maps IDataConnection concepts onto the MxAccess Gateway session model via the
/// <see cref="IMxGatewayClient"/> seam:
/// <list type="bullet">
///   <item>Connect → OpenSession + Register, then a background event loop.</item>
///   <item>Subscribe → AddItem + Advise; value changes arrive on the event stream.</item>
///   <item>Read/Write → ReadBulk / WriteBulk.</item>
///   <item>Browse → Galaxy repository BrowseChildren.</item>
/// </list>
/// Reconnection is driven by the <c>DataConnectionActor</c>: a stream fault raises
/// <see cref="Disconnected"/>, the actor disposes this adapter, creates a fresh one,
/// reconnects and re-subscribes all tags.
/// </summary>
public class MxGatewayDataConnection
    : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IBatchSubscribableConnection
{
    private readonly IMxGatewayClientFactory _clientFactory;
    private readonly ILogger<MxGatewayDataConnection> _logger;
    private readonly ISecretResolver? _secretResolver;
    private readonly int _supervisoryAdviseParallelism;
    private IMxGatewayClient? _client;
    private ConnectionHealth _status = ConnectionHealth.Disconnected;
    private CancellationTokenSource? _eventLoopCts;

    // Native alarm feed: the gateway StreamAlarms RPC is session-less and
    // gateway-wide, so one shared feed serves the whole connection. The
    // DataConnectionActor routes transitions to instances by source reference,
    // so a single shared callback (the first registered) suffices; subscriptions
    // are ref-counted so the feed stops when the last one is removed.
    private CancellationTokenSource? _alarmCts;
    private int _alarmSubCount;
    private readonly object _alarmLock = new();

    // Source references currently subscribed (alarm subscription id → source reference),
    // and the prefix the live stream was opened with. StreamAlarms carries ONE prefix, so
    // the pushable union is the longest common prefix of the active sources: the stream is
    // restarted only when a NEW source falls outside it. Guarded by _alarmLock.
    private readonly Dictionary<string, string> _alarmSubSources = new(StringComparer.Ordinal);
    private string _alarmStreamPrefix = string.Empty;

    // subscriptionId → (tagPath, callback) so the event loop can route updates by tag,
    // plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
    // loop reads from a background thread while Subscribe/Unsubscribe mutate.
    private readonly ConcurrentDictionary<string, (string TagPath, SubscriptionCallback Callback)> _subs = new();
    private readonly ConcurrentDictionary<string, string> _tagToSub = new();

    // DataConnectionLayer mirror of OpcUaDataConnection's once-only guard: an int toggled
    // with Interlocked.Exchange so only the first caller raises Disconnected.
    // 0 = not fired, 1 = fired. Reset on (re)connect.
    private int _disconnectFired;

    /// <summary>Initializes a new instance of <see cref="MxGatewayDataConnection"/>.</summary>
    /// <param name="clientFactory">Factory used to create gateway client instances.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="secretResolver">
    /// Optional secret resolver used to resolve a <c>secret:&lt;name&gt;</c> ApiKey reference at
    /// connect time. When null, a literal ApiKey still works, but a <c>secret:</c> reference
    /// fails closed (a connection is never established with an unresolved key).
    /// </param>
    /// <param name="supervisoryAdviseParallelism">
    /// Maximum in-flight supervisory advise commands on the bulk-subscribe path when the
    /// endpoint has no write-user context (<c>DataConnectionOptions.MxSupervisoryAdviseParallelism</c>).
    /// </param>
    public MxGatewayDataConnection(
        IMxGatewayClientFactory clientFactory,
        ILogger<MxGatewayDataConnection> logger,
        ISecretResolver? secretResolver = null,
        int supervisoryAdviseParallelism = 16)
    {
        _clientFactory = clientFactory;
        _logger = logger;
        _secretResolver = secretResolver;
        _supervisoryAdviseParallelism = Math.Max(1, supervisoryAdviseParallelism);
    }

    /// <inheritdoc />
    public ConnectionHealth Status => _status;

    /// <summary>Raised once when the gateway event stream faults (connection lost).</summary>
    public event Action? Disconnected;

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

        // Resolve a `secret:<name>` ApiKey reference BEFORE any teardown/client creation
        // so a fail-closed throw here leaves adapter state untouched (no half-open client,
        // previous connection — if any — still intact for a later retry). A literal key
        // (no `secret:` prefix) passes through unchanged for back-compat.
        var apiKey = cfg.ApiKey;
        if (!string.IsNullOrEmpty(apiKey) &&
            apiKey.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
        {
            if (_secretResolver is null)
            {
                throw new InvalidOperationException(
                    "MxGateway ApiKey uses a 'secret:' reference but no ISecretResolver is available to resolve it.");
            }
            var secretName = apiKey["secret:".Length..];
            apiKey = await _secretResolver.GetAsync(new SecretName(secretName), cancellationToken)
                     ?? throw new InvalidOperationException(
                         $"MxGateway ApiKey secret '{secretName}' could not be resolved (not found or tombstoned).");
        }

        // Reconnect reuses this adapter: cancel the previous event loop and dispose
        // the previous client so a flapping gateway cannot accumulate orphaned
        // streams/sockets, and the old loop's fault path cannot signal Disconnected
        // against the new session after the guard resets below. Keep this BEFORE the
        // _disconnectFired reset so a fault raised by the dying loop during teardown
        // is absorbed by the old guard cycle. Dispose is best-effort fire-and-forget.
        if (_eventLoopCts is { } previousLoopCts)
        {
            previousLoopCts.Cancel();
            previousLoopCts.Dispose();
            _eventLoopCts = null;
        }
        if (_client is { } previousClient)
        {
            _ = previousClient.DisposeAsync().AsTask().ContinueWith(
                t => _logger.LogWarning(t.Exception?.GetBaseException(),
                    "Disposing previous MxGateway client failed"),
                TaskContinuationOptions.OnlyOnFaulted);
        }

        Interlocked.Exchange(ref _disconnectFired, 0); // reset guard on (re)connect, like OPC UA
        _client = _clientFactory.Create();

        await _client.ConnectAsync(new MxGatewayConnectionOptions(
            cfg.Endpoint,
            apiKey,
            string.IsNullOrWhiteSpace(cfg.ClientName) ? "scadabridge" : cfg.ClientName,
            cfg.WriteUserId,
            cfg.UseTls,
            string.IsNullOrWhiteSpace(cfg.CaFile) ? null : cfg.CaFile,
            string.IsNullOrWhiteSpace(cfg.ServerName) ? null : cfg.ServerName,
            cfg.ReadTimeoutMs,
            _supervisoryAdviseParallelism), cancellationToken);

        _status = ConnectionHealth.Connected;

        // Background event loop: route each value change to the matching subscription callback.
        _eventLoopCts = new CancellationTokenSource();
        var loopToken = _eventLoopCts.Token;   // capture NOW: the field may be replaced by a
        var loopClient = _client;              // subsequent reconnect before Task.Run executes (N1)
        _ = Task.Run(() => RunEventLoopAsync(loopClient, loopToken));
    }

    private async Task RunEventLoopAsync(IMxGatewayClient client, CancellationToken ct)
    {
        try
        {
            await client.RunEventLoopAsync(update =>
            {
                if (_tagToSub.TryGetValue(update.TagPath, out var subId) && _subs.TryGetValue(subId, out var s))
                    s.Callback(update.TagPath, new TagValue(update.Value, update.Quality, update.Timestamp));
            }, ct);
        }
        catch (OperationCanceledException)
        {
            // Normal shutdown (DisconnectAsync / DisposeAsync cancelled the loop).
        }
        catch (Exception ex) when (ct.IsCancellationRequested)
        {
            // Teardown fault of a superseded loop (N1): Cancel() is not synchronous with
            // the loop's exit — a cancelled/disposed gRPC stream commonly surfaces as
            // RpcException(StatusCode.Cancelled) (Grpc.Net default without
            // ThrowOperationCanceledOnCancellation) or ObjectDisposedException from the
            // concurrent DisposeAsync, milliseconds AFTER ConnectAsync has already reset
            // _disconnectFired. Any fault on a cancelled token is normal shutdown;
            // raising Disconnected here would flap the NEW healthy session.
            _logger.LogDebug(ex,
                "MxGateway event loop faulted after cancellation — superseded loop teardown; not signalling disconnect");
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "MxGateway event stream faulted; signalling disconnect");
            RaiseDisconnected();
        }
    }

    private void RaiseDisconnected()
    {
        if (Interlocked.Exchange(ref _disconnectFired, 1) == 0)
        {
            _status = ConnectionHealth.Disconnected;
            Disconnected?.Invoke();
        }
    }

    /// <inheritdoc />
    public async Task DisconnectAsync(CancellationToken cancellationToken = default)
    {
        _eventLoopCts?.Cancel();
        lock (_alarmLock)
        {
            _alarmCts?.Cancel();
            _alarmCts?.Dispose();
            _alarmCts = null;
            _alarmSubCount = 0;
            _alarmSubSources.Clear();
            _alarmStreamPrefix = string.Empty;
        }
        if (_client is not null)
            await _client.DisconnectAsync(cancellationToken);
        _status = ConnectionHealth.Disconnected;
    }

    /// <inheritdoc />
    public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
    {
        var subId = await _client!.SubscribeAsync(tagPath, cancellationToken);
        _subs[subId] = (tagPath, callback);
        _tagToSub[tagPath] = subId;
        return subId;
    }

    /// <inheritdoc />
    public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
    {
        if (_subs.TryRemove(subscriptionId, out var s))
            _tagToSub.TryRemove(s.TagPath, out _);
        await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
        IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
    {
        // ONE gateway round trip per batch (SubscribeBulk = AddItem + Advise per tag in a
        // single worker pass) instead of the historical 2 RPCs per tag.
        var outcomes = await _client!.SubscribeBulkAsync(tagPaths, cancellationToken);
        var results = new List<TagSubscribeResult>(outcomes.Count);
        foreach (var outcome in outcomes)
        {
            if (outcome is { Success: true, SubscriptionId: not null })
            {
                _subs[outcome.SubscriptionId] = (outcome.TagPath, callback);
                _tagToSub[outcome.TagPath] = outcome.SubscriptionId;
            }

            results.Add(new TagSubscribeResult(
                outcome.TagPath, outcome.Success, outcome.SubscriptionId, outcome.Error));
        }

        return results;
    }

    /// <inheritdoc />
    public async Task UnsubscribeBatchAsync(
        IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
    {
        foreach (var id in subscriptionIds)
        {
            if (_subs.TryRemove(id, out var s))
                _tagToSub.TryRemove(s.TagPath, out _);
        }

        await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
    }

    /// <inheritdoc />
    public Task<string> SubscribeAlarmsAsync(
        string sourceReference, string? conditionFilter,
        AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
    {
        var subscriptionId = Guid.NewGuid().ToString();
        lock (_alarmLock)
        {
            _alarmSubCount++;
            _alarmSubSources[subscriptionId] = sourceReference;

            // Push the UNION of the subscribed source references to the gateway as the
            // stream's single filter prefix. It is a bandwidth optimisation only — the
            // MxGateway has no server-side CONDITION filter either, so conditionFilter is
            // still not forwarded: the DataConnectionActor's per-source + per-condition-type
            // gate stays authoritative, uniform with the OPC UA WhereClause stance.
            if (_alarmCts != null && AlarmFilterPrefix.Covers(_alarmStreamPrefix, sourceReference))
                return Task.FromResult(subscriptionId);

            // A new source outside the live prefix (or the first subscriber): (re)open the
            // stream on the recomputed union. The source replays a fresh snapshot, which
            // NativeAlarmActor already handles.
            if (_alarmCts != null)
            {
                _alarmCts.Cancel();
                _alarmCts.Dispose();
            }

            _alarmStreamPrefix = AlarmFilterPrefix.LongestCommonPrefix(_alarmSubSources.Values);
            _alarmCts = new CancellationTokenSource();
            var token = _alarmCts.Token;
            var client = _client!;
            var prefix = _alarmStreamPrefix.Length == 0 ? null : _alarmStreamPrefix;
            _ = Task.Run(() => client.RunAlarmStreamAsync(prefix, t => callback(t), token), token);
        }

        return Task.FromResult(subscriptionId);
    }

    /// <inheritdoc />
    public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
    {
        lock (_alarmLock)
        {
            _alarmSubSources.Remove(subscriptionId);
            if (_alarmSubCount > 0)
                _alarmSubCount--;
            // Deliberately no stream restart here: dropping a source can only leave the
            // prefix too BROAD, which costs bandwidth, never correctness.
            if (_alarmSubCount == 0)
            {
                _alarmCts?.Cancel();
                _alarmCts?.Dispose();
                _alarmCts = null;
                _alarmStreamPrefix = string.Empty;
            }
        }
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
    {
        var r = (await _client!.ReadAsync(new[] { tagPath }, cancellationToken)).Single();
        return ToReadResult(r);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
    {
        var list = tagPaths.ToList();
        var results = await _client!.ReadAsync(list, cancellationToken);
        return results.ToDictionary(r => r.TagPath, ToReadResult);
    }

    private static ReadResult ToReadResult(MxReadOutcome r) => r.Success
        ? new ReadResult(true, new TagValue(r.Value, r.Quality, r.Timestamp), null)
        : new ReadResult(false, null, r.Error);

    /// <inheritdoc />
    public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
    {
        var w = (await _client!.WriteAsync(new[] { (tagPath, value) }, cancellationToken)).Single();
        return new WriteResult(w.Success, w.Error);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
    {
        var results = await _client!.WriteAsync(values.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken);
        return results.ToDictionary(w => w.TagPath, w => new WriteResult(w.Success, w.Error));
    }

    /// <inheritdoc />
    public async Task<bool> WriteBatchAndWaitAsync(
        IDictionary<string, object?> values, string flagPath, object? flagValue,
        string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default)
    {
        await WriteBatchAsync(values, cancellationToken);
        await WriteAsync(flagPath, flagValue, cancellationToken);

        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(timeout);
        try
        {
            while (!timeoutCts.IsCancellationRequested)
            {
                var r = await ReadAsync(responsePath, timeoutCts.Token);
                // r.Value is a TagValue wrapper; compare its underlying scalar. String
                // projection tolerates numeric type differences across the gRPC boundary.
                if (r.Success && string.Equals(r.Value?.Value?.ToString(), responseValue?.ToString(), StringComparison.Ordinal))
                    return true;
                await Task.Delay(TimeSpan.FromMilliseconds(200), timeoutCts.Token);
            }
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // Timeout elapsed (the linked CTS, not the caller's token) — fall through to false.
        }
        return false;
    }

    /// <inheritdoc />
    public async Task<BrowseChildrenResult> BrowseChildrenAsync(
        string? parentNodeId,
        string? continuationToken = null,
        CancellationToken cancellationToken = default)
    {
        if (_status != ConnectionHealth.Connected || _client is null)
            throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");

        // MxGateway browse is not pageable — the gateway returns a single,
        // already-bounded child set per node. Any continuation token is ignored
        // and no continuation is ever surfaced (ContinuationToken stays null).
        var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
        var nodes = children
            .Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren, DataType: c.DataType))
            .ToList();
        return new BrowseChildrenResult(nodes, truncated);
    }

    /// <summary>Cancels the event loop and disposes the underlying MxGateway client.</summary>
    /// <returns>A <see cref="ValueTask"/> that completes when disposal is finished.</returns>
    public async ValueTask DisposeAsync()
    {
        _eventLoopCts?.Cancel();
        // The DataConnectionActor disposes adapters
        // fire-and-forget on failover/stop without necessarily calling
        // DisconnectAsync first, so tear down the alarm stream here too — otherwise
        // the long-running RunAlarmStreamAsync task and its CTS leak on every
        // MxGateway failover/teardown that goes through DisposeAsync. Mirror the
        // lock-guarded block already in DisconnectAsync.
        lock (_alarmLock)
        {
            _alarmCts?.Cancel();
            _alarmCts?.Dispose();
            _alarmCts = null;
            _alarmSubCount = 0;
            _alarmSubSources.Clear();
            _alarmStreamPrefix = string.Empty;
        }
        if (_client is not null)
            await _client.DisposeAsync();
        GC.SuppressFinalize(this);
    }
}
