package com.kixeye.chassis.bootstrap.configuration;

/*
 * #%L
 * Chassis Bootstrap
 * %%
 * Copyright (C) 2014 KIXEYE, Inc
 * %%
 * 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
 * 
 *      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.
 * #L%
 */

import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.kixeye.chassis.bootstrap.BootstrapException;
import com.kixeye.chassis.bootstrap.BootstrapException.ApplicationConfigurationNotFoundException;
import com.kixeye.chassis.bootstrap.aws.ServerInstanceContext;
import com.netflix.config.ConcurrentCompositeConfiguration;
import com.netflix.config.ConcurrentMapConfiguration;
import com.netflix.config.ConfigurationManager;

import org.apache.commons.configuration.AbstractConfiguration;
import org.apache.commons.configuration.Configuration;
import org.apache.commons.configuration.SystemConfiguration;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.SystemUtils;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.util.SystemPropertyUtils;

import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Builder class for creating the client application's configuration.  Configurations are hierarchical, and property lookup
 * evaluation will take place in the following order:
 * <p/>
 * <p/>
 * Apps using Zookeeper for config management:
 * system properties --> zookeeper properties --> client application properties (from @App.propertiesResourceLocation())
 * <p/>
 * Apps NOT using Zookeeper for config management:
 * system properties --> client application properties --> dependency module default properties
 * <p/>
 * The following exceptions to this are:
 * <p/>
 * <li>client application version can be defined in system properties or client application properties (@see APP_VERSION_KEY), and will be evaluated before zookeeper properties</li>
 * <li>zookeeper connection defaults can be in system properties or client application properties, and will be evaluated before default zookeeper properties</li>
 *
 * @author [email protected]
 */
