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

import com.atlassian.bitbucket.content.*;
import com.atlassian.bitbucket.scm.Command;
import com.atlassian.bitbucket.scm.DiffCommandParameters;
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.Iterables;
import org.apache.commons.collections.CollectionUtils;
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.annotation.Order;
import org.springframework.stereotype.Component;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.util.*;

import static com.atlassian.stash.internal.pull.comment.drift.DriftResult.ProcessedAnchor;

/**
 * Calculates comment drift by parsing diffs between the previous state of the pull request and its current state.
 * <p>
 * Each {@link InternalPullRequestDiffCommentAnchor anchor} has a {@link DiffSegmentType line type} associated with it
 * which indicates which "side" of the pull request the line came from. {@link DiffSegmentType#ADDED added} lines come
 * from the {@link InternalPullRequest#getFromRef() from} (or "source") side of the pull request, and all other lines
 * are from the {@link InternalPullRequest#getToRef() to} (or "target") side.
 * <p>
 * The drift calculated by this strategy can result one of several possible outcomes for any given anchor:
 * <ul>
 *     <li>{@link DriftContext#drift(InternalPullRequestDiffCommentAnchor, int) Drifting}, for anchors whose line
 *     numbers have been changed due to lines being added or removed <i>before</i> them</li>
 *     <li>{@link DriftContext#orphan(InternalPullRequestDiffCommentAnchor) Orphaning}, for anchors on lines which
 *     have been changed since the comment was added</li>
 *     <li>{@link DriftContext#retain(InternalPullRequestDiffCommentAnchor) Retaining}, for anchors in files which
 *     are not changed in any diff, or on lines which are before any new lines are added or removed</li>
 * </ul>
 * <p>
 * For simplicity, it is likely easiest to understand the diffs used with pictures to help visualise them. Consider
 * the following pull request:
 * <code><pre>
 *
 *  I      o---A (source)
 *        /    |
 *       /     M
 *      /      |
 *  ---o---o---1 (target)
 *
 * </pre></code>
 * {@code A} is the tip revision on the {@code source} branch, and {@code 1} is the tip revision on the {@code target}
 * branch. {@code M} identifies the theoretical merge commit which is used by the pull request's effective diff. If a
 * rescope happened which updated the pull request's {@code source} branch:
 * <code><pre>
 *
 *  II     o---A---B (source)
 *        /    |  /
 *       /     M N
 *      /      |/
 *  ---o---o---1 (target)
 *
 * </pre></code>
 * {@code B} is now the tip revision of the {@code source} branch, and {@code N} is the new theoretical merge commit
 * used by the effective diff. Note that {@code 1} is still the tip revision of the {@code target} branch. This rescope
 * introduces the following diffs:
 * <ul>
 *     <li>{@code A} to {@code B}: At first, it would appear {@link DiffSegmentType#ADDED added} lines could be drifted
 *     by this "source diff", because the added lines were introduced by the {@code A} commit. However, because comments
 *     are added to, and anchored in, the theoretical merge, the line numbers where the added lines <i>appear</i> might
 *     not match their line numbers in commit {@code A}. As a result <i>this diff is meaningless for drift</i></li>
 *     <li>{@code M} to {@code N}: This could be considered the "meta diff"; it describes how the diff has changed with
 *     the rescope. {@link DiffSegmentType#ADDED Added} lines will have their drift calculated by performing this diff,
 *     because it takes into consideration how the code on the {@code target} branch may have impacted the line numbers
 *     when the comments were added</li>
 * </ul>
 * Lastly, note that {@link DiffSegmentType#CONTEXT context} and {@link DiffSegmentType#REMOVED removed} lines <i>can
 * never drift</i> in this rescope; because they are anchored relative to the code in commit {@code 1}, which is still
 * the current tip for {@code target}, their line numbers are always unchanged.
 * <p>
 * Now, if a rescope were to update the {@code target} branch:
 * <code><pre>
 *
 *  III    o---o---B (source)
 *        /       /|
 *       /       N O
 *      /       /  |
 *  ---o---o---1---2 (target)
 *
 * </pre></code>
 * The previous {@code A} and {@code M} commits are no longer relevant, and so they have been omitted. {@code 2} is now
 * the tip of the {@code target} branch, and {@code O} is the new theoretical merge commit used by the effective diff
 * (Note that a rescope to <i>either</i> side always introduces a new theoretical merge). This rescope introduces the
 * following diffs:
 * <ul>
 *     <li>{@code N} to {@code O}: As with scenario II, this "meta diff" will be used to calculate drift for
 *     {@link DiffSegmentType#ADDED added} lines</li>
 *     <li>{@code 1} to {@code 2}: {@link DiffSegmentType#CONTEXT context} and {@link DiffSegmentType#REMOVED removed}
 *     lines, which are anchored to lines from commit {@code 1}, will have their drift calculated by performing this
 *     "target diff"</li>
 * </ul>
 * Unlike a rescope on the {@code source} branch, a rescope on the {@code target} branch can trigger drift for comments
 * on <i>all</i> line types.
 * <p>
 * The final scenario is a rescope which updates both the {@code source} and {@code target} branches:
 * <code><pre>
 *
 *  IV     o---o---B---C (source)
 *        /        |   |
 *       /         O   P
 *      /          |   |
 *  ---o---o---o---2---3 (target)
 *
 * </pre></code>
 * {@code C} is now the tip of the {@code source} branch, {@code 3} is now the tip of the {@code target} branch and
 * {@code P} is the new theoretical merge commit used by the effective diff. <i>For the purposes of calculating drift,
 * this scenario is identical to scenario III.</i> Consider the diffs:
 * <ul>
 *     <li>{@code B} to {@code C}: As described for scenario II, this "source diff" is meaningless for drift because no
 *     anchors are actually attached to it</li>
 *     <li>{@code O} to {@code P}: As with scenarios II and III, this "meta diff" will by used to calculate drift for
 *     {@link DiffSegmentType#ADDED added} lines</li>
 *     <li>{@code 2} to {@code 3}: As with scenario III, this "target diff" will be used to calculate drift for both
 *     {@link DiffSegmentType#CONTEXT context} and {@link DiffSegmentType#REMOVED removed} lines</li>
 * </ul>
 * To sum up, then: The "meta diff" (the diff between the theoretical merge commits) is used to
 * {@link #calculateDriftInSource(DriftContext, ScmCommandFactory, Map) calculate drift} for
 * {@link DiffSegmentType#ADDED added} lines and the "target diff" is used to
 * {@link #calculateDriftInTarget(DriftContext, ScmCommandFactory, Map) calculate drift} for both
 * {@link DiffSegmentType#CONTEXT context} and {@link DiffSegmentType#REMOVED removed} lines.
 * <p>
 * After calculating drift, if any comments are still considered "reachable", meaning they have either been retained
 * unmodified or have drifted to a new line, the final step is to run the pull request's current effective diff. By
 * matching the calculated anchors against the effective diff, two possible scenarios are covered:
 * <ul>
 *     <li>A matching, compatible change was made on the other side of the pull request, effectively converting any
 *     affected lines from whatever their previous type was to context</li>
 *     <li>Drift calculations are wrong, because the code is incorrect</li>
 * </ul>
 * Either of the scenarios above triggers the comment to be <i>orphaned</i>. The current drift processing performed by
 * this strategy will <i>never</i> update an anchor's {@link InternalPullRequestDiffCommentAnchor#getLineType() line
 * type}. Note that comments on context lines can be orphaned by this check; if the diff changes in a way that shifts
 * context lines such that the context around a change no longer includes them. For example, if a change is reverted
 * all of the context lines around that change may disappear from the new effective diff if there are no other changes
 * nearby to include them.
 *
 * @since 1.3
 * @see DriftDiffContentCallback
 * @see OrphanDetectingDiffContentCallback
 */
