using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using Xunit;

namespace ScadaLink.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<AuditEvent>()
            .Where(e => e.SourceSiteId == siteId)
            .ToListAsync();

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

    [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.
        var second = first with { ErrorMessage = "second-should-be-ignored" };
        await repo.InsertIfNotExistsAsync(second);

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

        Assert.Single(loaded);
        Assert.Equal("first", loaded[0].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(SourceSiteId: 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(Channel: AuditChannel.Notification, SourceSiteId: siteId),
            new AuditLogPaging(PageSize: 10));

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

    [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(SourceSiteId: siteId),
            new AuditLogPaging(PageSize: 10));

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

    [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(
                SourceSiteId: 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(SourceSiteId: 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(SourceSiteId: siteId),
            new AuditLogPaging(
                PageSize: 2,
                AfterOccurredAtUtc: cursor.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(SourceSiteId: siteId),
            new AuditLogPaging(
                PageSize: 2,
                AfterOccurredAtUtc: cursor2.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<AuditEvent>()
            .Where(e => e.SourceSiteId == siteId)
            .CountAsync();

        Assert.Equal(1, count);
    }

    [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(SourceSiteId: 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.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));
    }

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

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

        // The partition-switch path is intentionally blocked in M1 because
        // UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships
        // with the M6 purge actor.
        var ex = await Assert.ThrowsAsync<NotSupportedException>(
            () => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));

        Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase);
    }

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

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

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

    private static AuditEvent NewEvent(
        string siteId,
        DateTime occurredAtUtc,
        AuditChannel channel = AuditChannel.ApiOutbound,
        AuditKind kind = AuditKind.ApiCall,
        AuditStatus status = AuditStatus.Delivered,
        string? errorMessage = null) =>
        new()
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = occurredAtUtc,
            Channel = channel,
            Kind = kind,
            Status = status,
            SourceSiteId = siteId,
            ErrorMessage = errorMessage,
        };
}
