/*
 * Copyright 2005-2025 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.ws.server;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

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

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.OrderComparator;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.ws.FaultAwareWebServiceMessage;
import org.springframework.ws.NoEndpointFoundException;
import org.springframework.ws.WebServiceMessage;
import org.springframework.ws.context.MessageContext;
import org.springframework.ws.server.endpoint.MessageEndpoint;
import org.springframework.ws.server.endpoint.PayloadEndpoint;
import org.springframework.ws.server.endpoint.adapter.MessageEndpointAdapter;
import org.springframework.ws.server.endpoint.adapter.PayloadEndpointAdapter;
import org.springframework.ws.soap.server.SoapMessageDispatcher;
import org.springframework.ws.support.DefaultStrategiesHelper;
import org.springframework.ws.transport.WebServiceMessageReceiver;

/**
 * Central dispatcher for use within Spring-WS, dispatching Web service messages to
 * registered endpoints.
 * <p>
 * This dispatcher is quite similar to Spring MVCs {@link DispatcherServlet}. Just like
 * its counterpart, this dispatcher is very flexible. This class is SOAP agnostic; in
 * typical SOAP Web Services, the {@link SoapMessageDispatcher} subclass is used.
 * <ul>
 * <li>It can use any {@link EndpointMapping} implementation - whether standard, or
 * provided as part of an application - to control the routing of request messages to
 * endpoint objects. Endpoint mappings can be registered using the
 * {@link #setEndpointMappings(List) endpointMappings} property.</li>
 * <li>It can use any {@link EndpointAdapter}; this allows one to use any endpoint
 * interface or form. Defaults to the {@link MessageEndpointAdapter} and
 * {@link PayloadEndpointAdapter}, for {@link MessageEndpoint} and
 * {@link PayloadEndpoint}, respectively, and the
 * {@link org.springframework.ws.server.endpoint.adapter.MessageMethodEndpointAdapter
 * MessageMethodEndpointAdapter} and
 * {@link org.springframework.ws.server.endpoint.adapter.PayloadMethodEndpointAdapter
 * PayloadMethodEndpointAdapter}. Additional endpoint adapters can be added through the
 * {@link #setEndpointAdapters(List) endpointAdapters} property.</li>
 * <li>Its exception resolution strategy can be specified via a
 * {@link EndpointExceptionResolver}, for example mapping certain exceptions to SOAP
 * Faults. Default is none. Additional exception resolvers can be added through the
 * {@link #setEndpointExceptionResolvers(List) endpointExceptionResolvers} property.</li>
 * </ul>
 *
 * @author Arjen Poutsma
 * @since 1.0.0
 * @see EndpointMapping
 * @see EndpointAdapter
 * @see EndpointExceptionResolver
 * @see org.springframework.web.servlet.DispatcherServlet
 */
public class MessageDispatcher implements WebServiceMessageReceiver, BeanNameAware, ApplicationContextAware {

	/** Logger available to subclasses. */
	protected final Log logger = LogFactory.getLog(getClass());

	/** Log category to use when no mapped endpoint is found for a request. */
	public static final String ENDPOINT_NOT_FOUND_LOG_CATEGORY = "org.springframework.ws.server.EndpointNotFound";

	/** Additional logger to use when no mapped endpoint is found for a request. */
	protected static final Log endpointNotFoundLogger = LogFactory
		.getLog(MessageDispatcher.ENDPOINT_NOT_FOUND_LOG_CATEGORY);

	/** Log category to use for message tracing. */
	public static final String MESSAGE_TRACING_LOG_CATEGORY = "org.springframework.ws.server.MessageTracing";

	/** Additional logger to use for sent message tracing. */
	protected static final Log sentMessageTracingLogger = LogFactory
		.getLog(MessageDispatcher.MESSAGE_TRACING_LOG_CATEGORY + ".sent");

	/** Additional logger to use for received message tracing. */
	protected static final Log receivedMessageTracingLogger = LogFactory
		.getLog(MessageDispatcher.MESSAGE_TRACING_LOG_CATEGORY + ".received");

