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

namespace DodoSSH.Client.Session.Tests;

/// <summary>
/// What a vault stops keeping, and when.
/// </summary>
/// <remarks>
/// Retention is not housekeeping here the way it is for a local log file: these entries sync, so every one
/// kept costs every machine in the vault. That is the price of the decision that made them auditable, and
/// this is what bounds it.
/// </remarks>
public sealed class LogRetentionTests : 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 static readonly DateTimeOffset Now = new(2026, 7, 31, 12, 0, 0, TimeSpan.Zero);

    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($"retention-{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;
    }

    /// <remarks>
    /// Age is read from the entry rather than from the item id. The two are close and not the same: the id
    /// records when the entry was <em>written</em>, which for a connection is when it ended — so a shell
    /// left open across the retention boundary would be pruned by the wrong clock.
    /// </remarks>
    [Fact]
    public async Task EntriesOlderThanTheAgeLimit_Go()
    {
        await using var session = await UnlockAsync();

        await WriteConnectionAsync(session, Now.AddDays(-91));
        await WriteConnectionAsync(session, Now.AddDays(-89));
        await WriteConnectionAsync(session, Now.AddHours(-1));

        var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);

        result.Connections.ShouldBe(1);

        var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token);

        kept.Items.Count.ShouldBe(2);
        kept.Items.ShouldAllBe(item => item.Secret.StartedAt > Now.AddDays(-90));
    }

    /// <remarks>
    /// The other limit, and it is the one that binds for somebody who connects all day. Whichever bites
    /// first wins: an age alone lets a busy vault grow without bound, and a count alone loses a quiet
    /// month's history to one busy afternoon.
    /// </remarks>
    [Fact]
    public async Task EntriesPastTheCountLimit_GoOldestFirst()
    {
        await using var session = await UnlockAsync();

        for (var i = 0; i < 5; i++)
        {
            await WriteConnectionAsync(session, Now.AddMinutes(-i), $"host-{i}");
        }

        var result = await LogPruner.PruneAsync(
            session, new LogRetention(TimeSpan.FromDays(90), MaxEntries: 3), Now, Token);

        result.Connections.ShouldBe(2);

        var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token);

        // The newest three survive: entry i was written i minutes ago, so 0, 1 and 2 are the recent ones.
        kept.Items.Select(item => item.Secret.HostLabel).Order(StringComparer.Ordinal)
            .ShouldBe(["host-0", "host-1", "host-2"]);
    }

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

        await WriteConnectionAsync(session, Now.AddDays(-100));

        await session.ActivityLog.CreateAsync(
            session.ActiveVaultId,
            new ActivityLogSecret
            {
                ItemKind = "Host",
                ItemId = Guid.CreateVersion7(),
                ItemLabel = "prod-db",
                Operation = ActivityOperation.Created,
                At = Now.AddDays(-100),
                DeviceName = "laptop",
            },
            Token);

        var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);

        result.Connections.ShouldBe(1);
        result.Activity.ShouldBe(1);
        result.RemovedAnything.ShouldBeTrue();
    }

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

        await WriteConnectionAsync(session, Now.AddDays(-1));

        var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);

        result.RemovedAnything.ShouldBeFalse();
        (await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem();
    }

    private static Task<Guid> WriteConnectionAsync(
        VaultSession session,
        DateTimeOffset startedAt,
        string label = "prod-db") =>
        session.ConnectionLog.CreateAsync(
            session.ActiveVaultId,
            new ConnectionLogSecret
            {
                HostLabel = label,
                Address = "deploy@db.internal:22",
                StartedAt = startedAt,
                Duration = TimeSpan.FromMinutes(4),
                Outcome = ConnectionOutcome.Closed,
                DeviceName = "laptop",
            },
            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!;
    }
}
