using System.Globalization;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
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.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;

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

public sealed class DashboardSnapshotServiceTests
{
    /// <summary>
    /// Verifies snapshot returns empty collections and healthy status when registry is empty.
    /// </summary>
    [Fact]
    public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
    {
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Empty(snapshot.Sessions);
        Assert.Empty(snapshot.Workers);
        Assert.Empty(snapshot.Faults);
        Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0);
        Assert.Equal("Healthy", snapshot.GatewayStatus);
        Assert.NotNull(snapshot.Configuration);
    }

    /// <summary>
    /// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data.
    /// </summary>
    [Fact]
    public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
    {
        SessionRegistry registry = new();
        GatewaySession activeSession = CreateSession(
            "session-active",
            "client-one",
            DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
        activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
        activeSession.MarkReady();
        GatewaySession faultedSession = CreateSession(
            "session-faulted",
            "client-two",
            DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
        faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
        faultedSession.MarkFaulted("worker pipe disconnected");
        GatewaySession closedSession = CreateSession(
            "session-closed",
            "client-three",
            DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture));
        closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed));
        closedSession.TransitionTo(SessionState.Closed);
        registry.TryAdd(activeSession);
        registry.TryAdd(faultedSession);
        registry.TryAdd(closedSession);
        using GatewayMetrics metrics = new();
        metrics.SessionOpened();
        metrics.SessionOpened();
        metrics.CommandStarted("Register");
        metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7));
        metrics.EventReceived("session-active", "OnDataChange");
        metrics.Fault("WorkerFaulted");
        DashboardSnapshotService service = CreateService(registry, metrics);

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Equal(3, snapshot.Sessions.Count);
        Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId);
        Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State);
        DashboardSessionSummary activeSummary = Assert.Single(
            snapshot.Sessions,
            session => session.SessionId == "session-active");
        Assert.Equal(1, activeSummary.EventsReceived);
        Assert.Equal(2, snapshot.Workers.Count);
        Assert.DoesNotContain(snapshot.Workers, worker => worker.SessionId == "session-closed");
        Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1);
        Assert.Contains(
            snapshot.Metrics,
            metric => metric.Name == "mxgateway.events.received"
                && metric.Dimension == "OnDataChange"
                && metric.Value == 1);
        DashboardFaultSummary fault = Assert.Single(snapshot.Faults);
        Assert.Equal("Worker", fault.Source);
        Assert.Equal("session-faulted", fault.SessionId);
        Assert.Equal("worker pipe disconnected", fault.Message);
    }

    /// <summary>
    /// Verifies snapshot metrics include the cumulative alarm provider switch count.
    /// </summary>
    [Fact]
    public void GetSnapshot_IncludesAlarmProviderSwitchCountMetric()
    {
        SessionRegistry registry = new();
        using GatewayMetrics metrics = new();
        metrics.AlarmProviderSwitched(1, 2, AlarmProviderSwitchReason.Failover);
        DashboardSnapshotService service = CreateService(registry, metrics);

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Contains(
            snapshot.Metrics,
            metric => metric.Name == "mxgateway.alarms.provider_switches" && metric.Value == 1);
    }

    /// <summary>
    /// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages.
    /// </summary>
    [Fact]
    public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
    {
        SessionRegistry registry = new();
        GatewaySession session = CreateSession(
            "session-redacted",
            "Bearer mxgw_admin_super-secret",
            DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture),
            clientSessionName: "password=hunter2",
            clientCorrelationId: "token=abc123");
        session.MarkFaulted("secret=credential-value");
        registry.TryAdd(session);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(registry, metrics);

        DashboardSnapshot snapshot = service.GetSnapshot();

        DashboardSessionSummary summary = Assert.Single(snapshot.Sessions);
        Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity);
        Assert.Equal("[redacted]", summary.ClientSessionName);
        Assert.Equal("[redacted]", summary.ClientCorrelationId);
        Assert.Equal("[redacted]", summary.LastFault);
        Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message);
        Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
    }

    /// <summary>
    /// Verifies snapshot generation does not mutate session or worker client state.
    /// </summary>
    [Fact]
    public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
    {
        SessionRegistry registry = new();
        GatewaySession session = CreateSession(
            "session-active",
            "client-one",
            DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
        FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();
        registry.TryAdd(session);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(registry, metrics);

        service.GetSnapshot();
        service.GetSnapshot();

        Assert.Equal(1, registry.ActiveCount);
        Assert.Equal(SessionState.Ready, session.State);
        Assert.Equal(WorkerClientState.Ready, workerClient.State);
        Assert.Equal(0, workerClient.StartCount);
        Assert.Equal(0, workerClient.ShutdownCount);
        Assert.Equal(0, workerClient.KillCount);
    }

    /// <summary>
    /// Verifies snapshot respects configured limits for recent sessions and faults.
    /// </summary>
    [Fact]
    public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
    {
        SessionRegistry registry = new();
        GatewaySession olderSession = CreateSession(
            "session-older",
            "client-one",
            DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
        GatewaySession newerSession = CreateSession(
            "session-newer",
            "client-two",
            DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
        olderSession.MarkFaulted("older fault");
        newerSession.MarkFaulted("newer fault");
        registry.TryAdd(olderSession);
        registry.TryAdd(newerSession);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(
            registry,
            metrics,
            new GatewayOptions
            {
                Dashboard = new DashboardOptions
                {
                    SnapshotIntervalMilliseconds = 1,
                    RecentSessionLimit = 1,
                    RecentFaultLimit = 1,
                },
            });

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId);
        Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
    }

    /// <summary>
    /// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories.
    /// </summary>
    [Fact]
    public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
    {
        // The shared-library cache entry no longer carries a precomputed dashboard summary;
        // DashboardSnapshotService derives templates/categories from the entry's objects via
        // DashboardGalaxySummaryProjector. Seed objects that yield $Pump x2 / $Area x1 templates and
        // categories UserDefined(10) x2 / Area(13) x1, matching the asserted summary.
        GalaxyObject[] objects =
        [
            new GalaxyObject
            {
                GobjectId = 1,
                BrowseName = "AreaA",
                IsArea = true,
                CategoryId = 13,
                TemplateChain = { "$Area" },
            },
            new GalaxyObject
            {
                GobjectId = 2,
                BrowseName = "Pump01",
                CategoryId = 10,
                TemplateChain = { "$Pump" },
            },
            new GalaxyObject
            {
                GobjectId = 3,
                BrowseName = "Pump02",
                CategoryId = 10,
                TemplateChain = { "$Pump" },
            },
        ];
        GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 7,
            LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
            LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
            LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
            Objects = objects,
            Index = GalaxyHierarchyIndex.Build(objects),
            ObjectCount = 3,
            AreaCount = 1,
            AttributeCount = 2,
            HistorizedAttributeCount = 1,
            AlarmAttributeCount = 1,
        };
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry));

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status);
        Assert.Equal(3, snapshot.Galaxy.ObjectCount);
        Assert.Equal(1, snapshot.Galaxy.AreaCount);
        Assert.Equal(2, snapshot.Galaxy.AttributeCount);
        Assert.Equal(2, snapshot.Galaxy.TopTemplates.Count);
        Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
        Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
        Assert.Contains(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Area" && t.InstanceCount == 1);
        Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
        Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
    }

    /// <summary>
    /// The shared library replaces the cache entry via
    /// <c>previous with { ... }</c> at the <b>same</b> Sequence on a steady-state tick and on a
    /// refresh failure (Status → Unavailable, LastError set). The dashboard summary must reflect
    /// those per-tick-volatile fields, not a summary frozen at the last heavy-refresh sequence —
    /// otherwise the Galaxy health indicator keeps showing Healthy throughout a SQL outage.
    /// </summary>
    [Fact]
    public void GetSnapshot_WhenGalaxyEntryChangesAtSameSequence_ReflectsVolatileStatusAndError()
    {
        GalaxyHierarchyCacheEntry healthy = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 5,
            LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture),
            LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture),
        };
        MutableGalaxyHierarchyCache cache = new(healthy);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);

        Assert.Equal(DashboardGalaxyStatus.Healthy, service.GetSnapshot().Galaxy.Status);

        // SQL outage: no heavy refresh can succeed, so Sequence cannot advance; the library
        // mutates the entry in place to Unavailable with an error at the same Sequence.
        cache.Current = healthy with
        {
            Status = GalaxyCacheStatus.Unavailable,
            LastError = "SQL unreachable",
            LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture),
        };

        DashboardGalaxySummary summary = service.GetSnapshot().Galaxy;
        Assert.Equal(DashboardGalaxyStatus.Unavailable, summary.Status);
        Assert.Equal("SQL unreachable", summary.LastError);
        Assert.Equal(
            DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture),
            summary.LastQueriedAt);
    }

    /// <summary>
    /// Verifies the O(N) template/category breakdown is memoized on the cache Sequence: in
    /// production the object set only changes when the library bumps Sequence on a heavy refresh,
    /// so the breakdown is reused at an unchanged Sequence. Proves the memo keys on Sequence by
    /// swapping Objects at the same Sequence and asserting the breakdown does not recompute.
    /// </summary>
    [Fact]
    public void GetSnapshot_WhenSequenceUnchanged_ReusesExpensiveTemplateBreakdown()
    {
        GalaxyObject[] first =
            [new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }];
        GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 9,
            Objects = first,
            Index = GalaxyHierarchyIndex.Build(first),
            ObjectCount = 1,
        };
        MutableGalaxyHierarchyCache cache = new(entry);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);

        Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);

        GalaxyObject[] second =
            [new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }];
        cache.Current = entry with { Objects = second, Index = GalaxyHierarchyIndex.Build(second) };

        // Same Sequence (9) → breakdown reused → still "$Pump", not "$Valve".
        Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
    }

    /// <summary>
    /// Verifies that a changed cache Sequence invalidates the memoized template breakdown and the
    /// next snapshot reflects the new object set. Guards against an inverted sequence check that
    /// would freeze the Galaxy section after its first load.
    /// </summary>
    [Fact]
    public void GetSnapshot_WhenSequenceChanges_RecomputesTemplateBreakdown()
    {
        GalaxyObject[] first =
            [new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }];
        GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 9,
            Objects = first,
            Index = GalaxyHierarchyIndex.Build(first),
            ObjectCount = 1,
        };
        MutableGalaxyHierarchyCache cache = new(entry);
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache);

        Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);

        GalaxyObject[] second =
            [new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }];
        cache.Current = entry with { Sequence = 10, Objects = second, Index = GalaxyHierarchyIndex.Build(second) };

        // New Sequence (10) → breakdown recomputed → now "$Valve".
        Assert.Equal("$Valve", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName);
    }

    /// <summary>
    /// Verifies snapshot watcher cancels cleanly when subscriber cancels.
    /// </summary>
    [Fact]
    public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
    {
        using GatewayMetrics metrics = new();
        CountingApiKeyAdminStore apiKeyAdminStore = new();
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            apiKeyAdminStore: apiKeyAdminStore);

        DashboardSnapshot snapshot = service.GetSnapshot();

        Assert.Empty(snapshot.ApiKeys);
        Assert.Equal(0, apiKeyAdminStore.ListCount);
    }

    /// <summary>Verifies that snapshot service refreshes API key summaries before each snapshot.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
    {
        using GatewayMetrics metrics = new();
        CountingApiKeyAdminStore apiKeyAdminStore = new(
            new ApiKeyListItem(
                KeyId: "operator01",
                KeyPrefix: "mxgw",
                DisplayName: "Operator",
                Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
                ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with
                {
                    BrowseSubtrees = ["Area1/*"],
                }),
                CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
                LastUsedUtc: null,
                RevokedUtc: null));
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            apiKeyAdminStore: apiKeyAdminStore);
        using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
        await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
            .WatchSnapshotsAsync(cancellation.Token)
            .GetAsyncEnumerator(cancellation.Token);

        Assert.True(await enumerator.MoveNextAsync());
        DashboardSnapshot snapshot = enumerator.Current;

        DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
        Assert.Equal("operator01", key.KeyId);
        Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
        Assert.Equal(1, apiKeyAdminStore.ListCount);
    }

    /// <summary>Verifies that snapshot service reuses previous summaries when API key refresh fails.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
    {
        using GatewayMetrics metrics = new();
        SequencedApiKeyAdminStore apiKeyAdminStore = new(
            new ApiKeyListItem(
                KeyId: "operator01",
                KeyPrefix: "mxgw",
                DisplayName: "Operator",
                Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
                ConstraintsJson: null,
                CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
                LastUsedUtc: null,
                RevokedUtc: null));
        FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2026-08-15T00:00:00Z", CultureInfo.InvariantCulture));
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            new GatewayOptions
            {
                Dashboard = new DashboardOptions
                {
                    SnapshotIntervalMilliseconds = 1000,
                },
            },
            apiKeyAdminStore: apiKeyAdminStore,
            timeProvider: timeProvider);
        using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30));
        await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
            .WatchSnapshotsAsync(cancellation.Token)
            .GetAsyncEnumerator(cancellation.Token);

        Assert.True(await enumerator.MoveNextAsync());
        DashboardSnapshot first = enumerator.Current;
        apiKeyAdminStore.FailNext = true;

        // Advance past the key-summary refresh interval so the second tick really
        // does attempt a refresh — that attempt is the one that fails.
        DashboardSnapshot second = await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(20));

        Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
        Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
        Assert.Equal(2, apiKeyAdminStore.ListCount);
    }

    /// <summary>
    /// The API key list is a SQLite read; at the default 1s snapshot cadence it would run
    /// ~86k times a day against a table that changes by hand. Ticks inside the refresh
    /// interval must reuse the cached summaries and not touch the store.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task WatchSnapshotsAsync_WhenTicksFallInsideRefreshInterval_ListsApiKeysOnce()
    {
        using GatewayMetrics metrics = new();
        CountingApiKeyAdminStore apiKeyAdminStore = new(
            new ApiKeyListItem(
                KeyId: "operator01",
                KeyPrefix: "mxgw",
                DisplayName: "Operator",
                Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
                ConstraintsJson: null,
                CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
                LastUsedUtc: null,
                RevokedUtc: null));
        FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2026-08-15T00:00:00Z", CultureInfo.InvariantCulture));
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            new GatewayOptions
            {
                Dashboard = new DashboardOptions
                {
                    SnapshotIntervalMilliseconds = 1000,
                },
            },
            apiKeyAdminStore: apiKeyAdminStore,
            timeProvider: timeProvider);
        using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(30));
        await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
            .WatchSnapshotsAsync(cancellation.Token)
            .GetAsyncEnumerator(cancellation.Token);

        Assert.True(await enumerator.MoveNextAsync());
        Assert.Equal(1, apiKeyAdminStore.ListCount);

        // Two more 1s ticks, both well inside the 15s refresh interval.
        DashboardSnapshot second = await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(1));
        await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(1));

        Assert.Equal(1, apiKeyAdminStore.ListCount);
        Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);

        // A tick past the interval refreshes again, so an added or revoked key still
        // reaches the dashboard within the interval.
        await NextSnapshotAsync(enumerator, timeProvider, TimeSpan.FromSeconds(20));

        Assert.Equal(2, apiKeyAdminStore.ListCount);
    }

    /// <summary>
    /// The effective configuration is startup-static, so the snapshot must hand out the
    /// same instance instead of rebuilding the whole option tree on every tick.
    /// </summary>
    [Fact]
    public void GetSnapshot_ReusesTheSameEffectiveConfigurationInstance()
    {
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);

        DashboardSnapshot first = service.GetSnapshot();
        DashboardSnapshot second = service.GetSnapshot();

        Assert.Same(first.Configuration, second.Configuration);
    }

    /// <summary>Verifies that snapshot service disposes cleanly when subscriber cancels.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
    {
        using GatewayMetrics metrics = new();
        DashboardSnapshotService service = CreateService(
            new SessionRegistry(),
            metrics,
            new GatewayOptions
            {
                Dashboard = new DashboardOptions
                {
                    SnapshotIntervalMilliseconds = 1,
                },
            });
        using CancellationTokenSource cancellation = new();
        await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
            .WatchSnapshotsAsync(cancellation.Token)
            .GetAsyncEnumerator();

        Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
        await cancellation.CancelAsync();
        bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));

        Assert.False(hasNext);
    }

    /// <summary>
    /// Advances the fake clock past the next snapshot tick and returns the snapshot it
    /// produces. <c>MoveNextAsync</c> is started before the advance because the iterator
    /// creates its <see cref="PeriodicTimer"/> synchronously on that call — the timer must
    /// exist before the clock moves or the tick is missed.
    /// </summary>
    /// <param name="enumerator">The snapshot enumerator being driven.</param>
    /// <param name="timeProvider">The fake clock backing the snapshot timer.</param>
    /// <param name="advance">How far to advance the clock.</param>
    /// <returns>The snapshot produced by the tick.</returns>
    private static async Task<DashboardSnapshot> NextSnapshotAsync(
        IAsyncEnumerator<DashboardSnapshot> enumerator,
        FakeTimeProvider timeProvider,
        TimeSpan advance)
    {
        ValueTask<bool> pending = enumerator.MoveNextAsync();
        timeProvider.Advance(advance);
        Assert.True(await pending.AsTask().WaitAsync(TimeSpan.FromSeconds(10)));
        return enumerator.Current;
    }

    private static DashboardSnapshotService CreateService(
        SessionRegistry registry,
        GatewayMetrics metrics,
        GatewayOptions? options = null,
        IGalaxyHierarchyCache? galaxyHierarchyCache = null,
        IApiKeyAdminStore? apiKeyAdminStore = null,
        TimeProvider? timeProvider = null)
    {
        GatewayOptions resolvedOptions = options ?? new GatewayOptions
        {
            Dashboard = new DashboardOptions
            {
                SnapshotIntervalMilliseconds = 1,
            },
        };
        GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions));

        return new DashboardSnapshotService(
            registry,
            metrics,
            configurationProvider,
            galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
            apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
            Options.Create(resolvedOptions),
            timeProvider);
    }

    private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
    {
        /// <summary>
        /// Gets the current Galaxy hierarchy cache entry.
        /// </summary>
        public GalaxyHierarchyCacheEntry Current { get; } = current;

        /// <summary>
        /// Refreshes the cache asynchronously.
        /// </summary>
        /// <param name="cancellationToken">Cancellation token.</param>
        /// <returns>Completed task.</returns>
        public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;

        /// <summary>
        /// Waits for the first cache load asynchronously.
        /// </summary>
        /// <param name="cancellationToken">Cancellation token.</param>
        /// <returns>Completed task.</returns>
        public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }

    private sealed class MutableGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
    {
        /// <summary>Gets or sets the current Galaxy hierarchy cache entry, swappable between snapshots.</summary>
        public GalaxyHierarchyCacheEntry Current { get; set; } = current;

        /// <summary>Refreshes the cache asynchronously.</summary>
        /// <param name="cancellationToken">Cancellation token.</param>
        /// <returns>Completed task.</returns>
        public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;

        /// <summary>Waits for the first cache load asynchronously.</summary>
        /// <param name="cancellationToken">Cancellation token.</param>
        /// <returns>Completed task.</returns>
        public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }

    private class FakeApiKeyAdminStore : IApiKeyAdminStore
    {
        /// <summary>Does nothing; the fake never persists created keys.</summary>
        /// <param name="record">API key record to create.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
        {
            return Task.CompletedTask;
        }

        /// <summary>Lists API keys; the base fake always returns an empty list.</summary>
        /// <param name="ct">Cancellation token.</param>
        /// <returns>An empty list of API key summaries.</returns>
        public virtual Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
        {
            return Task.FromResult<IReadOnlyList<ApiKeyListItem>>([]);
        }

        /// <summary>Does nothing; the fake never revokes keys.</summary>
        /// <param name="keyId">Key identifier to revoke.</param>
        /// <param name="whenUtc">Revocation timestamp.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns><see langword="false"/>, always.</returns>
        public Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
        {
            return Task.FromResult(false);
        }

        /// <summary>Does nothing; the fake never rotates key secrets.</summary>
        /// <param name="keyId">Key identifier to rotate.</param>
        /// <param name="newSecretHash">New secret hash.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns><see langword="false"/>, always.</returns>
        public Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
        {
            return Task.FromResult(false);
        }

        /// <summary>Does nothing; the fake never deletes keys.</summary>
        /// <param name="keyId">Key identifier to delete.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns><see langword="false"/>, always.</returns>
        public Task<bool> DeleteAsync(string keyId, CancellationToken ct)
        {
            return Task.FromResult(false);
        }

        /// <summary>Does nothing; the fake never changes scopes (added to IApiKeyAdminStore in Auth 0.1.3).</summary>
        /// <param name="keyId">Key identifier.</param>
        /// <param name="scopes">Replacement scope set.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns><see langword="false"/>, always.</returns>
        public Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
        {
            return Task.FromResult(false);
        }

        /// <summary>Does nothing; the fake never toggles key state (added to IApiKeyAdminStore in Auth 0.1.3).</summary>
        /// <param name="keyId">Key identifier.</param>
        /// <param name="enabled">Desired enabled state.</param>
        /// <param name="whenUtc">Timestamp for the state change.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns><see langword="false"/>, always.</returns>
        public Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
        {
            return Task.FromResult(false);
        }
    }

    private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore
    {
        /// <summary>Gets the count of list operations performed.</summary>
        public int ListCount { get; protected set; }

        /// <inheritdoc />
        public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
        {
            ListCount++;
            return Task.FromResult<IReadOnlyList<ApiKeyListItem>>(records);
        }
    }

    private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record)
    {
        /// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
        public bool FailNext { get; set; }

        /// <inheritdoc />
        public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
        {
            if (FailNext)
            {
                FailNext = false;
                ListCount++;
                throw new InvalidOperationException("Simulated SQLite failure.");
            }

            return base.ListAsync(ct);
        }
    }

    private static GatewaySession CreateSession(
        string sessionId,
        string? clientIdentity,
        DateTimeOffset openedAt,
        string? clientSessionName = "test-session",
        string? clientCorrelationId = "client-correlation")
    {
        return new GatewaySession(
            sessionId,
            "mxaccess",
            $"mxaccess-gateway-1-{sessionId}",
            "nonce",
            clientIdentity,
            clientSessionName,
            clientCorrelationId,
            TimeSpan.FromSeconds(30),
            TimeSpan.FromSeconds(5),
            TimeSpan.FromSeconds(5),
            openedAt);
    }

    private sealed class FakeWorkerClient(
        string sessionId,
        int? processId,
        WorkerClientState state) : IWorkerClient
    {
        /// <inheritdoc />
        public string SessionId { get; } = sessionId;

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

        /// <inheritdoc />
        public WorkerClientState State { get; private set; } = state;

        /// <inheritdoc />
        public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);

        /// <summary>
        /// Gets the count of start invocations.
        /// </summary>
        public int StartCount { get; private set; }

        /// <summary>
        /// Gets the count of shutdown invocations.
        /// </summary>
        public int ShutdownCount { get; private set; }

        /// <summary>
        /// Gets the count of kill invocations.
        /// </summary>
        public int KillCount { get; private set; }

        /// <inheritdoc />
        public Task StartAsync(CancellationToken cancellationToken)
        {
            StartCount++;
            return Task.CompletedTask;
        }

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

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

        /// <inheritdoc />
        public Task ShutdownAsync(
            TimeSpan timeout,
            CancellationToken cancellationToken)
        {
            ShutdownCount++;
            State = WorkerClientState.Closed;
            return Task.CompletedTask;
        }

        /// <inheritdoc />
        public void Kill(string reason)
        {
            KillCount++;
            State = WorkerClientState.Faulted;
        }

        /// <summary>
        /// Releases resources used by this worker client.
        /// </summary>
        /// <returns>Completed value task.</returns>
        public ValueTask DisposeAsync()
        {
            return ValueTask.CompletedTask;
        }
    }
}
