/*
 * Copyright 2013-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.core.spel;

import static org.springframework.data.mongodb.core.spel.MethodReferenceNode.AggregationMethodReference.*;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import org.springframework.expression.spel.ExpressionState;
import org.springframework.expression.spel.ast.MethodReference;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;

/**
 * An {@link ExpressionNode} representing a method reference.
 *
 * @author Oliver Gierke
 * @author Thomas Darimont
 * @author Sebastien Gerard
 * @author Christoph Strobl
 * @author Mark Paluch
 */
public class MethodReferenceNode extends ExpressionNode {

	private static final Map<String, AggregationMethodReference> FUNCTIONS;

	static {

		Map<String, AggregationMethodReference> map = new HashMap<String, AggregationMethodReference>();

		// BOOLEAN OPERATORS
		map.put("and", arrayArgumentAggregationMethodReference().forOperator("$and"));
		map.put("or", arrayArgumentAggregationMethodReference().forOperator("$or"));
		map.put("not", arrayArgumentAggregationMethodReference().forOperator("$not"));

		// SET OPERATORS
		map.put("setEquals", arrayArgumentAggregationMethodReference().forOperator("$setEquals"));
		map.put("setIntersection", arrayArgumentAggregationMethodReference().forOperator("$setIntersection"));
		map.put("setUnion", arrayArgumentAggregationMethodReference().forOperator("$setUnion"));
		map.put("setDifference", arrayArgumentAggregationMethodReference().forOperator("$setDifference"));
		// 2nd.
		map.put("setIsSubset", arrayArgumentAggregationMethodReference().forOperator("$setIsSubset"));
		map.put("anyElementTrue", arrayArgumentAggregationMethodReference().forOperator("$anyElementTrue"));
		map.put("allElementsTrue", arrayArgumentAggregationMethodReference().forOperator("$allElementsTrue"));

		// COMPARISON OPERATORS
		map.put("cmp", arrayArgumentAggregationMethodReference().forOperator("$cmp"));
		map.put("eq", arrayArgumentAggregationMethodReference().forOperator("$eq"));
		map.put("gt", arrayArgumentAggregationMethodReference().forOperator("$gt"));
		map.put("gte", arrayArgumentAggregationMethodReference().forOperator("$gte"));
		map.put("lt", arrayArgumentAggregationMethodReference().forOperator("$lt"));
		map.put("lte", arrayArgumentAggregationMethodReference().forOperator("$lte"));
		map.put("ne", arrayArgumentAggregationMethodReference().forOperator("$ne"));

		// ARITHMETIC OPERATORS
		map.put("abs", singleArgumentAggregationMethodReference().forOperator("$abs"));
		map.put("add", arrayArgumentAggregationMethodReference().forOperator("$add"));
		map.put("ceil", singleArgumentAggregationMethodReference().forOperator("$ceil"));
		map.put("divide", arrayArgumentAggregationMethodReference().forOperator("$divide"));
		map.put("exp", singleArgumentAggregationMethodReference().forOperator("$exp"));
		map.put("floor", singleArgumentAggregationMethodReference().forOperator("$floor"));
		map.put("ln", singleArgumentAggregationMethodReference().forOperator("$ln"));
		map.put("log", arrayArgumentAggregationMethodReference().forOperator("$log"));
		map.put("log10", singleArgumentAggregationMethodReference().forOperator("$log10"));
		map.put("mod", arrayArgumentAggregationMethodReference().forOperator("$mod"));
		map.put("multiply", arrayArgumentAggregationMethodReference().forOperator("$multiply"));
		map.put("pow", arrayArgumentAggregationMethodReference().forOperator("$pow"));
		map.put("sqrt", singleArgumentAggregationMethodReference().forOperator("$sqrt"));
		map.put("subtract", arrayArgumentAggregationMethodReference().forOperator("$subtract"));
		map.put("trunc", singleArgumentAggregationMethodReference().forOperator("$trunc"));

		// STRING OPERATORS
		map.put("concat", arrayArgumentAggregationMethodReference().forOperator("$concat"));
		map.put("strcasecmp", arrayArgumentAggregationMethodReference().forOperator("$strcasecmp"));
		map.put("substr", arrayArgumentAggregationMethodReference().forOperator("$substr"));
		map.put("toLower", singleArgumentAggregationMethodReference().forOperator("$toLower"));
		map.put("toUpper", singleArgumentAggregationMethodReference().forOperator("$toUpper"));
		map.put("strcasecmp", arrayArgumentAggregationMethodReference().forOperator("$strcasecmp"));
		map.put("indexOfBytes", arrayArgumentAggregationMethodReference().forOperator("$indexOfBytes"));
		map.put("indexOfCP", arrayArgumentAggregationMethodReference().forOperator("$indexOfCP"));
		map.put("split", arrayArgumentAggregationMethodReference().forOperator("$split"));
		map.put("strLenBytes", singleArgumentAggregationMethodReference().forOperator("$strLenBytes"));
		map.put("strLenCP", singleArgumentAggregationMethodReference().forOperator("$strLenCP"));
		map.put("substrCP", arrayArgumentAggregationMethodReference().forOperator("$substrCP"));

		// TEXT SEARCH OPERATORS
		map.put("meta", singleArgumentAggregationMethodReference().forOperator("$meta"));

		// ARRAY OPERATORS
		map.put("arrayElemAt", arrayArgumentAggregationMethodReference().forOperator("$arrayElemAt"));
		map.put("concatArrays", arrayArgumentAggregationMethodReference().forOperator("$concatArrays"));
		map.put("filter", mapArgumentAggregationMethodReference().forOperator("$filter") //
				.mappingParametersTo("input", "as", "cond"));
		map.put("isArray", singleArgumentAggregationMethodReference().forOperator("$isArray"));
		map.put("size", singleArgumentAggregationMethodReference().forOperator("$size"));
		map.put("slice", arrayArgumentAggregationMethodReference().forOperator("$slice"));
		map.put("reverseArray", singleArgumentAggregationMethodReference().forOperator("$reverseArray"));
		map.put("reduce", mapArgumentAggregationMethodReference().forOperator("$reduce").mappingParametersTo("input",
				"initialValue", "in"));
		map.put("zip", mapArgumentAggregationMethodReference().forOperator("$zip").mappingParametersTo("inputs",
				"useLongestLength", "defaults"));
		map.put("in", arrayArgumentAggregationMethodReference().forOperator("$in"));

		// VARIABLE OPERATORS
		map.put("map", mapArgumentAggregationMethodReference().forOperator("$map") //
				.mappingParametersTo("input", "as", "in"));
		map.put("let", mapArgumentAggregationMethodReference().forOperator("$let").mappingParametersTo("vars", "in"));

		// LITERAL OPERATORS
		map.put("literal", singleArgumentAggregationMethodReference().forOperator("$literal"));

		// DATE OPERATORS
		map.put("dayOfYear", singleArgumentAggregationMethodReference().forOperator("$dayOfYear"));
		map.put("dayOfMonth", singleArgumentAggregationMethodReference().forOperator("$dayOfMonth"));
		map.put("dayOfWeek", singleArgumentAggregationMethodReference().forOperator("$dayOfWeek"));
		map.put("year", singleArgumentAggregationMethodReference().forOperator("$year"));
		map.put("month", singleArgumentAggregationMethodReference().forOperator("$month"));
		map.put("week", singleArgumentAggregationMethodReference().forOperator("$week"));
		map.put("hour", singleArgumentAggregationMethodReference().forOperator("$hour"));
		map.put("minute", singleArgumentAggregationMethodReference().forOperator("$minute"));
		map.put("second", singleArgumentAggregationMethodReference().forOperator("$second"));
		map.put("millisecond", singleArgumentAggregationMethodReference().forOperator("$millisecond"));
		map.put("dateToString", mapArgumentAggregationMethodReference().forOperator("$dateToString") //
				.mappingParametersTo("format", "date"));
		map.put("isoDayOfWeek", singleArgumentAggregationMethodReference().forOperator("$isoDayOfWeek"));
		map.put("isoWeek", singleArgumentAggregationMethodReference().forOperator("$isoWeek"));
		map.put("isoWeekYear", singleArgumentAggregationMethodReference().forOperator("$isoWeekYear"));

		// CONDITIONAL OPERATORS
		map.put("cond", mapArgumentAggregationMethodReference().forOperator("$cond") //
				.mappingParametersTo("if", "then", "else"));
		map.put("ifNull", arrayArgumentAggregationMethodReference().forOperator("$ifNull"));

		// GROUP OPERATORS
		map.put("sum", arrayArgumentAggregationMethodReference().forOperator("$sum"));
		map.put("avg", arrayArgumentAggregationMethodReference().forOperator("$avg"));
		map.put("first", singleArgumentAggregationMethodReference().forOperator("$first"));
		map.put("last", singleArgumentAggregationMethodReference().forOperator("$last"));
		map.put("max", arrayArgumentAggregationMethodReference().forOperator("$max"));
		map.put("min", arrayArgumentAggregationMethodReference().forOperator("$min"));
		map.put("push", singleArgumentAggregationMethodReference().forOperator("$push"));
		map.put("addToSet", singleArgumentAggregationMethodReference().forOperator("$addToSet"));
		map.put("stdDevPop", arrayArgumentAggregationMethodReference().forOperator("$stdDevPop"));
		map.put("stdDevSamp", arrayArgumentAggregationMethodReference().forOperator("$stdDevSamp"));

		// TYPE OPERATORS
		map.put("type", singleArgumentAggregationMethodReference().forOperator("$type"));

		FUNCTIONS = Collections.unmodifiableMap(map);
	}