@Component
@Order(0)
public class DiffCommentDriftStrategy implements CommentDriftStrategy {

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

    private final ScmService scmService;

    @Value(PageConstants.PULLREQUEST_DIFF_CONTEXT)
    private int diffContext;
    @Value(PageConstants.MAX_SOURCE_LINE_LENGTH)
    private int maxLineLength;

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

    @Override
    public void apply(@Nonnull DriftContext context) {
        DriftResult result = processAnchors(context);
        result.applyTo(context);
    }

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

    /**
     * Runs the diff from {@code sinceId} to {@code untilId} and calculates drift for the specified {@code anchors}.
     * <p>
     * Note: It is expected that each {@link InternalPullRequestDiffCommentAnchor anchor} in the provided collection has
     * a {@link InternalPullRequestDiffCommentAnchor#getLine() line number} relative to the {@code sinceId} revision. If
     * this expectation is not met the calculated drift will be incorrect. For reference:
     * <ul>
     *     <li>{@link DiffSegmentType#ADDED Added} lines are relative to the theoretical merge, which is the
     *     {@link PullRequestEffectiveDiff#getUntilId() untilId} of the
     *     {@link DriftContext#getPreviousDiff() previous effective diff}</li>
     *     <li>{@link DiffSegmentType#CONTEXT Context} and {@link DiffSegmentType#REMOVED removed} lines are relative
     *     to the {@link DriftContext#getPreviousToHash() previous "to" hash}</li>
     * </ul>
     *
     * @param commandFactory the command factory instance for building the diff command
     * @param untilId        the end revision for the diff
     * @param sinceId        the initial revision for the diff
     * @param anchors        the anchors to calculate drift for
     * @return a {@link DriftResult} containing drifted, orphaned and retained anchors based on drift processing
     * @see DriftDiffContentCallback
     */
    private DriftResult calculateDrift(ScmCommandFactory commandFactory, String untilId, String sinceId,
                                       Iterable<InternalPullRequestDiffCommentAnchor> anchors) {
        DiffCommandParameters.Builder parameters = new DiffCommandParameters.Builder()
                .contextLines(0)
                .maxLineLength(maxLineLength)
                .maxLines(Integer.MAX_VALUE)
                .sinceId(sinceId)
                .untilId(untilId);

        DriftDiffContentCallback callback = new DriftDiffContentCallback(anchors);
        commandFactory.diff(parameters.build(), callback).call();

        return callback.getResult();
    }

