/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.ode.axis2;

import org.apache.axiom.soap.SOAPEnvelope;
import org.apache.axis2.AxisFault;
import org.apache.axis2.addressing.EndpointReference;
import org.apache.axis2.client.OperationClient;
import org.apache.axis2.client.Options;
import org.apache.axis2.client.ServiceClient;
import org.apache.axis2.context.ConfigurationContext;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.description.*;
import org.apache.axis2.engine.AxisConfiguration;
import org.apache.axis2.transport.jms.JMSConstants;
import org.apache.axis2.transport.http.HTTPConstants;
import org.apache.axis2.wsdl.WSDLConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
import org.apache.neethi.Policy;
import org.apache.neethi.PolicyEngine;
import org.apache.ode.axis2.util.ClusterUrlTransformer;
import org.apache.ode.axis2.util.SoapMessageConverter;
import org.apache.ode.axis2.util.AxisUtils;
import org.apache.ode.bpel.engine.BpelServerImpl;
import org.apache.ode.bpel.epr.EndpointFactory;
import org.apache.ode.bpel.epr.MutableEndpoint;
import org.apache.ode.bpel.epr.WSAEndpoint;
import org.apache.ode.bpel.iapi.BpelServer;
import org.apache.ode.bpel.iapi.Message;
import org.apache.ode.bpel.iapi.MessageExchange;
import org.apache.ode.bpel.iapi.MessageExchange.FailureType;
import org.apache.ode.bpel.iapi.PartnerRoleMessageExchange;
import org.apache.ode.bpel.iapi.ProcessConf;
import org.apache.ode.bpel.iapi.Scheduler;
import org.apache.ode.il.OMUtils;
import org.apache.ode.utils.*;
import org.apache.ode.utils.uuid.UUID;
import org.apache.ode.utils.wsdl.Messages;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.wsdl.Definition;
import javax.wsdl.Fault;
import javax.wsdl.Operation;
import javax.xml.namespace.QName;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.net.MalformedURLException;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.lang.reflect.Field;

/**
 * Acts as a service not provided by ODE. Used mainly for invocation as a way to maintain the WSDL description of used
 * services.
 *
 * @author Matthieu Riou <mriou at apache dot org>
 */
public class SoapExternalService implements ExternalService {

    private static final Logger __log = LoggerFactory.getLogger(ExternalService.class);

    private static final org.apache.ode.utils.wsdl.Messages msgs = Messages.getMessages(Messages.class);


    private static ThreadLocal<ServiceClient> _cachedClients = new ThreadLocal<ServiceClient>();
    private WatchDog<Map, OptionsObserver> _axisOptionsWatchDog;
    private WatchDog<Long, ServiceFileObserver> _axisServiceWatchDog;
    private ConfigurationContext _configContext;


    private ExecutorService _executorService;
    private Definition _definition;
    private QName _serviceName;
    private String _portName;
    protected WSAEndpoint endpointReference;
    private AxisConfiguration _axisConfig;
    private SoapMessageConverter _converter;
    private Scheduler _sched;
    private BpelServer _server;
    private ProcessConf _pconf;
    private ClusterUrlTransformer _clusterUrlTransformer;
    private String endpointUrl;

