/*
 * Copyright 2015-2018 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
 *
 *      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.springframework.data.mongodb.repository.query;

import lombok.EqualsAndHashCode;
import lombok.Value;
import lombok.experimental.UtilityClass;

import java.io.StringWriter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.bind.DatatypeConverter;

import org.bson.codecs.BinaryCodec;
import org.bson.codecs.Codec;
import org.bson.codecs.UuidCodec;
import org.bson.json.JsonWriter;
import org.bson.types.Binary;
import org.springframework.data.mongodb.CodecRegistryProvider;
import org.springframework.data.mongodb.repository.query.StringBasedMongoQuery.ParameterBinding;
import org.springframework.data.repository.query.EvaluationContextProvider;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import com.mongodb.DBObject;
import com.mongodb.MongoClient;
import com.mongodb.util.JSON;

/**
 * {@link ExpressionEvaluatingParameterBinder} allows to evaluate, convert and bind parameters to placeholders within a
 * {@link String}.
 *
 * @author Christoph Strobl
 * @author Thomas Darimont
 * @author Oliver Gierke
 * @author Mark Paluch
 * @since 1.9
 */
class ExpressionEvaluatingParameterBinder {

	private final SpelExpressionParser expressionParser;
	private final EvaluationContextProvider evaluationContextProvider;
	private final CodecRegistryProvider codecRegistryProvider;

	/**
	 * Creates new {@link ExpressionEvaluatingParameterBinder}
	 *
	 * @param expressionParser must not be {@literal null}.
	 * @param evaluationContextProvider must not be {@literal null}.
	 */
	public ExpressionEvaluatingParameterBinder(SpelExpressionParser expressionParser,
			EvaluationContextProvider evaluationContextProvider) {

		Assert.notNull(expressionParser, "ExpressionParser must not be null!");
		Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null!");

		this.expressionParser = expressionParser;
		this.evaluationContextProvider = evaluationContextProvider;
		this.codecRegistryProvider = () -> MongoClient.getDefaultCodecRegistry();
	}

	/**
	 * Bind values provided by {@link MongoParameterAccessor} to placeholders in {@literal raw} while considering
	 * potential conversions and parameter types.
	 *
	 * @param raw can be empty.
	 * @param accessor must not be {@literal null}.
	 * @param bindingContext must not be {@literal null}.
	 * @return {@literal null} if given {@code raw} value is empty.
	 */
	public String bind(String raw, MongoParameterAccessor accessor, BindingContext bindingContext) {

		if (!StringUtils.hasText(raw)) {
			return raw;
		}

		return replacePlaceholders(raw, accessor, bindingContext);
	}

	/**
	 * Replaced the parameter placeholders with the actual parameter values from the given {@link ParameterBinding}s.
	 *
	 * @param input must not be {@literal null} or empty.
	 * @param accessor must not be {@literal null}.
	 * @param bindingContext must not be {@literal null}.
	 * @return
	 */
	private String replacePlaceholders(String input, MongoParameterAccessor accessor, BindingContext bindingContext) {

		if (!bindingContext.hasBindings()) {
			return input;
		}

		if (input.matches("^\\?\\d+$")) {
			return getParameterValueForBinding(accessor, bindingContext.getParameters(),
					bindingContext.getBindings().iterator().next());
		}

		Matcher matcher = createReplacementPattern(bindingContext.getBindings()).matcher(input);
		StringBuffer buffer = new StringBuffer();

		int parameterIndex = 0;
		while (matcher.find()) {

			Placeholder placeholder = extractPlaceholder(parameterIndex++, matcher);
			ParameterBinding binding = bindingContext.getBindingFor(placeholder);
			String valueForBinding = getParameterValueForBinding(accessor, bindingContext.getParameters(), binding);

			// appendReplacement does not like unescaped $ sign and others, so we need to quote that stuff first
			matcher.appendReplacement(buffer, Matcher.quoteReplacement(valueForBinding));
			if (StringUtils.hasText(placeholder.getSuffix())) {
				buffer.append(placeholder.getSuffix());
			}

			if (placeholder.isQuoted()) {
				postProcessQuotedBinding(buffer, valueForBinding,
						!binding.isExpression() ? accessor.getBindableValue(binding.getParameterIndex()) : null,
						binding.isExpression());
			}
		}

		matcher.appendTail(buffer);
		return buffer.toString();
	}

