package com.atlassian.stash.internal.rest.content;

import com.atlassian.bitbucket.comment.Comment;
import com.atlassian.bitbucket.comment.DiffCommentAnchor;
import com.atlassian.bitbucket.content.*;
import com.atlassian.bitbucket.rest.comment.RestComment;
import com.atlassian.bitbucket.rest.content.RestDiff;
import com.atlassian.bitbucket.rest.content.RestPath;
import com.atlassian.bitbucket.rest.util.StatefulJsonWriter;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import org.apache.commons.collections.CollectionUtils;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/**
 * Streams diff content to a {@code StatefulJsonWriter} provided during construction.
 * <p>
 * The JSON data produced by this callback is hierarchical. Multiple diffs are supported in a single payload, each
 * with its own set of hunks and segments. <i>Some of the marshaling is done by the {@link DiffResource} instead of
 * by this callback class.</i>
 * <p>
 * The data marshaled by this class will look like:
 * <code><pre>
 * { //Object begin for diff
 *     "source":&lt;{@link RestPath}&gt;,
 *     "destination":&lt;{@link RestPath}&gt;,
 *     "binary"?:true, //Omitted if false
 *     "hunks"?:[ //Omitted if "binary":true
 *         { //Object start for hunk
 *             "sourceLine":&lt;lineNumber&gt;,
 *             "destinationLine":&lt;lineNumber&gt;,
 *             "segments":[
 *                 { //Object start for segment
 *                     "type":"&lt;{@link DiffSegmentType}.name()&gt";,
 *                     "lines":[
 *                         { //Object start for line
 *                             "source":&lt;lineNumber&gt;,
 *                             "destination":&lt;lineNumber&gt;,
 *                             "line":"&lt;text&gt;",
 *                             "truncated":true|false,
 *                             "commentIds"?:[ //Omitted if empty
 *                                 &lt;commentId&gt;...
 *                             ]
 *                         }... //Object end for line; may be multiple lines present
 *                     ],
 *                     "truncated":true|false
 *                 }... //Object end for segment; may be multiple segments present
 *             ],
 *             "truncated":true|false
 *         }... //Object end for hunk; may be multiple hunks present
 *     ],
 *     "truncated":true|false,
 *     "fileComments"?:[ //Omitted if empty
 *         &lt;{@link RestComment}&gt;...
 *     ],
 *     "lineComments"?:[ //Omitted if empty
 *         &lt;{@link RestComment}&gt;...
 *     ]
 * }... //Object end for diff; may be multiple objects present
 * </pre></code>
 * As the data above shows, this callback <i>does not</i> write out name or array start/end markers for the diffs it
 * returns; it only writes their object start/end markers and the data below. It is up to the code using this callback
 * to provide the surrounding JSON.
 * <p>
 * The line numbers in the source/destination file are both always marshaled but do not always both have meaning. If
 * the segment type is {@link DiffSegmentType#ADDED}, only the "destination" line number is relevant. If the segment
 * type is {@link DiffSegmentType#REMOVED}, only the "source" line number is relevant. {@link DiffSegmentType#CONTEXT}
 * lines use both the "source" and "destination" line numbers, which will frequently differ depending on how the file
 * has been changed.
 * <p>
 * "truncated" flags are marshaled out in all objects:
 * <ul>
 *     <li>On line entries, "truncated" is used to indicate when an excessively long line of source has been truncated
 *     to a more reasonable length</li>
 *     <li>On segment entries, "truncated" is used to indicate when one or more lines have been omitted from the "lines"
 *     array due to internal data limits</li>
 *     <li>On hunk entries, "truncated" is used to indicate when one or more segments have been omitted from the
 *     "segments" array due to internal data limits</li>
 *     <li>On diff entries, "truncated" is used to indicate when one or more hunks have been omitted from the "hunks"
 *     array due to internal data limits</li>
 *     <li>On the top level object "truncated" is used to indicate that some sort of truncation has taken place</li>
 * </ul>
 * Segment, hunk, diff and top level "truncated" flags are cumulative. This means if a segment is
 * "truncated":{@code true}, then the hunk and diff above it will be as well. Only line "truncated" flags are not
 * cumulative--one or more lines can have their length truncated without the segment, hunk or diff showing as truncated.
 * Also, note that, for example, a hunk can show "truncated":{@code true} without any of its segments showing as such.
 * Such state indicates that one or more <i>entire segments</i> were dropped, but the last segment included was included
 * completely. If any truncation takes place then a top level "truncated":{code true} will be set.
 * <p>
 * Comments are optional data in the result, if {@link #offerAnchors(java.util.List)} is not invoked, or is invoked
 * with an empty {@code List}, all comment-related fields in the marshaled data will be omitted. Otherwise, if one or
 * more comments were offered, {@link DiffCommentAnchor#getLine() line} and {@link DiffCommentAnchor#getLineType() type}
 * information will be matched against the diff output as it is streamed and comment IDs will be attached to relevant
 * lines:
 * <ul>
 *     <li>{@link DiffSegmentType#ADDED}: The line number is matched against the "destination" line</li>
 *     <li>{@link DiffSegmentType#CONTEXT}: The line number is matched against the "source" line</li>
 *     <li>{@link DiffSegmentType#REMOVED}: The line number is matched against the "source" line</li>
 * </ul>
 * When multiple comments have been made on the same line, the entries in the "commentIds" array on the line will be
 * provided in date order, with the oldest comment first and subsequent comments after. Comments in reply to another
 * comment will be included in a nested "comments" array within each comment, nested as deeply as necessary depending
 * on replies to replies to replies. {@link DiffCommentAnchor#isFileComment() File comments}, where line information
 * is not included in the anchor, are marshaled in date order in the "fileComments" array in the diff object.
 *
 * @see DiffContentCallback
 *
 * The contents of the stream should match {@link RestDiff}
 */
