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

namespace DodoSSH.Api.Features.Identity;

/// <summary>
/// The public-key directory.
/// </summary>
/// <remarks>
/// <para>
/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security
/// decision rather than a convenience one. Two rules follow from that.
/// </para>
/// <para>
/// <b>Lookup by exact email, never by prefix.</b> There is no search, no wildcard and no listing of
/// everybody. A caller has to already know the address, which keeps this from being a way to
/// enumerate an organisation's staff out of a server that stores their addresses in plaintext.
/// </para>
/// <para>
/// <b>Lookup by id is restricted to people the caller shares a team with.</b> Ids come from a member
/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an
/// unrestricted id lookup would turn a leaked id from any source into a directory hit.
/// </para>
/// <para>
/// What this returns is <em>evidence</em>, not authority. A client must check the identity-provider
/// binding, compare against any fingerprint it has pinned, and confirm the key log head before
/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end
/// encryption entirely; see ADR 0001 and <c>DirectoryEntry</c>'s own remarks.
/// </para>
/// </remarks>
internal sealed class DirectoryService(DodoDbContext database)
{
    /// <summary>Looks a user up by exact email address.</summary>
    internal async Task<IReadOnlyList<DirectoryEntry>> FindByEmailAsync(
        string email,
        CancellationToken cancellationToken)
    {
        var normalised = email.Trim();

        if (normalised.Length == 0)
        {
            return [];
        }

        // The email column is citext, so this comparison is case-insensitive in the database and the
        // partial unique index on it means at most one row can match. Written as a list anyway
        // because the response shape must not have to change if a second issuer ever shares one.
        var users = await database.Users
            .Where(u => u.Email == normalised
                && u.DeletedAtUtc == null
                && u.Status == UserStatus.Active)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Looks up accounts the caller shares an active team with.</summary>
    internal async Task<IReadOnlyList<DirectoryEntry>> FindTeammatesAsync(
        Guid callerId,
        IReadOnlyList<Guid> userIds,
        CancellationToken cancellationToken)
    {
        if (userIds.Count == 0)
        {
            return [];
        }

        var teamIds = await database.TeamMemberships
            .Where(m => m.UserId == callerId
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .Select(m => m.TeamId)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        if (teamIds.Count == 0)
        {
            return [];
        }

        var visible = await database.TeamMemberships
            .Where(m => teamIds.Contains(m.TeamId)
                && userIds.Contains(m.UserId)
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .Select(m => m.UserId)
            .Distinct()
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var users = await database.Users
            .Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
    }

    /// <remarks>
    /// A user with no current key is dropped rather than returned with empty key fields. The entry
    /// exists to be wrapped to, and one carrying no key is something a caller would have to remember
    /// to check for — which is the kind of check that gets forgotten exactly once.
    /// </remarks>
    private async Task<IReadOnlyList<DirectoryEntry>> BuildAsync(
        List<UserAccount> users,
        CancellationToken cancellationToken)
    {
        if (users.Count == 0)
        {
            return [];
        }

        var userIds = users.Select(u => u.Id).ToArray();

        var keys = await database.UserKeys
            .Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        // The log position of the statement that introduced each key, so a client can compare what
        // it is told here against the append-only chain rather than taking this response on trust.
        var keyIds = keys.Select(k => k.UserId).ToArray();

        var sequences = await database.KeyLog
            .Where(e => keyIds.Contains(e.UserId))
            .GroupBy(e => new { e.UserId, e.Generation })
            .Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) })
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var entries = new List<DirectoryEntry>(users.Count);

        foreach (var user in users)
        {
            var key = keys.Find(k => k.UserId == user.Id);

            if (key is null)
            {
                continue;
            }

            var sequence = sequences
                .Find(s => s.UserId == user.Id && s.Generation == key.Generation)?
                .Sequence ?? 0;

            entries.Add(new DirectoryEntry(
                UserId: user.Id,
                Email: user.Email,
                DisplayName: user.DisplayName,
                EncryptionPublicKey: key.EncryptionPublicKey,
                SigningPublicKey: key.SigningPublicKey,
                Fingerprint: key.FingerprintSha256,
                KeyGeneration: key.Generation,
                KeyLogSequence: sequence));
        }

        return entries;
    }
}
