package com.atlassian.stash.internal.user;

import com.atlassian.bitbucket.AuthorisationException;
import com.atlassian.bitbucket.auth.AuthenticationException;
import com.atlassian.bitbucket.auth.IncorrectPasswordAuthenticationException;
import com.atlassian.bitbucket.avatar.AvatarSupplier;
import com.atlassian.bitbucket.avatar.CacheableAvatarSupplier;
import com.atlassian.bitbucket.event.user.UserAvatarUpdatedEvent;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionRequest;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.project.Project;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.user.*;
import com.atlassian.bitbucket.util.MoreCollectors;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageProvider;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.validation.ArgumentValidationException;
import com.atlassian.crowd.embedded.api.User;
import com.atlassian.crowd.embedded.impl.IdentifierUtils;
import com.atlassian.crowd.embedded.impl.ImmutableUser;
import com.atlassian.crowd.search.query.entity.restriction.constants.UserTermKeys;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.sal.api.user.UserKey;
import com.atlassian.stash.internal.AbstractService;
import com.atlassian.stash.internal.InternalConverter;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.auth.AuthenticationService;
import com.atlassian.stash.internal.avatar.DataUriAvatarMetaSupplier;
import com.atlassian.stash.internal.avatar.InternalAvatarService;
import com.atlassian.stash.internal.crowd.CrowdControl;
import com.atlassian.stash.internal.page.PageConstants;
import com.atlassian.stash.internal.util.InternalPageUtils;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Nonnull;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import static com.atlassian.bitbucket.util.PageUtils.asPageOf;
import static com.atlassian.crowd.embedded.impl.IdentifierUtils.toLowerCase;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.util.stream.Collectors.toSet;

@AvailableToPlugins(UserService.class)
@Service("userService")
@SuppressWarnings("ConstantConditions")
public class DefaultUserService extends AbstractService implements InternalUserService {

    private static final Logger log = LoggerFactory.getLogger(DefaultUserService.class);

    private final InternalAvatarService avatarService;
    private final InternalAuthenticationContext authenticationContext;
    private final AuthenticationService authenticationService;
    private final CrowdControl crowdControl;
    private final EventPublisher eventPublisher;
    private final I18nService i18nService;
    private final PasswordResetHelper passwordResetHelper;
    private final PermissionService permissionService;
    private final StashUserDao userDao;
    private final UserHelper userHelper;

    @Value(PageConstants.MAX_USERS)
    private int maxUserPageSize;
    @Value(PageConstants.MAX_GROUPS)
    private int maxGroupPageSize;

    /**
     * The InternalAvatarService is lazy because it has a circular dependency back to this DefaultUserService:
     * (DefaultUserService -> InternalAvatarService -> WebResourceAvatarSource -> ... -> ClusteredPluginController -> DefaultUserService)
     *
     * BSERVDEV-11187: The AuthenticationService is lazy because it has a circular dependency back to the DefaultUserService:
     * (DefaultUserService -> DefaultAuthenticationService -> DefaultCaptchaService -> PropertyFeatureManager -> LicenseServiceImpl -> DefaultUserService)
     *
     * The PermissionService is lazy because it has a direct circular dependency back to this DefaultUserService:
     * (PermissionServiceImpl -> DefaultUserService)
     */
    @Autowired
    public DefaultUserService(@Lazy InternalAvatarService avatarService,
                              InternalAuthenticationContext authenticationContext,
                              @Lazy AuthenticationService authenticationService,
                              CrowdControl crowdControl,
                              EventPublisher eventPublisher, I18nService i18nService,
                              PasswordResetHelper passwordResetHelper,
                              @Lazy PermissionService permissionService,
                              StashUserDao userDao, UserHelper userHelper) {

        this.avatarService = avatarService;
        this.authenticationContext = authenticationContext;
        this.authenticationService = authenticationService;
        this.crowdControl = crowdControl;
        this.eventPublisher = eventPublisher;
        this.i18nService = i18nService;
        this.passwordResetHelper = passwordResetHelper;
        this.permissionService = permissionService;
        this.userDao = userDao;
        this.userHelper = userHelper;
    }