public class JsonDiffContentCallback extends AbstractDiffContentCallback {

    private final Function<Comment, RestComment> commentTransformer;
    private final List<Comment> fileComments;
    private final Map<DiffSegmentType, Map<Integer, List<DiffCommentAnchor>>> lineAnchorsByType;
    private final List<Comment> lineComments;
    private final StatefulJsonWriter writer;

    private int currentDestinationLine;
    private Map<Integer, List<DiffCommentAnchor>> currentSegmentAnchors;
    private DiffSegmentType currentSegmentType;
    private int currentSourceLine;

    public JsonDiffContentCallback(StatefulJsonWriter writer, Function<Comment, RestComment> commentTransformer) {
        this.commentTransformer = commentTransformer;
        this.writer = writer;

        fileComments = Lists.newArrayList();
        lineAnchorsByType = Maps.newEnumMap(DiffSegmentType.class);
        lineComments = Lists.newArrayList();
    }

    @Override
    public void offerAnchors(@Nonnull List<DiffCommentAnchor> anchors) throws IOException {
        //If there are no anchors it means there are no comments, which is fine.
        if (CollectionUtils.isEmpty(anchors)) {
            return;
        }

        //Otherwise, break the anchors down into two groups, file anchors and line anchors. For file anchors, a simple
        //list is sufficient. For line anchors, based on how the callback works, we want to break them down by type
        //and then further by line, for quick access.
        //
        //TODO: Consider anchors coming from multiple files (because the callback supports multiple diffs)
        for (DiffCommentAnchor anchor : anchors) {
            if (anchor.isFileComment()) {
                fileComments.add(anchor.getComment());
            } else {
                Map<Integer, List<DiffCommentAnchor>> anchorsByLine = lineAnchorsByType.get(anchor.getLineType());
                if (anchorsByLine == null) {
                    anchorsByLine = Maps.newHashMap();

                    lineAnchorsByType.put(anchor.getLineType(), anchorsByLine);
                }

                List<DiffCommentAnchor> lineAnchors = anchorsByLine.get(anchor.getLine());
                if (lineAnchors == null) {
                    lineAnchors = Lists.newArrayList();

                    anchorsByLine.put(anchor.getLine(), lineAnchors);
                }
                lineAnchors.add(anchor);
                lineComments.add(anchor.getComment());
            }
        }
    }

