using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
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 D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
/// real partitioned schema. Tests scope all queries by a per-test
/// <c>SourceSiteId</c> guid suffix so they neither collide with one another nor
/// require cleanup.
/// </summary>
public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
    private readonly MsSqlMigrationFixture _fixture;

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

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
        await repo.InsertIfNotExistsAsync(evt);

        // Re-read in a fresh context so we exercise the persisted row, not the
        // (already-bypassed) change tracker.
        await using var readContext = CreateContext();
        var loaded = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal(evt.EventId, loaded[0].EventId);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var evt = NewEvent(
            siteId,
            occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
            sourceNode: "central-a");
        await repo.InsertIfNotExistsAsync(evt);

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal("central-a", loaded[0].SourceNode);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Caller passes null SourceNode (e.g. an unconfigured node) — the
        // column should persist as NULL, not as the empty string.
        var evt = NewEvent(
            siteId,
            occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
            sourceNode: null);
        await repo.InsertIfNotExistsAsync(evt);

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Null(loaded[0].SourceNode);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
        var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first");
        await repo.InsertIfNotExistsAsync(first);

        // Same EventId, different payload — first-write-wins, the second call is silently a no-op.
        // C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
        // a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
        var second = ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: first.EventId,
            occurredAtUtc: occurredAt,
            sourceSiteId: siteId,
            errorMessage: "second-should-be-ignored");
        await repo.InsertIfNotExistsAsync(second);

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(loaded);
        // C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert
        // first-write-wins kept the original payload.
        Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10)));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(3, rows.Count);
        Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc);
        Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                Channels: new[] { AuditChannel.Notification },
                SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc);
        // One row per channel; the multi-value filter must return the union of
        // ApiOutbound + Notification and exclude DbOutbound.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification },
                SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Contains(r.AsRow().Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
        Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc);
        // Failed + Parked are requested; Delivered must be excluded.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
                SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Contains(r.AsRow().Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
        Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
    }

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

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

        var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0));
        await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1)));
        await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2)));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Contains(r.AsRow().SourceSiteId, new[] { siteA, siteB }));
        Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));

        // An empty Channels list must mean "no filter" — NOT WHERE 1=0.
        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                Channels: Array.Empty<AuditChannel>(),
                SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
    }

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

        var siteId = NewSiteId();
        var otherSiteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1)));
        await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Equal(siteId, r.AsRow().SourceSiteId));
    }

    // ──────────────────────────────────────────────────────────────────────
    // Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
    // Node multi-select contract — non-empty list → SQL IN (…); NULL
    // SourceNode rows are excluded when the filter is set; the distinct
    // enumeration omits nulls and orders ascending.
    // ──────────────────────────────────────────────────────────────────────

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: null));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                SourceSiteIds: new[] { siteId },
                SourceNodes: new[] { "central-a" }),
            new AuditLogPaging(PageSize: 10));

        Assert.Single(rows);
        Assert.Equal("central-a", rows[0].SourceNode);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 4, 11, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "site-plant-a-node-a"));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                SourceSiteIds: new[] { siteId },
                SourceNodes: new[] { "central-a", "central-b" }),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-b"));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-a"));
        // Duplicate of "central-a" — must collapse to one entry.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "central-a"));
        // Null row — must be excluded entirely.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), sourceNode: null));

        var nodes = await repo.GetDistinctSourceNodesAsync();

        // The whole table is scanned (no per-test scoping on this query), so the
        // assertion is "our seeded values appear once each, in order" rather
        // than a strict equality on the full result.
        Assert.Contains("central-a", nodes);
        Assert.Contains("central-b", nodes);
        // No null entry — the WHERE SourceNode IS NOT NULL clause drops them.
        Assert.DoesNotContain(nodes, n => n is null);
        // Ordered ascending.
        Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var executionId = Guid.NewGuid();
        var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
        // Two rows share the ExecutionId; one carries a different ExecutionId and
        // one leaves it null — both must be excluded by the single-value filter.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                SourceSiteIds: new[] { siteId },
                ExecutionId: executionId),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var parentExecutionId = Guid.NewGuid();
        var t0 = new DateTime(2026, 5, 3, 13, 0, 0, DateTimeKind.Utc);
        // Two rows share the ParentExecutionId; one carries a different
        // ParentExecutionId and one leaves it null — both must be excluded by the
        // single-value filter.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, parentExecutionId: parentExecutionId));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), parentExecutionId: parentExecutionId));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), parentExecutionId: Guid.NewGuid()));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), parentExecutionId: null));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                SourceSiteIds: new[] { siteId },
                ParentExecutionId: parentExecutionId),
            new AuditLogPaging(PageSize: 10));

        Assert.Equal(2, rows.Count);
        Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30)));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2)));

        var rows = await repo.QueryAsync(
            new AuditLogQueryFilter(
                SourceSiteIds: new[] { siteId },
                FromUtc: t0.AddMinutes(10),
                ToUtc: t0.AddHours(1)),
            new AuditLogPaging(PageSize: 10));

        Assert.Single(rows);
        Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc);
        // Five rows at one-minute intervals. 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.
        for (var i = 0; i < 5; i++)
        {
            await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i)));
        }

        var page1 = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(PageSize: 2));

        Assert.Equal(2, page1.Count);
        Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc);
        Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc);

        var cursor = page1[^1];
        var page2 = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(
                PageSize: 2,
                AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
                AfterEventId: cursor.EventId));

        Assert.Equal(2, page2.Count);
        Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc);
        Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc);

        var cursor2 = page2[^1];
        var page3 = await repo.QueryAsync(
            new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
            new AuditLogPaging(
                PageSize: 2,
                AfterOccurredAtUtc: cursor2.AsRow().OccurredAtUtc,
                AfterEventId: cursor2.EventId));

        Assert.Single(page3);
        Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
    }

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

        var siteId = NewSiteId();

        // Single event used by every parallel call — same EventId, same payload.
        // The repository's IF NOT EXISTS … INSERT pattern has a check-then-act
        // race window between sessions; under concurrent load SQL Server can
        // raise a unique-index violation (error 2601) on UX_AuditLog_EventId.
        // Bundle A's hardening swallows 2601/2627 so duplicates collapse silently.
        var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));

        // 50 parallel inserters, each with its own DbContext (DbContext is not
        // thread-safe). Parallel.ForEachAsync aggregates exceptions, so a single
        // unhandled 2601 from the repository would fail this test loudly.
        await Parallel.ForEachAsync(
            Enumerable.Range(0, 50),
            new ParallelOptions { MaxDegreeOfParallelism = 50 },
            async (_, ct) =>
            {
                await using var context = CreateContext();
                var repo = new AuditLogRepository(context);
                await repo.InsertIfNotExistsAsync(evt, ct);
            });

        await using var readContext = CreateContext();
        var count = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .CountAsync();

        Assert.Equal(1, count);
    }

    // ------------------------------------------------------------------------
    // WP2.2: set-based InsertManyIfNotExistsAsync
    // ------------------------------------------------------------------------

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var baseTime = new DateTime(2026, 5, 21, 8, 0, 0, DateTimeKind.Utc);
        var events = Enumerable.Range(0, 12)
            .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
            .ToList();

        var inserted = await repo.InsertManyIfNotExistsAsync(events);

        Assert.Equal(12, inserted);

        await using var readContext = CreateContext();
        var rows = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Equal(12, rows.Count);
        Assert.Equal(
            events.Select(e => e.EventId).OrderBy(g => g).ToArray(),
            rows.Select(r => r.EventId).OrderBy(g => g).ToArray());
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // A set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are
        // already COMMITTED, so two copies of one EventId inside a single VALUES
        // constructor would both pass the anti-semi-join and collide on the
        // clustered PK — taking the whole statement down with them. The repository
        // de-duplicates the packet first (first-write-wins, matching the
        // single-row contract), which this pins.
        var occurred = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc);
        var first = NewEvent(siteId, occurredAtUtc: occurred);
        var duplicate = ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: first.EventId,
            occurredAtUtc: occurred,
            sourceSiteId: siteId,
            errorMessage: "duplicate-within-packet-should-be-ignored");
        var other = NewEvent(siteId, occurredAtUtc: occurred.AddSeconds(1));

        var inserted = await repo.InsertManyIfNotExistsAsync(
            new[] { first, duplicate, other, duplicate });

        Assert.Equal(2, inserted);

        await using var readContext = CreateContext();
        var rows = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Equal(2, rows.Count);

        // First-write-wins: the surviving row is the FIRST occurrence, so the
        // duplicate's ErrorMessage never lands.
        var stored = Assert.Single(rows, r => r.EventId == first.EventId);
        Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var baseTime = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc);
        var packet = Enumerable.Range(0, 5)
            .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
            .ToList();

        // A site retry / reconciliation pull re-delivers a packet that overlaps
        // an already-ingested one. The second call must insert only the genuinely
        // new rows and silently skip the rest (first-write-wins across packets).
        var firstInserted = await repo.InsertManyIfNotExistsAsync(packet);
        var extra = NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(99));
        var secondInserted = await repo.InsertManyIfNotExistsAsync(
            packet.Concat(new[] { extra }).ToList());

        // A third, wholly-redundant replay writes nothing at all.
        var thirdInserted = await repo.InsertManyIfNotExistsAsync(packet);

        Assert.Equal(5, firstInserted);
        Assert.Equal(1, secondInserted);
        Assert.Equal(0, thirdInserted);

        await using var readContext = CreateContext();
        var rows = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Equal(6, rows.Count);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // 250 rows × 10 bound parameters = 2,500 — past SQL Server's 2,100
        // parameter ceiling for a single statement, so this only succeeds if the
        // repository chunks. Nulls in the optional columns (Target, SourceNode,
        // CorrelationId, Actor) are left null on purpose: the VALUES constructor
        // derives its column types from the parameters, so an untyped null would
        // surface here as a conversion failure rather than silently.
        var baseTime = new DateTime(2026, 5, 21, 11, 0, 0, DateTimeKind.Utc);
        var events = Enumerable.Range(0, 250)
            .Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
            .ToList();

        var inserted = await repo.InsertManyIfNotExistsAsync(events);

        Assert.Equal(250, inserted);

        await using var readContext = CreateContext();
        var count = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .CountAsync();

        Assert.Equal(250, count);
    }

    /// <summary>
    /// An over-long value must be REJECTED by the server on every write path, not
    /// clipped to fit by the client. The set-based path used to declare each
    /// string parameter at its COLUMN width (Target/Actor 256, Action 64,
    /// Outcome 16, Category 32, SourceNode 64), which makes SqlClient truncate the
    /// value at bind time and commit the shortened row — in an APPEND-ONLY audit
    /// store, with no PayloadTruncated flag to admit it happened — while the
    /// per-row and reconciliation paths sent the same value in full and let the
    /// server raise 2628/8152. Reject-everywhere is the contract; this test pins
    /// both halves of it.
    /// </summary>
    [SkippableFact]
    public async Task InsertManyIfNotExistsAsync_TargetLongerThanColumn_IsRejected_NotTruncated()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteId = NewSiteId();

        // dbo.AuditLog.Target is nvarchar(256).
        var overlongTarget = new string('T', 300);
        var evt = NewEvent(
            siteId,
            occurredAtUtc: new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc),
            target: overlongTarget);

        await using var batchContext = CreateContext();
        var batchRepo = new AuditLogRepository(batchContext);

        // 2628 = "String or binary data would be truncated in table …" (SQL 2019+),
        // 8152 = its pre-2019 predecessor. Either proves the server saw the full value.
        var batchEx = await Assert.ThrowsAsync<SqlException>(
            () => batchRepo.InsertManyIfNotExistsAsync(new[] { evt }));
        Assert.Contains(batchEx.Number, new[] { 2628, 8152 });

        // The per-row path — which the batch path falls back to — rejects it too.
        await using var rowContext = CreateContext();
        var rowRepo = new AuditLogRepository(rowContext);
        var rowEx = await Assert.ThrowsAsync<SqlException>(
            () => rowRepo.InsertIfNotExistsAsync(evt));
        Assert.Contains(rowEx.Number, new[] { 2628, 8152 });

        // Nothing landed — in particular, no 256-character mutilated copy.
        await using var readContext = CreateContext();
        var rows = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Empty(rows);
    }

    /// <summary>
    /// The companion to the rejection test: a value that FITS must still round-trip
    /// through the set-based path byte for byte, at the exact column boundary.
    /// </summary>
    [SkippableFact]
    public async Task InsertManyIfNotExistsAsync_TargetAtColumnLimit_RoundTripsIntact()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteId = NewSiteId();
        var boundaryTarget = new string('T', 256);
        var evt = NewEvent(
            siteId,
            occurredAtUtc: new DateTime(2026, 5, 22, 12, 30, 0, DateTimeKind.Utc),
            target: boundaryTarget);

        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);
        Assert.Equal(1, await repo.InsertManyIfNotExistsAsync(new[] { evt }));

        await using var readContext = CreateContext();
        var loaded = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(loaded);
        Assert.Equal(boundaryTarget, loaded[0].Target);
    }

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

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

        Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>()));
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Four events all sharing the exact same OccurredAtUtc — the keyset
        // cursor must lean on the EventId tiebreaker (descending) to page
        // deterministically. Bundle D's reviewer flagged this as a deferred
        // verification because it depends on EF Core 10 translating
        // Guid.CompareTo against SQL Server's uniqueidentifier sort order.
        var occurredAt = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);

        // Build four distinct Guids; we don't care about the literal ordering
        // produced by Guid.CompareTo — only that paging is deterministic and
        // covers every row exactly once.
        var events = Enumerable.Range(0, 4)
            .Select(_ => NewEvent(siteId, occurredAtUtc: occurredAt))
            .ToList();

        foreach (var e in events)
        {
            await repo.InsertIfNotExistsAsync(e);
        }

        var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });

        var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
        Assert.Equal(2, page1.Count);
        Assert.All(page1, r => Assert.Equal(occurredAt, r.OccurredAtUtc));

        var cursor = page1[^1];
        var page2 = await repo.QueryAsync(
            filter,
            new AuditLogPaging(
                PageSize: 2,
                AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
                AfterEventId: cursor.EventId));

        Assert.Equal(2, page2.Count);
        Assert.All(page2, r => Assert.Equal(occurredAt, r.OccurredAtUtc));

        var page1Ids = page1.Select(r => r.EventId).ToHashSet();
        var page2Ids = page2.Select(r => r.EventId).ToHashSet();

        // No overlap between pages.
        Assert.Empty(page1Ids.Intersect(page2Ids));

        // Every inserted EventId appears in exactly one of the two pages.
        var allIds = page1Ids.Union(page2Ids).ToHashSet();
        Assert.Equal(4, allIds.Count);
        Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds));
    }

    // ------------------------------------------------------------------------
    // M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests
    // ------------------------------------------------------------------------
    //
    // The partition-switch path replaces M1's NotSupportedException stub with
    // the production CREATE-staging → SWITCH PARTITION → DROP-staging batch
    // documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to
    // bracket it: uniqueness rides the partition-ALIGNED clustered PK
    // (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests
    // verify the side effects an outsider can observe:
    //   * rows in the targeted month are removed
    //   * rows in OTHER months are NOT touched
    //   * no non-aligned UX_AuditLog_EventId is (re)created by a switch
    //   * InsertIfNotExistsAsync's first-write-wins idempotency still holds
    //     after a switch (the aligned key really does enforce it)
    //   * a thrown SqlException leaves the table intact with no orphaned staging

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Three distinct months — Jan, Feb, Mar 2026 — so the switch on Jan's
        // boundary purges exactly one month's worth of rows. Boundary values
        // come from the partition function's pre-seeded list (alog.md §4).
        var janEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc));
        var febEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 2, 15, 10, 0, 0, DateTimeKind.Utc));
        var marEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc));
        await repo.InsertIfNotExistsAsync(janEvt);
        await repo.InsertIfNotExistsAsync(febEvt);
        await repo.InsertIfNotExistsAsync(marEvt);

        // Boundary value '2026-01-01' identifies the January 2026 partition under
        // RANGE RIGHT semantics ($PARTITION returns the partition into which the
        // boundary value itself falls — the partition whose lower bound is the
        // boundary).
        await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));

        await using var readContext = CreateContext();
        var remaining = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.DoesNotContain(remaining, e => e.EventId == janEvt.EventId);
        Assert.Contains(remaining, e => e.EventId == febEvt.EventId);
        Assert.Contains(remaining, e => e.EventId == marEvt.EventId);
    }

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

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

        // Pick a different month per test so successive test runs (which share
        // the fixture's MSSQL database) don't tread on each other.
        await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));

        // WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it
        // would reinstate the offline drop/rebuild the purge was freed from and
        // block the next SWITCH until something dropped it again.
        await using var verifyContext = CreateContext();
        var indexExists = await ScalarAsync<int>(
            verifyContext,
            "SELECT COUNT(*) FROM sys.indexes " +
            "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
        Assert.Equal(0, indexExists);

        // The aligned clustered PK is what enforces uniqueness now, and the
        // switch must leave it untouched.
        var pkExists = await ScalarAsync<int>(
            verifyContext,
            "SELECT COUNT(*) FROM sys.indexes " +
            "WHERE name = 'PK_AuditLog' AND object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1;");
        Assert.Equal(1, pkExists);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Pre-existing row in May 2026 — must survive a switch on a different
        // (older) partition.
        var preExisting = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc));
        await repo.InsertIfNotExistsAsync(preExisting);

        // Switch out the June 2026 partition (different month, empty).
        await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));

        // Re-attempting the same EventId after the switch must STILL be a no-op.
        // The idempotency probe now seeks the aligned clustered PK's leading
        // column instead of a dedicated unique index; if the switch had disturbed
        // that key, this insert would silently produce a duplicate row and the
        // count assertion below would catch it.
        // C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
        // (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
        var dup = ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: preExisting.EventId,
            occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
            sourceSiteId: siteId,
            errorMessage: "second-should-be-ignored-after-switch");
        await repo.InsertIfNotExistsAsync(dup);

        await using var readContext = CreateContext();
        var rows = await readContext.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

        Assert.Single(rows);
        Assert.Equal(preExisting.EventId, rows[0].EventId);
        // First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5):
        // ErrorMessage rides in DetailsJson — decode it to assert.
        Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage);
    }

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

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

        // Force a deterministic switch failure with an inbound FOREIGN KEY:
        // ALTER TABLE … SWITCH refuses to move rows out of a partition that's
        // referenced by an FK from another table, raising msg 4928
        // ("ALTER TABLE SWITCH statement failed because target table … has a
        // foreign key …"). The CATCH branch then rolls back and drops the staging
        // table — which the assertion below verifies.
        //
        // The probe table is uniquely named with a guid suffix so reruns of
        // this test inside the same fixture DB never collide. We clean it up
        // in the finally so the constraint never leaks into other tests.
        var probeTable = $"AuditFkProbe_{Guid.NewGuid():N}".Substring(0, 32);
        await using (var setup = new SqlConnection(_fixture.ConnectionString))
        {
            await setup.OpenAsync();
            await using var cmd = setup.CreateCommand();
            // Composite FK references AuditLog's composite PK (EventId, OccurredAtUtc).
            cmd.CommandText =
                $"CREATE TABLE dbo.[{probeTable}] ( " +
                $"  EventId uniqueidentifier NOT NULL, " +
                $"  OccurredAtUtc datetime2(7) NOT NULL, " +
                $"  CONSTRAINT FK_{probeTable}_AuditLog FOREIGN KEY (EventId, OccurredAtUtc) " +
                $"    REFERENCES dbo.AuditLog(EventId, OccurredAtUtc));";
            await cmd.ExecuteNonQueryAsync();
        }

        try
        {
            var ex = await Assert.ThrowsAnyAsync<SqlException>(
                () => repo.SwitchOutPartitionAsync(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc)));
            // Smoke-check the message references the SWITCH statement so we
            // know we hit the engineered failure, not some unrelated error.
            Assert.Contains("SWITCH", ex.Message, StringComparison.OrdinalIgnoreCase);
        }
        finally
        {
            // Always drop the probe table so the FK is gone before the next
            // test runs against the shared fixture.
            await using var cleanup = new SqlConnection(_fixture.ConnectionString);
            await cleanup.OpenAsync();
            await using var cmd = cleanup.CreateCommand();
            cmd.CommandText =
                $"IF OBJECT_ID('dbo.[{probeTable}]', 'U') IS NOT NULL DROP TABLE dbo.[{probeTable}];";
            await cmd.ExecuteNonQueryAsync();
        }

        // The CATCH block drops the GUID-suffixed staging table regardless of
        // which step failed inside the TRY, so a failed purge leaves no orphaned
        // AuditLog_Staging_* object behind for the next tick to trip over.
        await using var verifyContext = CreateContext();
        var orphanedStaging = await ScalarAsync<int>(
            verifyContext,
            "SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';");
        Assert.Equal(0, orphanedStaging);

        // And it must NOT have (re)created the non-aligned unique index: WP2.2
        // deleted that rebuild entirely, so a failed switch cannot reintroduce
        // the object that blocks the NEXT switch.
        var indexExists = await ScalarAsync<int>(
            verifyContext,
            "SELECT COUNT(*) FROM sys.indexes " +
            "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
        Assert.Equal(0, indexExists);
    }

    // ------------------------------------------------------------------------
    // M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync
    // ------------------------------------------------------------------------

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Seed events in two months: July 2026 (old) and August 2026 (new).
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 7, 10, 0, 0, 0, DateTimeKind.Utc)));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc)));

        // Threshold = Aug 1 2026 — July partition's MAX (July 10) is older;
        // August partition's MAX (August 10) is newer. We expect only the July
        // boundary back.
        var threshold = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
        var boundaries = await repo.GetPartitionBoundariesOlderThanAsync(threshold);

        // The repo may also return EARLIER boundaries that have no data (their
        // MAX is NULL → treated as "no data, nothing to purge" by the contract).
        // We only assert the inclusion/exclusion that matters for our seeded
        // rows.
        Assert.Contains(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
        Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
    }

    // ------------------------------------------------------------------------
    // M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
    // ------------------------------------------------------------------------
    //
    // The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
    // "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour.
    // The repository must (a) count rows whose OccurredAtUtc falls in
    // [nowUtc - window, nowUtc] and (b) within that scope count rows whose
    // Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at
    // zero here — the service layer composes it in from the health aggregator.
    //
    // To keep the test deterministic against the shared fixture DB, each test
    // pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a
    // narrow band centred on that anchor — no other test in this class seeds
    // there, so the global count equals the seeded count for that band.

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Anchor in November 2026 — no other test in this class seeds there.
        var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc);
        // Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing
        // 1h window; plus 1 row outside the window that must be excluded.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded));
        // Outside-window row (2h before nowUtc).
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed));
        // Submitted is in-flight, not an "error" — must NOT count toward errors.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted));

        var snapshot = await repo.GetKpiSnapshotAsync(
            window: TimeSpan.FromHours(1),
            nowUtc: nowUtc);

        // 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted).
        // The 2h-before-nowUtc Failed row is excluded by the window.
        Assert.Equal(7, snapshot.TotalEventsLastHour);
        // Only Failed/Parked/Discarded count as errors → 3.
        Assert.Equal(3, snapshot.ErrorEventsLastHour);
        // The service layer fills BacklogTotal; the repo leaves it at 0.
        Assert.Equal(0, snapshot.BacklogTotal);
        // AsOfUtc echoes the anchor.
        Assert.Equal(nowUtc, snapshot.AsOfUtc);
    }

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

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

        // Anchor in December 2026 — no test seeds there, so the window is empty.
        var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc);

        var snapshot = await repo.GetKpiSnapshotAsync(
            window: TimeSpan.FromMinutes(1),
            nowUtc: nowUtc);

        Assert.Equal(0, snapshot.TotalEventsLastHour);
        Assert.Equal(0, snapshot.ErrorEventsLastHour);
        Assert.Equal(0, snapshot.BacklogTotal);
        Assert.Equal(nowUtc, snapshot.AsOfUtc);
    }

    // ------------------------------------------------------------------------
    // Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
    // ------------------------------------------------------------------------
    //
    // GetExecutionTreeAsync walks UP from any node to the chain root, then walks
    // DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct
    // execution in the chain. These tests verify the observable behaviour:
    //   * a multi-level chain returns the full set regardless of entry node
    //   * a parent referenced only via ParentExecutionId (no rows of its own)
    //     still surfaces, as a RowCount = 0 stub node
    //   * pathological cyclic data is bounded by the MAXRECURSION guard and
    //     surfaces a SqlException rather than hanging

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
        // RowCount aggregation is exercised; the child rows carry the parent's
        // ExecutionId as ParentExecutionId. Each execution is given a DISTINCT
        // channel, and its two rows carry DISTINCT statuses and timestamps, so
        // the per-node Channels/Statuses sets and the FirstOccurred/LastOccurred
        // span are meaningfully asserted (not all-defaults).
        var rootExec = Guid.NewGuid();
        var midExec = Guid.NewGuid();
        var leafExec = Guid.NewGuid();

        var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
        var rootT0 = t0;
        var rootT1 = t0.AddMinutes(1);
        var midT0 = t0.AddMinutes(2);
        var midT1 = t0.AddMinutes(3);
        var leafT0 = t0.AddMinutes(4);
        var leafT1 = t0.AddMinutes(5);

        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT0, channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, executionId: rootExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT1, channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, executionId: rootExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT0, channel: AuditChannel.DbOutbound, status: AuditStatus.Submitted, executionId: midExec, parentExecutionId: rootExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT1, channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, executionId: midExec, parentExecutionId: rootExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT0, channel: AuditChannel.Notification, status: AuditStatus.Submitted, executionId: leafExec, parentExecutionId: midExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT1, channel: AuditChannel.Notification, status: AuditStatus.Parked, executionId: leafExec, parentExecutionId: midExec));

        var expected = new[] { rootExec, midExec, leafExec };

        // Entry point must not matter: leaf, middle node, and root all yield the
        // same full chain.
        foreach (var entry in new[] { leafExec, midExec, rootExec })
        {
            var tree = await repo.GetExecutionTreeAsync(entry);

            Assert.Equal(3, tree.Count);
            Assert.True(
                expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)),
                $"entry {entry} did not return the full chain");

            var root = tree.Single(n => n.ExecutionId == rootExec);
            var mid = tree.Single(n => n.ExecutionId == midExec);
            var leaf = tree.Single(n => n.ExecutionId == leafExec);

            Assert.Null(root.ParentExecutionId);
            Assert.Equal(rootExec, mid.ParentExecutionId);
            Assert.Equal(midExec, leaf.ParentExecutionId);

            Assert.Equal(2, root.RowCount);
            Assert.Equal(2, mid.RowCount);
            Assert.Equal(2, leaf.RowCount);

            // Each populated node aggregates its own rows' channels and
            // statuses — distinct per execution, so a regression that mixes
            // executions or drops the per-id aggregate would be caught.
            Assert.Equal(
                new[] { nameof(AuditChannel.ApiOutbound) },
                root.Channels);
            Assert.Equal(
                new[] { nameof(AuditChannel.DbOutbound) },
                mid.Channels);
            Assert.Equal(
                new[] { nameof(AuditChannel.Notification) },
                leaf.Channels);

            Assert.True(
                new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Delivered) }
                    .ToHashSet().SetEquals(root.Statuses));
            Assert.True(
                new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Failed) }
                    .ToHashSet().SetEquals(mid.Statuses));
            Assert.True(
                new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Parked) }
                    .ToHashSet().SetEquals(leaf.Statuses));

            // Each populated node's timestamp span covers exactly its two rows.
            Assert.Equal(rootT0, root.FirstOccurredAtUtc);
            Assert.Equal(rootT1, root.LastOccurredAtUtc);
            Assert.Equal(midT0, mid.FirstOccurredAtUtc);
            Assert.Equal(midT1, mid.LastOccurredAtUtc);
            Assert.Equal(leafT0, leaf.FirstOccurredAtUtc);
            Assert.Equal(leafT1, leaf.LastOccurredAtUtc);
        }
    }

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

        // arch-review 04 (P2): the down-walk Edges CTE is anchored to a
        // [rootFirst - 1h, rootFirst + 7d) window around the root's first event, enabling
        // partition elimination instead of a full-table DISTINCT scan. A genuine descendant
        // stamped BEYOND that 7-day span is excluded by design; a normal in-window child is
        // still returned.
        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        var rootExec = Guid.NewGuid();
        var childExec = Guid.NewGuid();
        var farDescendantExec = Guid.NewGuid();
        var unrelatedExec = Guid.NewGuid();

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

        // Root + an in-window child (T0 + 5 min) — both must be returned.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: childExec, parentExecutionId: rootExec));

        // A genuine descendant of the child, but stamped 30 days out — beyond the 7-day
        // traversal bound, so it is documented-excluded from the tree.
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: farDescendantExec, parentExecutionId: childExec));

        // An unrelated execution far in time sharing no ancestry — never part of the chain
        // (asserts the window bound does not accidentally pull in strangers).
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: unrelatedExec));

        var tree = await repo.GetExecutionTreeAsync(rootExec);
        var ids = tree.Select(n => n.ExecutionId).ToHashSet();

        Assert.Contains(rootExec, ids);
        Assert.Contains(childExec, ids);
        Assert.DoesNotContain(farDescendantExec, ids);
        Assert.DoesNotContain(unrelatedExec, ids);
        Assert.Equal(2, tree.Count);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // The parent execution emitted no rows of its own (it performed no
        // trust-boundary action, or its rows were purged). Only the child has
        // rows, and they reference the parent via ParentExecutionId. The parent
        // must still surface as a node — a RowCount = 0 stub.
        var stubParentExec = Guid.NewGuid();
        var childExec = Guid.NewGuid();

        var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec));

        // Entering by the child must surface BOTH the child and the stub parent.
        var tree = await repo.GetExecutionTreeAsync(childExec);

        Assert.Equal(2, tree.Count);

        var stub = tree.Single(n => n.ExecutionId == stubParentExec);
        var child = tree.Single(n => n.ExecutionId == childExec);

        // The stub node: no rows, empty aggregate sets, null parent and timestamps.
        Assert.Equal(0, stub.RowCount);
        Assert.Empty(stub.Channels);
        Assert.Empty(stub.Statuses);
        Assert.Null(stub.ParentExecutionId);
        Assert.Null(stub.FirstOccurredAtUtc);
        Assert.Null(stub.LastOccurredAtUtc);

        // The child node carries its rows and points at the stub parent.
        Assert.Equal(2, child.RowCount);
        Assert.Equal(stubParentExec, child.ParentExecutionId);
    }

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

        var siteId = NewSiteId();
        await using var context = CreateContext();
        var repo = new AuditLogRepository(context);

        // Pathological cyclic data: A's rows point at B as parent, B's rows
        // point back at A. The ParentExecutionId graph is acyclic by
        // construction in production, but corrupt data must not hang the
        // server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises
        // a SqlException when the cycle exceeds the guard; the method surfaces
        // it rather than spinning forever.
        var execA = Guid.NewGuid();
        var execB = Guid.NewGuid();

        var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc);
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB));
        await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA));

        // The call must complete (throw) quickly, not hang. A generous 30s
        // ceiling distinguishes "bounded failure" from "infinite loop".
        var call = repo.GetExecutionTreeAsync(execA);
        var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30)));
        Assert.Same(call, completed);

        // MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang.
        await Assert.ThrowsAsync<SqlException>(() => call);
    }

    private async Task<T> ScalarAsync<T>(ScadaBridgeDbContext context, string sql)
    {
        var conn = context.Database.GetDbConnection();
        if (conn.State != System.Data.ConnectionState.Open)
        {
            await conn.OpenAsync();
        }
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        var result = await cmd.ExecuteScalarAsync();
        if (result is null || result is DBNull)
        {
            return default!;
        }
        return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
    }

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

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

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

    // C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
    // factory; the repository's transitional shim decomposes it into the 24-column
    // AuditLogRow on INSERT and recomposes the canonical record on QUERY.
    private static AuditEvent NewEvent(
        string siteId,
        DateTime occurredAtUtc,
        AuditChannel channel = AuditChannel.ApiOutbound,
        AuditKind kind = AuditKind.ApiCall,
        AuditStatus status = AuditStatus.Delivered,
        string? errorMessage = null,
        Guid? executionId = null,
        Guid? parentExecutionId = null,
        string? sourceNode = null,
        string? target = null) =>
        ScadaBridgeAuditEventFactory.Create(
            channel: channel,
            kind: kind,
            status: status,
            occurredAtUtc: occurredAtUtc,
            target: target,
            sourceNode: sourceNode,
            sourceSiteId: siteId,
            executionId: executionId,
            parentExecutionId: parentExecutionId,
            errorMessage: errorMessage);
}
