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

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// What the activity log records when somebody changes something in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// Written against a real unlocked vault rather than a stand-in, because the property under test is where
/// the hook sits: it is in <c>VaultItemRepository</c>, the one generic funnel every kind's writes go
/// through, and a test that called the sink directly would prove nothing about that.
/// </para>
/// <para>
/// The recorder writes on a background task, so every assertion here waits for the entry rather than reading
/// immediately. That is the design and not a testing inconvenience: a save the user is waiting on must not
/// also wait for a log entry to be encrypted.
/// </para>
/// </remarks>
public sealed class ActivityLogTests : IAsyncLifetime
{
    private const string Passphrase = "correct horse battery staple";
    private const string ServerUrl = "https://dodossh.example";

    /// <inheritdoc cref="SessionLifecycleTests" />
    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($"activity-{Guid.CreateVersion7():N}");
        await caches.MigrateAsync(Token);

        await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
            .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
    }

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

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

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

        var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret;

        entry.Operation.ShouldBe(ActivityOperation.Created);
        entry.ItemKind.ShouldBe("Host");
        entry.ItemId.ShouldBe(entityId);
        entry.ItemLabel.ShouldBe("prod-db");
        entry.ChangedFields.ShouldBeEmpty("a create changed everything, which is the same as nothing");

        // An audit record with no actor is not an audit record. Both halves matter once the vault is shared:
        // which account, and which of that account's machines.
        entry.ActorUserId.ShouldNotBe(Guid.Empty);
        entry.DeviceName.ShouldNotBeNullOrWhiteSpace();
    }

    /// <remarks>
    /// The rule the whole payload is built around. "Username" is what somebody reviewing a keychain needs to
    /// see; the account name it was changed to belongs in the item, not in a log that syncs to everybody.
    /// </remarks>
    [Fact]
    public async Task EditingAnItem_RecordsWhichFieldsChangedAndNeverTheirValues()
    {
        await using var session = await UnlockAsync();

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

        await session.Hosts.UpdateAsync(
            session.ActiveVaultId,
            entityId,
            Host("prod-db") with { Port = 2222, Username = "root" },
            Token);

        var entries = await WaitForAsync(session, 2);
        var edit = entries.Select(item => item.Secret).Single(e => e.Operation is ActivityOperation.Updated);

        edit.ChangedFields.ShouldBe("Port, Username");

        edit.ChangedFields.ShouldNotContain("2222", Case.Sensitive);
        edit.ChangedFields.ShouldNotContain("root", Case.Sensitive);
    }

    /// <remarks>
    /// Two edits are two lines, where the outbox coalesces them into one pending row. The outbox describes
    /// what still has to be sent; this describes what somebody did, and those are different questions.
    /// </remarks>
    [Fact]
    public async Task TwoEditsOfOneItem_AreTwoLines()
    {
        await using var session = await UnlockAsync();

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

        await session.Hosts.UpdateAsync(
            session.ActiveVaultId, entityId, Host("prod-db") with { Port = 2222 }, Token);

        await session.Hosts.UpdateAsync(
            session.ActiveVaultId, entityId, Host("renamed") with { Port = 2222 }, Token);

        var entries = await WaitForAsync(session, 3);

        entries.Count(item => item.Secret.Operation is ActivityOperation.Updated).ShouldBe(2);

        // And the second is measured against the first rather than against what the server last accepted,
        // which is why it reports the rename alone and not the port again.
        var latest = entries
            .Select(item => item.Secret)
            .Where(entry => entry.Operation is ActivityOperation.Updated)
            .OrderBy(entry => entry.At)
            .Last();

        latest.ChangedFields.ShouldBe("Name");
    }

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

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

        var entries = await WaitForAsync(session, 2);
        var deletion = entries.Select(e => e.Secret).Single(e => e.Operation is ActivityOperation.Deleted);

        // Read before the delete destroyed it. A lookup afterwards resolves to nothing, which is exactly the
        // case where the name matters most.
        deletion.ItemLabel.ShouldBe("prod-db");
        deletion.ItemId.ShouldBe(entityId);
    }

    /// <remarks>
    /// <para>
    /// <b>The guard that keeps this feature from being a runaway.</b> The hook lives in the one repository
    /// every kind writes through, so without <c>IItemKind.IsAudited</c> a log entry would produce a log
    /// entry, without end — and it would do so on a background task, quietly, filling a vault.
    /// </para>
    /// <para>
    /// Asserted by writing entries directly and then waiting long enough for a recursive write to have
    /// happened: the count has to stay where it was put.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task WritingALogEntry_DoesNotProduceALogEntryAboutIt()
    {
        await using var session = await UnlockAsync();

        await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token);
        await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token);

        // Long enough for a recursive write to have queued, been drained and stored several times over.
        await Task.Delay(TimeSpan.FromMilliseconds(300), Token);

        (await session.ActivityLog.ListAsync(session.ActiveVaultId, Token)).Items
            .ShouldBeEmpty("a log that logs itself never stops");

        (await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.Count.ShouldBe(2);
    }

    /// <remarks>
    /// A pin is written by the connect path rather than by a screen, which is precisely the write a hook
    /// placed in the view models would have missed — and trusting a host key is one of the more interesting
    /// things an audit trail can show.
    /// </remarks>
    [Fact]
    public async Task APinWrittenByTheConnectPath_IsRecordedToo()
    {
        await using var session = await UnlockAsync();

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

        await store.TrustAsync(
            new Ssh.HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);

        var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret;

        entry.ItemKind.ShouldBe("KnownHostKey");
        entry.Operation.ShouldBe(ActivityOperation.Created);

        store.Close();
    }

    /// <summary>Waits for the background recorder to have written at least this many entries.</summary>
    /// <remarks>
    /// Polled rather than awaited on a signal, because the recorder deliberately exposes none: its whole
    /// contract is that the caller does not wait for it. A timeout rather than a loop, so a hook that stopped
    /// firing fails as a test rather than as a hang.
    /// </remarks>
    private static async Task<IReadOnlyList<VaultItem<ActivityLogSecret>>> WaitForAsync(
        VaultSession session,
        int count)
    {
        var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);

        while (true)
        {
            var listing = await session.ActivityLog.ListAsync(session.ActiveVaultId, Token);

            if (listing.Items.Count >= count)
            {
                return listing.Items;
            }

            if (TimeProvider.System.GetUtcNow() > deadline)
            {
                listing.Items.Count.ShouldBe(count, "the activity hook stopped firing");
                return listing.Items;
            }

            await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
        }
    }

    private async Task<VaultSession> UnlockAsync()
    {
        var outcome = await new SessionOpener(caches, TimeProvider.System).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",
        };

    private static ConnectionLogSecret Connection() => new()
    {
        HostLabel = "prod-db",
        Address = "deploy@db.internal:22",
        StartedAt = DateTimeOffset.UnixEpoch,
        Duration = TimeSpan.FromMinutes(3),
        Outcome = ConnectionOutcome.Closed,
        DeviceName = "laptop",
    };
}
