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

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// Enrolling once, then unlocking with nothing but a passphrase and a file.
/// </summary>
/// <remarks>
/// The headline property here is that the second half needs no server at all. That is asserted directly:
/// every unlock in this suite runs against a <see cref="SessionOpener"/> that has never been given a
/// transport and could not reach one if it wanted to.
/// </remarks>
public sealed class SessionLifecycleTests : IAsyncLifetime
{
    private const string Passphrase = "correct horse battery staple";
    private const string ServerUrl = "https://dodossh.example";

    /// <remarks>
    /// Far below the shipped 256 MiB profile. The stretching is what makes a stolen wrap expensive to
    /// attack and none of these tests attack one; paying a third of a second per derivation — and there
    /// are three per enroll-and-unlock cycle — would only encourage sharing state between tests.
    /// </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!;

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

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

    [Fact]
    public async Task AFreshMachine_HasNothingToUnlock()
    {
        (await Opener().ReadProfileAsync(Token)).ShouldBeNull();

        var outcome = await Opener().UnlockAsync(Passphrase, Token);

        outcome.Status.ShouldBe(UnlockStatus.NotEnrolled);
        outcome.Session.ShouldBeNull();
        outcome.Message.ShouldContain("not enrolled");
    }

    [Fact]
    public async Task EnrollingLeavesEverythingAnOfflineUnlockNeeds()
    {
        // The property the whole storage layer exists for. After this point the passphrase alone opens
        // the vault: no salt is fetched, no grant is fetched, nothing is asked of a server.
        var provision = await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        provision.Status.ShouldBe(ProvisionStatus.Ready);
        provision.RecoveryCode.ShouldNotBeNullOrWhiteSpace();

        await using var session = await UnlockAsync();

        session.Profile.UserId.ShouldBe(server.UserId);
        session.Profile.ServerUrl.ShouldBe(ServerUrl);
        session.Profile.Issuer.ShouldBe(FakeAccountServer.Issuer);
        session.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Personal");
        session.UnreadableVaults.ShouldBeEmpty();
    }

    [Fact]
    public async Task TheProfileCanBeReadWithoutThePassphrase()
    {
        // So the unlock screen can say who it is asking, rather than showing an unexplained password box.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var profile = await Opener().ReadProfileAsync(Token);

        profile.ShouldNotBeNull();
        profile.Email.ShouldBe("alice@example.com");
        profile.DisplayName.ShouldBe("Alice");
        profile.ServerUrl.ShouldBe(ServerUrl);
    }

    [Fact]
    public async Task TheWrongPassphrase_IsAnAnswerRatherThanAnException()
    {
        // The overwhelmingly common failure. It is also indistinguishable from a tampered wrap, which is
        // correct: the AEAD tag is the only evidence either way and no verifier is stored anywhere.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var outcome = await Opener().UnlockAsync("not the passphrase", Token);

        outcome.Status.ShouldBe(UnlockStatus.WrongPassphrase);
        outcome.Session.ShouldBeNull();
    }

    [Fact]
    public async Task NoDeviceKeyIsRegistered()
    {
        // A device wrap whose private half has nowhere to live is a row nobody can ever open, and it would
        // make the account's device list claim this machine can unlock without a passphrase. Until the OS
        // keystore is wired, not offering it is the honest answer.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var request = server.LastEnrollment.ShouldNotBeNull();
        request.DevicePublicKey.ShouldBeNull();
        request.DeviceWrappedPrivateKey.ShouldBeNull();

        // The recovery wrap is still registered: it is the only route back if the passphrase is lost.
        request.RecoveryWrappedPrivateKey.ShouldNotBeNull();
        request.RecoveryKdfParameters.ShouldNotBeNull();
    }

    [Fact]
    public async Task AnAlreadyEnrolledAccount_IsNotEnrolledAgain()
    {
        // Re-enrolling would replace an identity key that other members may already have wrapped vault
        // keys to, which is a far worse outcome than asking for the existing passphrase.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var second = await Provisioner().EnrollAsync(ServerUrl, "a different one", "desktop", "Personal", Token);

        second.Status.ShouldBe(ProvisionStatus.Ready);
        second.RecoveryCode.ShouldBeNull();
        server.EnrollmentCount.ShouldBe(1);

        // And the original passphrase still works, because nothing was replaced.
        await using var session = await UnlockAsync();
        session.Vaults.ShouldHaveSingleItem();
    }

    [Fact]
    public async Task SigningInToAnUnenrolledAccount_AsksForEnrollmentRatherThanFailing()
    {
        var outcome = await Provisioner().RefreshAsync(ServerUrl, Token);

        outcome.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
        outcome.Me.EnrollmentRequired.ShouldBeTrue();

        // Nothing was cached, so an unlock still reports honestly.
        (await Opener().ReadProfileAsync(Token)).ShouldBeNull();
    }

