using DodoSSH.Contracts;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;
using static DodoSSH.Client.Storage.Tests.CacheHarness;

namespace DodoSSH.Client.Storage.Tests;

/// <summary>
/// The item mirror, the vault list, the sync cursor, and the conflict log.
/// </summary>
public sealed class CacheStoreTests : IAsyncLifetime
{
    private CacheHarness harness = null!;

    /// <inheritdoc />
    public async ValueTask InitializeAsync() => harness = await CreateAsync();

    /// <inheritdoc />
    public ValueTask DisposeAsync()
    {
        harness.Dispose();
        return ValueTask.CompletedTask;
    }

    // ---- Items ----

    [Fact]
    public async Task AnItem_RoundTripsItsCiphertextByteForByte()
    {
        // Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but
        // would throw away the ability to notice the server handing back bytes it should not have.
        var entityId = Guid.CreateVersion7();
        var item = Item(entityId, version: 4, changeSequence: 17, seed: 3);

        await harness.Items.SaveAsync(item, Token);

        var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);

        read.ShouldNotBeNull();
        read.Version.ShouldBe(4);
        read.ChangeSequence.ShouldBe(17);
        read.Payload.ShouldNotBeNull();
        read.Payload.Envelope.ShouldBe(item.Payload!.Envelope);
        read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey);
        read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId);
        read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration);
        read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion);
    }

    [Fact]
    public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase()
    {
        // The server has to hold a relay-enabled host's address in the clear because it resolves it.
        // This machine already holds the key that opens the payload, so leaving the address readable in
        // a file that ends up in a backup buys nothing.
        var entityId = Guid.CreateVersion7();
        var fields = new SyncPlaintextFields(
            RelayEnabled: true, Hostname: "bastion.internal", Port: 2222);

        await harness.Items.SaveAsync(Item(entityId, fields: fields), Token);

        var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
        read!.Fields.ShouldBe(fields);

        var stored = await ReadRawFieldsAsync(entityId);
        stored.ShouldNotBeNull();

        System.Text.Encoding.UTF8.GetString(stored)
            .Contains("bastion.internal", StringComparison.Ordinal)
            .ShouldBeFalse("the hostname is stored in the clear");
    }

    [Fact]
    public async Task ACacheRecord_CannotBeMovedToAnotherRow()
    {
        // Why the record is bound to its own row rather than only to the user. Swapping two rows would
        // otherwise point one host's connection at another host's address.
        var mine = Guid.CreateVersion7();
        var other = Guid.CreateVersion7();

        var sealedFields = harness.Protector.Protect(
            CryptoSpec.AadResourceType.Host,
            mine,
            PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22)));

        harness.Protector
            .TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields)
            .ShouldBeNull();

        harness.Protector
            .TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields)
            .ShouldBeNull();

        harness.Protector
            .TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields)
            .ShouldNotBeNull();
    }

    [Fact]
    public async Task ARecordSealedByAnotherIdentity_DoesNotOpen()
    {
        // Another *identity*, not another passphrase, and the distinction is the point. The cache key now
        // derives from the secret bundle, so changing a passphrase deliberately keeps the cache readable —
        // UserSecretBundleTests.TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be
        // unreadable is another user's cache, and that is what a second harness is.
        var entityId = Guid.CreateVersion7();

        using var stranger = await CreateAsync();

        var sealedFields = stranger.Protector.Protect(
            CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);

        harness.Protector
            .TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields)
            .ShouldBeNull();
    }

    [Fact]
    public async Task SavingTwice_ReplacesRatherThanDuplicates()
    {
        var entityId = Guid.CreateVersion7();

        await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token);
        await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token);

        var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token);

        items.ShouldHaveSingleItem().Version.ShouldBe(2);
    }

    [Fact]
    public async Task ATombstone_IsHiddenFromTheListButStillFindable()
    {
        // The interface must not show a deleted host. The sync engine must still be able to tell a
        // deleted item from one it has never seen — a row that simply vanished is indistinguishable
        // from the latter, and would silently reappear.
        var entityId = Guid.CreateVersion7();

        await harness.Items.SaveAsync(Item(entityId, version: 1), Token);
        await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token);

        (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty();

        var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
        withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue();

        var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
        found.ShouldNotBeNull();
        found.IsDeleted.ShouldBeTrue();
        found.Payload.ShouldBeNull();
    }

    [Fact]
    public async Task CollectingTombstones_LeavesLiveItemsAlone()
    {
        var live = Guid.CreateVersion7();
        var dead = Guid.CreateVersion7();

        await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token);
        await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token);

        var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100);
        var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token);

        collected.ShouldBe(1);

        var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
        remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live);
    }

    // ---- Vaults ----

    [Fact]
    public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves()
    {
        var keep = Guid.CreateVersion7();
        var drop = Guid.CreateVersion7();

        await harness.Vaults.ReplaceAllAsync(
            [Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token);

        await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token);

        var vaults = await harness.Vaults.ListAsync(Token);
        var only = vaults.ShouldHaveSingleItem();

        only.VaultId.ShouldBe(keep);
        only.Name.ShouldBe("Renamed");
        only.KeyGeneration.ShouldBe(2u);
        (await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull();
    }

    [Fact]
    public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt()
    {
        // Without this, an offline start could unlock the identity bundle and still not open a single
        // item.
        var vaultId = Guid.CreateVersion7();
        byte[] wrapped = [9, 9, 9, 9];

        await harness.Vaults.ReplaceAllAsync(
            [Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token);

        var read = await harness.Vaults.FindAsync(vaultId, Token);
        read!.WrappedVaultKey.ShouldBe(wrapped);
    }

    [Fact]
    public async Task HidingAVault_SurvivesTheNextVaultListFromTheServer()
    {
        // The whole reason the flag is a column here rather than a field on StoredVault. /me is fetched
        // once a minute; a refresh that carried this preference along would un-hide every vault within
        // the minute, and the user would never work out what kept switching them back on.
        var vaultId = Guid.CreateVersion7();

        await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
        await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);

        await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops renamed", 2)], Token);

        (await harness.Vaults.ListHiddenAsync(Token)).ShouldBe([vaultId]);
    }

    [Fact]
    public async Task TheServersVaultList_NeverHidesAVaultByItself()
    {
        // The other direction, and worth its own test: the server knows nothing about which vaults this
        // machine is showing, so no answer it gives may switch one off.
        var vaultId = Guid.CreateVersion7();

        await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);

        (await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
    }

    [Fact]
    public async Task HidingAVaultThatIsNoLongerCached_DoesNothing()
    {
        // A grant withdrawn between the click and the write. There is nothing left to record a reading
        // preference about, and the vault is already off every list the preference would have applied to.
        await Should.NotThrowAsync(
            () => harness.Vaults.SetHiddenAsync(Guid.CreateVersion7(), hidden: true, Token));

        (await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
    }

    [Fact]
    public async Task AVaultThatLosesItsGrantAndGetsItBack_ComesBackShowing()
    {
        // Absence from the server's list means access was lost, and the row goes with it. Being re-granted
        // is a new vault as far as this machine is concerned, and a new vault is shown.
        var vaultId = Guid.CreateVersion7();

        await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
        await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);

        await harness.Vaults.ReplaceAllAsync([], Token);
        await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);

        (await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
    }

    // ---- Unlock material ----

    [Fact]
    public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt()
    {
        // The offline unlock story, asserted rather than assumed: a fresh store over the same database
        // reads back the salt and the wrapped bundle with no network involved.
        var material = Material();

        await harness.Unlock.SaveAsync(material, Token);

        var reader = new UnlockStore(harness.Factory, TimeProvider.System);
        var read = await reader.ReadAsync(Token);

        read.ShouldNotBeNull();
        read.UserId.ShouldBe(material.UserId);
        read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
        read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
        read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes);
        read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes);
    }

    [Fact]
    public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow()
    {
        await harness.Unlock.SaveAsync(Material(), Token);
        await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token);

        var read = await harness.Unlock.ReadAsync(Token);
        read!.Email.ShouldBe("changed@example.com");

        (await CountUnlockRowsAsync()).ShouldBe(1);
    }

    [Fact]
    public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn()
    {
        // Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one
        // user's items into another's vault list.
        await harness.Unlock.SaveAsync(Material(), Token);

        var other = Material() with { UserId = Guid.CreateVersion7() };

        await Should.ThrowAsync<CacheIdentityMismatchException>(
            async () => await harness.Unlock.SaveAsync(other, Token));
    }

    // ---- Sync state ----

    [Fact]
    public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning()
    {
        // Not an error. A null cursor is exactly right for a vault this client has not synced, and is
        // also the recovery path for a cache that had to be discarded.
        var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token);

        state.Cursor.ShouldBeNull();
        state.KeyGeneration.ShouldBe(0u);
    }

    [Fact]
    public async Task TheCursor_IsStoredVerbatim()
    {
        // Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position
        // the server never granted; storing it untouched is the only correct handling.
        const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl";

        await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token);

        var read = await harness.SyncState.ReadAsync(VaultId, Token);
        read.Cursor.ShouldBe(Cursor);
        read.KeyGeneration.ShouldBe(3u);
    }

    [Fact]
    public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox()
    {
        // Local unpushed changes are the only copy of the user's work. Clearing them along with the
        // cache would turn a recoverable cache problem into lost work.
        var entityId = Guid.CreateVersion7();

        await harness.Items.SaveAsync(Item(entityId), Token);
        await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
        await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token);

        await harness.SyncState.ResetAsync(VaultId, Token);

        (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty();
        (await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull();
        (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
    }

    // ---- Conflicts ----

    [Fact]
    public async Task AConflictDetail_RoundTripsAndIsSealedAtRest()
    {
        // This is the one place the cache holds decrypted vault content on purpose: the value a merge
        // displaced. It has to be readable to be useful, and it is as sensitive as the item it came
        // from.
        var entityId = Guid.CreateVersion7();
        var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}""");

        var id = await harness.Conflicts.RecordAsync(
            VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token);

        var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
        listed.Id.ShouldBe(id);
        listed.Kind.ShouldBe(ConflictKind.FieldOverridden);
        listed.Detail.ShouldBe(detail);

        var raw = await ReadRawConflictDetailAsync(id);
        System.Text.Encoding.UTF8.GetString(raw!)
            .Contains("my secret", StringComparison.Ordinal)
            .ShouldBeFalse("the discarded value is stored in the clear");
    }

    [Fact]
    public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue()
    {
        // Someone who dismisses a warning and realises a minute later that they wanted the other value
        // should still be able to get it.
        var id = await harness.Conflicts.RecordAsync(
            VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden,
            new byte[] { 1, 2, 3 }, Token);

        (await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue();

        (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty();

        var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem();
        all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
    }

    /// <summary>
    /// A conflict can be recorded against any kind of item, not only the kinds that were here first.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Every other test in this section uses <see cref="SyncEntityType.Host"/>, and that is how three item
    /// kinds shipped with no way to record a conflict at all: the two logs and the buckets were added to both
    /// enums, to the reconciler registry and to the cipher pinning, while <c>AadResourceTypes.For</c> — which
    /// <c>ConflictStore.RecordAsync</c> calls unconditionally — kept throwing for them. The two other callers
    /// of that mapping only reach it when an item carries plaintext fields, which none of the three does, so
    /// nothing else so much as touched the gap.
    /// </para>
    /// <para>
    /// A theory over the types rather than one more <c>Host</c> case, because the failure was never about
    /// conflicts and always about which types the layer below had been taught. Recording is asserted through
    /// a read-back rather than by "it did not throw": an arm returning the wrong resource type would seal
    /// under one AAD and open under another, which is a null detail rather than an exception.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData(SyncEntityType.Host)]
    [InlineData(SyncEntityType.HostGroup)]
    [InlineData(SyncEntityType.Snippet)]
    [InlineData(SyncEntityType.ConnectionLogEntry)]
    [InlineData(SyncEntityType.ActivityLogEntry)]
    [InlineData(SyncEntityType.ObjectStore)]
    public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
    {
        var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");

        var id = await harness.Conflicts.RecordAsync(
            VaultId, entityType, Guid.CreateVersion7(), ConflictKind.FieldOverridden, detail, Token);

        var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();

        listed.Id.ShouldBe(id);
        listed.EntityType.ShouldBe(entityType);
        listed.Detail.ShouldBe(
            detail,
            "an empty detail here means the record was sealed under one resource type and opened under "
            + "another, which ListAsync reports as nothing rather than as a failure");
    }

    [Fact]
    public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
    {
        var id = await harness.Conflicts.RecordAsync(
            VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable,
            new byte[] { 1 }, Token);

        (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse();

        await harness.Conflicts.AcknowledgeAsync(id, Token);
        (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue();
    }

    // ---- Helpers ----

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) =>
        new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31,
            WrappedVaultKey: [1, 2, 3], RekeyRequired: false);

    private static StoredUnlockMaterial Material() =>
        new(
            "https://dodossh.example",
            UserId,
            "https://idp.example",
            "alice",
            "alice@example.com",
            "Alice",
            KeyGeneration: 1,
            WrappedPrivateKey: [4, 5, 6, 7],
            new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1),
            DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));

    private async Task<byte[]?> ReadRawFieldsAsync(Guid entityId)
    {
        var context = harness.Factory.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        return await context.Set<CachedItemRow>()
            .Where(row => row.EntityId == entityId)
            .Select(row => row.ProtectedFields)
            .SingleAsync(Token);
    }

    private async Task<byte[]?> ReadRawConflictDetailAsync(Guid id)
    {
        var context = harness.Factory.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        return await context.Set<ConflictRow>()
            .Where(row => row.Id == id)
            .Select(row => row.Detail)
            .SingleAsync(Token);
    }

    private async Task<int> CountUnlockRowsAsync()
    {
        var context = harness.Factory.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        return await context.Set<UnlockMaterialRow>().CountAsync(Token);
    }
}
