package com.atlassian.stash.internal.project;

import com.atlassian.bitbucket.AuthorisationException;
import com.atlassian.bitbucket.IntegrityException;
import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.avatar.AvatarSupplier;
import com.atlassian.bitbucket.avatar.CacheableAvatarSupplier;
import com.atlassian.bitbucket.event.permission.ProjectPermissionGrantedEvent;
import com.atlassian.bitbucket.event.project.*;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.permission.PermissionPredicateFactory;
import com.atlassian.bitbucket.permission.PermissionService;
import com.atlassian.bitbucket.project.*;
import com.atlassian.bitbucket.server.ApplicationMode;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.SecurityService;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.SimpleCancelState;
import com.atlassian.bitbucket.validation.groups.Create;
import com.atlassian.bitbucket.validation.groups.TrustedCreate;
import com.atlassian.bitbucket.validation.groups.TrustedUpdate;
import com.atlassian.bitbucket.validation.groups.Update;
import com.atlassian.crowd.event.user.UserCreatedEvent;
import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.stash.internal.HibernateUtils;
import com.atlassian.stash.internal.InternalConverter;
import com.atlassian.stash.internal.annotation.Secured;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.avatar.DataUriAvatarMetaSupplier;
import com.atlassian.stash.internal.avatar.InternalAvatarService;
import com.atlassian.stash.internal.page.PageConstants;
import com.atlassian.stash.internal.user.InternalApplicationUser;
import com.atlassian.stash.internal.user.InternalNormalUser;
import com.atlassian.stash.internal.user.InternalProjectPermission;
import com.atlassian.stash.internal.user.ProjectPermissionDao;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;
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.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Nonnull;
import javax.validation.Validator;
import java.util.List;
import java.util.function.Predicate;

import static com.atlassian.bitbucket.util.PageUtils.asPageOf;
import static com.atlassian.bitbucket.util.ValidationUtils.validate;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

@AvailableToPlugins(interfaces = {ProjectService.class, ProjectSupplier.class})
@Service("projectService")
public class DefaultProjectService extends DefaultProjectSupplier implements InternalProjectService {

    private static final Supplier<AvatarSupplier> NO_AVATAR = Suppliers.ofInstance(null);

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

    private final AuthenticationContext authenticationContext;
    private final InternalAvatarService avatarService;
    private final EventPublisher eventPublisher;
    private final I18nService i18nService;
    private final ApplicationMode mode;
    private final ProjectPermissionDao permissionDao;
    private final PermissionService permissionService;
    private final PermissionPredicateFactory predicateFactory;
    private final SecurityService securityService;
    private final Validator validator;

    private int maxProjects;

    @Autowired
    public DefaultProjectService(AuthenticationContext authenticationContext,
                                 InternalAvatarService avatarService, EventPublisher eventPublisher,
                                 I18nService i18nService,
                                 @Value("#{applicationPropertiesService.mode}") ApplicationMode mode,
                                 PermissionService permissionService,
                                 ProjectPermissionDao permissionDao, PermissionPredicateFactory predicateFactory,
                                 ProjectDao projectDao, SecurityService securityService, Validator validator) {
        super(projectDao);

        this.mode = mode;
        this.authenticationContext = authenticationContext;
        this.avatarService = avatarService;
        this.eventPublisher = eventPublisher;
        this.i18nService = i18nService;
        this.permissionDao = permissionDao;
        this.permissionService = permissionService;
        this.predicateFactory = predicateFactory;
        this.securityService = securityService;
        this.validator = validator;
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasGlobalPermission('PROJECT_CREATE')")
    public InternalProject create(@Nonnull ProjectCreateRequest request) {
        checkNotNull(request, "request");
        final Supplier<AvatarSupplier> metaSupplier = getAvatarSupplier(request);

        InternalProject project = new InternalNormalProject.Builder()
                .description(request.getDescription())
                .key(request.getKey())
                .name(request.getName())
                .namespace(request.getNamespace())
                .publiclyAccessible(request.isPublic())
                .build();

        // a mirror needs to be able to create personal projects without users so we relax the key checking a little
        validate(validator, project, isMirrorMode() ? TrustedCreate.class : Create.class);

        fireProjectCreationRequested(project);
        final InternalProject created = projectDao.create(project);
        log.info("Created new project: ({}) {}", request.getKey(), request.getName());

        /* Setting the avatar for a project through the AvatarService requires project admin permission. Since the
           the authenticated user's permission is cached for the request, it is not possible to grant the user
           with project admin permission then verify it in the same request. */
        securityService.withPermission(Permission.PROJECT_ADMIN, "Creating project with avatar").call(() -> {
            AvatarSupplier supplier = metaSupplier.get();
            if (supplier != null) {
                //Don't call updateAvatar or doUpdateAvatar, because they raise an update event
                avatarService.saveForProject(created, supplier);
            }
            return null;
        });

        grantProjectAdminToCreator(project, authenticationContext.getCurrentUser());
        fireProjectCreated(project);

        return created;
    }

    @Override
    @PreAuthorize("hasProjectPermission(#project, 'PROJECT_ADMIN')")
    @Transactional
    public boolean delete(Project project) {
        InternalProject internalProject = projectDao.getById(project.getId());
        if (internalProject == null) {
            return false;
        }
        if (project.getType() == ProjectType.PERSONAL) {
            throw new IntegrityException(i18nService.createKeyedMessage("bitbucket.service.project.personal.notdeletable"));
        }

        if (projectDao.hasRepositories(internalProject.getId())) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.service.project.notempty",
                    internalProject.getKey());
            throw new IntegrityException(message);
        }

