using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;

/// <summary>
/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>.
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so
/// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test
/// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test
/// <c>SourceSite</c> suffix) so tests neither collide nor require teardown.
/// </summary>
public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
    private readonly MsSqlMigrationFixture _fixture;

    public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
    {
        _fixture = fixture;
    }

    [SkippableFact]
    public async Task UpsertAsync_FreshId_InsertsOneRow()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var row = NewRow(id, status: "Submitted", retryCount: 0);
        await repo.UpsertAsync(row);

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<SiteCall>()
            .Where(s => s.TrackedOperationId == id)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal("Submitted", loaded[0].Status);
        Assert.Equal(0, loaded[0].RetryCount);
    }

    [SkippableFact]
    public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Submitted (rank 0) → Forwarded (rank 1) → Attempted (rank 2) — every
        // step strictly advances the rank, so each upsert must mutate the row.
        await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
        await repo.UpsertAsync(NewRow(id, status: "Forwarded", retryCount: 0));
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "transient 503"));

        var loaded = await repo.GetAsync(id);
        Assert.NotNull(loaded);
        Assert.Equal("Attempted", loaded!.Status);
        Assert.Equal(1, loaded.RetryCount);
        Assert.Equal("transient 503", loaded.LastError);
    }

    [SkippableFact]
    public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // First land Attempted (rank 2). A late-arriving Submitted (rank 0) must
        // NOT roll the row back — silent no-op.
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "transient"));
        var attemptedSnapshot = await repo.GetAsync(id);

        await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0, lastError: null));
        var afterStale = await repo.GetAsync(id);

        Assert.NotNull(afterStale);
        Assert.Equal("Attempted", afterStale!.Status);
        Assert.Equal(5, afterStale.RetryCount);
        Assert.Equal("transient", afterStale.LastError);
        // UpdatedAtUtc should not have moved when the stale write was rejected.
        Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
    }

    [SkippableFact]
    public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // A rejected packet must leave the mirror untouched — one row, still
        // carrying the advanced state. The hazard this pins is the insert leg
        // firing for a row that already exists and forking the mirror into two
        // rows for one id: the insert is gated on IF NOT EXISTS alone (never on
        // "the update matched nothing", which is ALSO what a monotonic rejection
        // looks like), so a stale or regressive packet is a pure no-op.
        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 3, lastError: null));

        // Every flavour of rejection: lower rank, equal terminal rank, and an
        // equal non-terminal rank with a stale timestamp.
        await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
        await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 9, lastError: "should-not-apply"));
        await repo.UpsertAsync(NewRow(
            id,
            status: "Delivered",
            retryCount: 99,
            updatedAtUtc: new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)));

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<SiteCall>()
            .Where(s => s.TrackedOperationId == id)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal("Delivered", loaded[0].Status);
        Assert.Equal(3, loaded[0].RetryCount);
        Assert.Null(loaded[0].LastError);
    }

    /// <summary>
    /// The deterministic half of the concurrent-first-write regression: the OTHER
    /// writer already committed the row, so this call's insert leg is skipped
    /// entirely and only the monotonic UPDATE can carry its state in. Under the
    /// UPDATE-first shape the loser of a first-write race ran its UPDATE BEFORE
    /// any row existed and then skipped its INSERT, dropping the packet's
    /// Status/RetryCount/HttpStatus/TerminalAtUtc on the floor.
    /// </summary>
    [SkippableFact]
    public async Task UpsertAsync_RowAlreadyCreatedByAnotherWriter_StillAppliesThisPacketsState()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        var t0 = new DateTime(2026, 5, 22, 8, 0, 0, DateTimeKind.Utc);

        // Writer 1 (say the reconciliation pull) wins the insert with an early state.
        await using var winnerContext = CreateContext();
        await new SiteCallAuditRepository(winnerContext).UpsertAsync(
            NewRow(id, status: "Submitted", createdAtUtc: t0, updatedAtUtc: t0));

        // Writer 2 (the cached dual-write) carries a LATER lifecycle state for the
        // same id, on its own connection.
        await using var loserContext = CreateContext();
        var loserRepo = new SiteCallAuditRepository(loserContext);
        await loserRepo.UpsertAsync(NewRow(
            id,
            status: "Parked",
            retryCount: 4,
            lastError: "gave up",
            httpStatus: 503,
            createdAtUtc: t0,
            updatedAtUtc: t0.AddSeconds(30),
            terminal: true));

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<SiteCall>()
            .Where(s => s.TrackedOperationId == id)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal("Parked", loaded[0].Status);
        Assert.Equal(4, loaded[0].RetryCount);
        Assert.Equal(503, loaded[0].HttpStatus);
        Assert.Equal("gave up", loaded[0].LastError);
        Assert.NotNull(loaded[0].TerminalAtUtc);
    }

    /// <summary>
    /// The real-concurrency half: two writers racing the FIRST packet of the same
    /// id, on separate connections, carrying DIFFERENT lifecycle states. However
    /// the interleaving falls — either insert winning, or the loser eating a 2627
    /// on its insert leg — the NEWER state must be the one on the row afterwards,
    /// and exactly one row must exist.
    /// </summary>
    /// <remarks>
    /// Repeated over several ids because the interleaving is not controllable; one
    /// id would make the test a coin flip on whether it visits the racing path at
    /// all. Every iteration must hold regardless of which way it raced, so the
    /// test itself is not flaky — only its coverage of the rare branch is
    /// probabilistic.
    /// </remarks>
    [SkippableFact]
    public async Task UpsertAsync_TwoConcurrentFirstWrites_NewerStateSurvives()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var t0 = new DateTime(2026, 5, 22, 9, 0, 0, DateTimeKind.Utc);

        for (var i = 0; i < 12; i++)
        {
            var id = TrackedOperationId.New();

            await using var contextA = CreateContext();
            await using var contextB = CreateContext();
            var repoA = new SiteCallAuditRepository(contextA);
            var repoB = new SiteCallAuditRepository(contextB);

            var early = NewRow(id, status: "Submitted", createdAtUtc: t0, updatedAtUtc: t0);
            var late = NewRow(
                id,
                status: "Delivered",
                retryCount: 2,
                createdAtUtc: t0,
                updatedAtUtc: t0.AddSeconds(10),
                terminal: true);

            await Task.WhenAll(
                Task.Run(() => repoA.UpsertAsync(early)),
                Task.Run(() => repoB.UpsertAsync(late)));

            await using var readContext = CreateContext();
            var loaded = await readContext.Set<SiteCall>()
                .Where(s => s.TrackedOperationId == id)
                .ToListAsync();

            Assert.Single(loaded);
            Assert.Equal("Delivered", loaded[0].Status);
            Assert.Equal(2, loaded[0].RetryCount);
            Assert.NotNull(loaded[0].TerminalAtUtc);
        }
    }

    [SkippableFact]
    public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        var stamp = new DateTime(2026, 6, 1, 9, 0, 0, DateTimeKind.Utc);
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first", updatedAtUtc: stamp));
        var snapshot = await repo.GetAsync(id);

        // Same rank (2) AND an EQUAL UpdatedAtUtc — an idempotent replay of the
        // same telemetry packet. The same-rank freshness tiebreaker only fires
        // for a STRICTLY-newer stamp, so a true duplicate stays a no-op (no
        // fields move). Task 11: newest-within-rank wins, equal stamps are inert.
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp));
        var afterDuplicate = await repo.GetAsync(id);

        Assert.NotNull(afterDuplicate);
        Assert.Equal("Attempted", afterDuplicate!.Status);
        Assert.Equal(1, afterDuplicate.RetryCount);
        Assert.Equal("first", afterDuplicate.LastError);
        Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
    }

    [SkippableFact]
    public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        var t0 = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc);
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Two Attempted packets (rank 2) from one retrying call; the second
        // carries a strictly-newer UpdatedAtUtc, so its live RetryCount/LastError
        // must land — the row no longer freezes at first-Attempted (Task 11).
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "timeout #1", updatedAtUtc: t0));
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));

        var row = await repo.GetAsync(id);
        Assert.NotNull(row);
        Assert.Equal(5, row!.RetryCount);
        Assert.Equal("timeout #5", row.LastError);
        Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
    }

    [SkippableFact]
    public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        var t0 = new DateTime(2026, 6, 1, 11, 0, 0, DateTimeKind.Utc);
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Land retry 5 (stamp t0+5), then an out-of-order retry-2 packet with an
        // OLDER stamp (t0). Same rank, older UpdatedAtUtc → rejected; stays at 5.
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "timeout #2", updatedAtUtc: t0));

        var row = await repo.GetAsync(id);
        Assert.NotNull(row);
        Assert.Equal(5, row!.RetryCount);
        Assert.Equal("timeout #5", row.LastError);
        Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
    }

    [SkippableFact]
    public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        var t0 = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Terminal Delivered (rank 3), then a late lower-rank Attempted (rank 2)
        // with a NEWER stamp. Rank monotonicity dominates the freshness
        // tiebreaker — the row stays Delivered (regression-proof).
        await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 0, updatedAtUtc: t0, terminal: true, terminalAtUtc: t0));
        await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 9, lastError: "late", updatedAtUtc: t0.AddMinutes(5)));

        var row = await repo.GetAsync(id);
        Assert.NotNull(row);
        Assert.Equal("Delivered", row!.Status);
        Assert.Equal(0, row.RetryCount);
    }

    [SkippableFact]
    public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // Bundle B3 plan: terminal statuses share rank 3 and are mutually
        // exclusive — Delivered cannot overwrite Parked. Task 11 note: the
        // Delivered packet carries a STRICTLY-NEWER UpdatedAtUtc, yet the row
        // stays Parked — the same-rank freshness tiebreaker is scoped to
        // NON-terminal ranks (< 3), so terminal immutability is preserved.
        var id = TrackedOperationId.New();
        var t0 = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc);
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", updatedAtUtc: t0, terminal: true, terminalAtUtc: t0));
        var afterPark = await repo.GetAsync(id);

        await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, updatedAtUtc: t0.AddMinutes(5), terminal: true, terminalAtUtc: t0.AddMinutes(5)));
        var afterDeliveredAttempt = await repo.GetAsync(id);

        Assert.NotNull(afterDeliveredAttempt);
        Assert.Equal("Parked", afterDeliveredAttempt!.Status);
        Assert.Equal("parked-reason", afterDeliveredAttempt.LastError);
        Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
    }

    [SkippableFact]
    public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // 50 parallel inserters with the same id. The IF NOT EXISTS … INSERT
        // pattern has a check-then-act race; concurrent losers must surface as
        // silent duplicate-key swallows, not thrown exceptions. Final row
        // count must be exactly 1.
        var id = TrackedOperationId.New();
        var row = NewRow(id, status: "Submitted", retryCount: 0);

        await Parallel.ForEachAsync(
            Enumerable.Range(0, 50),
            new ParallelOptions { MaxDegreeOfParallelism = 50 },
            async (_, ct) =>
            {
                await using var context = CreateContext();
                var repo = new SiteCallAuditRepository(context);
                await repo.UpsertAsync(row, ct);
            });

        await using var readContext = CreateContext();
        var count = await readContext.Set<SiteCall>()
            .Where(s => s.TrackedOperationId == id)
            .CountAsync();
        Assert.Equal(1, count);
    }

    [SkippableFact]
    public async Task GetAsync_KnownId_ReturnsRow()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));

        var loaded = await repo.GetAsync(id);
        Assert.NotNull(loaded);
        Assert.Equal(id, loaded!.TrackedOperationId);
    }

    [SkippableFact]
    public async Task GetAsync_UnknownId_ReturnsNull()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var loaded = await repo.GetAsync(TrackedOperationId.New());
        Assert.Null(loaded);
    }

    [SkippableFact]
    public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteA = NewSiteId();
        var siteB = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0));
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0.AddMinutes(1)));
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteB, createdAtUtc: t0.AddMinutes(2)));

        var rows = await repo.QueryAsync(
            new SiteCallQueryFilter(SourceSite: siteA),
            new SiteCallPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
    }

    [SkippableFact]
    public async Task QueryAsync_KeysetPaging_NoOverlap()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Five rows with distinct CreatedAtUtc. Page-size 2 → page 1 returns
        // minutes 4,3; cursor (minutes 3) → page 2 returns minutes 2,1; cursor
        // (minutes 1) → page 3 returns minute 0.
        var t0 = new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
        for (var i = 0; i < 5; i++)
        {
            await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: site, createdAtUtc: t0.AddMinutes(i)));
        }

        var page1 = await repo.QueryAsync(
            new SiteCallQueryFilter(SourceSite: site),
            new SiteCallPaging(PageSize: 2));
        Assert.Equal(2, page1.Count);
        Assert.Equal(t0.AddMinutes(4), page1[0].CreatedAtUtc);
        Assert.Equal(t0.AddMinutes(3), page1[1].CreatedAtUtc);

        var cursor1 = page1[^1];
        var page2 = await repo.QueryAsync(
            new SiteCallQueryFilter(SourceSite: site),
            new SiteCallPaging(
                PageSize: 2,
                AfterCreatedAtUtc: cursor1.CreatedAtUtc,
                AfterId: cursor1.TrackedOperationId));
        Assert.Equal(2, page2.Count);
        Assert.Equal(t0.AddMinutes(2), page2[0].CreatedAtUtc);
        Assert.Equal(t0.AddMinutes(1), page2[1].CreatedAtUtc);

        var cursor2 = page2[^1];
        var page3 = await repo.QueryAsync(
            new SiteCallQueryFilter(SourceSite: site),
            new SiteCallPaging(
                PageSize: 2,
                AfterCreatedAtUtc: cursor2.CreatedAtUtc,
                AfterId: cursor2.TrackedOperationId));
        Assert.Single(page3);
        Assert.Equal(t0.AddMinutes(0), page3[0].CreatedAtUtc);

        // No overlap across pages.
        var allIds = page1.Concat(page2).Concat(page3).Select(r => r.TrackedOperationId).ToHashSet();
        Assert.Equal(5, allIds.Count);
    }

    [SkippableFact]
    public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Three stuck rows (non-terminal, created before the cutoff) interleaved
        // by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an
        // old-but-terminal row. The stuck predicate is pushed into the SQL WHERE
        // alongside the keyset cursor, so each page must come back full of stuck
        // rows — never under-filled by a post-filter.
        var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
        var cutoff = t0.AddMinutes(10);

        var stuckIds = new List<TrackedOperationId>();
        for (var i = 0; i < 3; i++)
        {
            var stuckId = TrackedOperationId.New();
            stuckIds.Add(stuckId);
            // Stuck: non-terminal, created before the cutoff.
            await repo.UpsertAsync(NewRow(
                stuckId, sourceSite: site, status: "Attempted",
                createdAtUtc: t0.AddMinutes(i)));
            // Not stuck: non-terminal but created after the cutoff.
            await repo.UpsertAsync(NewRow(
                TrackedOperationId.New(), sourceSite: site, status: "Attempted",
                createdAtUtc: cutoff.AddMinutes(i + 1)));
            // Not stuck: created before the cutoff but terminal.
            await repo.UpsertAsync(NewRow(
                TrackedOperationId.New(), sourceSite: site, status: "Delivered",
                createdAtUtc: t0.AddMinutes(i), terminal: true,
                terminalAtUtc: t0.AddMinutes(i + 1)));
        }

        var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff);

        var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2));
        Assert.Equal(2, page1.Count);
        Assert.All(page1, r => Assert.Null(r.TerminalAtUtc));
        Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff));

        var cursor1 = page1[^1];
        var page2 = await repo.QueryAsync(
            filter,
            new SiteCallPaging(
                PageSize: 2,
                AfterCreatedAtUtc: cursor1.CreatedAtUtc,
                AfterId: cursor1.TrackedOperationId));
        // Only the third stuck row remains — no empty trailing page.
        Assert.Single(page2);
        Assert.Null(page2[0].TerminalAtUtc);
        Assert.True(page2[0].CreatedAtUtc < cutoff);

        // Exactly the three stuck rows, no overlap, no non-stuck leakage.
        var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet();
        Assert.Equal(stuckIds.ToHashSet(), returned);
    }

    [SkippableFact]
    public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // One row that's been Delivered for a long time (5 days ago) — should be purged.
        var oldId = TrackedOperationId.New();
        var fiveDaysAgo = DateTime.UtcNow.AddDays(-5);
        await repo.UpsertAsync(NewRow(
            oldId,
            sourceSite: site,
            status: "Delivered",
            retryCount: 1,
            createdAtUtc: fiveDaysAgo.AddMinutes(-1),
            updatedAtUtc: fiveDaysAgo,
            terminal: true,
            terminalAtUtc: fiveDaysAgo));

        var purged = await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));

        Assert.True(purged >= 1, $"Expected at least one purged row; got {purged}.");
        Assert.Null(await repo.GetAsync(oldId));
    }

    [SkippableFact]
    public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        // Non-terminal row: never eligible.
        var activeId = TrackedOperationId.New();
        await repo.UpsertAsync(NewRow(
            activeId,
            sourceSite: site,
            status: "Attempted",
            retryCount: 1,
            createdAtUtc: DateTime.UtcNow.AddDays(-10),
            updatedAtUtc: DateTime.UtcNow.AddDays(-10),
            terminal: false));

        // Recent terminal row: TerminalAtUtc within the keep window.
        var recentTerminalId = TrackedOperationId.New();
        await repo.UpsertAsync(NewRow(
            recentTerminalId,
            sourceSite: site,
            status: "Delivered",
            retryCount: 0,
            createdAtUtc: DateTime.UtcNow.AddHours(-2),
            updatedAtUtc: DateTime.UtcNow.AddHours(-1),
            terminal: true,
            terminalAtUtc: DateTime.UtcNow.AddHours(-1)));

        // Purge older than 1 day — both rows must survive.
        await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));

        Assert.NotNull(await repo.GetAsync(activeId));
        Assert.NotNull(await repo.GetAsync(recentTerminalId));
    }

    [SkippableFact]
    public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        var now = DateTime.UtcNow;
        var cutoff = now.AddDays(-1);

        // Attach a DELETE-counting interceptor so we can prove the purge issues MORE THAN ONE
        // DELETE for a multi-day backlog (arch-review 04 round 2, R6 — time-sliced batching).
        var deleteCounter = new DeleteCountingCommandInterceptor();
        var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer(_fixture.ConnectionString)
            .AddInterceptors(deleteCounter)
            .Options;
        await using var context = new ScadaBridgeDbContext(options);
        var repo = new SiteCallAuditRepository(context);

        // Three terminal rows on three DISTINCT days beyond the cutoff — a multi-day backlog.
        var oldIds = new List<TrackedOperationId>();
        foreach (var days in new[] { 3, 4, 5 })
        {
            var id = TrackedOperationId.New();
            oldIds.Add(id);
            var terminalAt = now.AddDays(-days);
            await repo.UpsertAsync(NewRow(
                id, sourceSite: site, status: "Delivered", retryCount: 0,
                createdAtUtc: terminalAt.AddMinutes(-1), updatedAtUtc: terminalAt,
                terminal: true, terminalAtUtc: terminalAt));
        }

        // A fresh terminal row inside the keep window — must survive.
        var recentTerminalId = TrackedOperationId.New();
        await repo.UpsertAsync(NewRow(
            recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0,
            createdAtUtc: now.AddHours(-2), updatedAtUtc: now.AddHours(-1),
            terminal: true, terminalAtUtc: now.AddHours(-1)));

        // An OLD non-terminal row — non-terminal rows are NEVER purged on age.
        var oldActiveId = TrackedOperationId.New();
        await repo.UpsertAsync(NewRow(
            oldActiveId, sourceSite: site, status: "Attempted", retryCount: 1,
            createdAtUtc: now.AddDays(-10), updatedAtUtc: now.AddDays(-10),
            terminal: false));

        deleteCounter.Reset();
        var purged = await repo.PurgeTerminalAsync(cutoff);

        // The three seeded backlog rows are gone; the fresh terminal + old non-terminal survive.
        Assert.True(purged >= 3, $"Expected at least the three backlog rows purged; got {purged}.");
        foreach (var id in oldIds)
        {
            Assert.Null(await repo.GetAsync(id));
        }
        Assert.NotNull(await repo.GetAsync(recentTerminalId));
        Assert.NotNull(await repo.GetAsync(oldActiveId));

        // Slicing: a multi-day backlog is broken into MORE THAN ONE DELETE (one per day slice).
        Assert.True(deleteCounter.DeleteCount > 1,
            $"Expected the multi-day purge to slice into >1 DELETE; issued {deleteCounter.DeleteCount}.");
    }

    // --- KPI snapshot tests -------------------------------------------------

    [SkippableFact]
    public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var site = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var now = DateTime.UtcNow;
        var stuckCutoff = now.AddMinutes(-10);
        var intervalSince = now.AddHours(-1);

        // Buffered + stuck (non-terminal Attempted, created 30 min ago).
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
        // Buffered but NOT stuck (non-terminal Attempted, created 2 min ago).
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
        // Parked (terminal).
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Parked",
            createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
            terminal: true, terminalAtUtc: now.AddMinutes(-4)));
        // Delivered within the interval.
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Delivered",
            createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
            terminal: true, terminalAtUtc: now.AddMinutes(-1)));
        // Failed within the interval.
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Failed",
            createdAtUtc: now.AddMinutes(-6), updatedAtUtc: now.AddMinutes(-2),
            terminal: true, terminalAtUtc: now.AddMinutes(-2)));
        // Delivered OUTSIDE the interval (2 hours ago) — must not count.
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), site, status: "Delivered",
            createdAtUtc: now.AddHours(-3), updatedAtUtc: now.AddHours(-2),
            terminal: true, terminalAtUtc: now.AddHours(-2)));

        var snapshot = await repo.ComputeKpisAsync(stuckCutoff, intervalSince);

        // Counts are global; assert the floor since the table is shared with
        // other tests. The OUTSIDE-interval Delivered row proves the window
        // bounds the throughput counts.
        Assert.True(snapshot.BufferedCount >= 2);
        Assert.True(snapshot.ParkedCount >= 1);
        Assert.True(snapshot.StuckCount >= 1);
        Assert.True(snapshot.DeliveredLastInterval >= 1);
        Assert.True(snapshot.FailedLastInterval >= 1);
        Assert.NotNull(snapshot.OldestPendingAge);
        Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
    }

    [SkippableFact]
    public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteA = NewSiteId();
        var siteB = NewSiteId();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var now = DateTime.UtcNow;
        var stuckCutoff = now.AddMinutes(-10);
        var intervalSince = now.AddHours(-1);

        // siteA: 2 buffered (one stuck), 1 parked.
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), siteA, status: "Parked",
            createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
            terminal: true, terminalAtUtc: now.AddMinutes(-4)));
        // siteB: 1 delivered within interval only.
        await repo.UpsertAsync(NewRow(
            TrackedOperationId.New(), siteB, status: "Delivered",
            createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
            terminal: true, terminalAtUtc: now.AddMinutes(-1)));

        var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince);

        var a = Assert.Single(perSite, s => s.SourceSite == siteA);
        Assert.Equal(2, a.BufferedCount);
        Assert.Equal(1, a.ParkedCount);
        Assert.Equal(1, a.StuckCount);
        Assert.NotNull(a.OldestPendingAge);

        var b = Assert.Single(perSite, s => s.SourceSite == siteB);
        Assert.Equal(0, b.BufferedCount);
        Assert.Equal(1, b.DeliveredLastInterval);
        // siteB has no non-terminal rows — no oldest-pending age.
        Assert.Null(b.OldestPendingAge);
    }

    [SkippableFact]
    public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // Use unique site + node combos to isolate from other tests running
        // concurrently on the shared MsSql fixture.
        var nodeId = "node-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var nodeB  = nodeId + "-b";
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        var now = DateTime.UtcNow;
        var stuckCutoff = now.AddMinutes(-10);
        var intervalSince = now.AddHours(-1);

        // nodeId: 2 buffered (one stuck), 1 parked.
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
            createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId));
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
            createdAtUtc: now.AddMinutes(-2), sourceNode: nodeId));
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Parked",
            createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId));
        // nodeB: 1 delivered within interval only.
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Delivered",
            createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
            terminal: true, terminalAtUtc: now.AddMinutes(-1), sourceNode: nodeB));
        // Null SourceNode row — must NOT appear in per-node results.
        await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
            createdAtUtc: now.AddMinutes(-3), sourceNode: null));

        var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince);

        var na = Assert.Single(perNode, n => n.SourceNode == nodeId);
        Assert.Equal(2, na.BufferedCount);
        Assert.Equal(1, na.ParkedCount);
        Assert.Equal(1, na.StuckCount);
        Assert.NotNull(na.OldestPendingAge);

        var nb = Assert.Single(perNode, n => n.SourceNode == nodeB);
        Assert.Equal(0, nb.BufferedCount);
        Assert.Equal(1, nb.DeliveredLastInterval);
        Assert.Null(nb.OldestPendingAge);

        // Null-node row must be absent.
        Assert.DoesNotContain(perNode, n => n.SourceNode is null);
    }

    // --- helpers ------------------------------------------------------------

    private ScadaBridgeDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer(_fixture.ConnectionString)
            .Options;
        return new ScadaBridgeDbContext(options);
    }

    private static string NewSiteId() =>
        "site-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);

    private static SiteCall NewRow(
        TrackedOperationId id,
        string? sourceSite = null,
        string status = "Submitted",
        int retryCount = 0,
        string? lastError = null,
        int? httpStatus = null,
        DateTime? createdAtUtc = null,
        DateTime? updatedAtUtc = null,
        bool terminal = false,
        DateTime? terminalAtUtc = null,
        string? sourceNode = null)
    {
        var created = createdAtUtc ?? DateTime.UtcNow;
        var updated = updatedAtUtc ?? created;
        DateTime? terminalAt = terminal
            ? (terminalAtUtc ?? updated)
            : null;

        return new SiteCall
        {
            TrackedOperationId = id,
            Channel = "ApiOutbound",
            Target = "ERP.GetOrder",
            SourceSite = sourceSite ?? NewSiteId(),
            SourceNode = sourceNode,
            Status = status,
            RetryCount = retryCount,
            LastError = lastError,
            HttpStatus = httpStatus,
            CreatedAtUtc = created,
            UpdatedAtUtc = updated,
            TerminalAtUtc = terminalAt,
            IngestedAtUtc = DateTime.UtcNow,
        };
    }

    // --- SourceNode-stamping (Task 14) --------------------------------------

    [SkippableFact]
    public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // SourceNode-stamping (Task 14): a fresh INSERT must persist the
        // SourceNode column verbatim — the central row carries the originating
        // site node name end-to-end.
        var id = TrackedOperationId.New();
        await using var context = CreateContext();
        var repo = new SiteCallAuditRepository(context);

        await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));

        var loaded = await repo.GetAsync(id);
        Assert.NotNull(loaded);
        Assert.Equal("node-a", loaded!.SourceNode);
    }

    [SkippableFact]
    public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // SourceNode-stamping (Task 14): the UPDATE uses
        // COALESCE(@SourceNode, SourceNode) so a subsequent packet that does
        // NOT carry a SourceNode (legacy / reconciliation pull from an
        // unstamped node) MUST NOT blank out the value the first packet set.
        // Combined with the monotonic-rank guard the Status advances but the
        // SourceNode survives.
        //
        // Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the
        // change tracker, so reusing a single context whose entity is already
        // tracked masks the post-UPDATE state on a follow-up FindAsync.
        var id = TrackedOperationId.New();
        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            // First packet: stamped Submit from node-a.
            await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
        }
        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            // Later packet: rank-advancing Attempted with null SourceNode.
            await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null));
        }

        await using (var readContext = CreateContext())
        {
            var readRepo = new SiteCallAuditRepository(readContext);
            var loaded = await readRepo.GetAsync(id);
            Assert.NotNull(loaded);
            // SourceNode preserved despite the null on the later packet.
            Assert.Equal("node-a", loaded!.SourceNode);
            // Status advanced — proves the UPDATE branch actually ran.
            Assert.Equal("Attempted", loaded.Status);
            Assert.Equal(1, loaded.RetryCount);
        }
    }

    [SkippableFact]
    public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
        // SourceNode) semantics the column protects against a *null*
        // incoming value blanking a previously-stamped one, but a non-null
        // incoming value DOES replace the existing value on a rank-advancing
        // packet. This is the "last-non-null-wins on advance" behaviour the
        // SQL operator literally implements — see the comment in
        // SiteCallAuditRepository.UpsertAsync.
        //
        // In practice both stamps within a single lifecycle SHOULD carry the
        // same value (same node, same execution); a divergence would imply a
        // mid-lifecycle node change (e.g. failover handing off to node-b) and
        // letting the latest stamp through is arguably the right call. This
        // test pins the actual behaviour so we notice if the SQL gets
        // inverted (to a true first-write-wins COALESCE(SourceNode,
        // @SourceNode)) inadvertently.
        var id = TrackedOperationId.New();
        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
        }
        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b"));
        }

        await using (var readContext = CreateContext())
        {
            var readRepo = new SiteCallAuditRepository(readContext);
            var loaded = await readRepo.GetAsync(id);
            Assert.NotNull(loaded);
            // Incoming non-null wins — node-b replaces node-a on rank advance.
            Assert.Equal("node-b", loaded!.SourceNode);
            // Other monotonic fields advanced too — proves the UPDATE ran.
            Assert.Equal("Attempted", loaded.Status);
        }
    }

    [SkippableFact]
    public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // SourceNode-stamping (Task 14): when the column was left NULL by an
        // earlier unstamped packet, a later rank-advancing packet with a
        // non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode)
        // SQL operator returns @SourceNode when @SourceNode is non-null, so
        // the incoming value wins over the existing NULL. This is the
        // recovery path for an initially-unstamped lifecycle whose later
        // packets carry the node identity.
        //
        // The intermediate verification and final read use FRESH contexts —
        // FindAsync hits the change tracker first, so a cached entity from
        // an earlier read in the same context can mask a raw-SQL UPDATE.
        var id = TrackedOperationId.New();
        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null));
        }

        // Verify the INSERT left SourceNode NULL via a fresh context.
        await using (var verifyContext = CreateContext())
        {
            var verifyRepo = new SiteCallAuditRepository(verifyContext);
            var afterInsert = await verifyRepo.GetAsync(id);
            Assert.NotNull(afterInsert);
            Assert.Null(afterInsert!.SourceNode);
        }

        await using (var context = CreateContext())
        {
            var repo = new SiteCallAuditRepository(context);
            await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a"));
        }

        await using (var readContext = CreateContext())
        {
            var readRepo = new SiteCallAuditRepository(readContext);
            var loaded = await readRepo.GetAsync(id);
            Assert.NotNull(loaded);
            Assert.Equal("node-a", loaded!.SourceNode);
            Assert.Equal("Attempted", loaded.Status);
        }
    }

    /// <summary>
    /// EF command interceptor that counts the DELETE statements actually issued to the store,
    /// so a test can prove the terminal purge slices a multi-day backlog into several windowed
    /// DELETEs rather than one year-scale statement. <c>ExecuteSqlInterpolatedAsync</c> routes
    /// through the async non-query path.
    /// </summary>
    private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor
    {
        public int DeleteCount { get; private set; }

        public void Reset() => DeleteCount = 0;

        private void CountIfDelete(DbCommand command)
        {
            if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase))
            {
                DeleteCount++;
            }
        }

        public override InterceptionResult<int> NonQueryExecuting(
            DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
        {
            CountIfDelete(command);
            return base.NonQueryExecuting(command, eventData, result);
        }

        public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
            DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
            CancellationToken cancellationToken = default)
        {
            CountIfDelete(command);
            return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
        }
    }
}
