package com.atlassian.stash.internal.user;

import com.atlassian.bitbucket.permission.EffectivePermission;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.project.Project;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.server.FeatureManager;
import com.atlassian.bitbucket.server.StandardFeature;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.UserService;
import com.atlassian.bitbucket.user.UserType;
import com.atlassian.bitbucket.util.*;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.crowd.embedded.impl.IdentifierUtils;
import com.atlassian.crowd.manager.recovery.RecoveryModeService;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.stash.internal.AbstractService;
import com.atlassian.stash.internal.annotation.NotProfiled;
import com.atlassian.stash.internal.page.PageConstants;
import com.atlassian.stash.internal.project.ProjectDao;
import com.atlassian.stash.internal.repository.RepositoryDao;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables;
import com.google.common.collect.Iterators;
import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.security.Principal;
import java.util.*;

import static com.atlassian.stash.internal.CommonValidations.*;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Lists.newArrayList;

/**
 * None of the service methods here are restricted by permissions because this is the service that controls them, and
 * it needs to be available to all contexts.
 */
@AvailableToPlugins(PermissionService.class)
@NotProfiled
@Service("permissionService")
public class PermissionServiceImpl extends AbstractService implements InternalPermissionService {

    public static final int GROUP_PAGESIZE = 1000;
    public static final String GROUP_PERMISSION_SEARCH = "Group Permission Search";
    public static final int USER_GROUP_PAGE_LIMIT = 500;

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

    private final InternalAuthenticationContext authenticationContext;
    private final EffectivePermissionDao effectivePermissionDao;
    private final ProjectDao projectDao;
    private final RecoveryModeService recoveryModeService;
    private final RepositoryDao repositoryDao;
    private final RepositoryPermissionDao repositoryPermissionDao;

    private FeatureManager featureManager;
    private PermissionGraphFactory permissionGraphFactory;
    private UserService userService;

    @Value(PageConstants.MAX_USERS)
    private int maxUserPageSize;

    @Autowired
    public PermissionServiceImpl(InternalAuthenticationContext authenticationContext,
                                 EffectivePermissionDao effectivePermissionDao,
                                 ProjectDao projectDao,
                                 RecoveryModeService recoveryModeService,
                                 RepositoryDao repositoryDao,
                                 RepositoryPermissionDao repositoryPermissionDao) {
        this.authenticationContext = authenticationContext;
        this.effectivePermissionDao = effectivePermissionDao;
        this.projectDao = projectDao;
        this.recoveryModeService = recoveryModeService;
        this.repositoryDao = repositoryDao;
        this.repositoryPermissionDao = repositoryPermissionDao;
    }

    @Override
    public boolean hasGlobalPermission(String username, @Nonnull Permission permission) {
        ApplicationUser user = (username == null) ? null : findUserByName(username);
        return hasGlobalPermission(user, permission);
    }

    @Override
    public boolean hasGlobalPermission(ApplicationUser user, @Nonnull Permission permission) {
        validateGlobalPermission(permission);

        return hasPermission(user, null, permission);
    }

    @Override
    public boolean hasGlobalPermission(StashUserAuthenticationToken token, Permission permission) {
        validateGlobalPermission(permission);

        return hasPermission(token, null, permission);
    }

    @Override
    public boolean hasGlobalPermission(@Nonnull Permission permission) {
        return hasGlobalPermission(authenticationContext.getCurrentToken(), permission);
    }

    @Override
    public boolean hasAnyUserPermission(@Nonnull ApplicationUser user, @Nonnull Permission permission) {
        validateUser(user);
        validateResourcePermission(permission);

        return hasPermission(user, null, permission);
    }

    @Override
    public boolean hasAnyUserPermission(@Nonnull Permission permission) {
        StashUserAuthenticationToken token = authenticationContext.getCurrentToken();

        return token != null && hasAnyUserPermission(token, permission);
    }

