/*
 * 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.endpoint.annotation;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.boot.actuate.endpoint.EndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.EndpointFilter;
import org.springframework.boot.actuate.endpoint.EndpointInfo;
import org.springframework.boot.actuate.endpoint.Operation;
import org.springframework.boot.actuate.endpoint.reflect.OperationMethodInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.reflect.ParameterMapper;
import org.springframework.context.ApplicationContext;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.style.ToStringCreator;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;

/**
 * A base {@link EndpointDiscoverer} implementation that discovers
 * {@link Endpoint @Endpoint} beans and {@link EndpointExtension @EndpointExtension} beans
 * in an application context.
 *
 * @param <K> the type of the operation key
 * @param <T> the type of the operation
 * @author Andy Wilkinson
 * @author Stephane Nicoll
 * @author Phillip Webb
 * @since 2.0.0
 */
public abstract class AnnotationEndpointDiscoverer<K, T extends Operation>
		implements EndpointDiscoverer<T> {

	private final Log logger = LogFactory.getLog(getClass());

	private final ApplicationContext applicationContext;

	private final Function<T, K> operationKeyFactory;

	private final OperationsFactory<T> operationsFactory;

	private final List<EndpointFilter<T>> filters;

	/**
	 * Create a new {@link AnnotationEndpointDiscoverer} instance.
	 * @param applicationContext the application context
	 * @param operationFactory a factory used to create operations
	 * @param operationKeyFactory a factory used to create a key for an operation
	 * @param parameterMapper the {@link ParameterMapper} used to convert arguments when
	 * an operation is invoked
	 * @param invokerAdvisors advisors used to add additional invoker advise
	 * @param filters filters that must match for an endpoint to be exposed
	 */
	protected AnnotationEndpointDiscoverer(ApplicationContext applicationContext,
			OperationFactory<T> operationFactory, Function<T, K> operationKeyFactory,
			ParameterMapper parameterMapper,
			Collection<? extends OperationMethodInvokerAdvisor> invokerAdvisors,
			Collection<? extends EndpointFilter<T>> filters) {
		Assert.notNull(applicationContext, "Application Context must not be null");
		Assert.notNull(operationFactory, "Operation Factory must not be null");
		Assert.notNull(operationKeyFactory, "Operation Key Factory must not be null");
		Assert.notNull(parameterMapper, "Parameter Mapper must not be null");
		this.applicationContext = applicationContext;
		this.operationKeyFactory = operationKeyFactory;
		this.operationsFactory = new OperationsFactory<>(operationFactory,
				parameterMapper, invokerAdvisors);
		this.filters = (filters == null ? Collections.emptyList()
				: new ArrayList<>(filters));
	}

	@Override
	public final Collection<EndpointInfo<T>> discoverEndpoints() {
		Class<T> operationType = getOperationType();
		Map<Class<?>, DiscoveredEndpoint> endpoints = getEndpoints(operationType);
		Map<Class<?>, DiscoveredExtension> extensions = getExtensions(operationType,
				endpoints);
		Collection<DiscoveredEndpoint> exposed = mergeExposed(endpoints, extensions);
		verify(exposed);
		return exposed.stream().map(DiscoveredEndpoint::getInfo)
				.collect(Collectors.toCollection(ArrayList::new));
	}

	/**
	 * Return the operation type being discovered. By default this method will resolve the
	 * class generic "{@code <T>}".
	 * @return the operation type
	 */
	@SuppressWarnings("unchecked")
	protected Class<T> getOperationType() {
		return (Class<T>) ResolvableType
				.forClass(AnnotationEndpointDiscoverer.class, getClass())
				.resolveGeneric(1);
	}

	private Map<Class<?>, DiscoveredEndpoint> getEndpoints(Class<T> operationType) {
		Map<Class<?>, DiscoveredEndpoint> endpoints = new LinkedHashMap<>();
		Map<String, DiscoveredEndpoint> endpointsById = new LinkedHashMap<>();
		String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(
				this.applicationContext, Endpoint.class);
		for (String beanName : beanNames) {
			addEndpoint(endpoints, endpointsById, beanName);
		}
		return endpoints;
	}

	private void addEndpoint(Map<Class<?>, DiscoveredEndpoint> endpoints,
			Map<String, DiscoveredEndpoint> endpointsById, String beanName) {
		Class<?> endpointType = this.applicationContext.getType(beanName);
		Object target = this.applicationContext.getBean(beanName);
		DiscoveredEndpoint endpoint = createEndpoint(target, endpointType);
		String id = endpoint.getInfo().getId();
		DiscoveredEndpoint previous = endpointsById.putIfAbsent(id, endpoint);
		Assert.state(previous == null, () -> "Found two endpoints with the id '" + id
				+ "': " + endpoint + " and " + previous);
		endpoints.put(endpointType, endpoint);
	}

	private DiscoveredEndpoint createEndpoint(Object target, Class<?> endpointType) {
		AnnotationAttributes annotationAttributes = AnnotatedElementUtils
				.findMergedAnnotationAttributes(endpointType, Endpoint.class, true, true);
		String id = annotationAttributes.getString("id");
		Assert.state(StringUtils.hasText(id),
				"No @Endpoint id attribute specified for " + endpointType.getName());
		boolean enabledByDefault = (Boolean) annotationAttributes.get("enableByDefault");
		Collection<T> operations = this.operationsFactory
				.createOperations(id, target, endpointType).values();
		EndpointInfo<T> endpointInfo = new EndpointInfo<>(id, enabledByDefault,
				operations);
		boolean exposed = isEndpointExposed(endpointType, endpointInfo);
		return new DiscoveredEndpoint(endpointType, endpointInfo, exposed);
	}

	private Map<Class<?>, DiscoveredExtension> getExtensions(Class<T> operationType,
			Map<Class<?>, DiscoveredEndpoint> endpoints) {
		Map<Class<?>, DiscoveredExtension> extensions = new LinkedHashMap<>();
		String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(
				this.applicationContext, EndpointExtension.class);
		for (String beanName : beanNames) {
			addExtension(endpoints, extensions, beanName);
		}
		return extensions;
	}

	private void addExtension(Map<Class<?>, DiscoveredEndpoint> endpoints,
			Map<Class<?>, DiscoveredExtension> extensions, String beanName) {
		Class<?> extensionType = this.applicationContext.getType(beanName);
		Class<?> endpointType = getEndpointType(extensionType);
		DiscoveredEndpoint endpoint = getExtendingEndpoint(endpoints, extensionType,
				endpointType);
		if (isExtensionExposed(endpointType, extensionType, endpoint.getInfo())) {
			Assert.state(endpoint.isExposed() || isEndpointFiltered(endpoint.getInfo()),
					() -> "Invalid extension " + extensionType.getName() + "': endpoint '"
							+ endpointType.getName()
							+ "' does not support such extension");
			Object target = this.applicationContext.getBean(beanName);
			Map<Method, T> operations = this.operationsFactory
					.createOperations(endpoint.getInfo().getId(), target, extensionType);
			DiscoveredExtension extension = new DiscoveredExtension(extensionType,
					operations.values());
			DiscoveredExtension previous = extensions.putIfAbsent(endpointType,
					extension);
			Assert.state(previous == null,
					() -> "Found two extensions for the same endpoint '"
							+ endpointType.getName() + "': "
							+ extension.getExtensionType().getName() + " and "
							+ previous.getExtensionType().getName());
		}
	}

	private Class<?> getEndpointType(Class<?> extensionType) {
		AnnotationAttributes attributes = AnnotatedElementUtils
				.getMergedAnnotationAttributes(extensionType, EndpointExtension.class);
		Class<?> endpointType = attributes.getClass("endpoint");
		Assert.state(!endpointType.equals(Void.class), () -> "Extension "
				+ endpointType.getName() + " does not specify an endpoint");
		return endpointType;
	}

	private DiscoveredEndpoint getExtendingEndpoint(
			Map<Class<?>, DiscoveredEndpoint> endpoints, Class<?> extensionType,
			Class<?> endpointType) {
		DiscoveredEndpoint endpoint = endpoints.get(endpointType);
		Assert.state(endpoint != null,
				() -> "Invalid extension '" + extensionType.getName()
						+ "': no endpoint found with type '" + endpointType.getName()
						+ "'");
		return endpoint;
	}

	private boolean isEndpointExposed(Class<?> endpointType,
			EndpointInfo<T> endpointInfo) {
		if (isEndpointFiltered(endpointInfo)) {
			return false;
		}
		AnnotationAttributes annotationAttributes = AnnotatedElementUtils
				.getMergedAnnotationAttributes(endpointType, FilteredEndpoint.class);
		if (annotationAttributes == null) {
			return true;
		}
		Class<?> filterClass = annotationAttributes.getClass("value");
		return isFilterMatch(filterClass, endpointInfo);
	}

	private boolean isEndpointFiltered(EndpointInfo<T> endpointInfo) {
		for (EndpointFilter<T> filter : this.filters) {
			if (!isFilterMatch(filter, endpointInfo)) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Determines if an extension is exposed.
	 * @param endpointType the endpoint type
	 * @param extensionType the extension type
	 * @param endpointInfo the endpoint info
	 * @return if the extension is exposed
	 */
	protected boolean isExtensionExposed(Class<?> endpointType, Class<?> extensionType,
			EndpointInfo<T> endpointInfo) {
		AnnotationAttributes annotationAttributes = AnnotatedElementUtils
				.getMergedAnnotationAttributes(extensionType, EndpointExtension.class);
		Class<?> filterClass = annotationAttributes.getClass("filter");
		return isFilterMatch(filterClass, endpointInfo);
	}

	@SuppressWarnings("unchecked")
	private boolean isFilterMatch(Class<?> filterClass, EndpointInfo<T> endpointInfo) {
		Class<?> generic = ResolvableType.forClass(EndpointFilter.class, filterClass)
				.resolveGeneric(0);
		if (generic == null || generic.isAssignableFrom(getOperationType())) {
			EndpointFilter<T> filter = (EndpointFilter<T>) BeanUtils
					.instantiateClass(filterClass);
			return isFilterMatch(filter, endpointInfo);
		}
		return false;
	}

	private boolean isFilterMatch(EndpointFilter<T> filter,
			EndpointInfo<T> endpointInfo) {
		try {
			return filter.match(endpointInfo, this);
		}
		catch (ClassCastException ex) {
			String msg = ex.getMessage();
			if (msg == null || msg.startsWith(endpointInfo.getClass().getName())) {
				// Possibly a lambda-defined listener which we could not resolve the
				// generic event type for
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("Non-matching info type for filter: " + filter, ex);
				}
				return false;
			}
			throw ex;
		}

	}

	private Collection<DiscoveredEndpoint> mergeExposed(
			Map<Class<?>, DiscoveredEndpoint> endpoints,
			Map<Class<?>, DiscoveredExtension> extensions) {
		List<DiscoveredEndpoint> result = new ArrayList<>();
		endpoints.forEach((endpointClass, endpoint) -> {
			if (endpoint.isExposed()) {
				DiscoveredExtension extension = extensions.remove(endpointClass);
				result.add(endpoint.merge(extension));
			}
		});
		return result;
	}

	/**
	 * Allows subclasses to verify that the descriptors are correctly configured.
	 * @param exposedEndpoints the discovered endpoints to verify before exposing
	 */
	protected void verify(Collection<DiscoveredEndpoint> exposedEndpoints) {
	}

	/**
	 * A discovered endpoint (which may not be valid and might not ultimately be exposed).
	 */
	protected final class DiscoveredEndpoint {

		private final EndpointInfo<T> info;

		private final boolean exposed;

		private final Map<OperationKey, List<T>> operations;

		private DiscoveredEndpoint(Class<?> type, EndpointInfo<T> info, boolean exposed) {
			Assert.notNull(info, "Info must not be null");
			this.info = info;
			this.exposed = exposed;
			this.operations = indexEndpointOperations(type, info);
		}

		private Map<OperationKey, List<T>> indexEndpointOperations(Class<?> endpointType,
				EndpointInfo<T> info) {
			return Collections.unmodifiableMap(
					indexOperations(info.getId(), endpointType, info.getOperations()));
		}

		private DiscoveredEndpoint(EndpointInfo<T> info, boolean exposed,
				Map<OperationKey, List<T>> operations) {
			Assert.notNull(info, "Info must not be null");
			this.info = info;
			this.exposed = exposed;
			this.operations = operations;
		}

		/**
		 * Return the {@link EndpointInfo} for the discovered endpoint.
		 * @return the endpoint info
		 */
		public EndpointInfo<T> getInfo() {
			return this.info;
		}

		/**
		 * Return {@code true} if the endpoint is exposed.
		 * @return if the is exposed
		 */
		private boolean isExposed() {
			return this.exposed;
		}

		/**
		 * Return all operation that were discovered. These might be different to the ones
		 * that are in {@link #getInfo()}.
		 * @return the endpoint operations
		 */
		public Map<OperationKey, List<T>> getOperations() {
			return this.operations;
		}

		/**
		 * Find any duplicate operations.
		 * @return any duplicate operations
		 */
		public Map<OperationKey, List<T>> findDuplicateOperations() {
			return this.operations.entrySet().stream()
					.filter((entry) -> entry.getValue().size() > 1)
					.collect(Collectors.toMap(Entry::getKey, Entry::getValue, (u, v) -> v,
							LinkedHashMap::new));
		}

		private DiscoveredEndpoint merge(DiscoveredExtension extension) {
			if (extension == null) {
				return this;
			}
			Map<OperationKey, List<T>> operations = mergeOperations(extension);
			EndpointInfo<T> info = new EndpointInfo<>(this.info.getId(),
					this.info.isEnableByDefault(), flatten(operations).values());
			return new DiscoveredEndpoint(info, this.exposed, operations);
		}

		private Map<OperationKey, List<T>> mergeOperations(
				DiscoveredExtension extension) {
			MultiValueMap<OperationKey, T> operations = new LinkedMultiValueMap<>(
					this.operations);
			operations.addAll(indexOperations(getInfo().getId(),
					extension.getExtensionType(), extension.getOperations()));
			return Collections.unmodifiableMap(operations);
		}

		private Map<K, T> flatten(Map<OperationKey, List<T>> operations) {
			Map<K, T> flattened = new LinkedHashMap<>();
			operations.forEach((operationKey, value) -> flattened
					.put(operationKey.getKey(), getLastValue(value)));
			return Collections.unmodifiableMap(flattened);
		}

		private T getLastValue(List<T> value) {
			return value.get(value.size() - 1);
		}

		private MultiValueMap<OperationKey, T> indexOperations(String endpointId,
				Class<?> target, Collection<T> operations) {
			LinkedMultiValueMap<OperationKey, T> result = new LinkedMultiValueMap<>();
			operations.forEach((operation) -> {
				K key = getOperationKey(operation);
				result.add(new OperationKey(endpointId, target, key), operation);
			});
			return result;
		}

		private K getOperationKey(T operation) {
			return AnnotationEndpointDiscoverer.this.operationKeyFactory.apply(operation);
		}

		@Override
		public String toString() {
			return getInfo().toString();
		}

	}

	/**
	 * A discovered extension.
	 */
	protected final class DiscoveredExtension {

		private final Class<?> extensionType;

		private final Collection<T> operations;

		private DiscoveredExtension(Class<?> extensionType, Collection<T> operations) {
			this.extensionType = extensionType;
			this.operations = operations;
		}

		public Class<?> getExtensionType() {
			return this.extensionType;
		}

		public Collection<T> getOperations() {
			return this.operations;
		}

		@Override
		public String toString() {
			return this.extensionType.getName();
		}

	}

	/**
	 * Define the key of an operation in the context of an operation's implementation.
	 */
	protected final class OperationKey {

		private final String endpointId;

		private final Class<?> target;

		private final K key;

		public OperationKey(String endpointId, Class<?> target, K key) {
			this.endpointId = endpointId;
			this.target = target;
			this.key = key;
		}

		public K getKey() {
			return this.key;
		}

		@Override
		@SuppressWarnings("unchecked")
		public boolean equals(Object o) {
			if (o == this) {
				return true;
			}
			if (o == null || getClass() != o.getClass()) {
				return false;
			}
			OperationKey other = (OperationKey) o;
			Boolean result = true;
			result = result && this.endpointId.equals(other.endpointId);
			result = result && this.target.equals(other.target);
			result = result && this.key.equals(other.key);
			return result;
		}

		@Override
		public int hashCode() {
			int result = this.endpointId.hashCode();
			result = 31 * result + this.target.hashCode();
			result = 31 * result + this.key.hashCode();
			return result;
		}

		@Override
		public String toString() {
			return new ToStringCreator(this).append("endpointId", this.endpointId)
					.append("target", this.target).append("key", this.key).toString();
		}

	}

}
