using System.Security.Cryptography;
using DodoSSH.Domain.Sync;

namespace DodoSSH.Domain.Tests.Sync;

/// <summary>
/// Cursor encoding and, more importantly, every way a bad cursor must be rejected.
/// </summary>
/// <remarks>
/// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the
/// client believes it is up to date while having skipped changes — which is strictly worse than an
/// error the client can retry from scratch.
/// </remarks>
public sealed class SyncCursorTests
{
    private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
    private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");

    [Theory]
    [InlineData(0L)]
    [InlineData(1L)]
    [InlineData(42L)]
    [InlineData(long.MaxValue)]
    public void RoundTrips(long sequence)
    {
        var cursor = SyncCursor.Encode(Key, VaultId, sequence);

        SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue();
        decoded.ShouldBe(sequence);
    }

    [Fact]
    public void IsDeterministic()
    {
        SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7));
    }

    [Fact]
    public void IsUrlSafeAndUnpadded()
    {
        var cursor = SyncCursor.Encode(Key, VaultId, 12345);

        cursor.ShouldNotContain("+");
        cursor.ShouldNotContain("/");
        cursor.ShouldNotContain("=");
    }

    [Fact]
    public void DoesNotRevealTheSequenceInPlainSight()
    {
        // Not a security property — the position is not secret — but it discourages clients from
        // parsing or synthesising cursors, which is what the opacity is actually for.
        SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654");
    }

    [Fact]
    public void RejectsATamperedTag()
    {
        var cursor = SyncCursor.Encode(Key, VaultId, 100);

        // The second-to-last character, never the last one. Base64 encodes 3 bytes per 4 characters,
        // so when the input length is not a multiple of 3 the final character carries bits that
        // decode to nothing — and altering only those produces a different string that decodes to
        // identical bytes, verifies fine, and makes this test pass or fail depending on the random
        // key. The penultimate character is fully significant at every input length.
        var tampered = cursor[..^2] + (cursor[^2] == 'A' ? 'B' : 'A') + cursor[^1];

        tampered.Equals(cursor, StringComparison.Ordinal).ShouldBeFalse();

        SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
    }

    [Fact]
    public void RejectsATamperedPayload()
    {
        // The attack this prevents: rewriting the sequence to skip ahead, so the client never
        // learns about the changes in between.
        var cursor = SyncCursor.Encode(Key, VaultId, 100);
        var mutated = cursor.ToCharArray();
        mutated[0] = mutated[0] == 'x' ? 'y' : 'x';

        SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse();
    }

    [Fact]
    public void RejectsACursorSignedWithAnotherKey()
    {
        var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100);

        SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse();
    }

    [Fact]
    public void RejectsACursorIssuedForAnotherVault()
    {
        // Legitimately issued and correctly tagged, but for a different vault. Without the vault
        // id inside the payload this would decode to a sequence from an unrelated log and serve
        // the wrong slice of history.
        var cursor = SyncCursor.Encode(Key, OtherVaultId, 100);

        SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("not-base64!!")]
    [InlineData("AAAA")]
    [InlineData("A")]
    public void RejectsMalformedInput(string? cursor)
    {
        SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
    }

    [Fact]
    public void RejectsATruncatedCursor()
    {
        var cursor = SyncCursor.Encode(Key, VaultId, 100);

        SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse();
    }

    [Fact]
    public void NeverThrowsOnClientSuppliedInput()
    {
        // Cursors come from clients, so rejection must be a return value rather than an exception
        // that becomes a 500.
        string[] hostile =
        [
            "\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%",
        ];

        foreach (var value in hostile)
        {
            Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _));
        }
    }

    [Fact]
    public void RejectsAnUndersizedSigningKey()
    {
        // Misconfiguration must fail loudly at the call site rather than producing weak tags.
        Should.Throw<ArgumentException>(() => SyncCursor.Encode(new byte[16], VaultId, 1));
        Should.Throw<ArgumentException>(() =>
            SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _));
    }

    [Fact]
    public void RejectsANegativeSequence()
    {
        Should.Throw<ArgumentOutOfRangeException>(() => SyncCursor.Encode(Key, VaultId, -1));
    }

    [Fact]
    public void DifferentSequencesProduceDifferentCursors()
    {
        var first = SyncCursor.Encode(Key, VaultId, 1);
        var second = SyncCursor.Encode(Key, VaultId, 2);

        string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse();
    }
}
