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

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// SSH keys through the same two-machine harness as hosts.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not a copy of <see cref="ConflictMatrixTests"/> with the nouns changed. The six collision
/// outcomes are decided by <c>ItemReconciler&lt;TSecret&gt;</c>, which is one implementation shared by both
/// item types, so re-asserting all of them per type would test the same code twice and grow with every type
/// added. What is tested here is what is genuinely different about a key: its cipher, its merge, the fact
/// that it hands the server nothing in plaintext, that the reconciler's messages call it a key, and that its
/// items cannot be confused with a host's.
/// </para>
/// <para>
/// The two collision cases that <em>are</em> repeated — resurrection and an abandoned delete — are here
/// because they are the two that touch key material: one re-seals it under a new id, the other decides
/// whether a private key survives a deletion.
/// </para>
/// </remarks>
public sealed class SshKeySyncTests : 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;
    }

    // ---- The uncontested paths ----

    [Fact]
    public async Task AKeyCreatedOnOneMachine_ReachesTheOther()
    {
        var entityId = await harness.First.CreateKeyAsync(
            Key("deploy", material: "LAPTOP-MATERIAL", passphrase: "hunter2", notes: "rotate in June"));

        await harness.SettleAsync();

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

        seen.Secret.Label.ShouldBe("deploy");
        seen.Secret.PrivateKeyPem.ShouldContain("LAPTOP-MATERIAL");
        seen.Secret.Passphrase.ShouldBe("hunter2");
        seen.Secret.Notes.ShouldBe("rotate in June");
        seen.HasUnsyncedChanges.ShouldBeFalse();
    }

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

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

        asked.ShouldContain(SyncEntityType.Host);
        asked.ShouldContain(
            SyncEntityType.SshKey,
            "a type the engine can reconcile but never requests would work in every unit test and never "
            + "sync");
    }

    [Fact]
    public async Task AHostAndAKeyQueuedTogether_BothGoInOnePush()
    {
        var hostId = await harness.First.CreateAsync(Host("prod-db"));
        var keyId = await harness.First.CreateKeyAsync(Key("deploy"));

        await harness.First.SyncAsync();

        harness.Server.PushCount.ShouldBe(1, "one outbox, one batch, whatever the item types in it");

        await harness.Second.SyncAsync();

        (await harness.Second.FindAsync(hostId)).Secret.Label.ShouldBe("prod-db");
        (await harness.Second.FindKeyAsync(keyId)).Secret.Label.ShouldBe("deploy");
    }

    [Fact]
    public async Task AKeyList_DoesNotShowHosts()
    {
        await harness.First.CreateAsync(Host("prod-db"));
        await harness.First.CreateKeyAsync(Key("deploy"));

        await harness.SettleAsync();

        (await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem()
            .Secret.Label.ShouldBe("deploy");

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

    // ---- What the server is told ----

    [Fact]
    public async Task AKeyHandsTheServerNothingInPlaintext()
    {
        // The public half is supplied, which is the case where a fingerprint could have been derived and
        // sent. The server has a column for one and would accept it; this client does not fill it, because a
        // fingerprint is a stable identifier for a key pair and nothing in the product reads the column.
        var entityId = await harness.First.CreateKeyAsync(
            Key("deploy", publicKey: "ssh-ed25519 AAAAC3Nz deploy@laptop"));

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

        queued.ShouldNotBeNull();
        queued.Fields.ShouldBeNull("a key sends no plaintext fields at all, not an empty set of them");

        await harness.SettleAsync();

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

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

    [Fact]
    public async Task AHostAndAKeyWithTheSameId_AreDifferentItems()
    {
        // Not reachable through the repositories, which mint UUIDv7s, so it is arranged on the server. The
        // point is that two things defend the separation independently: the item table is keyed on the type
        // as well as the id, and the payload's AAD binds a resource type — so neither payload can be opened
        // as the other even if a lookup did confuse them.
        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);

        await harness.Second.SyncAsync();

        var hosts = await harness.Second.ListAsync();
        var keys = await harness.Second.ListKeysAsync();

        hosts.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("prod-db");
        keys.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("deploy");

        hosts.Unreadable.ShouldBe(0);
        keys.Unreadable.ShouldBe(0);
    }

    // ---- Merging ----

    [Fact]
    public async Task TwoMachinesEditingDifferentFieldsOfAKey_BothSurvive()
    {
        var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "SHARED"));
        await harness.SettleAsync();

        await harness.First.UpdateKeyAsync(entityId, Key("deploy-laptop", material: "SHARED"));
        await harness.Second.UpdateKeyAsync(
            entityId, Key("deploy", material: "SHARED", notes: "from the desktop"));

        await harness.SettleAsync();

        var first = (await harness.First.FindKeyAsync(entityId)).Secret;
        var second = (await harness.Second.FindKeyAsync(entityId)).Secret;

        first.ShouldBe(second);
        first.Label.ShouldBe("deploy-laptop");
        first.Notes.ShouldBe("from the desktop");
        first.PrivateKeyPem.ShouldContain("SHARED");

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

    [Fact]
    public async Task BothReplacedTheKeyMaterial_NeitherKeyIsWrittenToTheConflictLog()
    {
        // The reason SshKeySecretMerge redacts. A host conflict records the value that lost so the user can
        // put it back; doing that with a private key would copy a secret into a log that is designed to be
        // read and is deliberately kept after acknowledgement.
        var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "ORIGINAL"));
        await harness.SettleAsync();

        await harness.First.UpdateKeyAsync(entityId, Key("deploy", material: "LAPTOP-SECRET"));
        await harness.Second.UpdateKeyAsync(entityId, Key("deploy", material: "DESKTOP-SECRET"));

        await harness.SettleAsync();

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

        var details = await ConflictDetailsAsync();

        details.ShouldContain(
            detail => detail.Contains("PrivateKeyPem", 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 BothChangedThePassphrase_ThePassphraseIsNotInTheLogEither()
    {
        var entityId = await harness.First.CreateKeyAsync(Key("deploy", passphrase: "original"));
        await harness.SettleAsync();

        await harness.First.UpdateKeyAsync(entityId, Key("deploy", passphrase: "laptop-passphrase"));
        await harness.Second.UpdateKeyAsync(entityId, Key("deploy", passphrase: "desktop-passphrase"));

        await harness.SettleAsync();

        foreach (var detail in await ConflictDetailsAsync())
        {
            detail.ShouldNotContain("laptop-passphrase");
            detail.ShouldNotContain("desktop-passphrase");
        }
    }

    // ---- Deletes, where key material can be lost ----

    [Fact]
    public async Task AKeyDeletedElsewhereWhileEditedHere_KeepsTheMaterialUnderANewName()
    {
        var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "IRREPLACEABLE"));
        await harness.SettleAsync();

        await harness.First.DeleteKeyAsync(entityId);
        await harness.Second.UpdateKeyAsync(
            entityId, Key("deploy", material: "IRREPLACEABLE", notes: "still in use"));

        await harness.SettleAsync();

        var restored = (await harness.First.ListKeysAsync()).Items.ShouldHaveSingleItem();

        restored.EntityId.ShouldNotBe(entityId);
        restored.Secret.Label.ShouldBe("deploy (restored)");
        restored.Secret.Notes.ShouldBe("still in use");
        restored.Secret.PrivateKeyPem.ShouldContain(
            "IRREPLACEABLE", Case.Sensitive, "a resurrection that lost the key would rescue nothing");

        (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected);
    }

    [Fact]
    public async Task AKeyEditedElsewhereAfterBeingDeletedHere_SurvivesAndIsCalledAKey()
    {
        var entityId = await harness.First.CreateKeyAsync(Key("deploy"));
        await harness.SettleAsync();

        await harness.First.UpdateKeyAsync(entityId, Key("deploy", notes: "still in use"));
        await harness.Second.DeleteKeyAsync(entityId);

        await harness.SettleAsync();

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

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

        // The noun matters: someone told a host was edited elsewhere goes looking in the host list.
        var details = await ConflictDetailsAsync();

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

    // ---- Helpers ----

    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)];
    }

    /// <remarks>
    /// The raw stored detail, decoded as text rather than parsed. The redaction claims are claims about what
    /// is <em>absent</em> from the bytes, and reading through the JSON model would only prove the material
    /// is absent from the fields the model happens to name.
    /// </remarks>
    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)),
        ];
    }
}
