using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
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.Dashboard;

public sealed class DashboardLiveDataServiceTests
{
    // Mirrors DashboardLiveDataService.MaxSubscribedTags — the cap is private, so the
    // tests drive it through the public read surface at exactly its documented size.
    private const int MaxSubscribedTags = 256;

    /// <summary>
    /// Verifies a tag already in the advise set is not subscribed again on a later read.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenTagAlreadySubscribed_DoesNotResubscribe()
    {
        RecordingWorkerClient worker = new();
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        await service.ReadAsync(["Tank_001.PV", "Tank_002.PV"], CancellationToken.None);
        DashboardLiveReadResult second = await service.ReadAsync(
            ["Tank_001.PV", "Tank_002.PV"],
            CancellationToken.None);

        Assert.Null(second.Error);
        Assert.Equal(["Tank_001.PV", "Tank_002.PV"], worker.SubscribedTags);
        Assert.Empty(worker.UnsubscribedHandles);
    }

    /// <summary>
    /// Verifies the advise set is capped: subscribing past the cap unadvises the
    /// least-recently-read tag on the worker and leaves the re-read tag advised.
    /// </summary>
    [Fact]
    public async Task ReadAsync_PastCap_EvictsLeastRecentlyReadTag()
    {
        RecordingWorkerClient worker = new();
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        // Within one read the last tag counts as most recently read, so filler[0] is
        // the tail of the recency list.
        string[] filler = CreateTagAddresses(MaxSubscribedTags);
        await service.ReadAsync(filler, CancellationToken.None);

        // Re-read the tail, making it most recent: the tag read before it is now the
        // eviction candidate.
        await service.ReadAsync([filler[0]], CancellationToken.None);
        Assert.Equal(MaxSubscribedTags, worker.SubscribedTags.Count);

        DashboardLiveReadResult overflow = await service.ReadAsync(
            ["Overflow.PV"],
            CancellationToken.None);

        Assert.Null(overflow.Error);
        Assert.Equal([worker.HandleFor(filler[1])], worker.UnsubscribedHandles);
        Assert.Equal("Overflow.PV", worker.SubscribedTags[^1]);
        Assert.Equal(MaxSubscribedTags + 1, worker.SubscribedTags.Count);

        // The evicted tag is no longer tracked and re-subscribes; the re-read one does not.
        await service.ReadAsync([filler[0], filler[1]], CancellationToken.None);
        Assert.Equal(filler[1], worker.SubscribedTags[^1]);
        Assert.Equal(MaxSubscribedTags + 2, worker.SubscribedTags.Count);
    }

    /// <summary>
    /// Verifies the cap is per-read, not absolute: a single read of more distinct tags
    /// than the cap keeps them all (a read never evicts a tag it is about to return),
    /// and the next read that subscribes anything squeezes the overshoot back out.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WithMoreDistinctTagsThanCap_KeepsThemAllThenSelfCorrects()
    {
        RecordingWorkerClient worker = new();
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        string[] oversize = CreateTagAddresses(300);
        DashboardLiveReadResult oversizeResult = await service.ReadAsync(oversize, CancellationToken.None);

        Assert.Null(oversizeResult.Error);
        Assert.Equal(300, oversizeResult.Values.Count);
        Assert.Equal(300, worker.SubscribedTags.Count);
        Assert.Empty(worker.UnsubscribedHandles);

        // 300 + 1 - 256 = 45 evicted in one pass, landing the set back on the cap.
        await service.ReadAsync(["Overflow.PV"], CancellationToken.None);
        Assert.Equal(oversize[..45].Select(worker.HandleFor), worker.UnsubscribedHandles);

        // Exactly at the cap now: one more new tag evicts exactly one.
        worker.UnsubscribedHandles.Clear();
        await service.ReadAsync(["Overflow2.PV"], CancellationToken.None);
        Assert.Equal([worker.HandleFor(oversize[45])], worker.UnsubscribedHandles);
    }

    /// <summary>
    /// Verifies tags read in the same call are never evicted for each other: a read that
    /// touches nearly the whole advise set evicts only the untouched remainder, ends over
    /// the cap, and the following read trims it back.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenTouchedTagsFillTheCap_EvictsOnlyUntouchedTags()
    {
        RecordingWorkerClient worker = new();
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        string[] filler = CreateTagAddresses(MaxSubscribedTags);
        await service.ReadAsync(filler, CancellationToken.None);

        // 250 already-advised tags + 10 new ones: only the 6 untouched tags are
        // evictable, so the set ends at 260.
        string[] fresh = CreateTagAddresses(10, "Fresh");
        await service.ReadAsync([.. filler[..250], .. fresh], CancellationToken.None);

        Assert.Equal(filler[250..].Select(worker.HandleFor), worker.UnsubscribedHandles);

        // 260 + 1 - 256 = 5 evicted on the next read that subscribes anything.
        worker.UnsubscribedHandles.Clear();
        await service.ReadAsync(["Overflow.PV"], CancellationToken.None);
        Assert.Equal(5, worker.UnsubscribedHandles.Count);
    }

