/*
 * Copyright 2019-2020 the original author or authors.
 *
 * Licensed 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
 *
 *     https://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.vividus.configuration;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.PropertiesFactoryBean;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.expression.ParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.util.PropertyPlaceholderHelper;

public final class ConfigurationResolver
{
    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationResolver.class);

    private static final String SYSTEM_PROPERTIES_PREFIX = "system.";
    private static final String VIVIDUS_SYSTEM_PROPERTY_FAMILY = "vividus.";
    private static final String CONFIGURATION_PROPERTY_FAMILY = "configuration.";
    private static final String ROOT = "";
    private static final String PROFILE = "profile";
    private static final String PROFILES = "profiles";
    private static final String ENVIRONMENT = "environment";
    private static final String ENVIRONMENTS = "environments";
    private static final String SUITE = "suite";

    private static final String PLACEHOLDER_PREFIX = "${";
    private static final String PLACEHOLDER_SUFFIX = "}";
    private static final String PLACEHOLDER_VALUE_SEPARATOR = "=";

    private static ConfigurationResolver instance;

    private final Properties properties;

    private ConfigurationResolver(Properties properties)
    {
        this.properties = properties;
    }

    public static ConfigurationResolver getInstance() throws IOException
    {
        if (instance != null)
        {
            return instance;
        }

        PropertiesLoader propertiesLoader = new PropertiesLoader(BeanFactory.getResourcePatternResolver());

        Properties configurationProperties = propertiesLoader.loadFromSingleResource("configuration.properties");
        Properties overridingProperties = propertiesLoader.loadFromOptionalResource("overriding.properties");

        Properties properties = new Properties();
        properties.putAll(configurationProperties);
        properties.putAll(propertiesLoader.loadFromResourceTreeRecursively("defaults"));

        Multimap<String, String> configuration = assembleConfiguration(configurationProperties, overridingProperties);
        for (Entry<String, String> configurationEntry : configuration.entries())
        {
            properties.putAll(propertiesLoader.loadFromResourceTreeRecursively(configurationEntry.getKey(),
                    configurationEntry.getValue()));
        }

        properties.putAll(propertiesLoader.loadFromResourceTreeRecursively(ROOT));

        Properties deprecatedProperties = propertiesLoader.loadFromResourceTreeRecursively("deprecated");
        DeprecatedPropertiesHandler deprecatedPropertiesHandler = new DeprecatedPropertiesHandler(
                deprecatedProperties, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX);
        deprecatedPropertiesHandler.replaceDeprecated(properties);

        Properties overridingAndSystemProperties = new Properties();
        overridingAndSystemProperties.putAll(overridingProperties);
        overridingAndSystemProperties.putAll(System.getenv());
        overridingAndSystemProperties.putAll(loadFilteredSystemProperties());

        deprecatedPropertiesHandler.replaceDeprecated(overridingAndSystemProperties, properties);

        properties.putAll(overridingAndSystemProperties);

        resolveSpelExpressions(properties, true);

        PropertyPlaceholderHelper propertyPlaceholderHelper = createPropertyPlaceholderHelper(false);

        for (Entry<Object, Object> entry : properties.entrySet())
        {
            String key = (String) entry.getKey();
            String value = (String) entry.getValue();
            deprecatedPropertiesHandler.warnIfDeprecated(key, value);
            entry.setValue(propertyPlaceholderHelper.replacePlaceholders(value, properties::getProperty));
        }
        deprecatedPropertiesHandler.removeDeprecated(properties);
        resolveSpelExpressions(properties, false);
        processSystemProperties(properties);

        instance = new ConfigurationResolver(properties);
        return instance;
    }

    private static PropertyPlaceholderHelper createPropertyPlaceholderHelper(boolean ignoreUnresolvablePlaceholders)
    {
        return new PropertyPlaceholderHelper(PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX, PLACEHOLDER_VALUE_SEPARATOR,
                ignoreUnresolvablePlaceholders);
    }

    private static Multimap<String, String> assembleConfiguration(Properties configurationProperties,
            Properties overridingProperties)
    {
        String profiles = getCompetingConfigurationPropertyValue(configurationProperties, overridingProperties,
                Pair.of(PROFILE, PROFILES));
        String environments = getConfigurationPropertyValue(configurationProperties, overridingProperties,
                ENVIRONMENTS);
        String suite = getConfigurationPropertyValue(configurationProperties, overridingProperties, SUITE);

        Properties mergedProperties = new Properties();
        mergedProperties.putAll(configurationProperties);
        mergedProperties.putAll(overridingProperties);
        PropertyPlaceholderHelper propertyPlaceholderHelper = createPropertyPlaceholderHelper(true);

        profiles = propertyPlaceholderHelper.replacePlaceholders(profiles, mergedProperties::getProperty);
        environments = propertyPlaceholderHelper.replacePlaceholders(environments, mergedProperties::getProperty);
        suite = propertyPlaceholderHelper.replacePlaceholders(suite, mergedProperties::getProperty);

        Multimap<String, String> configuration = LinkedHashMultimap.create();
        configuration.putAll(PROFILE, asPaths(profiles));
        configuration.putAll(ENVIRONMENT, asPaths(environments));
        configuration.put(SUITE, suite);
        return configuration;
    }

    private static String getCompetingConfigurationPropertyValue(Properties configurationProperties,
            Properties overridingProperties, Pair<String, String> competingKeys)
    {
        return Stream.of(competingKeys.getLeft(), competingKeys.getRight())
                .map(k -> Map.entry(k,
                        getConfigurationPropertyValue(configurationProperties, overridingProperties, k, false)))
                .filter(e -> e.getValue().isPresent())
                .collect(Collectors.collectingAndThen(Collectors.toList(), props ->
                {
                    int size = props.size();
                    if (size == 1)
                    {
                        return props.get(0).getValue().get();
                    }
                    String errorMessage = size == 0 ? "Either '%s' or '%s' configuration property must be set"
                            : "Exactly one configuration property: '%s' or '%s' must be set";
                    throw new IllegalStateException(
                            String.format(errorMessage, competingKeys.getLeft(), competingKeys.getRight()));
                }));
    }

    private static List<String> asPaths(String value)
    {
        // First configuration paths in the sequence have high priority then next ones
        return Stream.of(StringUtils.split(value, ','))
                .collect(Collectors.collectingAndThen(Collectors.toList(), Lists::reverse));
    }

    private static Map<String, String> loadFilteredSystemProperties()
    {
        Properties systemProperties = System.getProperties();
        return systemProperties.stringPropertyNames().stream()
                .filter(p -> p.startsWith(VIVIDUS_SYSTEM_PROPERTY_FAMILY))
                .filter(p -> !p.startsWith(VIVIDUS_SYSTEM_PROPERTY_FAMILY + CONFIGURATION_PROPERTY_FAMILY))
                .collect(Collectors.toMap(
                    p -> StringUtils.removeStart(p, VIVIDUS_SYSTEM_PROPERTY_FAMILY), systemProperties::getProperty));
    }

    private static String getConfigurationPropertyValue(Properties configurationProperties,
            Properties overridingProperties, String key)
    {
        return getConfigurationPropertyValue(configurationProperties, overridingProperties, key, true).get();
    }

    private static Optional<String> getConfigurationPropertyValue(Properties configurationProperties,
            Properties overridingProperties, String key, boolean failOnAbsence)
    {
        String propertyName = CONFIGURATION_PROPERTY_FAMILY + key;
        String value = System.getProperty(VIVIDUS_SYSTEM_PROPERTY_FAMILY + propertyName,
                System.getProperty(VIVIDUS_SYSTEM_PROPERTY_FAMILY + key,
                        System.getProperty(propertyName, System.getProperty(key))));
        if (value == null)
        {
            value = overridingProperties.getProperty(propertyName);
            if (value == null)
            {
                value = configurationProperties.getProperty(propertyName);
                if (value == null)
                {
                    if (failOnAbsence)
                    {
                        throw new IllegalStateException(key + " is not set");
                    }
                    return Optional.empty();
                }
            }
        }
        else
        {
            overridingProperties.put(propertyName, value);
        }
        return Optional.of(value);
    }

    private static void resolveSpelExpressions(Properties properties, boolean ignoreValuesWithPropertyPlaceholders)
    {
        Optional<Set<String>> propertyPlaceholders = ignoreValuesWithPropertyPlaceholders
                ? Optional.of(properties.stringPropertyNames().stream()
                        .map(n -> PLACEHOLDER_PREFIX + n + PLACEHOLDER_SUFFIX)
                        .collect(Collectors.toSet()))
                : Optional.empty();

        SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
        for (Entry<Object, Object> entry : properties.entrySet())
        {
            String value = (String) entry.getValue();
            if (propertyPlaceholders.stream().flatMap(Set::stream).noneMatch(value::contains))
            {
                try
                {
                    entry.setValue(
                            spelExpressionParser.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION).getValue());
                }
                catch (Exception e)
                {
                    throw new IllegalStateException("Exception during evaluation of expression " + value
                            + " for property '" + entry.getKey() + "'", e);
                }
            }
        }
    }

    private static void processSystemProperties(Properties properties)
    {
        Iterator<Entry<Object, Object>> iterator = properties.entrySet().iterator();
        while (iterator.hasNext())
        {
            Entry<Object, Object> entry = iterator.next();
            String key = (String) entry.getKey();
            if (key.startsWith(SYSTEM_PROPERTIES_PREFIX))
            {
                System.setProperty(StringUtils.removeStart(key, SYSTEM_PROPERTIES_PREFIX), (String) entry.getValue());
                iterator.remove();
            }
        }
    }

    public static void reset()
    {
        instance = null;
    }

    public Properties getProperties()
    {
        isReset();
        return (Properties) properties.clone();
    }

    private static void isReset()
    {
        if (instance == null)
        {
            throw new IllegalStateException("ConfigurationResolver has not been initialized after the reset");
        }
    }

    private static final class PropertiesLoader
    {
        private static final String ROOT_LOCATION = "classpath*:/properties/";
        private static final String DELIMITER = "/";

        private final ResourcePatternResolver resourcePatternResolver;

        PropertiesLoader(ResourcePatternResolver resourcePatternResolver)
        {
            this.resourcePatternResolver = resourcePatternResolver;
        }

        Properties loadFromSingleResource(String resourceName) throws IOException
        {
            String location = ROOT_LOCATION + resourceName;
            Resource[] resources = resourcePatternResolver.getResources(location);
            int resourcesLength = resources.length;
            if (resourcesLength == 0)
            {
                return new Properties();
            }
            if (resourcesLength > 1)
            {
                throw new IllegalStateException(
                        "Exactly one resource is expected: " + location + ", but found: " + resourcesLength);
            }
            return loadProperties(resources[0]);
        }

        Properties loadFromOptionalResource(String resourceName) throws IOException
        {
            Resource resource = resourcePatternResolver.getResource("classpath:/" + resourceName);
            return resource.exists() ? loadProperties(resource) : new Properties();
        }

        Properties loadFromResourceTreeRecursively(String... resourcePathParts) throws IOException
        {
            String resourcePath = String.join(DELIMITER, resourcePathParts);
            List<Resource> propertyResources = collectResourcesRecursively(resourcePatternResolver, resourcePath);
            LOGGER.info("Loading properties from /{}", resourcePath);
            Properties loadedProperties = loadProperties(propertyResources.toArray(new Resource[0]));
            loadedProperties.forEach((key, value) -> LOGGER.debug("{}=={}", key, value));
            return loadedProperties;
        }

        private static List<Resource> collectResourcesRecursively(ResourcePatternResolver resourcePatternResolver,
                String resourcePath) throws IOException
        {
            List<Resource> propertyResources = new LinkedList<>();
            StringBuilder path = new StringBuilder(ROOT_LOCATION);
            String[] locationParts = resourcePath.isEmpty() ? new String[] { resourcePath }
                    : StringUtils.split(resourcePath, DELIMITER);
            for (int i = 0; i < locationParts.length; i++)
            {
                boolean deepestLevel = i + 1 == locationParts.length;
                String locationPart = locationParts[i];
                path.append(locationPart);
                if (!locationPart.isEmpty())
                {
                    path.append(DELIMITER);
                }
                String resourceLocation = path.toString() + "*.properties";
                Resource[] resources = resourcePatternResolver.getResources(resourceLocation);
                if (deepestLevel && resources.length == 0)
                {
                    throw new IllegalStateException(
                            "No files with properties were found at location with pattern: " + resourceLocation);
                }
                propertyResources.addAll(Stream.of(resources).filter(Resource::exists).collect(Collectors.toList()));
            }
            return propertyResources;
        }

        private static Properties loadProperties(Resource... propertyResources) throws IOException
        {
            PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean();
            propertiesFactoryBean.setFileEncoding(StandardCharsets.UTF_8.name());
            propertiesFactoryBean.setLocations(propertyResources);
            propertiesFactoryBean.setSingleton(false);
            return propertiesFactoryBean.getObject();
        }
    }
}