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

namespace DodoSSH.Api.Authorization;

/// <summary>The result of an access check.</summary>
/// <param name="Vault">The vault, when access was granted.</param>
/// <param name="Permissions">The caller's effective permissions.</param>
public readonly record struct VaultAccess(Vault? Vault, PermissionFlags Permissions)
{
    /// <summary>Whether the caller may act on the vault at all.</summary>
    public bool Granted => Vault is not null;

    /// <summary>Denied access.</summary>
    public static VaultAccess Denied => new(null, PermissionFlags.None);
}

/// <summary>Resolves what the caller may do with a vault.</summary>
public interface IVaultAccessService
{
    /// <summary>
    /// Resolves the caller's effective permissions on a vault.
    /// </summary>
    /// <remarks>
    /// Returns <see cref="VaultAccess.Denied"/> both when the vault does not exist and when the
    /// caller cannot see it. Deliberately indistinguishable: a distinct "exists but forbidden"
    /// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
    /// </remarks>
    Task<VaultAccess> ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);

    /// <summary>
    /// Lists every vault the caller can reach, with their effective permissions.
    /// </summary>
    /// <remarks>
    /// Shares the permission rules with <see cref="ResolveAsync"/> rather than reimplementing them
    /// for the list case. A listing that disagreed with the per-vault check would show a user a
    /// vault they then could not open, or worse, hide one they can.
    /// </remarks>
    Task<IReadOnlyList<VaultAccess>> ListAsync(Guid userId, CancellationToken cancellationToken);
}

/// <summary>
/// Vault access resolution.
/// </summary>
/// <remarks>
/// <para>
/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the
/// team's active members, with the role deciding how much. Everything else is denied, which is what
/// keeps an unimplemented ownership kind from falling through to a permissive default.
/// </para>
/// <para>
/// <b>Permission is not the same thing as readability.</b> This service decides what the
/// <em>server</em> will serve; whether the caller can decrypt what it serves depends on holding a
/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no
/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed —
/// and <c>VaultSummary.WrappedVaultKey</c> is null for exactly that case. Conflating the two here
/// would mean a newly added member's vault silently vanished from their list instead of appearing
/// and saying it is waiting for a key.
/// </para>
/// <para>
/// Deliberately not a database view. <c>v_user_vault_permission</c> was sketched for this, and the
/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the
/// authorization model somewhere migrations own and tests cannot reach without a container.
/// </para>
/// </remarks>
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
{
    /// <summary>Everything the owner of a personal vault may do with it.</summary>
    private const PermissionFlags OwnerPermissions =
        PermissionFlags.Read
        | PermissionFlags.Write
        | PermissionFlags.Connect
        | PermissionFlags.Share
        | PermissionFlags.Admin;

    /// <inheritdoc />
    public async Task<VaultAccess> ResolveAsync(
        Guid userId,
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        var vault = await database.Vaults
            .SingleOrDefaultAsync(v => v.Id == vaultId && v.DeletedAtUtc == null, cancellationToken)
            .ConfigureAwait(false);

        if (vault is null)
        {
            return VaultAccess.Denied;
        }

        if (vault.OwnerKind == VaultOwnerKind.Personal)
        {
            return vault.OwnerUserId == userId
                ? new VaultAccess(vault, OwnerPermissions)
                : VaultAccess.Denied;
        }

        if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
        {
            return VaultAccess.Denied;
        }

        var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false);

        return role is { } granted
            ? new VaultAccess(vault, ForRole(granted))
            : VaultAccess.Denied;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<VaultAccess>> ListAsync(
        Guid userId,
        CancellationToken cancellationToken)
    {
        // The memberships first, then one pass over the vaults. The alternative — a join per vault —
        // would be the same answer at more round trips, and a user belongs to a handful of teams.
        var roles = await database.TeamMemberships
            .Where(m => m.UserId == userId
                && m.Status == MembershipStatus.Active
                && m.DeletedAtUtc == null)
            .ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken)
            .ConfigureAwait(false);

        var teamIds = roles.Keys.ToArray();

        var vaults = await database.Vaults
            .Where(v => v.DeletedAtUtc == null
                && ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId)
                    || (v.OwnerKind == VaultOwnerKind.Team
                        && v.TeamId != null
                        && teamIds.Contains(v.TeamId.Value))))
            .OrderBy(v => v.CreatedAtUtc)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        var accessible = new List<VaultAccess>(vaults.Count);

        foreach (var vault in vaults)
        {
            if (vault.OwnerKind == VaultOwnerKind.Personal)
            {
                accessible.Add(new VaultAccess(vault, OwnerPermissions));
                continue;
            }

            // The dictionary lookup cannot miss — the query filtered on the same set — but a role
            // that somehow is not there must not become a permissive default.
            if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role))
            {
                accessible.Add(new VaultAccess(vault, ForRole(role)));
            }
        }

        return accessible;
    }

    /// <summary>
    /// Maps a team role onto vault permissions.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is
    /// expressed by granting narrowly. See <see cref="PermissionFlags"/>.
    /// </para>
    /// <para>
    /// <see cref="PermissionFlags.Connect"/> rides along with Read for every role that has it,
    /// because it is a user-interface hint rather than a boundary — a role that could read a private
    /// key but was refused Connect would be describing a restriction this architecture cannot
    /// enforce. Granting it to a viewer is honest about that; withholding it would not be.
    /// </para>
    /// <para>
    /// Owner and Admin resolve identically here on purpose. What separates them is what they may do
    /// to the <em>team</em> — appoint owners, delete it — which is not a vault permission and is
    /// checked where those operations live.
    /// </para>
    /// </remarks>
    private static PermissionFlags ForRole(TeamRole role) => role switch
    {
        TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect,
        TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write,
        TeamRole.Admin or TeamRole.Owner => OwnerPermissions,

        // Unspecified, or a value written by a newer server. Failing closed is the only safe
        // direction for a role this build does not understand.
        _ => PermissionFlags.None,
    };

    /// <remarks>
    /// Only an <see cref="MembershipStatus.Active"/> membership confers anything. An invited member
    /// has not accepted and a revoked one has been removed; neither is a state in which the server
    /// should be serving ciphertext.
    /// </remarks>
    private async Task<TeamRole?> FindRoleAsync(
        Guid userId,
        Guid teamId,
        CancellationToken cancellationToken)
    {
        var membership = await database.TeamMemberships
            .SingleOrDefaultAsync(
                m => m.TeamId == teamId
                    && m.UserId == userId
                    && m.Status == MembershipStatus.Active
                    && m.DeletedAtUtc == null,
                cancellationToken)
            .ConfigureAwait(false);

        return membership?.Role;
    }
}
