using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi;

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

/// <summary>
/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (M6 KPI History,
/// #K7). A hand-rolled <see cref="ISiteCallAuditRepository"/> fake (matching the
/// project's existing test-double style — no mocking library) returns known
/// global + per-site + per-node snapshots; the assertions pin the exact
/// <c>(Metric, Scope, ScopeKey, Value)</c> tuples the recorder will persist, the
/// cutoffs derived from <see cref="SiteCallAuditOptions"/>, and the
/// oldest-pending-age omission when its source is <c>null</c>.
/// </summary>
public class SiteCallAuditKpiSampleSourceTests
{
    private static readonly DateTime CapturedAt =
        new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);

    private static SiteCallAuditOptions Options(
        TimeSpan? stuckAge = null, TimeSpan? kpiInterval = null) => new()
    {
        StuckAgeThreshold = stuckAge ?? TimeSpan.FromMinutes(10),
        KpiInterval = kpiInterval ?? TimeSpan.FromMinutes(1),
    };

    private static SiteCallAuditKpiSampleSource CreateSource(
        ISiteCallAuditRepository repo, SiteCallAuditOptions? options = null) =>
        new(repo, OptionsWrap(options ?? Options()));

    private static IOptions<SiteCallAuditOptions> OptionsWrap(SiteCallAuditOptions o) =>
        Microsoft.Extensions.Options.Options.Create(o);

    // ---------------------------------------------------------------------
    // 1. Source identifier is the canonical SiteCallAudit constant.
    // ---------------------------------------------------------------------

    [Fact]
    public void Source_IsSiteCallAuditConstant()
    {
        var source = CreateSource(new StubRepo());
        Assert.Equal(KpiSources.SiteCallAudit, source.Source);
    }

    // ---------------------------------------------------------------------
    // 2. Full snapshot: global + per-site + per-node, exact tuples.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task CollectAsync_EmitsExpectedTuples_ForGlobalSiteAndNode()
    {
        var repo = new StubRepo
        {
            Global = new SiteCallKpiSnapshot(
                BufferedCount: 5,
                ParkedCount: 2,
                FailedLastInterval: 1,
                DeliveredLastInterval: 9,
                OldestPendingAge: TimeSpan.FromSeconds(42),
                StuckCount: 3),
            PerSite =
            [
                new SiteCallSiteKpiSnapshot(
                    SourceSite: "site-a",
                    BufferedCount: 4,
                    ParkedCount: 1,
                    FailedLastInterval: 0,
                    DeliveredLastInterval: 7,
                    OldestPendingAge: TimeSpan.FromSeconds(30),
                    StuckCount: 2),
            ],
            PerNode =
            [
                new SiteCallNodeKpiSnapshot(
                    SourceNode: "node-a",
                    BufferedCount: 3,
                    ParkedCount: 1,
                    FailedLastInterval: 1,
                    DeliveredLastInterval: 5,
                    OldestPendingAge: TimeSpan.FromSeconds(20),
                    StuckCount: 1),
            ],
        };

        var samples = await CreateSource(repo).CollectAsync(CapturedAt);

        // Every sample carries Source + CapturedAtUtc.
        Assert.All(samples, s =>
        {
            Assert.Equal(KpiSources.SiteCallAudit, s.Source);
            Assert.Equal(CapturedAt, s.CapturedAtUtc);
        });

        // Global (null ScopeKey): six metrics (age present).
        AssertHas(samples, "buffered", KpiScopes.Global, null, 5);
        AssertHas(samples, "parked", KpiScopes.Global, null, 2);
        AssertHas(samples, "failedLastInterval", KpiScopes.Global, null, 1);
        AssertHas(samples, "deliveredLastInterval", KpiScopes.Global, null, 9);
        AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 42);
        AssertHas(samples, "stuck", KpiScopes.Global, null, 3);

        // Per-site (ScopeKey = site id).
        AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 4);
        AssertHas(samples, "parked", KpiScopes.Site, "site-a", 1);
        AssertHas(samples, "failedLastInterval", KpiScopes.Site, "site-a", 0);
        AssertHas(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 7);
        AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30);
        AssertHas(samples, "stuck", KpiScopes.Site, "site-a", 2);

        // Per-node (ScopeKey = node name).
        AssertHas(samples, "buffered", KpiScopes.Node, "node-a", 3);
        AssertHas(samples, "parked", KpiScopes.Node, "node-a", 1);
        AssertHas(samples, "failedLastInterval", KpiScopes.Node, "node-a", 1);
        AssertHas(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 5);
        AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 20);
        AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 1);

        // 6 metrics × 3 scopes, all ages present.
        Assert.Equal(18, samples.Count);
    }

    // ---------------------------------------------------------------------
    // 3. Null oldest-pending-age is omitted (not written as zero).
    // ---------------------------------------------------------------------

    [Fact]
    public async Task CollectAsync_OmitsOldestPendingAge_WhenNull()
    {
        var repo = new StubRepo
        {
            Global = new SiteCallKpiSnapshot(0, 0, 0, 0, OldestPendingAge: null, 0),
            PerSite =
            [
                new SiteCallSiteKpiSnapshot("site-a", 0, 0, 0, 0, OldestPendingAge: null, 0),
            ],
            PerNode =
            [
                new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, OldestPendingAge: null, 0),
            ],
        };

        var samples = await CreateSource(repo).CollectAsync(CapturedAt);

        // No oldestPendingAgeSeconds rows at any scope when the age is null.
        Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds");

        // The five count metrics are still present at each of the 3 scopes.
        Assert.Equal(15, samples.Count);
        AssertHas(samples, "buffered", KpiScopes.Global, null, 0);
        AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 0);
    }

    // ---------------------------------------------------------------------
    // 4. Empty per-site/per-node: only the global six metrics emitted.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task CollectAsync_GlobalOnly_WhenNoPerSiteOrPerNodeRows()
    {
        var repo = new StubRepo
        {
            Global = new SiteCallKpiSnapshot(1, 0, 0, 0, TimeSpan.FromSeconds(5), 0),
            PerSite = [],
            PerNode = [],
        };

        var samples = await CreateSource(repo).CollectAsync(CapturedAt);

        Assert.All(samples, s => Assert.Equal(KpiScopes.Global, s.Scope));
        Assert.Equal(6, samples.Count);
    }

    // ---------------------------------------------------------------------
    // 5. Cutoffs are anchored on capturedAtUtc using the options windows.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task CollectAsync_DerivesCutoffs_FromOptionsAnchoredOnCapturedAt()
    {
        var repo = new StubRepo();
        var options = Options(
            stuckAge: TimeSpan.FromMinutes(10),
            kpiInterval: TimeSpan.FromMinutes(2));

        await CreateSource(repo, options).CollectAsync(CapturedAt);

        var expectedStuck = CapturedAt - TimeSpan.FromMinutes(10);
        var expectedSince = CapturedAt - TimeSpan.FromMinutes(2);

        // Every repository call must have received the identical anchored cutoffs.
        Assert.NotEmpty(repo.Calls);
        Assert.All(repo.Calls, c =>
        {
            Assert.Equal(expectedStuck, c.StuckCutoff);
            Assert.Equal(expectedSince, c.IntervalSince);
        });

        // All three KPI methods were invoked.
        Assert.Equal(3, repo.Calls.Count);
    }

    // ---------------------------------------------------------------------
    // 6. Multiple sites/nodes are all emitted with distinct ScopeKeys.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task CollectAsync_EmitsAllSitesAndNodes()
    {
        var repo = new StubRepo
        {
            Global = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0),
            PerSite =
            [
                new SiteCallSiteKpiSnapshot("site-a", 1, 0, 0, 0, null, 0),
                new SiteCallSiteKpiSnapshot("site-b", 2, 0, 0, 0, null, 0),
            ],
            PerNode =
            [
                new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, null, 0),
                new SiteCallNodeKpiSnapshot("node-b", 0, 0, 0, 0, null, 0),
            ],
        };

        var samples = await CreateSource(repo).CollectAsync(CapturedAt);

        AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 1);
        AssertHas(samples, "buffered", KpiScopes.Site, "site-b", 2);
        Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-a" });
        Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-b" });
    }

    // ---------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------

    private static void AssertHas(
        IReadOnlyList<KpiSample> samples,
        string metric, string scope, string? scopeKey, double value)
    {
        var match = samples.SingleOrDefault(s =>
            s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey);
        Assert.True(
            match is not null,
            $"expected a sample for ({metric}, {scope}, {scopeKey ?? "<null>"})");
        Assert.Equal(value, match!.Value);
    }

    /// <summary>Captured arguments of one KPI computation call.</summary>
    private readonly record struct KpiCall(DateTime StuckCutoff, DateTime IntervalSince);

    /// <summary>
    /// Hand-rolled <see cref="ISiteCallAuditRepository"/> stub returning
    /// configurable snapshots and recording the cutoffs each KPI method received.
    /// Non-KPI members are inert (this source only reads KPIs).
    /// </summary>
    private sealed class StubRepo : ISiteCallAuditRepository
    {
        public SiteCallKpiSnapshot Global { get; set; } =
            new(0, 0, 0, 0, null, 0);
        public IReadOnlyList<SiteCallSiteKpiSnapshot> PerSite { get; set; } =
            Array.Empty<SiteCallSiteKpiSnapshot>();
        public IReadOnlyList<SiteCallNodeKpiSnapshot> PerNode { get; set; } =
            Array.Empty<SiteCallNodeKpiSnapshot>();

        public List<KpiCall> Calls { get; } = new();

        public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
        {
            Calls.Add(new KpiCall(stuckCutoff, intervalSince));
            return Task.FromResult(Global);
        }

        public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
        {
            Calls.Add(new KpiCall(stuckCutoff, intervalSince));
            return Task.FromResult(PerSite);
        }

        public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
        {
            Calls.Add(new KpiCall(stuckCutoff, intervalSince));
            return Task.FromResult(PerNode);
        }

        // ── Inert non-KPI members ──
        public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
            Task.CompletedTask;

        public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
            Task.FromResult<SiteCall?>(null);

        public Task<IReadOnlyList<SiteCall>> QueryAsync(
            SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
            Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());

        public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
            Task.FromResult(0);
    }
}
