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

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// Host key trust that outlives the process, and the snapshot the SSH handshake reads it from.
/// </summary>
/// <remarks>
/// <para>
/// The first test is the whole point of the feature: approve a fingerprint, lock the vault, unlock it again,
/// and the host is still trusted. It runs with no server at all — the pin is in the outbox and the local
/// cache, which is exactly the situation a user is in on a laptop that has not been online since.
/// </para>
/// <para>
/// The rest are the properties that would each be a security bug rather than an inconvenience: that a pin
/// answers for the one algorithm it was recorded for, that a locked vault answers "not pinned" rather than
/// something optimistic, and that withdrawing trust reaches every pin for the endpoint.
/// </para>
/// </remarks>
public sealed class VaultKnownHostStoreTests : IAsyncLifetime
{
    private const string Passphrase = "correct horse battery staple";
    private const string ServerUrl = "https://dodossh.example";

    /// <remarks>
    /// Far below the shipped profile, as in <see cref="SessionLifecycleTests"/>: nothing here attacks a wrap,
    /// and every test in this suite pays for at least one unlock.
    /// </remarks>
    private static readonly Argon2Profile CheapProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);

    private readonly FakeAccountServer server = new();
    private readonly StubKeyBinding keyBinding = new();

    private ClientCacheFactory caches = null!;

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

    /// <inheritdoc />
    public async ValueTask InitializeAsync()
    {
        caches = ClientCacheFactory.ForMemory($"known-hosts-{Guid.CreateVersion7():N}");
        await caches.MigrateAsync(TestContext.Current.CancellationToken);

        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
    }

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

    [Fact]
    public async Task AHostTrustedOnceIsStillTrustedAfterALockAndUnlock()
    {
        // The gap this closes. With trust in memory the user is asked to check the same fingerprint on every
        // launch, which is how people learn to approve host keys without reading them.
        var store = new VaultKnownHostStore();

        await using (var first = await UnlockAsync())
        {
            await store.OpenAsync(first, Token);
            await store.TrustAsync(Presented(), Token);
        }

        store.Close();

        await using var second = await UnlockAsync();
        await store.OpenAsync(second, Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token))
            .ShouldBe("SHA256:approved");
    }

    [Fact]
    public async Task AnUnvisitedHost_HasNoPin()
    {
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    [Fact]
    public async Task APinAnswersForOneAlgorithmOnly()
    {
        // A server legitimately offers several host keys, and which one is negotiated can change between
        // connections. A pin that answered for all of them would either accept a key nobody approved or
        // report a mismatch for an ordinary server.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
        (await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
        (await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBeNull();
        (await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    [Fact]
    public async Task AHostNameIsMatchedWithoutRegardToCase()
    {
        // DNS is case-insensitive, so DB.internal and db.internal are one machine. Treating them as two would
        // ask the user to approve the same server twice, and leave two pins where a withdrawal has to find
        // both.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(host: "DB.internal"), Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");

        // And the value stored is the one that was dialled, not a lower-cased rewrite of it.
        var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);
        listing.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("DB.internal");
    }

    [Fact]
    public async Task ReApprovingTheSameKey_QueuesNothingFurther()
    {
        // Re-trusting an identical fingerprint used to be a dictionary write that cost nothing. It is now an
        // outbox operation, and queueing one would push a modification to every other machine for a decision
        // that had not changed.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(), Token);

        var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);
        var entityId = listing.Items.ShouldHaveSingleItem().EntityId;

        var before = await session.Outbox
            .FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token);

        var operationId = before.ShouldNotBeNull().OperationId;

        await store.TrustAsync(Presented(), Token);

        // Asserted on the operation rather than on a count of pending changes, and the difference is the
        // whole test: queueing an identical write coalesces onto the same outbox row, so a count stays at one
        // either way. What would move is the operation id, which is re-minted whenever the payload is
        // rewritten — so this is what tells a redundant write from no write at all.
        var after = await session.Outbox
            .FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token);

        after.ShouldNotBeNull().OperationId.ShouldBe(operationId);

        (await session.KnownHosts.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem();
    }

    [Fact]
    public async Task ApprovingANewKeyForAKnownHost_ReplacesThePinRatherThanAddingOne()
    {
        // What happens after a server is rebuilt and its old pin has been forgotten. Two items for one
        // endpoint would leave the endpoint's trust depending on which of them a lookup happened to see.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(), Token);
        await store.TrustAsync(Presented(fingerprint: "SHA256:rebuilt"), Token);

        var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);

        listing.Items.ShouldHaveSingleItem().Secret.Fingerprint.ShouldBe("SHA256:rebuilt");

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:rebuilt");
    }

    [Fact]
    public async Task ForgettingAHost_TakesEveryAlgorithmWithIt()
    {
        // The user's decision is about the machine, not about one of the keys it offers. Leaving one behind
        // would mean a rebuilt server that still refuses to connect for a reason they believe they have
        // already dealt with.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
        await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token);
        await store.TrustAsync(Presented(host: "other.internal"), Token);

        (await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(2);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
        (await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();

        // And nothing else was touched.
        (await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
    }

    [Fact]
    public async Task ForgettingAHostThatWasNeverApproved_SaysSoRatherThanFailing()
    {
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        (await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(0);
    }

    [Fact]
    public async Task AForgottenPinStaysForgottenAcrossALockAndUnlock()
    {
        // The refresh that follows a withdrawal replaces the snapshot rather than merging into it. Merging
        // would have made a forgotten pin reappear, which is the failure that matters here: the user would be
        // told the host key had changed after explicitly saying it had.
        var store = new VaultKnownHostStore();

        await using (var first = await UnlockAsync())
        {
            await store.OpenAsync(first, Token);
            await store.TrustAsync(Presented(), Token);
            await store.ForgetAsync("db.internal", 22, Token);

            (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
        }

        store.Close();

        await using var second = await UnlockAsync();
        await store.OpenAsync(second, Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    [Fact]
    public async Task ALockedVaultAnswersNotPinnedRatherThanSomethingOptimistic()
    {
        // Reachable when a vault is locked while a handshake is in flight. Refusing the connection is the safe
        // direction; the alternative would be answering a host key question out of a vault that is closed.
        var store = new VaultKnownHostStore();

        await using var session = await UnlockAsync();

        await store.OpenAsync(session, Token);
        await store.TrustAsync(Presented(), Token);

        store.IsOpen.ShouldBeTrue();
        store.Close();
        store.IsOpen.ShouldBeFalse();

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    [Fact]
    public async Task ALockedVaultRefusesToRecordOrWithdrawTrust()
    {
        // Loudly rather than silently. A pin accepted into nothing would leave the user believing they had
        // approved a host, and a withdrawal accepted into nothing would leave one they believe is gone.
        var store = new VaultKnownHostStore();

        await Should.ThrowAsync<InvalidOperationException>(
            async () => await store.TrustAsync(Presented(), Token));

        await Should.ThrowAsync<InvalidOperationException>(
            async () => await store.ForgetAsync("db.internal", 22, Token));

        // And a refresh with nothing behind it is a no-op rather than a throw: a synchronisation pass may
        // finish after the vault was locked, and that is ordinary rather than exceptional.
        await store.RefreshAsync(Token);
    }

    [Fact]
    public async Task ARefreshPicksUpAPinRecordedElsewhere()
    {
        // Stands in for a pin arriving from another machine: something reached the vault that this store's
        // snapshot predates. Written through the repository directly, which is what a pull followed by a
        // listing amounts to.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await session.KnownHosts.CreateAsync(
            session.ActiveVaultId,
            new KnownHostSecret
            {
                Host = "bastion.internal",
                Port = 22,
                Algorithm = "ssh-ed25519",
                Fingerprint = "SHA256:approved-on-the-desktop",
            },
            Token);

        (await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token))
            .ShouldBeNull("the snapshot is only re-read when it is told to be");

        await store.RefreshAsync(Token);

        (await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token))
            .ShouldBe("SHA256:approved-on-the-desktop");
    }

    [Fact]
    public async Task ARefreshKeepsWhatWasApprovedHere()
    {
        // The mistake that would matter. A pass that re-read the vault must not drop a pin recorded on this
        // machine, or the user would be asked again about a host they had just approved.
        await using var session = await UnlockAsync();

        var store = new VaultKnownHostStore();
        await store.OpenAsync(session, Token);

        await store.TrustAsync(Presented(), Token);
        await store.RefreshAsync(Token);

        (await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
    }

    // ---- Helpers ----

    private static HostKeyPresentation Presented(
        string host = "db.internal",
        int port = 22,
        string algorithm = "ssh-ed25519",
        string fingerprint = "SHA256:approved") =>
        new(host, port, algorithm, fingerprint);

    private SessionOpener Opener() => new(caches, TimeProvider.System);

    private AccountProvisioner Provisioner() =>
        new(server, keyBinding, caches, TimeProvider.System, CheapProfile);

    private async Task<VaultSession> UnlockAsync()
    {
        var outcome = await Opener().UnlockAsync(Passphrase, Token);

        outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
        return outcome.Session!;
    }
}