    public SoapExternalService(ProcessConf pconf, QName serviceName, String portName, ExecutorService executorService,
            ConfigurationContext configContext, Scheduler sched, BpelServer server, MultiThreadedHttpConnectionManager connManager, ClusterUrlTransformer clusterUrlTransformer) throws AxisFault {
        _definition = pconf.getDefinitionForService(serviceName);
        _serviceName = serviceName;
        _portName = portName;
        _executorService = executorService;
        _configContext = configContext;
        _axisConfig = _configContext.getAxisConfiguration();
        _sched = sched;
        _converter = new SoapMessageConverter(_definition, serviceName, portName);
        _server = server;
        _pconf = pconf;
        _clusterUrlTransformer = clusterUrlTransformer;

        File fileToWatch = new File(_pconf.getBaseURI().resolve(_serviceName.getLocalPart() + ".axis2"));
        _axisServiceWatchDog = WatchDog.watchFile(fileToWatch, new ServiceFileObserver(fileToWatch));
        _axisOptionsWatchDog = new WatchDog<Map, OptionsObserver>(new EndpointPropertiesMutable(), new OptionsObserver());

        _configContext.setProperty(HTTPConstants.MULTITHREAD_HTTP_CONNECTION_MANAGER, connManager);
        // make sure the client is not shared, see also org.apache.ode.axis2.Properties.Axis2
        _configContext.setProperty(HTTPConstants.REUSE_HTTP_CLIENT, "false");

        // initial endpoint reference
        Element eprElmt = ODEService.genEPRfromWSDL(_definition, serviceName, portName);
        if (eprElmt == null)
            throw new IllegalArgumentException(msgs.msgPortDefinitionNotFound(serviceName, portName));
        endpointReference = EndpointFactory.convertToWSA(ODEService.createServiceRef(eprElmt));
        endpointUrl = endpointReference.getUrl();
    }


    public void invoke(final PartnerRoleMessageExchange odeMex) {
        boolean isTwoWay = odeMex.getMessageExchangePattern() == org.apache.ode.bpel.iapi.MessageExchange.MessageExchangePattern.REQUEST_RESPONSE;
        try {

            ServiceClient client = getServiceClient();

            // Override options are passed to the axis MessageContext so we can
            // retrieve them in our session out changeHandler.
            final MessageContext mctx = new MessageContext();
            /* make the given options the parent so it becomes the defaults of the MessageContexgt. That allows the user to override
            *  specific options on a given message context and not affect the overall options.
            */
            mctx.getOptions().setParent(client.getOptions());
            writeHeader(mctx, odeMex);

            _converter.createSoapRequest(mctx, odeMex.getRequest(), odeMex.getOperation());

            SOAPEnvelope soapEnv = mctx.getEnvelope();
            String mexEndpointUrl = ((MutableEndpoint) odeMex.getEndpointReference()).getUrl();

            EndpointReference axisEPR = new EndpointReference(mexEndpointUrl);
            // The endpoint URL might be overridden from the properties file(s)
            // The order of precedence is (in descending order): process, property, wsdl.
            if(endpointUrl.equals(mexEndpointUrl)) {
                String address = (String) client.getOptions().getProperty(Properties.PROP_ADDRESS);
                if(address!=null) {
                    if (__log.isDebugEnabled()) __log.debug("Endpoint URL overridden by property files. "+mexEndpointUrl+" => "+address);
                    axisEPR.setAddress(address);
                }
            }else{
                if (__log.isDebugEnabled()) __log.debug("Endpoint URL overridden by process. "+endpointUrl+" => "+mexEndpointUrl);
            }
            
            
            axisEPR.setAddress(_clusterUrlTransformer.rewriteOutgoingClusterURL(axisEPR.getAddress()));

            if (__log.isDebugEnabled()) {
                __log.debug("Axis2 sending message to " + axisEPR.getAddress() + " using MEX " + odeMex);
                __log.debug("Message: " + soapEnv);
            }

            final OperationClient operationClient = client.createClient(isTwoWay ? ServiceClient.ANON_OUT_IN_OP
                    : ServiceClient.ANON_OUT_ONLY_OP);
            operationClient.addMessageContext(mctx);
            // this Options can be alter without impacting the ServiceClient options (which is a requirement)
            Options operationOptions = operationClient.getOptions();

            // provide HTTP credentials if any
            AuthenticationHelper.setHttpAuthentication(odeMex, operationOptions);

            operationOptions.setAction(mctx.getSoapAction());
            operationOptions.setTo(axisEPR);

            if (isTwoWay) {
                final String mexId = odeMex.getMessageExchangeId();
                final Operation operation = odeMex.getOperation();

                // Defer the invoke until the transaction commits.
                _sched.registerSynchronizer(new Scheduler.Synchronizer() {
                    public void afterCompletion(boolean success) {
                        // If the TX is rolled back, then we don't send the request.
                        if (!success) return;

                        // The invocation must happen in a separate thread, holding on the afterCompletion
                        // blocks other operations that could have been listed there as well.
                        _executorService.submit(new Callable<Object>() {
                            public Object call() throws Exception {
                                try {
                                    operationClient.execute(true);
                                    MessageContext response = operationClient.getMessageContext(WSDLConstants.MESSAGE_LABEL_IN_VALUE);
                                    MessageContext flt = operationClient.getMessageContext(WSDLConstants.MESSAGE_LABEL_FAULT_VALUE);
                                    if (response != null && __log.isDebugEnabled())
                                        __log.debug("Service response:\n" + response.getEnvelope().toString());

                                    if (flt != null) {
                                        reply(mexId, operation, flt, true);
                                    } else {
                                        reply(mexId, operation, response, response.isFault());
                                    }
                                } catch (Throwable t) {
                                    String errmsg = "Error sending message (mex=" + odeMex + "): " + t.getMessage();
                                    __log.error(errmsg, t);
                                    replyWithFailure(mexId, MessageExchange.FailureType.COMMUNICATION_ERROR, errmsg);
                                } finally {
                                    // release the HTTP connection, we don't need it anymore
                                    TransportOutDescription out = mctx.getTransportOut();
                                    if (out != null && out.getSender() != null) {
                                        out.getSender().cleanup(mctx);
                                    }
                                }
                                return null;
                            }
                        });
                    }

                    public void beforeCompletion() {
                    }
                });
                odeMex.replyAsync();

            } else { /** one-way case * */
                _executorService.submit(new Callable<Object>() {
                    public Object call() throws Exception {
                        try {
                            operationClient.execute(true);
                        } catch (Throwable t) {
                            String errmsg = "Error sending message (mex=" + odeMex + "): " + t.getMessage();
                            __log.error(errmsg, t);
                        } finally {
                            // release the HTTP connection, we don't need it anymore
                            TransportOutDescription out = mctx.getTransportOut();
                            if (out != null && out.getSender() != null) {
                                out.getSender().cleanup(mctx);
                            }
                        }
                        return null;
                    }
                });
                odeMex.replyOneWayOk();
            }
        } catch (Throwable t) {
            String errmsg = "Error sending message to Axis2 for ODE mex " + odeMex;
            __log.error(errmsg, t);
            odeMex.replyWithFailure(MessageExchange.FailureType.COMMUNICATION_ERROR, errmsg, null);
        }
    }

