/*
 * Copyright 2013-2023 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.data.jpa.repository.query;

import static java.util.regex.Pattern.*;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier;
import org.springframework.data.jpa.repository.query.ParameterBinding.InParameterBinding;
import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding;
import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument;
import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin;
import org.springframework.data.repository.query.SpelQueryContext;
import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor;
import org.springframework.data.repository.query.parser.Part.Type;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

/**
 * Encapsulation of a JPA query String. Offers access to parameters as bindings. The internal query String is cleaned
 * from decorated parameters like {@literal %:lastname%} and the matching bindings take care of applying the decorations
 * in the {@link ParameterBinding#prepare(Object)} method. Note that this class also handles replacing SpEL expressions
 * with synthetic bind parameters.
 *
 * @author Oliver Gierke
 * @author Thomas Darimont
 * @author Oliver Wehrens
 * @author Mark Paluch
 * @author Jens Schauder
 * @author Diego Krupitza
 * @author Greg Turnquist
 * @author Yuriy Tsarkov
 */
class StringQuery implements DeclaredQuery {

	private final String query;
	private final List<ParameterBinding> bindings;
	private final @Nullable String alias;
	private final boolean hasConstructorExpression;
	private final boolean containsPageableInSpel;
	private final boolean usesJdbcStyleParameters;
	private final boolean isNative;
	private final QueryEnhancer queryEnhancer;

	/**
	 * Creates a new {@link StringQuery} from the given JPQL query.
	 *
	 * @param query must not be {@literal null} or empty.
	 */
	@SuppressWarnings("deprecation")
	StringQuery(String query, boolean isNative) {

		Assert.hasText(query, "Query must not be null or empty");

		this.isNative = isNative;
		this.bindings = new ArrayList<>();
		this.containsPageableInSpel = query.contains("#pageable");

		Metadata queryMeta = new Metadata();
		this.query = ParameterBindingParser.INSTANCE.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query,
				this.bindings, queryMeta);

		this.usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters;

