using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;

/// <summary>
/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
/// INSERTs that bypass the standard repository ingest path (so the seed
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
/// the real <see cref="AuditLogRepository"/> + per-test
/// <see cref="MsSqlMigrationFixture"/> database, and asserts:
/// <list type="number">
///   <item>The oldest partition (Jan) is removed.</item>
///   <item>Newer partitions (Feb + Mar) are untouched.</item>
///   <item>The switch leaves the aligned clustered <c>PK_AuditLog</c> intact and
///         does NOT (re)create the non-aligned <c>UX_AuditLog_EventId</c>.</item>
///   <item><see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> remains
///         idempotent against the aligned key after the purge.</item>
/// </list>
/// </summary>
/// <remarks>
/// The brief calls out that direct INSERTs bypass the writer role's INSERT-only
/// grant; the fixture connects as <c>sa</c> (see
/// <see cref="MsSqlMigrationFixture"/>'s default admin connection string), so
/// the seed step does not need the writer role at all. The switch batch itself
/// runs under the same admin connection because the test owns the database —
/// the role granularity is exercised in the repository tests, not here.
/// </remarks>
public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
    private readonly MsSqlMigrationFixture _fixture;

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

    private ScadaBridgeDbContext CreateContext() =>
        new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer(_fixture.ConnectionString).Options);

    /// <summary>
    /// Direct INSERT into <c>dbo.AuditLog</c> bypassing
    /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>. Used by the
    /// seed step so the test can place rows in arbitrary partitions without
    /// the repository's idempotency wrapper or ingest-stamping behaviour
    /// affecting the seed payload.
    /// </summary>
    private async Task DirectInsertAsync(
        SqlConnection conn,
        Guid eventId,
        DateTime occurredAtUtc,
        string siteId,
        string channel = "ApiOutbound",
        string kind = "ApiCall")
    {
        await using var cmd = conn.CreateCommand();
        // C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson;
        // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
        // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
        // Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
        // The channel/kind are parameterized so the M5.5 per-channel purge test can
        // seed multiple channels into the same partition.
        cmd.CommandText = @"
INSERT INTO dbo.AuditLog
    (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
VALUES
    (@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL,
     @DetailsJson);";
        cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}";
        cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel;
        cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
        // SqlDbType.DateTime2 with explicit Scale 7 matches the
        // OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
        // narrowing that SqlClient's default DateTime → datetime applies via
        // AddWithValue. Critical for partition assignment: the partition
        // function key column is datetime2(7); a narrowed value would still
        // land in the correct partition for first-of-month seeds, but
        // explicit typing here documents the intent and matches how the
        // production repository INSERT shapes its parameters.
        var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
        occurredParam.Scale = 7;
        occurredParam.Value = occurredAtUtc;
        // DetailsJson carries the camelCase domain fields (matching AuditDetailsCodec):
        // channel/kind/status drive the computed Kind/Status columns; sourceSiteId drives
        // the computed SourceSiteId column the verify queries scope on. payloadTruncated
        // is always present (the codec always writes the bool).
        var detailsJson =
            "{\"channel\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"status\":\"Delivered\"," +
            "\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}";
        cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
        await cmd.ExecuteNonQueryAsync();
    }

    /// <summary>
    /// Asserts the post-purge index state: the partition-ALIGNED clustered
    /// <c>PK_AuditLog</c> is intact and the non-aligned <c>UX_AuditLog_EventId</c>
    /// is absent (WP2.2 — <c>AlignAuditLogEventIdUniqueness</c>).
    /// </summary>
    /// <remarks>
    /// The purge used to bracket its SWITCH with a DROP/CREATE of
    /// <c>UX_AuditLog_EventId</c>, because a non-aligned unique index blocks
    /// <c>ALTER TABLE … SWITCH PARTITION</c> — an offline whole-table index build
    /// inside the switch transaction, plus a window with no idempotency index at
    /// all. Uniqueness now rides the aligned clustered PK, so the switch is
    /// metadata-only. Asserting the index's ABSENCE is what keeps that property:
    /// anything that recreates it silently reinstates the rebuild.
    /// </remarks>
    private static async Task AssertAlignedUniquenessAsync(SqlConnection conn)
    {
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
SELECT
    (SELECT COUNT(*) FROM sys.indexes
     WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) AS NonAligned,
    (SELECT COUNT(*) FROM sys.indexes
     WHERE object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1)          AS ClusteredPk;";
        await using var reader = await cmd.ExecuteReaderAsync();
        Assert.True(await reader.ReadAsync());
        var nonAligned = reader.GetInt32(0);
        var clusteredPk = reader.GetInt32(1);

        Assert.True(
            nonAligned == 0,
            $"UX_AuditLog_EventId must NOT exist post-purge (it blocks SWITCH PARTITION); sys.indexes count was {nonAligned}.");
        Assert.True(
            clusteredPk == 1,
            $"The aligned clustered PK_AuditLog must survive the purge; sys.indexes primary-key count was {clusteredPk}.");
    }

    private IActorRef CreateActor(
        IServiceProvider sp,
        AuditLogPurgeOptions purgeOptions,
        AuditLogOptions auditOptions)
    {
        return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
            sp,
            Options.Create(purgeOptions),
            Options.Create(auditOptions),
            NullLogger<AuditLogPurgeActor>.Instance)));
    }

    /// <summary>
    /// Returns three seed timestamps and a computed <c>RetentionDays</c> value that
    /// keep the purge-intent date-independent regardless of when the test runs.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The partition function <c>pf_AuditLog_Month</c> has explicit boundaries only
    /// for 2026-01-01 through 2027-12-01. Rows outside that range land in the
    /// catch-all partitions which have no <c>partition_range_values</c> entry and are
    /// therefore never returned by
    /// <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/>.
    /// All three seeds must therefore fall inside the defined boundary range.
    /// </para>
    /// <para>
    /// To remain date-independent the test computes <c>RetentionDays</c> dynamically
    /// so the purge threshold always lands near <b>2026-01-20</b>:
    /// <code>
    ///   RetentionDays = (int)(DateTime.UtcNow - new DateTime(2026, 1, 20, UTC)).TotalDays + 1
    /// </code>
    /// This gives:
    /// <list type="bullet">
    ///   <item>Jan 15 2026 row → Jan 15 &lt; Jan 20 threshold → <b>PURGED</b>.</item>
    ///   <item>Apr 15 / Jun 15 2026 rows → both after Jan 20 → <b>KEPT</b>.</item>
    /// </list>
    /// The threshold anchors to a fixed calendar point (~Jan 20 2026), so the
    /// relationship holds for any future run date as long as the explicit partition
    /// boundaries remain.
    /// </para>
    /// </remarks>
    private static (DateTime Old, DateTime Mid, DateTime Recent, int RetentionDays) SeedOccurredAt()
    {
        // Anchor the threshold midway through January 2026 — strictly after the
        // "old" seed (Jan 15) and strictly before the "mid" seed (Apr 15).
        var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
        var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;

        return (
            Old:          new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),   // in Jan-2026 partition → PURGED
            Mid:          new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),   // in Apr-2026 partition → KEPT
            Recent:       new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc),   // in Jun-2026 partition → KEPT
            RetentionDays: retentionDays
        );
    }

    // ---------------------------------------------------------------------
    // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
    // ---------------------------------------------------------------------

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

        // Seeds three rows in distinct calendar months. RetentionDays is computed
        // dynamically so the purge threshold always lands near 2026-01-20 (see
        // SeedOccurredAt() for the full rationale):
        //   Old    = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → PURGED
        //   Mid    = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → KEPT
        //   Recent = Jun 15 2026 → Jun 15 > threshold ~Jan 20 → KEPT
        var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var oldEventId = Guid.NewGuid();
        var midEventId = Guid.NewGuid();
        var recentEventId = Guid.NewGuid();
        var (oldOccurred, midOccurred, recentOccurred, retentionDays) = SeedOccurredAt();

        await using (var seedConn = _fixture.OpenConnection())
        {
            await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
            await DirectInsertAsync(seedConn, midEventId, midOccurred, siteId);
            await DirectInsertAsync(seedConn, recentEventId, recentOccurred, siteId);
        }

        // Wire the actor with a real EF context against the fixture DB.
        var services = new ServiceCollection();
        services.AddDbContext<ScadaBridgeDbContext>(
            opts => opts.UseSqlServer(_fixture.ConnectionString),
            ServiceLifetime.Scoped);
        services.AddScoped<IAuditLogRepository, AuditLogRepository>();
        var sp = services.BuildServiceProvider();

        var probe = CreateTestProbe();
        Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));

        var purgeOptions = new AuditLogPurgeOptions
        {
            IntervalHours = 24,
            IntervalOverride = TimeSpan.FromMilliseconds(100),
        };
        var auditOptions = new AuditLogOptions { RetentionDays = retentionDays };

        CreateActor(sp, purgeOptions, auditOptions);

        // The Jan-2026 partition boundary is the only eligible one in this fixture DB.
        var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var matched = probe.FishForMessage<AuditLogPurgedEvent>(
            isMessage: m => m.MonthBoundary == janBoundary,
            max: TimeSpan.FromSeconds(30));
        Assert.True(matched.RowsDeleted >= 1,
            $"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}.");

        // Allow a brief settle in case the actor re-enumerates quickly.
        await Task.Delay(TimeSpan.FromMilliseconds(500));

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

        // Old (Jan) removed; Mid (Apr) + Recent (Jun) untouched.
        Assert.DoesNotContain(rows, r => r.EventId == oldEventId);
        Assert.Contains(rows, r => r.EventId == midEventId);
        Assert.Contains(rows, r => r.EventId == recentEventId);
    }

    // ---------------------------------------------------------------------
    // 2. EndToEnd_AlignedUniqueness_AfterPurge
    // ---------------------------------------------------------------------

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

        // Same shape as test 1 — purge the Jan-2026 partition and then assert the
        // index state. RetentionDays is computed dynamically so the threshold always
        // lands near 2026-01-20 (see SeedOccurredAt()). Since WP2.2 the switch touches
        // no index at all: uniqueness rides the aligned clustered PK, so there is
        // nothing to drop before the SWITCH and nothing to rebuild after it.
        var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var oldEventId = Guid.NewGuid();
        var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();

        await using (var seedConn = _fixture.OpenConnection())
        {
            await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
        }

        var services = new ServiceCollection();
        services.AddDbContext<ScadaBridgeDbContext>(
            opts => opts.UseSqlServer(_fixture.ConnectionString),
            ServiceLifetime.Scoped);
        services.AddScoped<IAuditLogRepository, AuditLogRepository>();
        var sp = services.BuildServiceProvider();

        var probe = CreateTestProbe();
        Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));

        CreateActor(
            sp,
            new AuditLogPurgeOptions
            {
                IntervalHours = 24,
                IntervalOverride = TimeSpan.FromMilliseconds(100),
            },
            new AuditLogOptions { RetentionDays = retentionDays });

        var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        probe.FishForMessage<AuditLogPurgedEvent>(
            isMessage: m => m.MonthBoundary == janBoundary,
            max: TimeSpan.FromSeconds(30));

        // Open a fresh connection (the actor's pool is owned by EF) and
        // assert the index is present post-purge.
        await using var check = _fixture.OpenConnection();
        await AssertAlignedUniquenessAsync(check);
    }

    // ---------------------------------------------------------------------
    // 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
    // ---------------------------------------------------------------------

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

        // Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for
        // a fresh recent EventId. The second call must be a no-op (duplicate-key collision
        // swallowed by the repository, per M2 Bundle A's race-fix) — which means the
        // aligned clustered PK is still enforcing uniqueness as intended.
        // RetentionDays is computed dynamically so the threshold always lands near
        // 2026-01-20 (see SeedOccurredAt()).
        var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var oldEventId = Guid.NewGuid();
        var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();

        await using (var seedConn = _fixture.OpenConnection())
        {
            await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
        }

        var services = new ServiceCollection();
        services.AddDbContext<ScadaBridgeDbContext>(
            opts => opts.UseSqlServer(_fixture.ConnectionString),
            ServiceLifetime.Scoped);
        services.AddScoped<IAuditLogRepository, AuditLogRepository>();
        var sp = services.BuildServiceProvider();

        var probe = CreateTestProbe();
        Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));

        CreateActor(
            sp,
            new AuditLogPurgeOptions
            {
                IntervalHours = 24,
                IntervalOverride = TimeSpan.FromMilliseconds(100),
            },
            new AuditLogOptions { RetentionDays = retentionDays });

        var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        probe.FishForMessage<AuditLogPurgedEvent>(
            isMessage: m => m.MonthBoundary == janBoundary,
            max: TimeSpan.FromSeconds(30));

        // Settle then exercise InsertIfNotExistsAsync twice for the same
        // EventId. The repository's idempotency relies on the aligned clustered
        // PK (EventId, OccurredAtUtc) being intact so the IF NOT EXISTS … INSERT
        // race window resolves to a duplicate-key violation the repo swallows.
        // If the switch had disturbed that key, two rows would land and the second
        // InsertIfNotExistsAsync would silently double-insert.
        await Task.Delay(TimeSpan.FromMilliseconds(500));

        var freshEventId = Guid.NewGuid();
        var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc); // within partition range, well inside retention window
        var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var freshEvt = ScadaBridgeAuditEventFactory.Create(
            eventId: freshEventId,
            occurredAtUtc: freshOccurred,
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            sourceSiteId: freshSite,
            target: "system-x/method");

        await using (var ctx = CreateContext())
        {
            var repo = new AuditLogRepository(ctx);
            await repo.InsertIfNotExistsAsync(freshEvt);
            // Same row a second time — must be a silent no-op.
            await repo.InsertIfNotExistsAsync(freshEvt);
        }

        await using var verify = CreateContext();
        var rows = await verify.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == freshSite)
            .ToListAsync();
        Assert.Single(rows);
        Assert.Equal(freshEventId, rows[0].EventId);
    }

    // ---------------------------------------------------------------------
    // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
    // ---------------------------------------------------------------------

    /// <summary>
    /// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
    /// directly against the real repository + fixture DB. Seeds, in the SAME partition,
    /// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
    /// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
    /// purge for <c>ApiOutbound</c> only. Asserts:
    /// <list type="number">
    ///   <item>The overridden channel's OLD rows are deleted.</item>
    ///   <item>The overridden channel's RECENT rows (newer than the channel threshold) survive.</item>
    ///   <item>The un-overridden channel's rows (old AND recent) are completely untouched
    ///         — they follow the global window, which the channel purge never applies to them.</item>
    /// </list>
    /// This is the maintenance-path row DELETE; the fixture connects as <c>sa</c>, which
    /// the append-only writer-role DENYs do not bind (the role granularity is exercised
    /// in the repository/migration tests).
    /// </summary>
    [SkippableFact]
    public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8);

        // Two timestamps: one OLD (older than the channel threshold we will purge with)
        // and one RECENT (newer than it). Both sit comfortably inside the retention
        // window so the global partition purge would NOT touch either — isolating the
        // per-channel DELETE as the only force acting here.
        var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
        var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc);

        var apiOldId = Guid.NewGuid();      // ApiOutbound, old   → SHOULD be deleted
        var apiRecentId = Guid.NewGuid();   // ApiOutbound, recent→ SHOULD survive
        var dbOldId = Guid.NewGuid();       // DbOutbound, old    → SHOULD survive (un-overridden)
        var dbRecentId = Guid.NewGuid();    // DbOutbound, recent → SHOULD survive

        await using (var seedConn = _fixture.OpenConnection())
        {
            await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
            await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
            await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
            await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
        }

        // Purge ApiOutbound rows older than a threshold that sits strictly between the
        // old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies.
        var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);

        await using (var ctx = CreateContext())
        {
            var repo = new AuditLogRepository(ctx);
            var deleted = await repo.PurgeChannelOlderThanAsync(
                channel: "ApiOutbound",
                threshold: channelThreshold,
                batchSize: 2);

            Assert.Equal(1L, deleted);

            // Idempotent: a second run deletes nothing (the eligible row is gone).
            var deletedAgain = await repo.PurgeChannelOlderThanAsync(
                channel: "ApiOutbound",
                threshold: channelThreshold,
                batchSize: 2);
            Assert.Equal(0L, deletedAgain);
        }

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

        // Overridden channel: old gone, recent kept.
        Assert.DoesNotContain(rows, r => r.EventId == apiOldId);
        Assert.Contains(rows, r => r.EventId == apiRecentId);

        // Un-overridden channel: BOTH rows untouched (follow the global window).
        Assert.Contains(rows, r => r.EventId == dbOldId);
        Assert.Contains(rows, r => r.EventId == dbRecentId);
    }

    // ---------------------------------------------------------------------
    // 5. SwitchOut_WithExplicitCommandTimeout_Succeeds (Task 4)
    // ---------------------------------------------------------------------

    /// <summary>
    /// Task 4 (arch-review 04, S2): proves the explicit-<see cref="TimeSpan"/> maintenance-timeout
    /// overload of <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> runs the real
    /// staging/switch batch to completion against SQL Server — the old row is purged, the kept row
    /// survives, and the returned sampled row-count reflects the switched partition. Exercising the
    /// timeout path end-to-end guards against a regression that only sets the timeout on the sample
    /// command and forgets the DDL batch (or vice versa).
    /// </summary>
    [SkippableFact]
    public async Task SwitchOut_WithExplicitCommandTimeout_Succeeds()
    {
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        var siteId = "switch-timeout-" + Guid.NewGuid().ToString("N").Substring(0, 8);
        var oldEventId = Guid.NewGuid();
        var keptEventId = Guid.NewGuid();
        var (oldOccurred, keptOccurred, _, _) = SeedOccurredAt();

        await using (var seedConn = _fixture.OpenConnection())
        {
            await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
            await DirectInsertAsync(seedConn, keptEventId, keptOccurred, siteId);
        }

        var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);

        await using (var ctx = CreateContext())
        {
            var repo = new AuditLogRepository(ctx);
            var rowsDeleted = await repo.SwitchOutPartitionAsync(janBoundary, TimeSpan.FromMinutes(30));
            Assert.True(rowsDeleted >= 1,
                $"Expected the Jan-2026 partition switch-out to report >= 1 sampled row; got {rowsDeleted}.");
        }

        await using var verify = CreateContext();
        var rows = await verify.Set<AuditLogRow>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();
        Assert.DoesNotContain(rows, r => r.EventId == oldEventId);
        Assert.Contains(rows, r => r.EventId == keptEventId);

        // The switch must leave uniqueness enforcement exactly as it found it.
        await using var check = _fixture.OpenConnection();
        await AssertAlignedUniquenessAsync(check);
    }
}