    @Override
    public boolean hasProjectPermission(ApplicationUser user, @Nonnull Project project, @Nonnull Permission permission) {
        validateProject(project);
        validateProjectPermission(permission);

        return hasPermission(user, project, permission);
    }

    @Override
    public boolean hasProjectPermission(@Nonnull Project project, @Nonnull Permission permission) {
        return hasProjectPermission(authenticationContext.getCurrentToken(), project, permission);
    }

    @Override
    public boolean hasProjectPermission(int projectId, @Nonnull Permission permission) {
        return hasProjectPermission(authenticationContext.getCurrentToken(), projectId, permission);
    }

    @Override
    public boolean hasProjectPermission(ApplicationUser user, int projectId, @Nonnull Permission permission) {
        validateProjectPermission(permission);

        Project project = projectDao.getById(projectId);
        // do not stop the caller from trying to access a non-existing project
        return project == null || hasPermission(user, project, permission);
    }

    @Override
    public boolean hasDirectGlobalUserPermission(@Nonnull Permission permission) {
        validateGlobalPermission(permission);

        return hasDirectUserPermission(authenticationContext.getCurrentToken(), null, permission);
    }

    @Override
    public boolean hasDirectProjectUserPermission(@Nonnull Project project, @Nonnull Permission permission) {
        validateProject(project);
        validateProjectPermission(permission);

        return hasDirectUserPermission(authenticationContext.getCurrentToken(), project, permission);
    }

    @Override
    public boolean hasDirectRepositoryUserPermission(@Nonnull Repository repository, @Nonnull Permission permission) {
        validateRepository(repository);
        validateRepositoryPermission(permission);

        return hasDirectUserPermission(authenticationContext.getCurrentToken(), repository, permission);
    }

    private boolean hasDirectUserPermission(StashUserAuthenticationToken token, Object resource, Permission permission) {
        return token != null && hasDirectUserPermission(token.getPrincipal(), resource, permission);
    }

    @Override
    public boolean hasGlobalGroupPermission(@Nonnull Permission permission, @Nonnull String group) {
        validateGlobalPermission(permission);
        validateGroup(group);

        GroupPermissionCriteria criteria = new GroupPermissionCriteria.Builder(group).permission(permission).build();
        return effectivePermissionDao.isGrantedToGroup(criteria);
    }

    @Override
    public boolean isProjectAccessible(@Nonnull Project project) {
        validateProject(project);
        // We evaluate permissions before public accessibly as a performance optimisation to minimise
        // database accesses. There is a good chance we'll hit the permission graph or an elevated permission.
        return hasProjectPermission(project, Permission.PROJECT_VIEW) || isPubliclyAccessible(project);
    }

    @Override
    public boolean isProjectAccessible(int projectId) {
        Project project = projectDao.getById(projectId);
        return project != null && isProjectAccessible(project);
    }

    @Override
    public boolean isRepositoryAccessible(@Nonnull Repository repository) {
        validateRepository(repository);
        // We evaluate permissions before public accessibly as a performance optimisation to minimise
        // database accesses. There is a good chance we'll hit the permission graph or an elevated permission.
        return hasRepositoryPermission(repository, Permission.REPO_READ) || isPubliclyAccessible(repository);
    }

    @Override
    public boolean isRepositoryAccessible(int repositoryId) {
        Repository repository = repositoryDao.getById(repositoryId);
        return repository != null && isRepositoryAccessible(repository);
    }

    @Override
    @Transactional(propagation = Propagation.SUPPORTS)
    public boolean isPubliclyAccessible(@Nonnull Repository repository) {
        validateRepository(repository);
        return featureManager.isEnabled(StandardFeature.PUBLIC_ACCESS) &&
                (repository.isPublic() || repository.getProject().isPublic());
    }

    @Override
    public boolean isPubliclyAccessible(@Nonnull Project project) {
        validateProject(project);
        return featureManager.isEnabled(StandardFeature.PUBLIC_ACCESS) &&
                (project.isPublic() || repositoryDao.countPublicByProject(project.getId()) > 0);
    }

