////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2020 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.math.BigInteger;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;

import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
import com.puppycrawl.tools.checkstyle.api.Configuration;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * This class maintains a persistent(on file-system) store of the files
 * that have checked ok(no validation events) and their associated
 * timestamp. It is used to optimize Checkstyle between few launches.
 * It is mostly useful for plugin and extensions of Checkstyle.
 * It uses a property file
 * for storage.  A hashcode of the Configuration is stored in the
 * cache file to ensure the cache is invalidated when the
 * configuration has changed.
 *
 */
public final class PropertyCacheFile {

    /**
     * The property key to use for storing the hashcode of the
     * configuration. To avoid name clashes with the files that are
     * checked the key is chosen in such a way that it cannot be a
     * valid file name.
     */
    public static final String CONFIG_HASH_KEY = "configuration*?";

    /**
     * The property prefix to use for storing the hashcode of an
     * external resource. To avoid name clashes with the files that are
     * checked the prefix is chosen in such a way that it cannot be a
     * valid file name and makes it clear it is a resource.
     */
    public static final String EXTERNAL_RESOURCE_KEY_PREFIX = "module-resource*?:";

    /** Size of default byte array for buffer. */
    private static final int BUFFER_SIZE = 1024;

    /** Default buffer for reading from streams. */
    private static final byte[] BUFFER = new byte[BUFFER_SIZE];

    /** Default number for base 16 encoding. */
    private static final int BASE_16 = 16;

    /** The details on files. **/
    private final Properties details = new Properties();

    /** Configuration object. **/
    private final Configuration config;

    /** File name of cache. **/
    private final String fileName;

    /** Generated configuration hash. **/
    private String configHash;

    /**
     * Creates a new {@code PropertyCacheFile} instance.
     *
     * @param config the current configuration, not null
     * @param fileName the cache file
     * @throws IllegalArgumentException when either arguments are null
     */
    public PropertyCacheFile(Configuration config, String fileName) {
        if (config == null) {
            throw new IllegalArgumentException("config can not be null");
        }
        if (fileName == null) {
            throw new IllegalArgumentException("fileName can not be null");
        }
        this.config = config;
        this.fileName = fileName;
    }

    /**
     * Load cached values from file.
     * @throws IOException when there is a problems with file read
     */
    public void load() throws IOException {
        // get the current config so if the file isn't found
        // the first time the hash will be added to output file
        configHash = getHashCodeBasedOnObjectContent(config);
        final File file = new File(fileName);
        if (file.exists()) {
            try (InputStream inStream = Files.newInputStream(file.toPath())) {
                details.load(inStream);
                final String cachedConfigHash = details.getProperty(CONFIG_HASH_KEY);
                if (!configHash.equals(cachedConfigHash)) {
                    // Detected configuration change - clear cache
                    reset();
                }
            }
        }
        else {
            // put the hash in the file if the file is going to be created
            reset();
        }
    }

    /**
     * Cleans up the object and updates the cache file.
     * @throws IOException  when there is a problems with file save
     */
    public void persist() throws IOException {
        final Path path = Paths.get(fileName);
        final Path directory = path.getParent();
        if (directory != null) {
            Files.createDirectories(directory);
        }
        try (OutputStream out = Files.newOutputStream(path)) {
            details.store(out, null);
        }
    }

    /**
     * Resets the cache to be empty except for the configuration hash.
     */
    public void reset() {
        details.clear();
        details.setProperty(CONFIG_HASH_KEY, configHash);
    }

    /**
     * Checks that file is in cache.
     * @param uncheckedFileName the file to check
     * @param timestamp the timestamp of the file to check
     * @return whether the specified file has already been checked ok
     */
    public boolean isInCache(String uncheckedFileName, long timestamp) {
        final String lastChecked = details.getProperty(uncheckedFileName);
        return Objects.equals(lastChecked, Long.toString(timestamp));
    }

    /**
     * Records that a file checked ok.
     * @param checkedFileName name of the file that checked ok
     * @param timestamp the timestamp of the file
     */
    public void put(String checkedFileName, long timestamp) {
        details.setProperty(checkedFileName, Long.toString(timestamp));
    }

    /**
     * Retrieves the hash of a specific file.
     * @param name The name of the file to retrieve.
     * @return The has of the file or {@code null}.
     */
    public String get(String name) {
        return details.getProperty(name);
    }

    /**
     * Removed a specific file from the cache.
     * @param checkedFileName The name of the file to remove.
     */
    public void remove(String checkedFileName) {
        details.remove(checkedFileName);
    }

    /**
     * Calculates the hashcode for the serializable object based on its content.
     * @param object serializable object.
     * @return the hashcode for serializable object.
     * @throws IllegalStateException when some unexpected happened.
     */
    private static String getHashCodeBasedOnObjectContent(Serializable object) {
        try {
            final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            // in-memory serialization of Configuration
            serialize(object, outputStream);
            // Instead of hexEncoding outputStream.toByteArray() directly we
            // use a message digest here to keep the length of the
            // hashcode reasonable

            final MessageDigest digest = MessageDigest.getInstance("SHA-1");
            digest.update(outputStream.toByteArray());

            return new BigInteger(1, digest.digest()).toString(BASE_16).toUpperCase(Locale.ROOT);
        }
        catch (final IOException | NoSuchAlgorithmException ex) {
            // rethrow as unchecked exception
            throw new IllegalStateException("Unable to calculate hashcode.", ex);
        }
    }