	MethodReferenceNode(MethodReference reference, ExpressionState state) {
		super(reference, state);
	}

	/**
	 * Returns the name of the method.
	 *
	 * @Deprecated since 1.10. Please use {@link #getMethodReference()}.
	 */
	@Nullable
	@Deprecated
	public String getMethodName() {

		AggregationMethodReference methodReference = getMethodReference();
		return methodReference != null ? methodReference.getMongoOperator() : null;
	}

	/**
	 * Return the {@link AggregationMethodReference}.
	 *
	 * @return can be {@literal null}.
	 * @since 1.10
	 */
	@Nullable
	public AggregationMethodReference getMethodReference() {

		String name = getName();
		String methodName = name.substring(0, name.indexOf('('));
		return FUNCTIONS.get(methodName);
	}

	/**
	 * @author Christoph Strobl
	 * @since 1.10
	 */
	public static final class AggregationMethodReference {

		private final @Nullable String mongoOperator;
		private final @Nullable ArgumentType argumentType;
		private final @Nullable String[] argumentMap;

		/**
		 * Creates new {@link AggregationMethodReference}.
		 *
		 * @param mongoOperator can be {@literal null}.
		 * @param argumentType can be {@literal null}.
		 * @param argumentMap can be {@literal null}.
		 */
		private AggregationMethodReference(@Nullable String mongoOperator, @Nullable ArgumentType argumentType,
				@Nullable String[] argumentMap) {

			this.mongoOperator = mongoOperator;
			this.argumentType = argumentType;
			this.argumentMap = argumentMap;
		}

