/*
 * Copyright 2013-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.springframework.cloud.bootstrap.encrypt;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.cloud.bootstrap.BootstrapApplicationListener;
import org.springframework.cloud.context.environment.EnvironmentChangeEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.env.CompositePropertySource;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.PropertySources;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import org.springframework.security.crypto.encrypt.TextEncryptor;

/**
 * Decrypt properties from the environment and insert them with high priority so they
 * override the encrypted values.
 *
 * @author Dave Syer
 * @author Tim Ysewyn
 */
public class EnvironmentDecryptApplicationInitializer implements
		ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {

	/**
	 * Name of the decrypted property source.
	 */
	public static final String DECRYPTED_PROPERTY_SOURCE_NAME = "decrypted";

	/**
	 * Name of the decrypted bootstrap property source.
	 */
	public static final String DECRYPTED_BOOTSTRAP_PROPERTY_SOURCE_NAME = "decryptedBootstrap";

	/**
	 * Prefix indicating an encrypted value.
	 */
	public static final String ENCRYPTED_PROPERTY_PREFIX = "{cipher}";

	private static final Pattern COLLECTION_PROPERTY = Pattern
			.compile("(\\S+)?\\[(\\d+)\\](\\.\\S+)?");

	private static Log logger = LogFactory
			.getLog(EnvironmentDecryptApplicationInitializer.class);

	private int order = Ordered.HIGHEST_PRECEDENCE + 15;

	private TextEncryptor encryptor;

	private boolean failOnError = true;

	/**
	 * Strategy to determine how to handle exceptions during decryption.
	 * @param failOnError the flag value (default true)
	 */
	public void setFailOnError(boolean failOnError) {
		this.failOnError = failOnError;
	}

	public EnvironmentDecryptApplicationInitializer(TextEncryptor encryptor) {
		this.encryptor = encryptor;
	}

	@Override
	public int getOrder() {
		return this.order;
	}

	public void setOrder(int order) {
		this.order = order;
	}

	@Override
	public void initialize(ConfigurableApplicationContext applicationContext) {
		ConfigurableEnvironment environment = applicationContext.getEnvironment();
		MutablePropertySources propertySources = environment.getPropertySources();

		Set<String> found = new LinkedHashSet<>();
		if (!propertySources.contains(DECRYPTED_BOOTSTRAP_PROPERTY_SOURCE_NAME)) {
			// No reason to decrypt bootstrap twice
			PropertySource<?> bootstrap = propertySources
					.get(BootstrapApplicationListener.BOOTSTRAP_PROPERTY_SOURCE_NAME);
			if (bootstrap != null) {
				Map<String, Object> map = decrypt(bootstrap);
				if (!map.isEmpty()) {
					found.addAll(map.keySet());
					insert(applicationContext, new SystemEnvironmentPropertySource(
							DECRYPTED_BOOTSTRAP_PROPERTY_SOURCE_NAME, map));
				}
			}
		}
		removeDecryptedProperties(applicationContext);
		Map<String, Object> map = decrypt(propertySources);
		if (!map.isEmpty()) {
			// We have some decrypted properties
			found.addAll(map.keySet());
			insert(applicationContext, new SystemEnvironmentPropertySource(
					DECRYPTED_PROPERTY_SOURCE_NAME, map));
		}
		if (!found.isEmpty()) {
			ApplicationContext parent = applicationContext.getParent();
			if (parent != null) {
				// The parent is actually the bootstrap context, and it is fully
				// initialized, so we can fire an EnvironmentChangeEvent there to rebind
				// @ConfigurationProperties, in case they were encrypted.
				parent.publishEvent(new EnvironmentChangeEvent(parent, found));
			}

		}
	}

	private void insert(ApplicationContext applicationContext,
			PropertySource<?> propertySource) {
		ApplicationContext parent = applicationContext;
		while (parent != null) {
			if (parent.getEnvironment() instanceof ConfigurableEnvironment) {
				ConfigurableEnvironment mutable = (ConfigurableEnvironment) parent
						.getEnvironment();
				insert(mutable.getPropertySources(), propertySource);
			}
			parent = parent.getParent();
		}
	}

	private void insert(MutablePropertySources propertySources,
			PropertySource<?> propertySource) {
		if (propertySources
				.contains(BootstrapApplicationListener.BOOTSTRAP_PROPERTY_SOURCE_NAME)) {
			if (DECRYPTED_BOOTSTRAP_PROPERTY_SOURCE_NAME
					.equals(propertySource.getName())) {
				propertySources.addBefore(
						BootstrapApplicationListener.BOOTSTRAP_PROPERTY_SOURCE_NAME,
						propertySource);
			}
			else {
				propertySources.addAfter(
						BootstrapApplicationListener.BOOTSTRAP_PROPERTY_SOURCE_NAME,
						propertySource);
			}
		}
		else {
			propertySources.addFirst(propertySource);
		}
	}

	private void removeDecryptedProperties(ApplicationContext applicationContext) {
		ApplicationContext parent = applicationContext;
		while (parent != null) {
			if (parent.getEnvironment() instanceof ConfigurableEnvironment) {
				((ConfigurableEnvironment) parent.getEnvironment()).getPropertySources()
						.remove(DECRYPTED_PROPERTY_SOURCE_NAME);
			}
			parent = parent.getParent();
		}
	}

	public Map<String, Object> decrypt(PropertySources propertySources) {
		Map<String, Object> properties = merge(propertySources);
		decrypt(properties);
		return properties;
	}

	private Map<String, Object> decrypt(PropertySource<?> source) {
		Map<String, Object> properties = merge(source);
		decrypt(properties);
		return properties;
	}

	private Map<String, Object> merge(PropertySources propertySources) {
		Map<String, Object> properties = new LinkedHashMap<>();
		List<PropertySource<?>> sources = new ArrayList<>();
		for (PropertySource<?> source : propertySources) {
			sources.add(0, source);
		}
		for (PropertySource<?> source : sources) {
			merge(source, properties);
		}
		return properties;
	}

	private Map<String, Object> merge(PropertySource<?> source) {
		Map<String, Object> properties = new LinkedHashMap<>();
		merge(source, properties);
		return properties;
	}

	private void merge(PropertySource<?> source, Map<String, Object> properties) {
		if (source instanceof CompositePropertySource) {

			List<PropertySource<?>> sources = new ArrayList<>(
					((CompositePropertySource) source).getPropertySources());
			Collections.reverse(sources);

			for (PropertySource<?> nested : sources) {
				merge(nested, properties);
			}

		}
		else if (source instanceof EnumerablePropertySource) {
			Map<String, Object> otherCollectionProperties = new LinkedHashMap<>();
			boolean sourceHasDecryptedCollection = false;

			EnumerablePropertySource<?> enumerable = (EnumerablePropertySource<?>) source;
			for (String key : enumerable.getPropertyNames()) {
				Object property = source.getProperty(key);
				if (property != null) {
					String value = property.toString();
					if (value.startsWith(ENCRYPTED_PROPERTY_PREFIX)) {
						properties.put(key, value);
						if (COLLECTION_PROPERTY.matcher(key).matches()) {
							sourceHasDecryptedCollection = true;
						}
					}
					else if (COLLECTION_PROPERTY.matcher(key).matches()) {
						// put non-encrypted properties so merging of index properties
						// happens correctly
						otherCollectionProperties.put(key, value);
					}
					else {
						// override previously encrypted with non-encrypted property
						properties.remove(key);
					}
				}
			}
			// copy all indexed properties even if not encrypted
			if (sourceHasDecryptedCollection && !otherCollectionProperties.isEmpty()) {
				properties.putAll(otherCollectionProperties);
			}

		}
	}

	private void decrypt(Map<String, Object> properties) {
		properties.replaceAll((key, value) -> {
			String valueString = value.toString();
			if (!valueString.startsWith(ENCRYPTED_PROPERTY_PREFIX)) {
				return value;
			}
			return decrypt(key, valueString);
		});
	}

	private String decrypt(String key, String original) {
		String value = original.substring(ENCRYPTED_PROPERTY_PREFIX.length());
		try {
			value = this.encryptor.decrypt(value);
			if (logger.isDebugEnabled()) {
				logger.debug("Decrypted: key=" + key);
			}
			return value;
		}
		catch (Exception e) {
			String message = "Cannot decrypt: key=" + key;
			if (logger.isDebugEnabled()) {
				logger.warn(message, e);
			}
			else {
				logger.warn(message);
			}
			if (this.failOnError) {
				throw new IllegalStateException(message, e);
			}
			return "";
		}
	}

}