    @Override
    public void onBinary(Path src, Path dst) throws IOException {
        startDiff(src, dst);
        writer.name("binary").value(true);

        writeComments("fileComments", fileComments);

        writer.endObject();
    }

    @Override
    public void onDiffEnd(boolean truncated) throws IOException {
        writer.endArray();
        writer.name("truncated").value(truncated);

        writeComments("fileComments", fileComments);
        writeComments("lineComments", lineComments);

        writer.endObject();
    }

    @Override
    public void onDiffStart(Path src, Path dst) throws IOException {
        startDiff(src, dst);
        writer.name("hunks");
        writer.beginArray();
    }

    @Override
    public void onEnd(@Nonnull DiffSummary summary) throws IOException {
        writer.endArray() //End "diffs" array
                .name("truncated")
                .value(summary.isTruncated())
                .endObject();
    }

    @Override
    public void onHunkEnd(boolean truncated) throws IOException {
        writer.endArray();
        writer.name("truncated").value(truncated);
        writer.endObject();
    }

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

        writer.beginObject();
        writer.name("sourceLine").value(srcLine);
        writer.name("sourceSpan").value(srcSpan);
        writer.name("destinationLine").value(dstLine);
        writer.name("destinationSpan").value(dstSpan);
        writer.name("segments");
        writer.beginArray();
    }

    @Override
    public void onSegmentEnd(boolean truncated) throws IOException {
        currentSegmentAnchors = null;
        currentSegmentType = null;

        writer.endArray(); //End of "lines" array
        writer.name("truncated").value(truncated);
        writer.endObject(); //End of segment object
    }

    @Override
    public void onSegmentLine(@Nonnull String line, ConflictMarker marker, boolean truncated) throws IOException {
        int sourceLine = currentSourceLine;
        int destinationLine = currentDestinationLine;
        int commentLine = chooseCommentLineAndIncrementLines();

        writer.beginObject();
        writer.name("source").value(sourceLine);
        writer.name("destination").value(destinationLine);
        writer.name("line").value(line);
        writer.name("truncated").value(truncated);
        if (marker != null) {
            writer.name("conflictMarker").value(marker);
        }

        if (currentSegmentAnchors != null && currentSegmentAnchors.containsKey(commentLine)) {
            List<DiffCommentAnchor> anchors = currentSegmentAnchors.get(commentLine);

            writer.name("commentIds");
            writer.beginArray();
            for (DiffCommentAnchor anchor : anchors) {
                Comment comment = anchor.getComment();

                writer.value(comment.getId());
            }
            writer.endArray();
        }
        writer.endObject();
    }

    @Override
    public void onSegmentStart(@Nonnull DiffSegmentType type) throws IOException {
        currentSegmentAnchors = lineAnchorsByType.get(type);
        currentSegmentType = type;

        writer.beginObject(); //Begin of segment object
        writer.name("type").value(type.name());
        writer.name("lines");
        writer.beginArray(); //Begin of "lines" array
    }

    @Override
    public void onStart(@Nonnull DiffContext context) throws IOException {
        writer.beginObject()
                .name("fromHash").value(context.getFromHash())
                .name("toHash").value(context.getToHash())
                .name("contextLines").value(context.getContextLines())
                .name("whitespace").value(context.getWhitespace().name())
                .name("diffs")
                .beginArray();
    }

    /**
     * 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 #lineAnchorsByType}'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++;
        }
    }

    private void startDiff(Path src, Path dst) throws IOException {
        writer.beginObject();
        writePath("source", src);
        writePath("destination", dst);
    }

    private void writeComments(String name, List<Comment> comments) throws IOException {
        if (CollectionUtils.isNotEmpty(comments)) {
            writer.name(name);
            writer.beginArray();
            for (Comment comment : comments) {
                writer.value(commentTransformer.apply(comment));
            }
            writer.endArray();
        }
    }
    
    private void writePath(String name, Path path) throws IOException {
        writer.name(name);
        if (path == null) {
            writer.nullValue();
        } else {
            writer.value(new RestPath(path));
        }
    }
}
