using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;

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

/// <summary>
/// Bundle D (#23 M6-T5) integration tests for
/// <see cref="AuditLogPartitionMaintenance"/>. Uses the same
/// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository
/// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded
/// <c>pf_AuditLog_Month</c>.
/// </summary>
/// <remarks>
/// The migration seeds boundaries for every month in 2026 and 2027 (Jan 2026
/// through Dec 2027). Tests pick a lookahead relative to the current
/// max-boundary at test start (rather than a fixed-target date) so each test
/// is robust against earlier tests in the class having added boundaries to
/// the shared fixture DB. Tests run sequentially within the class via xunit's
/// per-class collection serialisation.
/// </remarks>
public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
{
    private readonly MsSqlMigrationFixture _fixture;

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

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

    private AuditLogPartitionMaintenance NewMaintenance(ScadaBridgeDbContext ctx) =>
        new(ctx, NullLogger<AuditLogPartitionMaintenance>.Instance);

    /// <summary>
    /// Computes the lookahead-in-months required to fall strictly inside the
    /// already-covered boundary range. Picks something well below the
    /// distance from "now" to the current max — guaranteed not to need any
    /// new SPLIT.
    /// </summary>
    private static int LookaheadInsideExistingRange(DateTime max)
    {
        var now = DateTime.UtcNow;
        // (max - now) in whole months, minus a 1-month safety margin so we
        // never accidentally hit the boundary horizon edge case.
        var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
        return Math.Max(1, months);
    }

    /// <summary>
    /// Computes the lookahead-in-months required to add exactly
    /// <paramref name="extraBoundaries"/> new boundaries past the current max.
    /// </summary>
    /// <remarks>
    /// EnsureLookaheadAsync defines horizon =
    /// <c>NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths</c>. The new
    /// boundaries it issues are first-of-month values strictly greater than
    /// max, up to and including horizon. So
    /// <c>lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries</c>
    /// is the exact value that lands horizon on <c>max + extraBoundaries</c>
    /// months.
    /// </remarks>
    private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
    {
        var nowFirstOfMonth = FirstOfNextMonth(DateTime.UtcNow);
        var monthsToMax = ((max.Year - nowFirstOfMonth.Year) * 12) + max.Month - nowFirstOfMonth.Month;
        return monthsToMax + extraBoundaries;
    }

    private static DateTime FirstOfNextMonth(DateTime instant)
    {
        var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
        return firstOfThisMonth.AddMonths(1);
    }

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

        await using var ctx = CreateContext();
        var maintenance = NewMaintenance(ctx);

        var max = await maintenance.GetMaxBoundaryAsync();
        Assert.NotNull(max);

        // Pick a lookahead small enough that horizon (NormalizeToFirstOfMonth(now)
        // + lookahead) lands well INSIDE the already-covered range — no SPLIT
        // should fire.
        var lookahead = LookaheadInsideExistingRange(max.Value);

        var added = await maintenance.EnsureLookaheadAsync(lookahead);

        Assert.Empty(added);

        // Sanity: the max boundary is unchanged after the no-op call.
        var maxAfter = await maintenance.GetMaxBoundaryAsync();
        Assert.Equal(max, maxAfter);
    }

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

        await using var ctx = CreateContext();
        var maintenance = NewMaintenance(ctx);

        var maxBefore = await maintenance.GetMaxBoundaryAsync();
        Assert.NotNull(maxBefore);

        var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
        var expectedAdded = maxBefore.Value.AddMonths(1);

        var added = await maintenance.EnsureLookaheadAsync(lookahead);

        Assert.Single(added);
        Assert.Equal(expectedAdded, added[0]);

        var maxAfter = await maintenance.GetMaxBoundaryAsync();
        Assert.Equal(expectedAdded, maxAfter);
    }

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

        await using var ctx = CreateContext();
        var maintenance = NewMaintenance(ctx);

        var maxBefore = await maintenance.GetMaxBoundaryAsync();
        Assert.NotNull(maxBefore);

        var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 3);

        var added = await maintenance.EnsureLookaheadAsync(lookahead);

        Assert.Equal(3, added.Count);
        Assert.Equal(maxBefore.Value.AddMonths(1), added[0]);
        Assert.Equal(maxBefore.Value.AddMonths(2), added[1]);
        Assert.Equal(maxBefore.Value.AddMonths(3), added[2]);

        var maxAfter = await maintenance.GetMaxBoundaryAsync();
        Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
    }

    /// <summary>
    /// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
    /// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
    /// connection drop) now aborts the loop instead of leaving partition holes. This
    /// test pins that abort behaviour: with an interceptor that throws on the SECOND
    /// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
    /// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
    /// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
    /// commit boundary N before throwing, and that the next tick can resume from
    /// boundary N+1 at-least-once with no holes.
    /// </summary>
    [SkippableFact]
    public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
    {
        // STM: CD-024-SecondSplitThrowsAbortsLoop marker.
        Skip.IfNot(_fixture.Available, _fixture.SkipReason);

        // Baseline max-boundary observed via a clean context.
        await using var baselineCtx = CreateContext();
        var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
        Assert.NotNull(maxBefore);

        var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
        var expectedFirst = maxBefore.Value.AddMonths(1);

        // Build a fresh context with an interceptor that throws on the 2nd ALTER
        // PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
        // ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
        // try/catch (CD-019), so the exception propagates after the first SPLIT
        // has already committed.
        var interceptor = new SecondSplitThrowsInterceptor();
        var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer(_fixture.ConnectionString)
            .AddInterceptors(interceptor)
            .Options;
        await using var ctx = new ScadaBridgeDbContext(options);
        var maintenance = NewMaintenance(ctx);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => maintenance.EnsureLookaheadAsync(lookahead));

        // Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
        // before the interceptor's throw: split #1 committed, split #2 threw,
        // split #3 was never attempted.
        Assert.Equal(1, interceptor.SuccessfulSplits);

        // And verify the first boundary IS now persisted — the loop aborted but
        // boundary N is durable so the next tick resumes from N+1 (no holes).
        await using var verifyCtx = CreateContext();
        var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
        Assert.Equal(expectedFirst, maxAfter);
    }

    /// <summary>
    /// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
    /// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
    /// on the second one. Threads through synchronous + async + scalar + reader
    /// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
    /// non-query async path but other code paths still go through the same
    /// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
    /// that were allowed to run so the test can pin the abort-after-one
    /// behaviour.
    /// </summary>
    private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
    {
        public int SuccessfulSplits { get; private set; }

        private bool IsTargetSplit(DbCommand command) =>
            command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
            && command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);

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

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

        public override int NonQueryExecuted(
            DbCommand command,
            CommandExecutedEventData eventData,
            int result)
        {
            if (IsTargetSplit(command))
            {
                SuccessfulSplits++;
            }
            return base.NonQueryExecuted(command, eventData, result);
        }

        public override ValueTask<int> NonQueryExecutedAsync(
            DbCommand command,
            CommandExecutedEventData eventData,
            int result,
            CancellationToken cancellationToken = default)
        {
            if (IsTargetSplit(command))
            {
                SuccessfulSplits++;
            }
            return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
        }

        private void ThrowIfSecondSplit(DbCommand command)
        {
            if (!IsTargetSplit(command))
            {
                return;
            }

            // Allow the first SPLIT through; throw on the second so the loop's
            // post-CD-019 "let it propagate" behaviour can be asserted.
            if (SuccessfulSplits >= 1)
            {
                throw new InvalidOperationException(
                    "Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
            }
        }
    }

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

        await using var ctx1 = CreateContext();
        var m1 = NewMaintenance(ctx1);

        var maxStart = await m1.GetMaxBoundaryAsync();
        Assert.NotNull(maxStart);

        // First call: add one boundary.
        var lookahead = LookaheadForExtraBoundaries(maxStart.Value, extraBoundaries: 1);
        var firstAdded = await m1.EnsureLookaheadAsync(lookahead);
        Assert.Single(firstAdded);

        // Second call: the boundary just added is now part of pf_AuditLog_Month,
        // so the same lookahead value should be a no-op — no exception, no
        // duplicate SPLIT.
        await using var ctx2 = CreateContext();
        var m2 = NewMaintenance(ctx2);
        var secondAdded = await m2.EnsureLookaheadAsync(lookahead);

        Assert.Empty(secondAdded);

        // The max boundary is unchanged across the second call.
        var maxAfter = await m2.GetMaxBoundaryAsync();
        Assert.Equal(firstAdded[0], maxAfter);
    }
}
