/*
 * Copyright 2002-2016 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.web.reactive.result.method;

import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;

import org.junit.Before;
import org.junit.Test;
import reactor.core.publisher.Mono;

import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.MockServerHttpRequest;
import org.springframework.http.server.reactive.MockServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Controller;
import org.springframework.tests.TestSubscriber;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.ModelMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.result.ResolvableMethod;
import org.springframework.web.reactive.result.method.RequestMappingInfo.BuilderConfiguration;
import org.springframework.web.server.MethodNotAllowedException;
import org.springframework.web.server.NotAcceptableStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.server.UnsupportedMediaTypeStatusException;
import org.springframework.web.server.adapter.DefaultServerWebExchange;
import org.springframework.web.server.session.MockWebSessionManager;
import org.springframework.web.server.session.WebSessionManager;
import org.springframework.web.util.HttpRequestPathHelper;

import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.springframework.web.bind.annotation.RequestMethod.GET;
import static org.springframework.web.bind.annotation.RequestMethod.HEAD;
import static org.springframework.web.bind.annotation.RequestMethod.OPTIONS;
import static org.springframework.web.reactive.result.method.RequestMappingInfo.paths;

/**
 * Unit tests for {@link RequestMappingInfoHandlerMapping}.
 * @author Rossen Stoyanchev
 */
public class RequestMappingInfoHandlerMappingTests {

	private TestRequestMappingInfoHandlerMapping handlerMapping;


	@Before
	public void setUp() throws Exception {
		this.handlerMapping = new TestRequestMappingInfoHandlerMapping();
		this.handlerMapping.registerHandler(new TestController());
	}


	@Test
	public void getMappingPathPatterns() throws Exception {
		String[] patterns = {"/foo/*", "/foo", "/bar/*", "/bar"};
		RequestMappingInfo info = paths(patterns).build();
		Set<String> actual = this.handlerMapping.getMappingPathPatterns(info);

		assertEquals(new HashSet<>(Arrays.asList(patterns)), actual);
	}

	@Test
	public void getHandlerDirectMatch() throws Exception {
		String[] patterns = new String[] {"/foo"};
		String[] params = new String[] {};
		Method expected = resolveMethod(new TestController(), patterns, null, params);

		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo");
		HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();

		assertEquals(expected, hm.getMethod());
	}

	@Test
	public void getHandlerGlobMatch() throws Exception {
		String[] patterns = new String[] {"/ba*"};
		RequestMethod[] methods = new RequestMethod[] {GET, HEAD};
		Method expected = resolveMethod(new TestController(), patterns, methods, null);

		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/bar");
		HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();

		assertEquals(expected, hm.getMethod());
	}

	@Test
	public void getHandlerEmptyPathMatch() throws Exception {
		String[] patterns = new String[] {""};
		Method expected = resolveMethod(new TestController(), patterns, null, null);

		ServerWebExchange exchange = createExchange(HttpMethod.GET, "");
		HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
		assertEquals(expected, hm.getMethod());

		exchange = createExchange(HttpMethod.GET, "/");
		hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
		assertEquals(expected, hm.getMethod());
	}

	@Test
	public void getHandlerBestMatch() throws Exception {
		String[] patterns = new String[] {"/foo"};
		String[] params = new String[] {"p"};
		Method expected = resolveMethod(new TestController(), patterns, null, params);

		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo");
		exchange.getRequest().getQueryParams().add("p", "anything");
		HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();

		assertEquals(expected, hm.getMethod());
	}

