package com.atlassian.bitbucket.commit;

import com.atlassian.bitbucket.event.pull.PullRequestRescopedEvent;
import com.atlassian.bitbucket.idx.CommitIndex;
import com.atlassian.bitbucket.pull.PullRequest;
import com.atlassian.bitbucket.pull.PullRequestRef;
import com.atlassian.bitbucket.repository.Repository;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.lang3.ObjectUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Set;

import static java.util.Objects.requireNonNull;

/**
 * Defines a request to retrieve commits "between" sets of {@link #getIncludes() included} and {@link #getExcludes()
 * excluded} commits, potentially filtering by {@link #getPaths() paths}.
 * <p>
 * The easiest way to understand commits "between" is with an illustration:
 * <pre>
 * "feature-B"               FB1 -- FB2 -- FB3
 *                           /      /
 *                          /      /
 * "master"    ---- A ---- B ---- C
 *                   \
 *                    \
 * "feature-A"        FA1 -- FA2 -- FA3
 * </pre>
 * Given the graph above, here are some examples of the commits "between" various points:
 * <ul>
 *     <li>Include "FA3", exclude "C": "FA1", "FA2", "FA3"
 *     <ul>
 *         <li>"FA3" also references "A", but, since "A" is reachable from "C", it is excluded from the results</li>
 *         <li>This might be used to list the commits that have been added on a feature branch and not yet merged</li>
 *     </ul>
 *     <li>Include "master", exclude "feature-A": "B", "C"
 *     <ul>
 *         <li>"feature-A" is resolved to "FA-3", excluding "FA2", "FA1" and "A", while "master" is resolved to "C"
 *         and includes "B"</li>
 *         <li>This might be used to list commits added on an upstream branch after a feature branch was created which
 *         have not yet been merged into the feature branch</li>
 *     </ul>
 *     </li>
 *     <li>Include "feature-B", exclude "FB1" and "C": "FB2", "FB3"
 *     <ul>
 *         <li>"feature-B" is resolved to "FB3", which includes "FB2" and "FB1" as well as "A", "B" and "C" from
 *         "master". Excluding "C" drops "A" and "B" as well</li>
 *         <li>This might be used, for example, to determine which commits have been added when a pull request is
 *         rescoped, excluding the previous {@link PullRequest#getFromRef() from ref} and the
 *         <i>current</i> {@link PullRequest#getToRef() to ref} yields the just the added
 *         commits</li>
 *     </ul>
 *     </li>
 *     <li>Include "FB1" and "C", exclude "feature-B": No commits
 *     <ul>
 *         <li>This is the logical negation of the previous example. When a pull request is rebased, this could be
 *         used to determine which commits were <i>removed</i> from the pull request by showing commits that
 *         used to be reachable from "FB1" but are no longer reachable from "FB3" or "C"</li>
 *     </ul>
 *     </li>
 * </ul>
 * The first two examples demonstrate how the selected commits vary, given the same set of possible commits,
 * depending on which commits are included and excluded. As might be expected, excluding a commit takes precedence
 * over including it. The second two examples show how using multiple includes and excludes can be used to determine
 * how a pull request has been changed by a {@link PullRequestRescopedEvent rescope event}.
 * <p>
 * Also useful when dealing with pull requests is the ability to determine the commits between two repositories.
 * When a {@link #getSecondaryRepository() secondary repository} is specified its commits will be made available in
 * the {@link #getRepository() primary repository}, which allows retrieving the commits that have been added on a
 * branch in a fork, for example, when opening a pull request back to its origin. When a secondary repository is
 * specified, it must be from the same {@link Repository#getHierarchyId() hierarchy} as the primary repository or
 * an exception will be thrown while {@link Builder#build building} the request.
 * <p>
 * <b>Warning:</b> Retrieving commits between repositories may be <i>expensive</i>. Additionally, when referring
 * to commits in the {@link #getSecondaryRepository() secondary repository}, {@link #getIncludes() includes} and
 * {@link #getExcludes() excludes} <i>must</i> be specified by hash. Branch and tag names are <i>always</i> resolved
 * using the {@link #getRepository() primary repository}, which may lead to unexpected results.
 * <p>
 * Note: Once {@link CommitsBetweenRequest.Builder#build() built}, a request is <i>immutable</i>. None of the returned
 * collections may be modified.
 */
//TODO in 5.0: Remove methods overridden from AbstractCommitsRequest
@SuppressWarnings("unused")
public class CommitsBetweenRequest extends AbstractCommitsRequest {

    private final Set<String> excludes;
    private final Set<String> includes;
    private final Repository secondaryRepository;