    private ServiceClient getServiceClient() throws AxisFault {
        try {
            // call manually the check procedure
            // we dont want a dedicated thread for that
            _axisServiceWatchDog.check();
            _axisOptionsWatchDog.check();
        } catch (RuntimeException e) {
            throw AxisFault.makeFault(e.getCause() != null ? e.getCause() : e);
        }
        AxisService anonymousService = _axisServiceWatchDog.getObserver().get();
        ServiceClient client = _cachedClients.get();
        if (client == null || !client.getAxisService().getName().equals(anonymousService.getName())) {
            // avoid race conditions in AxisConfiguration
            synchronized (_axisConfig) {
                // if the service has changed, discard the client and create a new one
                if (client != null) {
                    if (__log.isDebugEnabled()) __log.debug("Clean up and discard ServiceClient");
                    client.cleanup();
                }
                if (__log.isDebugEnabled())
                    __log.debug("Create a new ServiceClient for " + anonymousService.getName());
                client = new ServiceClient(_configContext, null);
                client.setAxisService(anonymousService);
            }
            _cachedClients.set(client);
        }

        // apply the options to the service client
        client.setOptions(_axisOptionsWatchDog.getObserver().get());
        return client;
    }

    private void applySecurityPolicy(Options options) {
        if (options!=null && options.getProperty(Properties.PROP_SECURITY_POLICY) != null) {
            String policy = (String) options.getProperty(Properties.PROP_SECURITY_POLICY);
            AxisService service = _axisServiceWatchDog.getObserver().get();
            AxisUtils.applySecurityPolicy(service, policy);
        }
    }