	@Test
	public void getHandlerRequestMethodNotAllowed() throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.POST, "/bar");
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);

		assertError(mono, MethodNotAllowedException.class,
				ex -> assertEquals(new HashSet<>(Arrays.asList("GET", "HEAD")), ex.getSupportedMethods()));
	}

	@Test // SPR-9603
	public void getHandlerRequestMethodMatchFalsePositive() throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/users");
		exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML));
		this.handlerMapping.registerHandler(new UserController());
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);

		TestSubscriber.subscribe(mono).assertError(NotAcceptableStatusException.class);
	}

	@Test // SPR-8462
	public void getHandlerMediaTypeNotSupported() throws Exception {
		testHttpMediaTypeNotSupportedException("/person/1");
		testHttpMediaTypeNotSupportedException("/person/1/");
		testHttpMediaTypeNotSupportedException("/person/1.json");
	}

	@Test
	public void getHandlerTestInvalidContentType() throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.PUT, "/person/1");
		exchange.getRequest().getHeaders().add("Content-Type", "bogus");
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);

		assertError(mono, UnsupportedMediaTypeStatusException.class,
				ex -> assertEquals("Request failure [status: 415, " +
						"reason: \"Invalid mime type \"bogus\": does not contain '/'\"]",
						ex.getMessage()));
	}

	@Test // SPR-8462
	public void getHandlerTestMediaTypeNotAcceptable() throws Exception {
		testMediaTypeNotAcceptable("/persons");
		testMediaTypeNotAcceptable("/persons/");
		testMediaTypeNotAcceptable("/persons.json");
	}

	@Test // SPR-12854
	public void getHandlerTestRequestParamMismatch() throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/params");
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);
		assertError(mono, ServerWebInputException.class, ex -> {
			assertThat(ex.getReason(), containsString("[foo=bar]"));
			assertThat(ex.getReason(), containsString("[bar=baz]"));
		});
	}

	@Test
	public void getHandlerHttpOptions() throws Exception {
		testHttpOptions("/foo", "GET,HEAD");
		testHttpOptions("/person/1", "PUT");
		testHttpOptions("/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS");
		testHttpOptions("/something", "PUT,POST");
	}

	@Test
	public void getHandlerProducibleMediaTypesAttribute() throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/content");
		exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML));
		this.handlerMapping.getHandler(exchange).block();

		String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
		assertEquals(Collections.singleton(MediaType.APPLICATION_XML), exchange.getAttributes().get(name));

		exchange = createExchange(HttpMethod.GET, "/content");
		exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
		this.handlerMapping.getHandler(exchange).block();

		assertNull("Negated expression shouldn't be listed as producible type",
				exchange.getAttributes().get(name));
	}

	@Test @SuppressWarnings("unchecked")
	public void handleMatchUriTemplateVariables() throws Exception {
		RequestMappingInfo key = paths("/{path1}/{path2}").build();
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2");
		String lookupPath = exchange.getRequest().getURI().getPath();
		this.handlerMapping.handleMatch(key, lookupPath, exchange);

		String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
		Map<String, String> uriVariables = (Map<String, String>) exchange.getAttributes().get(name);

		assertNotNull(uriVariables);
		assertEquals("1", uriVariables.get("path1"));
		assertEquals("2", uriVariables.get("path2"));
	}

	@Test // SPR-9098
	public void handleMatchUriTemplateVariablesDecode() throws Exception {
		RequestMappingInfo key = paths("/{group}/{identifier}").build();
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/group/a%2Fb");

		HttpRequestPathHelper pathHelper = new HttpRequestPathHelper();
		pathHelper.setUrlDecode(false);
		String lookupPath = pathHelper.getLookupPathForRequest(exchange);

		this.handlerMapping.setPathHelper(pathHelper);
		this.handlerMapping.handleMatch(key, lookupPath, exchange);

		String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
		@SuppressWarnings("unchecked")
		Map<String, String> uriVariables = (Map<String, String>) exchange.getAttributes().get(name);

		assertNotNull(uriVariables);
		assertEquals("group", uriVariables.get("group"));
		assertEquals("a/b", uriVariables.get("identifier"));
	}

	@Test
	public void handleMatchBestMatchingPatternAttribute() throws Exception {
		RequestMappingInfo key = paths("/{path1}/2", "/**").build();
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2");
		this.handlerMapping.handleMatch(key, "/1/2", exchange);

		assertEquals("/{path1}/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE));
	}

	@Test
	public void handleMatchBestMatchingPatternAttributeNoPatternsDefined() throws Exception {
		RequestMappingInfo key = paths().build();
		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2");

		this.handlerMapping.handleMatch(key, "/1/2", exchange);

		assertEquals("/1/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE));
	}

	@Test
	public void handleMatchMatrixVariables() throws Exception {
		ServerWebExchange exchange;
		MultiValueMap<String, String> matrixVariables;
		Map<String, String> uriVariables;

		exchange = createExchange(HttpMethod.GET, "/");
		handleMatch(exchange, "/{cars}", "/cars;colors=red,blue,green;year=2012");

		matrixVariables = getMatrixVariables(exchange, "cars");
		uriVariables = getUriTemplateVariables(exchange);

		assertNotNull(matrixVariables);
		assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors"));
		assertEquals("2012", matrixVariables.getFirst("year"));
		assertEquals("cars", uriVariables.get("cars"));

		exchange = createExchange(HttpMethod.GET, "/");
		handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars;colors=red,blue,green;year=2012");

		matrixVariables = getMatrixVariables(exchange, "params");
		uriVariables = getUriTemplateVariables(exchange);

		assertNotNull(matrixVariables);
		assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors"));
		assertEquals("2012", matrixVariables.getFirst("year"));
		assertEquals("cars", uriVariables.get("cars"));
		assertEquals(";colors=red,blue,green;year=2012", uriVariables.get("params"));

		exchange = createExchange(HttpMethod.GET, "/");
		handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars");

		matrixVariables = getMatrixVariables(exchange, "params");
		uriVariables = getUriTemplateVariables(exchange);

		assertNull(matrixVariables);
		assertEquals("cars", uriVariables.get("cars"));
		assertEquals("", uriVariables.get("params"));
	}

	@Test
	public void handleMatchMatrixVariablesDecoding() throws Exception {
		HttpRequestPathHelper urlPathHelper = new HttpRequestPathHelper();
		urlPathHelper.setUrlDecode(false);
		this.handlerMapping.setPathHelper(urlPathHelper);

		ServerWebExchange exchange = createExchange(HttpMethod.GET, "/");
		handleMatch(exchange, "/path{filter}", "/path;mvar=a%2fb");

		MultiValueMap<String, String> matrixVariables = getMatrixVariables(exchange, "filter");
		Map<String, String> uriVariables = getUriTemplateVariables(exchange);

		assertNotNull(matrixVariables);
		assertEquals(Collections.singletonList("a/b"), matrixVariables.get("mvar"));
		assertEquals(";mvar=a/b", uriVariables.get("filter"));
	}


	private ServerWebExchange createExchange(HttpMethod method, String url) throws URISyntaxException {
		ServerHttpRequest request = new MockServerHttpRequest(method, new URI(url));
		WebSessionManager sessionManager = new MockWebSessionManager();
		return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager);

	}

	@SuppressWarnings("unchecked")
	private <T> void assertError(Mono<Object> mono, final Class<T> exceptionClass, final Consumer<T> consumer)  {
		TestSubscriber
				.subscribe(mono)
				.assertErrorWith(ex -> {
					assertEquals(exceptionClass, ex.getClass());
					consumer.accept((T) ex);
				});
	}


	private void testHttpMediaTypeNotSupportedException(String url) throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.PUT, url);
		exchange.getRequest().getHeaders().setContentType(MediaType.APPLICATION_JSON);
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);

		assertError(mono, UnsupportedMediaTypeStatusException.class, ex ->
				assertEquals("Invalid supported consumable media types",
						Collections.singletonList(new MediaType("application", "xml")),
						ex.getSupportedMediaTypes()));
	}

	private void testHttpOptions(String requestURI, String allowHeader) throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.OPTIONS, requestURI);
		HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();

		ModelMap model = new ExtendedModelMap();
		Mono<HandlerResult> mono = new InvocableHandlerMethod(handlerMethod).invokeForRequest(exchange, model);

		HandlerResult result = mono.block();
		assertNotNull(result);

		Optional<Object> value = result.getReturnValue();
		assertTrue(value.isPresent());
		assertEquals(HttpHeaders.class, value.get().getClass());
		assertEquals(allowHeader, ((HttpHeaders) value.get()).getFirst("Allow"));
	}

	private void testMediaTypeNotAcceptable(String url) throws Exception {
		ServerWebExchange exchange = createExchange(HttpMethod.GET, url);
		exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
		Mono<Object> mono = this.handlerMapping.getHandler(exchange);

		assertError(mono, NotAcceptableStatusException.class, ex ->
				assertEquals("Invalid supported producible media types",
						Collections.singletonList(new MediaType("application", "xml")),
						ex.getSupportedMediaTypes()));
	}

	private void handleMatch(ServerWebExchange exchange, String pattern, String lookupPath) {
		RequestMappingInfo info = paths(pattern).build();
		this.handlerMapping.handleMatch(info, lookupPath, exchange);
	}

	@SuppressWarnings("unchecked")
	private MultiValueMap<String, String> getMatrixVariables(ServerWebExchange exchange, String uriVarName) {
		String attrName = HandlerMapping.MATRIX_VARIABLES_ATTRIBUTE;
		return ((Map<String, MultiValueMap<String, String>>) exchange.getAttributes().get(attrName)).get(uriVarName);
	}

	@SuppressWarnings("unchecked")
	private Map<String, String> getUriTemplateVariables(ServerWebExchange exchange) {
		String attrName = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
		return (Map<String, String>) exchange.getAttributes().get(attrName);
	}

	private Method resolveMethod(Object controller, String[] patterns,
			RequestMethod[] methods, String[] params) {

		return ResolvableMethod.on(controller)
				.matching(method -> {
					RequestMapping annot = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
					if (annot == null) {
						return false;
					}
					else if (patterns != null && !Arrays.equals(annot.path(), patterns)) {
						return false;
					}
					else if (methods != null && !Arrays.equals(annot.method(), methods)) {
						return false;
					}
					else if (params != null && (!Arrays.equals(annot.params(), params))) {
						return false;
					}
					return true;
				})
				.resolve();
	}


	@SuppressWarnings("unused")
	@Controller
	private static class TestController {

		@GetMapping("/foo")
		public void foo() {
		}

		@GetMapping(path = "/foo", params="p")
		public void fooParam() {
		}

		@RequestMapping(path = "/ba*", method = { GET, HEAD })
		public void bar() {
		}

		@RequestMapping(path = "")
		public void empty() {
		}

		@PutMapping(path = "/person/{id}", consumes="application/xml")
		public void consumes(@RequestBody String text) {
		}

		@RequestMapping(path = "/persons", produces="application/xml")
		public String produces() {
			return "";
		}

		@RequestMapping(path = "/params", params="foo=bar")
		public String param() {
			return "";
		}

		@RequestMapping(path = "/params", params="bar=baz")
		public String param2() {
			return "";
		}

		@RequestMapping(path = "/content", produces="application/xml")
		public String xmlContent() {
			return "";
		}

		@RequestMapping(path = "/content", produces="!application/xml")
		public String nonXmlContent() {
			return "";
		}

		@RequestMapping(path = "/something", method = OPTIONS)
		public HttpHeaders fooOptions() {
			HttpHeaders headers = new HttpHeaders();
			headers.add("Allow", "PUT,POST");
			return headers;
		}
	}

	@SuppressWarnings("unused")
	@Controller
	private static class UserController {

		@GetMapping(path = "/users", produces = "application/json")
		public void getUser() {
		}

		@PutMapping(path = "/users")
		public void saveUser() {
		}
	}

	private static class TestRequestMappingInfoHandlerMapping extends RequestMappingInfoHandlerMapping {

		void registerHandler(Object handler) {
			super.detectHandlerMethods(handler);
		}

		@Override
		protected boolean isHandler(Class<?> beanType) {
			return AnnotationUtils.findAnnotation(beanType, RequestMapping.class) != null;
		}

		@Override
		protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
			RequestMapping annot = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
			if (annot != null) {
				BuilderConfiguration options = new BuilderConfiguration();
				options.setPathHelper(getPathHelper());
				options.setPathMatcher(getPathMatcher());
				options.setSuffixPatternMatch(true);
				options.setTrailingSlashMatch(true);
				return paths(annot.value()).methods(annot.method())
						.params(annot.params()).headers(annot.headers())
						.consumes(annot.consumes()).produces(annot.produces())
						.options(options).build();
			}
			else {
				return null;
			}
		}
	}

}