    private CommitsBetweenRequest(Builder builder) {
        super(builder);

        excludes = builder.excludes.build();
        includes = builder.includes.build();
        secondaryRepository = builder.secondaryRepository;

        Repository repository = super.getRepository();
        if (secondaryRepository != null &&
                ObjectUtils.notEqual(repository.getHierarchyId(), secondaryRepository.getHierarchyId())) {
            //This is a developer error; it should not be possible for a user to ever trigger this. Given that,
            //this is not internationalised (additionally, it'd be pretty difficult to do so anyway...)
            throw new IllegalStateException(
                    secondaryRepository.getProject().getKey() + "/" + secondaryRepository.getSlug() +
                            " is not from the same hierarchy as " +
                            repository.getProject().getKey() + "/" + repository.getSlug() +
                            "; commits may only be streamed between repositories from the same hierarchy.");
        }
        if (includes.isEmpty()) {
            throw new IllegalStateException("At least one commit must be provided to include");
        }
    }

    /**
     * Retrieves commits, which may be identified by branch or tag name or by hash, which should be excluded from
     * the results.
     * <p>
     * Note: Branch and tag names are <i>always</i> resolved against the {@link #getRepository() primary repository}.
     *
     * @return a set containing zero or more commits to exclude
     */
    @Nonnull
    public Set<String> getExcludes() {
        return excludes;
    }

    /**
     * Retrieves commits, which may be identified by branch or tag name or by hash, which should be included in the
     * results. When a commit is both included and {@link #getExcludes() excluded}, it is <i>excluded</i>.
     * <p>
     * Note: Branch and tag names are <i>always</i> resolved against the {@link #getRepository() primary repository}.
     *
     * @return a set containing one or more commits to include
     */
    @Nonnull
    public Set<String> getIncludes() {
        return includes;
    }

    /**
     * @return the max message length to be processed for any of the commits, which may be
     *         {@link #UNLIMITED_MESSAGE_LENGTH} to include the full commit message regardless of length
     * @since 4.5
     */
    @Override
    public int getMaxMessageLength() {
        return super.getMaxMessageLength();
    }

    /**
     * Retrieves paths, which may identify directories or files within the repository, which are used to limit returned
     * commits. When multiple paths are provided, commits which modify <i>any</i> of the specified paths may be
     * returned.
     *
     * @return a set containing zero or more paths
     */
    @Nonnull
    @Override
    public Set<String> getPaths() {
        return super.getPaths();
    }

    /**
     * Retrieves the keys of {@link CommitIndex index} properties to load for returned commits.
     *
     * @return a set containing zero or more commit attributes to load
     * @see CommitIndex#getProperties(String, Iterable)
     * @see CommitIndex#getProperties(Iterable, Iterable)
     */
    @Nonnull
    @Override
    public Set<String> getPropertyKeys() {
        return super.getPropertyKeys();
    }

    /**
     * Retrieves the <b>primary</b> repository. This repository must always be provided. If any {@link #getIncludes()
     * included} or {@link #getExcludes() excluded} commit is identified by branch or tag name, those names will be
     * resolved using this repository.
     *
     * @return the primary repository for the request
     */
    @Nonnull
    @Override
    public Repository getRepository() {
        return super.getRepository();
    }

    /**
     * When retrieving commits between repositories, retrieves the <b>secondary</b> repository. Commits in this
     * repository may only be identified <i>by hash</i>. Any branch or tag names used will <i>always</i> be resolved
     * using the {@link #getRepository() primary repository}.
     *
     * @return a secondary repository whose commits should be considered
     */
    @Nullable
    public Repository getSecondaryRepository() {
        return secondaryRepository;
    }

    /**
     * Whether or not the commit history will attempt to follow renames. This is only applicable for individual file
     * {@link #getPaths() paths}
     *
     * @return {@code true} if renames are followed, {@code false} otherwise
     * @since 4.5
     */
    @Override
    public boolean isFollowingRenames() {
        return super.isFollowingRenames();
    }

    /**
     * @return whether or not to ignore missing commits, or {@code null} to let the application figure out whether
     * to do so.
     * @since 4.5
     * @deprecated in 4.5. This return type will change to {@code boolean} in 5.0.
     */
    @Deprecated
    @Nullable
    @Override
    @SuppressWarnings("deprecation")
    public Boolean isIgnoringMissing() {
        return super.isIgnoringMissing();
    }

    public static class Builder extends AbstractCommitsRequestBuilder<Builder> {

        private final ImmutableSet.Builder<String> excludes = ImmutableSet.builder();
        private final ImmutableSet.Builder<String> includes = ImmutableSet.builder();

        private Repository secondaryRepository;

        public Builder(@Nonnull Repository repository) {
            super(repository);
        }

