using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;

namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Kpi;

/// <summary>
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that
/// snapshots the Notification Outbox delivery KPIs (global / per-site / per-node) into the
/// central KPI-history store.
/// </summary>
public class NotificationOutboxKpiSampleSourceTests
{
    private static readonly DateTime CapturedAt = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);

    private static readonly NotificationOutboxOptions Options = new()
    {
        StuckAgeThreshold = TimeSpan.FromMinutes(10),
        DeliveredKpiWindow = TimeSpan.FromMinutes(1),
    };

    private static NotificationOutboxKpiSampleSource CreateSource(INotificationOutboxRepository repository) =>
        new(repository, Microsoft.Extensions.Options.Options.Create(Options));

    [Fact]
    public void Source_IsNotificationOutbox()
    {
        var source = CreateSource(Substitute.For<INotificationOutboxRepository>());

        Assert.Equal(KpiSources.NotificationOutbox, source.Source);
    }

    // C4 (arch-review 04): the two metric names that were inline string literals now
    // live in the shared KpiMetrics catalog. Their VALUES are persisted in the KpiSample
    // store and rendered by the trend UI, so a rename would silently orphan history —
    // pin the exact historical strings here.
    [Fact]
    public void KpiMetricCatalog_NotificationOutbox_PreservesHistoricalStringValues()
    {
        Assert.Equal("queueDepth", KpiMetrics.NotificationOutbox.QueueDepth);
        Assert.Equal("stuckCount", KpiMetrics.NotificationOutbox.StuckCount);
        Assert.Equal("parkedCount", KpiMetrics.NotificationOutbox.ParkedCount);
        Assert.Equal("deliveredLastInterval", KpiMetrics.NotificationOutbox.DeliveredLastInterval);
        Assert.Equal("oldestPendingAgeSeconds", KpiMetrics.NotificationOutbox.OldestPendingAgeSeconds);
    }

    [Fact]
    public async Task CollectAsync_PassesCutoffsAnchoredOnCapturedAt()
    {
        var repository = Substitute.For<INotificationOutboxRepository>();
        StubEmptySnapshots(repository);

        var source = CreateSource(repository);
        await source.CollectAsync(CapturedAt);

        var expectedStuckCutoff = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.StuckAgeThreshold;
        var expectedDeliveredSince = new DateTimeOffset(CapturedAt, TimeSpan.Zero) - Options.DeliveredKpiWindow;

        await repository.Received(1).ComputeKpisAsync(
            expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
        await repository.Received(1).ComputePerSiteKpisAsync(
            expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
        await repository.Received(1).ComputePerNodeKpisAsync(
            expectedStuckCutoff, expectedDeliveredSince, Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task CollectAsync_EmitsGlobalSiteAndNodeSamples_WithExpectedTuples()
    {
        var repository = Substitute.For<INotificationOutboxRepository>();
        repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(new NotificationKpiSnapshot(
                QueueDepth: 5,
                StuckCount: 2,
                ParkedCount: 1,
                DeliveredLastInterval: 7,
                OldestPendingAge: TimeSpan.FromSeconds(90)));
        repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(new[]
            {
                new SiteNotificationKpiSnapshot(
                    SourceSiteId: "site-a",
                    QueueDepth: 3,
                    StuckCount: 1,
                    ParkedCount: 0,
                    DeliveredLastInterval: 4,
                    OldestPendingAge: TimeSpan.FromSeconds(30)),
            });
        repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(new[]
            {
                new NodeNotificationKpiSnapshot(
                    SourceNode: "node-a",
                    QueueDepth: 2,
                    StuckCount: 1,
                    ParkedCount: 1,
                    DeliveredLastInterval: 3,
                    OldestPendingAge: TimeSpan.FromSeconds(60)),
            });

        var source = CreateSource(repository);
        var samples = await source.CollectAsync(CapturedAt);

        // 3 scopes × 5 metrics (all ages non-null) = 15 samples.
        Assert.Equal(15, samples.Count);
        Assert.All(samples, s => Assert.Equal(KpiSources.NotificationOutbox, s.Source));
        Assert.All(samples, s => Assert.Equal(CapturedAt, s.CapturedAtUtc));

        // Global — null ScopeKey.
        AssertSample(samples, "queueDepth", KpiScopes.Global, null, 5);
        AssertSample(samples, "stuckCount", KpiScopes.Global, null, 2);
        AssertSample(samples, "parkedCount", KpiScopes.Global, null, 1);
        AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 7);
        AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 90);

        // Site — ScopeKey == site id.
        AssertSample(samples, "queueDepth", KpiScopes.Site, "site-a", 3);
        AssertSample(samples, "stuckCount", KpiScopes.Site, "site-a", 1);
        AssertSample(samples, "parkedCount", KpiScopes.Site, "site-a", 0);
        AssertSample(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 4);
        AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30);

        // Node — ScopeKey == node name.
        AssertSample(samples, "queueDepth", KpiScopes.Node, "node-a", 2);
        AssertSample(samples, "stuckCount", KpiScopes.Node, "node-a", 1);
        AssertSample(samples, "parkedCount", KpiScopes.Node, "node-a", 1);
        AssertSample(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 3);
        AssertSample(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 60);
    }

    [Fact]
    public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
    {
        var repository = Substitute.For<INotificationOutboxRepository>();
        repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(new NotificationKpiSnapshot(
                QueueDepth: 0,
                StuckCount: 0,
                ParkedCount: 0,
                DeliveredLastInterval: 0,
                OldestPendingAge: null));
        repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(Array.Empty<SiteNotificationKpiSnapshot>());
        repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(Array.Empty<NodeNotificationKpiSnapshot>());

        var source = CreateSource(repository);
        var samples = await source.CollectAsync(CapturedAt);

        // Only the global snapshot, age omitted -> the four count metrics.
        Assert.Equal(4, samples.Count);
        Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds");
        AssertSample(samples, "queueDepth", KpiScopes.Global, null, 0);
        AssertSample(samples, "stuckCount", KpiScopes.Global, null, 0);
        AssertSample(samples, "parkedCount", KpiScopes.Global, null, 0);
        AssertSample(samples, "deliveredLastInterval", KpiScopes.Global, null, 0);
    }

    [Fact]
    public async Task CollectAsync_ReturnsEmptyList_NeverNull_WhenNothingToReport()
    {
        // ComputeKpisAsync always returns a global snapshot; the only way the list is empty is
        // a guard that produces no samples. Confirm an all-zero global with null age still yields
        // the four count metrics (i.e. the list is never null even at idle).
        var repository = Substitute.For<INotificationOutboxRepository>();
        StubEmptySnapshots(repository);

        var source = CreateSource(repository);
        var samples = await source.CollectAsync(CapturedAt);

        Assert.NotNull(samples);
    }

    private static void StubEmptySnapshots(INotificationOutboxRepository repository)
    {
        repository.ComputeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(new NotificationKpiSnapshot(0, 0, 0, 0, null));
        repository.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(Array.Empty<SiteNotificationKpiSnapshot>());
        repository.ComputePerNodeKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
            .Returns(Array.Empty<NodeNotificationKpiSnapshot>());
    }

    private static void AssertSample(
        IReadOnlyList<KpiSample> samples, string metric, string scope, string? scopeKey, double value)
    {
        var match = Assert.Single(
            samples, s => s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey);
        Assert.Equal(value, match.Value);
    }
}