    @Nonnull
    @Override
    @Transactional(propagation = Propagation.SUPPORTS, noRollbackFor = {AuthenticationException.class, NoSuchUserException.class})
    @Unsecured("This needs to be available to unauthenticated contexts")
    public ApplicationUser authenticate(@Nonnull String username, @Nonnull String password) {
        return authenticationService.authenticate(username, password);
    }

    @Nonnull
    @Override
    @Transactional(propagation = Propagation.SUPPORTS, noRollbackFor = {AuthenticationException.class, NoSuchUserException.class})
    @Unsecured("This needs to be available to unauthenticated contexts")
    public ApplicationUser authenticateWithCaptcha(@Nonnull String username, @Nonnull String password) {
        return authenticationService.authenticateWithCaptcha(username, password);
    }

    @Override
    // this _must_ be be the same permission check as _updateAvatar()_
    // users can delete their own avatars, admins can delete all avatars except sysadmins',
    // sysadmins can delete all avatars
    @PreAuthorize("isCurrentUser(#user) or hasGlobalPermission('SYS_ADMIN') or " +
                  "(hasGlobalPermission('ADMIN') and not hasGlobalPermission(#user, 'SYS_ADMIN'))")
    public void deleteAvatar(@Nonnull ApplicationUser user) {
        avatarService.deleteForUser(checkNotNull(user, "user"));
    }

    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public boolean existsGroup(String groupName) {
        return crowdControl.findGroup(groupName) != null;
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public Page<String> findGroups(@Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxGroupPageSize);

        return crowdControl.findGroups(pageRequest);
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public Page<String> findGroupsByName(String groupName, @Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxGroupPageSize);

        return crowdControl.findGroupsByName(groupName, pageRequest);
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public Page<String> findGroupsByPrefix(String groupPrefix, @Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxGroupPageSize);

        return crowdControl.findGroupsByPrefix(groupPrefix, pageRequest);
    }

    @Nonnull
    @Override
    @Unsecured("Used in unauthenticated contexts by licensing, permission checks and login processing")
    public Page<String> findGroupsByUser(@Nonnull String username, @Nonnull PageRequest pageRequest) {
        return crowdControl.findGroupsByUser(username, null, false, pageRequest)
                // Should this be moved into crowdControl?
                .transform(IdentifierUtils::toLowerCase);
    }

    @Nonnull
    @Override
    @PreAuthorize("isAuthenticated()")
    public Page<ServiceUser> findServiceUsersByName(String displayName, @Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxUserPageSize);

