package com.atlassian.stash.internal.pull.comment.drift;

import com.atlassian.bitbucket.content.AbstractChangeCallback;
import com.atlassian.bitbucket.content.Change;
import com.atlassian.bitbucket.content.ChangeCallback;
import com.atlassian.bitbucket.content.ChangeSummary;
import com.atlassian.bitbucket.scm.ChangesCommandParameters;
import com.atlassian.bitbucket.scm.ScmCommandFactory;
import com.atlassian.bitbucket.scm.ScmService;
import com.atlassian.bitbucket.scm.pull.PullRequestEffectiveDiff;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import com.atlassian.stash.internal.page.PageConstants;
import com.atlassian.stash.internal.pull.InternalPullRequest;
import com.atlassian.stash.internal.pull.InternalPullRequestDiffCommentAnchor;
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.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * Drift strategy that exclusively and single-handedly processes file comments. It <i>does not</i> affect any line
 * comments and it <i>always</i> processes <i>all</i> file comments in given drift context.
 * <p>
 * The algorithm applied by this strategy is based on calculating the diff-tree in the effective diff of the processed
 * pull request. At most one {@link ScmCommandFactory#changes(ChangesCommandParameters, ChangeCallback) changes command}
 * is triggered. See the documentation of {@link #apply(DriftContext)} for more details.
 *
 * @since 2.11
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class FileCommentDriftStrategy implements CommentDriftStrategy {

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

    private final ScmService scmService;

    @Value(PageConstants.MAX_CHANGES)
    private int maxChanges;

    @Autowired
    public FileCommentDriftStrategy(ScmService scmService) {
        this.scmService = scmService;
    }

    /**
     * File-level comments can either be retained, or orphaned, and cannot be drifted per se, as line numbers don't
     * apply.
     * <p>
     * If there are no file-level comments in the current {@code driftContext}, no further processing is done by this
     * strategy.
     * <p>
     * If there is at least one file-level comment, a call to
     * {@link ScmCommandFactory#changes(ChangesCommandParameters, ChangeCallback)} for the current
     * {@link DriftContext#getCurrentDiff() effective diff} is executed to determine what paths are still in scope
     * for the pull request. The file comment anchors are then retained (if pointing to a path that is still in scope),
     * or orphaned (if pointing to a path that is no longer in scope) accordingly.
     * <p>
     * From the above it follows that there is maximum one call to the {@code changes} command of the underlying SCM
     * made per single file-level comment drift calculation.
     *
     * @param driftContext the context to apply to.
     */
    @Override
    public void apply(@Nonnull DriftContext driftContext) {
        DriftResult driftResult = processPullRequestDiff(driftContext);
        // apply the changes to the drift context
        driftResult.applyTo(driftContext);
    }

    @Override
    public String getName() {
        return "FileComment";
    }

    private void processChanges(ScmCommandFactory commandFactory, FileCommentDriftCallback callback,
                                PullRequestEffectiveDiff diff) {
        ChangesCommandParameters parameters = new ChangesCommandParameters.Builder()
                .sinceId(diff.getSinceId())
                .untilId(diff.getUntilId())
                .maxChanges(maxChanges)
                .build(); //Note: Paths are _intentionally_ not set on these parameters

        commandFactory.changes(parameters, callback).call();
    }

    private DriftResult processPullRequestDiff(DriftContext context) {
        FileCommentDriftCallback callback = new FileCommentDriftCallback(context);
        if (callback.isEmpty()) {
            // no file comments - no processing required
            log.debug("{}: No file comment anchors to process", context.getPullRequest().getGlobalId());
            return callback.done();
        }

        ScmCommandFactory commandFactory = scmService.getCommandFactory(context.getRepository());
        PullRequestEffectiveDiff currentDiff = context.getCurrentDiff();
        InternalPullRequest pullRequest = context.getPullRequest();

        String timerName = "Drift: " + getName() + " - Diff-tree [" + currentDiff.getSinceId() +
                "]->[" + currentDiff.getUntilId() + "] " + pullRequest.getGlobalId();

        try (Timer ignored = TimerUtils.start(timerName)) {
            processChanges(commandFactory, callback, currentDiff);
        }

        return callback.done();
    }

    /**
     * {@code ChangeCallback} used to record information about file comment anchors to retain or orphan. All anchors
     * matching any path passed on to this callback are retained, all remaining ones are orphaned.
     */
    private static class FileCommentDriftCallback extends AbstractChangeCallback {

        private final DriftResult driftResult = new DriftResult();
        private final Map<String, List<InternalPullRequestDiffCommentAnchor>> fileAnchorsByPath = Maps.newHashMap();

        FileCommentDriftCallback(DriftContext context) {
            for (InternalPullRequestDiffCommentAnchor anchor : context) {
                if (anchor.isFileComment()) {
                    fileAnchorsByPath.computeIfAbsent(anchor.getPath(), path -> new ArrayList<>())
                            .add(anchor);
                }
            }
        }

        @Override
        public boolean onChange(@Nonnull Change change) throws IOException {
            // retain all anchors, whose paths match the change path, as it'll stay in the PR diff
            driftResult.retain(getMatchingAnchors(change));

            return true;
        }

        @Override
        public void onEnd(@Nonnull ChangeSummary summary) throws IOException {
            // orphan all the remaining anchors - no changes for their paths, so they're not in the PR diff
            driftResult.orphan(fileAnchorsByPath.values().stream().flatMap(List::stream));
            // clear the map to indicate we're done with partitioning the anchors
            fileAnchorsByPath.clear();
        }

        /**
         * Ensure all the file anchors have been processed and return the drift result. Must be called <i>after</i> this
         * callback has been passed on to {@link ScmCommandFactory#changes(ChangesCommandParameters, ChangeCallback)},
         * or otherwise making sure it is {@link #isEmpty() empty} beforehand.
         *
         * @return the drift result
         */
        DriftResult done() {
            if (!isEmpty()) {
                throw new IllegalStateException("This context should be empty after processing all changes");
            }

            return driftResult;
        }

        /**
         * @return {@code true}, if this callback is empty, meaning there is no file comments to process in the current
         * drift
         */
        boolean isEmpty() {
            return fileAnchorsByPath.isEmpty();
        }

        private Iterable<InternalPullRequestDiffCommentAnchor> getMatchingAnchors(Change change) {
            List<InternalPullRequestDiffCommentAnchor> matching = fileAnchorsByPath.remove(change.getPath().toString());

            return matching == null ? Collections.emptyList() : matching;
        }
    }
}