		this.queryEnhancer = QueryEnhancerFactory.forQuery(this);
		this.alias = this.queryEnhancer.detectAlias();
		this.hasConstructorExpression = this.queryEnhancer.hasConstructorExpression();
	}

	/**
	 * Returns whether we have found some like bindings.
	 */
	boolean hasParameterBindings() {
		return !bindings.isEmpty();
	}

	String getProjection() {
		return this.queryEnhancer.getProjection();
	}

	@Override
	public List<ParameterBinding> getParameterBindings() {
		return bindings;
	}

	@Override
	public DeclaredQuery deriveCountQuery(@Nullable String countQuery, @Nullable String countQueryProjection) {

		return DeclaredQuery.of( //
				countQuery != null ? countQuery : this.queryEnhancer.createCountQueryFor(countQueryProjection), //
				this.isNative);
	}

	@Override
	public boolean usesJdbcStyleParameters() {
		return usesJdbcStyleParameters;
	}

	@Override
	public String getQueryString() {
		return query;
	}

	@Override
	@Nullable
	public String getAlias() {
		return alias;
	}

	@Override
	public boolean hasConstructorExpression() {
		return hasConstructorExpression;
	}

	@Override
	public boolean isDefaultProjection() {
		return getProjection().equalsIgnoreCase(alias);
	}

	@Override
	public boolean hasNamedParameter() {
		return bindings.stream().anyMatch(b -> b.getIdentifier().hasName());
	}

	@Override
	public boolean usesPaging() {
		return containsPageableInSpel;
	}

	@Override
	public boolean isNativeQuery() {
		return isNative;
	}

	/**
	 * A parser that extracts the parameter bindings from a given query string.
	 *
	 * @author Thomas Darimont
	 */
	enum ParameterBindingParser {

		INSTANCE;

		private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__";
		public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![#\\w]))";
		// .....................................................................^ not followed by a hash or a letter.
		// .................................................................^ zero or more digits.
		// .............................................................^ start with a question mark.
		private static final Pattern PARAMETER_BINDING_BY_INDEX = Pattern.compile(POSITIONAL_OR_INDEXED_PARAMETER);
		private static final Pattern PARAMETER_BINDING_PATTERN;
		private static final Pattern JDBC_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?(?!\\d)"); // no \ and [no digit]
		private static final Pattern NUMBERED_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?\\d"); // no \ and [digit]
		private static final Pattern NAMED_STYLE_PARAM = Pattern.compile("(?!\\\\):\\w+"); // no \ and :[text]

		private static final String MESSAGE = "Already found parameter binding with same index / parameter name but differing binding type; "
				+ "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding";
		private static final int INDEXED_PARAMETER_GROUP = 4;
		private static final int NAMED_PARAMETER_GROUP = 6;
		private static final int COMPARISION_TYPE_GROUP = 1;

		static {

			List<String> keywords = new ArrayList<>();

			for (ParameterBindingType type : ParameterBindingType.values()) {
				if (type.getKeyword() != null) {
					keywords.add(type.getKeyword());
				}
			}

			StringBuilder builder = new StringBuilder();
			builder.append("(");
			builder.append(StringUtils.collectionToDelimitedString(keywords, "|")); // keywords
			builder.append(")?");
			builder.append("(?: )?"); // some whitespace
			builder.append("\\(?"); // optional braces around parameters
			builder.append("(");
			builder.append("%?(" + POSITIONAL_OR_INDEXED_PARAMETER + ")%?"); // position parameter and parameter index
			builder.append("|"); // or

			// named parameter and the parameter name
			builder.append("%?(" + QueryUtils.COLON_NO_DOUBLE_COLON + QueryUtils.IDENTIFIER_GROUP + ")%?");

			builder.append(")");
			builder.append("\\)?"); // optional braces around parameters

			PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE);
		}

		/**
		 * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns
		 * the cleaned up query.
		 */
		private String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query,
				List<ParameterBinding> bindings, Metadata queryMeta) {

			int greatestParameterIndex = tryFindGreatestParameterIndexIn(query);
			boolean parametersShouldBeAccessedByIndex = greatestParameterIndex != -1;

			/*
			 * Prefer indexed access over named parameters if only SpEL Expression parameters are present.
			 */
			if (!parametersShouldBeAccessedByIndex && query.contains("?#{")) {
				parametersShouldBeAccessedByIndex = true;
				greatestParameterIndex = 0;
			}

			SpelExtractor spelExtractor = createSpelExtractor(query, parametersShouldBeAccessedByIndex,
					greatestParameterIndex);

			String resultingQuery = spelExtractor.getQueryString();
			Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQuery);

			int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0;
			int syntheticParameterIndex = expressionParameterIndex + spelExtractor.size();

			ParameterBindings parameterBindings = new ParameterBindings(bindings, it -> checkAndRegister(it, bindings),
					syntheticParameterIndex);
			int currentIndex = 0;

			boolean usesJpaStyleParameters = false;

			while (matcher.find()) {

				if (spelExtractor.isQuoted(matcher.start())) {
					continue;
				}

				String parameterIndexString = matcher.group(INDEXED_PARAMETER_GROUP);
				String parameterName = parameterIndexString != null ? null : matcher.group(NAMED_PARAMETER_GROUP);
				Integer parameterIndex = getParameterIndex(parameterIndexString);

				String match = matcher.group(0);
				if (JDBC_STYLE_PARAM.matcher(match).find()) {
					queryMeta.usesJdbcStyleParameters = true;
				}

				if (NUMBERED_STYLE_PARAM.matcher(match).find() || NAMED_STYLE_PARAM.matcher(match).find()) {
					usesJpaStyleParameters = true;
				}

				if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) {
					throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported");
				}

				String typeSource = matcher.group(COMPARISION_TYPE_GROUP);
				Assert.isTrue(parameterIndexString != null || parameterName != null,
						() -> String.format("We need either a name or an index; Offending query string: %s", query));
				String expression = spelExtractor.getParameter(parameterName == null ? parameterIndexString : parameterName);
				String replacement = null;

				expressionParameterIndex++;
				if ("".equals(parameterIndexString)) {
					parameterIndex = expressionParameterIndex;
				}


				BindingIdentifier queryParameter;
				if (parameterIndex != null) {
					queryParameter = BindingIdentifier.of(parameterIndex);
				} else {
					queryParameter = BindingIdentifier.of(parameterName);
				}
				ParameterOrigin origin = ObjectUtils.isEmpty(expression)
						? ParameterOrigin.ofParameter(parameterName, parameterIndex)
						: ParameterOrigin.ofExpression(expression);

				BindingIdentifier targetBinding = queryParameter;
				Function<BindingIdentifier, ParameterBinding> bindingFactory;
				switch (ParameterBindingType.of(typeSource)) {

					case LIKE:

						Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2));
						bindingFactory = (identifier) -> new LikeParameterBinding(identifier, origin, likeType);
						break;

					case IN:
						bindingFactory = (identifier) -> new InParameterBinding(identifier, origin);
						break;

					case AS_IS: // fall-through we don't need a special parameter queryParameter for the given parameter.
					default:
						bindingFactory = (identifier) -> new ParameterBinding(identifier, origin);
				}

				if (origin.isExpression()) {
					parameterBindings.register(bindingFactory.apply(queryParameter));
				} else {
					targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory);
				}

				replacement = targetBinding.hasName() ? ":" + targetBinding.getName()
						: ((!usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) ? "?"
								: "?" + targetBinding.getPosition());
				String result;
				String substring = matcher.group(2);

				int index = resultingQuery.indexOf(substring, currentIndex);
				if (index < 0) {
					result = resultingQuery;
				} else {
					currentIndex = index + replacement.length();
					result = resultingQuery.substring(0, index) + replacement
							+ resultingQuery.substring(index + substring.length());
				}

				resultingQuery = result;
			}

			return resultingQuery;
		}

		private static SpelExtractor createSpelExtractor(String queryWithSpel, boolean parametersShouldBeAccessedByIndex,
				int greatestParameterIndex) {

			/*
			 * If parameters need to be bound by index, we bind the synthetic expression parameters starting from position of the greatest discovered index parameter in order to
			 * not mix-up with the actual parameter indices.
			 */
			int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0;

			BiFunction<Integer, String, String> indexToParameterName = parametersShouldBeAccessedByIndex
					? (index, expression) -> String.valueOf(index + expressionParameterIndex + 1)
					: (index, expression) -> EXPRESSION_PARAMETER_PREFIX + (index + 1);

			String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":";

			BiFunction<String, String, String> parameterNameToReplacement = (prefix, name) -> fixedPrefix + name;

			return SpelQueryContext.of(indexToParameterName, parameterNameToReplacement).parse(queryWithSpel);
		}

		@Nullable
		private static Integer getParameterIndex(@Nullable String parameterIndexString) {

			if (parameterIndexString == null || parameterIndexString.isEmpty()) {
				return null;
			}
			return Integer.valueOf(parameterIndexString);
		}

		private static int tryFindGreatestParameterIndexIn(String query) {

			Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query);

			int greatestParameterIndex = -1;
			while (parameterIndexMatcher.find()) {

				String parameterIndexString = parameterIndexMatcher.group(1);
				Integer parameterIndex = getParameterIndex(parameterIndexString);
				if (parameterIndex != null) {
					greatestParameterIndex = Math.max(greatestParameterIndex, parameterIndex);
				}
			}

			return greatestParameterIndex;
		}

		private static void checkAndRegister(ParameterBinding binding, List<ParameterBinding> bindings) {

			bindings.stream() //
					.filter(it -> it.bindsTo(binding)) //
					.forEach(it -> Assert.isTrue(it.equals(binding), String.format(MESSAGE, it, binding)));

			if (!bindings.contains(binding)) {
				bindings.add(binding);
			}
		}

		/**
		 * An enum for the different types of bindings.
		 *
		 * @author Thomas Darimont
		 * @author Oliver Gierke
		 */
		private enum ParameterBindingType {

			// Trailing whitespace is intentional to reflect that the keywords must be used with at least one whitespace
			// character, while = does not.
			LIKE("like "), IN("in "), AS_IS(null);

			private final @Nullable String keyword;

			ParameterBindingType(@Nullable String keyword) {
				this.keyword = keyword;
			}

			/**
			 * Returns the keyword that will trigger the binding type or {@literal null} if the type is not triggered by a
			 * keyword.
			 *
			 * @return the keyword
			 */
			@Nullable
			public String getKeyword() {
				return keyword;
			}

			/**
			 * Return the appropriate {@link ParameterBindingType} for the given {@link String}. Returns {@literal #AS_IS} in
			 * case no other {@link ParameterBindingType} could be found.
			 */
			static ParameterBindingType of(String typeSource) {

				if (!StringUtils.hasText(typeSource)) {
					return AS_IS;
				}

				for (ParameterBindingType type : values()) {
					if (type.name().equalsIgnoreCase(typeSource.trim())) {
						return type;
					}
				}

				throw new IllegalArgumentException(String.format("Unsupported parameter binding type %s", typeSource));
			}
		}
	}

	private static class Metadata {
		private boolean usesJdbcStyleParameters = false;
	}

	/**
	 * Utility to create unique parameter bindings for LIKE that refer to the same underlying method parameter but are
	 * bound to potentially unique query parameters for {@link LikeParameterBinding#prepare(Object) LIKE rewrite}.
	 *
	 * @author Mark Paluch
	 * @since 3.1.2
	 */
	static class ParameterBindings {

		private final MultiValueMap<BindingIdentifier, ParameterBinding> methodArgumentToLikeBindings = new LinkedMultiValueMap<>();

		private final Consumer<ParameterBinding> registration;
		private int syntheticParameterIndex;

		public ParameterBindings(List<ParameterBinding> bindings, Consumer<ParameterBinding> registration,
				int syntheticParameterIndex) {

			for (ParameterBinding binding : bindings) {
				this.methodArgumentToLikeBindings.put(binding.getIdentifier(), new ArrayList<>(List.of(binding)));
			}

			this.registration = registration;
			this.syntheticParameterIndex = syntheticParameterIndex;
		}

		/**
		 * Return whether the identifier is already bound.
		 *
		 * @param identifier
		 * @return
		 */
		public boolean isBound(BindingIdentifier identifier) {
			return !getBindings(identifier).isEmpty();
		}

		BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin,
				Function<BindingIdentifier, ParameterBinding> bindingFactory) {

			Assert.isInstanceOf(MethodInvocationArgument.class, origin);

			BindingIdentifier methodArgument = ((MethodInvocationArgument) origin).identifier();
			List<ParameterBinding> bindingsForOrigin = getBindings(methodArgument);

			if (!isBound(identifier)) {

				ParameterBinding binding = bindingFactory.apply(identifier);
				registration.accept(binding);
				bindingsForOrigin.add(binding);
				return binding.getIdentifier();
			}

			ParameterBinding binding = bindingFactory.apply(identifier);

			for (ParameterBinding existing : bindingsForOrigin) {

				if (existing.isCompatibleWith(binding)) {
					return existing.getIdentifier();
				}
			}

			BindingIdentifier syntheticIdentifier;
			if (identifier.hasName() && methodArgument.hasName()) {

				int index = 0;
				String newName = methodArgument.getName();
				while (existsBoundParameter(newName)) {
					index++;
					newName = methodArgument.getName() + "_" + index;
				}
				syntheticIdentifier = BindingIdentifier.of(newName);
			} else {
				syntheticIdentifier = BindingIdentifier.of(++syntheticParameterIndex);
			}

			ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier);
			registration.accept(newBinding);
			bindingsForOrigin.add(newBinding);
			return newBinding.getIdentifier();
		}

		private boolean existsBoundParameter(String key) {
			return methodArgumentToLikeBindings.values().stream().flatMap(Collection::stream)
					.anyMatch(it -> key.equals(it.getName()));
		}

		private List<ParameterBinding> getBindings(BindingIdentifier identifier) {
			return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>());
		}

		public void register(ParameterBinding parameterBinding) {
			registration.accept(parameterBinding);
		}
	}
}