    @Nonnull
    @Override
    public Page<ApplicationUser> getGrantedUsers(@Nonnull Permission permission, @Nonnull PageRequest request) {
        validateGlobalPermission(permission);
        validatePageRequest(request);

        return effectivePermissionDao.findUsers(permission, request, ApplicationUser::isActive);
    }

    @Nonnull
    @Override
    public Page<String> getGrantedGroups(@Nonnull Permission permission, @Nonnull PageRequest request) {
        validateGlobalPermission(permission);
        validatePageRequest(request);

        return effectivePermissionDao.findGroups(permission, request);
    }

    @Override
    public boolean hasGlobalPermissionThroughGroupMembership(@Nonnull Permission permission,
                                                             @Nonnull Set<String> excludedGroups) {
        validateGlobalPermission(permission);
        validateGroups(excludedGroups);

        return hasPermissionsThroughGroup(authenticationContext.getCurrentToken(), null, permission, excludedGroups);
    }

    @Override
    public boolean hasProjectPermissionThroughGroupMembership(@Nonnull Project project, @Nonnull Permission permission,
                                                              @Nonnull Set<String> excludedGroups) {
        validateProject(project);
        validateProjectPermission(permission);
        validateGroups(excludedGroups);

        return hasPermissionsThroughGroup(authenticationContext.getCurrentToken(), project, permission, excludedGroups);
    }

    @Override
    public boolean hasRepositoryPermissionThroughGroupMembership(@Nonnull Repository repository, @Nonnull Permission permission, @Nonnull Set<String> excludedGroups) {
        validateRepository(repository);
        validateRepositoryPermission(permission);
        validateGroups(excludedGroups);

        return hasPermissionsThroughGroup(authenticationContext.getCurrentToken(), repository, permission, excludedGroups);
    }

    @Override
    public boolean hasRepositoryPermission(ApplicationUser user, @Nonnull Repository repository, @Nonnull Permission permission) {
        validateRepository(repository);
        validateRepositoryPermission(permission);

        return hasPermission(user, repository, permission);
    }

    @Override
    public boolean hasRepositoryPermission(ApplicationUser user, int repositoryId, @Nonnull Permission permission) {
        Repository repository = repositoryDao.getById(repositoryId);
        // do not stop the caller from trying to access a non-existing repository
        return repository == null || hasRepositoryPermission(user, repository, permission);
    }

    @Override
    public boolean hasRepositoryPermission(@Nonnull Repository repository, @Nonnull Permission permission) {
        return hasRepositoryPermission(authenticationContext.getCurrentToken(), repository, permission);
    }

    @Override
    public boolean hasRepositoryPermission(int repositoryId, @Nonnull Permission permission) {
        return hasRepositoryPermission(authenticationContext.getCurrentToken(), repositoryId, permission);
    }

    @Nonnull
    @Override
    public Set<String> getUsersWithPermission(@Nonnull final Permission permission) {
        validateGlobalPermission(permission);

        Set<String> usersWithPermission = new HashSet<>();

        Iterable<ApplicationUser> users = Iterables.filter(new PagedIterable<>(new PageProvider<ApplicationUser>() {
            @Override
            public Page<ApplicationUser> get(PageRequest request) {
                return getGrantedUsers(permission, request);
            }
        }, maxUserPageSize), ApplicationUser::isActive);

        Iterables.addAll(usersWithPermission, Iterables.transform(users,
                user -> IdentifierUtils.TO_LOWER_CASE.apply(user.getName())));

        Iterable<String> groups = new PagedIterable<>(new PageProvider<String>() {
            @Override
            public Page<String> get(PageRequest request) {
                return getGrantedGroups(permission, request);
            }
        }, GROUP_PAGESIZE);

        for (final String group : groups) {
            Iterable<ApplicationUser> usersInGroup = Iterables.filter(new PagedIterable<>(new PageProvider<ApplicationUser>() {
                @Override
                public Page<ApplicationUser> get(PageRequest request) {
                    return PageUtils.asPageOf(ApplicationUser.class, userService.findUsersByGroup(group, request));
                }
            }, maxUserPageSize), ApplicationUser::isActive);

            Iterables.addAll(usersWithPermission, Iterables.transform(usersInGroup,
                    user -> IdentifierUtils.TO_LOWER_CASE.apply(user.getName())));
        }

        return usersWithPermission;
    }

