package com.atlassian.stash.internal.scm.git.command.blame;

import com.atlassian.bitbucket.content.FileContentCallback;
import com.atlassian.bitbucket.content.FileContext;
import com.atlassian.bitbucket.content.FileSummary;
import com.atlassian.bitbucket.io.ContentDetectionUtils;
import com.atlassian.bitbucket.io.LineReader;
import com.atlassian.bitbucket.scm.CommandOutputHandler;
import com.atlassian.bitbucket.scm.CommandResult;
import com.atlassian.bitbucket.scm.CommandSummary;
import com.atlassian.bitbucket.scm.CommandSummaryHandler;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.utils.process.ProcessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A {@link CommandOutputHandler CommandOutputHandler} implementation which parses Porcelain
 * output from {@code git blame} and passes the parsed data to a {@link FileContentCallback}. Because blame output is
 * forwarded to the callback as it is processed, this handler returns {@code void} for {@link #getOutput()}.
 */
public class PorcelainBlameOutputHandler extends AbstractBlameOutputHandler<Void> implements CommandSummaryHandler {

    public static final Pattern LINE_HEADER_PATTERN = Pattern.compile("([a-z0-9]+)\\s(\\d+)\\s(\\d+)\\s*");
    
    private static final Logger LOG = LoggerFactory.getLogger(PorcelainBlameOutputHandler.class);

    private final FileContentCallback callback;
    private final FileContext context;
    private final int maxReadLength;
    private final PageRequest pageRequest;

    private int added;

    public PorcelainBlameOutputHandler(FileContentCallback callback, FileContext context, PageRequest pageRequest) {
        this.callback = callback;
        this.context = context;
        this.pageRequest = pageRequest;

        maxReadLength = context.getMaxLineLength() + 4; //Leading tab, trailing carriage return/line feed, one more byte
    }

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

    @Override
    public void onComplete(@Nonnull CommandSummary summary) throws ProcessException {
        if (added < 0) {
            //The file was binary; no further callback calls will be necessary
            return;
        }

        try {
            if (added > 0) {
                callback.onEnd(new FileSummary.Builder(summary)
                        .lastPage(added <= pageRequest.getLimit())
                        .pageRequest(pageRequest)
                        .size(Math.min(added, pageRequest.getLimit()))
                        .build());
            } else if (summary.getResult() == CommandResult.SUCCEEDED) {
                callback.onStart(context);
                callback.onEnd(new FileSummary.Builder(summary)
                        .lastPage(true)
                        .pageRequest(pageRequest)
                        .build());
            }
        } catch (IOException e) {
            throw new ProcessException(e);
        }
    }

    @Override
    public void process(InputStream output) throws ProcessException {
        try {
            //Rather than trying to detect binary files from any individual line, which is likely to be hit-or-miss,
            //use the InputStream up front, prior to creating the reader, to allow using a bigger buffer
            BufferedInputStream stream = new BufferedInputStream(output);
            if (ContentDetectionUtils.isBinary(stream, 8192)) {
                cancelProcess();

                added = -1; //Signal for onComplete that the file was binary
                callback.onBinary();

                return;
            }
            output = stream; //We have already consumed part of "output", so we need to use "stream" in process(...)
        } catch (IOException e) {
            throw new ProcessException(e);
        }

        super.process(output);
    }

    @Override
    protected void processReader(LineReader reader) throws IOException {
        List<Span> spans = new ArrayList<Span>();

        //This loop is constructed with the assumption that the output being parsed has been properly paginated
        //relative to the PageRequest by git blame when it was generated. In other words, for a PageRequest that
        //starts at line 100 with a limit of 50, this loop assumes that:
        //  1. The first Span will begin at line 101 (index 100)
        //  2. Spans will end before or on line 152 (51 lines)
        //For the case where spans end before line 152, the loop will be terminated when reader.readLine() is
        //null. If the spans terminate on line 152, though, the work is a little more complex because there are
        //two possible points where that could happen:
        //  1. After reading a new Span header for a new commit
        //  2. While iterating over the spanned lines for a given commit
        //In the first scenario, the entire newly-read span is simply discarded. In the second, the existing span must
        //be trimmed down a page (note that this implies an assumption that lines will only ever be off by one).
        //Neither case above breaks out of the loop. Because the pagination is expected to be closely linked to the
        //initial PageRequest, there is expected to be no reason to break out early.
        while (true) {
            resetWatchdog();

            String line = reader.readLine();
            if (line == null) {
                break;
            }

            Matcher matcher = SPAN_HEADER_PATTERN.matcher(line);
            if (!matcher.matches()) {
                throw new IllegalStateException("Line [" + line + "] is not a valid span header");
            }

            Span span = createSpan(matcher);
            if (spans.isEmpty() && span.isEmpty()) {
                //This is how we can detect an empty file
                break;
            }
            if (isNewCommit(span.getHash())) {
                readCommit(span.getHash(), reader);
            }

            //Next line should be the output line
            String content = readContent(reader);
            int lineNumber = span.getLineNumber();

            //Important: If writeContent for the line immediately following the span header returns TRIM, it means the
            //           line we just read is outside the page boundary requested. That, in turn, means the span we just
            //           read in should _not_ be included in the spans list.
            State state = writeContent(lineNumber, content);
            if (state == State.CONTINUE) {
                //However: If we should CONTINUE, this span is valid as far as our page request is concerned.
                spans.add(span);

                //Now, if the commit spans multiple lines, we need to munge those out differently. Technically we could
                //make the SPAN_HEADER_PATTERN more generic, but this approach allows us to vet the output more closely
                for (int i = 1; i < span.getSpannedLines(); ++i) {
                    line = reader.readLine();
                    if (line == null) {
                        throw new IllegalStateException("Unexpected end of file processing line [" + (i + 1) + "/" +
                                span.getSpannedLines() + "] for span [" + span.getHash() + "]");
                    }

                    matcher = LINE_HEADER_PATTERN.matcher(line);
                    if (!matcher.matches()) {
                        LOG.debug("Encountered unexpected line [{}]; expected line header (Working line: {})",
                                line, lineNumber);
                        throw new IllegalStateException("Line [" + line + "] is not a valid line header");
                    }

                    //Ensure the hash being processed matches the spanning commit's hash.
                    String hash = matcher.group(1);
                    if (span.getHash().equals(hash)) {
                        ++lineNumber;

                        state = writeContent(lineNumber, readContent(reader));
                        if (state == State.STOP) {
                            //The callback has requested a stop, so break out of the inner loop. The top-level
                            //loop will then cancel the process and also break out
                            break;
                        }
                        if (state == State.TRIM) {
                            //Handle the case where our span includes one line too many because of pagination
                            span.trimLastPage();
                        }
                    } else {
                        throw new IllegalStateException("Encountered unexpected hash [" + hash +
                                "] while processing spanned lines for [" + span.getHash() + "]");
                    }
                }
            }

            if (state == State.STOP) {
                cancelProcess();

                break;
            }
        }

        if (!spans.isEmpty()) {
            callback.offerBlame(generateBlame(spans));
        }
    }

    private String readContent(LineReader reader) throws IOException {
        resetWatchdog();

        //Porcelain output includes a tab before the file content on each line that we don't want. However, we can't
        //use something like String.trim() because there may be "significant" leading whitespace that actually comes
        //from the real file content and we don't want to remove that. Instead, a simple substring to drop the first
        //character from the line (the tab character) should do the trick.
        return reader.readLine(maxReadLength).substring(1); //Strip the leading tab
    }
    
    private State writeContent(int lineNumber, String content) throws IOException {
        if (added++ == 0) {
            callback.onStart(context);
        }
        if (added > pageRequest.getLimit()) {
            return State.TRIM; //Don't pass this line to the callback
        }

        boolean truncated = false;
        if (content.length() > context.getMaxLineLength()) {
            truncated = true;

            content = content.substring(0, context.getMaxLineLength());
        }

        return callback.onLine(lineNumber, content, truncated) ? State.CONTINUE : State.STOP;
    }

    private enum State {

        /**
         * The requested page has not been filled and the callback has not requested a stop, so keep processing.
         */
        CONTINUE,
        /**
         * The page <i>may</i> be full, but regardless the callback has requested a stop.
         * <p>
         * Note: In this state, there will <i>always</i> be at least one line left to read on the process's output
         * stream because {@link #TRIM} detection is handled first.
         */
        STOP,
        /**
         * The page is full and the line that has just been read should not be include.
         * <p>
         * Note: This state should only be hit <i>once</i>, and should mean there is no further output.
         */
        TRIM,
    }
}
