/*
 * 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.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;

import org.slf4j.Logger;

import static org.apache.maven.shared.filtering.FilteringUtils.isEmpty;

/**
 * @author <a href="mailto:kenney@neonics.com">Kenney Westerhof</a>
 * @author William Ferguson
 */
public final class PropertyUtils {
    /**
     * Private empty constructor to prevent instantiation.
     */
    private PropertyUtils() {
        // prevent instantiation
    }

    /**
     * Reads a property file, resolving all internal variables, using the supplied base properties.
     * <p>
     * The properties are resolved iteratively, so if the value of property A refers to property B, then after
     * resolution the value of property B will contain the value of property B.
     * </p>
     *
     * @param propFile The property file to load.
     * @param baseProps Properties containing the initial values to substitute into the properties file.
     * @return Properties object containing the properties in the file with their values fully resolved.
     * @throws IOException if profile does not exist, or cannot be read.
     */
    public static Properties loadPropertyFile(File propFile, Properties baseProps) throws IOException {
        return loadPropertyFile(propFile, baseProps, null);
    }

    /**
     * Reads a property file, resolving all internal variables, using the supplied base properties.
     * <p>
     * The properties are resolved iteratively, so if the value of property A refers to property B, then after
     * resolution the value of property B will contain the value of property B.
     * </p>
     *
     * @param propFile The property file to load.
     * @param baseProps Properties containing the initial values to substitute into the properties file.
     * @param logger Logger instance
     * @return Properties object containing the properties in the file with their values fully resolved.
     * @throws IOException if profile does not exist, or cannot be read.
     *
     * @since 3.1.2
     */
    public static Properties loadPropertyFile(File propFile, Properties baseProps, Logger logger) throws IOException {
        if (!propFile.exists()) {
            throw new FileNotFoundException(propFile.toString());
        }

        final Properties fileProps = new Properties();

        try (InputStream inStream = Files.newInputStream(propFile.toPath())) {
            fileProps.load(inStream);
        }

        final Properties combinedProps = new Properties();
        combinedProps.putAll(baseProps == null ? new Properties() : baseProps);
        combinedProps.putAll(fileProps);

        // The algorithm iterates only over the fileProps which is all that is required to resolve
        // the properties defined within the file. This is slightly different to current, however
        // I suspect that this was the actual original intent.
        //
        // The difference is that #loadPropertyFile(File, boolean, boolean) also resolves System properties
        // whose values contain expressions. I believe this is unexpected and is not validated by the test cases,
        // as can be verified by replacing the implementation of #loadPropertyFile(File, boolean, boolean)
        // with the commented variant I have provided that reuses this method.

        for (Object o : fileProps.keySet()) {
            final String k = (String) o;
            final String propValue = getPropertyValue(k, combinedProps, logger);
            fileProps.setProperty(k, propValue);
        }

        return fileProps;
    }

    /**
     * Reads a property file, resolving all internal variables.
     *
     * @param propfile The property file to load
     * @param fail whether to throw an exception when the file cannot be loaded or to return null
     * @param useSystemProps whether to incorporate System.getProperties settings into the returned Properties object.
     * @return the loaded and fully resolved Properties object
     * @throws IOException if profile does not exist, or cannot be read.
     */
    public static Properties loadPropertyFile(File propfile, boolean fail, boolean useSystemProps) throws IOException {
        return loadPropertyFile(propfile, fail, useSystemProps, null);
    }

    /**
     * Reads a property file, resolving all internal variables.
     *
     * @param propfile The property file to load
     * @param fail whether to throw an exception when the file cannot be loaded or to return null
     * @param useSystemProps whether to incorporate System.getProperties settings into the returned Properties object.
     * @param logger Logger instance
     * @return the loaded and fully resolved Properties object
     * @throws IOException if profile does not exist, or cannot be read.
     *
     * @since 3.1.2
     */
    public static Properties loadPropertyFile(File propfile, boolean fail, boolean useSystemProps, Logger logger)
            throws IOException {

        final Properties baseProps = new Properties();

        if (useSystemProps) {
            baseProps.putAll(System.getProperties());
        }

        final Properties resolvedProps = new Properties();
        try {
            resolvedProps.putAll(loadPropertyFile(propfile, baseProps, logger));
        } catch (FileNotFoundException e) {
            if (fail) {
                throw new FileNotFoundException(propfile.toString());
            }
        }

        if (useSystemProps) {
            resolvedProps.putAll(baseProps);
        }

        return resolvedProps;
    }

    /**
     * Retrieves a property value, replacing values like ${token} using the Properties to look them up. It will leave
     * unresolved properties alone, trying for System properties, and implements reparsing (in the case that the value
     * of a property contains a key), and will not loop endlessly on a pair like test = ${test}.
     *
     * @param k
     * @param p
     * @param logger Logger instance
     * @return The filtered property value.
     */
    private static String getPropertyValue(String k, Properties p, Logger logger) {
        // This can also be done using InterpolationFilterReader,
        // but it requires reparsing the file over and over until
        // it doesn't change.

        // for cycle detection
        List<String> valueChain = new LinkedList<>();
        valueChain.add(k);

        String v = p.getProperty(k);
        String defaultValue = v;
        StringBuilder ret = new StringBuilder();
        int idx, idx2;

        while ((idx = v.indexOf("${")) >= 0) {
            // append prefix to result
            ret.append(v, 0, idx);

            // strip prefix from original
            v = v.substring(idx + 2);

            // if no matching } then bail
            idx2 = v.indexOf('}');
            if (idx2 < 0) {
                break;
            }

            // strip out the key and resolve it
            // resolve the key/value for the ${statement}
            String nk = v.substring(0, idx2);
            v = v.substring(idx2 + 1);
            String nv = p.getProperty(nk);

            if (valueChain.contains(nk)) {
                if (logger != null) {
                    logCircularDetection(valueChain, nk, logger);
                }
                return defaultValue;
            } else {
                valueChain.add(nk);

                // try global environment..
                if (nv == null && !isEmpty(nk)) {
                    nv = System.getProperty(nk);
                }

                // if the key cannot be resolved,
                // leave it alone ( and don't parse again )
                // else prefix the original string with the
                // resolved property ( so it can be parsed further )
                // taking recursion into account.
                if (nv == null || k.equals(nk)) {
                    ret.append("${").append(nk).append("}");
                } else {
                    v = nv + v.replace("${" + nk + "}", nv);
                }
            }
        }

        return ret + v;
    }

    /**
     * Logs the detected cycle in properties resolution
     * @param valueChain the sequence of properties resolved so far
     * @param nk the key the closes the cycle
     * @param logger Logger instance
     */
    private static void logCircularDetection(List<String> valueChain, String nk, Logger logger) {
        StringBuilder sb = new StringBuilder("Circular reference between properties detected: ");
        for (String key : valueChain) {
            sb.append(key).append(" => ");
        }
        sb.append(nk);
        logger.warn(sb.toString());
    }
}