    @Override
    public int getCountOfAccessibleRepositories(@Nonnull Project project) {
        validateProject(project);
        StashUserAuthenticationToken token = authenticationContext.getCurrentToken();

        if (hasProjectPermission(token, project, Permission.PROJECT_READ)) {
            return Ints.saturatedCast(repositoryDao.countByProject(checkNotNull(project, "project").getId()));
        } else if (token != null && token.getPrincipal() != null) {
            ApplicationUser user = token.getPrincipal();
            return Ints.saturatedCast(repositoryPermissionDao.countWithPermission(user.getId(),
                    getAllGroups(user.getName()), project.getId()));
        } else {
            return 0;
        }
    }

    @Override
    public Permission getHighestGlobalPermission(ApplicationUser user) {
        if (isNullOrInactive(user)) {
            return null;
        }
        List<Permission> globalPermissions = getOrderedGlobalPermissions();
        for (Permission globalPermission : globalPermissions) {
            if (hasGlobalPermission(user, globalPermission)) {
                return globalPermission;
            }
        }
        return null;
    }

    @Override
    public Permission getHighestGlobalPermission(String username) {
        ApplicationUser user = username != null ? findUserByName(username) : null;
        return user != null ? getHighestGlobalPermission(user) : null;
    }

    @Override
    public Permission getHighestGlobalGroupPermission(String groupName) {
        if (groupName == null) {
            return null;
        }
        List<Permission> globalPermissions = getOrderedGlobalPermissions();
        for (Permission globalPermission : globalPermissions) {
            if (hasGlobalGroupPermission(globalPermission, groupName)) {
                return globalPermission;
            }
        }
        return null;
    }

    @Nonnull
    @Override
    public Iterable<EffectivePermission> getEffectivePermissions(@Nonnull ApplicationUser user) {
        return permissionGraphFactory.createGraph(user);
    }

    @Autowired
    public void setFeatureManager(FeatureManager featureManager) {
        this.featureManager = featureManager;
    }

    @Autowired
    @Qualifier("permissionGraphFactory")
    public void setPermissionGraphFactory(PermissionGraphFactory permissionGraphFactory) {
        this.permissionGraphFactory = permissionGraphFactory;
    }

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @VisibleForTesting
    PartitionedGroups getAllGroups(final String username) {
        //Assemble all the groups for the user
        int pageSize = USER_GROUP_PAGE_LIMIT;
        return partitionGroups(new PagedIterable<>(new PageProvider<String>() {
            @Override
            public Page<String> get(PageRequest request) {
                return userService.findGroupsByUser(username, request);
            }
        }, new PageRequestImpl(0, pageSize)), pageSize);
    }

    /**
     * Attempts to produce a unified graph of <i>all</i> of the specified {@link ApplicationUser user's} permissions,
     * including any permissions granted by the groups they're a member of.
     * <p>
     * Note: With its current implementation, the computed graph should not be cached for long, because changes made
     * to the user's groups, their permissions, or the user's direct permissions will not be reflected. At the moment,
     * the life span of the computed graph is bounded to a single request.
     *
     * @param user the user to graph permissions for
     * @return a graph of the user's permissions (or something that will simulate one!)
     */
    @Nonnull
    private PermissionGraph createPermissionGraph(@Nonnull ApplicationUser user) {
        validateUser(user);

        return permissionGraphFactory.createGraph(user);
    }

    private static boolean isNullOrInactive(ApplicationUser user) {
        return user == null || (!user.isActive() && user.getType() == UserType.NORMAL);
    }

