/*
 * Copyright 2012-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.data.rest.webmvc;

import lombok.NonNull;
import lombok.RequiredArgsConstructor;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;

import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.core.mapping.ResourceMetadata;
import org.springframework.data.rest.webmvc.support.JpaHelper;
import org.springframework.data.util.ProxyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.StringValueResolver;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPatternParser;

/**
 * {@link RequestMappingHandlerMapping} implementation that will only find a handler method if a
 * {@link org.springframework.data.repository.Repository} is exported under that URL path segment. Also ensures the
 * {@link OpenEntityManagerInViewInterceptor} is registered in the application context. The OEMIVI is required for the
 * REST exporter to function properly.
 *
 * @author Jon Brisbin
 * @author Oliver Gierke
 * @author Mark Paluch
 */
public class RepositoryRestHandlerMapping extends BasePathAwareHandlerMapping {

	private static final PathPatternParser PARSER = new PathPatternParser();
	static final String EFFECTIVE_LOOKUP_PATH_ATTRIBUTE = RepositoryRestHandlerMapping.class.getName()
			+ ".EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH";

	private final ResourceMappings mappings;
	private final RepositoryRestConfiguration configuration;
	private final Optional<Repositories> repositories;

	private RepositoryCorsConfigurationAccessor corsConfigurationAccessor;
	private Optional<JpaHelper> jpaHelper = Optional.empty();

	/**
	 * Creates a new {@link RepositoryRestHandlerMapping} for the given {@link ResourceMappings} and
	 * {@link RepositoryRestConfiguration}.
	 *
	 * @param mappings must not be {@literal null}.
	 * @param config must not be {@literal null}.
	 */
	public RepositoryRestHandlerMapping(ResourceMappings mappings, RepositoryRestConfiguration config) {
		this(mappings, config, Optional.empty());
	}

	/**
	 * Creates a new {@link RepositoryRestHandlerMapping} for the given {@link ResourceMappings}
	 * {@link RepositoryRestConfiguration} and {@link Repositories}.
	 *
	 * @param mappings must not be {@literal null}.
	 * @param config must not be {@literal null}.
	 * @param repositories must not be {@literal null}.
	 */
	public RepositoryRestHandlerMapping(ResourceMappings mappings, RepositoryRestConfiguration config,
			Repositories repositories) {

		this(mappings, config, Optional.of(repositories));
	}

	private RepositoryRestHandlerMapping(ResourceMappings mappings, RepositoryRestConfiguration config,
			Optional<Repositories> repositories) {

		super(config);

		Assert.notNull(mappings, "ResourceMappings must not be null!");
		Assert.notNull(config, "RepositoryRestConfiguration must not be null!");
		Assert.notNull(repositories, "Repositories must not be null!");

		this.mappings = mappings;
		this.configuration = config;
		this.repositories = repositories;
		this.corsConfigurationAccessor = new RepositoryCorsConfigurationAccessor(mappings, NoOpStringValueResolver.INSTANCE,
				repositories);
	}

