/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.scm.provider.git.gitexe.command.status;

import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.maven.scm.ScmFile;
import org.apache.maven.scm.ScmFileSet;
import org.apache.maven.scm.ScmFileStatus;
import org.apache.maven.scm.util.AbstractConsumer;

/**
 * @author <a href="mailto:struberg@yahoo.de">Mark Struberg</a>
 */
public class GitStatusConsumer extends AbstractConsumer {

    /**
     * The pattern used to match added file lines.
     */
    private static final Pattern ADDED_PATTERN = Pattern.compile("^A[ M]* (.*)$");

    /**
     * The pattern used to match modified file lines.
     */
    private static final Pattern MODIFIED_PATTERN = Pattern.compile("^ *M[ M]* (.*)$");

    /**
     * The pattern used to match deleted file lines.
     */
    private static final Pattern DELETED_PATTERN = Pattern.compile("^ *D * (.*)$");

    /**
     * The pattern used to match renamed file lines.
     */
    private static final Pattern RENAMED_PATTERN = Pattern.compile("^R  (.*) -> (.*)$");

    private final File workingDirectory;

    private ScmFileSet scmFileSet;

    /**
     * Entries are relative to working directory, not to the repositoryroot.
     */
    private final List<ScmFile> changedFiles = new ArrayList<>();

    private URI relativeRepositoryPath;

    // ----------------------------------------------------------------------
    //
    // ----------------------------------------------------------------------

    /**
     * Consumer when workingDirectory and repositoryRootDirectory are the same.
     *
     * @param workingDirectory the working directory
     */
    public GitStatusConsumer(File workingDirectory) {
        this.workingDirectory = workingDirectory;
    }

    /**
     * Assuming that you have to discover the repositoryRoot, this is how you can get the
     * <code>relativeRepositoryPath</code>
     * <pre>
     * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
     * </pre>
     *
     * @param workingDirectory the working directory
     * @param relativeRepositoryPath the working directory relative to the repository root
     * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
     * @since 1.9
     */
    public GitStatusConsumer(File workingDirectory, URI relativeRepositoryPath) {
        this(workingDirectory);
        this.relativeRepositoryPath = relativeRepositoryPath;
    }

    /**
     * Assuming that you have to discover the repositoryRoot, this is how you can get the
     * <code>relativeRepositoryPath</code>
     * <pre>
     * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
     * </pre>
     *
     * @param workingDirectory the working directory
     * @param scmFileSet fileset with includes and excludes
     * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
     * @since 1.11.0
     */
    public GitStatusConsumer(File workingDirectory, ScmFileSet scmFileSet) {
        this(workingDirectory);
        this.scmFileSet = scmFileSet;
    }

    /**
     * Assuming that you have to discover the repositoryRoot, this is how you can get the
     * <code>relativeRepositoryPath</code>
     * <pre>
     * URI.create( repositoryRoot ).relativize( fileSet.getBasedir().toURI() )
     * </pre>
     *
     * @param workingDirectory the working directory
     * @param relativeRepositoryPath the working directory relative to the repository root
     * @param scmFileSet fileset with includes and excludes
     * @see GitStatusCommand#createRevparseShowPrefix(ScmFileSet)
     * @since 1.11.0
     */
    public GitStatusConsumer(File workingDirectory, URI relativeRepositoryPath, ScmFileSet scmFileSet) {
        this(workingDirectory, scmFileSet);
        this.relativeRepositoryPath = relativeRepositoryPath;
    }

    // ----------------------------------------------------------------------
    // StreamConsumer Implementation
    // ----------------------------------------------------------------------

    /**
     * {@inheritDoc}
     */
    public void consumeLine(String line) {
        if (logger.isDebugEnabled()) {
            logger.debug(line);
        }
        if (line == null || line.isEmpty()) {
            return;
        }

        ScmFileStatus status = null;

        List<String> files = new ArrayList<>();

        Matcher matcher;
        if ((matcher = ADDED_PATTERN.matcher(line)).find()) {
            status = ScmFileStatus.ADDED;
            files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
        } else if ((matcher = MODIFIED_PATTERN.matcher(line)).find()) {
            status = ScmFileStatus.MODIFIED;
            files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
        } else if ((matcher = DELETED_PATTERN.matcher(line)).find()) {
            status = ScmFileStatus.DELETED;
            files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
        } else if ((matcher = RENAMED_PATTERN.matcher(line)).find()) {
            status = ScmFileStatus.RENAMED;
            files.add(resolvePath(matcher.group(1), relativeRepositoryPath));
            files.add(resolvePath(matcher.group(2), relativeRepositoryPath));
            logger.debug("RENAMED status for line '" + line + "' files added '" + matcher.group(1) + "' '"
                    + matcher.group(2));
        } else {
            logger.warn("Ignoring unrecognized line: " + line);
            return;
        }

        // If the file isn't a file; don't add it.
        if (!files.isEmpty()) {
            if (workingDirectory != null) {
                if (status == ScmFileStatus.RENAMED) {
                    String oldFilePath = files.get(0);
                    String newFilePath = files.get(1);
                    if (isFile(oldFilePath)) {
                        logger.debug("file '" + oldFilePath + "' is a file");
                        return;
                    } else {
                        logger.debug("file '" + oldFilePath + "' not a file");
                    }
                    if (!isFile(newFilePath)) {
                        logger.debug("file '" + newFilePath + "' not a file");
                        return;
                    } else {
                        logger.debug("file '" + newFilePath + "' is a file");
                    }
                } else if (status == ScmFileStatus.DELETED) {
                    if (isFile(files.get(0))) {
                        return;
                    }
                } else {
                    if (!isFile(files.get(0))) {
                        return;
                    }
                }
            }

            for (String file : files) {
                if (this.scmFileSet != null && !isFileNameInFileList(this.scmFileSet.getFileList(), file)) {
                    // skip adding this file
                } else {
                    changedFiles.add(new ScmFile(file, status));
                }
            }
        }
    }