	private final DefaultStrategiesHelper defaultStrategiesHelper;

	/** The registered bean name for this dispatcher. */
	private String beanName;

	/** List of EndpointAdapters used in this dispatcher. */
	private List<EndpointAdapter> endpointAdapters;

	/** List of EndpointExceptionResolvers used in this dispatcher. */
	private List<EndpointExceptionResolver> endpointExceptionResolvers;

	/** List of EndpointMappings used in this dispatcher. */
	private List<EndpointMapping> endpointMappings;

	/** Initializes a new instance of the {@code MessageDispatcher}. */
	public MessageDispatcher() {
		this.defaultStrategiesHelper = new DefaultStrategiesHelper(getClass());
	}

	/** Returns the {@code EndpointAdapter}s to use by this {@code MessageDispatcher}. */
	public List<EndpointAdapter> getEndpointAdapters() {
		return this.endpointAdapters;
	}

	/** Sets the {@code EndpointAdapter}s to use by this {@code MessageDispatcher}. */
	public void setEndpointAdapters(List<EndpointAdapter> endpointAdapters) {
		this.endpointAdapters = endpointAdapters;
	}

	/**
	 * Returns the {@code EndpointExceptionResolver}s to use by this
	 * {@code MessageDispatcher}.
	 */
	public List<EndpointExceptionResolver> getEndpointExceptionResolvers() {
		return this.endpointExceptionResolvers;
	}

	/**
	 * Sets the {@code EndpointExceptionResolver}s to use by this
	 * {@code MessageDispatcher}.
	 */
	public void setEndpointExceptionResolvers(List<EndpointExceptionResolver> endpointExceptionResolvers) {
		this.endpointExceptionResolvers = endpointExceptionResolvers;
	}

	/** Returns the {@code EndpointMapping}s to use by this {@code MessageDispatcher}. */
	public List<EndpointMapping> getEndpointMappings() {
		return this.endpointMappings;
	}

	/** Sets the {@code EndpointMapping}s to use by this {@code MessageDispatcher}. */
	public void setEndpointMappings(List<EndpointMapping> endpointMappings) {
		this.endpointMappings = endpointMappings;
	}