    /**
     * Runs the "meta diff", as described in the class-level documentation, to calculate comment drift for
     * {@link DiffSegmentType#ADDED added} lines.
     * <p>
     * A rescope on either side of the pull request always introduces a new theoretical merge commit. As a result, this
     * diff is <i>always</i> run, if there are comments on {@link DiffSegmentType#ADDED added} lines.
     *
     * @param context        the context containing comments to process, used to determine the hashes to use when
     *                       performing the "meta diff"
     * @param commandFactory the command factory instance for building the diff command
     * @param anchorsByType  a map containing all unprocessed anchors mapped by their {@link DiffSegmentType line type}
     * @return a {@link DriftResult} containing drifted, orphaned and retained anchors based on drift processing
     * @see #calculateDrift(ScmCommandFactory, String, String, Iterable)
     */
    private DriftResult calculateDriftInSource(DriftContext context, ScmCommandFactory commandFactory,
                                               Map<DiffSegmentType, List<InternalPullRequestDiffCommentAnchor>> anchorsByType) {
        InternalPullRequest pullRequest = context.getPullRequest();

        List<InternalPullRequestDiffCommentAnchor> addAnchors = anchorsByType.get(DiffSegmentType.ADDED);
        if (addAnchors.isEmpty()) {
            log.debug("{}: No comments have been anchored to ADDED lines; skipping drift calculation",
                    pullRequest.getGlobalId());
            return new DriftResult();
        }

        //At least one comment is anchored to an ADDED line, so we need to diff the merge commits to calculate drift
        String previousMergeHash = context.getPreviousDiff().getUntilId();
        String mergeHash = context.getCurrentDiff().getUntilId();
        if (log.isDebugEnabled()) {
            log.debug("{}: Calculating drift from {} to {} for {} anchors on ADDED lines",
                    pullRequest.getGlobalId(), previousMergeHash, mergeHash, addAnchors.size());
        }

        String timerName = "Drift: " + getName() + " - Merge diff [" + previousMergeHash +
                "]->[" + mergeHash + "] " + pullRequest.getGlobalId();
        try (Timer ignored = TimerUtils.start(timerName)) {
            return calculateDrift(commandFactory, mergeHash, previousMergeHash, addAnchors);
        }
    }

