using DodoSSH.Domain;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace DodoSSH.Infrastructure.Tests;

/// <summary>
/// Proves the per-vault advisory lock makes change-log sequence order equal commit order.
/// </summary>
/// <remarks>
/// <para>
/// This is the executable form of ADR 0003's central hazard. <c>bigserial</c> assigns sequence
/// values when the <c>INSERT</c> runs, not when the transaction commits. So without serialising
/// writers per vault, transaction A can take sequence 5 while B takes 6, and B can commit first.
/// A reader that polls in between sees only 6, advances its cursor past 5, and never learns about
/// it — silent, permanent data loss for that item.
/// </para>
/// <para>
/// The first test demonstrates the hazard is real by reproducing it <em>without</em> the lock. The
/// second shows the lock removes it. Without the first, the second proves nothing: it would pass
/// just as happily if the interleaving never occurred.
/// </para>
/// </remarks>
[Collection(PostgresCollection.Name)]
public sealed class AdvisoryLockOrderingTests(PostgresFixture fixture)
{
    private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero);

    [Fact]
    public async Task WithoutTheLock_ACommittedGapIsObservable()
    {
        // Deliberately reproduces the bug. If this ever stops failing to produce a gap, either
        // PostgreSQL's sequence behaviour changed or the test stopped interleaving — and the
        // guarantee the next test claims would no longer be meaningful.
        var vaultId = await SeedVaultAsync();

        await using var first = new NpgsqlConnection(fixture.ConnectionString);
        await using var second = new NpgsqlConnection(fixture.ConnectionString);
        await first.OpenAsync();
        await second.OpenAsync();

        await using var firstTransaction = await first.BeginTransactionAsync();
        await using var secondTransaction = await second.BeginTransactionAsync();

        // A takes the lower sequence...
        var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);

        // ...B takes the higher one, and commits first.
        var highSequence = await InsertChangeAsync(second, secondTransaction, vaultId);
        await secondTransaction.CommitAsync();

        highSequence.ShouldBeGreaterThan(lowSequence);

        // A reader now sees the high sequence but not the low one. Advancing a cursor to
        // highSequence would skip lowSequence forever once A commits.
        var visible = await VisibleSequencesAsync(vaultId);
        visible.ShouldContain(highSequence);
        visible.ShouldNotContain(lowSequence);

        await firstTransaction.CommitAsync();

        // And here is the damage: the skipped row is now visible, but behind the cursor.
        var afterBothCommitted = await VisibleSequencesAsync(vaultId);
        afterBothCommitted.ShouldContain(lowSequence);
        afterBothCommitted.ShouldContain(highSequence);
    }

    [Fact]
    public async Task WithTheLock_SequenceOrderMatchesCommitOrder()
    {
        var vaultId = await SeedVaultAsync();

        await using var first = new NpgsqlConnection(fixture.ConnectionString);
        await using var second = new NpgsqlConnection(fixture.ConnectionString);
        await first.OpenAsync();
        await second.OpenAsync();

        await using var firstTransaction = await first.BeginTransactionAsync();
        await AcquireVaultLockAsync(first, firstTransaction, vaultId);
        var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);

        // The second writer blocks on the lock rather than racing ahead to a higher sequence.
        await using var secondTransaction = await second.BeginTransactionAsync();
        var blocked = Task.Run(async () =>
        {
            await AcquireVaultLockAsync(second, secondTransaction, vaultId);
            var sequence = await InsertChangeAsync(second, secondTransaction, vaultId)
                ;
            await secondTransaction.CommitAsync();
            return sequence;
        });

        // Give it a moment to prove it really is waiting, not merely slow.
        var completedEarly = await Task.WhenAny(blocked, Task.Delay(TimeSpan.FromMilliseconds(750)))
            ;
        completedEarly.ShouldNotBe(blocked, "the second writer should be blocked on the advisory lock");

        await firstTransaction.CommitAsync();

        var highSequence = await blocked;

        // The writer that committed first holds the lower sequence, so a reader advancing its
        // cursor monotonically can never skip a committed row.
        highSequence.ShouldBeGreaterThan(lowSequence);

        var visible = await VisibleSequencesAsync(vaultId);
        visible.ShouldContain(lowSequence);
        visible.ShouldContain(highSequence);
    }

    [Fact]
    public async Task WithTheLock_ConcurrentWritersProduceNoGaps()
    {
        // The property that actually matters, under real contention: reading the log in sequence
        // order after every writer has committed must yield a contiguous, complete set.
        var vaultId = await SeedVaultAsync();
        const int Writers = 12;

        var tasks = Enumerable.Range(0, Writers).Select(async _ =>
        {
            await using var connection = new NpgsqlConnection(fixture.ConnectionString);
            await connection.OpenAsync();
            await using var transaction = await connection.BeginTransactionAsync();

            await AcquireVaultLockAsync(connection, transaction, vaultId);
            var sequence = await InsertChangeAsync(connection, transaction, vaultId);

            await transaction.CommitAsync();
            return sequence;
        });

        var sequences = await Task.WhenAll(tasks);

        sequences.Distinct().Count().ShouldBe(Writers);

        var visible = await VisibleSequencesAsync(vaultId);
        visible.Count.ShouldBe(Writers);
        visible.OrderBy(s => s).ShouldBe(sequences.OrderBy(s => s));
    }

    private static Task<int> AcquireVaultLockAsync(
        NpgsqlConnection connection,
        NpgsqlTransaction transaction,
        Guid vaultId)
    {
        var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "SELECT pg_advisory_xact_lock(hashtextextended(@vault::text, 0))";
        command.Parameters.AddWithValue("vault", vaultId);
        return command.ExecuteNonQueryAsync();
    }

    private static async Task<long> InsertChangeAsync(
        NpgsqlConnection connection,
        NpgsqlTransaction transaction,
        Guid vaultId)
    {
        var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = """
            INSERT INTO dodo.sync_change
                (vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc)
            VALUES (@vault, 1, @entity, 1, 1, @actor, now())
            RETURNING sequence
            """;
        command.Parameters.AddWithValue("vault", vaultId);
        command.Parameters.AddWithValue("entity", Guid.CreateVersion7());
        command.Parameters.AddWithValue("actor", Guid.CreateVersion7());

        return (long)(await command.ExecuteScalarAsync())!;
    }

    /// <summary>Sequences a fresh reader can currently see, i.e. committed ones.</summary>
    private async Task<List<long>> VisibleSequencesAsync(Guid vaultId)
    {
        await using var context = fixture.CreateContext();

        return await context.VaultChanges
            .Where(c => c.VaultId == vaultId)
            .OrderBy(c => c.Sequence)
            .Select(c => c.Sequence)
            .ToListAsync()
            ;
    }

    private async Task<Guid> SeedVaultAsync()
    {
        await using var context = fixture.CreateContext();

        var user = new UserAccount
        {
            Id = Guid.CreateVersion7(),
            Issuer = "https://idp.example",
            Subject = Guid.CreateVersion7().ToString(),
            Status = UserStatus.Active,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        };

        var vault = new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "Personal",
            OwnerKind = VaultOwnerKind.Personal,
            OwnerUserId = user.Id,
            KeyGeneration = 1,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        };

        context.Users.Add(user);
        context.Vaults.Add(vault);
        await context.SaveChangesAsync();

        return vault.Id;
    }
}
