/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 1997-2017 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * https://oss.oracle.com/licenses/CDDL+GPL-1.1
 * or LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */

package com.sun.xml.ws.server;

import com.sun.istack.NotNull;
import com.sun.istack.Nullable;
import com.sun.xml.bind.marshaller.SAX2DOMEx;
import com.sun.xml.ws.api.ha.HighAvailabilityProvider;
import com.sun.xml.ws.api.ha.HighAvailabilityProvider.StoreType;
import com.sun.xml.ws.api.message.Header;
import com.sun.xml.ws.api.message.HeaderList;
import com.sun.xml.ws.api.message.MessageHeaders;
import com.sun.xml.ws.api.message.Packet;
import com.sun.xml.ws.api.server.InstanceResolver;
import com.sun.xml.ws.api.server.WSEndpoint;
import com.sun.xml.ws.api.server.WSWebServiceContext;
import com.sun.xml.ws.developer.EPRRecipe;
import com.sun.xml.ws.developer.StatefulWebServiceManager;
import com.sun.xml.ws.resources.ServerMessages;
import com.sun.xml.ws.util.DOMUtil;
import com.sun.xml.ws.util.InjectionPlan;
import com.sun.xml.ws.util.xml.XmlUtil;
import org.glassfish.ha.store.api.BackingStore;
import org.glassfish.ha.store.api.Storeable;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import javax.xml.namespace.QName;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.dom.DOMResult;
import javax.xml.transform.sax.SAXResult;
import javax.xml.ws.EndpointReference;
import javax.xml.ws.WebServiceContext;
import javax.xml.ws.WebServiceException;
import javax.xml.ws.wsaddressing.W3CEndpointReference;
import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * {@link InstanceResolver} that looks at JAX-WS cookie header to
 * determine the instance to which a message will be routed.
 *
 * <p>
 * See {@link StatefulWebServiceManager} for more about user-level semantics.
 *
 * @author Kohsuke Kawaguchi
 * @author Jitendra Kotamraju (added high availability)
 */
public final class StatefulInstanceResolver<T> extends AbstractMultiInstanceResolver<T> implements StatefulWebServiceManager<T> {
    /**
     * This instance is used for serving messages that have no cookie
     * or cookie value that the server doesn't recognize.
     */
    private volatile @Nullable T fallback;

    private HAMap haMap;

    // time out control. 0=disabled
    private volatile long timeoutMilliseconds = 0;
    private volatile Callback<T> timeoutCallback;
    /**
     * Timer that controls the instance time out. Lazily created.
     */
    private volatile Timer timer;

    // Application classloader(typically web app classloader), needed for
    // deserialization of web service class
    private final ClassLoader appCL;

    private final boolean haEnabled;

    // Used for {@link BackingStore#load()} and {@link BackingStore#save()}
    // Keep this a static class, otherwise enclosed object will be pulled in
    // during serialization
    private static final class HAInstance<T> implements Storeable {
        transient @NotNull T instance;
        private byte[] buf;

        private long lastAccess = 0L;

        private boolean isNew = false;

        // unless the request gives a version somehow, this cannot be used
        // to find out the dirty active cache entry
        private long version = -1;

        private long maxIdleTime;

        public HAInstance() {
            // Storeable objects require public no-arg constructor
        }

        public HAInstance(T instance, long timeout) {
            this.instance = instance;
            lastAccess = System.currentTimeMillis();
            maxIdleTime = timeout;
        }

