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

import com.atlassian.bitbucket.content.DiffContentCallback;
import com.atlassian.bitbucket.content.ConflictMarker;
import com.atlassian.bitbucket.content.DiffSegmentType;
import com.atlassian.stash.internal.content.DiffLines;
import com.atlassian.stash.internal.pull.InternalPullRequestCommentActivity;
import com.atlassian.bitbucket.property.PropertyMap;
import com.google.common.base.Function;
import com.google.common.collect.Collections2;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Iterator;
import java.util.List;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Processes {@link DiffContext} assembly for a batch of {@link DiffComment comments} created from
 * {@link com.atlassian.stash.internal.pull.InternalPullRequestCommentActivity comment activities}.
 * <p>
 * The batch maintains an internal {@link DiffContextWindow window} of {@link DiffContextLine context lines}, which is
 * used to assemble the "before" section for each comment's context. The window manages how many lines may be included
 * when producing the context. The window is cleared whenever a hunk in the diff ends, preventing context for a comment
 * from spanning multiple hunks. Because hunks represent contiguous lines within a diff, allowing context to span hunks
 * would likely result in confusing output because it would group lines potentially far apart in the file.
 * <p>
 * The comments in a batch are all assumed to be logically grouped by path, meaning their contexts can all be assembled
 * from the lines of a single diff. If comments from multiple files are included, they may receive the wrong context or,
 * more likely, no context at all if their anchors are not found in the diff.
 * <p>
 * Each method on this class relates to a method on the {@link DiffContentCallback}. That
 * related method is noted in the documentation for each method.
 *
 * @see DiffContentCallback
 */
public class DiffContextBatch {

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

    private final List<DiffContext> building;
    private final Set<DiffComment> foundComments;
    private final Set<DiffComment> searchComments;
    private final DiffContextWindow window;

    private DiffLines lines;
    private DiffSegmentType segmentType;

    /**
     * Constructs a new {@code DiffContextBatch} which will assemble {@link DiffContext context} for the comments in
     * the provided activities.
     *
     * @param activities the activities whose comments should be included in the batch
     */
    public DiffContextBatch(Set<InternalPullRequestCommentActivity> activities, final PropertyMap properties) {
        checkArgument(!checkNotNull(activities, "activities").isEmpty(),
                "At least one activity is required for the batch");

        searchComments = Sets.newHashSet(Collections2.transform(activities,
                new Function<InternalPullRequestCommentActivity, DiffComment>() {
                    @Override
                    public DiffComment apply(InternalPullRequestCommentActivity activity) {
                        return new DiffComment(activity, properties);
                    }
                }));
        log.debug("Searching for context for {} comment(s)", searchComments.size());

        building = Lists.newArrayListWithCapacity(activities.size());
        foundComments = Sets.newHashSetWithExpectedSize(activities.size());
        window = new DiffContextWindow();
    }

    /**
     * Completes the batch.
     *
     * @see DiffContentCallback#onDiffEnd(boolean)
     */
    public void endDiff() {
        //This should never happen; the DiffContentCallback guarantees that onHunkEnd() will always be called for any
        //hunk that is started, and if building is not empty a hunk must have been started because lines were provided.
        if (!building.isEmpty()) {
            log.warn("endHunk() was not called for the final hunk before calling done(); " +
                    "{} comment(s) were still assembling their context", building.size());
            endHunk();
        }

        if (searchComments.isEmpty()) {
            log.debug("Context has been applied for all {} comment(s)", foundComments.size());
        } else {
            log.debug("Context has been applied for {} comment(s); {} comment(s) were not found",
                    foundComments.size(), searchComments.size());
        }
    }

    /**
     * Ends context assembly within the current hunk, {@link DiffContext#done() completing} assembly for any contexts
     * still under construction, and clears the {@link DiffContextWindow window}.
     * <p>
     * Comments may only include context lines from the hunk they are anchored in, so the window is cleared to ensure
     * lines from the ended hunk are never included as "before" context on comments found in any subsequent hunks.
     *
     * @see DiffContentCallback#onHunkEnd(boolean)
     */
    public void endHunk() {
        //If contexts were still being built, we've run out of lines in the diff. Rationalise any pending lines
        //into the after collection.
        for (DiffContext context : building) {
            context.done();
        }
        //Clear the building list to ensure we don't accidentally offer context lines from any subsequent hunks
        //to contexts for comments found in the current hunk
        building.clear();

        //Context does not span hunks, so any context lines in the window are no longer relevant
        window.clear();
    }