    /// <summary>
    /// Verifies a tag the worker failed to advise still occupies a slot but is evicted
    /// without any unsubscribe command — there is no item handle to unadvise.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenAdviseFailed_EvictsTagWithoutUnsubscribing()
    {
        RecordingWorkerClient worker = new();
        worker.FailSubscribeFor.Add("Bad.PV");
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        // Bad.PV is read first, so it is the least recently read of the batch and the
        // first tag evicted.
        string[] filler = CreateTagAddresses(MaxSubscribedTags - 1);
        await service.ReadAsync(["Bad.PV", .. filler], CancellationToken.None);

        DashboardLiveReadResult overflow = await service.ReadAsync(
            ["Overflow.PV"],
            CancellationToken.None);

        Assert.Null(overflow.Error);
        Assert.Empty(worker.UnsubscribedHandles);
        Assert.Equal(0, worker.UnsubscribeCommandCount);

        // It was dropped from tracking all the same, so reading it again re-advises it.
        await service.ReadAsync(["Bad.PV"], CancellationToken.None);
        Assert.Equal("Bad.PV", worker.SubscribedTags[^1]);
    }

    /// <summary>
    /// Verifies a failed unadvise of an evicted tag does not fail the read, and the
    /// evicted tag is dropped from tracking anyway.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead()
    {
        RecordingWorkerClient worker = new() { FailUnsubscribe = true };
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager);

        string[] filler = CreateTagAddresses(MaxSubscribedTags);
        await service.ReadAsync(filler, CancellationToken.None);

        DashboardLiveReadResult overflow = await service.ReadAsync(
            ["Overflow.PV"],
            CancellationToken.None);

        Assert.Null(overflow.Error);
        Assert.Equal("Overflow.PV", Assert.Single(overflow.Values).TagAddress);
        Assert.Equal(1, sessionManager.OpenCount);

