/*
 * 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.shared.filtering;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.StringTokenizer;
import java.util.regex.Pattern;

import org.apache.commons.lang3.SystemUtils;
import org.codehaus.plexus.util.io.CachingOutputStream;
import org.codehaus.plexus.util.io.CachingWriter;

/**
 * @author Olivier Lamy
 * @author Dennis Lundberg
 */
public final class FilteringUtils {
    /**
     * The number of bytes in a kilobyte.
     */
    private static final int ONE_KB = 1024;

    /**
     * The number of bytes in a megabyte.
     */
    private static final int ONE_MB = ONE_KB * ONE_KB;

    /**
     * The file copy buffer size (30 MB)
     */
    private static final int FILE_COPY_BUFFER_SIZE = ONE_MB * 30;

    private static final String WINDOWS_PATH_PATTERN = "^(.*)[a-zA-Z]:\\\\(.*)";

    private static final Pattern PATTERN = Pattern.compile(WINDOWS_PATH_PATTERN);
    public static final int COPY_BUFFER_LENGTH = 8192;

    /**
     *
     */
    private FilteringUtils() {
        // nothing just an util class
    }

    // TODO: Correct to handle relative windows paths. (http://jira.apache.org/jira/browse/MSHARED-121)
    // How do we distinguish a relative windows path from some other value that happens to contain backslashes??
    /**
     * @param val The value to be escaped.
     * @return Escaped value
     */
    public static String escapeWindowsPath(String val) {
        if (!isEmpty(val) && PATTERN.matcher(val).matches()) {
            // Adapted from StringUtils.replace in plexus-utils to accommodate pre-escaped backslashes.
            StringBuilder buf = new StringBuilder(val.length());
            int start = 0, end = 0;
            while ((end = val.indexOf('\\', start)) != -1) {
                buf.append(val, start, end).append("\\\\");
                start = end + 1;

                if (val.indexOf('\\', end + 1) == end + 1) {
                    start++;
                }
            }

            buf.append(val.substring(start));

            return buf.toString();
        }
        return val;
    }

    /**
     * Resolve a file <code>filename</code> to its canonical form. If <code>filename</code> is
     * relative (doesn't start with <code>/</code>), it is resolved relative to
     * <code>baseFile</code>. Otherwise it is treated as a normal root-relative path.
     *
     * @param baseFile where to resolve <code>filename</code> from, if <code>filename</code> is relative
     * @param filename absolute or relative file path to resolve
     * @return the canonical <code>File</code> of <code>filename</code>
     */
    public static File resolveFile(final File baseFile, String filename) {
        String filenm = filename;
        if ('/' != File.separatorChar) {
            filenm = filename.replace('/', File.separatorChar);
        }

        if ('\\' != File.separatorChar) {
            filenm = filename.replace('\\', File.separatorChar);
        }

        // deal with absolute files
        if (filenm.startsWith(File.separator) || (SystemUtils.IS_OS_WINDOWS && filenm.indexOf(":") > 0)) {
            File file = new File(filenm);

            try {
                file = file.getCanonicalFile();
            } catch (final IOException ioe) {
                // nop
            }

            return file;
        }
        // FIXME: I'm almost certain this // removal is unnecessary, as getAbsoluteFile() strips
        // them. However, I'm not sure about this UNC stuff. (JT)
        final char[] chars = filename.toCharArray();
        final StringBuilder sb = new StringBuilder();

        // remove duplicate file separators in succession - except
        // on win32 at start of filename as UNC filenames can
        // be \\AComputer\AShare\myfile.txt
        int start = 0;
        if ('\\' == File.separatorChar) {
            sb.append(filenm.charAt(0));
            start++;
        }

        for (int i = start; i < chars.length; i++) {
            final boolean doubleSeparator = File.separatorChar == chars[i] && File.separatorChar == chars[i - 1];

            if (!doubleSeparator) {
                sb.append(chars[i]);
            }
        }

        filenm = sb.toString();

        // must be relative
        File file = (new File(baseFile, filenm)).getAbsoluteFile();

        try {
            file = file.getCanonicalFile();
        } catch (final IOException ioe) {
            // nop
        }

        return file;
    }