    /**
     * Runs the "target diff", as described in the class-level documentation, to calculate comment drift for
     * {@link DiffSegmentType#CONTEXT context} and {@link DiffSegmentType#REMOVED removed} lines.
     * <p>
     * A rescope affecting only the {@link InternalPullRequest#getFromRef() from} ("source") side of a pull request
     * cannot trigger drift for context and removed lines. As a result, this diff is only performed if the previous
     * {@link DriftContext#getPreviousToHash() to hash} and the current {@link InternalPullRequest#getToRef() to hash}
     * do not match. Naturally, if there are no comments on context or removed lines, the diff is also skipped.
     * <p>
     * If there are no comments on context or removed lines, the returned {@link DriftResult} will be empty. Otherwise,
     * if the diff is skipped because there is no "target diff", the returned {@link DriftResult} will retain all of the
     * anchors for context and removed lines unchanged. Otherwise, the diff will be run and drift will be calculated for
     * all anchors and the {@link DriftResult} will contain the processed anchors.
     *
     * @param context        the context containing comments to process, used to determine whether a "target diff"
     *                       exists, and the hashes to use when performing it
     * @param commandFactory the command factory instance for building the diff command
     * @param anchorsByType  a map containing all unprocessed anchors mapped by the {@link DiffSegmentType line type}
     * @return a {@link DriftContext} containing drifted, orphaned and retained anchors based on drift processing
     * @see #calculateDrift(ScmCommandFactory, String, String, Iterable)
     */
    private DriftResult calculateDriftInTarget(DriftContext context, ScmCommandFactory commandFactory,
                                               Map<DiffSegmentType, List<InternalPullRequestDiffCommentAnchor>> anchorsByType) {
        InternalPullRequest pullRequest = context.getPullRequest();

        List<InternalPullRequestDiffCommentAnchor> contextAnchors = anchorsByType.get(DiffSegmentType.CONTEXT);
        List<InternalPullRequestDiffCommentAnchor> removedAnchors = anchorsByType.get(DiffSegmentType.REMOVED);
        if (contextAnchors.isEmpty() && removedAnchors.isEmpty()) {
            log.debug("{}: No comments have been anchored to CONTEXT or REMOVED lines; skipping drift calculation",
                    pullRequest.getGlobalId());
            return new DriftResult();
        }
        Iterable<InternalPullRequestDiffCommentAnchor> anchors = Iterables.concat(contextAnchors, removedAnchors);

        String previousToHash = context.getPreviousToHash();
        String toHash = context.getPullRequest().getToRef().getLatestCommit();
        if (previousToHash.equals(toHash)) {
            log.debug("{}: The target branch has not been updated; skipping drift calculation",
                    pullRequest.getGlobalId());
            return DriftResult.forRetained(anchors);
        }
        //At least one removed or context comment, and the target branch has been updated, so we need to diff
        //the previous tip of the target branch against the current tip to calculate drift
        if (log.isDebugEnabled()) {
            log.debug("{}: Calculating drift from {} to {} for {}/{} anchors on CONTEXT/REMOVED lines",
                    pullRequest.getGlobalId(), previousToHash, toHash, contextAnchors.size(), removedAnchors.size());
        }

        String timerName = "Drift: " + getName() + " - Target diff [" + previousToHash +
                "]->[" + toHash + "] " + pullRequest.getGlobalId();
        try (Timer ignored = TimerUtils.start(timerName)) {
            return calculateDrift(commandFactory, toHash, previousToHash, anchors);
        }
    }