    private boolean isFileNameInFileList(List<File> fileList, String fileName) {
        if (relativeRepositoryPath == null) {
            return fileList.contains(new File(fileName));
        } else {
            for (File f : fileList) {
                File file = new File(relativeRepositoryPath.getPath(), fileName);
                if (file.getPath().endsWith(f.getName())) {
                    return true;
                }
            }
            return fileList.isEmpty();
        }
    }

    private boolean isFile(String file) {
        File targetFile = new File(workingDirectory, file);
        return targetFile.isFile();
    }

    public static String resolvePath(String fileEntry, URI path) {
        /* Quotes may be included (from the git status line) when an fileEntry includes spaces */
        String cleanedEntry = stripQuotes(fileEntry);
        if (path != null) {
            return resolveURI(cleanedEntry, path).getPath();
        } else {
            return cleanedEntry;
        }
    }

    /**
     * @param fileEntry the fileEntry, must not be {@code null}
     * @param path the path, must not be {@code null}
     * @return TODO
     */
    public static URI resolveURI(String fileEntry, URI path) {
        // When using URI.create, spaces need to be escaped but not the slashes, so we can't use
        // URLEncoder.encode( String, String )
        // new File( String ).toURI() results in an absolute URI while path is relative, so that can't be used either.
        return path.relativize(uriFromPath(stripQuotes(fileEntry)));
    }

    /**
     * Create an URI whose getPath() returns the given path and getScheme() returns null. The path may contain spaces,
     * colons, and other special characters.
     *
     * @param path the path
     * @return the new URI
     */
    public static URI uriFromPath(String path) {
        try {
            if (path != null && path.indexOf(':') != -1) {
                // prefixing the path so the part preceding the colon does not become the scheme
                String tmp = new URI(null, null, "/x" + path, null).toString().substring(2);
                // the colon is not escaped by default
                return new URI(tmp.replace(":", "%3A"));
            } else {
                return new URI(null, null, path, null);
            }
        } catch (URISyntaxException x) {
            throw new IllegalArgumentException(x.getMessage(), x);
        }
    }

    public List<ScmFile> getChangedFiles() {
        return changedFiles;
    }

    /**
     * @param str the (potentially quoted) string, must not be {@code null}
     * @return the string with a pair of double quotes removed (if they existed)
     */
    private static String stripQuotes(String str) {
        int strLen = str.length();
        return (strLen > 0 && str.startsWith("\"") && str.endsWith("\""))
                ? unescape(str.substring(1, strLen - 1))
                : str;
    }

    /**
     * Dequote a quoted string generated by git status --porcelain.
     * The leading and trailing quotes have already been removed.
     *
     * @param fileEntry
     * @return TODO
     */
    private static String unescape(String fileEntry) {
        // If there are no escaped characters, just return the input argument
        int pos = fileEntry.indexOf('\\');
        if (pos == -1) {
            return fileEntry;
        }

        // We have escaped characters
        byte[] inba = fileEntry.getBytes();
        int inSub = 0; // Input subscript into fileEntry
        byte[] outba = new byte[fileEntry.length()];
        int outSub = 0; // Output subscript into outba

        while (true) {
            System.arraycopy(inba, inSub, outba, outSub, pos - inSub);
            outSub += pos - inSub;
            inSub = pos + 1;
            switch ((char) inba[inSub++]) {
                case '"':
                    outba[outSub++] = '"';
                    break;

                case 'a':
                    outba[outSub++] = 7; // Bell
                    break;

                case 'b':
                    outba[outSub++] = '\b';
                    break;

                case 't':
                    outba[outSub++] = '\t';
                    break;

                case 'n':
                    outba[outSub++] = '\n';
                    break;

                case 'v':
                    outba[outSub++] = 11; // Vertical tab
                    break;

                case 'f':
                    outba[outSub++] = '\f';
                    break;

                case 'r':
                    outba[outSub++] = '\f';
                    break;

                case '\\':
                    outba[outSub++] = '\\';
                    break;

                case '0':
                case '1':
                case '2':
                case '3':
                    // This assumes that the octal escape here is valid.
                    byte b = (byte) ((inba[inSub - 1] - '0') << 6);
                    b |= (byte) ((inba[inSub++] - '0') << 3);
                    b |= (byte) (inba[inSub++] - '0');
                    outba[outSub++] = b;
                    break;

                default:
                    // This is an invalid escape in a string.  Just copy it.
                    outba[outSub++] = '\\';
                    inSub--;
                    break;
            }
            pos = fileEntry.indexOf('\\', inSub);
            if (pos == -1) // No more backslashes; we're done
            {
                System.arraycopy(inba, inSub, outba, outSub, inba.length - inSub);
                outSub += inba.length - inSub;
                break;
            }
        }
        // explicitly say UTF-8, otherwise it'll fail at least on Windows cmdline
        return new String(outba, 0, outSub, StandardCharsets.UTF_8);
    }
}
