/*
 * Copyright 2012-2017 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.boot.actuate.autoconfigure;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.junit.Test;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

import org.springframework.boot.actuate.metrics.CounterService;
import org.springframework.boot.actuate.metrics.GaugeService;
import org.springframework.boot.test.util.EnvironmentTestUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.NestedServletException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * Tests for {@link MetricFilterAutoConfiguration}.
 *
 * @author Phillip Webb
 * @author Andy Wilkinson
 * @author Stephane Nicoll
 */
public class MetricFilterAutoConfigurationTests {

	@Test
	public void defaultMetricFilterAutoConfigurationProperties() {
		MetricFilterProperties properties = new MetricFilterProperties();
		assertThat(properties.getGaugeSubmissions())
				.containsExactly(MetricsFilterSubmission.MERGED);
		assertThat(properties.getCounterSubmissions())
				.containsExactly(MetricsFilterSubmission.MERGED);
	}

	@Test
	public void recordsHttpInteractions() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		final MockHttpServletRequest request = new MockHttpServletRequest("GET",
				"/test/path");
		final MockHttpServletResponse response = new MockHttpServletResponse();
		FilterChain chain = mock(FilterChain.class);
		willAnswer(new Answer<Object>() {
			@Override
			public Object answer(InvocationOnMock invocation) throws Throwable {
				response.setStatus(200);
				return null;
			}
		}).given(chain).doFilter(request, response);
		filter.doFilter(request, response, chain);
		verify(context.getBean(CounterService.class)).increment("status.200.test.path");
		verify(context.getBean(GaugeService.class)).submit(eq("response.test.path"),
				anyDouble());
		context.close();
	}

	@Test
	public void recordsHttpInteractionsWithTemplateVariable() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/templateVarTest/foo")).andExpect(status().isOk());
		verify(context.getBean(CounterService.class))
				.increment("status.200.templateVarTest.someVariable");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.templateVarTest.someVariable"), anyDouble());
		context.close();
	}

	@Test
	public void recordsHttpInteractionsWithRegexTemplateVariable() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/templateVarRegexTest/foo")).andExpect(status().isOk());
		verify(context.getBean(CounterService.class))
				.increment("status.200.templateVarRegexTest.someVariable");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.templateVarRegexTest.someVariable"), anyDouble());
		context.close();
	}

	@Test
	public void recordsHttpInteractionsWithWildcardMapping() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/wildcardMapping/foo")).andExpect(status().isOk());
		verify(context.getBean(CounterService.class))
				.increment("status.200.wildcardMapping.star");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.wildcardMapping.star"), anyDouble());
		context.close();
	}

	@Test
	public void recordsHttpInteractionsWithDoubleWildcardMapping() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/doubleWildcardMapping/foo/bar/baz")).andExpect(status().isOk());
		verify(context.getBean(CounterService.class))
				.increment("status.200.doubleWildcardMapping.star-star.baz");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.doubleWildcardMapping.star-star.baz"), anyDouble());
		context.close();
	}

	@Test
	public void recordsKnown404HttpInteractionsAsSingleMetricWithPathAndTemplateVariable()
			throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/knownPath/foo")).andExpect(status().isNotFound());
		verify(context.getBean(CounterService.class))
				.increment("status.404.knownPath.someVariable");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.knownPath.someVariable"), anyDouble());
		context.close();
	}

	@Test
	public void records404HttpInteractionsAsSingleMetric() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/unknownPath/1")).andExpect(status().isNotFound());
		mvc.perform(get("/unknownPath/2")).andExpect(status().isNotFound());
		verify(context.getBean(CounterService.class), times(2))
				.increment("status.404.unmapped");
		verify(context.getBean(GaugeService.class), times(2))
				.submit(eq("response.unmapped"), anyDouble());
		context.close();
	}

	@Test
	public void records302HttpInteractionsAsSingleMetric() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class, RedirectFilter.class);
		MetricsFilter filter = context.getBean(MetricsFilter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).addFilter(context.getBean(RedirectFilter.class))
				.build();
		mvc.perform(get("/unknownPath/1")).andExpect(status().is3xxRedirection());
		mvc.perform(get("/unknownPath/2")).andExpect(status().is3xxRedirection());
		verify(context.getBean(CounterService.class), times(2))
				.increment("status.302.unmapped");
		verify(context.getBean(GaugeService.class), times(2))
				.submit(eq("response.unmapped"), anyDouble());
		context.close();
	}

	@Test
	public void skipsFilterIfMissingServices() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				MetricFilterAutoConfiguration.class);
		assertThat(context.getBeansOfType(Filter.class).size()).isEqualTo(0);
		context.close();
	}

	@Test
	public void skipsFilterIfPropertyDisabled() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
		EnvironmentTestUtils.addEnvironment(context,
				"endpoints.metrics.filter.enabled:false");
		context.register(Config.class, MetricFilterAutoConfiguration.class);
		context.refresh();
		assertThat(context.getBeansOfType(Filter.class).size()).isEqualTo(0);
		context.close();
	}

	@Test
	public void controllerMethodThatThrowsUnhandledException() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		try {
			mvc.perform(get("/unhandledException"))
					.andExpect(status().isInternalServerError());
		}
		catch (NestedServletException ex) {
			// Expected
		}
		verify(context.getBean(CounterService.class))
				.increment("status.500.unhandledException");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.unhandledException"), anyDouble());
		context.close();
	}

	@Test
	public void gaugeServiceThatThrows() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		GaugeService gaugeService = context.getBean(GaugeService.class);
		willThrow(new IllegalStateException()).given(gaugeService).submit(anyString(),
				anyDouble());
		Filter filter = context.getBean(Filter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter).build();
		mvc.perform(get("/templateVarTest/foo")).andExpect(status().isOk());
		verify(context.getBean(CounterService.class))
				.increment("status.200.templateVarTest.someVariable");
		verify(context.getBean(GaugeService.class))
				.submit(eq("response.templateVarTest.someVariable"), anyDouble());
		context.close();
	}

	@Test
	public void correctlyRecordsMetricsForDeferredResultResponse() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		MetricsFilter filter = context.getBean(MetricsFilter.class);
		CountDownLatch latch = new CountDownLatch(1);
		MockMvc mvc = MockMvcBuilders
				.standaloneSetup(new MetricFilterTestController(latch)).addFilter(filter)
				.build();
		String attributeName = MetricsFilter.class.getName() + ".StopWatch";
		MvcResult result = mvc.perform(post("/create")).andExpect(status().isOk())
				.andExpect(request().asyncStarted())
				.andExpect(request().attribute(attributeName, is(notNullValue())))
				.andReturn();
		latch.countDown();
		mvc.perform(asyncDispatch(result)).andExpect(status().isCreated())
				.andExpect(request().attribute(attributeName, is(nullValue())));
		verify(context.getBean(CounterService.class)).increment("status.201.create");
		context.close();
	}

	@Test
	public void correctlyRecordsMetricsForFailedDeferredResultResponse()
			throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class);
		MetricsFilter filter = context.getBean(MetricsFilter.class);
		CountDownLatch latch = new CountDownLatch(1);
		MockMvc mvc = MockMvcBuilders
				.standaloneSetup(new MetricFilterTestController(latch)).addFilter(filter)
				.build();
		String attributeName = MetricsFilter.class.getName() + ".StopWatch";
		MvcResult result = mvc.perform(post("/createFailure")).andExpect(status().isOk())
				.andExpect(request().asyncStarted())
				.andExpect(request().attribute(attributeName, is(notNullValue())))
				.andReturn();
		latch.countDown();
		try {
			mvc.perform(asyncDispatch(result));
			fail();
		}
		catch (Exception ex) {
			assertThat(result.getRequest().getAttribute(attributeName)).isNull();
			verify(context.getBean(CounterService.class))
					.increment("status.500.createFailure");
		}
		finally {
			context.close();
		}
	}

	@Test
	public void records5xxxHttpInteractionsAsSingleMetric() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
				Config.class, MetricFilterAutoConfiguration.class,
				ServiceUnavailableFilter.class);
		MetricsFilter filter = context.getBean(MetricsFilter.class);
		MockMvc mvc = MockMvcBuilders.standaloneSetup(new MetricFilterTestController())
				.addFilter(filter)
				.addFilter(context.getBean(ServiceUnavailableFilter.class)).build();
		mvc.perform(get("/unknownPath/1")).andExpect(status().isServiceUnavailable());
		mvc.perform(get("/unknownPath/2")).andExpect(status().isServiceUnavailable());
		verify(context.getBean(CounterService.class), times(2))
				.increment("status.503.unmapped");
		verify(context.getBean(GaugeService.class), times(2))
				.submit(eq("response.unmapped"), anyDouble());
		context.close();
	}

	@Test
	public void additionallyRecordsMetricsWithHttpMethodNameIfConfigured()
			throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
		context.register(Config.class, MetricFilterAutoConfiguration.class);
		EnvironmentTestUtils.addEnvironment(context,
				"endpoints.metrics.filter.gauge-submissions=merged,per-http-method",
				"endpoints.metrics.filter.counter-submissions=merged,per-http-method");
		context.refresh();
		Filter filter = context.getBean(Filter.class);
		final MockHttpServletRequest request = new MockHttpServletRequest("PUT",
				"/test/path");
		final MockHttpServletResponse response = new MockHttpServletResponse();
		FilterChain chain = mock(FilterChain.class);
		willAnswer(new Answer<Object>() {
			@Override
			public Object answer(InvocationOnMock invocation) throws Throwable {
				response.setStatus(200);
				return null;
			}
		}).given(chain).doFilter(request, response);
		filter.doFilter(request, response, chain);
		verify(context.getBean(GaugeService.class)).submit(eq("response.test.path"),
				anyDouble());
		verify(context.getBean(GaugeService.class)).submit(eq("response.PUT.test.path"),
				anyDouble());
		verify(context.getBean(CounterService.class))
				.increment(eq("status.200.test.path"));
		verify(context.getBean(CounterService.class))
				.increment(eq("status.PUT.200.test.path"));
		context.close();
	}

	@Test
	public void doesNotRecordRolledUpMetricsIfConfigured() throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
		context.register(Config.class, MetricFilterAutoConfiguration.class);
		EnvironmentTestUtils.addEnvironment(context,
				"endpoints.metrics.filter.gauge-submissions=",
				"endpoints.metrics.filter.counter-submissions=");
		context.refresh();
		Filter filter = context.getBean(Filter.class);
		final MockHttpServletRequest request = new MockHttpServletRequest("PUT",
				"/test/path");
		final MockHttpServletResponse response = new MockHttpServletResponse();
		FilterChain chain = mock(FilterChain.class);
		willAnswer(new Answer<Object>() {
			@Override
			public Object answer(InvocationOnMock invocation) throws Throwable {
				response.setStatus(200);
				return null;
			}
		}).given(chain).doFilter(request, response);
		filter.doFilter(request, response, chain);
		verify(context.getBean(GaugeService.class), never()).submit(anyString(),
				anyDouble());
		verify(context.getBean(CounterService.class), never()).increment(anyString());
		context.close();
	}

	@Test
	public void whenExceptionIsThrownResponseStatusIsUsedWhenResponseHasBeenCommitted()
			throws Exception {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
		context.register(Config.class, MetricFilterAutoConfiguration.class);
		context.refresh();
		Filter filter = context.getBean(Filter.class);
		final MockHttpServletRequest request = new MockHttpServletRequest("GET",
				"/test/path");
		final MockHttpServletResponse response = new MockHttpServletResponse();
		FilterChain chain = mock(FilterChain.class);
		willAnswer(new Answer<Object>() {
			@Override
			public Object answer(InvocationOnMock invocation) throws Throwable {
				response.setStatus(200);
				response.setCommitted(true);
				throw new IOException();
			}
		}).given(chain).doFilter(request, response);
		try {
			filter.doFilter(request, response, chain);
			fail();
		}
		catch (IOException ex) {
			// Continue
		}
		verify(context.getBean(CounterService.class))
				.increment(eq("status.200.test.path"));
		context.close();
	}

	@Configuration
	public static class Config {

		@Bean
		public CounterService counterService() {
			return mock(CounterService.class);
		}

		@Bean
		public GaugeService gaugeService() {
			return mock(GaugeService.class);
		}

	}

	@RestController
	class MetricFilterTestController {

		private final CountDownLatch latch;

		MetricFilterTestController() {
			this(null);
		}

		MetricFilterTestController(CountDownLatch latch) {
			this.latch = latch;
		}

		@RequestMapping("templateVarTest/{someVariable}")
		public String testTemplateVariableResolution(@PathVariable String someVariable) {
			return someVariable;
		}

		@RequestMapping("wildcardMapping/*")
		public String testWildcardMapping() {
			return "wildcard";
		}

		@RequestMapping("doubleWildcardMapping/**/baz")
		public String testDoubleWildcardMapping() {
			return "doubleWildcard";
		}

		@RequestMapping("templateVarRegexTest/{someVariable:[a-z]+}")
		public String testTemplateVariableRegexResolution(
				@PathVariable String someVariable) {
			return someVariable;
		}

		@RequestMapping("knownPath/{someVariable}")
		@ResponseStatus(HttpStatus.NOT_FOUND)
		@ResponseBody
		public String testKnownPathWith404Response(@PathVariable String someVariable) {
			return someVariable;
		}

		@ResponseBody
		@RequestMapping("unhandledException")
		public String testException() {
			throw new RuntimeException();
		}

		@RequestMapping("create")
		public DeferredResult<ResponseEntity<String>> create() {
			final DeferredResult<ResponseEntity<String>> result = new DeferredResult<>();
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						MetricFilterTestController.this.latch.await();
						result.setResult(
								new ResponseEntity<>("Done", HttpStatus.CREATED));
					}
					catch (InterruptedException ex) {
					}
				}
			}).start();
			return result;
		}

		@RequestMapping("createFailure")
		public DeferredResult<ResponseEntity<String>> createFailure() {
			final DeferredResult<ResponseEntity<String>> result = new DeferredResult<>();
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						MetricFilterTestController.this.latch.await();
						result.setErrorResult(new Exception("It failed"));
					}
					catch (InterruptedException ex) {

					}
				}
			}).start();
			return result;
		}

	}

	@Component
	@Order(0)
	public static class RedirectFilter extends OncePerRequestFilter {

		@Override
		protected void doFilterInternal(HttpServletRequest request,
				HttpServletResponse response, FilterChain chain)
						throws ServletException, IOException {
			// send redirect before filter chain is executed, like Spring Security sending
			// us back to a login page
			response.sendRedirect("http://example.com");
		}

	}

	@Component
	@Order(0)
	public static class ServiceUnavailableFilter extends OncePerRequestFilter {

		@Override
		protected void doFilterInternal(HttpServletRequest request,
				HttpServletResponse response, FilterChain chain)
						throws ServletException, IOException {

			response.sendError(HttpStatus.SERVICE_UNAVAILABLE.value());
		}

	}

}
