using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// Each item type must be sealed under its own AAD resource type, and the two enums that name item types
/// deliberately do not agree.
/// </summary>
/// <remarks>
/// <para>
/// <c>SyncEntityType</c> lists only syncable items, so <c>Host</c> is 1 and <c>SshKey</c> is 3.
/// <c>CryptoSpec.AadResourceType</c> also covers users, devices and vaults, so the same two are 4 and 6. A
/// cipher written by copying its neighbour and casting the wire type would therefore seal a private key as
/// if it were a vault — encrypting cleanly, decrypting cleanly on the machine that wrote it, and violating
/// docs/crypto.md in a way that surfaces only when another implementation reads the item.
/// </para>
/// <para>
/// These tests are cheap and the alternative is a comment. The payload's AAD is frozen, so getting this
/// wrong is not something a later release can quietly correct: only clients can re-encrypt, and they can
/// only do it if they can still open what is there.
/// </para>
/// </remarks>
public sealed class AadResourceTypeTests
{
    /// <remarks>
    /// The pairing stated as a table. If <c>AadResourceType</c> is ever renumbered, this is what says so —
    /// loudly, and before anything is written under the new numbers.
    /// </remarks>
    [Theory]
    [InlineData(SyncEntityType.Host, CryptoSpec.AadResourceType.Host)]
    [InlineData(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential)]
    [InlineData(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey)]
    [InlineData(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup)]
    [InlineData(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag)]
    [InlineData(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet)]
    [InlineData(SyncEntityType.PortForward, CryptoSpec.AadResourceType.PortForward)]
    [InlineData(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey)]
    [InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
    [InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
    [InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
    public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
        SyncEntityType wire,
        CryptoSpec.AadResourceType resource)
    {
        Enum.GetName(wire).ShouldBe(Enum.GetName(resource));

        // The point of the whole file: same name, different number. A test asserting equality here would be
        // asserting the bug.
        ((int)wire).ShouldNotBe(
            (int)resource,
            $"{wire} happens to share a value with its resource type, which makes a cast look correct. "
            + "Either the enums were renumbered or this pairing needs re-checking by hand.");
    }

    /// <summary>
    /// The specified pairing of wire type to AAD resource type, stated out of band, one row per cipher.
    /// </summary>
    /// <remarks>
    /// The single source for both tests below: what each cipher must use, and which types must have a cipher
    /// pinned at all. Adding an item type without adding a row here fails
    /// <see cref="EverySynchronisedType_HasItsCipherPinnedHere"/>.
    /// </remarks>
    private static readonly (SyncEntityType Wire, CryptoSpec.AadResourceType Resource)[] PinnedPairs =
    [
        (SyncEntityType.Host, CryptoSpec.AadResourceType.Host),
        (SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey),
        (SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
        (SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
        (SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup),
        (SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag),
        (SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet),
        (SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
        (SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
        (SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
    ];

    public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
    {
        get
        {
            var data = new TheoryData<SyncEntityType, CryptoSpec.AadResourceType>();

            foreach (var (wire, resource) in PinnedPairs)
            {
                data.Add(wire, resource);
            }

            return data;
        }
    }

    /// <remarks>
    /// <para>
    /// Opened <em>independently</em>, through the low-level <c>ItemKeys</c> API with the resource type this
    /// table names rather than the one the cipher holds. That is the whole point, and it is the property two
    /// earlier versions of this file lacked: checking that a key payload does not open as a host is true
    /// however both ciphers are misconfigured, because <c>Seal</c> and <c>TryOpen</c> share one constant. A
    /// test that compares an implementation against itself cannot catch a self-consistent mistake.
    /// </para>
    /// <para>
    /// Written as a table over every cipher, not one test per cipher, because the same hole was found three
    /// times — twice by mutation testing after the fact. Pointing <c>CredentialCipher</c> at
    /// <c>AadResourceType.Vault</c> passed the entire suite until this existed.
    /// </para>
    /// </remarks>
    [Theory]
    [MemberData(nameof(Pinned))]
    public void EveryCipher_SealsUnderTheResourceTypeTheSpecificationNames(
        SyncEntityType wire,
        CryptoSpec.AadResourceType resource)
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();
        const uint Generation = 1;
        const uint Version = 1;

        var payload = SealSample(wire, vaultKey, entityId, Generation, (int)Version);

        var dataKey = ItemKeys.TryUnwrapDataKey(
            vaultKey, payload.WrappedDataKey, resource, entityId, Generation, Version);

        dataKey.ShouldNotBeNull(
            $"The {wire} cipher must wrap its data key under AadResourceType.{resource}; a null here means "
            + "it used some other resource type, which round-trips fine and violates docs/crypto.md.");

        ItemKeys.TryOpenPayload(
            dataKey,
            payload.Envelope,
            resource,
            entityId,
            payload.DataKeyId,
            Generation,
            Version).ShouldNotBeNull("and it must seal the envelope under the same resource type.");
    }

    /// <remarks>
    /// The guard that makes the table above self-maintaining. A fourth item type would otherwise sync,
    /// encrypt and merge correctly while being sealed under any resource type at all, and nothing would say
    /// so until another implementation refused the item — by which point the AAD is frozen into stored
    /// ciphertext and only clients can re-encrypt it.
    /// </remarks>
    [Fact]
    public void EverySynchronisedType_HasItsCipherPinnedHere()
    {
        PinnedPairs.Select(pair => pair.Wire)
            .ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
    }

    /// <summary>
    /// The same pairing, made a second time in the storage layer, and every type must be in it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>AadResourceTypes.For</c> is the cache's copy of the table above: the ciphers seal an item for the
    /// <em>server</em>, and this seals the two things the local cache holds in the clear — a relay host's
    /// address, and the values a merge overrode. A type missing from it throws rather than mis-seals, which
    /// sounds like the safe failure and is not: <c>ConflictStore.RecordAsync</c> calls it unconditionally, so
    /// the exception lands on the path that records what a merge discarded.
    /// </para>
    /// <para>
    /// This is written after finding three types missing from it — <c>ConnectionLogEntry</c>,
    /// <c>ActivityLogEntry</c> and <c>ObjectStore</c> went two shipping phases without an arm, because the
    /// only unconditional caller is one a test suite reaches solely by causing a real merge conflict. Asserted
    /// over the whole wire enum rather than over <c>ItemKinds.SyncedTypes</c>, which is the stronger claim and
    /// the one the switch actually makes: the two reserved association types have arms too.
    /// </para>
    /// </remarks>
    [Fact]
    public void EverySyncableType_HasAnArmInTheStorageMapping()
    {
        var missing = new List<SyncEntityType>();

        foreach (var wire in Enum.GetValues<SyncEntityType>())
        {
            if (wire == SyncEntityType.Unspecified)
            {
                continue;
            }

            CryptoSpec.AadResourceType resource;

            try
            {
                resource = AadResourceTypes.For(wire);
            }
            catch (ArgumentOutOfRangeException)
            {
                missing.Add(wire);
                continue;
            }

            // Same name, as the cipher table demands — a wrong-but-present arm is the failure this half
            // would otherwise wave through.
            Enum.GetName(resource).ShouldBe(
                Enum.GetName(wire),
                $"AadResourceTypes.For({wire}) returns {resource}, which binds this type's cache records "
                + "to another type's resource.");
        }

        missing.ShouldBeEmpty(
            "every syncable type needs an arm in AadResourceTypes.For, or a conflict recorded against one "
            + "of these throws instead of being written — and the conflict log is what justifies the merge "
            + "picking a winner.");
    }

    private static EncryptedPayload SealSample(
        SyncEntityType wire,
        byte[] vaultKey,
        Guid entityId,
        uint generation,
        int version) => wire switch
        {
            SyncEntityType.Host => HostCipher.Seal(
                new HostSecret { Label = "prod-db", Hostname = "db.internal" },
                vaultKey,
                entityId,
                generation,
                version),

            SyncEntityType.SshKey => SshKeyCipher.Seal(
                NewKey(), vaultKey, entityId, generation, version),

            SyncEntityType.Credential => CredentialCipher.Seal(
                NewCredential(), vaultKey, entityId, generation, version),

            SyncEntityType.KnownHostKey => KnownHostKeyCipher.Seal(
                NewKnownHost(), vaultKey, entityId, generation, version),

            SyncEntityType.HostGroup => HostGroupCipher.Seal(
                NewGroup(), vaultKey, entityId, generation, version),

            // Tag is the row this file was written for. It is 5 on the wire and 8 in the crypto enum, and 5
            // in the crypto enum is Credential — so a TagCipher written by casting its wire type would seal
            // every tag in the vault under the resource type for a password, and only this test would say so.
            SyncEntityType.Tag => TagCipher.Seal(
                NewTag(), vaultKey, entityId, generation, version),

            SyncEntityType.Snippet => SnippetCipher.Seal(
                NewSnippet(), vaultKey, entityId, generation, version),

            SyncEntityType.ConnectionLogEntry => ConnectionLogCipher.Seal(
                NewConnectionEntry(), vaultKey, entityId, generation, version),

            SyncEntityType.ActivityLogEntry => ActivityLogCipher.Seal(
                NewActivityEntry(), vaultKey, entityId, generation, version),

            SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
                NewBucket(), vaultKey, entityId, generation, version),

            _ => throw new ArgumentOutOfRangeException(
                nameof(wire),
                wire,
                "No sample exists for this item type. Add one when adding the type, or the pairing above "
                + "cannot be checked."),
        };

    [Fact]
    public void ACredentialPayload_OpensAsNothingElse()
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var sealed_ = CredentialCipher.Seal(
            NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);

        HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
    }

    [Fact]
    public void ACredentialSealedAtOneVersion_DoesNotOpenAtAnother()
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var payload = CredentialCipher.Seal(
            NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);

        CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
    }

    [Fact]
    public void AKeyPayload_DoesNotOpenAsAHost()
    {
        // Weaker than the two above and kept anyway: it is the property a reader expects to see, and it
        // covers the case where one cipher is corrected and the other is not.
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var sealedKey = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);

        HostCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        SshKeyCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
    }

    [Fact]
    public void AHostPayload_DoesNotOpenAsAKey()
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" };
        var sealedHost = HostCipher.Seal(host, vaultKey, entityId, keyGeneration: 1, itemVersion: 1);

        SshKeyCipher.TryOpen(sealedHost, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
    }

    [Fact]
    public void AKey_RoundTripsThroughTheCipher()
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();
        var key = NewKey();

        var payload = SshKeyCipher.Seal(key, vaultKey, entityId, keyGeneration: 1, itemVersion: 3);
        var opened = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3);

        opened.ShouldNotBeNull();
        opened.Key.ShouldBe(key);
        opened.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion);
        opened.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void AKeySealedAtOneVersion_DoesNotOpenAtAnother()
    {
        // The item version is in the AAD, which is what stops a server rolling a row back to earlier
        // ciphertext. Asserted for keys as well as hosts because it is the property most easily lost by
        // copying a cipher and adjusting the wrong argument.
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);

        SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
    }

    [Fact]
    public void AKnownHostPayload_OpensAsNothingElse()
    {
        // The pairing table above is the load-bearing check; this is the cross-type refusal a reader expects
        // to see spelled out, and it is the one that would notice a second cipher being pointed at
        // AadResourceType.KnownHostKey by mistake.
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var sealed_ = KnownHostKeyCipher.Seal(
            NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);

        HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
        KnownHostKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
    }

    [Fact]
    public void AKnownHostSealedAtOneVersion_DoesNotOpenAtAnother()
    {
        var vaultKey = RandomNumberGenerator.GetBytes(32);

        var entityId = Guid.CreateVersion7();

        var payload = KnownHostKeyCipher.Seal(
            NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);

        KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
    }

    private static ConnectionLogSecret NewConnectionEntry() => new()
    {
        HostLabel = "prod-db",
        Address = "deploy@db.internal:22",
        HostId = Guid.CreateVersion7(),
        StartedAt = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
        Duration = TimeSpan.FromMinutes(11),
        Outcome = ConnectionOutcome.Closed,
        DeviceName = "laptop",
        ActorUserId = Guid.CreateVersion7(),
    };

    private static ActivityLogSecret NewActivityEntry() => new()
    {
        ItemKind = nameof(SyncEntityType.Host),
        ItemId = Guid.CreateVersion7(),
        ItemLabel = "prod-db",
        Operation = ActivityOperation.Updated,
        ChangedFields = "Port, Username",
        At = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
        DeviceName = "laptop",
        ActorUserId = Guid.CreateVersion7(),
    };

    private static ObjectStoreSecret NewBucket() => new()
    {
        Label = "backups",
        Bucket = "dodossh-backups",
        AccessKeyId = "AKIAEXAMPLE",
        SecretAccessKey = "an example secret access key",
        Region = "eu-west-1",
    };

    private static HostGroupSecret NewGroup() => new() { Label = "production" };

    private static TagSecret NewTag() => new() { Label = "pci" };

    private static SnippetSecret NewSnippet() => new()
    {
        Label = "restart the api",
        Command = "sudo systemctl restart dodossh-api",
        Notes = "checked with the on-call rota first",
    };

    private static KnownHostSecret NewKnownHost() => new()
    {
        Host = "db.internal",
        Port = 22,
        Algorithm = "ssh-ed25519",
        Fingerprint = "SHA256:5cWZ1Zc2ZmEXAMPLEfingerprintvalue0123456789a",
    };

    private static CredentialSecret NewCredential() => new()
    {
        Label = "db-login",
        Password = "hunter2",
        Username = "postgres",
        Notes = "used by CI",
    };

    private static SshKeySecret NewKey() => new()
    {
        Label = "deploy",
        PrivateKeyPem = "-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----",
        Passphrase = "a passphrase",
        PublicKey = "ssh-ed25519 AAAAC3Nz deploy@example",
        Notes = "used by CI",
    };
}