    private ApplicationUser findUserByName(String username) {
        return userService.getUserByName(username);
    }

    private List<Permission> getOrderedGlobalPermissions() {
        List<Permission> globalPermissions = newArrayList(Permission.getGlobalPermissions());
        Collections.sort(globalPermissions, new Comparator<Permission>() {
            public int compare(Permission o1, Permission o2) {
                return o1 == o2 ? 0 : (o1.getWeight() < o2.getWeight() ? 1 : o1.getWeight() > o2.getWeight() ? -1 : 0);
            }
        });
        return globalPermissions;
    }

    @Nonnull
    private PermissionGraph getPermissionGraph(@Nonnull StashUserAuthenticationToken token) {
        //When dealing with a user token, which is bound to the security context and cleared at the end of each
        //server request, attempt to cache _all_ of the user's permissions the first time a permission check is
        //performed in a given request. This way the user's groups are only traversed once and all subsequent
        //permission checks can be answered directly from the permission graph held in memory, making them all
        //substantially more performant.
        PermissionGraph graph = token.getPermissions();
        if (graph == null) {
            graph = createPermissionGraph(token.getPrincipal());

            token.setPermissions(graph);
        }
        return graph;
    }

    private boolean hasAnyUserPermission(StashUserAuthenticationToken token, Permission permission) {
        validateResourcePermission(permission);

        return hasPermission(token, null, permission);
    }

    private boolean hasDirectUserPermission(ApplicationUser user, Object resource, Permission permission) {
        if (isNullOrInactive(user)) {
            // null users (anonymous) cannot have permissions associated with them and
            // inactive users cannot login therefore we always return false
            return false;
        }
        Integer userId = user.getId();
        if (userId == null) {
            //If the user hasn't been persisted, clearly they don't have any permissions.
            return false;
        }

        UserPermissionCriteria criteria = new UserPermissionCriteria.Builder(userId).permission(permission).resource(resource).build();
        boolean granted = effectivePermissionDao.isGrantedToUser(criteria);
        if (granted) {
            log.trace("{}: {}, or an inheriting permission, has been explicit granted", user.getName(), permission);
        } else {
            log.trace("{}: {} has not been explicitly granted", user.getName(), permission);
        }
        return granted;
    }

    private boolean hasPermission(StashUserAuthenticationToken token, Object resource, Permission permission) {
        if (token == null) {
            return false;
        }

        return token.isGranted(permission, resource) || hasPermission(token.getPrincipal(), resource, permission);
    }

    private boolean hasPermission(@Nullable ApplicationUser user, @Nullable Object resource, @Nonnull Permission permission) {
        log.trace("user = {}, resource = {}, requested permission = {}", user, resource, permission);

        if (isNullOrInactive(user)) {
            // Anonymous users cannot be granted permissions by definition.
            // Inactive users cannot login so we shouldn't honour any of their permissions.
            return false;
        }

        // check for recovery mode
        if (recoveryModeService.isRecoveryModeOn() && user.getName().equals(recoveryModeService.getRecoveryUsername())) {
            // recovery user is assumed to have all permissions
            return true;
        }

        PermissionGraph graph;
        if (user.equals(authenticationContext.getCurrentUser())) {
            // Optimisation for when the current user explicitly queries for their own permission.
            // This can happen for various reasons within a request.

            //noinspection ConstantConditions
            graph = getPermissionGraph(authenticationContext.getCurrentToken());
        } else {
            // if we can't use the graph associated with the current user, create a new graph
            graph = createPermissionGraph(user);
        }

        return graph.isGranted(permission, resource)
                // Authenticated users have implied permissions if a project/repository is publicly accessible.
                // For example an authenticated user can comment on a pull request if a repository is public
                // but not an unauthenticated user
                || permission == Permission.REPO_READ && resource != null && isPubliclyAccessible((Repository) resource)
                || permission == Permission.PROJECT_VIEW && resource != null && isPubliclyAccessible((Project) resource);
    }