    /**
     * Constructs a {@link DiffContextLine context line} from the provided line details, searches for comments anchored
     * on it, and {@link DiffContextWindow#slide(DiffContextLine) slides} it into the window for possible inclusion as
     * "before" context.
     * <p>
     * For any comments anchored on the line, a new {@link DiffContext} is created for assembling their context. A
     * {@link DiffContextWindow#snapshot() snapshot} is taken of the window, providing the lines for the context's
     * "before" section. The context is added to the list of contexts being built so that it can assemble its "after"
     * section from any subsequent lines.
     * <p>
     * For any {@link DiffContext contexts} being built, the line is {@link DiffContext#offer(DiffContextLine) offered}
     * for inclusion in their "after" sections.
     *
     * @param line      the line text
     * @param marker    indicates whether the line is conflicting and, if so, which side it's on
     * @param truncated {@code true} if the text was truncated; otherwise, {@code false} if the text is complete
     * @see DiffContentCallback#onSegmentLine(String, ConflictMarker, boolean)
     */
    public void processLine(String line, ConflictMarker marker, boolean truncated) {
        if (building.isEmpty() && searchComments.isEmpty()) {
            //Every comment has already been found and they've all built their full context. Any further lines in
            //the diff are not relevant. This means the "lines" field will not have its numbers incremented, but
            //since there are no more relevant lines that doesn't matter.
            return;
        }
        DiffContextLine contextLine = lines.withLines(segmentType, line, marker, truncated, DiffContextLine.FACTORY_CALLBACK);

        //Offer this line to any contexts which are still building their "after" sections.
        Iterator<DiffContext> workingIterator = building.iterator();
        while (workingIterator.hasNext()) {
            DiffContext context = workingIterator.next();
            if (!context.offer(contextLine)) {
                //This context is "complete"; it has received the maximum number of "after" lines desired
                context.done();
                //Remove the context from the building list so we stop offering it lines
                workingIterator.remove();
            }
        }

        //Check for any comments which are anchored on this line. Since it is possible to start multiple threads
        //at the same line, it's possible multiple entries in the search set will match here.
        Iterator<DiffComment> searchIterator = searchComments.iterator();
        while (searchIterator.hasNext()) {
            DiffComment comment = searchIterator.next();
            if (comment.isAnchoredOn(contextLine)) {
                //Add a new context to the building list so we can assemble its "after" section
                building.add(new DiffContext(comment, contextLine, window.snapshot()));
                foundComments.add(comment);

                //Remove the comment from the search set so we stop checking its anchor
                searchIterator.remove();
            }
        }

        //Slide the line into the window, allowing it to be used for context on other comments anchored to subsequent
        //lines within the same hunk. Note that this must be done _after_ checking for comments anchored on the line!
        window.slide(contextLine);
    }

    /**
     * Starts assembling context within a new hunk.
     * <p>
     * This replaces the working {@link DiffLines} instance with a new one using the provided {@code srcLine} and
     * {@code dstLine} to provide the starting line numbers for both sides.
     *
     * @param srcLine the starting line number for the new hunk in the source file
     * @param dstLine the starting line number for the new hunk in the destination file
     * @see DiffContentCallback#onHunkStart(int, int, int, int)
     */
    public void startHunk(int srcLine, int dstLine) {
        lines = new DiffLines(srcLine, dstLine);
    }

    /**
     * Starts a new segment within the current hunk.
     * <p>
     * This replaces the working {@link DiffSegmentType} with the new segment's type. {@link DiffLines Lines} will be
     * updated based on the new type, and it will be applied to each {@link DiffContextLine context lines} created in
     * the segment.
     *
     * @param segmentType the new segment type
     * @see DiffContentCallback#onSegmentStart(DiffSegmentType)
     */
    public void startSegment(DiffSegmentType segmentType) {
        this.segmentType = segmentType;
    }
}
