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

import com.atlassian.bitbucket.mesh.git.builder.revlist.CommitPump;
import com.atlassian.bitbucket.mesh.git.builder.revlist.CommitPumpSettings;
import com.atlassian.bitbucket.mesh.git.builder.revlist.CommitSink;
import com.atlassian.bitbucket.mesh.git.commit.Commit;
import com.atlassian.bitbucket.mesh.git.content.Change;
import com.atlassian.bitbucket.mesh.git.content.Changeset;
import com.atlassian.bitbucket.mesh.git.content.ChangesetCallback;
import com.atlassian.bitbucket.mesh.git.exception.CommitNotFoundException;
import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.atlassian.bitbucket.mesh.process.ProcessStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.google.common.collect.Iterables;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;

import static com.atlassian.bitbucket.mesh.git.builder.difftree.AbstractDiffTreeStdioHandler.parseChange;

/**
 * Streams {@link Changeset changesets} to a {@link ChangesetCallback} until no more changesets are available on
 * {@code stdout} or the callback signals completion, whichever happens first.
 */
public class CallbackChangesetDiffTreeStdioHandler
        extends AbstractLineStdioHandler<Void>
        implements ProcessStdioHandler<Void> {

    private static final String DIFF_TREE_END = "@@diff_tree_end@@";

    private final ChangesetCallback callback;
    private final Deque<String> commitIds;
    private final CommitPump commitPump;
    private final boolean ignoreMissing;
    private final int maxChanges;

    private int changeCount;
    private List<Change> changes;
    private Commit commit;
    private String commitId;
    private State state;

    public CallbackChangesetDiffTreeStdioHandler(Collection<String> commitIds, ChangesetCallback callback,
                                                 boolean ignoreMissing, int maxChanges, int maxMessageLength) {
        super(StandardCharsets.UTF_8);

        this.callback = callback;
        this.commitIds = new LinkedList<>(commitIds);
        this.ignoreMissing = ignoreMissing;
        this.maxChanges = maxChanges;

        changes = new ArrayList<>();
        commitPump = new CommitPump(
                new CommitPumpSettings.Builder()
                        .maxMessageLength(maxMessageLength)
                        .build(),
                new ChangesetCommitSink());
    }

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

    @Override
    public void onStart(@Nonnull OsProcess process) {
        state = State.START_COMMIT;
        pump();
    }

    // Note: onStdinReady must be implemented because this class is a ProcessStdioHandler, but it is not expected to be
    // called (as all the stdin handling is managed in pump/pumpIfRunning).
    @Override
    public boolean onStdinReady(@Nonnull ByteBuffer buffer) {
        // Flip the buffer (to leave it empty), close stdin and return false to signal we have no stdin to provide and
        // prevent further callbacks.
        buffer.flip();
        closeStdin();

        return false;
    }

    // Note: The state can advance multiple times handling a single line, depending on how large the requested object
    //       is. As a result, this uses a series of if statements rather than an else if chain or switch statement.
    @Override
    public boolean onStdout(@Nonnull String line, boolean truncated) {
        if (state == State.START_COMMIT) {
            // Even if the commit does not exist, because we also wrote out a marker value, we should reliably get at
            // least one line of output to read.
            if (DIFF_TREE_END.equals(line)) {
                // If we make it here, it means the commit we requested doesn't exist (so git output nothing for
                // that line of input, followed by the OBJECT_END string).
                if (ignoreMissing) {
                    state = State.REQUEST_COMMIT;
                } else {
                    throw new CommitNotFoundException(commitId);
                }
            } else {
                state = State.COMMIT_DETAILS;
            }
        }

        if (state == State.DISCARD_COMMIT_DETAILS) {
            if (DIFF_TREE_END.equals(line)) {
                // When we get here we have already written out the diff-to-first-parent request to {@code git diff-tree}
                // (above in {@link #onCommit}) and have finished flushing out all the commit details we are trying to
                // discard, so reset state ready for reading the diff-to-first-parent commit.
                resetState(State.START_COMMIT);
            } else {
                commitPump.onStdout(line);
            }
        }

        if (state == State.DIFF_TREE) {
            if (StringUtils.isEmpty(line)) {
                // Ignore empty lines, they're printed between the commit details and the beginning of the diff-tree
            } else if (DIFF_TREE_END.equals(line)) {
                // End of diff tree found
                state = State.DIFF_TREE_END;
            } else if (changeCount <= maxChanges) {
                // Only parse the change line if we're going to use it.
                changes.add(parseChange(line));
                ++changeCount;
            }
        }

        if (state == State.DIFF_TREE_END) {
            if (callback.onChangeset(buildChangeset(commit, changes))) {
                state = State.REQUEST_COMMIT;
            } else {
                return false;
            }
        }

        if (state == State.REQUEST_COMMIT) {
            if (commitIds.isEmpty()) {
                return false;
            }
            // Write the next commit ID to the process's stdin, followed by a marker. This will trigger git to dump
            // the diff tree to stdout.
            pumpIfRunning();

            resetState(State.START_COMMIT);
            commit = null;
        }

        if (state == State.COMMIT_DETAILS) {
            // Read in the commit details; by this point, we've verified that there should be some.
            commitPump.onStdout(line);
        }

        return true;
    }

    private Changeset buildChangeset(Commit commit, List<Change> changes) {
        return new Changeset.Builder(commit)
                .fromCommitId(Iterables.getFirst(commit.getParents(), null))
                .changes(changes)
                .build();
    }

    /**
     * Retrieves the next object ID to request from {@code git diff-tree}. If the returned ID is {@code null},
     * the process's {@code stdin} will be closed to let it terminate.
     */
    private void pump() {
        commitId = commitIds.pollFirst();
        if (commitId == null) {
            closeStdin();
        } else {
            requestObject(commitId);
        }
    }

    private void pumpIfRunning() {
        if (isCanceled()) {
            closeStdin();
        } else {
            pump();
        }
    }

    /**
     * Writes the provided object ID and a delimiter, followed by a line feed ({@code \n}) to the process's {@code stdin},
     * which will trigger {@code git diff-tree --stdin} to load the requested object.
     *
     * @param objectId the object ID to request
     */
    private void requestObject(String objectId) {
        StringBuilder builder = new StringBuilder();
        // Write the line of output. This should trigger git to output commit details.
        builder.append(objectId).append("\n");
        // Write a unique delimiter to the process's stdin so that we can tell when we reach the end of the diff tree.
        // Because this value is not a valid commit ID, git will echo it back to us unchanged.
        builder.append(DIFF_TREE_END).append("\n");

        process.writeStdin(ByteBuffer.wrap((builder.toString()).getBytes(StandardCharsets.UTF_8)));
    }

    private void resetState(State state) {
        this.state = state;
        changeCount = 0;
        changes = new ArrayList<>();
    }

    private class ChangesetCommitSink implements CommitSink {

        @Override
        public void onCommit(@Nonnull Commit c) {
            // If the commit has already been set, any subsequent commit can be discarded. Once the commit is fully
            // processed, onStdout will null it out, and then the pump will accept the next one it reads.
            if (commit == null) {
                commit = c;

                // If the commit is a merge commit, no diff tree should be output.
                if (commit.getParents().size() > 1) {
                    // Because we have the IDs for the merge commit's parents, we can request a single diff tree
                    // relative to the first parent in the list, explicitly. We do that by writing out the commit
                    // ID followed by its first parent's ID.
                    requestObject(commitId + " " + Iterables.getFirst(commit.getParents(), null));

                    // The output will then return _similar_ commit details, but where before it included all of the
                    // commit's parents, in this case it will only include the parent we specifically requested. For
                    // that reason, the returned commit details are useless to us, so we just discard them.
                    state = State.DISCARD_COMMIT_DETAILS;
                    return;
                }
            }

            // We get to this point when the commit was already set or when the set commit only has 1 parent.
            resetState(State.DIFF_TREE);
        }

        @Override
        public void onPartialMessage(int maxLength) {
            setMaxLineLength(maxLength);
        }
    }

    private enum State {

        /**
         * The details for a commit should be read.
         */
        COMMIT_DETAILS,
        /**
         * The diff tree for a commit should be read.
         */
        DIFF_TREE,
        /**
         * Finish processing the diff tree and commit.
         */
        DIFF_TREE_END,
        /**
         * The details for a commit should be read and discarded.
         */
        DISCARD_COMMIT_DETAILS,
        /**
         * The next commit should be requested.
         */
        REQUEST_COMMIT,
        /**
         * Start processing the commit.
         */
        START_COMMIT
    }
}