	/**
	 * @param jpaHelper the jpaHelper to set
	 */
	public void setJpaHelper(JpaHelper jpaHelper) {
		this.jpaHelper = Optional.ofNullable(jpaHelper);
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping#setEmbeddedValueResolver(org.springframework.util.StringValueResolver)
	 */
	@Override
	public void setEmbeddedValueResolver(StringValueResolver resolver) {

		super.setEmbeddedValueResolver(resolver);

		this.corsConfigurationAccessor = new RepositoryCorsConfigurationAccessor(mappings,
				resolver == null ? NoOpStringValueResolver.INSTANCE : resolver, repositories);
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod(java.lang.String, javax.servlet.http.HttpServletRequest)
	 */
	@Override
	protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {

		HandlerMethod handlerMethod = super.lookupHandlerMethod(lookupPath, request);

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

		String repositoryLookupPath = new BaseUri(configuration.getBaseUri()).getRepositoryLookupPath(lookupPath);

		// Repository root resource
		if (!StringUtils.hasText(repositoryLookupPath)) {
			return handlerMethod;
		}

		String repositoryBasePath = getRepositoryBasePath(repositoryLookupPath);

		if (!mappings.exportsTopLevelResourceFor(repositoryBasePath)) {
			return null;
		}

		exposeEffectiveLookupPathKey(handlerMethod, request, repositoryBasePath);

		return handlerMethod;
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping#handleNoMatch(java.util.Set, java.lang.String, javax.servlet.http.HttpServletRequest)
	 */
	@Override
	protected HandlerMethod handleNoMatch(Set<RequestMappingInfo> requestMappingInfos, String lookupPath,
			HttpServletRequest request) throws ServletException {
		return null;
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping#isHandler(java.lang.Class)
	 */
	@Override
	protected boolean isHandler(Class<?> beanType) {

		Class<?> type = ProxyUtils.getUserClass(beanType);

		return AnnotationUtils.findAnnotation(type, RepositoryRestController.class) != null;
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.servlet.handler.AbstractHandlerMapping#extendInterceptors(java.util.List)
	 */
	@Override
	protected void extendInterceptors(List<Object> interceptors) {

		jpaHelper.map(JpaHelper::getInterceptors) //
				.orElseGet(() -> Collections.emptyList()) //
				.forEach(interceptors::add);
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.rest.webmvc.BasePathAwareHandlerMapping#process(org.springframework.web.servlet.mvc.condition.ProducesRequestCondition)
	 */
	@Override
	protected ProducesRequestCondition customize(ProducesRequestCondition condition) {

		if (!condition.isEmpty()) {
			return condition;
		}

		HashSet<String> mediaTypes = new LinkedHashSet<String>();
		mediaTypes.add(configuration.getDefaultMediaType().toString());
		mediaTypes.add(MediaType.APPLICATION_JSON_VALUE);

		return new ProducesRequestCondition(mediaTypes.toArray(new String[mediaTypes.size()]));
	}

	/* (non-Javadoc)
	 * @see org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#getCorsConfiguration(java.lang.Object, javax.servlet.http.HttpServletRequest)
	 */
	@Override
	protected CorsConfiguration getCorsConfiguration(Object handler, HttpServletRequest request) {

		String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
		String repositoryLookupPath = new BaseUri(configuration.getBaseUri()).getRepositoryLookupPath(lookupPath);
		CorsConfiguration corsConfiguration = super.getCorsConfiguration(handler, request);

		return repositories.filter(it -> StringUtils.hasText(repositoryLookupPath))//
				.flatMap(it -> corsConfigurationAccessor.findCorsConfiguration(repositoryLookupPath))
				.map(it -> it.combine(corsConfiguration))//
				.orElse(corsConfiguration);
	}

	/**
	 * Returns the first segment of the given repository lookup path.
	 *
	 * @param repositoryLookupPath must not be {@literal null}.
	 * @return
	 */
	private static String getRepositoryBasePath(String repositoryLookupPath) {

		int secondSlashIndex = repositoryLookupPath.indexOf('/', repositoryLookupPath.startsWith("/") ? 1 : 0);
		return secondSlashIndex == -1 ? repositoryLookupPath : repositoryLookupPath.substring(0, secondSlashIndex);
	}

	/**
	 * Exposes the effective repository resource lookup path as request attribute via
	 * {@link #EFFECTIVE_LOOKUP_PATH_ATTRIBUTE}, i.e. {@code /people/search/\{search\}} instead of
	 * {@code /\{repository\}/search/\{search\}}.
	 *
	 * @param method must not be {@literal null}.
	 * @param request must not be {@literal null}.
	 * @param repositoryBasePath must not be {@literal null}.
	 */
	private void exposeEffectiveLookupPathKey(HandlerMethod method, HttpServletRequest request,
			String repositoryBasePath) {

		RequestMappingInfo mappingInfo = getMappingForMethod(method.getMethod(), method.getBeanType());

		if (mappingInfo == null) {
			return;
		}

		String pattern = mappingInfo.getPatternsCondition() //
				.getMatchingCondition(request)//
				.getPatterns() //
				.iterator().next();

		request.setAttribute(EFFECTIVE_LOOKUP_PATH_ATTRIBUTE,
				PARSER.parse(pattern.replace("/{repository}", repositoryBasePath)));
	}

	/**
	 * No-op {@link StringValueResolver} that returns the given {@link String} value as is.
	 *
	 * @author Oliver Gierke
	 * @since 2.6
	 */
	enum NoOpStringValueResolver implements StringValueResolver {

		INSTANCE;

		/*
		 * (non-Javadoc)
		 * @see org.springframework.util.StringValueResolver#resolveStringValue(java.lang.String)
		 */
		@Override
		public String resolveStringValue(String value) {
			return value;
		}
	}

	/**
	 * Accessor to obtain {@link CorsConfiguration} for exposed repositories.
	 * <p>
	 * Exported repository classes can be annotated with {@link CrossOrigin} to configure CORS for a specific repository.
	 *
	 * @author Mark Paluch
	 * @author Oliver Gierke
	 * @since 2.6
	 */
	@RequiredArgsConstructor
	static class RepositoryCorsConfigurationAccessor {

		private final @NonNull ResourceMappings mappings;
		private final @NonNull StringValueResolver embeddedValueResolver;
		private final @NonNull Optional<Repositories> repositories;

		Optional<CorsConfiguration> findCorsConfiguration(String lookupPath) {

			return getResourceMetadata(getRepositoryBasePath(lookupPath))//
					.flatMap(it -> repositories.flatMap(foo -> foo.getRepositoryInformationFor(it.getDomainType())))//
					.map(it -> it.getRepositoryInterface())//
					.map(it -> createConfiguration(it));
		}

		private Optional<ResourceMetadata> getResourceMetadata(String basePath) {

			if (!mappings.exportsTopLevelResourceFor(basePath)) {
				return Optional.empty();
			}

			return mappings.stream()//
					.filter(it -> it.getPath().matches(basePath) && it.isExported())//
					.findFirst();
		}

		/**
		 * Creates {@link CorsConfiguration} from a repository interface.
		 *
		 * @param repositoryInterface the repository interface
		 * @return {@link CorsConfiguration} or {@literal null}.
		 */
		protected CorsConfiguration createConfiguration(Class<?> repositoryInterface) {

			CrossOrigin typeAnnotation = AnnotatedElementUtils.findMergedAnnotation(repositoryInterface, CrossOrigin.class);

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

			CorsConfiguration config = new CorsConfiguration();
			updateCorsConfig(config, typeAnnotation);

			if (CollectionUtils.isEmpty(config.getAllowedOrigins())) {
				config.setAllowedOrigins(Arrays.asList(CrossOrigin.DEFAULT_ORIGINS));
			}

			if (CollectionUtils.isEmpty(config.getAllowedMethods())) {
				for (HttpMethod httpMethod : HttpMethod.values()) {
					config.addAllowedMethod(httpMethod);
				}
			}

			if (CollectionUtils.isEmpty(config.getAllowedHeaders())) {
				config.setAllowedHeaders(Arrays.asList(CrossOrigin.DEFAULT_ALLOWED_HEADERS));
			}

			if (config.getAllowCredentials() == null) {
				config.setAllowCredentials(CrossOrigin.DEFAULT_ALLOW_CREDENTIALS);
			}

			if (config.getMaxAge() == null) {
				config.setMaxAge(CrossOrigin.DEFAULT_MAX_AGE);
			}

			return config;
		}

		private void updateCorsConfig(CorsConfiguration config, CrossOrigin annotation) {

			for (String origin : annotation.origins()) {
				config.addAllowedOrigin(resolveCorsAnnotationValue(origin));
			}

			for (RequestMethod method : annotation.methods()) {
				config.addAllowedMethod(method.name());
			}

			for (String header : annotation.allowedHeaders()) {
				config.addAllowedHeader(resolveCorsAnnotationValue(header));
			}

			for (String header : annotation.exposedHeaders()) {
				config.addExposedHeader(resolveCorsAnnotationValue(header));
			}

			String allowCredentials = resolveCorsAnnotationValue(annotation.allowCredentials());

			if ("true".equalsIgnoreCase(allowCredentials)) {
				config.setAllowCredentials(true);
			} else if ("false".equalsIgnoreCase(allowCredentials)) {
				config.setAllowCredentials(false);
			} else if (!allowCredentials.isEmpty()) {
				throw new IllegalStateException("@CrossOrigin's allowCredentials value must be \"true\", \"false\", "
						+ "or an empty string (\"\"): current value is [" + allowCredentials + "]");
			}

			if (annotation.maxAge() >= 0 && config.getMaxAge() == null) {
				config.setMaxAge(annotation.maxAge());
			}
		}

		private String resolveCorsAnnotationValue(String value) {
			return this.embeddedValueResolver.resolveStringValue(value);
		}
	}
}