		/**
		 * Get the MongoDB specific operator.
		 *
		 * @return can be {@literal null}.
		 */
		@Nullable
		public String getMongoOperator() {
			return this.mongoOperator;
		}

		/**
		 * Get the {@link ArgumentType} used by the MongoDB.
		 *
		 * @return never {@literal null}.
		 */
		@Nullable
		public ArgumentType getArgumentType() {
			return this.argumentType;
		}

		/**
		 * Get the property names in order order of appearance in resulting operation.
		 *
		 * @return never {@literal null}.
		 */
		public String[] getArgumentMap() {
			return argumentMap != null ? argumentMap : new String[] {};
		}

		/**
		 * Create a new {@link AggregationMethodReference} for a {@link ArgumentType#SINGLE} argument.
		 *
		 * @return never {@literal null}.
		 */
		static AggregationMethodReference singleArgumentAggregationMethodReference() {
			return new AggregationMethodReference(null, ArgumentType.SINGLE, null);
		}

		/**
		 * Create a new {@link AggregationMethodReference} for an {@link ArgumentType#ARRAY} argument.
		 *
		 * @return never {@literal null}.
		 */
		static AggregationMethodReference arrayArgumentAggregationMethodReference() {
			return new AggregationMethodReference(null, ArgumentType.ARRAY, null);
		}

		/**
		 * Create a new {@link AggregationMethodReference} for a {@link ArgumentType#MAP} argument.
		 *
		 * @return never {@literal null}.
		 */
		static AggregationMethodReference mapArgumentAggregationMethodReference() {
			return new AggregationMethodReference(null, ArgumentType.MAP, null);
		}

		/**
		 * Create a new {@link AggregationMethodReference} for a given {@literal aggregationExpressionOperator} reusing
		 * previously set arguments.
		 *
		 * @param aggregationExpressionOperator should not be {@literal null}.
		 * @return never {@literal null}.
		 */
		AggregationMethodReference forOperator(String aggregationExpressionOperator) {
			return new AggregationMethodReference(aggregationExpressionOperator, argumentType, argumentMap);
		}

		/**
		 * Create a new {@link AggregationMethodReference} for mapping actual parameters within the AST to the given
		 * {@literal aggregationExpressionProperties} reusing previously set arguments. <br />
		 * <strong>NOTE:</strong> Can only be applied to {@link AggregationMethodReference} of type
		 * {@link ArgumentType#MAP}.
		 *
		 * @param aggregationExpressionProperties should not be {@literal null}.
		 * @return never {@literal null}.
		 * @throws IllegalArgumentException
		 */
		AggregationMethodReference mappingParametersTo(String... aggregationExpressionProperties) {

			Assert.isTrue(ObjectUtils.nullSafeEquals(argumentType, ArgumentType.MAP),
					"Parameter mapping can only be applied to AggregationMethodReference with MAPPED ArgumentType.");
			return new AggregationMethodReference(mongoOperator, argumentType, aggregationExpressionProperties);
		}

		/**
		 * The actual argument type to use when mapping parameters to MongoDB specific format.
		 *
		 * @author Christoph Strobl
		 * @since 1.10
		 */
		public enum ArgumentType {
			SINGLE, ARRAY, MAP
		}
	}

}
