/*
 * Copyright 2017-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.vault.repository.convert;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;

import org.springframework.core.CollectionFactory;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.data.convert.EntityInstantiator;
import org.springframework.data.mapping.MappingException;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.mapping.PreferredConstructor.Parameter;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.model.ConvertingPropertyAccessor;
import org.springframework.data.mapping.model.ParameterValueProvider;
import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider;
import org.springframework.data.mapping.model.PropertyValueProvider;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.vault.repository.mapping.VaultPersistentEntity;
import org.springframework.vault.repository.mapping.VaultPersistentProperty;

/**
 * {@link VaultConverter} that uses a {@link MappingContext} to do sophisticated mapping
 * of domain objects to {@link SecretDocument}. This converter converts between Map-typed
 * representations and domain objects without use of a JSON library.
 * {@link SecretDocument} is the input to JSON mapping to exchange secrets with Vault.
 *
 * @author Mark Paluch
 * @since 2.0
 */
public class MappingVaultConverter extends AbstractVaultConverter {

	private final MappingContext<? extends VaultPersistentEntity<?>, VaultPersistentProperty> mappingContext;

	private VaultTypeMapper typeMapper;

	public MappingVaultConverter(
			MappingContext<? extends VaultPersistentEntity<?>, VaultPersistentProperty> mappingContext) {

		super(new DefaultConversionService());

		Assert.notNull(mappingContext, "MappingContext must not be null");

		this.mappingContext = mappingContext;
		this.typeMapper = new DefaultVaultTypeMapper(DefaultVaultTypeMapper.DEFAULT_TYPE_KEY, mappingContext);
	}

	/**
	 * Configures the {@link VaultTypeMapper} to be used to add type information to
	 * {@link SecretDocument}s created by the converter and how to lookup type information
	 * from {@link SecretDocument}s when reading them. Uses a
	 * {@link DefaultVaultTypeMapper} by default. Setting this to {@literal null} will
	 * reset the {@link org.springframework.data.convert.TypeMapper} to the default one.
	 * @param typeMapper the typeMapper to set, must not be {@literal null}.
	 */
	public void setTypeMapper(VaultTypeMapper typeMapper) {

		Assert.notNull(typeMapper, "VaultTypeMapper must not be null");
		this.typeMapper = typeMapper;
	}

	@Override
	public MappingContext<? extends VaultPersistentEntity<?>, VaultPersistentProperty> getMappingContext() {
		return this.mappingContext;
	}

	@Override
	public <S> S read(Class<S> type, SecretDocument source) {
		return read(ClassTypeInformation.from(type), source);
	}

	@SuppressWarnings("unchecked")
	private <S> S read(TypeInformation<S> type, Object source) {

		SecretDocument secretDocument = getSecretDocument(source);

		TypeInformation<? extends S> typeToUse = secretDocument != null
				? this.typeMapper.readType(secretDocument.getBody(), type)
				: (TypeInformation) ClassTypeInformation.OBJECT;
		Class<? extends S> rawType = typeToUse.getType();

		if (this.conversions.hasCustomReadTarget(source.getClass(), rawType)) {
			return this.conversionService.convert(source, rawType);
		}

		if (SecretDocument.class.isAssignableFrom(rawType)) {
			return (S) source;
		}

		if (Map.class.isAssignableFrom(rawType) && secretDocument != null) {
			return (S) secretDocument.getBody();
		}

		if (typeToUse.isMap() && secretDocument != null) {
			return (S) readMap(typeToUse, secretDocument.getBody());
		}

		if (typeToUse.equals(ClassTypeInformation.OBJECT)) {
			return (S) source;
		}

		return read((VaultPersistentEntity<S>) this.mappingContext.getRequiredPersistentEntity(typeToUse),
				secretDocument);
	}

