using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;

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

/// <summary>
///     Regression tests for the internal dashboard mirror. The dashboard is a
///     first-class subscriber on the session's <see cref="SessionEventDistributor"/>, so it
///     receives session events whether or not a gRPC client is streaming — fixing the
///     "dark feed" where the dashboard only saw events while a gRPC client was actively
///     streaming (the inline per-RPC tap removed by this task).
/// </summary>
public sealed class GatewaySessionDashboardMirrorTests
{
    private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);

    /// <summary>
    ///     The KEY bug-fix test: the dashboard broadcaster receives session events even when
    ///     NO gRPC <c>StreamEvents</c> subscriber is attached. The session is driven to Ready
    ///     with a fake worker emitting events; only the internal dashboard subscriber exists.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DashboardMirror_ReceivesEvents_WithNoGrpcSubscriber()
    {
        FakeWorkerClient workerClient = new();
        workerClient.Events.Add(CreateWorkerEvent(10, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateWorkerEvent(11, MxEventFamily.OnWriteComplete));
        workerClient.CompleteAfterConfiguredEvents = true;
        RecordingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);

        // MarkReady starts the internal dashboard mirror; no gRPC subscriber is ever attached.
        session.MarkReady();

        await WaitUntilAsync(() => broadcaster.Captures.Count == 2);

        IReadOnlyList<DashboardEventCapture> captures = broadcaster.Captures;
        Assert.Equal(0, session.ActiveEventSubscriberCount);
        Assert.Equal([10UL, 11UL], captures.Select(capture => capture.MxEvent.WorkerSequence).ToArray());
        Assert.All(captures, capture => Assert.Equal(session.SessionId, capture.SessionId));
    }

    /// <summary>
    ///     A gRPC subscriber and the dashboard both receive every event concurrently. The
    ///     gRPC path is no longer the dashboard's source — both read independent leases fed by
    ///     the single distributor pump.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DashboardMirror_AndGrpcSubscriber_BothReceiveEvents()
    {
        FakeWorkerClient workerClient = new();
        workerClient.Events.Add(CreateWorkerEvent(1, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateWorkerEvent(2, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateWorkerEvent(3, MxEventFamily.OnWriteComplete));
        workerClient.CompleteAfterConfiguredEvents = true;
        // Hold the worker stream until BOTH subscribers are attached so neither misses an event.
        // MarkReady registers the internal dashboard subscriber and starts the pump, which then
        // blocks on the gate; the gRPC subscriber attaches below; only then is the finite stream
        // released. Without this gate the pump can drain all three events before the gRPC
        // subscriber registers — a register-vs-pump race that otherwise makes this test flaky.
        workerClient.HoldEventsUntilReleased();
        RecordingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();

        EventStreamService service = new(
            new SingleSessionManager(session),
            Options.Create(new GatewayOptions { Events = new EventOptions { QueueCapacity = 8 } }),
            new GatewayMetrics());

        List<MxEvent> grpcEvents = [];
        Task grpcReader = Task.Run(async () =>
        {
            await foreach (MxEvent mxEvent in service
                .StreamEventsAsync(new StreamEventsRequest { SessionId = session.SessionId }, callerKeyId: null, CancellationToken.None)
                .WithCancellation(CancellationToken.None))
            {
                grpcEvents.Add(mxEvent);
            }
        });

        // The gRPC subscriber counts against ActiveEventSubscriberCount (the internal dashboard
        // mirror does not), so count == 1 confirms it has attached. Only then release the stream.
        await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
        workerClient.ReleaseEvents();

        await grpcReader.WaitAsync(TestTimeout);
        await WaitUntilAsync(() => broadcaster.Captures.Count == 3);

        Assert.Equal([1UL, 2UL, 3UL], grpcEvents.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
        Assert.Equal([1UL, 2UL, 3UL], broadcaster.Captures.Select(capture => capture.MxEvent.WorkerSequence).ToArray());
    }

    /// <summary>
    ///     With the internal dashboard mirror active, a second internal
    ///     subscriber (the alarm monitor's feed, attached via
    ///     <see cref="GatewaySession.AttachInternalEventSubscriber"/>) receives EVERY event —
    ///     including the alarm <c>Acknowledge</c> transition — rather than the two consumers
    ///     each getting a random half of the single worker channel. Before the fix the alarm
    ///     monitor drained the worker channel directly, so with the dashboard mirror pump also
    ///     draining it the two split the stream and Acknowledge transitions were silently lost.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task InternalAlarmSubscriber_AndDashboardMirror_BothReceiveEveryEvent()
    {
        FakeWorkerClient workerClient = new();
        workerClient.Events.Add(CreateWorkerEvent(1, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateAlarmTransitionEvent(2, AlarmTransitionKind.Raise));
        workerClient.Events.Add(CreateAlarmTransitionEvent(3, AlarmTransitionKind.Acknowledge));
        workerClient.Events.Add(CreateAlarmTransitionEvent(4, AlarmTransitionKind.Clear));
        workerClient.CompleteAfterConfiguredEvents = true;
        // Hold the finite stream until BOTH internal subscribers (dashboard mirror + alarm feed)
        // are registered so neither misses an event before the pump drains.
        workerClient.HoldEventsUntilReleased();
        RecordingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);

        // MarkReady registers the internal dashboard subscriber and starts the (gated) pump.
        session.MarkReady();

        // Attach the alarm monitor's internal subscriber and drain it concurrently. Registered
        // BEFORE the stream is released, so it is present at pump start alongside the dashboard.
        using IEventSubscriberLease alarmLease = session.AttachInternalEventSubscriber();
        List<MxEvent> alarmEvents = [];
        Task alarmReader = Task.Run(async () =>
        {
            await foreach (MxEvent mxEvent in alarmLease.Reader.ReadAllAsync(CancellationToken.None))
            {
                alarmEvents.Add(mxEvent);
            }
        });

        workerClient.ReleaseEvents();

        await WaitUntilAsync(() => broadcaster.Captures.Count == 4 && alarmEvents.Count == 4);
        await alarmReader.WaitAsync(TestTimeout);

        // Both internal consumers see the full, identical, ordered stream — no splitting.
        Assert.Equal([1UL, 2UL, 3UL, 4UL], broadcaster.Captures.Select(capture => capture.MxEvent.WorkerSequence).ToArray());
        Assert.Equal([1UL, 2UL, 3UL, 4UL], alarmEvents.Select(mxEvent => mxEvent.WorkerSequence).ToArray());

        // The Acknowledge transition specifically reaches the alarm feed (the transition the
        // pre-fix split silently dropped, leaving clients showing unacked alarms indefinitely).
        Assert.Contains(
            alarmEvents,
            mxEvent => mxEvent.BodyCase == MxEvent.BodyOneofCase.OnAlarmTransition
                && mxEvent.OnAlarmTransition.TransitionKind == AlarmTransitionKind.Acknowledge);
    }

    /// <summary>
    ///     Hazard guard: starting the pump at Ready with a fast-completing worker stream
    ///     and zero subscribers used to drain into nothing and leave a later subscriber hanging.
    ///     Now the dashboard subscriber is registered BEFORE the pump starts, so even a worker
    ///     stream that completes immediately delivers every event to the dashboard with no hang.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DashboardMirror_FastCompletingWorkerStream_DeliversAllEventsWithoutHang()
    {
        FakeWorkerClient workerClient = new();
        workerClient.Events.Add(CreateWorkerEvent(1, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateWorkerEvent(2, MxEventFamily.OnDataChange));
        workerClient.CompleteAfterConfiguredEvents = true;
        RecordingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();

        await WaitUntilAsync(() => broadcaster.Captures.Count == 2);
        Assert.Equal([1UL, 2UL], broadcaster.Captures.Select(capture => capture.MxEvent.WorkerSequence).ToArray());
    }

    /// <summary>
    ///     The dashboard Publish must be never-throw at the seam too: a throwing broadcaster
    ///     must not fault the session or stop the mirror from continuing past the failure.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DashboardMirror_WhenBroadcasterThrows_DoesNotFaultSessionAndKeepsMirroring()
    {
        FakeWorkerClient workerClient = new();
        workerClient.Events.Add(CreateWorkerEvent(1, MxEventFamily.OnDataChange));
        workerClient.Events.Add(CreateWorkerEvent(2, MxEventFamily.OnDataChange));
        workerClient.CompleteAfterConfiguredEvents = true;
        ThrowingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();

        await WaitUntilAsync(() => broadcaster.PublishAttempts == 2);
        Assert.NotEqual(SessionState.Faulted, session.State);
    }

    /// <summary>
    ///     The internal dashboard subscriber must NOT count against the single-subscriber
    ///     guard: a gRPC subscriber can still attach while the dashboard mirror is running.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DashboardMirror_DoesNotCountAgainstSingleSubscriberGuard()
    {
        FakeWorkerClient workerClient = new();
        RecordingDashboardEventBroadcaster broadcaster = new();

        await using GatewaySession session = CreateSession(workerClient, broadcaster);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();

        Assert.Equal(0, session.ActiveEventSubscriberCount);
        using IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
        Assert.Equal(1, session.ActiveEventSubscriberCount);
    }

    private static GatewaySession CreateSession(
        IWorkerClient workerClient,
        IDashboardEventBroadcaster broadcaster)
    {
        return new GatewaySession(
            sessionId: "session-dashboard-mirror",
            backendName: GatewayContractInfo.DefaultBackendName,
            pipeName: "mxaccess-gateway-1-session-dashboard-mirror",
            nonce: "nonce",
            clientIdentity: "client-1",
            ownerKeyId: null,
            clientSessionName: "test-session",
            clientCorrelationId: "client-correlation-1",
            commandTimeout: TimeSpan.FromSeconds(5),
            startupTimeout: TimeSpan.FromSeconds(5),
            shutdownTimeout: TimeSpan.FromSeconds(5),
            leaseDuration: TimeSpan.FromMinutes(30),
            openedAt: DateTimeOffset.UtcNow,
            eventStreaming: new SessionEventStreaming(
                new MxAccessGrpcMapper(),
                new EventOptions { QueueCapacity = 8 },
                NullLogger<SessionEventDistributor>.Instance,
                TimeProvider.System,
                new GatewayMetrics(),
                broadcaster));
    }

    private static WorkerEvent CreateWorkerEvent(ulong sequence, MxEventFamily family)
    {
        MxEvent mxEvent = new()
        {
            SessionId = "session-dashboard-mirror",
            Family = family,
            WorkerSequence = sequence,
        };

        switch (family)
        {
            case MxEventFamily.OnDataChange:
                mxEvent.OnDataChange = new OnDataChangeEvent();
                break;
            case MxEventFamily.OnWriteComplete:
                mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
                break;
        }

        return new WorkerEvent { Event = mxEvent };
    }

    private static WorkerEvent CreateAlarmTransitionEvent(ulong sequence, AlarmTransitionKind kind)
    {
        MxEvent mxEvent = new()
        {
            SessionId = "session-dashboard-mirror",
            Family = MxEventFamily.OnAlarmTransition,
            WorkerSequence = sequence,
            OnAlarmTransition = new OnAlarmTransitionEvent { TransitionKind = kind },
        };

        return new WorkerEvent { Event = mxEvent };
    }

    private static async Task WaitUntilAsync(Func<bool> predicate, [CallerArgumentExpression(nameof(predicate))] string? condition = null)
    {
        using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
        try
        {
            while (!predicate())
            {
                await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
            }
        }
        catch (OperationCanceledException)
        {
            Assert.Fail($"Timed out after {TestTimeout.TotalSeconds}s waiting for: {condition}");
        }
    }

    private sealed class ThrowingDashboardEventBroadcaster : IDashboardEventBroadcaster
    {
        private int _publishAttempts;

        /// <summary>Number of times <see cref="Publish"/> has been invoked.</summary>
        public int PublishAttempts => Volatile.Read(ref _publishAttempts);

        /// <inheritdoc />
        public void Publish(string sessionId, MxEvent mxEvent)
        {
            Interlocked.Increment(ref _publishAttempts);
            throw new InvalidOperationException("simulated dashboard broadcaster failure");
        }
    }

    private sealed class SingleSessionManager(GatewaySession session) : ISessionManager
    {
        /// <inheritdoc />
        public Task<GatewaySession> OpenSessionAsync(
            SessionOpenRequest request,
            string? clientIdentity,
            string? ownerKeyId,
            CancellationToken cancellationToken) => Task.FromResult(session);

        /// <inheritdoc />
        public bool TryGetSession(string sessionId, out GatewaySession gatewaySession)
        {
            gatewaySession = session;
            return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
        }

        /// <inheritdoc />
        public Task<WorkerCommandReply> InvokeAsync(
            string sessionId,
            WorkerCommand command,
            CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());

        /// <inheritdoc />
        public Task<SessionCloseResult> CloseSessionAsync(
            string sessionId,
            CancellationToken cancellationToken) =>
            Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));

        /// <inheritdoc />
        public Task<SessionCloseResult> KillWorkerAsync(
            string sessionId,
            string reason,
            CancellationToken cancellationToken) =>
            Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));

        /// <inheritdoc />
        public Task<int> CloseExpiredLeasesAsync(
            DateTimeOffset now,
            CancellationToken cancellationToken) => Task.FromResult(0);

        /// <inheritdoc />
        public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }

    private sealed class FakeWorkerClient : IWorkerClient
    {
        /// <summary>Events yielded by <see cref="ReadEventsAsync"/>, in order.</summary>
        public List<WorkerEvent> Events { get; } = [];

        /// <summary>When <see langword="true"/>, <see cref="ReadEventsAsync"/> completes after yielding <see cref="Events"/> instead of hanging.</summary>
        public bool CompleteAfterConfiguredEvents { get; set; }

        // Gate that holds the event stream before it yields anything. Released by default, so
        // ungated tests are unaffected. HoldEventsUntilReleased() makes ReadEventsAsync block
        // until ReleaseEvents(), letting a test attach every subscriber before a finite,
        // fast-completing stream drains (avoids a register-vs-pump race).
        private TaskCompletionSource _releaseGate = CreateReleasedGate();

        private static TaskCompletionSource CreateReleasedGate()
        {
            TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
            gate.SetResult();
            return gate;
        }

        /// <summary>Re-arms the release gate so <see cref="ReadEventsAsync"/> blocks before yielding events until <see cref="ReleaseEvents"/> is called.</summary>
        public void HoldEventsUntilReleased() =>
            _releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

        /// <summary>Releases the gate set by <see cref="HoldEventsUntilReleased"/>, letting <see cref="ReadEventsAsync"/> proceed.</summary>
        public void ReleaseEvents() => _releaseGate.TrySetResult();

        /// <inheritdoc />
        public string SessionId { get; } = "session-dashboard-mirror";

        /// <inheritdoc />
        public int? ProcessId { get; } = 1234;

        /// <inheritdoc />
        public WorkerClientState State { get; } = WorkerClientState.Ready;

        /// <inheritdoc />
        public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;

        /// <inheritdoc />
        public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

        /// <inheritdoc />
        public Task<WorkerCommandReply> InvokeAsync(
            WorkerCommand command,
            TimeSpan timeout,
            CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());

        /// <inheritdoc />
        public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            // Block before yielding any event until released (ungated by default).
            await _releaseGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);

            foreach (WorkerEvent workerEvent in Events)
            {
                cancellationToken.ThrowIfCancellationRequested();
                yield return workerEvent;
            }

            if (CompleteAfterConfiguredEvents)
            {
                yield break;
            }

            await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
        }

        /// <inheritdoc />
        public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;

        /// <inheritdoc />
        public void Kill(string reason)
        {
        }

        /// <summary>No-op dispose; the fake worker client owns no resources to release.</summary>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }
}