public class ConfigurationBuilder implements Closeable {
    public static final String LOCAL_INSTANCE_ID = "local";
    public static final String UNKNOWN = "unknown";
    private static final String ARCHAIUS_DEPLOYMENT_ENVIRONMENT = "archaius.deployment.environment";

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationBuilder.class);
    private static final DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
    private final Reflections reflections;

    private boolean publishDefaults = false;
    private boolean scanModuleConfigurations = true;
    private boolean addSystemConfigs = true;
    private String applicationPropertiesPath;
    private String appName;
    private String appEnvironment;
    private String appVersion;
    private ServerInstanceContext serverInstanceContext;

    //properties from local client application file(s)
    private AbstractConfiguration applicationFileConfiguration;

    //instance properties loaded from configuration provider
    private AbstractConfiguration applicationConfiguration;

    //properties loaded from dependency modules
    private AbstractConfiguration moduleDefaultConfiguration;

    private ConfigurationProvider configurationProvider;
    private boolean configureArchaius = true;

    public ConfigurationBuilder(String appName, String appEnvironment, boolean addSystemConfigs, Reflections reflections) {
        Preconditions.checkArgument(StringUtils.isNotBlank(appName));
        Preconditions.checkArgument(StringUtils.isNotBlank(appEnvironment));
        Preconditions.checkNotNull(reflections);

        this.appName = appName;
        this.appEnvironment = appEnvironment;
        this.addSystemConfigs = addSystemConfigs;
        this.reflections = reflections;

        System.setProperty(BootstrapConfigKeys.APP_NAME_KEY.getPropertyName(), appName);
        System.setProperty(BootstrapConfigKeys.APP_ENVIRONMENT_KEY.getPropertyName(), appEnvironment);
    }

    //add properties to the base PropertiesConfiguration. If the base already contains a key that
    //is to be added, an exception is thrown.
    private static void join(Map<String, Object> base, Properties properties,
                             String propertyFile, String[] propertyFiles) {
        for (Object key : properties.keySet()) {
            if (base.get(key) != null) {
                BootstrapException.moduleKeysConflictFound(propertyFile, propertyFiles);
            }
            base.put((String) key, properties.get(key));
        }
    }

    /**
     * Build the Configuration
     *
     * @return the configuration
     */
    public AbstractConfiguration build() {
        initApplicationFileConfiguration();
        initAppVersion();
        initApplicationConfiguration();
        initModuleConfiguration();

        ConcurrentCompositeConfiguration finalConfiguration = new ConcurrentCompositeConfiguration();
        if (addSystemConfigs) {
            finalConfiguration.addConfiguration(new ConcurrentMapConfiguration(new SystemConfiguration()));
        }

        finalConfiguration.addProperty(BootstrapConfigKeys.APP_VERSION_KEY.getPropertyName(), appVersion);

        addServerInstanceProperties(finalConfiguration);

        if (applicationConfiguration == null) {
            LOGGER.warn("\n\n    ****** Default configuration being used ******\n    client application \"" + appName + "\" is being configured with modules defaults. Defaults should only be used in development environments.\n    In non-developement environments, a configuration provider should be used to configure the client application and it should define ALL required configuration properties.\n");
            finalConfiguration.addConfiguration(applicationFileConfiguration);
            finalConfiguration.addConfiguration(moduleDefaultConfiguration);
        } else {
            finalConfiguration.addConfiguration(applicationConfiguration);
            finalConfiguration.addConfiguration(applicationFileConfiguration);
        }

        finalConfiguration.setProperty(BootstrapConfigKeys.APP_VERSION_KEY.getPropertyName(), appVersion);

        configureArchaius(finalConfiguration);

        logConfiguration(finalConfiguration);

        return finalConfiguration;
    }

    private void configureArchaius(ConcurrentCompositeConfiguration finalConfiguration) {
        if (configureArchaius) {
            Properties systemProps = System.getProperties();
            if (systemProps.getProperty(ARCHAIUS_DEPLOYMENT_ENVIRONMENT) == null) {
                systemProps.setProperty(ARCHAIUS_DEPLOYMENT_ENVIRONMENT, appEnvironment);
            }
            ConfigurationManager.install(finalConfiguration);
        }
    }

    private void addServerInstanceProperties(Configuration configuration) {
        String instanceId = LOCAL_INSTANCE_ID;
        String region = UNKNOWN;
        String availabilityZone = UNKNOWN;
        String privateIp = "127.0.0.1";
        String publicIp = null;
        String instanceName = Joiner.on("-").join(appEnvironment, appName, appVersion);

        if (serverInstanceContext != null) {
            instanceId = serverInstanceContext.getInstanceId();
            region = serverInstanceContext.getRegion();
            availabilityZone = serverInstanceContext.getAvailabilityZone();
            privateIp = serverInstanceContext.getPrivateIp();
            publicIp = serverInstanceContext.getPublicIp();
        }

        configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_ID.getPropertyName(), instanceId);
        configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_REGION.getPropertyName(), region);
        configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_AVAILABILITY_ZONE.getPropertyName(), availabilityZone);
        configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_PRIVATE_IP.getPropertyName(), privateIp);
        if (publicIp != null) {
            configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_PUBLIC_IP.getPropertyName(), publicIp);
        }
        configuration.addProperty(BootstrapConfigKeys.AWS_INSTANCE_NAME.getPropertyName(), instanceName);
    }

    private void logConfiguration(ConcurrentCompositeConfiguration configuration) {
        new LoggerConfigurationWriter(LOGGER).write(configuration, null);
    }

    private void initModuleConfiguration() {
        if (!scanModuleConfigurations) {
            this.moduleDefaultConfiguration = new ConcurrentMapConfiguration();
            return;
        }
        HashMap<String, Object> base = new HashMap<>();
        Set<Class<?>> types = reflections
                .getTypesAnnotatedWith(PropertySource.class);
        for (Class<?> type : types) {
            PropertySource propertySource = type
                    .getAnnotation(PropertySource.class);
            String[] propertiesFiles = propertySource.value();
            for (String propertyFile : propertiesFiles) {
                Properties properties = new Properties();
                try (InputStream is = resourceLoader.getResource(SystemPropertyUtils.resolvePlaceholders(propertyFile))
                        .getInputStream()) {
                    properties.load(is);
                    LOGGER.debug("Initializing module properties from path " + propertyFile);
                } catch (Exception e) {
                    BootstrapException.resourceLoadingFailed(propertyFile, e);
                }
                join(base, properties, propertyFile, propertiesFiles);
            }
        }
        this.moduleDefaultConfiguration = new ConcurrentMapConfiguration(base);
    }

    //get the application configuration from the provider, possibly publishing module defaults
    private void initApplicationConfiguration() {
        if (configurationProvider == null) {
            return;
        }
        try {
            this.applicationConfiguration = configurationProvider.getApplicationConfiguration(appEnvironment, appName, appVersion, serverInstanceContext);
        } catch (ApplicationConfigurationNotFoundException e) {
            if (this.publishDefaults) {
                publishDefaults();
                initApplicationConfiguration();
            } else{
                throw e;
            }
        }
    }

    @SuppressWarnings("resource")
	private void publishDefaults() {
        ConfigurationBuilder defaultsBuilder = new ConfigurationBuilder(appName, appEnvironment, addSystemConfigs, reflections);
        defaultsBuilder.withAppVersion(appVersion);
        //a bit of a hack to get around Archaius's singleton requirement
        defaultsBuilder.configureArchaius = false;
        if (applicationPropertiesPath != null) {
            defaultsBuilder.withApplicationProperties(applicationPropertiesPath);
        }
        if (serverInstanceContext != null) {
            defaultsBuilder.withServerInstanceContext(serverInstanceContext);
        }
        defaultsBuilder.withScanModuleConfigurations(scanModuleConfigurations);
        configurationProvider.writeApplicationConfiguration(appEnvironment, appName, appVersion, defaultsBuilder.build(), false);
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    private void initApplicationFileConfiguration() {
        if (applicationPropertiesPath == null) {
            LOGGER.debug("No client application properties to configure. Skipping...");
            applicationFileConfiguration = new ConcurrentMapConfiguration();
            return;
        }

        this.applicationFileConfiguration = new ConcurrentCompositeConfiguration();

        String path = SystemPropertyUtils.resolvePlaceholders(applicationPropertiesPath);
        LOGGER.debug("Configuring client application properties from path " + applicationPropertiesPath);

        Map applicationProperties = new Properties();

        if (SystemUtils.IS_OS_WINDOWS) {
            if (path.startsWith("file://")) {
                if (!path.startsWith("file:///")) {
                    path = path.replaceFirst(Pattern.quote("file://"), "file:///");
                }
            }
        }

        try (InputStream is = resourceLoader.getResource(path).getInputStream()) {
            ((Properties) applicationProperties).load(is);
        } catch (Exception e) {
            BootstrapException.resourceLoadingFailed(path, applicationPropertiesPath, e);
        }

        Map environmentApplicationProperties = getEnvironmentSpecificProperties(path);
        if (environmentApplicationProperties != null) {
            ((ConcurrentCompositeConfiguration) this.applicationFileConfiguration).addConfiguration(new ConcurrentMapConfiguration(environmentApplicationProperties));
        }
        ((ConcurrentCompositeConfiguration) this.applicationFileConfiguration).addConfiguration(new ConcurrentMapConfiguration(applicationProperties));

        if (applicationFileConfiguration.containsKey(BootstrapConfigKeys.PUBLISH_DEFAULTS_KEY.getPropertyName())) {
            this.publishDefaults = applicationFileConfiguration.getBoolean(BootstrapConfigKeys.PUBLISH_DEFAULTS_KEY.getPropertyName());
        }
    }

    @SuppressWarnings("rawtypes")
    private Map getEnvironmentSpecificProperties(String path) {
        path = path.replace(".properties", "." + this.appEnvironment + ".properties");

        try (InputStream is = resourceLoader.getResource(path).getInputStream()) {
            Properties properties = new Properties();
            properties.load(is);
            LOGGER.debug("Configuration client application properties from path " + path);
            return properties;
        } catch (FileNotFoundException e) {
            LOGGER.debug("Attempted to load environment specific client application configuration at path " + path + " but didn't find one. skipping...");
            return null;
        } catch (Exception e) {
            BootstrapException.resourceLoadingFailed(path, e);
            return null;
        }
    }

    private void initAppVersion() {
        //if version not already set, pull from system props, then application file props
        if (appVersion == null) {
            appVersion = System.getProperty(BootstrapConfigKeys.APP_VERSION_KEY.getPropertyName(), null);
            if(appVersion == null && applicationFileConfiguration != null){
                appVersion = applicationFileConfiguration.getString(BootstrapConfigKeys.APP_VERSION_KEY.getPropertyName(), null);
            }
        }
        if (StringUtils.isBlank(appVersion)) {
            BootstrapException.missingApplicationVersion();
        }
        System.setProperty(BootstrapConfigKeys.APP_VERSION_KEY.getPropertyName(), appVersion);
    }

    public ConfigurationBuilder withServerInstanceContext(ServerInstanceContext serverInstanceContext) {
        this.serverInstanceContext = serverInstanceContext;
        return this;
    }

    public ConfigurationBuilder withApplicationProperties(String path) {
        this.applicationPropertiesPath = path;
        return this;
    }

    public ConfigurationBuilder withAppVersion(String appVersion) {
        this.appVersion = appVersion;
        return this;
    }

    public ConfigurationBuilder withScanModuleConfigurations(boolean scanModuleConfigurations) {
        this.scanModuleConfigurations = scanModuleConfigurations;
        return this;
    }

    public ConfigurationBuilder withConfigurationProvider(ConfigurationProvider configurationProvider) {
        this.configurationProvider = configurationProvider;
        return this;
    }

    @Override
    public void close() throws IOException {
        if (configurationProvider != null) {
            configurationProvider.close();
        }
    }
}