using System.Security.Cryptography;
using DodoSSH.Crypto;
using NSec.Cryptography;

namespace DodoSSH.Crypto.Tests;

/// <summary>
/// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md §4.4.
/// </summary>
/// <remarks>
/// The negative tests are the point of this file. They are the executable form of the claim
/// that a server holding every ciphertext and every plaintext column still cannot relocate,
/// roll back, replay or repurpose a blob.
/// </remarks>
public sealed class DshCryptoTests
{
    private static readonly Guid CredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid OtherCredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");

    private static byte[] NewKey() => RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);

    private static AadDescriptor Payload(Guid id, uint generation = 1, uint version = 1) =>
        AadDescriptor.Create(
            CryptoSpec.AadPurpose.ItemPayload,
            CryptoSpec.AadResourceType.Credential,
            id,
            keyGeneration: generation,
            itemVersion: version);

    [Fact]
    public void Seal_RoundTrips()
    {
        var key = NewKey();
        var plaintext = "correct horse battery staple"u8.ToArray();
        var descriptor = Payload(CredentialId);

        var envelope = DshCrypto.Seal(key, plaintext, descriptor);

        DshCrypto.Open(key, envelope, descriptor).ShouldBe(plaintext);
    }

    [Fact]
    public void Seal_ProducesAWellFormedEnvelope()
    {
        var envelope = DshCrypto.Seal(NewKey(), "x"u8, Payload(CredentialId));

        DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
        view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.XChaCha20Poly1305);
        view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
        view.EphemeralPublicKey.IsEmpty.ShouldBeTrue();
        envelope[..4].ShouldBe("DSH1"u8.ToArray());
    }

    [Fact]
    public void Seal_UsesAFreshNoncePerCall()
    {
        var key = NewKey();
        var descriptor = Payload(CredentialId);

        var first = DshCrypto.Seal(key, "same"u8, descriptor);
        var second = DshCrypto.Seal(key, "same"u8, descriptor);

        first.ShouldNotBe(second);
    }

    [Fact]
    public void Open_RejectsTheWrongKey()
    {
        var envelope = DshCrypto.Seal(NewKey(), "secret"u8, Payload(CredentialId));

        DshCrypto.Open(NewKey(), envelope, Payload(CredentialId)).ShouldBeNull();
    }

    [Fact]
    public void Open_RejectsATamperedCiphertext()
    {
        var key = NewKey();
        var descriptor = Payload(CredentialId);
        var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);

        envelope[^1] ^= 0x01;

        DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
    }

    [Fact]
    public void Open_RejectsANonZeroFlagByte()
    {
        var key = NewKey();
        var descriptor = Payload(CredentialId);
        var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);

        // Fail closed on an envelope we do not fully understand.
        envelope[5] = 0x01;

        DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
    }

    [Theory]
    [InlineData("DSH0")]
    [InlineData("XSH1")]
    public void Open_RejectsABadMagic(string magic)
    {
        var key = NewKey();
        var descriptor = Payload(CredentialId);
        var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);

        System.Text.Encoding.ASCII.GetBytes(magic).CopyTo(envelope, 0);

        DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
    }

    [Fact]
    public void Open_RejectsATruncatedEnvelope()
    {
        var key = NewKey();
        var descriptor = Payload(CredentialId);
        var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);

        DshCrypto.Open(key, envelope.AsSpan(0, envelope.Length / 2), descriptor).ShouldBeNull();
        DshCrypto.Open(key, [], descriptor).ShouldBeNull();
    }

    // ---- docs/crypto.md §4.4: what a malicious server cannot do ----

    [Fact]
    public void Server_CannotMoveCiphertextToAnotherResource()
    {
        var key = NewKey();
        var envelope = DshCrypto.Seal(key, "host-a password"u8, Payload(CredentialId));

        // Same vault key, same everything, different row.
        DshCrypto.Open(key, envelope, Payload(OtherCredentialId)).ShouldBeNull();
    }

    [Fact]
    public void Server_CannotRollBackAKeyGeneration()
    {
        var key = NewKey();
        var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId, generation: 5));

        DshCrypto.Open(key, envelope, Payload(CredentialId, generation: 4)).ShouldBeNull();
    }

    [Fact]
    public void Server_CannotRollBackAnItemVersion()
    {
        var key = NewKey();
        var envelope = DshCrypto.Seal(key, "v2 secret"u8, Payload(CredentialId, version: 2));

        DshCrypto.Open(key, envelope, Payload(CredentialId, version: 1)).ShouldBeNull();
    }

    [Fact]
    public void Server_CannotRepurposeAPayloadAsMetadata()
    {
        var key = NewKey();
        var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));

        var asMetadata = AadDescriptor.Create(
            CryptoSpec.AadPurpose.ItemMetadata,
            CryptoSpec.AadResourceType.Credential,
            CredentialId,
            itemVersion: 1);

        DshCrypto.Open(key, envelope, asMetadata).ShouldBeNull();
    }

    [Fact]
    public void Server_CannotRepurposeAcrossResourceTypes()
    {
        var key = NewKey();
        var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));

        var asHost = AadDescriptor.Create(
            CryptoSpec.AadPurpose.ItemPayload,
            CryptoSpec.AadResourceType.Host,
            CredentialId,
            itemVersion: 1);

        DshCrypto.Open(key, envelope, asHost).ShouldBeNull();
    }

    // ---- SealTo ----

    private static Key NewAgreementKey() => Key.Create(
        KeyAgreementAlgorithm.X25519,
        new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });

    private static AadDescriptor Grant(Guid vaultId, uint generation = 1) => AadDescriptor.Create(
        CryptoSpec.AadPurpose.VaultKeyGrant,
        CryptoSpec.AadResourceType.Vault,
        vaultId,
        keyGeneration: generation);

    [Fact]
    public void SealTo_RoundTrips()
    {
        var vaultId = Guid.CreateVersion7();
        using var recipient = NewAgreementKey();
        var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);

        var vaultKey = NewKey();
        var descriptor = Grant(vaultId);

        var envelope = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);

        DshCrypto.OpenSealed(recipient, envelope, descriptor).ShouldBe(vaultKey);
    }

    [Fact]
    public void SealTo_ProducesAWellFormedEnvelopeCarryingAnEphemeralKey()
    {
        using var recipient = NewAgreementKey();
        var envelope = DshCrypto.SealTo(
            recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
            NewKey(),
            Grant(Guid.CreateVersion7()));

        DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
        view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.SealToX25519);
        view.EphemeralPublicKey.Length.ShouldBe(CryptoSpec.PublicKeySize);
        view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
    }

    [Fact]
    public void SealTo_IsNotOpenableByAnotherRecipient()
    {
        using var intended = NewAgreementKey();
        using var attacker = NewAgreementKey();
        var descriptor = Grant(Guid.CreateVersion7());

        var envelope = DshCrypto.SealTo(
            intended.PublicKey.Export(KeyBlobFormat.RawPublicKey),
            NewKey(),
            descriptor);

        DshCrypto.OpenSealed(attacker, envelope, descriptor).ShouldBeNull();
    }

    [Fact]
    public void SealTo_IsBoundToItsVaultAndGeneration()
    {
        var vaultId = Guid.CreateVersion7();
        using var recipient = NewAgreementKey();
        var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);

        var envelope = DshCrypto.SealTo(recipientPublic, NewKey(), Grant(vaultId, generation: 3));

        // A revoked grant from an earlier generation must not be replayable.
        DshCrypto.OpenSealed(recipient, envelope, Grant(vaultId, generation: 2)).ShouldBeNull();
        DshCrypto.OpenSealed(recipient, envelope, Grant(Guid.CreateVersion7(), generation: 3)).ShouldBeNull();
    }

    [Fact]
    public void SealTo_UsesAFreshEphemeralKeyPerCall()
    {
        using var recipient = NewAgreementKey();
        var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
        var descriptor = Grant(Guid.CreateVersion7());
        var vaultKey = NewKey();

        var first = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
        var second = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);

        DshEnvelope.TryRead(first, out var a).ShouldBeTrue();
        DshEnvelope.TryRead(second, out var b).ShouldBeTrue();

        a.EphemeralPublicKey.SequenceEqual(b.EphemeralPublicKey).ShouldBeFalse();
    }

    [Fact]
    public void OpenSealed_RejectsASymmetricEnvelope()
    {
        // Cross-construction confusion: a symmetric envelope must not be accepted here.
        using var recipient = NewAgreementKey();
        var descriptor = Grant(Guid.CreateVersion7());
        var symmetric = DshCrypto.Seal(NewKey(), "secret"u8, descriptor);

        DshCrypto.OpenSealed(recipient, symmetric, descriptor).ShouldBeNull();
    }

    [Fact]
    public void Open_RejectsASealedEnvelope()
    {
        using var recipient = NewAgreementKey();
        var descriptor = Grant(Guid.CreateVersion7());
        var sealedEnvelope = DshCrypto.SealTo(
            recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
            NewKey(),
            descriptor);

        DshCrypto.Open(NewKey(), sealedEnvelope, descriptor).ShouldBeNull();
    }

    // ---- Fingerprints ----

    [Fact]
    public void Fingerprint_IsStableAndOrderSensitive()
    {
        var x25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
        var ed25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);

        var fingerprint = DshCrypto.ComputeFingerprint(x25519, ed25519);

        fingerprint.Length.ShouldBe(CryptoSpec.DigestSize);
        fingerprint.ShouldBe(DshCrypto.ComputeFingerprint(x25519, ed25519));

        // Swapping the keys must change the fingerprint, or the two roles would be conflated.
        fingerprint.ShouldNotBe(DshCrypto.ComputeFingerprint(ed25519, x25519));
    }

    [Fact]
    public void Fingerprint_RejectsWrongSizedKeys()
    {
        var valid = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);

        Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(new byte[31], valid));
        Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(valid, new byte[33]));
    }

    [Fact]
    public void Seal_RejectsWrongSizedKeys()
    {
        Should.Throw<ArgumentException>(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId)));
    }
}
