using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// Holding more than one generation of a vault's key at once.
/// </summary>
/// <remarks>
/// A rotation does not re-encrypt what is already stored, so a rotated vault holds items sealed under
/// two or three different keys and every read has to choose the one the item names. These are the tests
/// that say so: the alternative — one key per vault — reads a rotated vault's whole history as corrupt,
/// which is a data-loss bug that looks exactly like a decryption failure.
/// </remarks>
public sealed class VaultKeyringTests : IDisposable
{
    private static readonly Guid VaultId = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid HostId = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");

    private readonly UserSecretBundle bundle =
        UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));

    /// <inheritdoc />
    public void Dispose() => bundle.Dispose();

    [Fact]
    public void AVaultWithNoHistory_HoldsExactlyOneGeneration()
    {
        var (vault, _) = Rotated(currentGeneration: 1);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        keyring.GenerationsHeld(VaultId).ShouldBe([1u]);
        keyring.CanRead(VaultId).ShouldBeTrue();
        keyring.Unopened.ShouldBeEmpty();
    }

    [Fact]
    public void ARotatedVault_OpensEveryGenerationItWasGranted()
    {
        var (vault, keys) = Rotated(currentGeneration: 3);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u, 3u]);

        foreach (var (generation, key) in keys)
        {
            keyring.TryGetAt(VaultId, generation, out var held).ShouldBeTrue();
            held.ToArray().ShouldBe(key);
        }
    }

    /// <remarks>
    /// Writes go under the newest key, always. Sealing a new item under a superseded one would produce
    /// an item that nobody who joined after the rotation can read, and the author would have no way to
    /// tell — their own keyring still holds the old key.
    /// </remarks>
    [Fact]
    public void TheCurrentGeneration_IsTheNewestOneAndNotTheOldest()
    {
        var (vault, keys) = Rotated(currentGeneration: 3);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        keyring.TryGet(VaultId, out var current, out var generation).ShouldBeTrue();

        generation.ShouldBe(3u);
        current.ToArray().ShouldBe(keys[3u]);
    }

    /// <remarks>
    /// The state a member is left in between somebody rotating a vault and somebody wrapping the new key
    /// to them. They can still read what was there — their old grants stand — and they must not be able
    /// to write, because anything they wrote would be sealed under a key the vault has moved past.
    /// </remarks>
    [Fact]
    public void AMemberAwaitingTheNewKey_ReadsTheHistoryAndCannotWrite()
    {
        var (vault, keys) = Rotated(currentGeneration: 2);

        var awaiting = vault with { WrappedVaultKey = null };

        using var keyring = VaultKeyring.Open(bundle, [awaiting]);

        keyring.CanRead(VaultId).ShouldBeFalse();
        keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();
        keyring.Unopened.ShouldBe([VaultId]);

        keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
        first.ToArray().ShouldBe(keys[1u]);
    }

    /// <remarks>
    /// What the rotating client itself does: it generates the next key, the server accepts it, and the
    /// keyring takes it without losing the one the vault's existing items are sealed under.
    /// </remarks>
    [Fact]
    public void AdoptingANewGeneration_KeepsTheOneBeforeIt()
    {
        var (vault, keys) = Rotated(currentGeneration: 1);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        var next = VaultKeys.Create();

        keyring.Adopt(VaultId, next, keyGeneration: 2);

        keyring.TryGet(VaultId, out _, out var generation).ShouldBeTrue();
        generation.ShouldBe(2u);

        keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u]);
        keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
        first.ToArray().ShouldBe(keys[1u]);
    }

    /// <remarks>
    /// The whole point, at the layer that pays for it: an item written before a rotation still opens
    /// after one. Sealed and opened through the real cipher, so the AAD's generation binding is
    /// exercised rather than assumed.
    /// </remarks>
    [Fact]
    public void AnItemSealedBeforeARotation_StillOpensAfterIt()
    {
        var (vault, _) = Rotated(currentGeneration: 1);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();

        var host = new HostSecret { Label = "web-01", Hostname = "web-01.example", Username = "ops" };
        var payload = HostCipher.Seal(host, vaultKey.Span, HostId, generation, itemVersion: 1);

        keyring.Adopt(VaultId, VaultKeys.Create(), keyGeneration: 2);

        // Chosen by the payload's own generation, which is what every read path does.
        keyring.TryGetAt(VaultId, payload.KeyGeneration, out var itemKey).ShouldBeTrue();

        HostCipher.TryOpen(payload, itemKey.Span, HostId, itemVersion: 1)
            .ShouldNotBeNull()
            .Host.Label.ShouldBe("web-01");

        // And the current key does not open it, which is why holding only that one would be a loss.
        keyring.TryGet(VaultId, out var newest, out _).ShouldBeTrue();
        HostCipher.TryOpen(payload, newest.Span, HostId, itemVersion: 1).ShouldBeNull();
    }

    /// <remarks>
    /// What another client rotating the vault looks like from here: the key this session holds is
    /// suddenly the previous generation. It goes on opening what it wrote, and it must stop being the
    /// one new items are sealed under — an item written under a superseded key is readable to its
    /// author and to nobody else, with nothing to show that anything went wrong.
    /// </remarks>
    [Fact]
    public void AVaultRotatedElsewhere_StopsBeingWritableAndStaysReadable()
    {
        var (vault, keys) = Rotated(currentGeneration: 1);

        using var keyring = VaultKeyring.Open(bundle, [vault]);

        keyring.CanRead(VaultId).ShouldBeTrue();

        // What RefreshVaultsAsync does when the server reports a generation this session has no grant
        // for: the admit fails, and the vault is marked unreadable.
        keyring.MarkUnreadable(VaultId);

        keyring.CanRead(VaultId).ShouldBeFalse();
        keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();

        keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
        first.ToArray().ShouldBe(keys[1u]);
    }

    /// <remarks>
    /// A wrap that will not open is one unusable grant, not a broken vault. Skipping it leaves the
    /// generations that did open readable; refusing them all would take the whole vault down over one
    /// bad row.
    /// </remarks>
    [Fact]
    public void AnUnopenableHistoricWrap_IsSkippedRatherThanFatal()
    {
        var (vault, _) = Rotated(currentGeneration: 2);

        var corrupted = vault with
        {
            PriorKeyWraps = [new VaultKeyWrap(1, new byte[110])],
        };

        using var keyring = VaultKeyring.Open(bundle, [corrupted]);

        keyring.CanRead(VaultId).ShouldBeTrue();
        keyring.GenerationsHeld(VaultId).ShouldBe([2u]);
        keyring.TryGetAt(VaultId, 1, out _).ShouldBeFalse();
    }

    /// <summary>
    /// A vault at <paramref name="currentGeneration"/>, with a distinct key wrapped for every generation
    /// up to it.
    /// </summary>
    private (StoredVault Vault, Dictionary<uint, byte[]> Keys) Rotated(uint currentGeneration)
    {
        var keys = new Dictionary<uint, byte[]>();
        var prior = new List<VaultKeyWrap>();
        byte[]? current = null;

        for (var generation = 1u; generation <= currentGeneration; generation++)
        {
            var key = VaultKeys.Create();
            var wrapped = VaultKeys.WrapTo(key, bundle.EncryptionPublicKey, VaultId, generation);

            keys[generation] = key;

            if (generation == currentGeneration)
            {
                current = wrapped;
            }
            else
            {
                prior.Add(new VaultKeyWrap(generation, wrapped));
            }
        }

        var vault = new StoredVault(
            VaultId,
            "Platform secrets",
            IsPersonal: false,
            TeamId: Guid.CreateVersion7(),
            currentGeneration,
            Permissions: 31,
            current,
            RekeyRequired: false,
            prior);

        return (vault, keys);
    }
}
