using DodoSSH.Api.Authorization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Api.Features.Identity;

/// <summary>
/// Assembles the caller's own profile and unlock state.
/// </summary>
/// <remarks>
/// This is the first authenticated call a client makes and the only one that works before
/// enrollment, so it has to answer "what do I do next" unambiguously: either enroll, or unlock with
/// these parameters and open these vaults.
/// </remarks>
internal sealed class IdentityService(DodoDbContext database, IVaultAccessService vaultAccess)
{
    /// <summary>Builds the caller's profile.</summary>
    internal async Task<MeResponse> GetMeAsync(UserAccount user, CancellationToken cancellationToken)
    {
        var key = await database.UserKeys
            .SingleOrDefaultAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
            .ConfigureAwait(false);

        var wrap = key is null
            ? null
            : await database.UserKeyWraps
                .SingleOrDefaultAsync(
                    w => w.UserId == user.Id && w.Kind == UserKeyWrapKind.Passphrase,
                    cancellationToken)
                .ConfigureAwait(false);

        var vaults = await BuildVaultsAsync(user, key, cancellationToken).ConfigureAwait(false);

        return new MeResponse(
            UserId: user.Id,
            Issuer: user.Issuer,
            Subject: user.Subject,
            Email: user.Email,
            DisplayName: user.DisplayName,

            // The single flag the client branches on at startup.
            EnrollmentRequired: key is null,

            KeyGeneration: key?.Generation,

            // Both are needed to unlock, and both are useless to the server. Returning them
            // together is what lets a client cache them and unlock offline later: the KDF salt must
            // never be something it has to fetch at unlock time.
            WrappedPrivateKey: wrap?.Wrap,
            KdfParameters: ToContract(wrap),

            Vaults: vaults);
    }

    private async Task<IReadOnlyList<VaultSummary>> BuildVaultsAsync(
        UserAccount user,
        UserKey? key,
        CancellationToken cancellationToken)
    {
        var accessible = await vaultAccess.ListAsync(user.Id, cancellationToken).ConfigureAwait(false);
        if (accessible.Count == 0)
        {
            return [];
        }

        var vaultIds = accessible.Select(a => a.Vault!.Id).ToArray();

        var grants = await database.VaultKeyGrants
            .Where(g => vaultIds.Contains(g.VaultId)
                && g.RecipientUserId == user.Id
                && g.State == GrantState.Active
                && g.RevokedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var summaries = new List<VaultSummary>(accessible.Count);

        foreach (var access in accessible)
        {
            var vault = access.Vault!;

            // The grant must match the exact identity key it was wrapped to. One left over from a
            // superseded identity key is not merely stale — the client's current private key cannot
            // open it, so offering it would produce a tag failure the user reads as data corruption.
            var mine = grants
                .Where(g => g.VaultId == vault.Id
                    && key is not null
                    && g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256))
                .ToList();

            var grant = mine.Find(g => g.KeyGeneration == vault.KeyGeneration);

            // Everything older, oldest first. A rotation does not re-encrypt what is already stored —
            // each item keeps the generation it was sealed under — so a client holding only the
            // current key would read the vault's whole history as corrupt. See RekeyVaultRequest.
            var prior = mine
                .Where(g => g.KeyGeneration < vault.KeyGeneration)
                .OrderBy(g => g.KeyGeneration)
                .Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
                .ToArray();

            summaries.Add(new VaultSummary(
                VaultId: vault.Id,
                Name: vault.Name,
                IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal,
                TeamId: vault.TeamId,
                KeyGeneration: (uint)vault.KeyGeneration,
                Permissions: (int)access.Permissions,

                // Null means temporarily unreadable, not forbidden. A member holding Share must
                // re-wrap it; the client has to say so rather than showing an empty vault.
                WrappedVaultKey: grant?.WrappedKey,

                RekeyRequired: vault.RekeyRequired,
                PriorKeyWraps: prior));
        }

        return summaries;
    }

    private static KdfParameters? ToContract(UserKeyWrap? wrap)
    {
        // The CHECK constraint on user_key_wrap guarantees a passphrase wrap carries every
        // parameter, so a partial row cannot exist. Pattern-matched anyway rather than asserted,
        // because a null here would become an unopenable vault rather than an exception.
        if (wrap is null
            || wrap.KdfAlgorithm is null
            || wrap.KdfSalt is null
            || wrap.KdfMemoryKibibytes is null
            || wrap.KdfPasses is null
            || wrap.KdfParallelism is null)
        {
            return null;
        }

        return new KdfParameters(
            wrap.KdfAlgorithm,
            wrap.KdfSalt,
            wrap.KdfMemoryKibibytes.Value,
            wrap.KdfPasses.Value,
            wrap.KdfParallelism.Value);
    }
}