    /**
     * <p>This method can calculate the relative path between two paths on a file system.</p>
     * <pre>
     * PathTool.getRelativeFilePath( null, null )                                   = ""
     * PathTool.getRelativeFilePath( null, "/usr/local/java/bin" )                  = ""
     * PathTool.getRelativeFilePath( "/usr/local", null )                           = ""
     * PathTool.getRelativeFilePath( "/usr/local", "/usr/local/java/bin" )          = "java/bin"
     * PathTool.getRelativeFilePath( "/usr/local", "/usr/local/java/bin/" )         = "java/bin"
     * PathTool.getRelativeFilePath( "/usr/local/java/bin", "/usr/local/" )         = "../.."
     * PathTool.getRelativeFilePath( "/usr/local/", "/usr/local/java/bin/java.sh" ) = "java/bin/java.sh"
     * PathTool.getRelativeFilePath( "/usr/local/java/bin/java.sh", "/usr/local/" ) = "../../.."
     * PathTool.getRelativeFilePath( "/usr/local/", "/bin" )                        = "../../bin"
     * PathTool.getRelativeFilePath( "/bin", "/usr/local/" )                        = "../usr/local"
     * </pre>
     * Note: On Windows based system, the <code>/</code> character should be replaced by <code>\</code> character.
     *
     * @param oldPath old path
     * @param newPath new path
     * @return a relative file path from <code>oldPath</code>.
     */
    public static String getRelativeFilePath(final String oldPath, final String newPath) {
        if (isEmpty(oldPath) || isEmpty(newPath)) {
            return "";
        }

        // normalise the path delimiters
        String fromPath = new File(oldPath).getPath();
        String toPath = new File(newPath).getPath();

        // strip any leading slashes if its a windows path
        if (toPath.matches("^\\[a-zA-Z]:")) {
            toPath = toPath.substring(1);
        }
        if (fromPath.matches("^\\[a-zA-Z]:")) {
            fromPath = fromPath.substring(1);
        }

        // lowercase windows drive letters.
        if (fromPath.startsWith(":", 1)) {
            fromPath = Character.toLowerCase(fromPath.charAt(0)) + fromPath.substring(1);
        }
        if (toPath.startsWith(":", 1)) {
            toPath = Character.toLowerCase(toPath.charAt(0)) + toPath.substring(1);
        }

        // check for the presence of windows drives. No relative way of
        // traversing from one to the other.
        if ((toPath.startsWith(":", 1) && fromPath.startsWith(":", 1))
                && (!toPath.substring(0, 1).equals(fromPath.substring(0, 1)))) {
            // they both have drive path element but they dont match, no
            // relative path
            return null;
        }

        if ((toPath.startsWith(":", 1) && !fromPath.startsWith(":", 1))
                || (!toPath.startsWith(":", 1) && fromPath.startsWith(":", 1))) {
            // one has a drive path element and the other doesnt, no relative
            // path.
            return null;
        }

        String resultPath = buildRelativePath(toPath, fromPath, File.separatorChar);

        if (newPath.endsWith(File.separator) && !resultPath.endsWith(File.separator)) {
            return resultPath + File.separator;
        }

        return resultPath;
    }

    private static String buildRelativePath(String toPath, String fromPath, final char separatorChar) {
        // use tokeniser to traverse paths and for lazy checking
        StringTokenizer toTokeniser = new StringTokenizer(toPath, String.valueOf(separatorChar));
        StringTokenizer fromTokeniser = new StringTokenizer(fromPath, String.valueOf(separatorChar));

        int count = 0;

        // walk along the to path looking for divergence from the from path
        while (toTokeniser.hasMoreTokens() && fromTokeniser.hasMoreTokens()) {
            if (separatorChar == '\\') {
                if (!fromTokeniser.nextToken().equalsIgnoreCase(toTokeniser.nextToken())) {
                    break;
                }
            } else {
                if (!fromTokeniser.nextToken().equals(toTokeniser.nextToken())) {
                    break;
                }
            }

            count++;
        }

        // reinitialise the tokenisers to count positions to retrieve the
        // gobbled token

        toTokeniser = new StringTokenizer(toPath, String.valueOf(separatorChar));
        fromTokeniser = new StringTokenizer(fromPath, String.valueOf(separatorChar));

        while (count-- > 0) {
            fromTokeniser.nextToken();
            toTokeniser.nextToken();
        }

        StringBuilder relativePath = new StringBuilder();

        // add back refs for the rest of from location.
        while (fromTokeniser.hasMoreTokens()) {
            fromTokeniser.nextToken();

            relativePath.append("..");

            if (fromTokeniser.hasMoreTokens()) {
                relativePath.append(separatorChar);
            }
        }

        if (relativePath.length() != 0 && toTokeniser.hasMoreTokens()) {
            relativePath.append(separatorChar);
        }

        // add fwd fills for whatevers left of newPath.
        while (toTokeniser.hasMoreTokens()) {
            relativePath.append(toTokeniser.nextToken());

            if (toTokeniser.hasMoreTokens()) {
                relativePath.append(separatorChar);
            }
        }
        return relativePath.toString();
    }