	/**
	 * Sanitize String binding by replacing single quoted values with double quotes which prevents potential single quotes
	 * contained in replacement to interfere with the Json parsing. Also take care of complex objects by removing the
	 * quotation entirely.
	 *
	 * @param buffer the {@link StringBuffer} to operate upon.
	 * @param valueForBinding the actual binding value.
	 * @param raw the raw binding value
	 * @param isExpression {@literal true} if the binding value results from a SpEL expression.
	 */
	private void postProcessQuotedBinding(StringBuffer buffer, String valueForBinding, @Nullable Object raw,
			boolean isExpression) {

		int quotationMarkIndex = buffer.length() - valueForBinding.length() - 1;
		char quotationMark = buffer.charAt(quotationMarkIndex);

		while (quotationMark != '\'' && quotationMark != '"') {

			quotationMarkIndex--;

			if (quotationMarkIndex < 0) {
				throw new IllegalArgumentException("Could not find opening quotes for quoted parameter");
			}

			quotationMark = buffer.charAt(quotationMarkIndex);
		}

		// remove quotation char before the complex object string
		if (valueForBinding.startsWith("{") && (raw instanceof DBObject || isExpression)) {

			buffer.deleteCharAt(quotationMarkIndex);

		} else {

			if (isExpression) {

				buffer.deleteCharAt(quotationMarkIndex);
				return;
			}

			if (quotationMark == '\'') {
				buffer.replace(quotationMarkIndex, quotationMarkIndex + 1, "\"");
			}

			buffer.append("\"");
		}
	}

	/**
	 * Returns the serialized value to be used for the given {@link ParameterBinding}.
	 *
	 * @param accessor must not be {@literal null}.
	 * @param parameters
	 * @param binding must not be {@literal null}.
	 * @return
	 */
	private String getParameterValueForBinding(MongoParameterAccessor accessor, MongoParameters parameters,
			ParameterBinding binding) {

		Object value = binding.isExpression()
				? evaluateExpression(binding.getExpression(), parameters, accessor.getValues())
				: accessor.getBindableValue(binding.getParameterIndex());

		if (value instanceof String && binding.isQuoted()) {

			if (binding.isExpression() && ((String) value).startsWith("{")) {
				return (String) value;
			}

			return binding.isExpression() ? JSON.serialize(value) : QuotedString.unquote(JSON.serialize(value));
		}

		if (value instanceof byte[]) {

			if (binding.isQuoted()) {
				return DatatypeConverter.printBase64Binary((byte[]) value);
			}

			return encode(new Binary((byte[]) value), BinaryCodec::new);
		}

		if (value instanceof UUID) {

			if (binding.isQuoted()) {
				return value.toString();
			}

			return encode((UUID) value, UuidCodec::new);
		}

		return JSON.serialize(value);
	}

	private <T> String encode(T value, Supplier<Codec<T>> defaultCodec) {

		Codec<T> codec = codecRegistryProvider.getCodecFor((Class<T>) value.getClass()).orElseGet(defaultCodec);

		StringWriter writer = new StringWriter();

		codec.encode(new JsonWriter(writer), value, null);
		writer.flush();

		return writer.toString();
	}

	/**
	 * Evaluates the given {@code expressionString}.
	 *
	 * @param expressionString must not be {@literal null} or empty.
	 * @param parameters must not be {@literal null}.
	 * @param parameterValues must not be {@literal null}.
	 * @return
	 */
	@Nullable
	private Object evaluateExpression(String expressionString, MongoParameters parameters, Object[] parameterValues) {

		EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(parameters, parameterValues);
		Expression expression = expressionParser.parseExpression(expressionString);

		return expression.getValue(evaluationContext, Object.class);
	}

	/**
	 * Creates a replacement {@link Pattern} for all {@link ParameterBinding#getParameter() binding parameters} including
	 * a potentially trailing quotation mark.
	 *
	 * @param bindings
	 * @return
	 */
	private Pattern createReplacementPattern(List<ParameterBinding> bindings) {

		StringBuilder regex = new StringBuilder();

		for (ParameterBinding binding : bindings) {

			regex.append("|");
			regex.append("(" + Pattern.quote(binding.getParameter()) + ")");
			regex.append("([\\w.]*");
			regex.append("(\\W?['\"]|\\w*')?)");
		}

		return Pattern.compile(regex.substring(1));
	}