    /**
     * Runs the {@link DriftContext#getCurrentDiff() new effective diff} to ensure that all anchors which are believed
     * to still be reachable in fact are.
     * <p>
     * The primary purpose of running this diff is to catch cases where a matching change is made on the opposite side
     * of a pull request. Lines that are {@link DiffSegmentType#ADDED added} on "source" side of a pull request may be
     * added on the "target" side as well, for example, which changes those lines from added to context and will cause
     * their anchors to no longer match.
     * <p>
     * Distilled to its essence, this step takes the currently reachable set of anchors, potentially with drift applied
     * to move them to a new line number, and verifies that they can, in fact, be found in the new effective diff. Any
     * anchor which cannot be found is assumed to have been orphaned by an identical change on the opposite side of the
     * pull request.
     *
     * @param context        the context containing comments to process, used to determine whether a "source diff"
     *                       or "target diff" exists and the hashes to use to perform the new effective diff
     * @param commandFactory the command factory instance for building the diff command
     * @param sourceDrift    the processed {@link #calculateDriftInSource(DriftContext, ScmCommandFactory, Map) source
     *                       drift}, used to determine whether any anchors on {@link DiffSegmentType#ADDED added} lines
     *                       are still reachable (not orphaned)
     * @param targetDrift    the processed {@link #calculateDriftInTarget(DriftContext, ScmCommandFactory, Map) target
     *                       drift}, used to determine whether any anchors on {@link DiffSegmentType#CONTEXT context}
     *                       or {@link DiffSegmentType#REMOVED removed} lines are still reachable (not orphaned)
     * @return a set containing zero or more {@link ProcessedAnchor}s for which the calculated drift is superseded by
     *         changes in the new effective diff requiring the underlying anchor to be orphaned instead
     */
    private Set<ProcessedAnchor> detectOrphans(DriftContext context, ScmCommandFactory commandFactory,
                                               DriftResult sourceDrift, DriftResult targetDrift) {
        InternalPullRequest pullRequest = context.getPullRequest();

        List<ProcessedAnchor> sourceReachable = sourceDrift.getReachableAnchors();
        List<ProcessedAnchor> targetReachable = targetDrift.getReachableAnchors();
        if (sourceReachable.isEmpty() && targetReachable.isEmpty()) {
            log.debug("{}: After calculating drift, all comments have been orphaned", pullRequest.getGlobalId());

            return Collections.emptySet();
        }

        Iterable<ProcessedAnchor> reachable = Iterables.concat(sourceReachable, targetReachable);
        if (log.isDebugEnabled()) {
            log.debug("{}: Validating {} anchor(s)",
                    pullRequest.getGlobalId(), (sourceReachable.size() + targetReachable.size()));
        }

        PullRequestEffectiveDiff currentDiff = context.getCurrentDiff();
        DiffCommandParameters.Builder parameters = new DiffCommandParameters.Builder()
                .contextLines(diffContext)
                .maxLineLength(maxLineLength)
                .maxLines(Integer.MAX_VALUE)
                .sinceId(currentDiff.getSinceId())
                .untilId(currentDiff.getUntilId());

        OrphanDetectingDiffContentCallback callback = new OrphanDetectingDiffContentCallback(reachable);
        Command<Void> command = commandFactory.diff(parameters.build(), callback);

        String timerName = "Drift: " + getName() + " - Effective diff [" + currentDiff.getSinceId() +
                "]->[" + currentDiff.getUntilId() + "] " + pullRequest.getGlobalId();
        try (Timer ignored = TimerUtils.start(timerName)) {
            command.call();
        }

        return callback.done();
    }

