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

import com.atlassian.bitbucket.mesh.git.GitChangeType;
import com.atlassian.bitbucket.mesh.git.GitNodeType;
import com.atlassian.bitbucket.mesh.git.GitUtils;
import com.atlassian.bitbucket.mesh.git.content.Change;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import org.apache.commons.lang3.StringUtils;

import javax.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.util.Set;

import static java.util.Objects.requireNonNull;

public abstract class AbstractDiffTreeStdioHandler<T> extends AbstractLineStdioHandler<T> {

    private static final String HASH_FORMAT = "%H";

    private Set<String> paths;

    protected AbstractDiffTreeStdioHandler() {
        super(StandardCharsets.UTF_8);
    }

    @Nonnull
    public String getFormat() {
        return HASH_FORMAT; // just the commit hash please
    }

    public void restrictPaths(@Nonnull Set<String> paths) {
        this.paths = requireNonNull(paths, "paths");
    }

    protected static Change parseChange(String line) {
        int index = line.indexOf('\t');
        if (index == -1) {
            throw new IllegalArgumentException("Line [" + line + "] does not contain a tab");
        }

        String metadata = line.substring(0, index); //File modes, content hashes, change type
        String paths = line.substring(index + 1); //At least 1 path

        int parents = metadata.lastIndexOf(':') + 1; //Count the colons--they tell how many parents there are
        if (parents == 0) { //-1 for not found, +1
            throw new IllegalStateException("Leading colons indicating the number of parents were not found");
        }
        metadata = metadata.substring(parents); //Drop the colons from the first mode before the split

        //Array length will vary, but we can use the parent count produced from the leading colons to know how
        //long the array should be
        String[] meta = metadata.split("\\s");
        if (meta.length != ((2 * (parents + 1)) + 1)) {
            //There are (parents + 1) file modes and hashes (where the +1 is the destination mode/hash)
            //There is the additional +1 for the change type
            throw new IllegalStateException("Metadata count [" + meta.length + "] does not match expectations");
        }

        Change.Builder builder = new Change.Builder();

        String rawType = meta[meta.length - 1]; //Last entry is always the change type
        GitChangeType type = GitChangeType.fromStatus(rawType.substring(0, 1));
        if (parents == 1 && type.isScored()) {
            //When only a single parent exists, certain change types (copy, rename) are scored. The percent score is
            //appended to the type string, like "R099" for 99% match on a rename.
            builder.percentUnchanged(Integer.parseInt(rawType.substring(1)));

            //Scored types have two paths in src/dst order if there is only one parent
            String[] rawPaths = StringUtils.split(paths, "\t", 2);
            builder.path(GitUtils.unescapeQuoted(rawPaths[1]))
                    .srcPath(GitUtils.unescapeQuoted(rawPaths[0]));
        } else {
            //All other types have no score and only 1 path. Note that if there is more than 1 parent, even renames and
            //copies have no score and only one path as well
            builder.path(GitUtils.unescapeQuoted(paths));
        }
        builder.executable(GitUtils.isExecutable(meta[parents]))
                .srcExecutable(GitUtils.isExecutable(meta[0]))
                // If the path is or used to be a submodule, then we treat it as such
                // This covers the cases of modifying a submodule ('160000 160000'),
                // adding a submodule ('000000 160000'), removing a submodule ('160000 000000')
                // and changing the submodule to a file ('160000 100644'). This last case is particularly
                // important as it's our current strategy for submodule conflict resolution
                // (see MergeConflictHandler.visit(SubmoduleGitMergeConflict))
                .nodeType(GitUtils.isSubmodule(meta[parents]) || GitUtils.isSubmodule(meta[parents - 1]) ?
                        GitNodeType.SUBMODULE : GitNodeType.FILE)
                .type(type);

        if (type == GitChangeType.DELETE) {
            //Deletion, use the source (first) content hash
            builder.contentId(meta[parents + 1]);
        } else {
            //For all other operations, use the last content hash
            builder.contentId(meta[meta.length - 2]);
        }
        builder.fromContentId(meta[parents + 1]);

        return builder.build();
    }

    /**
     * Partially parses the line to determine whether the {@link Change} was {@link #restrictPaths requested}. If no
     * paths were provided to restrict the output, no changes are filtered.
     * <p>
     * There are two options for how to do this:
     * <ul>
     *     <li>Always call {@link #parseChange}, so the parsing is only coded once, and filter the returned
     *     {@link Change} using its paths</li>
     *     <li>Partially parse the line here, enough to extract the paths and apply filtering, and then parse
     *     it fully later if it's not filtered</li>
     * </ul>
     * Fully parsing a {@link Change} only to then filter it is expensive. Not only is more parsing done, but, for
     * example, {@code Path}s are constructed for its paths and must then be "unwrapped" in order to apply filtering.
     * If the majority of the changes are included, however, then the overhead of partially parsing the line twice
     * may be worse.
     * <p>
     * Neither option is really ideal, and it's not possible to choose the "best" one based on the input because we
     * can't know in advance how many changes will be streamed. In the end, partial parsing was chosen because:
     * <ul>
     *     <li>Partial parsing produces less overhead when a line is filtered, and is only a small amount of overhead
     *     when a line is accepted; full parsing produces significantly higher overhead for filtered paths in exchange
     *     for its lower overhead for accepted lines
     *     <ul>
     *         <li>This implies partial parsing is the more "balanced" approach; it avoids high overhead for either
     *         possible outcome</li>
     *     </ul>
     *     </li>
     *     <li>Anecdotal evidence suggests an "excessive" number of paths, which would trigger <i>using</i> this
     *     filtering, are never passed "in the wild"
     *     <ul>
     *         <li>Prior to 5.4, doing so would have caused the command to fail, since the combined length of paths
     *         wasn't checked. The lack of reports of this suggests it's never actually happened</li>
     *         <li>Analyzing 10,000+ pull requests from the STASH/stash repository showed <i>none</i> of them had
     *         comments on enough paths to even come close to hitting the command line limit</li>
     *     </ul>
     * </ul>
     *
     * @param line the line to test
     * @return {@code true} if the line is filtered and <i>should not</i> be included in the output; otherwise,
     *         {@code false} if the line is accepted
     */
    protected boolean isFiltered(String line) {
        if (paths == null) {
            return false;
        }

        int index = line.indexOf('\t');
        if (index == -1) {
            return true;
        }

        String[] rawPaths = StringUtils.split(line.substring(index + 1), "\t", 2);
        for (String rawPath : rawPaths) {
            if (paths.contains(GitUtils.unescapeQuoted(rawPath))) {
                return false;
            }
        }

        return true;
    }
}
