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

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// Unlocking with this machine's device key instead of the passphrase.
/// </summary>
/// <remarks>
/// <para>
/// The headline test is the offline one: register a device, close the vault, and open it again with no
/// server and no passphrase. That is the whole feature, and it is the case a design that fetched the wrap
/// at unlock time would have failed.
/// </para>
/// <para>
/// The rest are the ways it is allowed to fail, and every one of them has the same remedy — ask for the
/// passphrase. They are separate statuses rather than one because the caller shows a different sentence
/// for a machine that was never registered than for a gesture somebody declined.
/// </para>
/// </remarks>
public sealed class DeviceUnlockTests : IAsyncLifetime
{
    private const string Passphrase = "correct horse battery staple";
    private const string ServerUrl = "https://dodossh.example";

    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 readonly FakeDeviceKeyStore deviceKeys = new();

    private ClientCacheFactory caches = null!;

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

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

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

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

    [Fact]
    public async Task ARegisteredDevice_UnlocksWithNoPassphraseAndNoNetwork()
    {
        // The feature. Everything else in this file is about the ways it declines to happen.
        await using (var first = await UnlockAsync())
        {
            (await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token)).ShouldBeTrue();
        }

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

        outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
        await using var session = outcome.Session!;

        session.Profile.UserId.ShouldBe(server.UserId);
        session.ActiveVaultId.ShouldNotBe(Guid.Empty);
    }

    [Fact]
    public async Task ADeviceUnlock_ReadsTheSameCacheThePassphraseWrote()
    {
        // Why the cache key had to move off the master key. A device unlock never computes one, so under the
        // old derivation this session would have opened the identity and then found its own cache
        // unreadable — see docs/crypto.md §3.2.
        Guid hostId;

        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);

            hostId = await first.Hosts.CreateAsync(
                first.ActiveVaultId,
                new HostSecret { Label = "db", Hostname = "db.internal", Username = "deploy" },
                Token);
        }

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);
        outcome.IsUnlocked.ShouldBeTrue(outcome.Message);

        await using var session = outcome.Session!;
        var hosts = await session.Hosts.ListAsync(session.ActiveVaultId, Token);

        hosts.Items.ShouldContain(host => host.EntityId == hostId);
    }

    [Fact]
    public async Task WithNoDeviceRegistered_ItSaysSoRatherThanFailing()
    {
        // The ordinary state of a machine nobody has opted in on. A caller checks this before showing a
        // gesture prompt that could not lead anywhere.
        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

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

    [Fact]
    public async Task WhenTheGestureIsDeclined_ItAsksForThePassphrase()
    {
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

        // What a cancelled fingerprint prompt looks like from here, and what a Hello key invalidated by a
        // PIN reset looks like too. Deliberately the same status: the remedy does not differ.
        deviceKeys.Decline = true;

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

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

    [Fact]
    public async Task WhenTheStoredKeyDoesNotOpenTheWrap_ItIsRejectedRatherThanRetried()
    {
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

        // A different key of the right length: what a rotated identity leaves behind on a machine whose
        // wrap predates it. Distinct from a declined gesture because this one will never succeed again.
        deviceKeys.Overwrite(new byte[CryptoSpec.SymmetricKeySize]);

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

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

    [Fact]
    public async Task WhenTheKeystoreReturnsSomethingThatIsNotAKey_ItIsRefusedNotThrown()
    {
        // A keystore handing back the wrong number of bytes is a broken keystore, and the answer is still a
        // passphrase prompt rather than a crash on the unlock screen.
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

        deviceKeys.Overwrite([1, 2, 3]);

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

        outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected);
    }

    [Fact]
    public async Task OnAMachineWithNoKeystore_RegisteringDeclinesAndRegistersNothing()
    {
        // Registering a device whose private half does not survive the process would put a wrap on the
        // server that nothing can open, and make the account claim a capability this machine lacks.
        await using var session = await UnlockAsync();

        var registered = await session.RegisterDeviceAsync(
            server, new UnavailableDeviceKeyStore(), "this laptop", Token);

        registered.ShouldBeFalse();
        server.RegisteredDevices.ShouldBeEmpty();
    }

    [Fact]
    public async Task ForgettingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
    {
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

        server.RegisteredDevices.Count.ShouldBe(1);

        await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
        {
            var revocation = await second.ForgetDeviceAsync(server, deviceKeys, Token);

            revocation.ShouldBe(DeviceRevocation.Complete);
        }

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

        outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey);

        // The half that used to be left behind. A kind=device wrap is the identity bundle sealed to a key
        // somebody may still hold, so a revocation that only cleared this machine left the dangerous part
        // exactly where it was.
        server.RegisteredDevices.ShouldBeEmpty();

        // And the passphrase still works, which is the property that makes forgetting safe to offer.
        await using var byPassphrase = await UnlockAsync();
        byPassphrase.ActiveVaultId.ShouldNotBe(Guid.Empty);
    }

    /// <remarks>
    /// Revoking is most wanted at the moment a machine is lost, and being offline is no reason to leave it
    /// able to let itself in. The local half is the half that decides whether this machine may unlock — the
    /// unlock path never asks the server — so doing it anyway is strictly better than refusing, provided the
    /// caller is told the account has not been told.
    /// </remarks>
    [Fact]
    public async Task ForgettingTheDeviceOffline_StillStopsThisMachineAndSaysWhatWasNotDone()
    {
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

        await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!)
        {
            var revocation = await second.ForgetDeviceAsync(api: null, deviceKeys, Token);

            revocation.ShouldBe(DeviceRevocation.LocalOnly);
        }

        (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Status
            .ShouldBe(UnlockStatus.NoDeviceKey);

        server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
    }

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

        var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);

        revocation.ShouldBe(DeviceRevocation.NothingRegistered);
    }

    /// <remarks>
    /// Registering twice from one machine must not leave two revocable devices behind, which is a property of
    /// the id the server issues rather than of the client: the real service is idempotent on the public key
    /// and returns the id it already has. Pinned here because a fake that invented a fresh id per call would
    /// make every revocation test pass while revoking something that was never registered.
    /// </remarks>
    [Fact]
    public async Task RegisteringTwice_KeepsOneDeviceAndOneIdToRevoke()
    {
        await using var session = await UnlockAsync();

        await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);

        server.RegisteredDevices.Count.ShouldBe(1);

        var revocation = await session.ForgetDeviceAsync(server, deviceKeys, Token);

        revocation.ShouldBe(DeviceRevocation.Complete);
        server.RegisteredDevices.ShouldBeEmpty();
    }

    [Fact]
    public async Task RefreshingTheProfile_DoesNotDiscardTheDeviceWrap()
    {
        // The trap in UnlockStore.Apply. /me is re-read on every sign-in and knows nothing about this
        // machine's keystore, so writing its device columns unconditionally would delete the wrap on the
        // next launch — and the user's fingerprint would stop working for no visible reason.
        await using (var first = await UnlockAsync())
        {
            await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);
        }

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

        var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token);

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

    [Fact]
    public async Task TheWrapReachesTheServerAndTheKeyDoesNot()
    {
        // The division the whole design rests on: the server stores a sealed bundle it cannot open, and the
        // private half never leaves this machine.
        await using var session = await UnlockAsync();

        await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token);

        server.RegisteredDevices.Count.ShouldBe(1);

        var stored = deviceKeys.Peek().ShouldNotBeNull();
        stored.Length.ShouldBe(CryptoSpec.SymmetricKeySize);

        // Nothing the server holds contains the private scalar.
        foreach (var wrap in server.RegisteredDevices.Values)
        {
            Convert.ToHexString(wrap).ShouldNotContain(
                Convert.ToHexString(stored), Case.Insensitive);
        }
    }

    // ---- Helpers ----

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

/// <summary>