    /**
     * Maps all of the unprocessed {@link InternalPullRequestDiffCommentAnchor anchors} in the {@link DriftContext} into
     * lists by their {@link InternalPullRequestDiffCommentAnchor#getLineType() line type}.
     * <p>
     * Note: The returned {@code Map} includes a {@code List} for every {@link DiffSegmentType type}, even if there are
     * no anchors with that type. This is intended to simplify processing by removing {@code null} checks.
     *
     * @param context the context containing comments to process
     * @return a map containing a {@code List} for each possible {@link DiffSegmentType line type}, each of which may
     *         contain zero or more anchors
     */
    private static Map<DiffSegmentType, List<InternalPullRequestDiffCommentAnchor>> mapAnchorsByType(DriftContext context) {
        Map<DiffSegmentType, List<InternalPullRequestDiffCommentAnchor>> anchorsByType = new EnumMap<>(DiffSegmentType.class);
        for (DiffSegmentType value : DiffSegmentType.values()) {
            anchorsByType.put(value, new ArrayList<>());
        }
        for (InternalPullRequestDiffCommentAnchor anchor : context) {
            // only deal with the line comment anchors here
            if (anchor.getLineType() != null) {
                anchorsByType.get(anchor.getLineType()).add(anchor);
            }
        }
        return anchorsByType;
    }

    /**
     * Processes all of the anchors in the provided {@link DriftContext}, calculating drift for each and, for any
     * which are not orphaned by drift processing, ensuring the calculated drift allows the anchor to be found in
     * the current effective diff.
     * <p>
     * The returned {@code Map} should contain a {@link InternalPullRequestDiffCommentAnchor anchor} keying to a
     * {@link ProcessedAnchor processing result} for <i>every</i> anchor in the context.
     *
     * @param context the context containing comments to process
     * @return a map from an {@link InternalPullRequestDiffCommentAnchor anchor} to its calculated
     *         {@link ProcessedAnchor processing result}
     */
    private DriftResult processAnchors(DriftContext context) {
        Map<DiffSegmentType, List<InternalPullRequestDiffCommentAnchor>> anchorsByType = mapAnchorsByType(context);

        ScmCommandFactory commandFactory = scmService.getCommandFactory(context.getRepository());
        DriftResult sourceDrift = calculateDriftInSource(context, commandFactory, anchorsByType);
        DriftResult targetDrift = calculateDriftInTarget(context, commandFactory, anchorsByType);

        Set<ProcessedAnchor> orphaned = detectOrphans(context, commandFactory, sourceDrift, targetDrift);

        // merge source and target drift result and override with the orphaned anchors
        return sourceDrift.merge(targetDrift).orphan(orphaned.stream().map(ProcessedAnchor::getAnchor));
    }

    private static class DriftDiffContentCallback extends AbstractDiffContentCallback {

        private final Map<String, List<InternalPullRequestDiffCommentAnchor>> anchorsByPath;
        private final DriftResult result;

        private List<InternalPullRequestDiffCommentAnchor> workingAnchors;
        private int workingDrift;

        private DriftDiffContentCallback(Iterable<InternalPullRequestDiffCommentAnchor> anchors) {
            anchorsByPath = new HashMap<>();
            result = new DriftResult();

            for (InternalPullRequestDiffCommentAnchor anchor : anchors) {
                anchorsByPath.computeIfAbsent(anchor.getPath(), path -> new ArrayList<>())
                        .add(anchor);
            }
        }

        public DriftResult getResult() {
            return result;
        }

        @Override
        public void onDiffEnd(boolean truncated) {
            if (CollectionUtils.isNotEmpty(workingAnchors)) {
                //Any anchors still working are anchored to lines after any lines in the relevant diff. That means
                //the accumulated drift should be applied to their lines and they should be retained
                workingAnchors.forEach(this::drift);
                workingAnchors = null;
            }
        }

