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

namespace DodoSSH.Api.Features.Teams;

/// <summary>
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
/// </summary>
/// <remarks>
/// <para>
/// <b>An invitation is a standing instruction, not a message and not a token.</b> This server has no
/// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row
/// says "the next account to sign in with this address joins this team as this role", and telling
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
/// path — a link nobody can deliver would be worse than none.
/// </para>
/// <para>
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
/// else's address walk into their team — which is precisely the attack
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
/// </para>
/// <para>
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
/// the first half of it earlier.
/// </para>
/// </remarks>
internal sealed class TeamInvitationService(
    DodoDbContext database,
    TimeProvider clock,
    ILogger<TeamInvitationService> logger)
    : ITeamInvitationClaim
{
    /// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
    private const int MaxEmailLength = 320;

    /// <summary>
    /// How long an invitation stays claimable.
    /// </summary>
    /// <remarks>
    /// Fourteen days, and finite for a reason rather than as a default. An invitation that never
    /// expired would be a standing offer against an address, and addresses are reassigned — a
    /// company address handed to the next person to hold the job would let them into a team the
    /// person who left was invited to. Fourteen days is long enough to survive a holiday and short
    /// enough that a forgotten invitation lapses rather than waiting.
    /// </remarks>
    private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);

    /// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
    /// <remarks>
    /// Every member may read this, as with the members list and for the same reason: whoever is about
    /// to be handed a vault key needs to see who else is on their way into the team. Accepted and
    /// revoked rows are returned too, so the screen can show that an invitation was taken up rather
    /// than having it silently vanish and read as never sent.
    /// </remarks>
    internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
        Guid teamId,
        CancellationToken cancellationToken)
    {
        var invitations = await database.TeamInvitations
            .Where(i => i.TeamId == teamId)
            .OrderByDescending(i => i.CreatedAtUtc)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var now = clock.GetUtcNow();

        return [.. invitations.Select(invitation => Describe(invitation, now))];
    }

    /// <summary>Invites an address to a team.</summary>
    /// <remarks>
    /// <para>
    /// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
    /// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
    /// for which addresses have accounts, answerable by anybody willing to create a team first. It
    /// would also be answering a question the caller did not ask: they want that person in the team,
    /// and whether the account exists yet only changes how soon it happens. An existing account picks
    /// the invitation up on its next request, within the hour.
    /// </para>
    /// <para>
    /// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
    /// address returns the existing invitation rather than a second one. A different address under an
    /// id already in use is refused rather than reinterpreted.
    /// </para>
    /// </remarks>
    internal async Task<TeamInvitationSummary> CreateAsync(
        UserAccount actor,
        Guid teamId,
        CreateTeamInvitationRequest request,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(request);

        var email = RequireEmail(request.Email);
        var role = RequireInvitableRole(request.Role);

        if (request.InvitationId == Guid.Empty)
        {
            throw new TeamInvitationInvalidException(
                "An invitation id is required. Generate a UUIDv7 on the client.");
        }

        var now = clock.GetUtcNow();

        var existing = await database.TeamInvitations
            .SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
            .ConfigureAwait(false);

        if (existing is not null)
        {
            return ResolveExisting(existing, teamId, email, now);
        }

        await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);

        var invitation = new TeamInvitation
        {
            Id = request.InvitationId,
            TeamId = teamId,
            Email = email,
            Role = role,
            InvitedByUserId = actor.Id,
            CreatedAtUtc = now,
            ExpiresAtUtc = now + Lifetime,
        };

        database.TeamInvitations.Add(invitation);

        try
        {
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (DbUpdateException exception) when (IsUniqueViolation(exception))
        {
            // The partial unique index on (team, email) among live invitations. Reported as its own
            // sentence because it is the one failure the caller could not see coming from their own
            // input — somebody else may have invited the same person a minute earlier.
            throw new TeamInvitationInvalidException(
                "There is already an invitation to that address for this team. Withdraw it first if "
                + "you want to invite them at a different role.");
        }

        TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);

        return Describe(invitation, now);
    }

    /// <remarks>
    /// A retry is the same id against the same team and address. Anything else under an id already in
    /// use is refused rather than reinterpreted: returning a differently-addressed invitation would
    /// tell a client its invite went to somebody it did not.
    /// </remarks>
    private static TeamInvitationSummary ResolveExisting(
        TeamInvitation existing,
        Guid teamId,
        string email,
        DateTimeOffset now)
    {
        var isRetry = existing.TeamId == teamId
            && string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);

        return isRetry
            ? Describe(existing, now)
            : throw new TeamInvitationInvalidException(
                "That invitation id is already in use. Generate a new UUIDv7 and retry.");
    }

    private static TeamRole RequireInvitableRole(TeamMemberRole role)
    {
        var domain = ToDomain(role);

        return domain is TeamRole.Unspecified or TeamRole.Owner
            ? throw new TeamInvitationInvalidException(
                "Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
                + "deliberately, never conferred by an address signing in.")
            : domain;
    }

    /// <summary>Withdraws an invitation that has not been taken up.</summary>
    /// <returns>Whether there was a live invitation to withdraw.</returns>
    /// <remarks>
    /// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
    /// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
    /// is a different operation with different consequences — it revokes their vault key grants and
    /// flags every team vault for rekey.
    /// </remarks>
    internal async Task<bool> RevokeAsync(
        UserAccount actor,
        Guid teamId,
        Guid invitationId,
        CancellationToken cancellationToken)
    {
        var invitation = await database.TeamInvitations
            .SingleOrDefaultAsync(
                i => i.Id == invitationId
                    && i.TeamId == teamId
                    && i.AcceptedAtUtc == null
                    && i.RevokedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        if (invitation is null)
        {
            return false;
        }

        invitation.RevokedAtUtc = clock.GetUtcNow();

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

        TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);

        return true;
    }

    /// <inheritdoc />
    public async Task<int> ClaimAsync(
        UserAccount user,
        string? email,
        bool emailVerified,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(user);

        if (string.IsNullOrWhiteSpace(email))
        {
            return 0;
        }

        var now = clock.GetUtcNow();

        var pending = await database.TeamInvitations
            .Where(i => i.Email == email
                && i.AcceptedAtUtc == null
                && i.RevokedAtUtc == null
                && i.ExpiresAtUtc > now)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

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

        if (!emailVerified)
        {
            // Logged rather than silent, and this is the only signal an operator gets that their
            // provider is not sending the claim. Without it, invitations would simply never work and
            // there would be nothing anywhere saying why.
            TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
            return 0;
        }

        return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Turns each claimable invitation into an active membership.</summary>
    private async Task<int> ApplyAsync(
        UserAccount user,
        List<TeamInvitation> pending,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        var teamIds = pending.Select(i => i.TeamId).ToArray();

        // Archived teams are excluded here as well as at archive time. An invitation issued moments
        // before an archive can still be in flight, and joining a team nobody can see is worse than
        // an invitation that quietly lapses.
        var liveTeamIds = await database.Teams
            .Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
            .Select(t => t.Id)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var live = liveTeamIds.ToHashSet();

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

        var claimed = 0;

        foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
        {
            invitation.AcceptedAtUtc = now;
            invitation.AcceptedByUserId = user.Id;

            if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
            {
                claimed++;

                TeamLog.InvitationClaimed(
                    logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
            }
        }

        return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Adds or reactivates the membership an invitation asks for.</summary>
    /// <returns>Whether the membership changed. False means they were already an active member.</returns>
    private bool Join(
        UserAccount user,
        TeamInvitation invitation,
        TeamMembership? membership,
        DateTimeOffset now)
    {
        if (membership is null)
        {
            database.TeamMemberships.Add(new TeamMembership
            {
                Id = Guid.CreateVersion7(),
                TeamId = invitation.TeamId,
                UserId = user.Id,
                Role = invitation.Role,
                Status = MembershipStatus.Active,
                InvitedByUserId = invitation.InvitedByUserId,
                JoinedAtUtc = now,
                CreatedAtUtc = now,
            });

            return true;
        }

        if (membership.Status == MembershipStatus.Active)
        {
            // Already in the team — the invitation is satisfied rather than applied. It must not
            // change a role somebody set deliberately in the meantime, which is what re-applying an
            // invitation issued weeks ago would silently do.
            return false;
        }

        // Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
        // as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
        // membership. Their revoked key grants are not restored — those were wrapped to a generation
        // the vault has since been flagged to leave behind.
        membership.Role = invitation.Role;
        membership.Status = MembershipStatus.Active;
        membership.JoinedAtUtc = now;

        return true;
    }

    /// <remarks>
    /// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
    /// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
    /// call would put violations from this table inside a filter written for exactly one race, and
    /// its rethrow would stop being correct.
    /// </remarks>
    private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
    {
        try
        {
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (DbUpdateException exception) when (IsUniqueViolation(exception))
        {
            // Two of this account's requests claiming at once. One wins; the other finds nothing
            // left to do on the next sweep. Swallowed rather than surfaced because this runs inside
            // the authorization middleware, where a throw is a 500 on a request that was otherwise
            // fine — and because the outcome the caller wanted has happened either way.
            foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
            {
                entry.State = EntityState.Detached;
            }

            return 0;
        }

        return claimed;
    }

    /// <remarks>
    /// Refused only for an account that is <em>already in this team</em> — a fact about a team the
    /// caller can see, so naming it leaks nothing. Whether an address has an account at all is
    /// deliberately not answered here; see <see cref="CreateAsync"/>.
    /// </remarks>
    private async Task RefuseIfAlreadyAMemberAsync(
        Guid teamId,
        string email,
        CancellationToken cancellationToken)
    {
        var isMember = await database.TeamMemberships
            .Where(m => m.TeamId == teamId
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .Join(
                database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
                m => m.UserId,
                u => u.Id,
                (m, u) => m.Id)
            .AnyAsync(cancellationToken)
            .ConfigureAwait(false);

        if (isMember)
        {
            throw new TeamInvitationInvalidException(
                "That address already belongs to a member of this team. Change their role instead.");
        }
    }

    /// <summary>Derives what has become of an invitation from its timestamps.</summary>
    /// <remarks>
    /// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
    /// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
    /// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
    /// </remarks>
    private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
    {
        var state = invitation switch
        {
            { AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
            { RevokedAtUtc: not null } => TeamInvitationState.Revoked,
            _ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
            _ => TeamInvitationState.Pending,
        };

        return new TeamInvitationSummary(
            invitation.Id,
            invitation.Email,
            ToContract(invitation.Role),
            state,
            invitation.InvitedByUserId,
            invitation.CreatedAtUtc,
            invitation.ExpiresAtUtc,
            invitation.AcceptedAtUtc);
    }

    /// <remarks>
    /// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
    /// with something either side, no spaces, and inside the length the column holds — and nothing
    /// more. A stricter address grammar here would reject addresses that a real identity provider
    /// will happily assert, and the only thing that ultimately decides whether an address is that
    /// person's is the provider marking it verified.
    /// </remarks>
    private static string RequireEmail(string? value)
    {
        var email = (value ?? string.Empty).Trim();

        var at = email.IndexOf('@', StringComparison.Ordinal);

        var acceptable = email.Length is > 2 and <= MaxEmailLength
            && at > 0
            && at == email.LastIndexOf('@')
            && at < email.Length - 1
            && !email.Any(char.IsWhiteSpace);

        return acceptable
            ? email
            : throw new TeamInvitationInvalidException(
                "That does not look like an email address. Invite the address they sign in with.");
    }

    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 bool IsUniqueViolation(DbUpdateException exception) =>
        string.Equals(
            (exception.InnerException as PostgresException)?.SqlState,
            PostgresErrorCodes.UniqueViolation,
            StringComparison.Ordinal);
}
