using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Client.Storage;

/// <summary>
/// The vaults this user can reach, and the grants that open them.
/// </summary>
/// <remarks>
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
/// either public metadata or ciphertext.
/// </remarks>
public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
{
    /// <summary>Reads every known vault.</summary>
    public async Task<IReadOnlyList<StoredVault>> ListAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var rows = await context.Set<CachedVaultRow>()
            .AsNoTracking()
            .OrderByDescending(row => row.IsPersonal)
            .ThenBy(row => row.Name)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return [.. rows.Select(ToStored)];
    }

    /// <summary>Reads one vault.</summary>
    public async Task<StoredVault?> FindAsync(Guid vaultId, CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<CachedVaultRow>()
            .AsNoTracking()
            .SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
            .ConfigureAwait(false);

        return row is null ? null : ToStored(row);
    }

    /// <summary>
    /// Replaces the cached vault list with what the server reported.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Vaults absent from the list are removed, because losing access to a vault is exactly what that
    /// absence means and a stale row would offer the user a vault they can no longer sync.
    /// </para>
    /// <para>
    /// Their <em>items</em> are a separate matter and are not touched here. Removing a member does not
    /// retroactively erase what they already hold — that is not achievable, which is why offboarding
    /// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
    /// would only make the client pretend otherwise.
    /// </para>
    /// </remarks>
    public async Task ReplaceAllAsync(
        IReadOnlyList<StoredVault> vaults,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(vaults);

        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var existing = await context.Set<CachedVaultRow>()
            .ToDictionaryAsync(row => row.VaultId, cancellationToken)
            .ConfigureAwait(false);

        var now = clock.GetUtcNow();

        foreach (var vault in vaults)
        {
            if (!existing.Remove(vault.VaultId, out var row))
            {
                row = new CachedVaultRow { VaultId = vault.VaultId };
                context.Add(row);
            }

            Apply(row, vault, now);
        }

        context.RemoveRange(existing.Values);

        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Records one vault, leaving the rest alone.
    /// </summary>
    /// <remarks>
    /// For the vault this machine has just created, which exists here before the server's next
    /// <c>/me</c> confirms it. <see cref="ReplaceAllAsync"/> would be wrong for that: it treats absence
    /// as loss of access, and the one list that does not yet mention this vault is the one this client
    /// last fetched.
    /// </remarks>
    public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(vault);

        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<CachedVaultRow>()
            .SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
            .ConfigureAwait(false);

        if (row is null)
        {
            row = new CachedVaultRow { VaultId = vault.VaultId };
            context.Add(row);
        }

        Apply(row, vault, clock.GetUtcNow());

        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }

    private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
    {
        row.Name = vault.Name;
        row.IsPersonal = vault.IsPersonal;
        row.TeamId = vault.TeamId;
        row.KeyGeneration = vault.KeyGeneration;
        row.Permissions = vault.Permissions;
        row.WrappedVaultKey = vault.WrappedVaultKey;
        row.RekeyRequired = vault.RekeyRequired;
        row.UpdatedAtUtc = now;
    }

    private static StoredVault ToStored(CachedVaultRow row) =>
        new(
            row.VaultId,
            row.Name,
            row.IsPersonal,
            row.TeamId,
            row.KeyGeneration,
            row.Permissions,
            row.WrappedVaultKey,
            row.RekeyRequired);
}