        @Override
        public void onDiffStart(@Nullable Path src, @Nullable Path dst) {
            //Note: With the diffs that are run, _all anchors are guaranteed to be relative to the src path_. The dst
            //      path is only provided in two cases: A new file is added (can't possibly have comments); a file is
            //      renamed (the src path for the rename is the only one that could have had comments). Since comments
            //      can't exist on the dst path in either case, there's no reason to look at it.
            if (src != null) {
                workingAnchors = anchorsByPath.remove(src.toString());
            }
            workingDrift = 0;
        }

        @Override
        public void onEnd(@Nonnull DiffSummary summary) {
            //If there are still entries in the map, it means some diffs didn't exist. That means there is no drift
            //to apply to any of those anchors
            for (List<InternalPullRequestDiffCommentAnchor> anchors : anchorsByPath.values()) {
                for (InternalPullRequestDiffCommentAnchor anchor : anchors) {
                    result.retain(anchor);
                }
            }
        }

        @Override
        public void onHunkStart(int srcLine, int srcSpan, int dstLine, int dstSpan) {
            if (workingAnchors == null) {
                //No anchors to process drift for in this diff. Since the command is not path-limited based on the
                //comments (due to potential operating system restrictions), it's possible some irrelevant paths
                //will be included. Just ignore their hunks.
                return;
            }
            int endLine = srcLine + srcSpan;

            Iterator<InternalPullRequestDiffCommentAnchor> anchorIterator = workingAnchors.iterator();
            while (anchorIterator.hasNext()) {
                InternalPullRequestDiffCommentAnchor anchor = anchorIterator.next();

                //Note: With the diffs that are run, the anchor lines _always_ correspond to the source lines (even
                //      if, from the lineType(), they should be the target; the hashes used put what was the "target"
                //      on the source side of these diffs)
                int anchorLine = anchor.getLine();
                if (anchorLine < srcLine || (srcSpan == 0 && anchorLine == srcLine)) {
                    //This anchor is before the start of this segment. That means any accumulated drift so far should
                    //be applied to this anchor, but drift from this and subsequent segments should not
                    drift(anchor);
                    anchorIterator.remove();
                } else if (srcSpan > 0 && anchorLine < endLine) { //Empty source spans cannot orphan
                    //This hunk occludes this anchor, so the comment is orphaned
                    result.orphan(anchor);
                    anchorIterator.remove();
                }
            }

            workingDrift += (dstSpan - srcSpan);
        }

        private void drift(InternalPullRequestDiffCommentAnchor anchor) {
            result.drift(workingDrift, anchor);
        }
    }

    private static class OrphanDetectingDiffContentCallback extends AbstractDiffContentCallback {

        private final Map<String, Map<DiffSegmentType, Map<Integer, List<ProcessedAnchor>>>> typesByPath;

        private int currentDestinationLine;
        private String currentPath;
        private Map<DiffSegmentType, Map<Integer, List<ProcessedAnchor>>> currentPathTypes;
        private DiffSegmentType currentSegmentType;
        private Map<Integer, List<ProcessedAnchor>> currentTypeLines;
        private int currentSourceLine;

        private OrphanDetectingDiffContentCallback(Iterable<ProcessedAnchor> processedAnchors) {
            typesByPath = new HashMap<>();

            for (ProcessedAnchor processedAnchor : processedAnchors) {
                typesByPath.computeIfAbsent(processedAnchor.getPath(), path -> new HashMap<>())
                        .computeIfAbsent(processedAnchor.getLineType(), lineType -> new HashMap<>())
                        .computeIfAbsent(processedAnchor.getLine(), line -> new ArrayList<>())
                        .add(processedAnchor);
            }
        }

        public Set<ProcessedAnchor> done() {
            Set<ProcessedAnchor> notFound = new HashSet<>();
            for (Map<DiffSegmentType, Map<Integer, List<ProcessedAnchor>>> linesByType : typesByPath.values()) {
                for (Map<Integer, List<ProcessedAnchor>> anchorsByLine : linesByType.values()) {
                    anchorsByLine.values().forEach(notFound::addAll);
                }
            }

            return notFound;
        }