        /**
         * Creates a new {@code Builder} by copying the specified {@link CommitsBetweenRequest request}.
         *
         * @param request the request to copy
         */
        public Builder(@Nonnull CommitsBetweenRequest request) {
            super(request);

            exclude(request.getExcludes())
                    .include(request.getIncludes())
                    .maxMessageLength(request.getMaxMessageLength())
                    .secondaryRepository(request.getSecondaryRepository());
        }

        /**
         * Creates a new {@code Builder} with defaults appropriate for retrieving the commits which are included by
         * the specified {@link PullRequest}.
         * <p>
         * The new builder:
         * <ul>
         *     <li>Uses the {@link PullRequest#getToRef() to ref's} {@link Repository} as the
         *     {@link CommitsBetweenRequest#getRepository() repository} for the request</li>
         *     <li>Uses the {@link PullRequest#getFromRef() from ref's} {@link Repository} as the
         *     {@link #secondaryRepository(Repository) secondary repository}, if the provided
         *     {@link PullRequest} is {@link PullRequest#isCrossRepository() cross-repository}</li>
         *     <li>{@link #include(Iterable) Includes} the {@link PullRequest#getFromRef() from ref}</li>
         *     <li>{@link #exclude(Iterable) Excludes} the {@link PullRequest#getToRef() to ref}</li>
         * </ul>
         *
         * @param pullRequest the pull request to retrieve commits for
         */
        public Builder(@Nonnull PullRequest pullRequest) {
            this(requireNonNull(pullRequest, "pullRequest").getFromRef(), pullRequest.getToRef());
        }

        private Builder(@Nonnull PullRequestRef fromRef, @Nonnull PullRequestRef toRef) {
            super(requireNonNull(toRef, "pullRequest.toRef").getRepository());

            requireNonNull(fromRef, "pullRequest.fromRef");

            exclude(requireNonNull(toRef.getLatestCommit(), "pullRequest.toRef.latestCommit"))
                    .include(requireNonNull(fromRef.getLatestCommit(), "pullRequest.fromRef.latestCommit"));
            if (toRef.getRepository().getId() != fromRef.getRepository().getId()) {
                secondaryRepository(fromRef.getRepository());
            }
        }

        /**
         * @return a new {@link CommitsBetweenRequest} assembled from the provided values
         * @throws IllegalStateException if a {@link #secondaryRepository(Repository) secondary repository} was
         *                               specified and it belongs to a different {@link Repository#getHierarchyId()
         *                               hierarchy} than the primary repository
         */
        @Nonnull
        public CommitsBetweenRequest build() {
            return new CommitsBetweenRequest(this);
        }

        @Nonnull
        public Builder exclude(@Nullable String value, @Nullable String... values) {
            addIf(NOT_BLANK, excludes, value, values);

            return self();
        }

        @Nonnull
        public Builder exclude(@Nullable Iterable<String> values) {
            addIf(NOT_BLANK, excludes, values);

            return self();
        }

        @Nonnull
        @Override
        public Builder followRenames(boolean value) {
            return super.followRenames(value);
        }

        @Nonnull
        @Override
        @SuppressWarnings("deprecation")
        public Builder ignoreMissing(@Nullable Boolean value) {
            return super.ignoreMissing(value);
        }

        @Nonnull
        public Builder include(@Nullable String value, @Nullable String... values) {
            addIf(NOT_BLANK, includes, value, values);

            return self();
        }

        @Nonnull
        public Builder include(@Nullable Iterable<String> values) {
            addIf(NOT_BLANK, includes, values);

            return self();
        }

        @Nonnull
        @Override
        public Builder maxMessageLength(int value) {
            return super.maxMessageLength(value);
        }

        @Nonnull
        @Override
        public Builder path(@Nullable String value) {
            return super.path(value);
        }

        @Nonnull
        @Override
        public Builder paths(@Nullable Iterable<String> values) {
            return super.paths(values);
        }

        @Nonnull
        @Override
        public Builder paths(@Nullable String value, @Nullable String... values) {
            return super.paths(value, values);
        }

        @Nonnull
        @Override
        public Builder propertyKey(@Nullable String value) {
            return super.propertyKey(value);
        }

        @Nonnull
        @Override
        public Builder propertyKeys(@Nullable Iterable<String> values) {
            return super.propertyKeys(values);
        }

        @Nonnull
        @Override
        public Builder propertyKeys(@Nullable String value, @Nullable String... values) {
            return super.propertyKeys(value, values);
        }

        @Nonnull
        public Builder secondaryRepository(@Nullable Repository value) {
            secondaryRepository = value;

            return self();
        }

        @Override
        protected Builder self() {
            return this;
        }
    }
}