    /**
     * Serializes object to output stream.
     * @param object object to be serialized
     * @param outputStream serialization stream
     * @throws IOException if an error occurs
     */
    private static void serialize(Serializable object,
                                  OutputStream outputStream) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(outputStream)) {
            oos.writeObject(object);
        }
    }

    /**
     * Puts external resources in cache.
     * If at least one external resource changed, clears the cache.
     * @param locations locations of external resources.
     */
    public void putExternalResources(Set<String> locations) {
        final Set<ExternalResource> resources = loadExternalResources(locations);
        if (areExternalResourcesChanged(resources)) {
            reset();
            fillCacheWithExternalResources(resources);
        }
    }

    /**
     * Loads a set of {@link ExternalResource} based on their locations.
     * @param resourceLocations locations of external configuration resources.
     * @return a set of {@link ExternalResource}.
     */
    private static Set<ExternalResource> loadExternalResources(Set<String> resourceLocations) {
        final Set<ExternalResource> resources = new HashSet<>();
        for (String location : resourceLocations) {
            try {
                final byte[] content = loadExternalResource(location);
                final String contentHashSum = getHashCodeBasedOnObjectContent(content);
                resources.add(new ExternalResource(EXTERNAL_RESOURCE_KEY_PREFIX + location,
                        contentHashSum));
            }
            catch (CheckstyleException | IOException ex) {
                // if exception happened (configuration resource was not found, connection is not
                // available, resource is broken, etc), we need to calculate hash sum based on
                // exception object content in order to check whether problem is resolved later
                // and/or the configuration is changed.
                final String contentHashSum = getHashCodeBasedOnObjectContent(ex);
                resources.add(new ExternalResource(EXTERNAL_RESOURCE_KEY_PREFIX + location,
                        contentHashSum));
            }
        }
        return resources;
    }

    /**
     * Loads the content of external resource.
     * @param location external resource location.
     * @return array of bytes which represents the content of external resource in binary form.
     * @throws IOException if error while loading occurs.
     * @throws CheckstyleException if error while loading occurs.
     */
    private static byte[] loadExternalResource(String location)
            throws IOException, CheckstyleException {
        final URI uri = CommonUtil.getUriByFilename(location);

        try (InputStream is = uri.toURL().openStream()) {
            return toByteArray(is);
        }
    }

    /**
     * Reads all the contents of an input stream and returns it as a byte array.
     * @param stream The input stream to read from.
     * @return The resulting byte array of the stream.
     * @throws IOException if there is an error reading the input stream.
     */
    private static byte[] toByteArray(InputStream stream) throws IOException {
        final ByteArrayOutputStream content = new ByteArrayOutputStream();

        while (true) {
            final int size = stream.read(BUFFER);
            if (size == -1) {
                break;
            }

            content.write(BUFFER, 0, size);
        }

        return content.toByteArray();
    }

    /**
     * Checks whether the contents of external configuration resources were changed.
     * @param resources a set of {@link ExternalResource}.
     * @return true if the contents of external configuration resources were changed.
     */
    private boolean areExternalResourcesChanged(Set<ExternalResource> resources) {
        return resources.stream().anyMatch(resource -> {
            boolean changed = false;
            if (isResourceLocationInCache(resource.location)) {
                final String contentHashSum = resource.contentHashSum;
                final String cachedHashSum = details.getProperty(resource.location);
                if (!cachedHashSum.equals(contentHashSum)) {
                    changed = true;
                }
            }
            else {
                changed = true;
            }
            return changed;
        });
    }

    /**
     * Fills cache with a set of {@link ExternalResource}.
     * If external resource from the set is already in cache, it will be skipped.
     * @param externalResources a set of {@link ExternalResource}.
     */
    private void fillCacheWithExternalResources(Set<ExternalResource> externalResources) {
        externalResources
            .forEach(resource -> details.setProperty(resource.location, resource.contentHashSum));
    }

    /**
     * Checks whether resource location is in cache.
     * @param location resource location.
     * @return true if resource location is in cache.
     */
    private boolean isResourceLocationInCache(String location) {
        final String cachedHashSum = details.getProperty(location);
        return cachedHashSum != null;
    }

    /**
     * Class which represents external resource.
     */
    private static class ExternalResource {

        /** Location of resource. */
        private final String location;
        /** Hash sum which is calculated based on resource content. */
        private final String contentHashSum;

        /**
         * Creates an instance.
         * @param location resource location.
         * @param contentHashSum content hash sum.
         */
        /* package */ ExternalResource(String location, String contentHashSum) {
            this.location = location;
            this.contentHashSum = contentHashSum;
        }

    }

}