        fireProjectDeletionRequested(project);
        projectDao.delete(internalProject);
        eventPublisher.publish(new ProjectDeletedEvent(this, project));

        return true;
    }

    @Nonnull
    @Override
    @Secured("Secured using a project accessible predicate")
    public Page<Project> findAll(@Nonnull PageRequest pageRequest) {
        PageRequest restrictedPageRequest = pageRequest.buildRestrictedPageRequest(maxProjects);

        return asPageOf(Project.class,
                projectDao.findAll(restrictedPageRequest, predicateFactory.createProjectAccessiblePredicate()));
    }

    @Nonnull
    @Override
    @Secured("Secured using a predicate")
    public List<String> findAllKeys() {
        Predicate<Project> predicate = predicateFactory.createProjectAccessiblePredicate();
        Iterable<String> keys = projectDao.findAllKeys(predicate);

        return ImmutableList.copyOf(keys);
    }

    @Nonnull
    @Override
    @PostAuthorize("isProjectAccessible(#projectId)")
    public CacheableAvatarSupplier getAvatar(int projectId, int size) {
        InternalProject project = getProjectOrFail(projectId);

        return avatarService.getForProject(project, size);
    }

    @Override
    @Unsecured("Internal service method, this method is also exposed via JMX")
    public long getCount() {
        return projectDao.countAll();
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')") //Note: Doesn't require PROJECT_CREATE for personal projects
    @Transactional
    public InternalPersonalProject getPersonalProject() {
        //getPersonalProject(true) can never return null, but IDEA's inspections don't understand Javadocs and the
        //@Nonnull annotation has no way to convey such shades of intention.
        //
        //noinspection ConstantConditions
        return getPersonalProject(true);
    }

    @Nonnull
    @Override
    // All authenticated users can see other peoples personal project.
    // This however does not mean they can see repositories within that project.
    // anonymous users can see a users profile if and only a project is publiclyAccessible
    @PostAuthorize("hasGlobalPermission('LICENSED_USER') or isProjectAccessible(returnObject)")
    @Transactional
    public InternalPersonalProject getPersonalProject(@Nonnull ApplicationUser user) {
        return getOrCreatePersonalProject(checkNotNull(user, "user"), true);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    @Transactional
    public InternalPersonalProject getPersonalProject(boolean create) {
        ApplicationUser currentUser = authenticationContext.getCurrentUser();
        if (currentUser == null) {
            //Should be impossible, since LICENSED_USER can't be granted to anonymous
            throw new AuthorisationException(i18nService.createKeyedMessage("bitbucket.service.project.personal.anonymous"));
        }

        return getOrCreatePersonalProject(currentUser, create);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public boolean isPersonalProject(@Nonnull String projectKey) {
        ApplicationUser currentUser = authenticationContext.getCurrentUser();

        return currentUser != null &&
                currentUser.getName().equalsIgnoreCase(InternalPersonalProject.getPersonalProjectOwner(projectKey));
    }

    /**
     * Restores a user's {@link Permission#PROJECT_ADMIN PROJECT_ADMIN} permission on their personal project, if the
     * project already exists when the user is created.
     * <p>
     * When users are deleted, all of their permissions, including project permissions, are deleted. If the user is
     * then recreated later, their personal project already exists but they no longer have access to it. This checks,
     * when a user is created, to ensure that, if their personal project exists, they have admin rights to it. If they
     * do not, it is granted.
     *
     * @param event provides the created user
     */
    @EventListener
    public void onUserCreated(final UserCreatedEvent event) {
        InternalPersonalProject project = projectDao.getByUsername(event.getUser().getName());
        if (!(project == null || permissionService.hasProjectPermission(project.getOwner(), project, Permission.PROJECT_ADMIN))) {
            grantProjectAdminToCreator(project, project.getOwner());
        }
    }

    @Nonnull
    @Override
    @Secured("Secured using a predicate for (at least) project accessible checks")
    public Page<Project> search(@Nonnull ProjectSearchRequest searchRequest, @Nonnull PageRequest pageRequest) {
        checkNotNull(searchRequest, "searchRequest");
        pageRequest = checkNotNull(pageRequest, "pageRequest").buildRestrictedPageRequest(maxProjects);

        //At a minimum, if no more specific permission is supplied, project accessibility is required
        @SuppressWarnings("ConstantConditions")
        Predicate<Project> permissionPredicate = searchRequest.hasPermission() ?
                predicateFactory.createProjectPermissionPredicate(searchRequest.getPermission()) :
                predicateFactory.createProjectAccessiblePredicate();

        return asPageOf(Project.class, projectDao.search(searchRequest, pageRequest, permissionPredicate));
    }

    @Value(PageConstants.MAX_PROJECTS)
    public void setMaxProjects(int maxProjects) {
        this.maxProjects = maxProjects;
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasProjectPermission(#request.id, 'PROJECT_ADMIN')")
    public InternalProject update(@Nonnull ProjectUpdateRequest request) {
        checkNotNull(request, "request");
        InternalNormalProject project = HibernateUtils.tryCast(getProjectOrFail(request.getId()), InternalNormalProject.class);
        if (project == null) {
            throw new IntegrityException(i18nService.createKeyedMessage("bitbucket.service.project.personal.detailsunmodifiable"));
        }

        // STASH-2637: ProjectDao.update merges changes into 'project' instance, therefore we create a copy of
        // it to pass to events
        InternalNormalProject current = project.copy().build();
        InternalProject updated = current.copy()
                .description(request.getDescription())
                .key(request.getKey())
                .name(request.getName())
                .publiclyAccessible(request.isPublic())
                .build();

        // a mirror needs to be able to rename personal projects so we relax the key checking a little
        validate(validator, updated, isMirrorMode() ? TrustedUpdate.class : Update.class);

        fireProjectModificationRequested(current, updated);
        updated = projectDao.update(updated);

        eventPublisher.publish(new ProjectModifiedEvent(this, current, updated));
        return updated;
    }

    @Override
    @PreAuthorize("hasProjectPermission(#projectId, 'PROJECT_ADMIN')")
    public void updateAvatar(int projectId, @Nonnull AvatarSupplier supplier) {
        checkNotNull(supplier, "supplier");

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

    @Override
    @PreAuthorize("hasProjectPermission(#projectId, 'PROJECT_ADMIN')")
    public void updateAvatar(int projectId, @Nonnull String uri) {
        checkArgument(!checkNotNull(uri, "uri").trim().isEmpty(), "A non-blank data URI is required");

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

    /**
     * Retrieves the project identified by the specified {@code projectId} and updates its avatar using an
     * {@link AvatarSupplier} obtained from the provided {@code metaSupplier}.
     *
     * @param projectId    the {@link Project#getId() primary key ID} of the project to update
     * @param metaSupplier an {@link AvatarSupplier} supplier, which may not be {@code null} or return {@code null}
     */
    private void doUpdateAvatar(int projectId, Supplier<AvatarSupplier> metaSupplier) {
        InternalProject project = getProjectOrFail(projectId);
        if (project.getType() == ProjectType.PERSONAL) {
            if (avatarService.isEnabled()) {
                //Gravatar is enabled, so the user can change the avatar for their personal project by changing their
                //avatar on Gravatar. The project will automatically update
                throw new IntegrityException(i18nService.createKeyedMessage("bitbucket.service.project.personal.avatarunmodifiable.usegravatar", Product.NAME));
            } else {
                //When Gravatar is not enabled, there is currently no way for a user to set their avatar. That means
                //there is also no way to set a different project avatar.
                throw new IntegrityException(i18nService.createKeyedMessage("bitbucket.service.project.personal.avatarunmodifiable"));
            }
        }

        avatarService.saveForProject(project, metaSupplier.get());
        eventPublisher.publish(new ProjectAvatarUpdatedEvent(this, project));
    }

    private void fireProjectCreated(Project project) {
        eventPublisher.publish(new ProjectCreatedEvent(this, project));
    }

    private void fireProjectCreationRequested(Project project) {
        SimpleCancelState cancelState = new SimpleCancelState();
        eventPublisher.publish(new ProjectCreationRequestedEvent(this, project, cancelState));
        if (cancelState.isCanceled()) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.service.project.creationcanceled");

            throw new ProjectCreationCanceledException(message, cancelState.getCancelMessages());
        }
    }

    private void fireProjectDeletionRequested(Project project) {
        SimpleCancelState cancelState = new SimpleCancelState();
        eventPublisher.publish(new ProjectDeletionRequestedEvent(this, project, cancelState));
        if (cancelState.isCanceled()) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.service.project.deletioncanceled");

            throw new ProjectDeletionCanceledException(message, cancelState.getCancelMessages());
        }
    }

    private void fireProjectModificationRequested(Project current, Project updated) {
        SimpleCancelState cancelState = new SimpleCancelState();
        eventPublisher.publish(new ProjectModificationRequestedEvent(this, current, updated, cancelState));
        if (cancelState.isCanceled()) {
            KeyedMessage message = i18nService.createKeyedMessage("bitbucket.service.project.updatedcanceled");

            throw new ProjectModificationCanceledException(message, cancelState.getCancelMessages());
        }
    }

    private Supplier<AvatarSupplier> getAvatarSupplier(ProjectCreateRequest request) {
        if (request.getAvatar() != null) {
            return Suppliers.ofInstance(request.getAvatar());
        } else if (!StringUtils.isBlank(request.getAvatarUri())) {
            return new DataUriAvatarMetaSupplier(avatarService, request.getAvatarUri());
        } else {
            return NO_AVATAR;
        }
    }

    /**
     * Retrieves the personal project for the specified user, optionally creating it if it does not exist.
     * <p>
     * If the {@code create} flag is {@code false}, this method will return {@code null} if the specified user does not
     * already have a personal project. Otherwise, when {@code create} is {@code true}, this method will never return
     * {@code null}. If the project does not already exist, it will be created.
     *
     * @param user   the user to retrieve and/or create a personal project for
     * @param create {@code true} if the user's personal project should be created, if it doesn't exist; otherwise,
     *               {@code false} to indicate one should not be created
     * @return a newly-created project, if {@code create} is {@code true}, an existing project, or {@code null} if the
     *         specified user does not already have a personal project and {@code create} is {@code false}
     */
    private InternalPersonalProject getOrCreatePersonalProject(ApplicationUser user, boolean create) {
        InternalPersonalProject project = projectDao.getByOwner(user.getId());
        if (project == null) {
            if (create) {
                InternalNormalUser stashUser = InternalConverter.convertToInternalUser(user)
                        .accept(InternalNormalUser.TO_NORMAL_USER);
                project = new InternalPersonalProject.Builder()
                        .owner(stashUser)
                        .build();

                //Do not raise a ProjectCreationRequestedEvent; personal project creation cannot be canceled, as the
                //system would not be able to respond rationally to that. If customers want to prevent users from
                //having personal projects/repositories that should be implemented similar to fork disabling, rather
                //than as an EventListener.
                //Do not apply the CREATE group validation to this project, as it will not pass.
                project = projectDao.createPersonal(project);
                log.debug("Created personal project {}", project.getKey());

                //Grant the user admin
                grantProjectAdminToCreator(project, user);
                fireProjectCreated(project);
            } else {
                log.trace("User {} does not yet have a personal project, and one has not been created", user.getName());
            }
        } else {
            log.trace("Personal project {} already exists; returning the existing project", project.getKey());
        }
        return project;
    }

    /**
     * Retrieves the {@link InternalProject project} with the specified ID, or throws a {@link NoSuchProjectException}
     * if no matching project exists.
     *
     * @param projectId the {@link InternalProject#getId() ID} of the project to retrieve
     * @return the retrieved project
     * @throws NoSuchProjectException if no project exists with the specified ID
     */
    @Nonnull
    private InternalProject getProjectOrFail(int projectId) {
        InternalProject project = getById(projectId);
        if (project == null) {
            throw new NoSuchProjectException(i18nService.createKeyedMessage("bitbucket.service.project.nosuchid", projectId));
        }

        return project;
    }

    /**
     * Grants {@link Permission#PROJECT_ADMIN} to the specified {@link InternalApplicationUser creator} for the specified
     * {@link InternalProject project}.
     *
     * @param project the project to grant the permission on
     * @param creator the creator to grant the permission to
     */
    private void grantProjectAdminToCreator(final InternalProject project, final ApplicationUser creator) {
        final InternalApplicationUser user = InternalConverter.convertToInternalUser(creator);

        //Sadly, this can't use the PermissionAdminService because how setProjectPermission works is not very smart.
        //For users who are ADMINs or SYSADMINs, granting PROJECT_ADMIN fails because it first attempts to revoke
        //the user's other permissions, which isn't allowed as it would "demote" their permissions. Also, setting
        //permissions is not allowed on personal projects.
        InternalProjectPermission permission = new InternalProjectPermission.Builder()
                .permission(Permission.PROJECT_ADMIN)
                .project(project)
                .user(user)
                .build();
        permissionDao.create(permission);

        eventPublisher.publish(new ProjectPermissionGrantedEvent(this, Permission.PROJECT_ADMIN, project, null, creator));
        log.debug("Granted {} to {} for project {}", Permission.PROJECT_ADMIN, creator.getName(), project.getKey());
    }

    private boolean isMirrorMode() {
        return mode == ApplicationMode.MIRROR;
    }
}
