using System.Globalization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;

namespace DodoSSH.Api.Features.Teams;

/// <summary>The result of a team access check.</summary>
/// <param name="Team">The team, when the caller is an active member.</param>
/// <param name="Role">The caller's role.</param>
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
{
    /// <summary>Whether the caller is in this team at all.</summary>
    public bool Granted => Team is not null;

    /// <summary>
    /// Whether the caller may manage members and vaults.
    /// </summary>
    /// <remarks>
    /// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
    /// it: those flags describe a vault, and adding a member is not an operation on any vault.
    /// </remarks>
    public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;

    /// <summary>
    /// Whether the caller owns this team.
    /// </summary>
    /// <remarks>
    /// Distinct from <see cref="CanAdminister"/>, and the distinction is load-bearing: an admin may
    /// manage members and vaults, but archiving a team and handing it to somebody else are the two
    /// things that decide whether the team continues to exist and who controls it. Gating those on
    /// <see cref="CanAdminister"/> would let anybody the owner promoted take the team from them.
    /// </remarks>
    public bool IsOwner => Role is TeamRole.Owner;

    /// <summary>Denied access.</summary>
    public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}

/// <summary>
/// Teams and their membership.
/// </summary>
/// <remarks>
/// <para>
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
/// require the server to hold a key, which is the one thing this design is built to avoid.
/// </para>
/// <para>
/// The reverse direction is the honest half of the same split. Removing a member revokes their
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
/// imply more than it delivers.
/// </para>
/// </remarks>
internal sealed class TeamService(
    DodoDbContext database,
    TimeProvider clock,
    IVaultEventPublisher events,
    ILogger<TeamService> logger)
{
    /// <summary>Longest acceptable slug. Matches the column.</summary>
    private const int MaxSlugLength = 128;

    /// <summary>Longest acceptable display name. Matches the column.</summary>
    private const int MaxNameLength = 256;

    /// <summary>Longest acceptable description. Matches the column.</summary>
    private const int MaxDescriptionLength = 2048;

    /// <summary>Creates a team, with the caller as its owner.</summary>
    /// <remarks>
    /// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
    /// lost can be re-sent verbatim and returns the same team rather than creating a second one under
    /// a name the user meant to type once. A different body under the same id is a client that has
    /// lost track of its own state and is refused rather than silently reinterpreted.
    /// </remarks>
    internal async Task<TeamSummary> CreateAsync(
        UserAccount user,
        CreateTeamRequest request,
        CancellationToken cancellationToken)
    {
        var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
        var slug = RequireSlug(request.Slug);
        var description = OptionalText(request.Description, MaxDescriptionLength);

        if (request.TeamId == Guid.Empty)
        {
            throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
        }

        var existing = await database.Teams
            .SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
            .ConfigureAwait(false);

        if (existing is not null)
        {
            return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
                .ConfigureAwait(false);
        }

        var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);

        try
        {
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (DbUpdateException exception) when (IsUniqueViolation(exception))
        {
            // The partial unique index on slug. Reported as its own code because it is the one
            // failure the caller could not have foreseen from their own input.
            throw new TeamSlugTakenException(
                $"The slug '{slug}' is already in use. Choose another.");
        }

        TeamLog.TeamCreated(logger, team.Id, user.Id);

        return new TeamSummary(
            team.Id, team.Name, team.Slug, team.Description,
            TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
    }

    /// <summary>
    /// Adds the team row and the creator's owner membership.
    /// </summary>
    /// <remarks>
    /// The two together, never one: a team with no members has nobody who can add any, and the row
    /// would have to be found and fixed by hand.
    /// </remarks>
    private Team AddTeamWithOwner(
        UserAccount user,
        Guid teamId,
        string name,
        string slug,
        string? description)
    {
        var now = clock.GetUtcNow();

        var team = new Team
        {
            Id = teamId,
            Name = name,
            Slug = slug,
            Description = description,
            CreatedByUserId = user.Id,
            CreatedAtUtc = now,
        };

        database.Teams.Add(team);

        database.TeamMemberships.Add(new TeamMembership
        {
            Id = Guid.CreateVersion7(),
            TeamId = team.Id,
            UserId = user.Id,
            Role = TeamRole.Owner,
            Status = MembershipStatus.Active,
            JoinedAtUtc = now,
            CreatedAtUtc = now,
        });

        return team;
    }

    /// <summary>Renames a team, or changes its description.</summary>
    /// <remarks>
    /// The slug is not touched and cannot be. It is unique only among live teams, so a rename could
    /// take a slug an archived team still holds, and that archived team could then never be restored
    /// — a rename that quietly forecloses somebody else's recovery is worse than one the product
    /// simply does not offer. There is also nowhere to record that this happened: <c>team</c> has no
    /// updated-at column, so nothing can show "edited" and the log line is the only trace.
    /// </remarks>
    /// <param name="actor">Who is renaming it.</param>
    /// <param name="access">
    /// The caller's resolved access. The <em>role</em> is taken from here rather than assumed, because
    /// an admin may rename a team and telling them the response says <see cref="TeamRole.Owner"/> would
    /// hand a client a summary claiming rights it does not have — and this is the one write on a team
    /// that both an admin and an owner can perform.
    /// </param>
    /// <param name="request">The new name and description.</param>
    /// <param name="cancellationToken">Cancellation.</param>
    internal async Task<TeamSummary> UpdateAsync(
        UserAccount actor,
        TeamAccess access,
        UpdateTeamRequest request,
        CancellationToken cancellationToken)
    {
        var team = access.Team
            ?? throw new TeamInvalidException("That team is not there.");

        ArgumentNullException.ThrowIfNull(request);

        team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
        team.Description = OptionalText(request.Description, MaxDescriptionLength);

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.TeamUpdated(logger, team.Id, actor.Id);

        var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false);
        var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);

        return new TeamSummary(
            team.Id, team.Name, team.Slug, team.Description,
            ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc);
    }

    /// <summary>
    /// Archives a team, provided it owns no vaults.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The vault check is the whole of this operation's safety and it refuses rather than
    /// cascades.</b> Archiving a team hides it from every member's list at once, and a team vault
    /// resolves through membership — so archiving one that still owned vaults would take those vaults
    /// away from people who hold keys to them, silently, including the caller. The way out is to delete
    /// those vaults first — <c>VaultGrantService.DeleteVaultAsync</c>, which asks its own question and
    /// withdraws every key — and then archive what is left. A refusal that names a route is worth more
    /// than a cascade, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and
    /// a quiet removal is not.
    /// </para>
    /// <para>
    /// Deleting the <em>last</em> vault of a team made to carry it archives that team on the way past, so
    /// the ordinary case never reaches this refusal at all. See <c>DeleteVaultEndpoint</c>.
    /// </para>
    /// <para>
    /// Memberships are archived with the team, in one transaction, because a live membership pointing
    /// at an archived team is a row every membership query has to remember to exclude twice. The slug
    /// is freed by the same write — the unique index is filtered on <c>deleted_at_utc IS NULL</c> — so
    /// a team can be recreated under the archived one's slug, and restoring the archived one would
    /// then collide. Only an operator can restore it, and this is the thing they have to look at
    /// first.
    /// </para>
    /// </remarks>
    internal async Task ArchiveAsync(
        UserAccount actor,
        Team team,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(team);

        var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);

        if (vaultCount > 0)
        {
            throw new TeamNotEmptyException(
                string.Create(
                    CultureInfo.InvariantCulture,
                    $"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
        }

        var now = clock.GetUtcNow();
        var strategy = database.Database.CreateExecutionStrategy();

        var archived = await strategy.ExecuteAsync(async () =>
        {
            var transaction = await database.Database
                .BeginTransactionAsync(cancellationToken)
                .ConfigureAwait(false);
            await using var _ = transaction.ConfigureAwait(false);

            var memberships = await database.TeamMemberships
                .Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
                .ToListAsync(cancellationToken)
                .ConfigureAwait(false);

            foreach (var membership in memberships)
            {
                membership.Status = MembershipStatus.Revoked;
                membership.DeletedAtUtc = now;
            }

            team.DeletedAtUtc = now;

            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);

            return memberships.Count;
        }).ConfigureAwait(false);

        TeamLog.TeamArchived(logger, team.Id, actor.Id, archived);
    }

    /// <summary>
    /// Hands ownership to another active member, demoting the outgoing owner to admin.
    /// </summary>
    /// <remarks>
    /// <para>
    /// One transaction, because ownership is sole and the two writes are not separable: promoting
    /// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure
    /// between them leaves whichever of those the ordering chose. That is why this is not two calls
    /// to <see cref="ChangeRoleAsync"/>, which refuses <see cref="TeamRole.Owner"/> outright.
    /// </para>
    /// <para>
    /// The recipient must already be an active member. Adding somebody and handing them the team in
    /// one step would let an id supplied once take it, and the reason
    /// <see cref="AddMemberAsync"/> refuses the owner role is the same one.
    /// </para>
    /// <para>
    /// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key
    /// grants and flag every team vault for rekey — a far larger act than the one asked for, and
    /// somebody handing over a team is usually staying in it.
    /// </para>
    /// </remarks>
    internal async Task TransferOwnershipAsync(
        UserAccount actor,
        Guid teamId,
        TransferTeamOwnershipRequest request,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(request);

        if (request.UserId == actor.Id)
        {
            throw new TeamInvalidException("You already own this team.");
        }

        var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken)
            .ConfigureAwait(false);

        // Belt and braces: the endpoint already refused anybody who is not the owner. Checking again
        // here keeps the invariant with the code that enforces it rather than one layer away.
        if (outgoing.Role != TeamRole.Owner)
        {
            throw new LastTeamOwnerException("Only this team's owner can hand it over.");
        }

        var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
            .ConfigureAwait(false);

        var strategy = database.Database.CreateExecutionStrategy();

        await strategy.ExecuteAsync(async () =>
        {
            var transaction = await database.Database
                .BeginTransactionAsync(cancellationToken)
                .ConfigureAwait(false);
            await using var _ = transaction.ConfigureAwait(false);

            incoming.Role = TeamRole.Owner;
            outgoing.Role = TeamRole.Admin;

            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        }).ConfigureAwait(false);

        TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId);
    }

    /// <summary>Lists the teams the caller is an active member of.</summary>
    internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
        UserAccount user,
        CancellationToken cancellationToken)
    {
        var memberships = await database.TeamMemberships
            .Where(m => m.UserId == user.Id
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

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

        var teamIds = memberships.Select(m => m.TeamId).ToArray();

        var teams = await database.Teams
            .Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
            .OrderBy(t => t.CreatedAtUtc)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var memberCounts = await database.TeamMemberships
            .Where(m => teamIds.Contains(m.TeamId)
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .GroupBy(m => m.TeamId)
            .Select(g => new { TeamId = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
            .ConfigureAwait(false);

        var vaultCounts = await database.Vaults
            .Where(v => v.OwnerKind == VaultOwnerKind.Team
                && v.TeamId != null
                && teamIds.Contains(v.TeamId.Value)
                && v.DeletedAtUtc == null)
            .GroupBy(v => v.TeamId!.Value)
            .Select(g => new { TeamId = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
            .ConfigureAwait(false);

        return
        [
            .. teams.Select(team => new TeamSummary(
                team.Id,
                team.Name,
                team.Slug,
                team.Description,
                ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
                memberCounts.GetValueOrDefault(team.Id),
                vaultCounts.GetValueOrDefault(team.Id),
                team.CreatedAtUtc)),
        ];
    }

    /// <summary>
    /// Lists a team's members.
    /// </summary>
    /// <remarks>
    /// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
    /// to know who else already holds one, and a directory that only administrators can read makes
    /// the sharing graph less visible to the people it is about than it is to the operator — who can
    /// read it straight out of the database either way.
    /// </remarks>
    internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
        Guid teamId,
        CancellationToken cancellationToken)
    {
        var memberships = await database.TeamMemberships
            .Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
            .Include(m => m.User)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

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

        var userIds = memberships.Select(m => m.UserId).ToArray();

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

        var enrolledIds = enrolled.ToHashSet();

        return
        [
            .. memberships
                .OrderByDescending(m => m.Role)
                .ThenBy(m => m.CreatedAtUtc)
                .Select(m => new TeamMemberSummary(
                    m.UserId,
                    m.User?.Email,
                    m.User?.DisplayName,
                    ToContract(m.Role),
                    ToContract(m.Status),
                    enrolledIds.Contains(m.UserId),
                    m.JoinedAtUtc,
                    m.User?.LastSeenAtUtc)),
        ];
    }

    /// <summary>Adds a member, or reactivates one who was removed.</summary>
    /// <remarks>
    /// <para>
    /// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
    /// somebody else is a transfer rather than an addition — a different operation, with its own
    /// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight
    /// to owner would hand a team to an id typed once.
    /// </para>
    /// <para>
    /// Re-adding a removed member reactivates the original row rather than inserting a second one,
    /// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
    /// restore their revoked key grants: those were wrapped to a generation the vault has since been
    /// flagged to leave behind, and a member holding Share has to wrap the key afresh.
    /// </para>
    /// <para>
    /// Enrollment is not required of the account being added, and asking for it would be asking the
    /// wrong question. A membership is authorization and grants nothing readable — that is the whole
    /// of ADR 0009 — so somebody can be added on Monday and publish a key on Tuesday, which is what
    /// <c>TeamMemberSummary.IsEnrolled</c> is for.
    /// </para>
    /// </remarks>
    internal async Task<TeamMemberSummary> AddMemberAsync(
        UserAccount actor,
        Guid teamId,
        AddTeamMemberRequest request,
        CancellationToken cancellationToken)
    {
        var role = ToDomain(request.Role);

        if (role is TeamRole.Unspecified or TeamRole.Owner)
        {
            throw new TeamInvalidException(
                "Add a member as viewer, member or admin. Ownership is sole and is not transferred "
                + "by adding somebody.");
        }

        var target = await ResolveTargetAsync(request, cancellationToken).ConfigureAwait(false);

        var now = clock.GetUtcNow();

        var membership = await database.TeamMemberships
            .SingleOrDefaultAsync(
                m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        if (membership is null)
        {
            membership = new TeamMembership
            {
                Id = Guid.CreateVersion7(),
                TeamId = teamId,
                UserId = target.Id,
                InvitedByUserId = actor.Id,
                CreatedAtUtc = now,
            };

            database.TeamMemberships.Add(membership);
        }
        else if (membership.Status == MembershipStatus.Active)
        {
            throw new TeamInvalidException(
                "That account is already a member of this team. Change their role instead.");
        }

        membership.Role = role;
        membership.Status = MembershipStatus.Active;
        membership.JoinedAtUtc = now;

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);

        // Membership is what the server will serve, so every vault this team owns has just appeared in
        // the new member's list — before anybody wraps a key to them, which is a separate act and its
        // own notice. Told at once rather than on their next pass. See ADR 0012.
        events.VaultAccessChanged(target.Id);

        return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Finds the account an add is aimed at, by id when the caller has one and by address otherwise.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The two are not interchangeable and the order matters. An id came from a directory lookup the
    /// caller has already made, so it names an account whose key they have seen; an address is what is
    /// left when the directory could not answer, which it cannot for anybody who has signed in but not
    /// yet published a key.
    /// </para>
    /// <para>
    /// Only the active, undeleted account matters here, and the address is matched the way the
    /// directory matches it — the email column is citext, so the comparison is case-insensitive in the
    /// database and the partial unique index on it means at most one row can answer.
    /// </para>
    /// <para>
    /// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
    /// already know from the lookup that produced it. An address is answered only for an admin or owner
    /// of the team the add names — checked by the endpoint before this runs — and is the same fact the
    /// member list would show them a moment later. It carries its own code so the caller can say what
    /// has to happen next — that person signing in here once — rather than reporting a failure at
    /// somebody who simply is not here yet.
    /// </para>
    /// </remarks>
    private async Task<UserAccount> ResolveTargetAsync(
        AddTeamMemberRequest request,
        CancellationToken cancellationToken)
    {
        if (request.UserId != Guid.Empty)
        {
            return await database.Users
                .SingleOrDefaultAsync(
                    u => u.Id == request.UserId && u.DeletedAtUtc == null,
                    cancellationToken)
                .ConfigureAwait(false)

                ?? throw new NoSuchAccountException(
                    "No such account on this server. A member has to sign in here once before they "
                    + "can be added — that is what creates the account.");
        }

        var email = (request.Email ?? string.Empty).Trim();

        if (email.Length == 0)
        {
            throw new TeamInvalidException(
                "Say who to add: either a user id from the directory, or the email address they sign "
                + "in with.");
        }

        return await database.Users
            .SingleOrDefaultAsync(
                u => u.Email == email && u.DeletedAtUtc == null && u.Status == UserStatus.Active,
                cancellationToken)
            .ConfigureAwait(false)

            ?? throw new NoSuchAccountException(
                "No account here uses that address yet. Ask them to sign in to this server once, "
                + "which is what creates the account, and then add them.");
    }

    /// <remarks>
    /// Enrollment is looked up rather than inferred, because it is the one field on a member row that
    /// is about them and not about the membership: somebody can be added on Monday and set their
    /// vault up on Tuesday, and the interface has to stop offering to share with them in between.
    /// </remarks>
    private async Task<TeamMemberSummary> DescribeAsync(
        UserAccount user,
        TeamMembership membership,
        CancellationToken cancellationToken)
    {
        var isEnrolled = await database.UserKeys
            .AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
            .ConfigureAwait(false);

        return new TeamMemberSummary(
            user.Id,
            user.Email,
            user.DisplayName,
            ToContract(membership.Role),
            ToContract(membership.Status),
            isEnrolled,
            membership.JoinedAtUtc,
            user.LastSeenAtUtc);
    }

    /// <summary>Changes a member's role.</summary>
    internal async Task<TeamMemberSummary> ChangeRoleAsync(
        UserAccount actor,
        Guid teamId,
        Guid memberId,
        ChangeTeamMemberRoleRequest request,
        CancellationToken cancellationToken)
    {
        var role = ToDomain(request.Role);

        if (role is TeamRole.Unspecified or TeamRole.Owner)
        {
            throw new TeamInvalidException(
                "A member may be made a viewer, a member or an admin. Ownership is sole and is not "
                + "granted this way.");
        }

        var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
            .ConfigureAwait(false);

        // Demoting the owner here would leave the team ownerless, because this operation cannot
        // appoint a replacement in the same breath. Transferring can, and does both at once — so
        // the refusal names it rather than saying the thing is impossible.
        if (membership.Role == TeamRole.Owner)
        {
            throw new LastTeamOwnerException(
                "This team's owner cannot be demoted on its own. Transfer ownership to another "
                + "member instead: that hands the team over and makes the outgoing owner an admin, "
                + "in one step, so the team is never left with nobody who can manage it.");
        }

        membership.Role = role;

        await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);

        // The account cannot be missing — a membership has a foreign key to it — but the query is
        // written to tolerate it rather than to assert, because a null here would become an
        // exception on a change that has already been committed.
        var user = await database.Users
            .SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
            .ConfigureAwait(false);

        return user is null
            ? new TeamMemberSummary(
                memberId, null, null, ToContract(role), ToContract(membership.Status), false,
                membership.JoinedAtUtc)
            : await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// Removes a member, revoking every vault key grant they hold from this team.
    /// </summary>
    /// <remarks>
    /// <para>
    /// One transaction, because the two halves are not separable: a membership revoked without its
    /// grants leaves a departed member holding a key the server will happily keep serving, and grants
    /// revoked without the membership leaves an active member whose vaults have silently stopped
    /// opening.
    /// </para>
    /// <para>
    /// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
    /// every item's data key under a new vault key and can only be performed by a client that holds
    /// the current one; the server can record that one is owed and nothing more. That is M5's key
    /// rotation, and until it lands the flag is what the interface reads to say so out loud.
    /// </para>
    /// </remarks>
    internal async Task RemoveMemberAsync(
        UserAccount actor,
        Guid teamId,
        Guid memberId,
        CancellationToken cancellationToken)
    {
        var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
            .ConfigureAwait(false);

        if (membership.Role == TeamRole.Owner)
        {
            throw new LastTeamOwnerException(
                "This team's owner cannot be removed while they own it, because that would leave the "
                + "team with nobody who can manage it. Transfer ownership to another member first — "
                + "the outgoing owner becomes an admin and can then be removed like anybody else.");
        }

        var now = clock.GetUtcNow();
        var strategy = database.Database.CreateExecutionStrategy();

        var revoked = await strategy.ExecuteAsync(async () =>
        {
            var transaction = await database.Database
                .BeginTransactionAsync(cancellationToken)
                .ConfigureAwait(false);
            await using var _ = transaction.ConfigureAwait(false);

            membership.Status = MembershipStatus.Revoked;
            membership.DeletedAtUtc = now;

            var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
                .ConfigureAwait(false);

            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);

            return count;
        }).ConfigureAwait(false);

        TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);

        // After the commit, so their client re-reads a list the server has already stopped serving
        // those vaults from. Their open socket re-resolves as it forwards this, which is what stops it
        // announcing changes to vaults they have just lost.
        events.VaultAccessChanged(memberId);
    }

    /// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
    private async Task<int> RevokeTeamGrantsAsync(
        Guid teamId,
        Guid memberId,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        var vaults = await database.Vaults
            .Where(v => v.TeamId == teamId
                && v.OwnerKind == VaultOwnerKind.Team
                && v.DeletedAtUtc == null)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        if (vaults.Count == 0)
        {
            return 0;
        }

        var vaultIds = vaults.Select(v => v.Id).ToArray();

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

        foreach (var grant in grants)
        {
            grant.State = GrantState.Revoked;
            grant.RevokedAtUtc = now;
        }

        // Flagged whether or not this member held a grant. Somebody who was a member without a key
        // still saw the vault's existence, its item count and its plaintext columns, and the vault's
        // key is what a rekey would change — so "they never had a grant" is not a reason to leave the
        // flag clear.
        foreach (var vault in vaults)
        {
            vault.RekeyRequired = true;
            vault.RekeyReason = RekeyReason.MemberRemoved;
            vault.UpdatedAtUtc = now;
        }

        return grants.Count;
    }

    /// <summary>Reads the caller's own membership, for authorization checks.</summary>
    internal Task<TeamMembership?> FindActiveMembershipAsync(
        Guid teamId,
        Guid userId,
        CancellationToken cancellationToken) =>
        database.TeamMemberships.SingleOrDefaultAsync(
            m => m.TeamId == teamId
                && m.UserId == userId
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null,
            cancellationToken);

    /// <summary>
    /// Resolves what the caller may do with a team.
    /// </summary>
    /// <remarks>
    /// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
    /// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
    /// forbidden" is an oracle for other tenants' team ids.
    /// </remarks>
    internal async Task<TeamAccess> ResolveAsync(
        Guid userId,
        Guid teamId,
        CancellationToken cancellationToken)
    {
        var team = await database.Teams
            .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
            .ConfigureAwait(false);

        if (team is null)
        {
            return TeamAccess.Denied;
        }

        var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
            .ConfigureAwait(false);

        return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
    }

    private async Task<TeamMembership> RequireMembershipAsync(
        Guid teamId,
        Guid memberId,
        CancellationToken cancellationToken)
    {
        var membership = await database.TeamMemberships
            .SingleOrDefaultAsync(
                m => m.TeamId == teamId
                    && m.UserId == memberId
                    && m.Status == MembershipStatus.Active
                    && m.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        return membership
            ?? throw new TeamInvalidException("That account is not an active member of this team.");
    }

    /// <summary>Counts a team's active members.</summary>
    private Task<int> CountMembersAsync(Guid teamId, CancellationToken cancellationToken) =>
        database.TeamMemberships.CountAsync(
            m => m.TeamId == teamId
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null,
            cancellationToken);

    /// <summary>Counts the vaults a team owns.</summary>
    /// <remarks>
    /// Filtered on <c>OwnerKind</c> as well as on the id, matching <see cref="ListAsync"/>. A vault
    /// carrying a team id it does not belong to would otherwise be counted here and not there, and
    /// this count is what decides whether a team may be archived.
    /// </remarks>
    private Task<int> CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) =>
        database.Vaults.CountAsync(
            v => v.TeamId == teamId
                && v.OwnerKind == VaultOwnerKind.Team
                && v.DeletedAtUtc == null,
            cancellationToken);

    /// <remarks>
    /// A retry is the same id with the same name and slug, from the account that owns it. Anything
    /// else under an id that is already taken is refused: silently returning somebody else's team
    /// would be an existence oracle, and returning a differently-named one would tell a client its
    /// rename succeeded when nothing changed.
    /// </remarks>
    private async Task<TeamSummary> ResolveExistingAsync(
        UserAccount user,
        Team existing,
        string name,
        string slug,
        CancellationToken cancellationToken)
    {
        var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
            .ConfigureAwait(false);

        var isRetry = membership?.Role == TeamRole.Owner
            && existing.DeletedAtUtc == null
            && string.Equals(existing.Name, name, StringComparison.Ordinal)
            && string.Equals(existing.Slug, slug, StringComparison.Ordinal);

        if (!isRetry)
        {
            throw new TeamInvalidException(
                "That team id is already in use. Generate a new UUIDv7 and retry.");
        }

        var memberCount = await database.TeamMemberships
            .CountAsync(
                m => m.TeamId == existing.Id
                    && m.Status == MembershipStatus.Active
                    && m.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        var vaultCount = await database.Vaults
            .CountAsync(
                v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        return new TeamSummary(
            existing.Id, existing.Name, existing.Slug, existing.Description,
            TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
    }

    /// <summary>
    /// Validates a slug.
    /// </summary>
    /// <remarks>
    /// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
    /// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
    /// value that renders differently from how it compares is how two teams end up looking distinct
    /// in a list and colliding on insert.
    /// </remarks>
    private static string RequireSlug(string? value)
    {
        var slug = (value ?? string.Empty).Trim();

        if (slug.Length is 0 or > MaxSlugLength)
        {
            throw new TeamInvalidException(
                $"A slug of 1 to {MaxSlugLength} characters is required.");
        }

        var previousWasHyphen = false;

        for (var index = 0; index < slug.Length; index++)
        {
            var character = slug[index];
            var isHyphen = character == '-';

            var acceptable = (character is >= 'a' and <= 'z')
                || (character is >= '0' and <= '9')
                || isHyphen;

            if (!acceptable
                || (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
            {
                throw new TeamInvalidException(
                    "A slug is lowercase letters, digits and single hyphens, and cannot start or end "
                    + "with a hyphen.");
            }

            previousWasHyphen = isHyphen;
        }

        return slug;
    }

    private static string RequireText(string? value, string field, int maxLength)
    {
        var text = (value ?? string.Empty).Trim();

        if (text.Length == 0 || text.Length > maxLength)
        {
            throw new TeamInvalidException(
                string.Create(
                    CultureInfo.InvariantCulture,
                    $"{field} is required, and at most {maxLength} characters."));
        }

        return text;
    }

    private static string? OptionalText(string? value, int maxLength)
    {
        var text = value?.Trim();

        if (string.IsNullOrEmpty(text))
        {
            return null;
        }

        if (text.Length > maxLength)
        {
            throw new TeamInvalidException(
                string.Create(
                    CultureInfo.InvariantCulture,
                    $"A description is at most {maxLength} characters."));
        }

        return text;
    }

    /// <remarks>
    /// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
    /// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
    /// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
    /// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
    /// </remarks>
    private static TeamRole ToDomain(TeamMemberRole role) => role switch
    {
        TeamMemberRole.Viewer => TeamRole.Viewer,
        TeamMemberRole.Member => TeamRole.Member,
        TeamMemberRole.Admin => TeamRole.Admin,
        TeamMemberRole.Owner => TeamRole.Owner,
        _ => TeamRole.Unspecified,
    };

    private static TeamMemberRole ToContract(TeamRole role) => role switch
    {
        TeamRole.Viewer => TeamMemberRole.Viewer,
        TeamRole.Member => TeamMemberRole.Member,
        TeamRole.Admin => TeamMemberRole.Admin,
        TeamRole.Owner => TeamMemberRole.Owner,
        _ => TeamMemberRole.Unspecified,
    };

    private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
    {
        MembershipStatus.Invited => TeamMemberStatus.Invited,
        MembershipStatus.Active => TeamMemberStatus.Active,
        MembershipStatus.Revoked => TeamMemberStatus.Revoked,
        _ => TeamMemberStatus.Unspecified,
    };

    private static bool IsUniqueViolation(DbUpdateException exception) =>
        string.Equals(
            (exception.InnerException as PostgresException)?.SqlState,
            PostgresErrorCodes.UniqueViolation,
            StringComparison.Ordinal);
}
