using NSec.Cryptography;

namespace DodoSSH.Crypto.Tests;

/// <summary>
/// The vault key grant tuple and its signature. See docs/crypto.md §7.3.
/// </summary>
/// <remarks>
/// Sealing a vault key is anonymous-sender, so without this signature a server could fabricate a grant
/// containing a key of its own choosing and the recipient would unwrap it happily. Most of these tests
/// are therefore about a signature failing to transfer between contexts it should not.
/// </remarks>
public sealed class GrantStatementCodecTests
{
    private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid Alice = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid Bob = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly DateTimeOffset GrantedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);

    [Fact]
    public void Encode_IsDeterministic()
    {
        Encode().ShouldBe(Encode());
    }

    [Fact]
    public void Encode_BeginsWithTheDomainLabel()
    {
        var encoding = Encode();

        encoding.AsSpan(0, GrantStatementCodec.Label.Length)
            .SequenceEqual(GrantStatementCodec.Label)
            .ShouldBeTrue();
    }

    [Theory]
    [InlineData("vault")]
    [InlineData("generation")]
    [InlineData("kind")]
    [InlineData("grantee")]
    [InlineData("granteeFingerprint")]
    [InlineData("wrappedKey")]
    [InlineData("granter")]
    [InlineData("granterFingerprint")]
    [InlineData("keyLogHead")]
    [InlineData("grantedAt")]
    public void ChangingAnyField_ChangesTheEncoding(string field)
    {
        var baseline = Encode();

        var altered = field switch
        {
            "vault" => Encode(vaultId: VaultB),
            "generation" => Encode(keyGeneration: 2),
            "kind" => Encode(kind: GrantPurpose.Recovery),
            "grantee" => Encode(granteeUserId: Bob),
            "granteeFingerprint" => Encode(granteeFingerprint: Fingerprint(0xB0)),
            "wrappedKey" => Encode(wrappedKey: Bytes(80, 0x99)),
            "granter" => Encode(granterUserId: Bob),
            "granterFingerprint" => Encode(granterFingerprint: Fingerprint(0xC0)),
            "keyLogHead" => Encode(keyLogHead: Fingerprint(0xD0)),
            "grantedAt" => Encode(grantedAt: GrantedAt.AddMilliseconds(1)),
            _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."),
        };

        altered.ShouldNotBe(baseline);
    }

    [Fact]
    public void AbsentAndPresentKeyLogHeads_EncodeDifferently()
    {
        // The presence byte, without which a grant carrying no head and one carrying 32 zero bytes
        // would be indistinguishable.
        Encode(keyLogHead: default).ShouldNotBe(Encode(keyLogHead: new byte[32]));
    }

    [Fact]
    public void TheSignatureCoversOnlyTheWrappedKeysDigest()
    {
        // So a verifier can check who issued a grant without holding the vault key. The encoding is
        // 32 bytes of digest regardless of how large the wrapped key is.
        var small = Encode(wrappedKey: Bytes(48, 0x11));
        var large = Encode(wrappedKey: Bytes(4096, 0x11));

        small.Length.ShouldBe(large.Length);
        small.ShouldNotBe(large);
    }

    [Fact]
    public void AFreshSignature_Verifies()
    {
        using var key = CreateSigningKey();
        var grant = Encode();

        var signature = GrantStatementCodec.Sign(key, grant);

        signature.Length.ShouldBe(CryptoSpec.SignatureSize);
        GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeTrue();
    }

    [Fact]
    public void ASignatureOverAnotherGrant_DoesNotVerify()
    {
        // The property that matters: a grant for one vault cannot be replayed onto another.
        using var key = CreateSigningKey();

        var signature = GrantStatementCodec.Sign(key, Encode(vaultId: VaultA));

        GrantStatementCodec.Verify(PublicKeyBytes(key), Encode(vaultId: VaultB), signature)
            .ShouldBeFalse();
    }

    [Fact]
    public void ASignatureFromAnotherGranter_DoesNotVerify()
    {
        using var key = CreateSigningKey();
        using var other = CreateSigningKey();
        var grant = Encode();

        var signature = GrantStatementCodec.Sign(other, grant);

        GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeFalse();
    }

    [Fact]
    public void AKeyStatementSignature_DoesNotVerifyAsAGrant()
    {
        // Context separation. Without it a signature produced in one role could be presented in
        // another, which is the whole reason each context string exists.
        using var key = CreateSigningKey();
        var grant = Encode();

        var wrongContext = DshSignatures.SignKeyStatement(key, grant);

        GrantStatementCodec.Verify(PublicKeyBytes(key), grant, wrongContext).ShouldBeFalse();
    }

    [Theory]
    [InlineData(0)]
    [InlineData(31)]
    [InlineData(33)]
    public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length)
    {
        using var key = CreateSigningKey();
        var grant = Encode();
        var signature = GrantStatementCodec.Sign(key, grant);

        GrantStatementCodec.Verify(new byte[length], grant, signature).ShouldBeFalse();
    }

    [Fact]
    public void Encode_RejectsAnUnspecifiedKind()
    {
        Should.Throw<ArgumentOutOfRangeException>(() => Encode(kind: GrantPurpose.Unspecified));
    }

    [Fact]
    public void Encode_RejectsAnEmptyWrappedKey()
    {
        Should.Throw<ArgumentException>(() => Encode(wrappedKey: []));
    }

    [Fact]
    public void Encode_RejectsAWrongLengthFingerprint()
    {
        Should.Throw<ArgumentException>(() => Encode(granteeFingerprint: new byte[16]));
    }

    [Fact]
    public void Encode_RejectsAWrongLengthKeyLogHead()
    {
        Should.Throw<ArgumentException>(() => Encode(keyLogHead: new byte[16]));
    }

    [Fact]
    public void ThePurposeValues_MatchTheDomainEnum()
    {
        // Covered by the signature, so a renumbering would make every grant of the changed kind fail
        // verification. Domain cannot be referenced from here, so the values are asserted literally
        // against docs/crypto.md §7.3.
        ((byte)GrantPurpose.Member).ShouldBe((byte)1);
        ((byte)GrantPurpose.Recovery).ShouldBe((byte)2);
        ((byte)GrantPurpose.Escrow).ShouldBe((byte)3);
    }

    // ---- Helpers ----

    private static byte[] Encode(
        Guid? vaultId = null,
        uint keyGeneration = 1,
        GrantPurpose kind = GrantPurpose.Member,
        Guid? granteeUserId = null,
        byte[]? granteeFingerprint = null,
        byte[]? wrappedKey = null,
        Guid? granterUserId = null,
        byte[]? granterFingerprint = null,
        byte[]? keyLogHead = null,
        DateTimeOffset? grantedAt = null) =>
        GrantStatementCodec.Encode(
            vaultId ?? VaultA,
            keyGeneration,
            kind,
            granteeUserId ?? Alice,
            granteeFingerprint ?? Fingerprint(0x40),
            wrappedKey ?? Bytes(80, 0x77),
            granterUserId ?? Alice,
            granterFingerprint ?? Fingerprint(0x60),
            keyLogHead ?? [],
            grantedAt ?? GrantedAt);

    private static byte[] Fingerprint(byte seed) => Bytes(CryptoSpec.DigestSize, seed);

    private static byte[] Bytes(int length, byte seed) =>
        [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))];

    private static Key CreateSigningKey() =>
        Key.Create(
            SignatureAlgorithm.Ed25519,
            new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });

    private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
}