	@Nullable
	@SuppressWarnings("unchecked")
	private SecretDocument getSecretDocument(Object source) {

		SecretDocument secretDocument = null;
		if (source instanceof Map) {
			secretDocument = new SecretDocument((Map) source);
		}
		else if (source instanceof SecretDocument) {
			secretDocument = (SecretDocument) source;
		}
		return secretDocument;
	}

	private ParameterValueProvider<VaultPersistentProperty> getParameterProvider(VaultPersistentEntity<?> entity,
			SecretDocument source) {

		VaultPropertyValueProvider provider = new VaultPropertyValueProvider(source);

		PersistentEntityParameterValueProvider<VaultPersistentProperty> parameterProvider = new PersistentEntityParameterValueProvider<>(
				entity, provider, source);

		return new ParameterValueProvider<VaultPersistentProperty>() {

			@Nullable
			@Override
			public <T> T getParameterValue(Parameter<T, VaultPersistentProperty> parameter) {

				Object value = parameterProvider.getParameterValue(parameter);
				return value != null ? readValue(value, parameter.getType()) : null;
			}
		};
	}

	private <S> S read(VaultPersistentEntity<S> entity, SecretDocument source) {

		ParameterValueProvider<VaultPersistentProperty> provider = getParameterProvider(entity, source);
		EntityInstantiator instantiator = this.instantiators.getInstantiatorFor(entity);
		S instance = instantiator.createInstance(entity, provider);

		PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor(entity.getPropertyAccessor(instance),
				this.conversionService);

		VaultPersistentProperty idProperty = entity.getIdProperty();
		SecretDocumentAccessor documentAccessor = new SecretDocumentAccessor(source);

		// make sure id property is set before all other properties
		Object idValue;

		if (entity.requiresPropertyPopulation()) {
			if (idProperty != null && !entity.isConstructorArgument(idProperty)
					&& documentAccessor.hasValue(idProperty)) {

				idValue = readIdValue(idProperty, documentAccessor);
				accessor.setProperty(idProperty, idValue);
			}

			VaultPropertyValueProvider valueProvider = new VaultPropertyValueProvider(documentAccessor);

			readProperties(entity, accessor, idProperty, documentAccessor, valueProvider);
		}

		return instance;
	}

	@Nullable
	private Object readIdValue(VaultPersistentProperty idProperty, SecretDocumentAccessor documentAccessor) {

		Object resolvedValue = documentAccessor.get(idProperty);

		return resolvedValue != null ? readValue(resolvedValue, idProperty.getTypeInformation()) : null;
	}

	private void readProperties(VaultPersistentEntity<?> entity, PersistentPropertyAccessor accessor,
			@Nullable VaultPersistentProperty idProperty, SecretDocumentAccessor documentAccessor,
			VaultPropertyValueProvider valueProvider) {

		for (VaultPersistentProperty prop : entity) {

			// we skip the id property since it was already set
			if (idProperty != null && idProperty.equals(prop)) {
				continue;
			}

			if (entity.isConstructorArgument(prop) || !documentAccessor.hasValue(prop)) {
				continue;
			}

			accessor.setProperty(prop, valueProvider.getPropertyValue(prop));
		}
	}

	@Nullable
	@SuppressWarnings("unchecked")
	private <T> T readValue(Object value, TypeInformation<?> type) {

		Class<?> rawType = type.getType();

		if (this.conversions.hasCustomReadTarget(value.getClass(), rawType)) {
			return (T) this.conversionService.convert(value, rawType);
		}
		else if (value instanceof List) {
			return (T) readCollectionOrArray(type, (List) value);
		}
		else if (value instanceof Map) {
			return (T) read(type, (Map) value);
		}
		else {
			return (T) getPotentiallyConvertedSimpleRead(value, rawType);
		}
	}

