package com.atlassian.bitbucket.internal.webhook;

import com.atlassian.bitbucket.event.project.ProjectEvent;
import com.atlassian.bitbucket.event.repository.RepositoryEvent;
import com.atlassian.bitbucket.event.repository.RepositoryRefsChangedEvent;
import com.atlassian.bitbucket.internal.webhook.json.JsonWebHookParametersHelper;
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.user.ApplicationUser;
import com.atlassian.bitbucket.user.UserType;
import com.atlassian.bitbucket.util.MoreStreams;
import com.atlassian.bitbucket.webhook.WebHookEventId;
import com.atlassian.bitbucket.webhook.WebHookListenerOwnerResolver;
import com.atlassian.webhooks.api.publish.WebHookEvent;
import com.atlassian.webhooks.api.register.listener.PersistentWebHookListener;
import com.atlassian.webhooks.api.register.listener.WebHookListener;
import com.atlassian.webhooks.api.util.*;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;

import javax.annotation.Nonnull;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;

import static com.atlassian.bitbucket.util.OptionalUtils.or;
import static com.atlassian.bitbucket.webhook.WebHookEventId.ALL;
import static org.slf4j.LoggerFactory.getLogger;

public class StandardWebHookListenerAccessVoter extends AbstractWebHookListenerAccessVoter {
    private static final Logger log = getLogger(StandardWebHookListenerAccessVoter.class);

    private static final Set<WebHookEventId> ALL_EVENT_IDS;
    private static final Set<WebHookEventId> PROJECT_EVENT_IDS = ImmutableSet.copyOf(Arrays.asList(
            WebHookEventId.PROJECT,
            WebHookEventId.PROJECT_CREATED,
            WebHookEventId.PROJECT_DELETED,
            WebHookEventId.PROJECT_MODIFIED));
    private static final Set<WebHookEventId> REPOSITORY_EVENT_IDS = ImmutableSet.copyOf(Arrays.asList(
            WebHookEventId.REPOSITORY,
            WebHookEventId.REPOSITORY_CREATED,
            WebHookEventId.REPOSITORY_DELETED,
            WebHookEventId.REPOSITORY_MODIFIED,
            WebHookEventId.REPOSITORY_REFS_CHANGED));

    static {
        ALL_EVENT_IDS = ImmutableSet.<WebHookEventId>builder().add(ALL).addAll(PROJECT_EVENT_IDS).addAll(REPOSITORY_EVENT_IDS).build();
    }

    private final PermissionService permissionService;
    private final WebHookListenerOwnerResolverRegistry resolverRegistry;

    public StandardWebHookListenerAccessVoter(PermissionService permissionService,
                                              WebHookListenerOwnerResolverRegistry resolverRegistry) {
        this.permissionService = permissionService;
        this.resolverRegistry = resolverRegistry;
    }

    @Nonnull
    @Override
    public Vote canAdmin(@Nonnull PersistentWebHookListener listener, @Nonnull Channel channel) {
        return voteOnRegistrationMutation(listener);
    }

    @Nonnull
    @Override
    public Vote canCreate(@Nonnull PersistentWebHookListener listener, @Nonnull Channel channel) {
        return voteOnRegistrationMutation(listener);
    }

    @Nonnull
    @Override
    public Vote canPublish(@Nonnull WebHookEvent webHookEvent, @Nonnull WebHookListener listener) {
        if (!isApplicationWebHookEvent(webHookEvent)) {
            log.debug("Vote requested for unknown web hook {} - abstaining", webHookEvent.getId());
            return Vote.ABSTAIN;
        }

        return findActiveUserFor(listener)
                .map(user -> voteOnPublication(webHookEvent, listener, user))
                .orElse(Vote.DENY);
    }

    @Nonnull
    @Override
    public Vote canRead(@Nonnull PersistentWebHookListener listener, @Nonnull Channel channel) {
        return voteOnRegistrationMutation(listener);
    }

    private boolean canRead(ApplicationUser user, Project project) {
        return permissionService.hasProjectPermission(user, project, Permission.PROJECT_READ);
    }

