namespace DodoSSH.Client.Ssh.Tests;

/// <summary>
/// What makes two pins the same pin, and the in-memory store that answers on those terms.
/// </summary>
/// <remarks>
/// <para>
/// No container and no vault. <see cref="KnownHostIdentity"/> is shared by every
/// <see cref="IKnownHostStore"/>, so the identity rules are asserted here once, at the layer that defines
/// them — and <see cref="InMemoryKnownHostStore"/> is what the rest of this suite runs against, so its own
/// behaviour has to be right or every test above it is testing something the application does not do.
/// </para>
/// <para>
/// The vault-backed store that actually ships has the same rules asserted against a real cache in
/// <c>DodoSSH.Client.Session.Tests</c>. The duplication is deliberate: two implementations of one interface,
/// and a store that quietly disagreed with the other about which host a pin belongs to would make this
/// suite's coverage of the connect path meaningless.
/// </para>
/// </remarks>
public sealed class KnownHostStoreTests
{
    private static CancellationToken Token => TestContext.Current.CancellationToken;

    [Fact]
    public async Task AnUnvisitedHost_HasNoPin()
    {
        var store = new InMemoryKnownHostStore();

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

    [Fact]
    public async Task APinAnswersForOneHostPortAndAlgorithm()
    {
        // A server legitimately offers several host keys and may negotiate a different one next time, so the
        // algorithm is part of what was approved. A pin that answered for all of them would accept a key
        // nobody checked.
        var store = new InMemoryKnownHostStore();

        await store.TrustAsync(Presented(), 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 these are one machine and must be one pin.
        var store = new InMemoryKnownHostStore();

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

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

    [Fact]
    public async Task ReApproving_ReplacesTheFingerprint()
    {
        var store = new InMemoryKnownHostStore();

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

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

    [Fact]
    public async Task ForgettingAHost_TakesEveryAlgorithmAndNothingElse()
    {
        // The decision being withdrawn is about the machine, not about one of the keys it offers — and a pin
        // left behind would keep refusing a connection the user believes they have already fixed.
        var store = new InMemoryKnownHostStore();

        await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
        await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token);
        await store.TrustAsync(Presented(port: 2222), 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();

        // A different port is a different endpoint, and a different host is obviously untouched.
        (await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
        (await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
    }

    [Fact]
    public async Task ForgettingAHostThatWasNeverApproved_SaysNothingWasRemoved()
    {
        var store = new InMemoryKnownHostStore();

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

    [Fact]
    public async Task ForgettingIsCaseInsensitiveToo()
    {
        // The lookup and the withdrawal have to agree about identity, or a pin could be found and not
        // forgotten — which is the worst of the two, because the user would be told the trust was gone.
        var store = new InMemoryKnownHostStore();

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

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

    [Fact]
    public void AnIdentityIsMadeOfAllThreeParts()
    {
        // Stated directly, because every store keys on this string and a format that dropped the port or the
        // algorithm would silently merge pins that are not the same pin.
        KnownHostIdentity.For("db.internal", 22, "ssh-ed25519").ShouldBe("db.internal:22/ssh-ed25519");

        // Asserted through the comparer rather than with ShouldNotBe, because the comparer is what every
        // store actually keys on — a difference the default string comparison sees but this one does not
        // would still collapse two pins into one.
        KnownHostIdentity.Comparer.Equals(
            KnownHostIdentity.For("db.internal", 22, "ssh-ed25519"),
            KnownHostIdentity.For("db.internal", 2222, "ssh-ed25519")).ShouldBeFalse();

        KnownHostIdentity.Comparer.Equals(
            KnownHostIdentity.For("DB.internal", 22, "ssh-ed25519"),
            KnownHostIdentity.For("db.internal", 22, "ssh-ed25519")).ShouldBeTrue();
    }

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

/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
/// <remarks>
/// <c>System.Progress&lt;T&gt;</c> posts to a captured synchronisation context, or to the thread pool when
/// there is none — which is what a test has here — so a list it appended to would be asserted on before it
/// had been written. The same reason the shell does not use it either; see <c>VaultViewModel.ReporterFor</c>.
/// </remarks>
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
{
    public void Report(T value) => report(value);
}