    /**
     * Extracts the action to be used for the given operation.  It first checks to see
     * if a value is specified using WS-Addressing in the portType, it then falls back onto
     * getting it from the SOAP Binding.
     *
     * @param operation the name of the operation to get the Action for
     * @return The action value for the specified operation
     */
    private String getAction(String operation) {
        String action = _converter.getWSAInputAction(operation);
        if (action == null || "".equals(action)) {
            action = _converter.getSoapAction(operation);
        }
        return action;
    }

    /**
     * Extracts endpoint information from ODE message exchange to stuff them into Axis MessageContext.
     */
    private void writeHeader(MessageContext ctxt, PartnerRoleMessageExchange odeMex) {
        Options options = ctxt.getOptions();
        WSAEndpoint targetWSAEPR = EndpointFactory.convertToWSA((MutableEndpoint) odeMex.getEndpointReference());
        WSAEndpoint myRoleWSAEPR = EndpointFactory.convertToWSA((MutableEndpoint) odeMex.getMyRoleEndpointReference());
        WSAEndpoint targetEPR = new WSAEndpoint(targetWSAEPR);

        EndpointReference replyEPR = null;

        String partnerSessionId = odeMex.getProperty(MessageExchange.PROPERTY_SEP_PARTNERROLE_SESSIONID);
        String myRoleSessionId = odeMex.getProperty(MessageExchange.PROPERTY_SEP_MYROLE_SESSIONID);

        if (partnerSessionId != null) {
            if (__log.isDebugEnabled()) {
                __log.debug("Partner session identifier found for WSA endpoint: " + partnerSessionId);
            }
            targetEPR.setSessionId(partnerSessionId);
        }
        options.setProperty(ODEService.TARGET_SESSION_ENDPOINT, targetEPR);

        if (myRoleWSAEPR != null) {
            WSAEndpoint myRoleEPR = new WSAEndpoint(myRoleWSAEPR);
            if (myRoleSessionId != null) {
                if (__log.isDebugEnabled()) {
                    __log.debug("MyRole session identifier found for myrole (callback) WSA endpoint: "
                            + myRoleSessionId);
                }
                myRoleEPR.setSessionId(myRoleSessionId);
            }
            options.setProperty(ODEService.CALLBACK_SESSION_ENDPOINT, myRoleEPR);

            // Map My Session ID to JMS Correlation ID
            Document callbackEprXml = odeMex.getMyRoleEndpointReference().toXML();
            Element serviceElement = callbackEprXml.getDocumentElement();

            if (myRoleSessionId != null) {
                options.setProperty(JMSConstants.JMS_COORELATION_ID, myRoleSessionId);
            } else {
                if (myRoleWSAEPR.getSessionId() != null) {
                    options.setProperty(JMSConstants.JMS_COORELATION_ID, myRoleSessionId);
                }
            }

            Element address = DOMUtils.findChildByName(serviceElement,
                    new QName(Namespaces.WS_ADDRESSING_NS, "Address"), true);
            if (__log.isDebugEnabled()) {
                __log.debug("The system-defined wsa address is : "
                        + address);
            }
            if (address != null) {
                String url = address.getTextContent();
                String jmsDestination = (String) options.getProperty(JMSConstants.PARAM_REPLY_DESTINATION);
                if (__log.isDebugEnabled()) {
                    __log.debug("The user-defined JMS replyTo destination is: "
                            + jmsDestination);
                    __log.debug("The user-defined JMS wait timeout is: "
                            + options.getProperty(JMSConstants.JMS_WAIT_REPLY));
                }
                if (jmsDestination == null || "".equals(jmsDestination.trim())) {
                    // If the REPLY_PARAM property is not user-defined, then use the default value from myRole EPR                  
                    int startIndex = url.indexOf("jms:/");
                    if (startIndex != -1) {
                        startIndex += "jms:/".length();
                        if (url.charAt(startIndex + 1) == '/') {
                            // startIndex++; // treat "/" as valid start character for queue name
                        }
                        if (url.startsWith("dynamic")) {
                            startIndex += "dynamicQueues".length();
                        }
                        int jmsEndIndex = url.indexOf("?", startIndex);
                        if (jmsEndIndex == -1) {
                            jmsEndIndex = url.length();
                        }
                        jmsDestination = url.substring(startIndex, jmsEndIndex);
                        options.setProperty(JMSConstants.PARAM_REPLY_DESTINATION, jmsDestination);
                        replyEPR = new EndpointReference(url);
                    } else {
                        startIndex = url.indexOf("http://");
                        if (startIndex != -1) {
                            startIndex = url.indexOf("/processes/");
                            if (startIndex != -1) {
                                startIndex += "/processes/".length();
                                jmsDestination = url.substring(startIndex);
                                options.setProperty(JMSConstants.PARAM_REPLY_DESTINATION, jmsDestination);
                            }
                        }
                    }
                } else {
                    replyEPR = new EndpointReference("jms:/" + jmsDestination);
                }
            }
        } else {
            __log.debug("My-Role EPR not specified, SEP will not be used.");
        }

        String action = getAction(odeMex.getOperationName());
        ctxt.setSoapAction(action);

        if (replyEPR == null) {
            if (MessageExchange.MessageExchangePattern.REQUEST_RESPONSE == odeMex.getMessageExchangePattern()) {
                replyEPR = new EndpointReference(Namespaces.WS_ADDRESSING_ANON_URI);
            }
        }
        if (replyEPR != null) {
            ctxt.setReplyTo(replyEPR);
            ctxt.setMessageID("uuid:" + new UUID().toString());
        }
    }