        @Override
        public void onDiffEnd(boolean truncated) throws IOException {
            currentPath = null;
            currentPathTypes = null;
        }

        @Override
        public void onDiffStart(@Nullable Path src, @Nullable Path dst) throws IOException {
            //Note: The diff being run here is the effective diff for the pull request, which is the same diff that
            //      comments are added in. Anchors use the dst path as their "path" value, in that diff, so we prefer
            //      that path here as well. The only time the src path will be considered is for comments made on lines
            //      in deleted files (since the src path is the only path a deleted file has).
            if (dst != null) {
                //If a destination path is provided, it will _always_ be the path that matches any anchors' paths
                currentPath = dst.toString();
            } else if (src != null) {
                //If a destination path is not provided, it means the file has been removed and its source path is
                //its only path, so it will be the path to match any anchors' paths
                currentPath = src.toString();
            }

            currentPathTypes = typesByPath.get(currentPath);
        }

        @Override
        public void onHunkStart(int srcLine, int srcSpan, int dstLine, int dstSpan) {
            currentDestinationLine = dstLine;
            currentSourceLine = srcLine;
        }

        @Override
        public void onSegmentEnd(boolean truncated) {
            currentSegmentType = null;
            currentTypeLines = null;
        }

        @Override
        public void onSegmentLine(@Nonnull String line, @Nullable ConflictMarker marker, boolean truncated) {
            if (currentSegmentType == null) {
                //We don't care about line numbers for this path, so there's no need to calculate them
                return;
            }

            //Note: Even if there are not anchors in this segment, if we care about this path (which we do if the
            //      segment type is set, we always have to increment the line numbers. Otherwise, when we later
            //      get to a segment type we do care about this path, the line numbers will be wrong because lines
            //      went by where the numbers were not updated
            int commentLine = chooseCommentLineAndIncrementLines();
            if (currentTypeLines != null && currentTypeLines.containsKey(commentLine)) {
                currentTypeLines.remove(commentLine);

                if (currentTypeLines.isEmpty()) {
                    //No further lines in this segment need to be checked
                    currentTypeLines = null;

                    //If this segment type is encountered again in this diff it doesn't need to be checked anymore
                    //because all of its anchors have been found already
                    currentPathTypes.remove(currentSegmentType);
                    if (currentPathTypes.isEmpty()) {
                        //No further segments in this path need to be checked
                        currentPathTypes = null;
                        currentSegmentType = null;

                        //Since all anchors in this path have been found, simplify the post-diff processing by
                        //removing this path from the map
                        typesByPath.remove(currentPath);
                    }
                }
            }
        }

        @Override
        public void onSegmentStart(@Nonnull DiffSegmentType type) {
            if (currentPathTypes != null) {
                currentSegmentType = type;
                currentTypeLines = currentPathTypes.get(type);
            }
        }

        /**
         * Uses the the {@link #currentSegmentType} to determine whether to increment the {@link #currentSourceLine}
         * and/or the {@link #currentDestinationLine}, and which of the two should be used to check for the presence
         * of a line comment.
         *
         * @return the line number to use when looking up comments in {@link #currentPathTypes}'s value maps
         */
        private int chooseCommentLineAndIncrementLines() {
            switch (currentSegmentType) {
                case ADDED:
                    //Added lines are only in the destination file, and the destination file's line numbers are what the
                    //comments work against
                    return currentDestinationLine++;
                case CONTEXT:
                    //Context lines are in both files, but comments work against the source line numbers and so that's what
                    //we want to return. However, we still need to increment the destination line as well or future ADDED
                    //lines will be wrong
                    ++currentDestinationLine;

                    //Fall-through intentional
                case REMOVED:
                default:
                    //Removed lines are only in the source file, and the source file's line numbers are what the comments
                    //work against
                    return currentSourceLine++;
            }
        }
    }
}