    private boolean hasPermissionsThroughGroup(Principal user, Object resource, Permission permission,
                                               Set<String> excludedGroups) {
        String name = user.getName();

        //Iterate over the user's groups and determine whether any of them are granted the requested permission.
        //
        //The alternative approach is to iterate over groups which are granted the permission and determine if the user
        //is a member of any of those groups. Which is more efficient depends on whether the user has more groups or the
        //permission has been granted to more groups. Since there's no cut and dried answer, either approach is viable.
        //This approach is chosen because it seems likely users will not be members of large numbers of groups, and this
        //approach makes the most efficient use of our own permission DAO.
        try (Timer ignored = TimerUtils.start(GROUP_PERMISSION_SEARCH)) {
            PageRequest pageRequest = PageUtils.newRequest(0, USER_GROUP_PAGE_LIMIT);
            Page<String> page = userService.findGroupsByUser(name, pageRequest);
            while (true) {
                //All of the groups retrieved from findGroupsByUser will be lowercased due to the implementation of
                //the UserService, so in order for this removal to do the right thing, it needs to lowercase as well
                Set<String> groups = Sets.newHashSet(page.getValues());
                Iterables.removeAll(groups, Collections2.transform(excludedGroups, IdentifierUtils.TO_LOWER_CASE));
                if (groups.isEmpty()) {
                    log.trace("All groups on the page have been excluded");
                } else {
                    log.trace("Testing for permission against {} groups", groups.size());

                    GroupPermissionCriteria criteria = new GroupPermissionCriteria.Builder(groups).permission(permission).resource(resource).build();
                    boolean granted = effectivePermissionDao.isGrantedToGroup(criteria);
                    if (granted) {
                        log.trace("{}: Permission {} granted by group membership", name, permission);

                        return true;
                    }
                }

                if (page.getIsLastPage()) {
                    log.trace("{}: All group memberships have been exhausted", name);
                    break;
                }
                log.trace("{}: Loading next page of groups", name);
                //noinspection ConstantConditions
                page = userService.findGroupsByUser(name, page.getNextPageRequest());
            }

            log.trace("permission not granted by any group membership");
            return false;
        }
    }

    private boolean hasProjectPermission(StashUserAuthenticationToken token, Project project, Permission permission) {
        validateProject(project);
        validateProjectPermission(permission);

        return hasPermission(token, project, permission);
    }

    private boolean hasProjectPermission(StashUserAuthenticationToken token, int projectId, Permission permission) {
        validateProjectPermission(permission);

        Project project = projectDao.getById(projectId);
        // do not stop the caller from trying to access a non-existing project
        return project == null || hasPermission(token, project, permission);
    }

    private boolean hasRepositoryPermission(StashUserAuthenticationToken token, Repository repository,
                                            Permission permission) {
        validateRepository(repository);
        validateRepositoryPermission(permission);

        return hasPermission(token, repository, permission);
    }

    private boolean hasRepositoryPermission(StashUserAuthenticationToken token, int repositoryId,
                                            Permission permission) {
        Repository repository = repositoryDao.getById(repositoryId);
        // do not stop the caller from trying to access a non-existing repository
        return repository == null || hasRepositoryPermission(token, repository, permission);
    }

    private PartitionedGroups partitionGroups(final PagedIterable<String> groups, final int partitionSize) {
        return new PartitionedGroups() {
            @SuppressWarnings("unchecked")
            @Override
            public Iterator<Collection<String>> iterator() {
                // We need to handle the special case of an empty list of groups, but don't want to invoke
                // PagedIterable.iterator twice and incur the cost of _two_ lookups tofindGroupsByUser().
                Iterator<String> iterator = groups.iterator();
                if (!iterator.hasNext()) {
                    // Ensure we always return at least one element
                    return Collections.<Collection<String>>singletonList(Collections.<String>emptyList()).iterator();
                }
                return (Iterator) Iterators.partition(iterator, partitionSize);
            }
        };
    }
}
