using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;

/// <summary>
/// Verifies that <see cref="AlarmsHubPublisher"/> honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): the alarm value fields of
/// both value-bearing payload arms are stripped from the copy broadcast to
/// browser clients when the flag is off, present when it is on, and the source
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
/// subscribers and the alarms page — is never mutated.
/// </summary>
public sealed class AlarmsHubPublisherTests
{
    private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);

    /// <summary>Both value-bearing arms lose their values when the flag is off; metadata survives.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata()
    {
        AlarmFeedMessage transition = BuildTransition();
        AlarmFeedMessage activeAlarm = BuildActiveAlarm();
        CapturingHubContext hubContext = await RunPublisherAsync(
            showTagValues: false,
            transition,
            activeAlarm);

        Assert.Equal(2, hubContext.Sent.Count);

        AlarmFeedMessage sentTransition = hubContext.Sent[0];
        Assert.Null(sentTransition.Transition.CurrentValue);
        Assert.Null(sentTransition.Transition.LimitValue);
        Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference);
        Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference);
        Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind);
        Assert.Equal(800, sentTransition.Transition.Severity);
        Assert.Equal("Process", sentTransition.Transition.Category);

        AlarmFeedMessage sentActive = hubContext.Sent[1];
        Assert.Null(sentActive.ActiveAlarm.CurrentValue);
        Assert.Null(sentActive.ActiveAlarm.LimitValue);
        Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference);
        Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState);
        Assert.Equal(500, sentActive.ActiveAlarm.Severity);
    }

    /// <summary>
    /// Redaction applies to a clone: the source message fans out to gRPC
    /// <c>StreamAlarms</c> subscribers and the alarms page, so it must keep its
    /// values.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage()
    {
        AlarmFeedMessage transition = BuildTransition();
        AlarmFeedMessage activeAlarm = BuildActiveAlarm();
        CapturingHubContext hubContext = await RunPublisherAsync(
            showTagValues: false,
            transition,
            activeAlarm);

        Assert.NotNull(transition.Transition.CurrentValue);
        Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue);
        Assert.NotNull(transition.Transition.LimitValue);
        Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue);
        Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue);
        Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue);

        Assert.NotSame(transition, hubContext.Sent[0]);
        Assert.NotSame(activeAlarm, hubContext.Sent[1]);
    }

    /// <summary>Values pass through unredacted — and uncloned — when the flag is on.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues()
    {
        AlarmFeedMessage transition = BuildTransition();
        AlarmFeedMessage activeAlarm = BuildActiveAlarm();
        CapturingHubContext hubContext = await RunPublisherAsync(
            showTagValues: true,
            transition,
            activeAlarm);

        Assert.Same(transition, hubContext.Sent[0]);
        Assert.Same(activeAlarm, hubContext.Sent[1]);
        Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue);
        Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue);
        Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue);
        Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue);
    }

    /// <summary>
    /// A payload arm that carries no value is forwarded as-is — the same
    /// instance, no clone. This is also the contract for arms added later: the
    /// switch names only the value-bearing arms, so a new arm passes through.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched()
    {
        AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true };
        AlarmFeedMessage providerStatus = new()
        {
            ProviderStatus = new AlarmProviderStatus
            {
                Mode = AlarmProviderMode.Subtag,
                Degraded = true,
                Reason = "alarmmgr unavailable",
            },
        };

        AlarmFeedMessage snapshotStatus = new()
        {
            SnapshotStatus = new AlarmSnapshotStatus { Truncated = true },
        };

        CapturingHubContext hubContext = await RunPublisherAsync(
            showTagValues: false,
            snapshotComplete,
            providerStatus,
            snapshotStatus);

        Assert.Same(snapshotComplete, hubContext.Sent[0]);
        Assert.Same(providerStatus, hubContext.Sent[1]);
        Assert.Same(snapshotStatus, hubContext.Sent[2]);
        Assert.True(hubContext.Sent[2].SnapshotStatus.Truncated);
    }

    /// <summary>
    /// Starts the publisher over a scripted feed, waits until every scripted
    /// message has been broadcast, and stops it.
    /// </summary>
    /// <param name="showTagValues">The <c>Dashboard:ShowTagValues</c> value under test.</param>
    /// <param name="messages">The messages the fake alarm feed yields, in order.</param>
    /// <returns>The hub context holding everything the publisher sent.</returns>
    private static async Task<CapturingHubContext> RunPublisherAsync(
        bool showTagValues,
        params AlarmFeedMessage[] messages)
    {
        ScriptedAlarmService alarmService = new(messages);
        CapturingHubContext hubContext = new();
        GatewayOptions gatewayOptions = new()
        {
            Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
        };

        AlarmsHubPublisher publisher = new(
            alarmService,
            hubContext,
            Options.Create(gatewayOptions),
            NullLogger<AlarmsHubPublisher>.Instance);

        using CancellationTokenSource cts = new();
        await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
        await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length);
        await cts.CancelAsync();
        await publisher.StopAsync(CancellationToken.None);

        return hubContext;
    }

    private static async Task WaitUntilAsync(Func<bool> predicate)
    {
        using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
        while (!predicate())
        {
            await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
        }
    }

    /// <summary>Builds a value-bearing <c>transition</c> feed message.</summary>
    /// <returns>The message.</returns>
    private static AlarmFeedMessage BuildTransition()
    {
        return new AlarmFeedMessage
        {
            Transition = new OnAlarmTransitionEvent
            {
                AlarmFullReference = "Tank01.Level.HiHi",
                SourceObjectReference = "Tank01",
                AlarmTypeName = "AnalogLimitAlarm.HiHi",
                TransitionKind = AlarmTransitionKind.Raise,
                Severity = 800,
                Category = "Process",
                Description = "Level high-high",
                CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 },
                LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 },
            },
        };
    }

    /// <summary>Builds a value-bearing <c>active_alarm</c> feed message.</summary>
    /// <returns>The message.</returns>
    private static AlarmFeedMessage BuildActiveAlarm()
    {
        return new AlarmFeedMessage
        {
            ActiveAlarm = new ActiveAlarmSnapshot
            {
                AlarmFullReference = "Tank02.Level.Lo",
                SourceObjectReference = "Tank02",
                AlarmTypeName = "AnalogLimitAlarm.Lo",
                CurrentState = AlarmConditionState.Active,
                Severity = 500,
                Category = "Process",
                Description = "Level low",
                CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 },
                LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 },
            },
        };
    }

    /// <summary>
    /// Yields a scripted message list once and then stays open until cancelled,
    /// so the publisher's reconnect loop never re-subscribes mid-test.
    /// </summary>
    /// <param name="messages">The messages to yield, in order.</param>
    private sealed class ScriptedAlarmService(IReadOnlyList<AlarmFeedMessage> messages) : IGatewayAlarmService
    {
        /// <inheritdoc />
        public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;

        /// <inheritdoc />
        public string? LastError => null;

        /// <inheritdoc />
        public int? WorkerProcessId => null;

        /// <inheritdoc />
        public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];

        /// <inheritdoc />
        public bool SnapshotTruncated => false;

        /// <inheritdoc />
        public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
            string? alarmFilterPrefix,
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            foreach (AlarmFeedMessage message in messages)
            {
                yield return message;
            }

            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
            }
        }

        /// <inheritdoc />
        public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
            AcknowledgeAlarmRequest request,
            CancellationToken cancellationToken) => throw new NotSupportedException();
    }

    private sealed class CapturingHubContext : IHubContext<AlarmsHub>
    {
        private readonly CapturingHubClients _clients = new();

        /// <summary>Gets the hub clients.</summary>
        public IHubClients Clients => _clients;

        /// <summary>Gets the group manager.</summary>
        public IGroupManager Groups { get; } = new NoopGroupManager();

        /// <summary>Gets every message the publisher broadcast, in order.</summary>
        public IReadOnlyList<AlarmFeedMessage> Sent => _clients.GroupProxy.Sent;
    }

    private sealed class CapturingHubClients : IHubClients
    {
        /// <summary>Gets the capturing client proxy shared by this fake.</summary>
        public CapturingClientProxy GroupProxy { get; } = new();

        public IClientProxy All => GroupProxy;

        public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;

        public IClientProxy Client(string connectionId) => GroupProxy;

        public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;

        public IClientProxy Group(string groupName) => GroupProxy;

        public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;

        public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;

        public IClientProxy User(string userId) => GroupProxy;

        public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
    }

    private sealed class CapturingClientProxy : IClientProxy
    {
        private readonly List<AlarmFeedMessage> _sent = [];

        /// <summary>Gets every alarm message sent through this proxy, in order.</summary>
        public IReadOnlyList<AlarmFeedMessage> Sent
        {
            get
            {
                lock (_sent)
                {
                    return [.. _sent];
                }
            }
        }

        /// <summary>Records the broadcast message and completes synchronously.</summary>
        /// <param name="method">The SignalR method name.</param>
        /// <param name="args">The method arguments.</param>
        /// <param name="cancellationToken">Token to observe for cancellation.</param>
        /// <returns>A completed task.</returns>
        public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
        {
            if (args.Length > 0 && args[0] is AlarmFeedMessage message)
            {
                lock (_sent)
                {
                    _sent.Add(message);
                }
            }

            return Task.CompletedTask;
        }
    }

    private sealed class NoopGroupManager : IGroupManager
    {
        public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
            => Task.CompletedTask;

        public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
    }
}
