namespace DodoSSH.Crypto.Tests;

/// <summary>The key log hash chain. See docs/crypto.md §7.2.</summary>
public sealed class KeyLogChainTests
{
    private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
    private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);

    [Fact]
    public void GenesisPreviousHash_IsThirtyTwoZeroBytes()
    {
        var genesis = KeyLogChain.CreateGenesisPreviousHash();

        genesis.Length.ShouldBe(CryptoSpec.DigestSize);
        genesis.ShouldAllBe(b => b == 0);
    }

    [Fact]
    public void GenesisPreviousHash_IsANewArrayEachCall()
    {
        // Shared mutable state in a hash input would be a spectacular way to corrupt a chain.
        var first = KeyLogChain.CreateGenesisPreviousHash();
        first[0] = 0xFF;

        KeyLogChain.CreateGenesisPreviousHash()[0].ShouldBe((byte)0);
    }

    [Fact]
    public void ComputeEntryHash_IsDeterministic()
    {
        Hash().ShouldBe(Hash());
    }

    [Fact]
    public void ChangingThePreviousHash_ChangesTheEntryHash()
    {
        // The link itself. If this held, a server could reorder or drop entries undetectably.
        var linked = Hash(previousHash: Hash());

        linked.ShouldNotBe(Hash());
    }

    [Theory]
    [InlineData("user")]
    [InlineData("generation")]
    [InlineData("encryptionKey")]
    [InlineData("signingKey")]
    [InlineData("signature")]
    [InlineData("createdAt")]
    public void ChangingAnyField_ChangesTheEntryHash(string field)
    {
        var baseline = Hash();

        var altered = field switch
        {
            "user" => Hash(userId: Bob),
            "generation" => Hash(generation: 2),
            "encryptionKey" => Hash(encryptionPublicKey: TestKeys.Alternate),
            "signingKey" => Hash(signingPublicKey: TestKeys.Alternate),
            "signature" => Hash(signature: AlternateSignature),
            "createdAt" => Hash(createdAt: CreatedAt.AddMilliseconds(1)),
            _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
        };

        altered.ShouldNotBe(baseline);
    }

    [Fact]
    public void SubMillisecondPrecision_IsTruncatedAway()
    {
        // The stored column round-trips through PostgreSQL's microseconds. If the hash used finer
        // precision than the storage, no entry could ever reproduce its own hash after being read.
        Hash(createdAt: CreatedAt.AddTicks(9_999)).ShouldBe(Hash(createdAt: CreatedAt));
    }

    [Fact]
    public void TruncateTimestamp_MatchesWhatTheHashUses()
    {
        var truncated = KeyLogChain.TruncateTimestamp(CreatedAt.AddTicks(9_999));

        truncated.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds());
        truncated.Ticks.ShouldBe(truncated.Ticks / TimeSpan.TicksPerMillisecond * TimeSpan.TicksPerMillisecond);
        truncated.Offset.ShouldBe(TimeSpan.Zero);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(31)]
    [InlineData(33)]
    public void ComputeEntryHash_RejectsAWrongLengthPreviousHash(int length)
    {
        Should.Throw<ArgumentException>(() => Hash(previousHash: new byte[length]));
    }

    [Fact]
    public void ComputeEntryHash_RejectsAWrongLengthSignature()
    {
        Should.Throw<ArgumentException>(() => Hash(signature: new byte[32]));
    }

    [Fact]
    public void ComputeEntryHash_RejectsAGenerationBelowOne()
    {
        Should.Throw<ArgumentOutOfRangeException>(() => Hash(generation: 0));
    }

    private static byte[] AlternateSignature { get; } =
        [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0xC0 + i))];

    private static byte[] Signature { get; } =
        [.. Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i))];

    private static byte[] Hash(
        byte[]? previousHash = null,
        Guid? userId = null,
        int generation = 1,
        byte[]? encryptionPublicKey = null,
        byte[]? signingPublicKey = null,
        byte[]? signature = null,
        DateTimeOffset? createdAt = null) =>
        KeyLogChain.ComputeEntryHash(
            previousHash ?? KeyLogChain.CreateGenesisPreviousHash(),
            userId ?? Alice,
            generation,
            encryptionPublicKey ?? TestKeys.Encryption,
            signingPublicKey ?? TestKeys.Signing,
            signature ?? Signature,
            createdAt ?? CreatedAt);
}