    private boolean canRead(ApplicationUser user, Repository repository) {
        return permissionService.hasRepositoryPermission(user, repository, Permission.REPO_READ);
    }

    private boolean canReadProject(int projectId) {
        return permissionService.hasProjectPermission(projectId, Permission.PROJECT_READ);
    }

    private boolean canReadRepository(int repositoryId) {
        return permissionService.hasRepositoryPermission(repositoryId, Permission.REPO_READ);
    }

    /**
     * Finds the user associated with the web hook listener. This is delegated to OSGi-exported
     * {@link WebHookListenerOwnerResolver} instances which can perform the mapping depending on the type of listener,
     * the installed plugins and what these resolvers know about various internal mappings. The first resolver to
     * {@link Optional#isPresent() successfully} match the listener to a user is used.
     * <p>
     * To make things more concrete, for persistent listeners (created by the Web Hooks API) this will typically map
     * to the last user to update the listener's record in the database. For module-based listeners (typically defined
     * in Atlassian Connect descriptors for add-ons) the user will be the active (not deleted, not disabled) service
     * user for that add-on.
     *
     * @see PersistentWebHookListenerOwnerResolver
     * @see WebHookListenerOwnerResolver
     */
    private Optional<ApplicationUser> findActiveUserFor(@Nonnull WebHookListener listener) {
        return resolverRegistry.getResolvers().stream()
                .map(resolver -> resolver.resolveOwner(listener))
                .flatMap(MoreStreams::streamOptional)
                .filter(user -> user.getType() == UserType.NORMAL || user.isActive())
                .findFirst();
    }

    private WebHookFilterParameters getFilterParams(String filterJson) {
        return JsonWebHookParametersHelper.toFilterParameters(filterJson);
    }

    private WebHookFilterParameters getFilterParams(PersistentWebHookListener listener) {
        Filter filter = listener.getFilters().get(new SectionKey(WebHookConstants.FILTER_RESOURCE));
        return getFilterParams(filter == null ? "" : filter.getValue());
    }

    private boolean isApplicationWebHookEvent(@Nonnull WebHookEvent webHookEvent) {
        return isApplicationWebHookEvent(webHookEvent.getId());
    }

    private boolean isApplicationWebHookEvent(String webHookId) {
        return WebHookEventId.forId(webHookId).map(ALL_EVENT_IDS::contains).orElse(false);
    }

    private Boolean isForOtherRepository(WebHookFilterParameters parameters, Repository repository) {
        return parameters.getRepositoryId().map(id -> !id.equals(repository.getId())).orElse(false);
    }

    private Boolean isForOtherProject(WebHookFilterParameters parameters, Project project) {
        return parameters.getProjectId().map(id -> !id.equals(project.getId())).orElse(false);
    }

    protected boolean isProjectEvent(@Nonnull WebHookEvent webHookEvent) {
        return webHookEvent.getEvent() instanceof ProjectEvent;
    }

    private boolean isRepositoryRefChangedEvent(WebHookEvent webHookEvent) {
        return webHookEvent.getEvent() instanceof RepositoryRefsChangedEvent;
    }

    protected boolean isRepositoryEvent(@Nonnull WebHookEvent webHookEvent) {
        return webHookEvent.getEvent() instanceof RepositoryEvent;
    }

    private Vote toVote(boolean allowed) {
        return allowed ? Vote.ALLOW : Vote.DENY;
    }