	/**
	 * Extract the placeholder stripping any trailing trailing quotation mark that might have resulted from the
	 * {@link #createReplacementPattern(List) pattern} used.
	 *
	 * @param parameterIndex The actual parameter index.
	 * @param matcher The actual {@link Matcher}.
	 * @return
	 */
	private Placeholder extractPlaceholder(int parameterIndex, Matcher matcher) {

		String rawPlaceholder = matcher.group(parameterIndex * 3 + 1);
		String suffix = matcher.group(parameterIndex * 3 + 2);

		if (!StringUtils.hasText(rawPlaceholder)) {

			rawPlaceholder = matcher.group();
			if (rawPlaceholder.matches(".*\\d$")) {
				suffix = "";
			} else {
				int index = rawPlaceholder.replaceAll("[^\\?0-9]*$", "").length() - 1;
				if (index > 0 && rawPlaceholder.length() > index) {
					suffix = rawPlaceholder.substring(index + 1);
				}
			}
			if (QuotedString.endsWithQuote(rawPlaceholder)) {
				rawPlaceholder = rawPlaceholder.substring(0,
						rawPlaceholder.length() - (StringUtils.hasText(suffix) ? suffix.length() : 1));
			}
		}

		if (StringUtils.hasText(suffix)) {

			boolean quoted = QuotedString.endsWithQuote(suffix);

			return Placeholder.of(parameterIndex, rawPlaceholder, quoted,
					quoted ? QuotedString.unquoteSuffix(suffix) : suffix);
		}
		return Placeholder.of(parameterIndex, rawPlaceholder, false, null);
	}

	/**
	 * @author Christoph Strobl
	 * @author Mark Paluch
	 * @since 1.9
	 */
	static class BindingContext {

		final MongoParameters parameters;
		final Map<Placeholder, ParameterBinding> bindings;

		/**
		 * Creates new {@link BindingContext}.
		 *
		 * @param parameters
		 * @param bindings
		 */
		public BindingContext(MongoParameters parameters, List<ParameterBinding> bindings) {

			this.parameters = parameters;
			this.bindings = mapBindings(bindings);
		}

		/**
		 * @return {@literal true} when list of bindings is not empty.
		 */
		boolean hasBindings() {
			return !CollectionUtils.isEmpty(bindings);
		}

		/**
		 * Get unmodifiable list of {@link ParameterBinding}s.
		 *
		 * @return never {@literal null}.
		 */
		public List<ParameterBinding> getBindings() {
			return new ArrayList<ParameterBinding>(bindings.values());
		}

		/**
		 * Get the concrete {@link ParameterBinding} for a given {@literal placeholder}.
		 *
		 * @param placeholder must not be {@literal null}.
		 * @return
		 * @throws java.util.NoSuchElementException
		 * @since 1.10
		 */
		ParameterBinding getBindingFor(Placeholder placeholder) {

			if (!bindings.containsKey(placeholder)) {
				throw new NoSuchElementException(String.format("Could not to find binding for placeholder '%s'.", placeholder));
			}

			return bindings.get(placeholder);
		}

		/**
		 * Get the associated {@link MongoParameters}.
		 *
		 * @return
		 */
		public MongoParameters getParameters() {
			return parameters;
		}

		private static Map<Placeholder, ParameterBinding> mapBindings(List<ParameterBinding> bindings) {

			Map<Placeholder, ParameterBinding> map = new LinkedHashMap<Placeholder, ParameterBinding>(bindings.size(), 1);

			int parameterIndex = 0;
			for (ParameterBinding binding : bindings) {
				map.put(Placeholder.of(parameterIndex++, binding.getParameter(), binding.isQuoted(), null), binding);
			}

			return map;
		}
	}

	/**
	 * Encapsulates a quoted/unquoted parameter placeholder.
	 *
	 * @author Mark Paluch
	 * @since 1.9
	 */
	@Value(staticConstructor = "of")
	@EqualsAndHashCode(exclude = { "quoted", "suffix" })
	static class Placeholder {

		private int parameterIndex;
		private final String parameter;
		private final boolean quoted;
		private final @Nullable String suffix;

		/*
		 * (non-Javadoc)
		 * @see java.lang.Object#toString()
		 */
		@Override
		public String toString() {
			return quoted ? String.format("'%s'", parameter + (suffix != null ? suffix : ""))
					: parameter + (suffix != null ? suffix : "");
		}

	}

	/**
	 * Utility to handle quoted strings using single/double quotes.
	 *
	 * @author Mark Paluch
	 */
	@UtilityClass
	static class QuotedString {

		/**
		 * @param string
		 * @return {@literal true} if {@literal string} ends with a single/double quote.
		 */
		static boolean endsWithQuote(String string) {
			return string.endsWith("'") || string.endsWith("\"");
		}

		/**
		 * Remove trailing quoting from {@literal quoted}.
		 *
		 * @param quoted
		 * @return {@literal quoted} with removed quotes.
		 */
		public static String unquoteSuffix(String quoted) {
			return quoted.substring(0, quoted.length() - 1);
		}

		/**
		 * Remove leading and trailing quoting from {@literal quoted}.
		 *
		 * @param quoted
		 * @return {@literal quoted} with removed quotes.
		 */
		public static String unquote(String quoted) {
			return quoted.substring(1, quoted.length() - 1);
		}
	}
}