        // The evicted tag was dropped from tracking despite the failed unadvise.
        await service.ReadAsync([filler[0]], CancellationToken.None);
        Assert.Equal(filler[0], worker.SubscribedTags[^1]);
    }

    /// <summary>
    /// Verifies the <c>/browse</c> live-value seam honours
    /// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): with the flag off — the
    /// default — the value text the page renders is the redaction placeholder,
    /// never the formatted tag value.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata()
    {
        RecordingWorkerClient worker = new()
        {
            ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
        };
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false);

        DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);

        DashboardTagValue value = Assert.Single(result.Values);
        Assert.Equal(DashboardTagValue.RedactedValueText, value.ValueText);
        Assert.DoesNotContain("42.5", value.ValueText, StringComparison.Ordinal);

        // Everything that is not the value still renders: the panel stays useful.
        Assert.Equal("Tank_001.PV", value.TagAddress);
        Assert.True(value.Ok);
        Assert.Equal("Double", value.DataType);
        Assert.Equal(192, value.Quality);
        Assert.True(value.QualityGood);
        Assert.Null(value.Error);
    }

    /// <summary>Verifies the formatted value is served when the flag is on.</summary>
    [Fact]
    public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue()
    {
        RecordingWorkerClient worker = new()
        {
            ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
        };
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: true);

        DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);

        DashboardTagValue value = Assert.Single(result.Values);
        Assert.Equal("42.5", value.ValueText);
        Assert.Equal("Double", value.DataType);
    }

    /// <summary>
    /// Verifies redaction is scoped to reads that actually produced a value: a
    /// failed read keeps the "-" placeholder rather than claiming a value was
    /// withheld, and its diagnostic still reaches the panel. Redacting it would
    /// contradict the error the same row displays.
    /// </summary>
    [Fact]
    public async Task ReadAsync_WhenReadFailedAndShowTagValuesFalse_LeavesPlaceholderUnredacted()
    {
        RecordingWorkerClient worker = new()
        {
            ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
        };
        worker.FailReadFor.Add("Bad.PV");
        await using FakeSessionManager sessionManager = new(worker);
        await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false);

        DashboardLiveReadResult result = await service.ReadAsync(
            ["Bad.PV", "Tank_001.PV"],
            CancellationToken.None);

        DashboardTagValue failed = result.Values.Single(value => value.TagAddress == "Bad.PV");
        Assert.False(failed.Ok);
        Assert.Equal("-", failed.ValueText);
        Assert.Equal("Simulated read failure.", failed.Error);
        Assert.False(failed.QualityGood);

        // The successful row in the same read is still redacted.
        DashboardTagValue succeeded = result.Values.Single(value => value.TagAddress == "Tank_001.PV");
        Assert.Equal(DashboardTagValue.RedactedValueText, succeeded.ValueText);
    }

    private static DashboardLiveDataService CreateService(
        ISessionManager sessionManager,
        bool showTagValues = false)
    {
        GatewayOptions gatewayOptions = new()
        {
            Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
        };

        return new DashboardLiveDataService(
            sessionManager,
            new FakeGatewayAlarmService(),
            Options.Create(gatewayOptions),
            NullLogger<DashboardLiveDataService>.Instance);
    }

    private static string[] CreateTagAddresses(int count, string prefix = "Tank")
    {
        string[] addresses = new string[count];
        for (int i = 0; i < count; i++)
        {
            addresses[i] = $"{prefix}_{i:D4}.PV";
        }

        return addresses;
    }

    // Serves the dashboard service a single Ready session backed by the recording
    // worker, so reads exercise the real GatewaySession bulk command path.
    private sealed class FakeSessionManager(RecordingWorkerClient workerClient) : ISessionManager, IAsyncDisposable
    {
        private readonly List<GatewaySession> _sessions = [];

        /// <summary>Gets the number of sessions the dashboard service opened.</summary>
        public int OpenCount { get; private set; }

        /// <inheritdoc />
        public Task<GatewaySession> OpenSessionAsync(
            SessionOpenRequest request,
            string? clientIdentity,
            string? ownerKeyId,
            CancellationToken cancellationToken)
        {
            OpenCount++;
            string sessionId = $"dashboard-session-{OpenCount}";
            GatewaySession session = new(
                sessionId,
                "Galaxy",
                $"mxgw-1-{sessionId}",
                "nonce",
                clientIdentity,
                request.ClientSessionName,
                request.ClientCorrelationId,
                TimeSpan.FromSeconds(30),
                TimeSpan.FromSeconds(5),
                TimeSpan.FromSeconds(5),
                DateTimeOffset.UnixEpoch);
            session.AttachWorkerClient(workerClient);
            session.MarkReady();
            _sessions.Add(session);
            return Task.FromResult(session);
        }

        /// <inheritdoc />
        public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
        {
            session = _sessions.Find(candidate => candidate.SessionId == sessionId);
            return session is not null;
        }

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

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

        /// <inheritdoc />
        public Task<SessionCloseResult> KillWorkerAsync(
            string sessionId,
            string reason,
            CancellationToken cancellationToken) =>
            throw new NotSupportedException();

        /// <inheritdoc />
        public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
            throw new NotSupportedException();

        /// <inheritdoc />
        public Task ShutdownAsync(CancellationToken cancellationToken) => throw new NotSupportedException();

        /// <summary>Disposes every session handed to the dashboard service.</summary>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public async ValueTask DisposeAsync()
        {
            foreach (GatewaySession session in _sessions)
            {
                await session.DisposeAsync().ConfigureAwait(false);
            }
        }
    }

    // Answers Register / SubscribeBulk / UnsubscribeBulk / ReadBulk with successful
    // replies and records what the dashboard advised and unadvised.
    private sealed class RecordingWorkerClient : IWorkerClient
    {
        private const int RegisteredServerHandle = 77;

        private readonly Dictionary<string, int> _itemHandles = new(StringComparer.OrdinalIgnoreCase);
        private int _nextItemHandle = 1000;

        /// <inheritdoc />
        public string SessionId => "dashboard-session-1";

        /// <inheritdoc />
        public int? ProcessId => 4242;

        /// <inheritdoc />
        public WorkerClientState State => WorkerClientState.Ready;

        /// <inheritdoc />
        public DateTimeOffset LastHeartbeatAt => DateTimeOffset.UnixEpoch;

        /// <summary>Gets the tag addresses subscribed, in the order the dashboard asked for them.</summary>
        public List<string> SubscribedTags { get; } = [];

        /// <summary>Gets the item handles the dashboard unsubscribed, in order.</summary>
        public List<int> UnsubscribedHandles { get; } = [];

        /// <summary>Gets the number of unsubscribe commands the dashboard sent.</summary>
        public int UnsubscribeCommandCount { get; private set; }

        /// <summary>Gets the tag addresses the worker refuses to advise.</summary>
        public HashSet<string> FailSubscribeFor { get; } = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>Gets or sets a value indicating whether unsubscribe commands throw.</summary>
        public bool FailUnsubscribe { get; set; }

        /// <summary>
        /// Gets or sets the value every bulk read returns. Null (the default) leaves
        /// the read results value-less, which is all the advise-set tests need.
        /// </summary>
        public MxValue? ReadValue { get; set; }

        /// <summary>Gets the tag addresses whose bulk read comes back unsuccessful.</summary>
        public HashSet<string> FailReadFor { get; } = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>Gets the item handle bound for a previously subscribed tag.</summary>
        /// <param name="tagAddress">Tag address to look up.</param>
        /// <returns>The bound item handle.</returns>
        public int HandleFor(string tagAddress) => _itemHandles[tagAddress];

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

        /// <inheritdoc />
        public Task<WorkerCommandReply> InvokeAsync(
            WorkerCommand command,
            TimeSpan timeout,
            CancellationToken cancellationToken)
        {
            MxCommand mxCommand = command.Command
                ?? throw new InvalidOperationException("The dashboard sent a command with no payload.");

            MxCommandReply reply = new()
            {
                Kind = mxCommand.Kind,
                ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            };

            switch (mxCommand.Kind)
            {
                case MxCommandKind.Register:
                    reply.Register = new RegisterReply { ServerHandle = RegisteredServerHandle };
                    break;
                case MxCommandKind.SubscribeBulk:
                    reply.SubscribeBulk = Subscribe(mxCommand.SubscribeBulk.TagAddresses);
                    break;
                case MxCommandKind.UnsubscribeBulk:
                    UnsubscribeCommandCount++;
                    if (FailUnsubscribe)
                    {
                        throw new InvalidOperationException("Simulated worker unsubscribe failure.");
                    }

                    UnsubscribedHandles.AddRange(mxCommand.UnsubscribeBulk.ItemHandles);
                    reply.UnsubscribeBulk = new BulkSubscribeReply();
                    break;
                case MxCommandKind.ReadBulk:
                    reply.ReadBulk = Read(mxCommand.ReadBulk.TagAddresses);
                    break;
                default:
                    throw new NotSupportedException($"Unexpected dashboard command {mxCommand.Kind}.");
            }

            return Task.FromResult(new WorkerCommandReply { Reply = reply });
        }

        /// <inheritdoc />
        public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await Task.CompletedTask.ConfigureAwait(false);
            yield break;
        }

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

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

        /// <inheritdoc />
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;

        private BulkSubscribeReply Subscribe(IEnumerable<string> tagAddresses)
        {
            BulkSubscribeReply subscribeReply = new();
            foreach (string tagAddress in tagAddresses)
            {
                SubscribedTags.Add(tagAddress);
                if (FailSubscribeFor.Contains(tagAddress))
                {
                    subscribeReply.Results.Add(new SubscribeResult
                    {
                        ServerHandle = RegisteredServerHandle,
                        TagAddress = tagAddress,
                        ItemHandle = 0,
                        WasSuccessful = false,
                        ErrorMessage = "Simulated advise failure.",
                    });
                    continue;
                }

                if (!_itemHandles.TryGetValue(tagAddress, out int itemHandle))
                {
                    itemHandle = _nextItemHandle++;
                    _itemHandles[tagAddress] = itemHandle;
                }

                subscribeReply.Results.Add(new SubscribeResult
                {
                    ServerHandle = RegisteredServerHandle,
                    TagAddress = tagAddress,
                    ItemHandle = itemHandle,
                    WasSuccessful = true,
                });
            }

            return subscribeReply;
        }

        private BulkReadReply Read(IEnumerable<string> tagAddresses)
        {
            BulkReadReply readReply = new();
            foreach (string tagAddress in tagAddresses)
            {
                if (FailReadFor.Contains(tagAddress))
                {
                    readReply.Results.Add(new BulkReadResult
                    {
                        ServerHandle = RegisteredServerHandle,
                        TagAddress = tagAddress,
                        ItemHandle = _itemHandles.TryGetValue(tagAddress, out int failedHandle) ? failedHandle : 0,
                        WasSuccessful = false,
                        Quality = 0,
                        ErrorMessage = "Simulated read failure.",
                    });
                    continue;
                }

                BulkReadResult readResult = new()
                {
                    ServerHandle = RegisteredServerHandle,
                    TagAddress = tagAddress,
                    ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0,
                    WasSuccessful = true,
                    Quality = 192,
                };

                if (ReadValue is not null)
                {
                    readResult.Value = ReadValue.Clone();
                }

                readReply.Results.Add(readResult);
            }

            return readReply;
        }
    }
}