        return asPageOf(ServiceUser.class, userDao.findServiceUsersByName(displayName, pageRequest));
    }

    @Override
    @Transactional
    @Unsecured("This needs to be available in unauthenticated contexts; it is used for password resets")
    public ApplicationUser findUserByNameOrEmail(@Nonnull String value) {
        if (checkNotNull(value, "value").contains("@")) {
            // try email first
            User user = crowdControl.findUserByProperty(UserTermKeys.EMAIL, value);
            if (user != null) {
                return getOrCreateMappedUser(user);
            }
        }

        return userDao.findByName(value);
    }

    @Nonnull
    @Override
    @PreAuthorize("isAuthenticated()")
    public Page<ApplicationUser> findUsers(@Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxUserPageSize);

        return asPageOf(ApplicationUser.class, userHelper.transformOrCreate(crowdControl.findUsers(pageRequest)));
    }

    @Nonnull
    @Override
    @Unsecured("Used in unauthenticated contexts by licensing, permission checks and login processing")
    public Page<ApplicationUser> findUsersByGroup(@Nonnull String groupName, @Nonnull PageRequest pageRequest) {
        return findUsersByGroup(groupName, null, pageRequest);
    }

    @Nonnull
    @Override
    @PreAuthorize("isAuthenticated()")
    public Page<ApplicationUser> findUsersByName(String username, @Nonnull PageRequest pageRequest) {
        pageRequest = pageRequest.buildRestrictedPageRequest(maxUserPageSize);

        return asPageOf(ApplicationUser.class,
                userHelper.transformOrCreate(crowdControl.findUsersByName(username, pageRequest)));
    }

    @Nonnull
    @Override
    @Unsecured("User avatars are not more privileged than getUserBySlug(String)")
    public CacheableAvatarSupplier getAvatar(@Nonnull ApplicationUser user, int size) {
        checkNotNull(user, "user");

        return avatarService.getForUser(user, size);
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ServiceUser getServiceUserByName(@Nonnull String username) {
        return getServiceUserByName(username, false);
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ServiceUser getServiceUserByName(@Nonnull String username, boolean inactive) {
        return userDao.findServiceUserByName(checkNotNull(username, "username"), inactive);
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ServiceUser getServiceUserBySlug(@Nonnull String slug) {
        // inactive users are filtered out in the DAO
        return userDao.findServiceUserBySlug(checkNotNull(slug, "slug"));
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ApplicationUser getUserById(int id) {
        return getUserById(id, false);
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ApplicationUser getUserById(int id, boolean inactive) {
        return maybeFilterInactive(userDao.getById(id), inactive);
    }

    @Override
    public ApplicationUser getUserByKey(UserKey key) {
        return getUserByKey(key, false);
    }

    @Override
    public ApplicationUser getUserByKey(UserKey key, boolean inactive) {
        if (key == null) {
            return null;
        }

        try {
            return getUserById(Integer.parseInt(key.getStringValue()), inactive);
        } catch (NumberFormatException e) {
            // the user key wasn't a number. Most likely the caller is calling us with a UserKey constructed from a
            // user name. This can happen if they've previously stored usernames and have since switched to storing
            // user keys. UPM, I'm looking at you! We'll play nice and try to get the user by name anyway.
            return getUserByName(key.getStringValue(), inactive);
        }
    }

    @Override
    @Transactional
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ApplicationUser getUserByName(@Nonnull String username) {
        return getUserByName(username, false);
    }

    @Override
    @Transactional
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ApplicationUser getUserByName(@Nonnull String username, boolean inactive) {
        String lowercasedName = toLowerCase(checkNotNull(username, "username"));
        if (authenticationContext.isAuthenticated()) {
            // if the current user is being requested, there's no need to hit the database
            ApplicationUser currentUser = authenticationContext.getCurrentUser();
            if (lowercasedName.equals(currentUser.getName())) {
                return currentUser;
            }
        }

        return maybeFilterInactive(userDao.findByName(username), inactive);
    }

    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public ApplicationUser getUserBySlug(@Nonnull String slug) {
        String lowercasedSlug = toLowerCase(checkNotNull(slug, "slug"));
        if (authenticationContext.isAuthenticated()) {
            // if the current user is being requested, there's no need to hit the database
            ApplicationUser currentUser = authenticationContext.getCurrentUser();
            if (lowercasedSlug.equals(currentUser.getSlug())) {
                return currentUser;
            }
        }

        return maybeFilterInactive(userDao.findBySlug(slug), false);
    }

    @Nonnull
    @Override
    @Unsecured("This needs to be available to unauthenticated contexts")
    public Set<ApplicationUser> getUsersById(@Nonnull Set<Integer> ids) {
        return getUsersById(ids, false);
    }

    @Nonnull
    @Override
    @Unsecured("This needs to be available in unauthenticated contexts")
    public Set<ApplicationUser> getUsersById(@Nonnull Set<Integer> ids, boolean inactive) {
        return maybeFilterInactive(userDao.getByIds(ids), inactive);
    }

    @Nonnull
    @Override
    @Unsecured("Should be on the same level of permission as getUserByName(String)")
    public Set<ApplicationUser> getUsersByName(@Nonnull Set<String> usernames) {
        return getUsersByName(usernames, false);
    }

    @Nonnull
    @Override
    @Unsecured("Should be on the same level of permission as getUserByName(String)")
    public Set<ApplicationUser> getUsersByName(@Nonnull Set<String> usernames, boolean inactive) {
        checkNotNull(usernames, "usernames");
        return maybeFilterInactive(userDao.findByNames(usernames), inactive);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public boolean isUserInGroup(@Nonnull ApplicationUser user, @Nonnull String groupName) {
        checkNotNull(user, "user");
        checkNotNull(groupName, "groupName");

        InternalApplicationUser internalUser = InternalConverter.convertToInternalUser(user);

        return internalUser.accept(new InternalStashUserVisitor<Boolean>() {
            @Override
            public Boolean visit(@Nonnull InternalNormalUser user) {
                User crowdUser = user.getBackingCrowdUser();
                if (crowdUser == null) {
                    //This should be impossible; CrowdAwareLoadEventListener should load the backing Crowd user on every
                    //InternalApplicationUser that gets loaded, and calling getBackingCrowdUser() should force any Hibernate
                    //proxy to initialise, effectively ensuring the backing user is always available.
                    crowdUser = crowdControl.findUser(user.getUsername(), true);
                    if (crowdUser == null) {
                        //It must be deleted; that's the only reason it would not be populated on the ApplicationUser or findable
                        //directly in Crowd, so it must not be a member of any groups
                        return false;
                    }
                }

                return crowdControl.isGroupMember(groupName, crowdUser);
            }

            @Override
            public Boolean visit(@Nonnull InternalServiceUser user) {
                // only Crowd backed users can be in groups
                return false;
            }
        });
    }

    @Override
    @Unsecured("This is not restricted by permissions so that SAL tests pass. It should not be exposed via REST")
    public boolean isUserInGroup(@Nonnull String username, @Nonnull String groupName) {
        checkNotNull(username, "username");
        checkNotNull(groupName, "groupName");

        //Note: This _may_ create a ApplicationUser in a read-only transaction (meaning the newly-created user is rolled
        //      back when this method call returns). Because the user is not returned from this method, that rollback
        //      is acceptable. It's desirable not to have a read/write transaction here, because this method is used
        //      extensively in permission checks.
        ApplicationUser user = getUserByName(username);
        return user != null && isUserInGroup(user, groupName);
    }

    @Nonnull
    @Override
    @Transactional
    @Unsecured("This should not be more private than findUserByNameOrEmail")
    public Map<String, ApplicationUser> mapUsersByEmail(@Nonnull Set<String> emailAddresses) {
        //Performance here is going to hurt... Crowd's API doesn't provide a good mechanism for doing any form
        //of bulk search where we're trying to match username or email. To try and minimize the Crowd calls we
        //batch up all the distinct e-mail addresses and map the users out so that we only query for each user
        //once, with the hope being that pages will contain a small set of distinct users
        Map<String, ApplicationUser> usersByEmail = Maps.newHashMapWithExpectedSize(emailAddresses.size());
        for (String emailAddress : emailAddresses) {
            ApplicationUser user = findUserByNameOrEmail(emailAddress);
            if (user != null) {
                usersByEmail.put(emailAddress, user);
            }
        }
        return usersByEmail;
    }

    @Override
    @Transactional
    @Unsecured("This needs to be available to unauthenticated contexts")
    public ApplicationUser preauthenticate(@Nonnull String username) {
        log.debug("Authenticating user: {}", username);
        ApplicationUser user = getUserByName(username);
        if (user == null) {
            user = getServiceUserByName(username);
        }
        if (user != null) {
            SecurityContextHolder.getContext().setAuthentication(StashUserAuthenticationToken.forUser(user));
        }
        return user;
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public Page<ApplicationUser> search(@Nonnull UserSearchRequest request, @Nonnull PageRequest pageRequest) {
        String username = request.getFilter();
        String groupName = request.getGroup();
        Set<PermissionRequest> permissions = request.getPermissions();
        if (permissions.isEmpty()) {
            return asPageOf(ApplicationUser.class, findUsersByGroup(groupName, username, pageRequest));
        }

        validatePermissions(permissions);
        Predicate<ApplicationUser> predicates = user -> true;
        for (PermissionRequest permissionRequest : permissions) {
            predicates = predicates.and(user -> hasPermission(permissionRequest, user));
        }

        PageProvider<ApplicationUser> userProvider = r -> asPageOf(ApplicationUser.class, findUsersByGroup(groupName, username, r));

        return InternalPageUtils.filterPages(userProvider, predicates, pageRequest, maxUserPageSize * 2);
    }

    @Override
    @Unsecured("This needs to be available to all contexts")
    public void unauthenticate() {
        SecurityContextHolder.clearContext();
    }

    @Override
    @PreAuthorize("isCurrentUser(#user) or " + //Users can always change their own avatar
            "hasGlobalPermission('SYS_ADMIN') or " + //SYS_ADMINs can change anyone's avatar
            //ADMINs can change anyone's avatar except SYS_ADMINs
            "(hasGlobalPermission('ADMIN') and not hasGlobalPermission(#user, 'SYS_ADMIN'))")
    public void updateAvatar(@Nonnull ApplicationUser user, @Nonnull AvatarSupplier supplier) {
        checkNotNull(supplier, "supplier");

        doUpdateAvatar(user, Suppliers.ofInstance(supplier));
    }

    @Override
    @PreAuthorize("isCurrentUser(#user) or " + //Users can always change their own avatar
            "hasGlobalPermission('SYS_ADMIN') or " + //SYS_ADMINs can change anyone's avatar
            //ADMINs can change anyone's avatar except SYS_ADMINs
            "(hasGlobalPermission('ADMIN') and not hasGlobalPermission(#user, 'SYS_ADMIN'))")
    public void updateAvatar(@Nonnull ApplicationUser user, @Nonnull String uri) {
        checkArgument(!checkNotNull(uri, "uri").trim().isEmpty(), "A non-blank data URI is required");

        doUpdateAvatar(user, new DataUriAvatarMetaSupplier(avatarService, uri));
    }

    @Override
    @Transactional
    @Unsecured("Internal service method")
    public void updateLastAuthentication(@Nonnull String username) {
        User user = crowdControl.findUser(username, false);
        if (user != null) {
            crowdControl.setUserAttribute(user, ATTR_LAST_AUTHENTICATION_TIMESTAMP, System.currentTimeMillis());
        }
    }

    @Override
    @Transactional
    @PreAuthorize("isAuthenticated()")
    public void updatePassword(@Nonnull String currentPassword, @Nonnull String newPassword) {
        // get the username of the current user
        String name = getCurrentUserForUpdate().getName();

        try {
            // check if the old password matches the known one
            User user = crowdControl.authenticate(name, currentPassword);

            // update the password
            passwordResetHelper.resetPassword(user, newPassword);
        } catch (IncorrectPasswordAuthenticationException e) {
            throw new IncorrectPasswordAuthenticationException(
                    i18nService.createKeyedMessage("bitbucket.service.user.password.invalid"));
        }
    }

    @Nonnull
    @Override
    @PreAuthorize("isAuthenticated()")
    @Transactional
    public ApplicationUser updateUser(@Nonnull String displayName, @Nonnull String email) {
        checkNotNull(displayName, "displayName");
        checkNotNull(email, "email");

        // get the username of the current user
        String name = getCurrentUserForUpdate().getName();

        // update its display name and email
        User user = crowdControl.updateUser(ImmutableUser.newUser()
                .name(name)
                .displayName(displayName)
                .emailAddress(email)
                .toUser());
        return userHelper.transformOrCreate(user);
    }

    /**
     * Retrieves the user identified by the specified {@code slug} and updates its avatar using an
     * {@link AvatarSupplier} obtained from the provided {@code metaSupplier}.
     *
     * @param user         the user to update
     * @param metaSupplier an {@link AvatarSupplier} supplier, which may not return {@code null}
     * @since 2.12
     */
    private void doUpdateAvatar(@Nonnull ApplicationUser user, @Nonnull Supplier<AvatarSupplier> metaSupplier) {
        avatarService.saveForUser(checkNotNull(user, "user"), checkNotNull(metaSupplier, "metaSupplier").get());
        eventPublisher.publish(new UserAvatarUpdatedEvent(this, user));
    }

    private Page<ApplicationUser> findUsersByGroup(String groupName, String username, PageRequest pageRequest) {
        if (groupName == null) {
            return findUsersByName(username, pageRequest);
        } else {
            pageRequest = pageRequest.buildRestrictedPageRequest(maxUserPageSize);

            return asPageOf(ApplicationUser.class,
                    userHelper.transformOrCreate(crowdControl.findUsersByGroup(groupName, username, false, pageRequest)));
        }
    }

    @Nonnull
    private ApplicationUser getCurrentUserForUpdate() {
        ApplicationUser user = authenticationContext.getCurrentUser();
        if (user == null) {
            throw new AuthorisationException(i18nService.createKeyedMessage("bitbucket.service.user.anonymousupdate"));
        }
        return user;
    }

    /**
     * Retrieves an existing {@link ApplicationUser} for the provided Crowd {@code User} or, if no such {@code ApplicationUser}
     * exists, creates one.
     * <p>
     * Note: Because this call may create a new {@link InternalApplicationUser}, it must be run in a read-write transaction.
     *
     * @param crowdUser the backing crowd user. Must not be null.
     * @return the mapped stash user for the given crowd user, creating if necessary
     */
    private InternalNormalUser getOrCreateMappedUser(User crowdUser) {
        //The transform will look up the user ID. If there is no ApplicationUser for this Crowd User, the transform will
        //create the user if it does not exist yet.
        return userHelper.transformOrCreate(crowdUser);
    }

    private boolean hasPermission(PermissionRequest permissionRequest, ApplicationUser user) {
        Permission permission = permissionRequest.getPermission();
        Object resource = permissionRequest.getResource();

        if (permission.isGlobal()) {
            return permissionService.hasGlobalPermission(user, permission);
        }

        if (permission.isResource(Project.class)) {
            if (resource instanceof Project) {
                return permissionService.hasProjectPermission(user, permissionRequest.getResourceAs(Project.class),
                        permission);
            }
            if (resource instanceof Integer) {
                return permissionService.hasProjectPermission(user, permissionRequest.getResourceAs(Integer.class),
                        permission);
            }
        }

        if (permission.isResource(Repository.class)) {
            if (resource instanceof Repository) {
                return permissionService.hasRepositoryPermission(user, permissionRequest.getResourceAs(Repository.class),
                        permission);
            }
            if (resource instanceof Integer) {
                return permissionService.hasRepositoryPermission(user, permissionRequest.getResourceAs(Integer.class),
                        permission);
            }
        }

        // should never happen if validatePermissions was called beforehand
        throw new AssertionError("Unsupported resource type " + resource.getClass().getName());
    }

    private Predicate<ApplicationUser> userFilter(boolean inactive) {
        Predicate<ApplicationUser> filter = Objects::nonNull;

        return inactive ? filter : filter.and(ApplicationUser::isActive);
    }

    private Set<ApplicationUser> maybeFilterInactive(Collection<? extends ApplicationUser> users, boolean inactive) {
        return users.stream()
                .filter(userFilter(inactive))
                .collect(toSet());
    }

    private <U extends InternalApplicationUser> U maybeFilterInactive(U user, boolean inactive) {
        return userFilter(inactive).test(user) ? user : null;
    }

    private void validatePermissions(Set<PermissionRequest> requests) {
        requests.forEach(this::validatePermissionRequest);
    }

    private void validatePermissionRequest(PermissionRequest request) {
        Permission permission = request.getPermission();
        Object resource = request.getResource();

        if (permission.isGlobal()) {
            if (resource != null) {
                throw new ArgumentValidationException(i18nService.createKeyedMessage(
                        "bitbucket.service.user.permission.globalresource",
                        request.getResource()
                ));
            }
        } else {
            if (resource == null) {
                throw new ArgumentValidationException(i18nService.createKeyedMessage(
                        "bitbucket.service.user.permission.emptyresource",
                        request.getPermission()
                ));
            }
            if (!permission.isResource(resource.getClass())) {
                throw new ArgumentValidationException(i18nService.createKeyedMessage(
                        "bitbucket.service.user.permission.incompatibleresource",
                        resource.getClass().getName(),
                        permission,
                        permission.getResourceTypes().stream()
                                .map(Class::getName)
                                .collect(toSet())
                ));
            }
        }
    }
}
