using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.Tests.SyncHarness;

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// Credentials through the two-machine harness.
/// </summary>
/// <remarks>
/// Shorter still than <see cref="SshKeySyncTests"/>, and that is the payoff of the shared reconciler: the six
/// collision outcomes are one implementation and are already exercised. What is left to check per type is its
/// cipher, what it tells the server, that its items cannot be confused with another type's, and that the one
/// thing which must never be logged is not logged.
/// </remarks>
public sealed class CredentialSyncTests : IAsyncLifetime
{
    private SyncHarness harness = null!;

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

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

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

    [Fact]
    public async Task ACredentialCreatedOnOneMachine_ReachesTheOther()
    {
        var entityId = await harness.First.CreateCredentialAsync(
            Credential("prod-db", password: "hunter2", username: "postgres", notes: "rotate in June"));

        await harness.SettleAsync();

        var seen = await harness.Second.FindCredentialAsync(entityId);

        seen.Secret.Label.ShouldBe("prod-db");
        seen.Secret.Password.ShouldBe("hunter2");
        seen.Secret.Username.ShouldBe("postgres");
        seen.Secret.Notes.ShouldBe("rotate in June");
        seen.HasUnsyncedChanges.ShouldBeFalse();
    }

    [Fact]
    public async Task ThePull_AsksForAllThreeTypes()
    {
        await harness.First.SyncAsync();

        var asked = harness.Server.LastPullTypes.ShouldNotBeNull();

        asked.ShouldContain(SyncEntityType.Host);
        asked.ShouldContain(SyncEntityType.SshKey);
        asked.ShouldContain(SyncEntityType.Credential);
    }

    [Fact]
    public async Task ACredentialHandsTheServerNothingInPlaintext()
    {
        var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));

        var queued = await harness.First.Outbox
            .FindAsync(VaultId, SyncEntityType.Credential, entityId, Token);

        queued.ShouldNotBeNull();
        queued.Fields.ShouldBeNull("nothing about a password is safe to hold in the clear");

        await harness.SettleAsync();

        var row = harness.Server.Find(entityId, SyncEntityType.Credential).ShouldNotBeNull();

        row.Fields.RelayEnabled.ShouldBeFalse();
        row.Fields.Hostname.ShouldBeNull();
        row.Fields.PublicKeyFingerprint.ShouldBeNull();
    }

    [Fact]
    public async Task ThreeTypesSharingOneId_AreThreeItems()
    {
        // The cache keys on the type as well as the id, and each payload's AAD binds a different resource
        // type — two defences, independently. Arranged on the server because the repositories mint UUIDv7s
        // and would never collide.
        var sharedId = Guid.CreateVersion7();

        harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();

        harness.Server.ExternalUpsert(
            sharedId,
            HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1),
            new SyncPlaintextFields(),
            SyncEntityType.Host);

        harness.Server.ExternalUpsert(
            sharedId,
            SshKeyCipher.Seal(Key("deploy"), vaultKey.Span, sharedId, generation, itemVersion: 1),
            null,
            SyncEntityType.SshKey);

        harness.Server.ExternalUpsert(
            sharedId,
            CredentialCipher.Seal(
                Credential("db-login"), vaultKey.Span, sharedId, generation, itemVersion: 1),
            null,
            SyncEntityType.Credential);

        await harness.Second.SyncAsync();

        (await harness.Second.ListAsync()).Items.ShouldHaveSingleItem()
            .Secret.Label.ShouldBe("prod-db");
        (await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem()
            .Secret.Label.ShouldBe("deploy");

        var credentials = await harness.Second.ListCredentialsAsync();

        credentials.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("db-login");
        credentials.Unreadable.ShouldBe(0);
    }

    [Fact]
    public async Task TwoMachinesEditingDifferentFields_BothSurvive()
    {
        var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
        await harness.SettleAsync();

        await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db-primary"));
        await harness.Second.UpdateCredentialAsync(
            entityId, Credential("prod-db", notes: "from the desktop"));

        await harness.SettleAsync();

        var first = (await harness.First.FindCredentialAsync(entityId)).Secret;

        first.ShouldBe((await harness.Second.FindCredentialAsync(entityId)).Secret);
        first.Label.ShouldBe("prod-db-primary");
        first.Notes.ShouldBe("from the desktop");

        (await ConflictKindsAsync()).ShouldBeEmpty();
    }

    [Fact]
    public async Task BothChangedThePassword_NeitherReachesTheConflictLog()
    {
        var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
        await harness.SettleAsync();

        await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", "LAPTOP-SECRET"));
        await harness.Second.UpdateCredentialAsync(entityId, Credential("prod-db", "DESKTOP-SECRET"));

        await harness.SettleAsync();

        (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.FieldOverridden);

        var details = await ConflictDetailsAsync();

        details.ShouldContain(
            detail => detail.Contains("Password", StringComparison.Ordinal),
            "the user still has to be told which field clashed");

        foreach (var detail in details)
        {
            detail.ShouldNotContain("LAPTOP-SECRET");
            detail.ShouldNotContain("DESKTOP-SECRET");
        }
    }

    [Fact]
    public async Task ACredentialEditedElsewhereAfterBeingDeletedHere_IsCalledACredential()
    {
        var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
        await harness.SettleAsync();

        await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", notes: "still in use"));
        await harness.Second.Credentials.DeleteAsync(VaultId, entityId, Token);

        await harness.SettleAsync();

        (await harness.First.FindCredentialAsync(entityId)).Secret.Notes.ShouldBe("still in use");

        var details = await ConflictDetailsAsync();

        details.ShouldContain(
            detail => detail.Contains("This credential was edited", StringComparison.Ordinal));

        details.ShouldNotContain(
            detail => detail.Contains("This host was edited", StringComparison.Ordinal));
    }

    private async Task<IReadOnlyList<ConflictKind>> ConflictKindsAsync()
    {
        var first = await harness.First.ConflictsAsync();
        var second = await harness.Second.ConflictsAsync();

        return [.. first.Concat(second).Select(conflict => conflict.Kind)];
    }

    private async Task<IReadOnlyList<string>> ConflictDetailsAsync()
    {
        var first = await harness.First.ConflictsAsync();
        var second = await harness.Second.ConflictsAsync();

        return
        [
            .. first.Concat(second)
                .Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)),
        ];
    }
}