        public T getInstance(final ClassLoader cl) {
            if (instance == null) {
                try {
                    ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(buf)) {
                        @Override
                        protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
                            Class<?> clazz = cl.loadClass(desc.getName());
                            if (clazz == null) {
                                clazz = super.resolveClass(desc);
                            }
                            return clazz;
                        }
                    };
                    instance = (T) in.readObject();
                    in.close();
                } catch (Exception ioe) {
                    throw new WebServiceException(ioe);
                }
            }
            return instance;
        }

        @Override
        public long _storeable_getVersion() {
            return version;
        }

        @Override
        public void _storeable_setVersion(long version) {
            this.version = version;
        }

        @Override
        public long _storeable_getLastAccessTime() {
            return lastAccess;
        }

        @Override
        public void _storeable_setLastAccessTime(long time) {
            lastAccess = time;
        }

        @Override
        public long _storeable_getMaxIdleTime() {
            return maxIdleTime;
        }

        @Override
        public void _storeable_setMaxIdleTime(long time) {
            maxIdleTime = time;
        }

        @Override
        public String[] _storeable_getAttributeNames() {
            return new String[0];
        }

        @Override
        public boolean[] _storeable_getDirtyStatus() {
            return new boolean[0];
        }

        @Override
        public void _storeable_writeState(OutputStream os) throws IOException {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream boos = new ObjectOutputStream(bos);
            boos.writeObject(instance);
            boos.close();
            this.buf = bos.toByteArray();        // convert instance to byte[]

            ObjectOutputStream oos = new ObjectOutputStream(os);
            oos.writeLong(version);
            oos.writeLong(lastAccess);
            oos.writeLong(maxIdleTime);
            oos.writeBoolean(isNew);
            oos.writeInt(buf.length);
            oos.write(buf);
            oos.close();
        }

        @Override
        public void _storeable_readState(InputStream is) throws IOException {
            ObjectInputStream ois = new ObjectInputStream(is);
            version = ois.readLong();
            lastAccess = ois.readLong();
            maxIdleTime = ois.readLong();
            isNew = ois.readBoolean();
            int len = ois.readInt();
            buf = new byte[len];
            ois.readFully(buf);
            ois.close();
        }
    }

    /**
     * Maintains the stateful service instance and its time-out timer.
     */
    private final class Instance {
        final @NotNull T instance;
        volatile TimerTask task;

        public Instance(T instance) {
            this.instance = instance;
        }

        /**
         * Resets the timer.
         */
        public synchronized void restartTimer() {
            cancel();
            if (timeoutMilliseconds == 0) {
                return;
            } // no timer

            task = new TimerTask() {
                @Override
                public void run() {
                    try {
                        Callback<T> cb = timeoutCallback;
                        if (cb != null) {
                            if (logger.isLoggable(Level.FINEST)) {
                                logger.log(Level.FINEST, "Invoking timeout callback for instance/timeouttask = [ {0} / {1} ]", new Object[]{instance, this});
                            }
                            cb.onTimeout(instance, StatefulInstanceResolver.this);
                            return;
                        }
                        // default operation is to unexport it.
                        unexport(instance);
                    } catch (Throwable e) {
                        // don't let an error in the code kill the timer thread
                        logger.log(Level.SEVERE, "time out handler failed", e);
                    }
                }
            };
            timer.schedule(task, timeoutMilliseconds);
        }

        /**
         * Cancels the timer.
         */
        public synchronized void cancel() {
            if (task != null) {
                boolean result = task.cancel();
                if (logger.isLoggable(Level.FINEST)) {
                    logger.log(Level.FINEST, "Timeout callback CANCELED for instance/timeouttask/cancel result = [ {0} / {1} / {2} ]", new Object[]{instance, this, result});
                }
            } else {
                if (logger.isLoggable(Level.FINEST)) {
                    logger.log(Level.FINEST, "Timeout callback NOT CANCELED for instance = [ {0} ]; task is null ...", instance);
                }
            }
            task = null;
        }
        
        public synchronized void setTask(TimerTask t) {
            this.task = t;
        }

    }


    @SuppressWarnings("unchecked")
    public StatefulInstanceResolver(Class<T> clazz) {
        super(clazz);
        appCL = clazz.getClassLoader();

        boolean ha = false;
        if (HighAvailabilityProvider.INSTANCE.isHaEnvironmentConfigured()) {
            if (Serializable.class.isAssignableFrom(clazz)) {
                logger.log(Level.WARNING,"{0}" + " doesn''t implement Serializable. High availibility is disabled i.e." +
                        "if a failover happens, stateful instance state is not failed over.", clazz);
                ha = true;
            }
        }
        haEnabled = ha;

    }

    @Override
    public @NotNull T resolve(Packet request) {

        MessageHeaders headers = request.getMessage().getHeaders();
        Header header = headers.get(COOKIE_TAG, true);
        String id = null;
        if (header != null) {
            // find the instance
            id = header.getStringContent();
            Instance o = haMap.get(id);
            if (o != null) {
                if (logger.isLoggable(Level.FINEST)) {
                    logger.log(Level.FINEST, "Restarting timer for objectId/Instance = [ {0} / {1} ]", new Object[]{id, o});
                }
                o.restartTimer();
                return o.instance;
            }

            // huh? what is this ID?
            logger.log(Level.INFO, "Request had an unrecognized object ID {0}", id);
        } else {
            logger.fine("No objectId header received");
        }

        // need to fallback
        T flbk = this.fallback;
        if (flbk != null) {
            return flbk;
        }

        if (id == null) {
            throw new WebServiceException(ServerMessages.STATEFUL_COOKIE_HEADER_REQUIRED(COOKIE_TAG));
        } else {
            throw new WebServiceException(ServerMessages.STATEFUL_COOKIE_HEADER_INCORRECT(COOKIE_TAG, id));
        }
    }

    /*
     * Changed stateful web service instance is pushed to backing store
     * after the invocation of service.
     */
    @Override
    public void postInvoke(@NotNull Packet request, @NotNull T servant) {
        haMap.put(servant);
    }

    @Override
    public void start(WSWebServiceContext wsc, WSEndpoint endpoint) {
        super.start(wsc, endpoint);

        haMap = new HAMap();

        if (endpoint.getBinding().getAddressingVersion() == null) {
            throw new WebServiceException(ServerMessages.STATEFUL_REQURES_ADDRESSING(clazz));
        }

        // inject StatefulWebServiceManager.
        for (Field field : clazz.getDeclaredFields()) {
            if (field.getType() == StatefulWebServiceManager.class) {
                if (!Modifier.isStatic(field.getModifiers())) {
                    throw new WebServiceException(ServerMessages.STATIC_RESOURCE_INJECTION_ONLY(StatefulWebServiceManager.class, field));
                }
                new InjectionPlan.FieldInjectionPlan<T, StatefulWebServiceManager>(field).inject(null, this);
            }
        }

        for (Method method : clazz.getDeclaredMethods()) {
            Class[] paramTypes = method.getParameterTypes();
            if (paramTypes.length != 1) {
                continue;
            }   // not what we are looking for

            if (paramTypes[0] == StatefulWebServiceManager.class) {
                if (!Modifier.isStatic(method.getModifiers())) {
                    throw new WebServiceException(ServerMessages.STATIC_RESOURCE_INJECTION_ONLY(StatefulWebServiceManager.class, method));
                }

                new InjectionPlan.MethodInjectionPlan<T, StatefulWebServiceManager>(method).inject(null, this);
            }
        }
    }

    @Override
    public void dispose() {
        synchronized (haMap) {
            for (Instance t : haMap.values()) {
                t.cancel();
                dispose(t.instance);
            }
            haMap.destroy();
        }
        if (fallback != null) {
            dispose(fallback);
            fallback = null;
        }
        stopTimer();
    }

    @NotNull
    @Override
    public W3CEndpointReference export(T o) {
        return export(W3CEndpointReference.class, o);
    }

    @NotNull
    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> epr, T o) {
        return export(epr, o, null);
    }

    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> epr, T o, EPRRecipe recipe) {
        return export(epr, InvokerTube.getCurrentPacket(), o, recipe);
    }

    @NotNull
    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> epr, WebServiceContext context, T o) {
        if (context instanceof WSWebServiceContext) {
            WSWebServiceContext wswsc = (WSWebServiceContext) context;
            return export(epr, wswsc.getRequestPacket(), o);
        }

        throw new WebServiceException(ServerMessages.STATEFUL_INVALID_WEBSERVICE_CONTEXT(context));
    }

    @NotNull
    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> adrsVer, @NotNull Packet currentRequest, T o) {
        return export(adrsVer, currentRequest, o, null);
    }

    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> adrsVer, @NotNull Packet currentRequest, T o, EPRRecipe recipe) {
        return export(adrsVer, currentRequest.webServiceContextDelegate.getEPRAddress(currentRequest, owner),
                currentRequest.webServiceContextDelegate.getWSDLAddress(currentRequest, owner), o, recipe);
    }

    @NotNull
    @Override
    public <EPR extends EndpointReference> EPR export(Class<EPR> adrsVer, String endpointAddress, T o) {
        return export(adrsVer, endpointAddress, null, o, null);
    }

    @NotNull
    public <EPR extends EndpointReference> EPR export(Class<EPR> adrsVer, String endpointAddress, String wsdlAddress, T o, EPRRecipe recipe) {
        if (endpointAddress == null) {
            throw new IllegalArgumentException("No address available");
        }

        String key = haMap.get(o);

        if (key != null) {
            return createEPR(key, adrsVer, endpointAddress, wsdlAddress, recipe);
        }

        // not exported yet.
        synchronized (this) {
            // double check now in the synchronization block to
            // really make sure that we can export.
            key = haMap.get(o);
            if (key != null) {
                return createEPR(key, adrsVer, endpointAddress, wsdlAddress, recipe);
            }

            if (o != null) {
                prepare(o);
            }
            key = UUID.randomUUID().toString();
            Instance instance = new Instance(o);
            if (logger.isLoggable(Level.FINEST)) {
                logger.log(Level.FINEST, "Storing instance ID/Instance/Object/TimerTask = [ {0} / {1} / {2} / {3} ]", new Object[]{key, instance, instance.instance, instance.task});
            }
            haMap.put(key, instance);
            if (timeoutMilliseconds != 0) {
                instance.restartTimer();
            }
        }

        return createEPR(key, adrsVer, endpointAddress, wsdlAddress, recipe);
    }

    /*
     * Creates an EPR that has the right key.
     */

    private <EPR extends EndpointReference> EPR createEPR(String key,
                                                          Class<EPR> eprClass, String address, String wsdlAddress, EPRRecipe recipe) {

        List<Element> referenceParameters = new ArrayList<Element>();
        List<Element> metadata = new ArrayList<Element>();

        Document doc = DOMUtil.createDom();
        Element cookie =
                doc.createElementNS(COOKIE_TAG.getNamespaceURI(),
                        COOKIE_TAG.getPrefix() + ":" + COOKIE_TAG.getLocalPart());
        cookie.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:"
                + COOKIE_TAG.getPrefix(), COOKIE_TAG.getNamespaceURI());
        cookie.setTextContent(key);
        referenceParameters.add(cookie);

        if (recipe != null) {
            for (Header h : recipe.getReferenceParameters()) {
                doc = DOMUtil.createDom();
                SAX2DOMEx s2d = new SAX2DOMEx(doc);
                try {
                    h.writeTo(s2d, XmlUtil.DRACONIAN_ERROR_HANDLER);
                    referenceParameters.add((Element) doc.getLastChild());
                } catch (SAXException e) {
                    throw new WebServiceException("Unable to write EPR Reference parameters " + h, e);
                }
            }
            Transformer t = XmlUtil.newTransformer();
            for (Source s : recipe.getMetadata()) {
                try {
                    DOMResult r = new DOMResult();
                    t.transform(s, r);
                    Document d = (Document) r.getNode();
                    metadata.add(d.getDocumentElement());
                } catch (TransformerException e) {
                    throw new IllegalArgumentException("Unable to write EPR metadata " + s, e);
                }
            }

        }

        return eprClass.cast(owner.getEndpointReference(eprClass, address, wsdlAddress, metadata, referenceParameters));
    }

    @Override
    public void unexport(@Nullable T o) {
        if (o == null) {
            return;
        }
        Instance i = haMap.remove(o);
        if (logger.isLoggable(Level.FINEST)) {
            logger.log(Level.FINEST, "Removed Instance = [ {0} ], remaining instance keys = [ {1} ]", new Object[]{o, haMap.instances.keySet()});
        }
        if (i != null) {
            i.cancel();
        }
    }

    @Override
    public T resolve(EndpointReference epr) {
        class CookieSniffer extends DefaultHandler {
            StringBuilder buf = new StringBuilder();
            boolean inCookie = false;

            @Override
            public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
                if (localName.equals(COOKIE_TAG.getLocalPart()) && uri.equals(COOKIE_TAG.getNamespaceURI())) {
                    inCookie = true;
                }
            }

            @Override
            public void characters(char ch[], int start, int length) throws SAXException {
                if (inCookie) {
                    buf.append(ch, start, length);
                }
            }

            @Override
            public void endElement(String uri, String localName, String qName) throws SAXException {
                inCookie = false;
            }
        }
        CookieSniffer sniffer = new CookieSniffer();
        epr.writeTo(new SAXResult(sniffer));

        Instance o = haMap.get(sniffer.buf.toString());
        if (o != null) {
            return o.instance;
        }
        return null;
    }

    @Override
    public void setFallbackInstance(T o) {
        if (o != null) {
            prepare(o);
        }
        this.fallback = o;
    }

    @Override
    public void setTimeout(long milliseconds, Callback<T> callback) {
        if (milliseconds < 0) {
            throw new IllegalArgumentException();
        }
        this.timeoutMilliseconds = milliseconds;
        this.timeoutCallback = callback;
        haMap.getExpiredTask().cancel();
        if (timeoutMilliseconds > 0) {
            startTimer();
            timer.schedule(haMap.newExpiredTask(), timeoutMilliseconds, timeoutMilliseconds);
        } else {
            stopTimer();
        }
    }

    @Override
    public void touch(T o) {
        Instance i = haMap.touch(o);
        if (i != null) {
            i.restartTimer();
        }
    }


    private synchronized void startTimer() {
        if (timer == null) {
            timer = new Timer("JAX-WS stateful web service timeout timer");
        }
    }

    private synchronized void stopTimer() {
        if (timer != null) {
            timer.cancel();
            timer = null;
        }
    }

    private class HAMap {
        // cookie --> Instance
        final Map<String, Instance> instances = new HashMap<String, Instance>();
        // object --> cookie
        final Map<T, String> reverseInstances = new HashMap<T, String>();
        final BackingStore<String, HAInstance> bs;
        // Removes expired entrees from BackingStore
        TimerTask expiredTask;

        HAMap() {
            StoreType type = haEnabled ? StoreType.IN_MEMORY : StoreType.NOOP;
            bs = HighAvailabilityProvider.INSTANCE.createBackingStore(
                    HighAvailabilityProvider.INSTANCE.getBackingStoreFactory(type),
                    owner.getServiceName() + ":" + owner.getPortName() + ":STATEFUL_WEB_SERVICE",
                    String.class,
                    HAInstance.class);
            expiredTask = newExpiredTask();
        }

        TimerTask getExpiredTask() {
            return expiredTask;
        }

        private TimerTask newExpiredTask() {
            expiredTask = new TimerTask() {
                @Override
                public void run() {
                    HighAvailabilityProvider.removeExpired(bs);
                }
            };
            return expiredTask;
        }

        synchronized String get(T t) {
            return reverseInstances.get(t);
        }

        synchronized Instance touch(T t) {
            String id = get(t);
            if (id != null) {
                Instance i = get(id);
                if (i != null) {
                    put(id, i);
                    return i;
                }
            }
            return null;
        }

        synchronized Instance get(String id) {
            Instance i = instances.get(id);
            if (i == null) {
                HAInstance<T> hai = HighAvailabilityProvider.loadFrom(bs, id, null);
                if (hai != null) {
                    T t = hai.getInstance(appCL);
                    i = new Instance(t);
                    instances.put(id, i);
                    reverseInstances.put(t, id);
                }
            }
            return i;
        }

        synchronized void put(String id, Instance newi) {
            Instance oldi = instances.get(id);
            boolean isNew = oldi == null;
            if (!isNew) {
                reverseInstances.remove(oldi.instance);
                // reuse the original timeout task
                newi.setTask(oldi.task);
            }

            instances.put(id, newi);
            reverseInstances.put(newi.instance, id);
            HAInstance<T> hai = new HAInstance<T>(newi.instance, timeoutMilliseconds);
            HighAvailabilityProvider.saveTo(bs, id, hai, isNew);
        }

        synchronized void put(T t) {
            String id = reverseInstances.get(t);
            if (id != null) {
                put(id, new Instance(t));
            }
        }

        synchronized void remove(String id) {
            Instance i = instances.get(id);
            if (i != null) {
                instances.remove(id);
                reverseInstances.remove(i.instance);
                HighAvailabilityProvider.removeFrom(bs, id);
            }
        }

        synchronized Instance remove(T t) {
            String id = reverseInstances.get(t);
            if (id != null) {
                reverseInstances.remove(t);
                Instance i = instances.remove(id);
                HighAvailabilityProvider.removeFrom(bs, id);
                return i;
            }
            return null;
        }

        synchronized void destroy() {
            instances.clear();
            reverseInstances.clear();
            HighAvailabilityProvider.destroy(bs);
        }

        Collection<Instance> values() {
            return instances.values();
        }

    }


    private static final QName COOKIE_TAG = new QName("http://jax-ws.dev.java.net/xml/ns/", "objectId", "jaxws");

    private static final Logger logger =
            Logger.getLogger(com.sun.xml.ws.util.Constants.LoggingDomain + ".server");
}