    static boolean isEmpty(final String string) {
        return string == null || string.trim().isEmpty();
    }

    /**
     * <b>If wrappers is null or empty, the file will be copy only if to.lastModified() &lt; from.lastModified() or if
     * overwrite is true</b>.
     *
     * @param from the file to copy
     * @param to the destination file
     * @param encoding the file output encoding (only if wrappers is not empty)
     * @param wrappers array of {@link FilterWrapper}
     * @throws IOException if an IO error occurs during copying or filtering
     */
    public static void copyFile(File from, File to, String encoding, FilterWrapper[] wrappers) throws IOException {
        setReadWritePermissions(to);

        if (wrappers == null || wrappers.length == 0) {
            try (OutputStream os = new CachingOutputStream(to.toPath())) {
                Files.copy(from.toPath(), os);
            }
        } else {
            Charset charset = charset(encoding);
            try (Reader fileReader = Files.newBufferedReader(from.toPath(), charset)) {
                Reader wrapped = fileReader;
                for (FilterWrapper wrapper : wrappers) {
                    wrapped = wrapper.getReader(wrapped);
                }
                try (Writer writer = new CachingWriter(to.toPath(), charset)) {
                    char[] buffer = new char[COPY_BUFFER_LENGTH];
                    int nRead;
                    while ((nRead = wrapped.read(buffer, 0, COPY_BUFFER_LENGTH)) >= 0) {
                        writer.write(buffer, 0, nRead);
                    }
                }
            }
        }

        copyFilePermissions(from, to);
    }

    /**
     * <b>If wrappers is null or empty, the file will be copy only if to.lastModified() &lt; from.lastModified() or if
     * overwrite is true</b>.
     *
     * @param from the file to copy
     * @param to the destination file
     * @param encoding the file output encoding (only if wrappers is not empty)
     * @param wrappers array of {@link FilterWrapper}
     * @param overwrite unused
     * @throws IOException if an IO error occurs during copying or filtering
     * @deprecated use {@link #copyFile(File, File, String, FilterWrapper[])} instead
     */
    @Deprecated
    public static void copyFile(File from, File to, String encoding, FilterWrapper[] wrappers, boolean overwrite)
            throws IOException {
        copyFile(from, to, encoding, wrappers);
    }

    /**
     * Attempts to copy file permissions from the source to the destination file.
     * Initially attempts to copy posix file permissions, assuming that the files are both on posix filesystems.
     * If the initial attempts fail then a second attempt using less precise permissions model.
     * Note that permissions are copied on a best-efforts basis,
     * failure to copy permissions will not result in an exception.
     *
     * @param source the file to copy permissions from.
     * @param destination the file to copy permissions to.
     */
    private static void copyFilePermissions(File source, File destination) throws IOException {
        try {
            // attempt to copy posix file permissions
            Files.setPosixFilePermissions(destination.toPath(), Files.getPosixFilePermissions(source.toPath()));
        } catch (NoSuchFileException nsfe) {
            // ignore if destination file or symlink does not exist
        } catch (UnsupportedOperationException e) {
            // fallback to setting partial permissions
            destination.setExecutable(source.canExecute());
            destination.setReadable(source.canRead());
            destination.setWritable(source.canWrite());
        }
    }

    @SuppressWarnings("ResultOfMethodCallIgnored")
    private static void setReadWritePermissions(File file) throws IOException {
        if (file.exists()) {
            try {
                Files.setPosixFilePermissions(
                        file.toPath(),
                        EnumSet.of(
                                PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
                                PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
                                PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_WRITE));
            } catch (UnsupportedOperationException e) {
                file.setReadable(true);
                file.setWritable(true);
            }
        }
    }

    private static Charset charset(String encoding) {
        if (encoding == null || encoding.isEmpty()) {
            return Charset.defaultCharset();
        } else {
            return Charset.forName(encoding);
        }
    }
}