    /**
     * Votes on whether a webhook event should be published. If the webhook registration filter contains a project id
     * or repository id it will proceed as long as the event matches the provided id (permission checking for the
     * supplied user is not performed to allow web hooks to continue operating when users leave the company etc).
     * If no repository or project id is supplied in the filter then permission checking is performed before voting.
     */
    private Vote voteOnPublication(@Nonnull WebHookEvent webHookEvent, @Nonnull WebHookListener listener, @Nonnull ApplicationUser user) {
        WebHookFilterParameters parameters = getFilterParams(listener.getParameters().getFilter());

        if (isRepositoryEvent(webHookEvent) || isRepositoryRefChangedEvent(webHookEvent)) {
            Repository repository = isRepositoryEvent(webHookEvent)
                    ? ((RepositoryEvent) webHookEvent.getEvent()).getRepository()
                    : ((RepositoryRefsChangedEvent) webHookEvent.getEvent()).getRepository();

            Project project = repository.getProject();

            // repository id was specified for this web hook but this event is for a different repository
            if (isForOtherRepository(parameters, repository)) {
                log.debug("Abstaining from voting for web hook {} - the listener is filtering on a different repository " +
                          "to the current event's source: {} vs {}", webHookEvent.getId(), parameters.getRepositoryId().get(),
                          repository.getId());
                // abstain because a similar web hook with a different filter may snag it
                return Vote.ABSTAIN;
            }

            // project id was specified for this web hook but this event is for a repository belonging to a different
            // project
            if (isForOtherProject(parameters, project)) {
                log.debug("Abstaining from voting for web hook {} - the listener is filtering on a different project " +
                          "to the current event's source: {} vs {}", webHookEvent.getId(), parameters.getProjectId().get(),
                          project.getId());
                // abstain because a similar web hook with a different filter may snag it
                return Vote.ABSTAIN;
            }

            // if we get here we know either there is no specific repo / project filtering or if there is it matches
            // with the event we are voting on

            // if repo id or project id was supplied then specific permissions were checked at registration time, that's
            // good enough otherwise allow only if the user can read the repository the event is related to
            return or(parameters.getRepositoryId().map(id -> Vote.ALLOW), parameters.getProjectId().map(id -> Vote.ALLOW))
                    .orElseGet(() -> toVote(canRead(user, repository)));
        } else if (isProjectEvent(webHookEvent)) {
            Project project = ((ProjectEvent) webHookEvent.getEvent()).getProject();

            // project id is specified and this is not the right one
            if (isForOtherProject(parameters, project)) {
                log.debug("Abstaining from voting for web hook {} - the listener is filtering on a different project " +
                                "to the current event's source: {} vs {}", webHookEvent.getId(), parameters.getProjectId().get(),
                        project.getId());
                // abstain because a similar web hook with a different filter may snag it
                return Vote.ABSTAIN;
            }

            // if we get here we know either there is no specific project filtering or if there is it matches
            // with the event we are voting on

            // if project id is supplied then specific permissions were checked at registration time, that's good enough
            // otherwise allow only if the user can read the project
            return parameters.getProjectId().map(id -> Vote.ALLOW)
                    .orElseGet(() -> toVote(canRead(user, project)));
        } else {
            // we shouldn't get here
            log.debug("Can't handle unexpected event class {} for web hook {}",
                    webHookEvent.getEvent().getClass(), webHookEvent.getId());
            return Vote.ABSTAIN;
        }
    }

    /**
     * Votes on whether a web hook registration should be created, updated or deleted.
     * If a project id or repository id is specifed in the filter, the current user must have read permissions on these
     * for the registration modification to proceed otherwise it is denied. If no project or repository id is specified the
     * registration modification proceeds but permission checking will be performed at publication time.
     */
    private Vote voteOnRegistrationMutation(PersistentWebHookListener listener) {
        CompositeVote vote = new CompositeVote();
        WebHookFilterParameters parameters = getFilterParams(listener);

        // if the repo ID is specified, check the user has read access to it
        Supplier<Optional<Vote>> canReadRepoIfSpecified = () -> parameters.getRepositoryId().map(id -> toVote(canReadRepository(id)));
        // else if if the project ID is specified, check the user has read access to it
        Supplier<Optional<Vote>> canReadProjectIfSpecified = () -> parameters.getProjectId().map(id -> toVote(canReadProject(id)));

        listener.getEvents().stream().filter(this::isApplicationWebHookEvent).forEach(eventId -> {
            // if no project or repo id is provided it means that the web hook listener applies to _all_ projects or repos
            // (as suitable to the kind of web hook). Filtering needs to be done at publish time instead
            vote.add(or(canReadRepoIfSpecified, canReadProjectIfSpecified).orElse(Vote.ALLOW));
        });

        return vote.getOutcome();
    }
}