	@Override
	public final void setBeanName(String beanName) {
		this.beanName = beanName;
	}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		initEndpointAdapters(applicationContext);
		initEndpointExceptionResolvers(applicationContext);
		initEndpointMappings(applicationContext);
	}

	@Override
	public void receive(MessageContext messageContext) throws Exception {
		// Let's keep a reference to the request content as it came in, it might be
		// changed by interceptors in dispatch()
		String requestContent = "";
		if (receivedMessageTracingLogger.isTraceEnabled() || sentMessageTracingLogger.isTraceEnabled()) {
			requestContent = getMessageContent(messageContext.getRequest());
		}
		if (receivedMessageTracingLogger.isTraceEnabled()) {
			receivedMessageTracingLogger.trace("Received request [" + requestContent + "]");
		}
		else if (receivedMessageTracingLogger.isDebugEnabled()) {
			receivedMessageTracingLogger.debug("Received request [" + messageContext.getRequest() + "]");
		}
		dispatch(messageContext);
		if (messageContext.hasResponse()) {
			WebServiceMessage response = messageContext.getResponse();
			if (sentMessageTracingLogger.isTraceEnabled()) {
				String responseContent = getMessageContent(response);
				sentMessageTracingLogger
					.trace("Sent response [" + responseContent + "] for request [" + requestContent + "]");
			}
			else if (sentMessageTracingLogger.isDebugEnabled()) {
				sentMessageTracingLogger
					.debug("Sent response [" + response + "] for request [" + messageContext.getRequest() + "]");
			}
		}
		else if (sentMessageTracingLogger.isDebugEnabled()) {
			sentMessageTracingLogger.debug("MessageDispatcher with name '" + this.beanName
					+ "' sends no response for request [" + messageContext.getRequest() + "]");
		}
	}

	private String getMessageContent(WebServiceMessage message) throws IOException {
		ByteArrayOutputStream bos = new ByteArrayOutputStream();
		message.writeTo(bos);
		return bos.toString(StandardCharsets.UTF_8);
	}

	/**
	 * Dispatches the request in the given MessageContext according to the configuration.
	 * @param messageContext the message context
	 * @throws org.springframework.ws.NoEndpointFoundException thrown when an endpoint
	 * cannot be resolved for the incoming message
	 */
	protected final void dispatch(MessageContext messageContext) throws Exception {
		EndpointInvocationChain mappedEndpoint = null;
		int interceptorIndex = -1;
		try {
			try {
				// Determine endpoint for the current context
				mappedEndpoint = getEndpoint(messageContext);
				if (mappedEndpoint == null || mappedEndpoint.getEndpoint() == null) {
					throw new NoEndpointFoundException(messageContext.getRequest());
				}
				if (!handleRequest(mappedEndpoint, messageContext)) {
					return;
				}
				// Apply handleRequest of registered interceptors
				if (mappedEndpoint.getInterceptors() != null) {
					for (int i = 0; i < mappedEndpoint.getInterceptors().length; i++) {
						EndpointInterceptor interceptor = mappedEndpoint.getInterceptors()[i];
						interceptorIndex = i;
						if (!interceptor.handleRequest(messageContext, mappedEndpoint.getEndpoint())) {
							triggerHandleResponse(mappedEndpoint, interceptorIndex, messageContext);
							triggerAfterCompletion(mappedEndpoint, interceptorIndex, messageContext, null);
							return;
						}
					}
				}
				// Actually invoke the endpoint
				EndpointAdapter endpointAdapter = getEndpointAdapter(mappedEndpoint.getEndpoint());
				endpointAdapter.invoke(messageContext, mappedEndpoint.getEndpoint());

				// Apply handleResponse methods of registered interceptors
				triggerHandleResponse(mappedEndpoint, interceptorIndex, messageContext);
			}
			catch (NoEndpointFoundException ex) {
				// No triggering of interceptors if no endpoint is found
				if (endpointNotFoundLogger.isWarnEnabled()) {
					endpointNotFoundLogger.warn("No endpoint mapping found for [" + messageContext.getRequest() + "]");
				}
				throw ex;
			}
			catch (Exception ex) {
				Object endpoint = (mappedEndpoint != null) ? mappedEndpoint.getEndpoint() : null;
				processEndpointException(messageContext, endpoint, ex);
				triggerHandleResponse(mappedEndpoint, interceptorIndex, messageContext);
			}
			triggerAfterCompletion(mappedEndpoint, interceptorIndex, messageContext, null);
		}
		catch (NoEndpointFoundException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Trigger after-completion for thrown exception.
			triggerAfterCompletion(mappedEndpoint, interceptorIndex, messageContext, ex);
			throw ex;
		}
	}

	/**
	 * Returns the endpoint for this request. All endpoint mappings are tried, in order.
	 * @return the {@code EndpointInvocationChain}, or {@code null} if no endpoint could
	 * be found.
	 */
	protected EndpointInvocationChain getEndpoint(MessageContext messageContext) throws Exception {
		for (EndpointMapping endpointMapping : getEndpointMappings()) {
			EndpointInvocationChain endpoint = endpointMapping.getEndpoint(messageContext);
			if (endpoint != null) {
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("Endpoint mapping [" + endpointMapping + "] maps request to endpoint ["
							+ endpoint.getEndpoint() + "]");
				}
				return endpoint;
			}
			else if (this.logger.isDebugEnabled()) {
				this.logger.debug("Endpoint mapping [" + endpointMapping + "] has no mapping for request");
			}
		}
		return null;
	}

	/**
	 * Returns the {@code EndpointAdapter} for the given endpoint.
	 * @param endpoint the endpoint to find an adapter for
	 * @return the adapter
	 */
	protected EndpointAdapter getEndpointAdapter(Object endpoint) {
		for (EndpointAdapter endpointAdapter : getEndpointAdapters()) {
			if (this.logger.isDebugEnabled()) {
				this.logger.debug("Testing endpoint adapter [" + endpointAdapter + "]");
			}
			if (endpointAdapter.supports(endpoint)) {
				return endpointAdapter;
			}
		}
		throw new IllegalStateException("No adapter for endpoint [" + endpoint + "]: Is your endpoint annotated with "
				+ "@Endpoint, or does it implement a supported interface like MessageHandler or PayloadEndpoint?");
	}

	/**
	 * Callback for pre-processing of given invocation chain and message context. Gets
	 * called before invocation of {@code handleRequest} on the interceptors.
	 * <p>
	 * Default implementation does nothing, and returns {@code true}.
	 * @param mappedEndpoint the mapped {@code EndpointInvocationChain}
	 * @param messageContext the message context
	 * @return {@code true} if processing should continue; {@code false} otherwise
	 */
	protected boolean handleRequest(EndpointInvocationChain mappedEndpoint, MessageContext messageContext) {
		return true;
	}

	/**
	 * Determine an error {@code SOAPMessage} response via the registered
	 * {@code EndpointExceptionResolvers}. Most likely, the response contains a
	 * {@code SOAPFault}. If no suitable resolver was found, the exception is rethrown.
	 * @param messageContext current SOAPMessage request
	 * @param endpoint the executed endpoint, or null if none chosen at the time of the
	 * exception
	 * @param ex the exception that got thrown during handler execution
	 * @throws Exception if no suitable resolver is found
	 */
	protected void processEndpointException(MessageContext messageContext, Object endpoint, Exception ex)
			throws Exception {
		if (!CollectionUtils.isEmpty(getEndpointExceptionResolvers())) {
			for (EndpointExceptionResolver resolver : getEndpointExceptionResolvers()) {
				if (resolver.resolveException(messageContext, endpoint, ex)) {
					if (this.logger.isDebugEnabled()) {
						this.logger.debug("Endpoint invocation resulted in exception - responding with Fault", ex);
					}
					return;
				}
			}
		}
		// exception not resolved
		throw ex;
	}

	/**
	 * Trigger handleResponse or handleFault on the mapped EndpointInterceptors. Will just
	 * invoke said method on all interceptors whose handleRequest invocation returned
	 * {@code true}, in addition to the last interceptor who returned {@code false}.
	 * @param mappedEndpoint the mapped EndpointInvocationChain
	 * @param interceptorIndex index of last interceptor that was called
	 * @param messageContext the message context, whose request and response are filled
	 * @see EndpointInterceptor#handleResponse(MessageContext,Object)
	 * @see EndpointInterceptor#handleFault(MessageContext, Object)
	 */
	private void triggerHandleResponse(EndpointInvocationChain mappedEndpoint, int interceptorIndex,
			MessageContext messageContext) throws Exception {
		if (mappedEndpoint != null && messageContext.hasResponse()
				&& !ObjectUtils.isEmpty(mappedEndpoint.getInterceptors())) {
			boolean hasFault = false;
			WebServiceMessage response = messageContext.getResponse();
			if (response instanceof FaultAwareWebServiceMessage) {
				hasFault = ((FaultAwareWebServiceMessage) response).hasFault();
			}
			boolean resume = true;
			for (int i = interceptorIndex; resume && i >= 0; i--) {
				EndpointInterceptor interceptor = mappedEndpoint.getInterceptors()[i];
				if (!hasFault) {
					resume = interceptor.handleResponse(messageContext, mappedEndpoint.getEndpoint());
				}
				else {
					resume = interceptor.handleFault(messageContext, mappedEndpoint.getEndpoint());
				}
			}
		}
	}

	/**
	 * Trigger afterCompletion callbacks on the mapped EndpointInterceptors. Will just
	 * invoke afterCompletion for all interceptors whose handleRequest invocation has
	 * successfully completed and returned true, in addition to the last interceptor who
	 * returned {@code false}.
	 * @param mappedEndpoint the mapped EndpointInvocationChain
	 * @param interceptorIndex index of last interceptor that successfully completed
	 * @param ex exception thrown on handler execution, or {@code null} if none
	 * @see EndpointInterceptor#afterCompletion
	 */
	private void triggerAfterCompletion(EndpointInvocationChain mappedEndpoint, int interceptorIndex,
			MessageContext messageContext, Exception ex) throws Exception {

		// Apply afterCompletion methods of registered interceptors.
		if (mappedEndpoint != null) {
			EndpointInterceptor[] interceptors = mappedEndpoint.getInterceptors();
			if (interceptors != null) {
				for (int i = interceptorIndex; i >= 0; i--) {
					EndpointInterceptor interceptor = interceptors[i];
					try {
						interceptor.afterCompletion(messageContext, mappedEndpoint.getEndpoint(), ex);
					}
					catch (Throwable ex2) {
						this.logger.error("EndpointInterceptor.afterCompletion threw exception", ex2);
					}
				}
			}
		}
	}

	/**
	 * Initialize the {@code EndpointAdapters} used by this class. If no adapter beans are
	 * explicitly set by using the {@code endpointAdapters} property, we use the default
	 * strategies.
	 * @see #setEndpointAdapters(java.util.List)
	 */
	private void initEndpointAdapters(ApplicationContext applicationContext) throws BeansException {
		if (this.endpointAdapters == null) {
			Map<String, EndpointAdapter> matchingBeans = BeanFactoryUtils
				.beansOfTypeIncludingAncestors(applicationContext, EndpointAdapter.class, true, false);
			if (!matchingBeans.isEmpty()) {
				this.endpointAdapters = new ArrayList<>(matchingBeans.values());
				this.endpointAdapters.sort(new OrderComparator());
			}
			else {
				this.endpointAdapters = this.defaultStrategiesHelper.getDefaultStrategies(EndpointAdapter.class,
						applicationContext);
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("No EndpointAdapters found, using defaults");
				}
			}
		}
	}

	/**
	 * Initialize the {@code EndpointExceptionResolver} used by this class. If no resolver
	 * beans are explicitly set by using the {@code endpointExceptionResolvers} property,
	 * we use the default strategies.
	 * @see #setEndpointExceptionResolvers(java.util.List)
	 */
	private void initEndpointExceptionResolvers(ApplicationContext applicationContext) throws BeansException {
		if (this.endpointExceptionResolvers == null) {
			Map<String, EndpointExceptionResolver> matchingBeans = BeanFactoryUtils
				.beansOfTypeIncludingAncestors(applicationContext, EndpointExceptionResolver.class, true, false);
			if (!matchingBeans.isEmpty()) {
				this.endpointExceptionResolvers = new ArrayList<>(matchingBeans.values());
				this.endpointExceptionResolvers.sort(new OrderComparator());
			}
			else {
				this.endpointExceptionResolvers = this.defaultStrategiesHelper
					.getDefaultStrategies(EndpointExceptionResolver.class, applicationContext);
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("No EndpointExceptionResolvers found, using defaults");
				}
			}
		}
	}

	/**
	 * Initialize the {@code EndpointMappings} used by this class. If no mapping beans are
	 * explictely set by using the {@code endpointMappings} property, we use the default
	 * strategies.
	 * @see #setEndpointMappings(java.util.List)
	 */
	private void initEndpointMappings(ApplicationContext applicationContext) throws BeansException {
		if (this.endpointMappings == null) {
			Map<String, EndpointMapping> matchingBeans = BeanFactoryUtils
				.beansOfTypeIncludingAncestors(applicationContext, EndpointMapping.class, true, false);
			if (!matchingBeans.isEmpty()) {
				this.endpointMappings = new ArrayList<>(matchingBeans.values());
				this.endpointMappings.sort(new OrderComparator());
			}
			else {
				this.endpointMappings = this.defaultStrategiesHelper.getDefaultStrategies(EndpointMapping.class,
						applicationContext);
				if (this.logger.isDebugEnabled()) {
					this.logger.debug("No EndpointMappings found, using defaults");
				}
			}
		}
	}

}
