/*
 * 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 javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.maven.model.Resource;
import org.codehaus.plexus.util.DirectoryScanner;
import org.codehaus.plexus.util.Scanner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sonatype.plexus.build.incremental.BuildContext;

import static java.util.Objects.requireNonNull;

/**
 * @author Olivier Lamy
 */
@Singleton
@Named
public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering {
    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultMavenResourcesFiltering.class);

    private static final String[] EMPTY_STRING_ARRAY = {};

    private static final String[] DEFAULT_INCLUDES = {"**/**"};

    private final List<String> defaultNonFilteredFileExtensions;

    private final MavenFileFilter mavenFileFilter;

    private final BuildContext buildContext;

    @Inject
    public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, BuildContext buildContext) {
        this.mavenFileFilter = requireNonNull(mavenFileFilter);
        this.buildContext = requireNonNull(buildContext);
        this.defaultNonFilteredFileExtensions = new ArrayList<>(5);
        this.defaultNonFilteredFileExtensions.add("jpg");
        this.defaultNonFilteredFileExtensions.add("jpeg");
        this.defaultNonFilteredFileExtensions.add("gif");
        this.defaultNonFilteredFileExtensions.add("bmp");
        this.defaultNonFilteredFileExtensions.add("png");
        this.defaultNonFilteredFileExtensions.add("ico");
    }

    @Override
    public boolean filteredFileExtension(String fileName, List<String> userNonFilteredFileExtensions) {
        List<String> nonFilteredFileExtensions = new ArrayList<>(getDefaultNonFilteredFileExtensions());
        if (userNonFilteredFileExtensions != null) {
            nonFilteredFileExtensions.addAll(userNonFilteredFileExtensions);
        }
        String extension = getExtension(fileName);
        boolean filteredFileExtension = !nonFilteredFileExtensions.contains(extension);
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("file " + fileName + " has a" + (filteredFileExtension ? " " : " non ")
                    + "filtered file extension");
        }
        return filteredFileExtension;
    }

    private static String getExtension(String fileName) {
        String rawExt = FilenameUtils.getExtension(fileName);
        return rawExt == null ? null : rawExt.toLowerCase(Locale.ROOT);
    }

    @Override
    public List<String> getDefaultNonFilteredFileExtensions() {
        return this.defaultNonFilteredFileExtensions;
    }

    @Override
    public void filterResources(MavenResourcesExecution mavenResourcesExecution) throws MavenFilteringException {
        if (mavenResourcesExecution == null) {
            throw new MavenFilteringException("mavenResourcesExecution cannot be null");
        }

        if (mavenResourcesExecution.getResources() == null) {
            LOGGER.info("No resources configured skip copying/filtering");
            return;
        }

        if (mavenResourcesExecution.getOutputDirectory() == null) {
            throw new MavenFilteringException("outputDirectory cannot be null");
        }

        if (mavenResourcesExecution.isUseDefaultFilterWrappers()) {
            handleDefaultFilterWrappers(mavenResourcesExecution);
        }

        if (mavenResourcesExecution.getEncoding() == null
                || mavenResourcesExecution.getEncoding().length() < 1) {
            LOGGER.warn("Using platform encoding (" + System.getProperty("file.encoding")
                    + " actually) to copy filtered resources, i.e. build is platform dependent!");
        } else {
            LOGGER.debug("Using '" + mavenResourcesExecution.getEncoding() + "' encoding to copy filtered resources.");
        }

        if (mavenResourcesExecution.getPropertiesEncoding() == null
                || mavenResourcesExecution.getPropertiesEncoding().length() < 1) {
            LOGGER.debug("Using '" + mavenResourcesExecution.getEncoding()
                    + "' encoding to copy filtered properties files.");
        } else {
            LOGGER.debug("Using '" + mavenResourcesExecution.getPropertiesEncoding()
                    + "' encoding to copy filtered properties files.");
        }

        // Keep track of filtering being used and the properties files being filtered
        boolean isFilteringUsed = false;
        List<File> propertiesFiles = new ArrayList<>();

        for (Resource resource : mavenResourcesExecution.getResources()) {

            if (LOGGER.isDebugEnabled()) {
                String ls = System.lineSeparator();
                StringBuilder debugMessage = new StringBuilder("resource with targetPath ")
                        .append(resource.getTargetPath())
                        .append(ls);
                debugMessage
                        .append("directory ")
                        .append(resource.getDirectory())
                        .append(ls);

                // @formatter:off
                debugMessage
                        .append("excludes ")
                        .append(
                                resource.getExcludes() == null
                                        ? " empty "
                                        : resource.getExcludes().toString())
                        .append(ls);
                debugMessage
                        .append("includes ")
                        .append(
                                resource.getIncludes() == null
                                        ? " empty "
                                        : resource.getIncludes().toString());

                // @formatter:on
                LOGGER.debug(debugMessage.toString());
            }

            String targetPath = resource.getTargetPath();

            File resourceDirectory = (resource.getDirectory() == null) ? null : new File(resource.getDirectory());

            if (resourceDirectory != null && !resourceDirectory.isAbsolute()) {
                resourceDirectory =
                        new File(mavenResourcesExecution.getResourcesBaseDirectory(), resourceDirectory.getPath());
            }

            if (resourceDirectory == null || !resourceDirectory.exists()) {
                LOGGER.info("skip non existing resourceDirectory " + resourceDirectory);
                continue;
            }

            // this part is required in case the user specified "../something"
            // as destination
            // see MNG-1345
            File outputDirectory = mavenResourcesExecution.getOutputDirectory();
            if (!outputDirectory.mkdirs() && !outputDirectory.exists()) {
                throw new MavenFilteringException("Cannot create resource output directory: " + outputDirectory);
            }

            if (resource.isFiltering()) {
                isFilteringUsed = true;
            }

            boolean filtersFileChanged = buildContext.hasDelta(mavenResourcesExecution.getFileFilters());
            Path resourcePath = resourceDirectory.toPath();
            DirectoryScanner scanner = new DirectoryScanner() {
                @Override
                protected boolean isSelected(String name, File file) {
                    if (filtersFileChanged) {
                        // if the file filters file has a change we must assume everything is out of
                        // date
                        return true;
                    }
                    if (file.isFile()) {
                        try {
                            File targetFile = getTargetFile(file);
                            if (targetFile.isFile() && buildContext.isUptodate(targetFile, file)) {
                                return false;
                            }
                        } catch (MavenFilteringException e) {
                            // can't really do anything than to assume we must copy the file...
                        }
                    }
                    return true;
                }

                private File getTargetFile(File file) throws MavenFilteringException {
                    Path relativize = resourcePath.relativize(file.toPath());
                    return getDestinationFile(
                            outputDirectory, targetPath, relativize.toString(), mavenResourcesExecution);
                }
            };
            scanner.setBasedir(resourceDirectory);

            setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes());

            scanner.scan();

            if (mavenResourcesExecution.isIncludeEmptyDirs()) {
                try {
                    File targetDirectory = targetPath == null ? outputDirectory : new File(outputDirectory, targetPath);
                    copyDirectoryLayout(resourceDirectory, targetDirectory, scanner);
                } catch (IOException e) {
                    throw new MavenFilteringException("Cannot copy directory structure from "
                            + resourceDirectory.getPath() + " to " + outputDirectory.getPath());
                }
            }

            List<String> includedFiles = Arrays.asList(scanner.getIncludedFiles());

            try {
                Path basedir = mavenResourcesExecution
                        .getMavenProject()
                        .getBasedir()
                        .getAbsoluteFile()
                        .toPath();
                Path destination = getDestinationFile(outputDirectory, targetPath, "", mavenResourcesExecution)
                        .getAbsoluteFile()
                        .toPath();
                String origin = basedir.relativize(
                                resourceDirectory.getAbsoluteFile().toPath())
                        .toString();
                if (StringUtils.isEmpty(origin)) {
                    origin = ".";
                }
                LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "")
                        + " from "
                        + origin
                        + " to "
                        + basedir.relativize(destination));
            } catch (Exception e) {
                // be foolproof: if for ANY reason throws, do not abort, just fall back to old message
                LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "")
                        + (targetPath == null ? "" : " to " + targetPath));
            }

            for (String name : includedFiles) {

                LOGGER.debug("Copying file " + name);
                File source = new File(resourceDirectory, name);

                File destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution);

                if (mavenResourcesExecution.isFlatten() && destinationFile.exists()) {
                    if (mavenResourcesExecution.isOverwrite()) {
                        LOGGER.warn("existing file " + destinationFile.getName() + " will be overwritten by " + name);
                    } else {
                        throw new MavenFilteringException("existing file " + destinationFile.getName()
                                + " will be overwritten by " + name + " and overwrite was not set to true");
                    }
                }
                boolean filteredExt =
                        filteredFileExtension(source.getName(), mavenResourcesExecution.getNonFilteredFileExtensions());
                if (resource.isFiltering() && isPropertiesFile(source)) {
                    propertiesFiles.add(source);
                }

                // Determine which encoding to use when filtering this file
                String encoding = getEncoding(
                        source, mavenResourcesExecution.getEncoding(), mavenResourcesExecution.getPropertiesEncoding());
                LOGGER.debug("Using '" + encoding + "' encoding to copy filtered resource '" + source.getName() + "'.");
                mavenFileFilter.copyFile(
                        source,
                        destinationFile,
                        resource.isFiltering() && filteredExt,
                        mavenResourcesExecution.getFilterWrappers(),
                        encoding,
                        mavenResourcesExecution.isOverwrite());
            }

            // deal with deleted source files

            Scanner deleteScanner = buildContext.newDeleteScanner(resourceDirectory);

            setupScanner(resource, deleteScanner, mavenResourcesExecution.isAddDefaultExcludes());

            deleteScanner.scan();

            for (String name : deleteScanner.getIncludedFiles()) {
                File destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution);

                destinationFile.delete();

                buildContext.refresh(destinationFile);
            }
        }

        // Warn the user if all of the following requirements are met, to avoid those that are not affected
        // - the propertiesEncoding parameter has not been set
        // - properties is a filtered extension
        // - filtering is enabled for at least one resource
        // - there is at least one properties file in one of the resources that has filtering enabled
        if ((mavenResourcesExecution.getPropertiesEncoding() == null
                        || mavenResourcesExecution.getPropertiesEncoding().length() < 1)
                && !mavenResourcesExecution.getNonFilteredFileExtensions().contains("properties")
                && isFilteringUsed
                && propertiesFiles.size() > 0) {
            // @todo Sometime in the future we should change this to be a warning
            LOGGER.info("The encoding used to copy filtered properties files has not been set."
                    + " This means that the same encoding will be used to copy filtered properties files"
                    + " as when copying other filtered resources. This might not be what you want!"
                    + " Run your build with --debug to see which files might be affected."
                    + " Read more at "
                    + "https://maven.apache.org/plugins/maven-resources-plugin/"
                    + "examples/filtering-properties-files.html");

            StringBuilder affectedFiles = new StringBuilder();
            affectedFiles.append("Here is a list of the filtered properties files in your project that might be"
                    + " affected by encoding problems: ");
            for (File propertiesFile : propertiesFiles) {
                affectedFiles.append(System.lineSeparator()).append(" - ").append(propertiesFile.getPath());
            }
            LOGGER.debug(affectedFiles.toString());
        }
    }

    /**
     * Get the encoding to use when filtering the specified file. Properties files can be configured to use a different
     * encoding than regular files.
     *
     * @param file The file to check
     * @param encoding The encoding to use for regular files
     * @param propertiesEncoding The encoding to use for properties files
     * @return The encoding to use when filtering the specified file
     * @since 3.2.0
     */
    static String getEncoding(File file, String encoding, String propertiesEncoding) {
        if (isPropertiesFile(file)) {
            if (propertiesEncoding == null) {
                // Since propertiesEncoding is a new feature, not all plugins will have implemented support for it.
                // These plugins will have propertiesEncoding set to null.
                return encoding;
            } else {
                return propertiesEncoding;
            }
        } else {
            return encoding;
        }
    }

    /**
     * Determine whether a file is a properties file or not.
     *
     * @param file The file to check
     * @return <code>true</code> if the file name has an extension of "properties", otherwise <code>false</code>
     * @since 3.2.0
     */
    static boolean isPropertiesFile(File file) {
        return "properties".equals(getExtension(file.getName()));
    }

    private void handleDefaultFilterWrappers(MavenResourcesExecution mavenResourcesExecution)
            throws MavenFilteringException {
        List<FilterWrapper> filterWrappers = new ArrayList<>();
        if (mavenResourcesExecution.getFilterWrappers() != null) {
            filterWrappers.addAll(mavenResourcesExecution.getFilterWrappers());
        }
        filterWrappers.addAll(mavenFileFilter.getDefaultFilterWrappers(mavenResourcesExecution));
        mavenResourcesExecution.setFilterWrappers(filterWrappers);
    }

    private File getDestinationFile(
            File outputDirectory, String targetPath, String name, MavenResourcesExecution mavenResourcesExecution)
            throws MavenFilteringException {
        String destination;
        if (!mavenResourcesExecution.isFlatten()) {
            destination = name;
        } else {
            Path path = Paths.get(name);
            Path filePath = path.getFileName();
            destination = filePath.toString();
        }

        if (mavenResourcesExecution.isFilterFilenames()
                && mavenResourcesExecution.getFilterWrappers().size() > 0) {
            destination = filterFileName(destination, mavenResourcesExecution.getFilterWrappers());
        }

        if (targetPath != null) {
            destination = targetPath + "/" + destination;
        }

        File destinationFile = new File(destination);
        if (!destinationFile.isAbsolute()) {
            destinationFile = new File(outputDirectory, destination);
        }

        if (!destinationFile.getParentFile().exists()) {
            destinationFile.getParentFile().mkdirs();
        }
        return destinationFile;
    }

    private String[] setupScanner(Resource resource, Scanner scanner, boolean addDefaultExcludes) {
        String[] includes;
        if (resource.getIncludes() != null && !resource.getIncludes().isEmpty()) {
            includes = resource.getIncludes().toArray(EMPTY_STRING_ARRAY);
        } else {
            includes = DEFAULT_INCLUDES;
        }
        scanner.setIncludes(includes);

        String[] excludes = null;
        if (resource.getExcludes() != null && !resource.getExcludes().isEmpty()) {
            excludes = resource.getExcludes().toArray(EMPTY_STRING_ARRAY);
            scanner.setExcludes(excludes);
        }

        if (addDefaultExcludes) {
            scanner.addDefaultExcludes();
        }
        return includes;
    }

    private void copyDirectoryLayout(File sourceDirectory, File destinationDirectory, Scanner scanner)
            throws IOException {
        if (sourceDirectory == null) {
            throw new IOException("source directory can't be null.");
        }

        if (destinationDirectory == null) {
            throw new IOException("destination directory can't be null.");
        }

        if (sourceDirectory.equals(destinationDirectory)) {
            throw new IOException("source and destination are the same directory.");
        }

        if (!sourceDirectory.exists()) {
            throw new IOException("Source directory doesn't exist (" + sourceDirectory.getAbsolutePath() + ").");
        }

        for (String name : scanner.getIncludedDirectories()) {
            File source = new File(sourceDirectory, name);

            if (source.equals(sourceDirectory)) {
                continue;
            }

            File destination = new File(destinationDirectory, name);
            destination.mkdirs();
        }
    }

    private String getRelativeOutputDirectory(MavenResourcesExecution execution) {
        String relOutDir = execution.getOutputDirectory().getAbsolutePath();

        if (execution.getMavenProject() != null && execution.getMavenProject().getBasedir() != null) {
            String basedir = execution.getMavenProject().getBasedir().getAbsolutePath();
            relOutDir = FilteringUtils.getRelativeFilePath(basedir, relOutDir);
            if (relOutDir == null) {
                relOutDir = execution.getOutputDirectory().getPath();
            } else {
                relOutDir = relOutDir.replace('\\', '/');
            }
        }

        return relOutDir;
    }

    /*
     * Filter the name of a file using the same mechanism for filtering the content of the file.
     */
    private String filterFileName(String name, List<FilterWrapper> wrappers) throws MavenFilteringException {

        Reader reader = new StringReader(name);
        for (FilterWrapper wrapper : wrappers) {
            reader = wrapper.getReader(reader);
        }

        try (StringWriter writer = new StringWriter()) {
            IOUtils.copy(reader, writer);
            String filteredFilename = writer.toString();

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("renaming filename " + name + " to " + filteredFilename);
            }
            return filteredFilename;
        } catch (IOException e) {
            throw new MavenFilteringException("Failed filtering filename" + name, e);
        }
    }
}
