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

import com.atlassian.bitbucket.content.*;
import com.atlassian.bitbucket.io.LineReader;
import com.atlassian.bitbucket.scm.CommandResult;
import com.atlassian.bitbucket.scm.CommandSummary;
import com.atlassian.bitbucket.scm.CommandSummaryHandler;
import com.atlassian.bitbucket.scm.git.GitUtils;
import com.atlassian.utils.process.ProcessException;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.Map;

/**
 * Streams {@link Change changes} to a {@link ChangeCallback} until no more changes are
 * available on the {@link LineReader} or the callback signals completion, whichever happens first.
 * <p>
 * Note: This output handler does <i>not</i> handle merge commit output. It is expected that this handler will not be
 * used with {@code git diff-tree -m}. See {@link #processReader(LineReader)} for more details.
 *
 * @since 1.3
 */
public class CallbackDiffTreeOutputHandler
        extends AbstractChangeDiffTreeOutputHandler<Void>
        implements CommandSummaryHandler {

    private static final Predicate<String> IS_DIRECTORY = new Predicate<String>() {

        @Override
        public boolean apply(String path) {
            return path.endsWith("/");
        }
    };
    private static final Function<SimpleChange, Conflict> NO_CONFLICT = new Function<SimpleChange, Conflict>() {

        @Override
        public Conflict apply(SimpleChange change) {
            return null;
        }
    };

    private final ChangeCallback callback;
    private final ChangeContext context;
    private final Function<SimpleChange, Conflict> directoryConflicts;
    private final Function<SimpleChange, Conflict> fileConflicts;

    private int added;

    public CallbackDiffTreeOutputHandler(ChangeCallback callback, ChangeContext context) {
        this(callback, context, null);
    }

    public CallbackDiffTreeOutputHandler(ChangeCallback callback, ChangeContext context,
                                         Map<String, Conflict> conflicts) {
        this.callback = callback;
        this.context = context;

        if (conflicts == null || conflicts.isEmpty()) {
            directoryConflicts = NO_CONFLICT;
            fileConflicts = NO_CONFLICT;
        } else {
            //Finding directory conflicts is much more expensive than finding file conflicts, because we have to iterate
            //over the keys and do startsWith comparisons instead of gets. To optimise that, separate the directory keys
            //from the file keys. Directory keys will end with a /.
            //
            //The filtered maps are passed through ImmutableMap.copyOf to reduce the number of times the predicate has
            //to be applied, given that a diff-tree can contain hundreds of entries.
            Map<String, Conflict> directoryPaths = ImmutableMap.copyOf(Maps.filterKeys(conflicts, IS_DIRECTORY));
            if (directoryPaths.isEmpty()) {
                //If there are no directory keys, fantastic. Null that field, to simplify checking and assign the
                //entire map as fileConflicts
                directoryConflicts = NO_CONFLICT;
                fileConflicts = new FileConflictFunction(conflicts);
            } else {
                directoryConflicts = new DirectoryConflictFunction(directoryPaths);

                if (directoryPaths.size() == conflicts.size()) {
                    //We _only_ have directory conflicts, so null out the fileConflicts to simplify checking
                    fileConflicts = NO_CONFLICT;
                } else {
                    //Otherwise, we know we must have some file conflicts, so filter those out from the combined map
                    fileConflicts = new FileConflictFunction(
                            ImmutableMap.copyOf(Maps.filterKeys(conflicts, Predicates.not(IS_DIRECTORY))));
                }
            }
        }
    }

    /**
     * A basic {@link #HASH_FORMAT hash format}, as commit details are not relevant to this output handler.
     *
     * @return {@link #HASH_FORMAT}
     */
    @Nonnull
    @Override
    public String getFormat() {
        return HASH_FORMAT;
    }

    /**
     * All output is streamed to the callback, so this handler produces no object output.
     *
     * @return {@code null}
     */
    @Override
    public Void getOutput() {
        return null;
    }

    @Override
    public void onComplete(@Nonnull CommandSummary summary) throws ProcessException {
        try {
            if (added > 0) {
                //If we've parsed at least one change, inform the callback that we're done.
                callback.onEnd(new ChangeSummary.Builder(summary)
                        .truncated(added > context.getMaxChanges()) //If we've hit the max we've truncated the output
                        .build());
            } else if (summary.getResult() == CommandResult.SUCCEEDED) {
                //IF there were no changes between the two commits, the handler needs to be notified anyway
                //per its contract. However, if the command _failed_, the handler should not be notified
                callback.onStart(context);
                callback.onEnd(new ChangeSummary.Builder(summary).build());
            }
        } catch (IOException e) {
            throw new ProcessException(e);
        }
    }

    @Override
    protected SimpleChange parseChange(String line) {
        SimpleChange change = super.parseChange(line);

        Conflict conflict = fileConflicts.apply(change);
        if (conflict == null) {
            conflict = directoryConflicts.apply(change);
        }
        if (conflict != null) {
            change = new SimpleChange.Builder(change)
                    .conflict(conflict)
                    .build();
        }

        return change;
    }

    /**
     * {@link ChangeCallback#onStart(ChangeContext) Starts} the callback, and then streams {@link Change
     * changes} to it until {@link ChangeCallback#onChange(Change) onChange} signals the end
     * or until no more changes are available, then {@link ChangeCallback#onEnd(ChangeSummary) ends} the callback.
     * <p>
     * This output handler does not accept merge commits, because in a merge commit it is not possible, from the output,
     * to determine which parent the tree is relative to. The presence of a commit hash in the output after any changes
     * is assumed to indicate the presence of a merge commit and an exception will be thrown.
     *
     * @param reader the reader for receiving lines of git output
     * @throws IllegalStateException if a commit hash is encountered after any changes have been parsed
     * @throws IOException if the {@link ChangeCallback} throws an {@code IOException}
     */
    @Override
    protected void processReader(LineReader reader) throws IOException {
        String line;
        while ((line = reader.readLine()) != null) {
            resetWatchdog();

            if (line.isEmpty()) {
                //git diff-tree outputs a blank line between the commit hash and the diff tree, which we ignore
                continue;
            }
            if (GitUtils.isHash(line)) {
                if (added > 0) {
                    //After streaming any changes, if we find another commit hash it usually means we've received
                    //a merge commit, and diff-tree is now outputting the tree relative to another parent. This
                    //shouldn't happen, as the command should not be run with merge commits enabled
                    throw new IllegalStateException("Encountered unexpected commit hash line [" + line +
                            "] while parsing changes. Merge commits are not supported by this output handler.");
                } else {
                    //First line of output _may_ be a commit hash; diff-tree's output is inconsistent about this
                    continue;
                }
            }

            //When we find the first actual change in the tree, note that we've found one
            if (added++ == 0) {
                callback.onStart(context);
            }
            if (added > context.getMaxChanges() || !callback.onChange(parseChange(line))) {
                //We've parsed the maximum amount of changes we're allowed to, or the callback has received as many
                //changes as it wants to handle. Cancel the process and break out of the loop.
                cancelProcess();

                break;
            }
        }
    }

    private static class DirectoryConflictFunction implements Function<SimpleChange, Conflict> {

        private final Map<String, Conflict> conflicts;

        private DirectoryConflictFunction(Map<String, Conflict> conflicts) {
            this.conflicts = conflicts;
        }

        @Override
        public Conflict apply(SimpleChange change) {
            String path = change.getPath().toString();
            String srcPath = change.getSrcPath() == null ? null : change.getSrcPath().toString();

            for (Map.Entry<String, Conflict> entry : conflicts.entrySet()) {
                if (path.startsWith(entry.getKey()) || (srcPath != null && srcPath.startsWith(entry.getKey()))) {
                    return entry.getValue();
                }
            }
            return null;
        }
    }

    private static class FileConflictFunction implements Function<SimpleChange, Conflict> {

        private final Map<String, Conflict> conflicts;

        private FileConflictFunction(Map<String, Conflict> conflicts) {
            this.conflicts = conflicts;
        }

        @Override
        public Conflict apply(SimpleChange change) {
            Conflict conflict = conflicts.get(change.getPath().toString());
            if (conflict == null && change.getSrcPath() != null) {
                conflict = conflicts.get(change.getSrcPath().toString());
            }
            return conflict;
        }
    }
}
