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

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// Known host keys through the two-machine harness.
/// </summary>
/// <remarks>
/// The first test here is the whole reason this item type exists: a host approved on the laptop is approved on
/// the desktop. Everything else is what the credential and key suites check per type — the cipher, what the
/// server is told, that the items cannot be confused with another type's — plus the two properties that are
/// specific to trust: that withdrawing it propagates, and that a clash between two fingerprints is reported
/// with both of them, unlike a clash between two passwords.
/// </remarks>
public sealed class KnownHostSyncTests : 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 AHostApprovedOnOneMachine_IsApprovedOnTheOther()
    {
        // The point of the item type. Without it a user is asked to check the same fingerprint on every
        // device, which is how people learn to approve host keys without reading them.
        var entityId = await harness.First.CreateKnownHostAsync(
            KnownHost("bastion.internal", port: 2222, fingerprint: "SHA256:approved-on-the-laptop"));

        await harness.SettleAsync();

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

        seen.Secret.Host.ShouldBe("bastion.internal");
        seen.Secret.Port.ShouldBe(2222);
        seen.Secret.Algorithm.ShouldBe("ssh-ed25519");
        seen.Secret.Fingerprint.ShouldBe("SHA256:approved-on-the-laptop");
        seen.HasUnsyncedChanges.ShouldBeFalse();
    }

    [Fact]
    public async Task TrustWithdrawnOnOneMachine_IsWithdrawnOnTheOther()
    {
        // The other half, and not a symmetry argument: a changed host key is refused with no way to continue,
        // so a withdrawal that did not travel would leave a rebuilt server unreachable from every machine
        // except the one that forgot its old key.
        var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
        await harness.SettleAsync();

        (await harness.Second.ListKnownHostsAsync()).Items.ShouldHaveSingleItem();

        await harness.First.DeleteKnownHostAsync(entityId);
        await harness.SettleAsync();

        (await harness.Second.ListKnownHostsAsync()).Items.ShouldBeEmpty();
    }

    [Fact]
    public async Task ThePull_AsksForKnownHostKeys()
    {
        // Derived from the registry rather than listed, so this cannot be forgotten — but a pin that
        // reconciles perfectly and is never requested would work on one machine and exist nowhere else,
        // which is exactly the failure the first test would then be unable to see.
        await harness.First.SyncAsync();

        harness.Server.LastPullTypes.ShouldNotBeNull().ShouldContain(SyncEntityType.KnownHostKey);
    }

    [Fact]
    public async Task APinHandsTheServerNothingInPlaintext()
    {
        // The address especially. It is the one field here that the server is allowed to hold for a
        // relay-enabled host, and putting it on this item as well would hand the operator the list of
        // endpoints every user actually reaches — assembled out of values that are each harmless.
        var entityId = await harness.First.CreateKnownHostAsync(KnownHost("bastion.internal"));

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

        queued.ShouldNotBeNull();
        queued.Fields.ShouldBeNull("a pin tells the server nothing but its ciphertext");

        await harness.SettleAsync();

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

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

    [Fact]
    public async Task APinAndAHostSharingAnId_AreTwoItems()
    {
        // The cache keys on the type as well as the id, and each payload's AAD binds a different resource
        // type. 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,
            KnownHostKeyCipher.Seal(
                KnownHost(), vaultKey.Span, sharedId, generation, itemVersion: 1),
            null,
            SyncEntityType.KnownHostKey);

        await harness.Second.SyncAsync();

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

        var pins = await harness.Second.ListKnownHostsAsync();

        pins.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("db.internal");
        pins.Unreadable.ShouldBe(0);
    }

    [Fact]
    public async Task BothMachinesApprovedADifferentKey_TheDiscardedFingerprintIsReported()
    {
        // The deliberate contrast with the password merge, which reports that something differed and nothing
        // more. A fingerprint is published by the operator so that it can be compared; a notice that withheld
        // the value it dropped would leave the user with nothing to check it against.
        var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
        await harness.SettleAsync();

        await harness.First.UpdateKnownHostAsync(
            entityId, KnownHost(fingerprint: "SHA256:seen-from-the-laptop"));

        await harness.Second.UpdateKnownHostAsync(
            entityId, KnownHost(fingerprint: "SHA256:seen-from-the-desktop"));

        await harness.SettleAsync();

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

        // Converged, and on the value that reached the server first: every replica has to resolve a clash the
        // same way or the two would push against each other for ever.
        first.ShouldBe((await harness.Second.FindKnownHostAsync(entityId)).Secret);
        first.Fingerprint.ShouldBe("SHA256:seen-from-the-laptop");

        var details = await ConflictDetailsAsync();

        details.ShouldContain(detail => detail.Contains("SHA256:seen-from-the-desktop", StringComparison.Ordinal));
        details.ShouldContain(detail => detail.Contains("Fingerprint", StringComparison.Ordinal));
    }

    [Fact]
    public async Task APinEditedElsewhereAfterBeingDeletedHere_IsCalledAKnownHostKey()
    {
        // The noun reaches a person. "This host key was edited elsewhere" would send them to look at the
        // server they are connecting to, rather than at a decision they made about it.
        var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
        await harness.SettleAsync();

        await harness.First.UpdateKnownHostAsync(
            entityId, KnownHost(fingerprint: "SHA256:still-the-one-i-approved"));

        await harness.Second.DeleteKnownHostAsync(entityId);

        await harness.SettleAsync();

        (await harness.First.FindKnownHostAsync(entityId)).Secret.Fingerprint
            .ShouldBe("SHA256:still-the-one-i-approved");

        var details = await ConflictDetailsAsync();

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

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