	/**
	 * Reads the given {@link List} into a collection of the given {@link TypeInformation}
	 * .
	 * @param targetType must not be {@literal null}.
	 * @param sourceValue must not be {@literal null}.
	 * @return the converted {@link Collection} or array, will never be {@literal null}.
	 */
	@Nullable
	@SuppressWarnings({ "rawtypes", "unchecked" })
	private Object readCollectionOrArray(TypeInformation<?> targetType, List sourceValue) {

		Assert.notNull(targetType, "Target type must not be null");

		Class<?> collectionType = targetType.getType();

		TypeInformation<?> componentType = targetType.getComponentType() != null ? targetType.getComponentType()
				: ClassTypeInformation.OBJECT;
		Class<?> rawComponentType = componentType.getType();

		collectionType = Collection.class.isAssignableFrom(collectionType) ? collectionType : List.class;
		Collection<Object> items = targetType.getType().isArray() ? new ArrayList<>(sourceValue.size())
				: CollectionFactory.createCollection(collectionType, rawComponentType, sourceValue.size());

		if (sourceValue.isEmpty()) {
			return getPotentiallyConvertedSimpleRead(items, collectionType);
		}

		for (Object obj : sourceValue) {

			if (obj instanceof Map) {
				items.add(read(componentType, (Map) obj));
			}
			else if (obj instanceof List) {
				items.add(readCollectionOrArray(ClassTypeInformation.OBJECT, (List) obj));
			}
			else {
				items.add(getPotentiallyConvertedSimpleRead(obj, rawComponentType));
			}
		}

		return getPotentiallyConvertedSimpleRead(items, targetType.getType());
	}

	/**
	 * Reads the given {@link Map} into a {@link Map}. will recursively resolve nested
	 * {@link Map}s as well.
	 * @param type the {@link Map} {@link TypeInformation} to be used to unmarshal this
	 * {@link Map}.
	 * @param sourceMap must not be {@literal null}
	 * @return the converted {@link Map}.
	 */
	protected Map<Object, Object> readMap(TypeInformation<?> type, Map<String, Object> sourceMap) {

		Assert.notNull(sourceMap, "Source map must not be null");

		Class<?> mapType = this.typeMapper.readType(sourceMap, type).getType();

		TypeInformation<?> keyType = type.getComponentType();
		TypeInformation<?> valueType = type.getMapValueType();

		Class<?> rawKeyType = keyType != null ? keyType.getType() : null;
		Class<?> rawValueType = valueType != null ? valueType.getType() : null;

		Map<Object, Object> map = CollectionFactory.createMap(mapType, rawKeyType, sourceMap.keySet().size());

		for (Entry<String, Object> entry : sourceMap.entrySet()) {

			if (this.typeMapper.isTypeKey(entry.getKey())) {
				continue;
			}

			Object key = entry.getKey();

			if (rawKeyType != null && !rawKeyType.isAssignableFrom(key.getClass())) {
				key = this.conversionService.convert(key, rawKeyType);
			}

			Object value = entry.getValue();
			TypeInformation<?> defaultedValueType = valueType != null ? valueType : ClassTypeInformation.OBJECT;

			if (value instanceof Map) {
				map.put(key, read(defaultedValueType, (Map) value));
			}
			else if (value instanceof List) {
				map.put(key,
						readCollectionOrArray(valueType != null ? valueType : ClassTypeInformation.LIST, (List) value));
			}
			else {
				map.put(key, getPotentiallyConvertedSimpleRead(value, rawValueType));
			}
		}

		return map;
	}

	/**
	 * Checks whether we have a custom conversion for the given simple object. Converts
	 * the given value if so, applies {@link Enum} handling or returns the value as is.
	 * @param value
	 * @param target must not be {@literal null}.
	 * @return
	 */
	@Nullable
	@SuppressWarnings({ "rawtypes", "unchecked" })
	private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

		if (value == null || target == null || target.isAssignableFrom(value.getClass())) {
			return value;
		}

		if (Enum.class.isAssignableFrom(target)) {
			return Enum.valueOf((Class<Enum>) target, value.toString());
		}