    public org.apache.ode.bpel.iapi.EndpointReference getInitialEndpointReference() {
        return endpointReference;
    }

    public void close() {
        // nothing
    }

    public String getPortName() {
        return _portName;
    }

    public QName getServiceName() {
        return _serviceName;
    }

    private void replyWithFailure(final String odeMexId, final FailureType error, final String errmsg) {
        // ODE MEX needs to be invoked in a TX.
        try {
            _sched.execTransaction(new Callable<Void>() {
                public Void call() throws Exception {
                    PartnerRoleMessageExchange odeMex = (PartnerRoleMessageExchange) _server.getEngine().getMessageExchange(odeMexId);
                    odeMex.replyWithFailure(error, errmsg, null);
                    return null;
                }
            });

        } catch (Exception e) {
            String emsg = "Error executing replyWithFailure transaction; reply will be lost.";
            __log.error(emsg, e);
        }
    }

    private void reply(final String odeMexId, final Operation operation, final MessageContext reply, final boolean isFault) {
        // ODE MEX needs to be invoked in a TX.
        try {
            _sched.execTransaction(new Callable<Void>() {
                public Void call() throws Exception {
                    PartnerRoleMessageExchange odeMex = (PartnerRoleMessageExchange) _server.getEngine().getMessageExchange(odeMexId);
                    // Setting the response
                    try {
                        if (__log.isDebugEnabled()) __log.debug("Received response for MEX " + odeMex);
                        if (isFault) {
                            Document odeMsg = DOMUtils.newDocument();
                            Element odeMsgEl = odeMsg.createElementNS(null, "message");
                            odeMsg.appendChild(odeMsgEl);
                            Fault fault = _converter.parseSoapFault(odeMsgEl, reply.getEnvelope(), operation);

                            if (fault != null) {
                                if (__log.isWarnEnabled())
                                    __log.warn("Fault response: faultName=" + fault.getName() + " faultType=" + fault.getMessage().getQName() + "\n" + DOMUtils.domToString(odeMsgEl));

                                QName faultType = fault.getMessage().getQName();
                                QName faultName = new QName(_definition.getTargetNamespace(), fault.getName());
                                Message response = odeMex.createMessage(faultType);
                                response.setMessage(odeMsgEl);

                                odeMex.replyWithFault(faultName, response);
                            } else {
                                if (__log.isWarnEnabled())
                                    __log.warn("Fault response: faultType=(unkown)\n" + reply.getEnvelope().toString());
                                odeMex.replyWithFailure(FailureType.OTHER, reply.getEnvelope().getBody()
                                        .getFault().getText(), OMUtils.toDOM(reply.getEnvelope().getBody()));
                            }
                        } else {
                            Message response = odeMex.createMessage(odeMex.getOperation().getOutput().getMessage().getQName());
                            _converter.parseSoapResponse(response, reply.getEnvelope(), operation);
                            if (__log.isInfoEnabled()) __log.info("Response:\n" + (response.getMessage() != null ?
                                    DOMUtils.domToString(response.getMessage()) : "empty"));
                            odeMex.reply(response);
                        }
                    } catch (Exception ex) {
                        String errmsg = "Unable to process response: " + ex.getMessage();
                        __log.error(errmsg, ex);
                        odeMex.replyWithFailure(FailureType.OTHER, errmsg, null);
                    }
                    return null;
                }
            });

        } catch (Exception e) {
            String errmsg = "Error executing reply transaction; reply will be lost.";
            __log.error(errmsg, e);
        }
    }