    [Fact]
    public async Task RefreshingAnEnrolledAccount_RepairsACacheThatLostItsVaults()
    {
        // What signing in on a machine whose cache was cleared looks like. The material comes back from
        // the server, and the passphrase — which the server never had — opens it again.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        using var replacement = ClientCacheFactory.ForMemory($"session-{Guid.CreateVersion7():N}");
        await replacement.MigrateAsync(Token);

        var outcome = await new AccountProvisioner(
            server, keyBinding, replacement, TimeProvider.System, CheapProfile)
            .RefreshAsync(ServerUrl, Token);

        outcome.Status.ShouldBe(ProvisionStatus.Ready);

        var unlocked = await new SessionOpener(replacement, TimeProvider.System)
            .UnlockAsync(Passphrase, Token);

        unlocked.IsUnlocked.ShouldBeTrue(unlocked.Message);
        await unlocked.Session!.DisposeAsync();
    }

    [Fact]
    public async Task AVaultWhoseGrantWasRevoked_SaysSoRatherThanLookingEmpty()
    {
        // A rekey this client has not been re-issued for. Reporting a wrong passphrase here would send the
        // user to retype something that was never the problem.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        server.RevokeVaultGrant();
        await Provisioner().RefreshAsync(ServerUrl, Token);

        var outcome = await Opener().UnlockAsync(Passphrase, Token);

        outcome.Status.ShouldBe(UnlockStatus.NoReadableVault);
        outcome.Message.ShouldContain("rotated");
    }

    [Fact]
    public async Task AnUnsupportedKdf_IsNamedRatherThanThrowingFromLibsodium()
    {
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var profile = (await Opener().ReadProfileAsync(Token)).ShouldNotBeNull();

        await new UnlockStore(caches, TimeProvider.System).SaveAsync(
            profile with
            {
                KdfParameters = profile.KdfParameters with { Algorithm = "argon2-from-the-future" },
            },
            Token);

        var outcome = await Opener().UnlockAsync(Passphrase, Token);

        outcome.Status.ShouldBe(UnlockStatus.UnsupportedKdf);
        outcome.Message.ShouldContain("argon2-from-the-future");
    }

    [Fact]
    public async Task AnUnlockedSession_ReadsAndWritesHostsWithNoServer()
    {
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        await using var session = await UnlockAsync();

        var entityId = await session.Hosts.CreateAsync(
            session.ActiveVaultId, Host("prod-db"), Token);

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

        var host = listing.Items.ShouldHaveSingleItem();
        host.EntityId.ShouldBe(entityId);
        host.Secret.Label.ShouldBe("prod-db");
        host.HasUnsyncedChanges.ShouldBeTrue();

        (await session.PendingChangeCountAsync(Token)).ShouldBe(1);
    }

    [Fact]
    public async Task ASessionSyncsThroughWhicheverTransportItIsHanded()
    {
        // The session deliberately holds no transport: losing the network invalidates the connection, not
        // the vault.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        await using var session = await UnlockAsync();
        await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);

        var transport = new EmptySyncApi();
        var report = await session.SyncAsync(transport, session.ActiveVaultId, Token);

        // Two operations for one host: the host, and the activity log entry recording that somebody created
        // it. PushedItems is the number that means "the user's own work", and it is one — see
        // SyncReport.PushedItems for why the two are counted apart.
        report.Pushed.ShouldBe(2);
        report.PushedItems.ShouldBe(1);
        report.PushedLogEntries.ShouldBe(1);

        transport.PushCount.ShouldBe(1, "both go in one batch");

        // Zero for the same reason: log entries are not somebody's work waiting to be made safe.
        (await session.PendingChangeCountAsync(Token)).ShouldBe(0);
    }

    [Fact]
    public async Task ADisposedSession_RefusesToBeUsed()
    {
        // Locking is disposing, so this is what "locked" has to mean in practice.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var session = await UnlockAsync();
        var vaultId = session.ActiveVaultId;

        await session.DisposeAsync();

        await Should.ThrowAsync<ObjectDisposedException>(
            async () => await session.ReadConflictsAsync(Token));

        await Should.ThrowAsync<ObjectDisposedException>(
            async () => await session.Hosts.ListAsync(vaultId, Token));

        // Idempotent, because shutdown paths call it more than once.
        await session.DisposeAsync();
    }

    [Fact]
    public async Task ASecondUnlock_ProducesAnIndependentSession()
    {
        // Two windows, or a lock followed by an unlock. Disposing one must not take the other's keys with
        // it, which it would if anything here were shared statically.
        await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var first = await UnlockAsync();
        await using var second = await UnlockAsync();

        await first.DisposeAsync();

        var listing = await second.Hosts.ListAsync(second.ActiveVaultId, Token);
        listing.Unreadable.ShouldBe(0);
    }

    // ---- Helpers ----

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

    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!;
    }

    private static HostSecret Host(string label) =>
        new()
        {
            Label = label,
            Hostname = "db.internal",
            Port = 22,
            Username = "deploy",
        };
}