		return this.conversionService.convert(value, target);
	}

	@Override
	public void write(Object source, SecretDocument sink) {

		Class<?> entityType = ClassUtils.getUserClass(source.getClass());
		TypeInformation<? extends Object> type = ClassTypeInformation.from(entityType);

		SecretDocumentAccessor documentAccessor = new SecretDocumentAccessor(sink);

		writeInternal(source, documentAccessor, type);

		boolean handledByCustomConverter = this.conversions.hasCustomWriteTarget(entityType, SecretDocument.class);
		if (!handledByCustomConverter) {
			this.typeMapper.writeType(type, sink.getBody());
		}
	}

	/**
	 * Internal write conversion method which should be used for nested invocations.
	 * @param obj
	 * @param sink
	 * @param typeHint
	 */
	@SuppressWarnings("unchecked")
	protected void writeInternal(Object obj, SecretDocumentAccessor sink, @Nullable TypeInformation<?> typeHint) {

		Class<?> entityType = obj.getClass();
		Optional<Class<?>> customTarget = this.conversions.getCustomWriteTarget(entityType, SecretDocument.class);

		if (customTarget.isPresent()) {

			SecretDocument result = this.conversionService.convert(obj, SecretDocument.class);

			if (result.getId() != null) {
				sink.setId(result.getId());
			}

			sink.getBody().putAll(result.getBody());
			return;
		}

		if (Map.class.isAssignableFrom(entityType)) {
			writeMapInternal((Map<Object, Object>) obj, sink.getBody(), ClassTypeInformation.MAP);
			return;
		}

		VaultPersistentEntity<?> entity = this.mappingContext.getRequiredPersistentEntity(entityType);
		writeInternal(obj, sink, entity);
		addCustomTypeKeyIfNecessary(typeHint, obj, sink);
	}

	protected void writeInternal(Object obj, SecretDocumentAccessor sink, VaultPersistentEntity<?> entity) {

		PersistentPropertyAccessor accessor = entity.getPropertyAccessor(obj);

		VaultPersistentProperty idProperty = entity.getIdProperty();
		if (idProperty != null && !sink.hasValue(idProperty)) {

			Object value = accessor.getProperty(idProperty);

			if (value != null) {
				sink.put(idProperty, value);
			}
		}
		writeProperties(entity, accessor, sink, idProperty);
	}

	private void writeProperties(VaultPersistentEntity<?> entity, PersistentPropertyAccessor accessor,
			SecretDocumentAccessor sink, @Nullable VaultPersistentProperty idProperty) {

		// Write the properties
		for (VaultPersistentProperty prop : entity) {

			if (prop.equals(idProperty) || !prop.isWritable()) {
				continue;
			}

			Object value = accessor.getProperty(prop);

			if (value == null) {
				continue;
			}

			if (!this.conversions.isSimpleType(value.getClass())) {
				writePropertyInternal(value, sink, prop);
			}
			else {

				sink.put(prop, getPotentiallyConvertedSimpleWrite(value));
			}
		}
	}

	@SuppressWarnings({ "unchecked" })
	protected void writePropertyInternal(@Nullable Object obj, SecretDocumentAccessor accessor,
			VaultPersistentProperty prop) {

		if (obj == null) {
			return;
		}

		TypeInformation<?> valueType = ClassTypeInformation.from(obj.getClass());
		TypeInformation<?> type = prop.getTypeInformation();

		if (valueType.isCollectionLike()) {
			List<Object> collectionInternal = createCollection(asCollection(obj), prop);
			accessor.put(prop, collectionInternal);
			return;
		}

		if (valueType.isMap()) {
			Map<String, Object> mapDbObj = createMap((Map<Object, Object>) obj, prop);
			accessor.put(prop, mapDbObj);
			return;
		}

		// Lookup potential custom target type
		Optional<Class<?>> basicTargetType = this.conversions.getCustomWriteTarget(obj.getClass());

		if (basicTargetType.isPresent()) {

			accessor.put(prop, this.conversionService.convert(obj, basicTargetType.get()));
			return;
		}

		VaultPersistentEntity<?> entity = isSubtype(prop.getType(), obj.getClass())
				? this.mappingContext.getRequiredPersistentEntity(obj.getClass())
				: this.mappingContext.getRequiredPersistentEntity(type);

		SecretDocumentAccessor nested = accessor.writeNested(prop);

		writeInternal(obj, nested, entity);
		addCustomTypeKeyIfNecessary(ClassTypeInformation.from(prop.getRawType()), obj, nested);
	}

	private static boolean isSubtype(Class<?> left, Class<?> right) {
		return left.isAssignableFrom(right) && !left.equals(right);
	}

	/**
	 * Writes the given {@link Collection} using the given {@link VaultPersistentProperty}
	 * information.
	 * @param collection must not be {@literal null}.
	 * @param property must not be {@literal null}.
	 * @return the converted {@link List}.
	 */
	protected List<Object> createCollection(Collection<?> collection, VaultPersistentProperty property) {

		return writeCollectionInternal(collection, property.getTypeInformation(), new ArrayList<>());
	}

	/**
	 * Populates the given {@link List} with values from the given {@link Collection}.
	 * @param source the collection to create a {@link List} for, must not be
	 * {@literal null}.
	 * @param type the {@link TypeInformation} to consider or {@literal null} if unknown.
	 * @param sink the {@link List} to write to.
	 * @return the converted {@link List}.
	 */
	private List<Object> writeCollectionInternal(Collection<?> source, @Nullable TypeInformation<?> type,
			List<Object> sink) {

		TypeInformation<?> componentType = null;

		if (type != null) {
			componentType = type.getComponentType();
		}

		for (Object element : source) {

			Class<?> elementType = element == null ? null : element.getClass();

			if (elementType == null || this.conversions.isSimpleType(elementType)) {
				sink.add(getPotentiallyConvertedSimpleWrite(element));
			}
			else if (element instanceof Collection || elementType.isArray()) {
				sink.add(writeCollectionInternal(asCollection(element), componentType, new ArrayList<>()));
			}
			else {
				SecretDocumentAccessor accessor = new SecretDocumentAccessor(new SecretDocument());
				writeInternal(element, accessor, componentType);
				sink.add(accessor.getBody());
			}
		}

		return sink;
	}

	/**
	 * Writes the given {@link Map} using the given {@link VaultPersistentProperty}
	 * information.
	 * @param map must not {@literal null}.
	 * @param property must not be {@literal null}.
	 * @return the converted {@link Map}.
	 */
	protected Map<String, Object> createMap(Map<Object, Object> map, VaultPersistentProperty property) {

		Assert.notNull(map, "Given map must not be null");
		Assert.notNull(property, "PersistentProperty must not be null");

		return writeMapInternal(map, new LinkedHashMap<>(), property.getTypeInformation());
	}

	/**
	 * Writes the given {@link Map} to the given {@link Map} considering the given
	 * {@link TypeInformation}.
	 * @param obj must not be {@literal null}.
	 * @param bson must not be {@literal null}.
	 * @param propertyType must not be {@literal null}.
	 * @return the converted {@link Map}.
	 */
	protected Map<String, Object> writeMapInternal(Map<Object, Object> obj, Map<String, Object> bson,
			TypeInformation<?> propertyType) {

		for (Entry<Object, Object> entry : obj.entrySet()) {

			Object key = entry.getKey();
			Object val = entry.getValue();

			if (this.conversions.isSimpleType(key.getClass())) {

				String simpleKey = key.toString();
				if (val == null || this.conversions.isSimpleType(val.getClass())) {
					bson.put(simpleKey, val);
				}
				else if (val instanceof Collection || val.getClass().isArray()) {

					bson.put(simpleKey, writeCollectionInternal(asCollection(val), propertyType.getMapValueType(),
							new ArrayList<>()));
				}
				else {
					SecretDocumentAccessor nested = new SecretDocumentAccessor(new SecretDocument());
					TypeInformation<?> valueTypeInfo = propertyType.isMap() ? propertyType.getMapValueType()
							: ClassTypeInformation.OBJECT;
					writeInternal(val, nested, valueTypeInfo);
					bson.put(simpleKey, nested.getBody());
				}
			}
			else {
				throw new MappingException("Cannot use a complex object as a key value.");
			}
		}

		return bson;
	}

	/**
	 * Adds custom type information to the given {@link SecretDocument} if necessary. That
	 * is if the value is not the same as the one given. This is usually the case if you
	 * store a subtype of the actual declared type of the property.
	 * @param type type hint.
	 * @param value must not be {@literal null}.
	 * @param accessor must not be {@literal null}.
	 */
	protected void addCustomTypeKeyIfNecessary(@Nullable TypeInformation<?> type, Object value,
			SecretDocumentAccessor accessor) {

		Class<?> reference = type != null ? type.getActualType().getType() : Object.class;
		Class<?> valueType = ClassUtils.getUserClass(value.getClass());

		boolean notTheSameClass = !valueType.equals(reference);
		if (notTheSameClass) {
			this.typeMapper.writeType(valueType, accessor.getBody());
		}
	}

	/**
	 * Checks whether we have a custom conversion registered for the given value into an
	 * arbitrary simple Vault type. Returns the converted value if so. If not, we perform
	 * special enum handling or simply return the value as is.
	 * @param value the value to write.
	 * @return the converted value. Can be {@literal null}.
	 */
	@Nullable
	private Object getPotentiallyConvertedSimpleWrite(@Nullable Object value) {

		if (value == null) {
			return null;
		}

		Optional<Class<?>> customTarget = this.conversions.getCustomWriteTarget(value.getClass());

		if (customTarget.isPresent()) {
			return this.conversionService.convert(value, customTarget.get());
		}

		if (ObjectUtils.isArray(value)) {

			if (value instanceof byte[]) {
				return value;
			}
			return asCollection(value);
		}

		return Enum.class.isAssignableFrom(value.getClass()) ? ((Enum<?>) value).name() : value;
	}

	/**
	 * Returns given object as {@link Collection}. Will return the {@link Collection} as
	 * is if the source is a {@link Collection} already, will convert an array into a
	 * {@link Collection} or simply create a single element collection for everything
	 * else.
	 * @param source the collection object. Can be a {@link Collection}, array or
	 * singleton object.
	 * @return the {@code source} as {@link Collection}.
	 */
	private static Collection<?> asCollection(Object source) {

		if (source instanceof Collection) {
			return (Collection<?>) source;
		}

		return source.getClass().isArray() ? CollectionUtils.arrayToList(source) : Collections.singleton(source);
	}

	/**
	 * {@link PropertyValueProvider} to evaluate a SpEL expression if present on the
	 * property or simply accesses the field of the configured source
	 * {@link SecretDocument}.
	 *
	 */
	class VaultPropertyValueProvider implements PropertyValueProvider<VaultPersistentProperty> {

		private final SecretDocumentAccessor source;

		VaultPropertyValueProvider(SecretDocument source) {

			Assert.notNull(source, "Source document must no be null!");

			this.source = new SecretDocumentAccessor(source);
		}

		VaultPropertyValueProvider(SecretDocumentAccessor accessor) {

			Assert.notNull(accessor, "SecretDocumentAccessor must no be null!");

			this.source = accessor;
		}

		@Nullable
		public <T> T getPropertyValue(VaultPersistentProperty property) {

			Object value = this.source.get(property);

			if (value == null) {
				return null;
			}

			return readValue(value, property.getTypeInformation());
		}

	}

}