    /**
     * This class wraps a {@link org.apache.axis2.client.ServiceClient} and watches changes (deletions,creations,updates)
     * on a  Axis2 service config file named {service-name}.axis2.<p/>
     * The {@link org.apache.axis2.client.ServiceClient} instance is created from the main Axis2 config instance and
     * this service-specific config file.
     */
    private class ServiceFileObserver extends WatchDog.DefaultObserver<AxisService> {
        File file;

        private ServiceFileObserver(File file) {
            this.file = file;
        }

        public void init() {
            // create an anonymous axis service that will be used by the ServiceClient
            // this service will be added to the AxisConfig so do not reuse the name of the external service
            // as it could blow up if the service is deployed in the same axis2 instance
            String serviceName = "axis_service_for_" + _serviceName + "#" + _portName + "_" + new GUID().toString();
            object = new AxisService(serviceName);
            object.setParent(_axisConfig);

            OutOnlyAxisOperation outOnlyOperation = new OutOnlyAxisOperation(ServiceClient.ANON_OUT_ONLY_OP);
            object.addOperation(outOnlyOperation);

            OutInAxisOperation outInOperation = new OutInAxisOperation(ServiceClient.ANON_OUT_IN_OP);
            object.addOperation(outInOperation);

            // set a right default action *after* operations have been added to the service.
            outOnlyOperation.setSoapAction("");
            outInOperation.setSoapAction("");
        }

        public void onUpdate() {
            // axis2 service configuration
            // if the config file has been modified (i.e added or updated), re-create a ServiceClient
            // and load the new config.
            init(); // create a new ServiceClient instance
            try {
                String name = object.getName();
                AxisUtils.configureService(_configContext, object, file.toURI().toURL());
                // do not allow the service.xml file to change the service name
                object.setName(name);
            } catch (Exception e) {
                if (__log.isWarnEnabled()) __log.warn("Exception while configuring service: " + _serviceName, e);
                throw new RuntimeException("Exception while configuring service: " + _serviceName, e);
            }
            Options options = _axisOptionsWatchDog.getObserver().get();
            applySecurityPolicy(options);
        }
    }

    private class OptionsObserver extends WatchDog.DefaultObserver<Options> {

        public void init() {
            object = new Options();
            // set defaults values
            object.setExceptionToBeThrownOnSOAPFault(false);

            // this value does NOT override Properties.PROP_HTTP_CONNECTION_TIMEOUT
            // nor Properties.PROP_HTTP_SOCKET_TIMEOUT.
            // it will be applied only if the laters are not set.
            object.setTimeOutInMilliSeconds(60000);
        }

        public void onUpdate() {
            init();

            // note: don't make this map an instance attribute, so we always get the latest version
            final Map<String, String> properties = _pconf.getEndpointProperties(endpointReference);
            Properties.Axis2.translate(properties, object);

            applySecurityPolicy(object);
        }
    }

    private class EndpointPropertiesMutable implements WatchDog.Mutable<Map> {
        // ProcessConf#getProperties(String...) cannot return null (by contract)
        public boolean exists() {
            return true;
        }

        public boolean hasChangedSince(Map since) {
            Map latest = lastModified();  // cannot be null but better be prepared
            // check if mappings are equal
            return !CollectionUtils.equals(latest, since);
        }

        public Map lastModified() {
            return _pconf.getEndpointProperties(endpointReference);
        }

        public String toString() {
            return "Properties for Endpoint: " + _serviceName + "#" + _portName;
        }
    }

}
