using DodoSSH.Contracts;
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);

        var wraps = await ReadWrapsAsync(context, cancellationToken).ConfigureAwait(false);

        return [.. rows.Select(row => ToStored(row, wraps.GetValueOrDefault(row.VaultId, [])))];
    }

    /// <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);

        if (row is null)
        {
            return null;
        }

        var wraps = await context.Set<CachedVaultKeyWrapRow>()
            .AsNoTracking()
            .Where(w => w.VaultId == vaultId)
            .OrderBy(w => w.KeyGeneration)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return ToStored(row, [.. wraps.Select(ToWrap)]);
    }

    /// <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);

            await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
        }

        context.RemoveRange(existing.Values);

        // The wraps of a vault that is gone from the list go with it. They are keys to something this
        // machine can no longer fetch, and keeping them would be keeping key material for a vault the
        // user has been told they no longer have.
        foreach (var dropped in existing.Keys)
        {
            await RemoveWrapsAsync(context, dropped, cancellationToken).ConfigureAwait(false);
        }

        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 ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);

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

    /// <summary>
    /// Removes one vault's row and its wraps, leaving the rest alone.
    /// </summary>
    /// <remarks>
    /// <para>
    /// For the vault this machine has just deleted, which is gone here before the server's next
    /// <c>/me</c> stops mentioning it — the mirror image of <see cref="UpsertAsync"/>, and needed for the
    /// same reason: a screen that went on listing a vault the user has just deleted, until the next
    /// refresh, would look as though the deletion had not worked.
    /// </para>
    /// <para>
    /// The wraps go with it, as they do in <see cref="ReplaceAllAsync"/> and for the same reason: they are
    /// keys to something that no longer exists. Its <em>items</em> stay, exactly as they stay for a vault
    /// whose grant was withdrawn — the ciphertext is on every other member's machine too, and deleting
    /// these rows would be the client pretending to a reach it does not have.
    /// </para>
    /// </remarks>
    public async Task RemoveAsync(Guid vaultId, CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

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

        if (row is null)
        {
            return;
        }

        context.Remove(row);

        await RemoveWrapsAsync(context, vaultId, cancellationToken).ConfigureAwait(false);

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

    /// <summary>Reads the vaults this machine has been asked to leave off the screens.</summary>
    /// <remarks>
    /// The ids alone: a caller wanting the names already has <see cref="ListAsync"/>, and a list that
    /// carried them would invite somebody to build a vault list out of this one, which is the list that
    /// must never decide what syncs.
    /// </remarks>
    public async Task<IReadOnlyList<Guid>> ListHiddenAsync(CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        return await context.Set<CachedVaultRow>()
            .AsNoTracking()
            .Where(row => row.Hidden)
            .Select(row => row.VaultId)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);
    }

    /// <summary>Records whether one vault's items are shown.</summary>
    /// <remarks>
    /// A vault with no row here is not an error and not worth reporting: a grant withdrawn between the
    /// click and this write leaves nothing to record a preference about, and the vault is already gone
    /// from every list the preference would have applied to.
    /// </remarks>
    public async Task SetHiddenAsync(Guid vaultId, bool hidden, CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

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

        if (row is null || row.Hidden == hidden)
        {
            return;
        }

        row.Hidden = hidden;
        row.UpdatedAtUtc = clock.GetUtcNow();

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

    /// <remarks>
    /// <see cref="CachedVaultRow.Hidden"/> is deliberately not among these. This method exists to write
    /// what the server said, and the server has never been told which vaults this machine is currently
    /// showing — so a refresh that touched the flag would be a refresh that silently un-hid every vault,
    /// once a minute. Leaving it out is what makes the preference survive <see cref="ReplaceAllAsync"/>.
    /// </remarks>
    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;
    }

    /// <summary>
    /// Replaces one vault's earlier-generation wraps with what the server reported.
    /// </summary>
    /// <remarks>
    /// Deleted and re-inserted rather than merged. There are a handful of these per vault at most, the
    /// server's list is authoritative, and a merge would have to decide what a wrap present here and
    /// absent there means — which is "that grant was revoked", and the answer to that is to drop it.
    /// </remarks>
    private static async Task ApplyWrapsAsync(
        ClientCacheContext context,
        StoredVault vault,
        CancellationToken cancellationToken)
    {
        await RemoveWrapsAsync(context, vault.VaultId, cancellationToken).ConfigureAwait(false);

        foreach (var wrap in vault.PriorKeyWraps ?? [])
        {
            context.Add(new CachedVaultKeyWrapRow
            {
                VaultId = vault.VaultId,
                KeyGeneration = wrap.KeyGeneration,
                WrappedKey = wrap.WrappedKey,
            });
        }
    }

    private static async Task RemoveWrapsAsync(
        ClientCacheContext context,
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        var stale = await context.Set<CachedVaultKeyWrapRow>()
            .Where(w => w.VaultId == vaultId)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        context.RemoveRange(stale);
    }

    private static async Task<Dictionary<Guid, IReadOnlyList<VaultKeyWrap>>> ReadWrapsAsync(
        ClientCacheContext context,
        CancellationToken cancellationToken)
    {
        var rows = await context.Set<CachedVaultKeyWrapRow>()
            .AsNoTracking()
            .OrderBy(row => row.KeyGeneration)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return rows
            .GroupBy(row => row.VaultId)
            .ToDictionary(
                group => group.Key,
                group => (IReadOnlyList<VaultKeyWrap>)[.. group.Select(ToWrap)]);
    }

    private static VaultKeyWrap ToWrap(CachedVaultKeyWrapRow row) =>
        new(row.KeyGeneration, row.WrappedKey);

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