package com.atlassian.bitbucket.mesh.git.builder.diff;

import com.atlassian.bitbucket.mesh.git.GitUtils;
import com.atlassian.bitbucket.mesh.git.content.DiffCallback;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.BinaryDisposition;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Handles output from the {@code git diff} command, streaming data to a provided {@link DiffCallback} as it is
 * read.
 * <p>
 * This handler has been built to support both single and multi-file diff output. It is left to the surrounding context
 * to handle that correctly.
 * <p>
 * {@code git diff} doesn't support {@code stdin}, which means trying to specify a large number of paths can result in a
 * command line longer than the operating system allows. It's not safe to simply split the paths and run multiple
 * {@code git diff} commands because copy and rename diffs can involve multiple paths. That means splitting the paths
 * across multiple commands may alter the results, if two paths involved in a copy or rename end up being split up and
 * passed to separate processes.
 * <p>
 * To ensure correct diffs, when too many paths are provided they are not passed to {@code git diff} and are instead
 * applied as a {@link #paths} filter. This ensures the callback only receives output for the requested set of paths.
 */
public class DiffStdioHandler extends AbstractLineStdioHandler<Void> {

    public static final String DST_PREFIX = "dst://";
    public static final String KEY_DESTINATION_PATH = "+++ ";
    public static final String KEY_SOURCE_PATH = "--- ";
    public static final String SRC_PREFIX = "src://";
    public static final String TRANSCODED_BINARY = "Dropping BINARY output for: ";
    public static final Pattern PATTERN_BINARY_FILES = Pattern.compile("Binary files (\"?" + SRC_PREFIX + ".+\"?|/dev/null) and (\"?" + DST_PREFIX + ".+\"?|/dev/null) differ");
    public static final Pattern PATTERN_DIFF_HEADER = Pattern.compile("diff --git (\"?" + SRC_PREFIX + ".+\"?)\\s(\"?" + DST_PREFIX + ".+\"?)");
    public static final Pattern PATTERN_HUNK_HEADER = Pattern.compile("@@\\s-([\\d]+)(?:,([\\d]+))?\\s\\+([\\d]+)(?:,([\\d]+))?\\s@@(?:\\s(.+))?$", Pattern.DOTALL);
    public static final int TRUNCATED = -1;

    private static final String NULL_PATH = "/dev/null";

    private final DiffCallback callback;
    private final int maxLines;
    private final int maxLineLength;
    private final int maxReadLength;
    private final Set<String> paths;

    private String dst;
    private boolean hasDst;
    private boolean hasHunkHeader;
    private boolean hasSrc;
    private boolean hasStartedDiff;
    private boolean hasStartedHeader;
    private boolean hasStartedHunk;
    private boolean hasStartedSegment;
    private Matcher hunkHeaderMatcher;
    private boolean isBinary;
    private boolean isDeletedFile;
    private boolean isModeChanged;
    private boolean isNewFile;
    private int lines;
    private String path;
    private GitDiffSegmentType segmentType;
    private String src;
    private State state;

    /**
     * @param callback      a callback to receive the streamed diff
     * @param paths         a set containing the paths used to filter which diffs are streamed, or null for no filter
     * @param maxLines      maximum number of lines
     * @param maxLineLength maximum length of a line
     */
    public DiffStdioHandler(DiffCallback callback, @Nullable Set<String> paths, int maxLines, int maxLineLength) {
        //Same comment applies here as with AbstractBlameOutputHandler. git diff runs in UTF-8 mode unless it is
        //explicitly told to use something else, so all we're doing here is matching git's preferred encoding.
        super(StandardCharsets.UTF_8, LineDecoder.Mode.CRLF_OR_LF);

        this.callback = callback;
        this.maxLines = maxLines;
        this.maxLineLength = maxLineLength;
        this.paths = paths;

        maxReadLength = maxLineLength + 4; //Segment type + CRLF + 1 to know when truncation occurs
        state = State.START;
    }

    @Override
    public Void getResult() {
        return null;
    }

    @Override
    public void onExit(int exitCode) {
        if (state == State.DIFF_HEADER_START && hasStartedHeader) {
            handleDiffHeader(null);
        } else {
            if (hasStartedSegment) {
                callback.onSegmentEnd(false);
            }
            if (hasStartedHunk) {
                callback.onHunkEnd(false);
            }
        }
        if (hasStartedDiff) {
            callback.onDiffEnd(lines == TRUNCATED);
        }
    }

    @Override
    public boolean onStdout(@Nonnull String line, boolean truncated) {
        //Note that the state field can be modified as part of processing inside the handle methods below, so a
        //line can be handled by multiple handlers. (In cases where this is _not_ required then an explicit "return"
        //is used).
        if (state == State.START) {
            handleStart(line);
        }
        if (state == State.DIFF_HEADER_START) {
            handleDiffHeaderStart(line);
        }
        if (state == State.DIFF_HEADER) {
            handleDiffHeader(line);
        }
        if (state == State.DIFF_BODY) {
            handleDiffBody(line);
        }
        if (state == State.HUNK_HEADER) {
            handleHunkHeader(line);
            //Skip any further processing of this line, as the hunk body will not start until the next line
            return true;
        }
        if (state == State.HUNK_BODY) {
            handleHunkBody(line);
        }
        if (state == State.HUNK_END) {
            handleHunkEnd(line);
        }
        if (state == State.DIFF_END) {
            handleDiffEnd(line);
        }
        return state != State.CANCEL;
    }

    @Nonnull
    @Override
    protected BinaryDisposition onBinary() {
        //Git detects binary files when creating a diff and outputs "Binary files differ". A diff can contain
        //binary and text files. Since Git has its own binary detection, we ignore our own
        return BinaryDisposition.STREAM;
    }

    private static String getPath(String rawPath) {
        if (NULL_PATH.equals(rawPath)) {
            //This is not an error; it means the file was either added or deleted. However, above the command,
            //this path has no real meaning. So we drop it here
            return null;
        }
        return rawPath;
    }

    /**
     * Parses the provided {@code line} as a path.
     * <p>
     * If the file contains any spaces git will append tab to the filename, which needs to be removed. If the last
     * character of the filename is actually a tab then the name is quoted, and still appended with tab, therefore
     * it is safe to remove a trailing tab in all cases.
     * <p>
     * If the line starts and ends with quotes, it is {@link GitUtils#unescape(String) un-escaped}, replacing any
     * Unicode escape sequences with their equivalent character. After escaping, if the resulting line starts with
     * the specified {@code prefix}, the prefix is removed before the line is {@link #getPath(String) parsed}.
     *
     * @param line   the line to parse as a path
     * @param prefix the prefix the line may start with
     * @return the parsed path
     */
    private static String parsePath(String line, String prefix) {
        if (line.endsWith("\t")) {
            line = line.substring(0, line.length() - 1);
        }
        if (line.startsWith("\"") && line.endsWith("\"")) {
            line = line.substring(1, line.length() - 1);
            line = GitUtils.unescape(line);
        }
        String rawPath = line.startsWith(prefix) ? line.substring(prefix.length()) : line;

        return getPath(rawPath);
    }

    private static String parsePath(String line, String prefix, String key) {
        if (line.startsWith(key)) {
            return parsePath(line.substring(key.length()), prefix);
        }
        throw new IllegalStateException("Expected " + key + prefix + ", but found [" + line + "]");
    }

    private static int parseSpan(Matcher matcher, int group) {
        String value = matcher.group(group);

        //For one-line spans, git omits the ,span portion. If 0 lines are affected on a given side, git explicitly
        //outputs 0. So if the span group doesn't match (group(int) returns null), it means the span is 1, not 0
        return value == null ? 1 : Integer.parseInt(value);
    }

    private void handleDiffBody(String line) {
        if (line.startsWith(KEY_SOURCE_PATH)) { //Most common case, diffing two files git can interpret
            //--- <path>, indicating a real diff is about to follow
            //Either path may be null, but not both
            src = parsePath(line, SRC_PREFIX, KEY_SOURCE_PATH);
            hasSrc = true;
            return;
        }

        if (hasSrc && !hasDst) {
            dst = parsePath(line, DST_PREFIX, KEY_DESTINATION_PATH);
            hasDst = true;

            //Will do binary check next, which can be performed on a truncated line
            setMaxLineLength(maxReadLength);
            return;
        }

        if (hasSrc) {
            if (!hasHunkHeader) {
                hasHunkHeader = isHunkHeader(line);
                if (hasHunkHeader) {
                    state = State.HUNK_HEADER;
                    //No need to process this line any further
                    return;
                }
            }

            if (!hasStartedDiff) {
                hasStartedDiff = maybeStartDiff(src, dst, isBinary);
            }
            if (!hasStartedDiff || isBinary) {
                //If the diff is filtered, or is for a transcoded binary file, drop lines until we either
                //reach the end of the stream or find the next diff
                if (isDiffHeader(line)) {
                    resetState(State.DIFF_HEADER_START);
                }
            } else { //Process a normal text diff
                state = State.HUNK_HEADER;
                setMaxLineLength(maxReadLength);
            }
        } else if (!hasStartedDiff) {
            Matcher matcher = PATTERN_BINARY_FILES.matcher(line);
            if (matcher.matches()) {
                //Binary files..., indicating a binary file has differences. Note that, even if this diff is
                //filtered, there are no lines to skip; the next line will start a new diff
                String srcPath = parsePath(matcher.group(1), SRC_PREFIX);
                String dstPath = parsePath(matcher.group(2), DST_PREFIX);
                hasStartedDiff = maybeStartDiff(srcPath, dstPath, true);
                resetState(State.START);
            } else {
                //Something else unexpected
                throw new IllegalStateException("Expected diff paths but found [" + line + "]");
            }
        }
    }

    private void handleDiffEnd(String line) {
        callback.onDiffEnd(lines == TRUNCATED);
        hasStartedDiff = false;

        //Start processing the next diff
        resetState(State.DIFF_HEADER_START);
        Matcher matcher = PATTERN_DIFF_HEADER.matcher(line);
        path = matcher.matches() ? matcher.group(1) : null;
    }

    private void handleDiffHeader(String line) {
        //If there are no more lines in the working diff (which is identified by either a line with null value or the
        //next line being a diff --git header), we need to process the state we've accumulated so far because it
        //represents the full diff.
        if (line == null || isDiffHeader(line)) {
            //If src _or_ dst is null they _both_ should be, since the headers work as a pair. Just to be safe, we
            //pretend they're both null if either is.
            //
            //The only way to get here with both non-null is a copy or a rename that does not modify file contents
            if (src == null || dst == null) {
                if (isDeletedFile) {
                    //The only way we can get here is the deletion of an empty file
                    src = parsePath(path, SRC_PREFIX);
                    dst = null; //Ensure no destination, just as a precaution
                } else if (isNewFile) {
                    //The only way we can get here is the addition of an empty file
                    src = null; //Ensure no source, just as a precaution
                    //Note: We reuse group 1 (instead of 2, which is the real destination path) because both paths
                    //      will be the same and 1 is less likely to have been truncated
                    dst = parsePath(path, SRC_PREFIX);
                } else if (isModeChanged) {
                    //The only way we can get here is if a file is _not_ renamed/copied and its execute bit is
                    //changed without changing its contents.
                    //
                    //Note: Since there were no rename/copy path headers, we know the path did not change. We reuse
                    //      group 1 for the destination path (instead of 2, which is the real destination path)
                    //      because both paths will be the same and 1 is less likely to have been truncated
                    src = dst = parsePath(path, SRC_PREFIX);
                } else {
                    //The expectation is that there should be hunks after the headers unless the headers fully
                    //describe the changes. For example:
                    //- A rename or copy with no differences between the source and destination
                    //- The addition or deletion of an empty file
                    //- Changing a file's execute bit without changing its contents
                    //All of these cases will result in a diff with no output beyond the headers.
                    //
                    //If we make it here it indicates there's a headers-only case which is not handled.
                    throw new IllegalStateException("Unexpected end of diff output");
                }
                hasSrc = true;
                hasDst = true;
            }

            if (!hasStartedDiff) {
                //If the diff is not filtered, call onDiffEnd(...). Whether the diff is filtered or not, though,
                //"line" is already at the start of the next diff, so there's nothing to skip
                if (maybeStartDiff(src, dst, false)) {
                    hasStartedDiff = true;
                    state = State.DIFF_END;
                }
            }
        } else {
            //If line doesn't match the start of a new diff, stream the diff to the callback
            state = State.DIFF_BODY;
        }
    }

    private void handleDiffHeaderStart(String line) {
        GitDiffHeader header = GitDiffHeader.parseToHeader(line);
        if (header == null) {
            if (hasStartedHeader) {
                state = State.DIFF_HEADER;
            }
        } else if (header.getType() == GitDiffHeaderType.PATH) {
            String path = getPath(header.getValue(line));
            if (header.getTarget() == GitDiffTarget.SOURCE) {
                src = path;
            } else {
                dst = path;
            }
        } else if (header.getType() == GitDiffHeaderType.MODE) {
            isModeChanged = true;

            if (GitDiffHeader.DELETED_FILE_MODE == header) {
                isDeletedFile = true;
            } else if (GitDiffHeader.NEW_FILE_MODE == header) {
                isNewFile = true;
            }
        }
        hasStartedHeader = true;
    }

    private void handleHunkBody(String line) {
        if (!hasStartedDiff) {
            //When transcoding is enabled, git diff no longer does binary detection. That means we need
            //to manually detect binary ourselves
            isBinary = isTranscodedBinary(line);
            hasStartedDiff = maybeStartDiff(src, dst, isBinary);
            if (!hasStartedDiff || isBinary) {
                //If the diff is filtered, or is for a transcoded binary file, drop lines until we either
                //reach the end of the stream or find the next diff
                resetState(State.START);
                return;
            }
        }

        GitDiffSegmentType lineType = GitDiffSegmentType.forLine(line);
        if (lineType == null) {
            state = State.HUNK_END;
            return;
        }
        if (!lineType.isValid()) {
            //This type of segment is not valid for output to the callback, so skip it
            return;
        }

        //There's another line to output, but before we do so we need to consider the limits
        if (lines >= maxLines) {
            lines = TRUNCATED; //Signal truncation

            //If type is null, there is no open hunk--the previous hunk was completed without truncation. It
            //is the overall diff that is truncated, which will be marked due to the -1 above.
            if (segmentType != null) {
                //If the types match, we're truncating a segment in progress. If they do not, we're ending the
                //in-progress segment without truncating it, but we are truncating the hunk (because there are
                //more segments in the hunk--we know for sure because we're at a usable line)
                callback.onSegmentEnd(segmentType == lineType);
                hasStartedSegment = false;
                callback.onHunkEnd(true);
                hasStartedHunk = false;
            }

            //Reached the limit - no further input is required
            state = State.CANCEL;
            return;
        }
        if (segmentType != lineType) {
            if (segmentType == null) {
                //When we hit the first usable line in a hunk, open the hunk. This is done to prevent the page
                //limit from resulting in an empty truncated hunk.
                int srcLine = Integer.parseInt(hunkHeaderMatcher.group(1));
                int srcSpan = parseSpan(hunkHeaderMatcher, 2);
                int dstLine = Integer.parseInt(hunkHeaderMatcher.group(3));
                int dstSpan = parseSpan(hunkHeaderMatcher, 4);

                callback.onHunkStart(srcLine, srcSpan, dstLine, dstSpan, hunkHeaderMatcher.group(5));
                hasStartedHunk = true;
            } else {
                callback.onSegmentEnd(false); //If there is a previous type, end that segment
                hasStartedSegment = false;
            }
            segmentType = lineType; //Note the type we're working on for this segment

            //Start a new segment with this type
            callback.onSegmentStart(lineType.getType());
            hasStartedSegment = true;
        }

        line = lineType.dropPrefix(line);

        boolean truncated = line.length() > maxLineLength;
        if (truncated) {
            line = line.substring(0, maxLineLength);
        }
        callback.onSegmentLine(line, truncated);
        ++lines;
    }

    private void handleHunkEnd(String line) {
        if (hasStartedSegment) {
            callback.onSegmentEnd(false);
            hasStartedSegment = false;
        }
        if (hasStartedHunk) {
            callback.onHunkEnd(false);
            hasStartedHunk = false;
        }
        segmentType = null;

        state = isHunkHeader(line) ? State.HUNK_BODY : State.DIFF_END;
    }

    private void handleHunkHeader(String line) {
        if (isHunkHeader(line)) {
            state = State.HUNK_BODY;
            segmentType = null;
        }
    }

    private void handleStart(String line) {
        //When using git log -p instead of git diff, the first 2 lines will be empty
        if (line.isEmpty()) {
            return;
        }

        //First line should be a diff header
        //
        //Note: We do not try to pull the paths from this line because they may be truncated here. There is always
        //      another place in the output where they will be provided and we can be sure they won't be truncated
        //      by the reader, so we defer that processing for later.
        Matcher matcher = PATTERN_DIFF_HEADER.matcher(line);
        if (matcher.matches()) {
            state = State.DIFF_HEADER_START;
            path = matcher.groupCount() > 1 ? matcher.group(1) : null;
        }
    }

    /**
     * Examines the {@code src} and {@code dst} paths to determine whether the diff was {@link #paths requested}.
     * If no paths were provided to restrict the output, all diffs are accepted.
     * <p>
     * Since no paths were passed to {@code git diff}, it's free to pair up paths however it chooses. That means
     * there's a possibility that, given a {@code (src, dst)} pair, only one of them was actually requested. When
     * that happens, we err on the side of returning too much data, rather than too little, and accept the diff.
     * <p>
     * This is <i>partially</i> consistent with what {@code git diff} would have done had paths been provided, in
     * that, with this approach, the caller might see a copy or a rename where only one path was requested where,
     * had paths been provided, they would have seen either a straight add or a straight delete depending on which
     * "side" of the copy/rename the requested path was on. Either way, a diff for the path would be present.
     *
     * @param src the source path, which may be {@code null} if the file was added
     * @param dst the destination path, which may be {@code null} if the file is deleted
     * @return {@code true} if the diff should be streamed; otherwise, {@code false} if it should be skipped
     */
    private boolean isAccepted(String src, String dst) {
        return paths == null || (src != null && paths.contains(src)) || (dst != null && paths.contains(dst));
    }

    private boolean isDiffHeader(String line) {
        return PATTERN_DIFF_HEADER.matcher(line).matches();
    }

    private boolean isHunkHeader(String line) {
        hunkHeaderMatcher = PATTERN_HUNK_HEADER.matcher(line);
        return hunkHeaderMatcher.matches();
    }

    /**
     * Checks the line to determine if it is a text marker for a binary diff. This happens when transcoding is enabled
     * for a repository. When using {@code textconv}, {@code git diff} does not perform its own binary detection.
     * <p>
     * When this method returns {@code true}, the first two lines of the diff will be dropped. However, there may
     * be additional lines that should also be dropped before the next diff (or end of stream). It is left to the
     * <i>caller</i> to drop any remaining lines.
     * <p>
     * <b>Warning</b>: This detection mechanism has an unhandled corner case. If a non-binary file becomes binary,
     * instead of being marked as binary the diff will show up as a delete for every line in the file followed by
     * an add of the {@link #TRANSCODED_BINARY} marker. This case is not handled because doing so requires the ability
     * to buffer an unknown number of remove lines while reading down to the first added line. It is expected (hoped?)
     * that this corner case will happen sufficiently rarely to not be a serious issue.
     *
     * @param line the line to check for the binary diff marker
     * @return {@code true} if the diff is a text diff for a binary file, the result of transcoding; otherwise
     *         {@code false} if the diff should be processed as normal text
     */
    private boolean isTranscodedBinary(String line) {
        return line.substring(1).startsWith(TRANSCODED_BINARY);
    }

    private boolean maybeStartDiff(String src, String dst, boolean binary) {
        if (isAccepted(src, dst)) {
            startDiff(src, dst, binary);
            return true;
        }
        return false;
    }

    private void resetState(State state) {
        this.state = state;
        hasStartedHeader = false;
        dst = null;
        src = null;

        isDeletedFile = false;
        isModeChanged = false;
        isNewFile = false;

        hasSrc = false;
        hasDst = false;

        hasStartedDiff = false;
        hasHunkHeader = false;
        isBinary = false;

        hunkHeaderMatcher = null;
        hasStartedSegment = false;
        path = null;
        segmentType = null;

        //Set max line length back to unlimited
        setMaxLineLength(0);
    }

    private void startDiff(String src, String dst, boolean binary) {
        if (binary) {
            callback.onBinary(src, dst);
        } else {
            callback.onDiffStart(src, dst);
        }
    }

    enum State {

        CANCEL, //Indicates that no further input is required
        DIFF_BODY, //The diff body
        DIFF_END, //End of the diff
        DIFF_HEADER_START, //Start of the diff header
        DIFF_HEADER, //Remainder of the diff header
        HUNK_BODY, //Body of the diff hunk
        HUNK_END, //End of the diff hunk
        HUNK_HEADER, //Header of the hunk within the diff
        SEGMENT, //Segment within the diff hunk
        START //Start state
    }
}
