/**
 * 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.openejb.client;

import org.apache.openejb.client.proxy.ProxyManager;
import org.apache.openejb.client.serializer.SerializationWrapper;
import org.apache.openejb.client.util.ClassLoaderUtil;

import javax.ejb.EJBException;
import javax.ejb.EJBObject;
import java.lang.reflect.Method;
import java.rmi.RemoteException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;

@SuppressWarnings("NullArgumentToVariableArgMethod")
public abstract class EJBObjectHandler extends EJBInvocationHandler {
    protected static final Method GETEJBHOME = getMethod(EJBObject.class, "getEJBHome", null);
    protected static final Method GETHANDLE = getMethod(EJBObject.class, "getHandle", null);
    protected static final Method GETPRIMARYKEY = getMethod(EJBObject.class, "getPrimaryKey", null);
    protected static final Method ISIDENTICAL = getMethod(EJBObject.class, "isIdentical", EJBObject.class);
    protected static final Method REMOVE = getMethod(EJBObject.class, "remove", null);
    protected static final Method GETHANDLER = getMethod(EJBObjectProxy.class, "getEJBObjectHandler", null);
    protected static final Method CANCEL = getMethod(Future.class, "cancel", boolean.class);

    /*
    * The registryId is a logical identifier that is used as a key when placing EntityEJBObjectHandler into
    * the BaseEjbProxyHanlder's liveHandleRegistry.  EntityEJBObjectHandlers that represent the same
    * bean identity (keyed by the registry id) will be stored together so that they can be removed together
    * when the EJBInvocationHandler.invalidateAllHandlers is invoked. The EntityEJBObjectHandler uses a
    * compound key composed of the entity bean's primary key, deployment id, and
    * container id.  This uniquely identifies the bean identity that is proxied by this handler allowing it
    * to be removed with other handlers bound to the same registry id.
    */
    public Object registryId;

    EJBHomeProxy ejbHome = null;

    protected ThreadPoolExecutor executor = null;

    public EJBObjectHandler() {
    }

    public EJBObjectHandler(final ThreadPoolExecutor es, final EJBMetaDataImpl ejb, final ServerMetaData server, final ClientMetaData client, final JNDIContext.AuthenticationInfo auth) {
        super(ejb, server, client, null, auth);
        this.executor = es;
    }

    public EJBObjectHandler(final ThreadPoolExecutor es,
                            final EJBMetaDataImpl ejb,
                            final ServerMetaData server,
                            final ClientMetaData client,
                            final Object primaryKey,
                            final JNDIContext.AuthenticationInfo auth) {
        super(ejb, server, client, primaryKey, auth);
        this.executor = es;
    }

    protected void setEJBHomeProxy(final EJBHomeProxy ejbHome) {
        this.ejbHome = ejbHome;
    }

    public static EJBObjectHandler createEJBObjectHandler(final ThreadPoolExecutor executorService,
                                                          final EJBMetaDataImpl ejb,
                                                          final ServerMetaData server,
                                                          final ClientMetaData client,
                                                          final Object primaryKey,
                                                          final JNDIContext.AuthenticationInfo auth) {

        switch (ejb.type) {
            case EJBMetaDataImpl.BMP_ENTITY:
            case EJBMetaDataImpl.CMP_ENTITY:

                return new EntityEJBObjectHandler(executorService, ejb, server, client, primaryKey, auth);

            case EJBMetaDataImpl.STATEFUL:

                return new StatefulEJBObjectHandler(executorService, ejb, server, client, primaryKey, auth);

            case EJBMetaDataImpl.STATELESS:

                return new StatelessEJBObjectHandler(executorService, ejb, server, client, primaryKey, auth);

            case EJBMetaDataImpl.SINGLETON:

                return new SingletonEJBObjectHandler(executorService, ejb, server, client, primaryKey, auth);
        }

        throw new IllegalStateException("Uknown bean type code '" + ejb.type + "' : " + ejb.toString());
    }

    public abstract Object getRegistryId();

    public EJBObjectProxy createEJBObjectProxy() {

        EJBObjectProxy ejbObject = null;

        try {
            final List<Class> interfaces = new ArrayList<Class>();
            // Interface class must be listed first, before EJBObjectProxy,
            // otherwise the proxy code will select the openejb system class
            // loader for proxy creation instead of the application class loader
            if (ejb.remoteClass != null) {
                interfaces.add(ejb.remoteClass);
            } else if (ejb.businessClasses.size() > 0) {
                interfaces.addAll(ejb.businessClasses);
            }
            interfaces.add(EJBObjectProxy.class);

            final ClassLoader oldCl = Thread.currentThread().getContextClassLoader();
            final boolean parent = ClassLoaderUtil.isParent(getClass().getClassLoader(), oldCl);
            if (!parent) {
                Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
            }
            try {
                ejbObject = (EJBObjectProxy) ProxyManager.newProxyInstance(interfaces.toArray(new Class[interfaces.size()]), this);
            } finally {
                if (!parent) {
                    Thread.currentThread().setContextClassLoader(oldCl);
                }
            }
        } catch (IllegalAccessException e) {

            e.printStackTrace();
        }
        return ejbObject;
    }

    @Override
    public Object _invoke(final Object p, final Method m, final Object[] a) throws Throwable {

        try {

            if (m.getDeclaringClass().equals(Object.class)) {

                if (m.equals(TOSTRING)) {
                    return "proxy=" + this;
                } else if (m.equals(EQUALS)) {
                    return equals(m, a, p);
                } else if (m.equals(HASHCODE)) {
                    return this.hashCode();
                } else {
                    throw new UnsupportedOperationException("Unkown method: " + m);
                }

            } else if (m.getDeclaringClass() == EJBObjectProxy.class) {

                if (m.equals(GETHANDLER)) {
                    return this;
                } else if (m.getName().equals("writeReplace")) {
                    return new EJBObjectProxyHandle(this);
                } else if (m.getName().equals("readResolve")) {
                    return null;
                } else {
                    throw new UnsupportedOperationException("Unkown method: " + m);
                }

            } else if (m.getDeclaringClass() == javax.ejb.EJBObject.class) {

                if (m.equals(GETHANDLE)) {
                    return getHandle(m, a, p);
                } else if (m.equals(GETPRIMARYKEY)) {
                    return getPrimaryKey(m, a, p);
                } else if (m.equals(ISIDENTICAL)) {
                    return isIdentical(m, a, p);
                } else if (m.equals(GETEJBHOME)) {
                    return getEJBHome(m, a, p);
                } else if (m.equals(REMOVE)) {
                    return remove(m, a, p);
                } else {
                    throw new UnsupportedOperationException("Unkown method: " + m);
                }

            } else {

                return businessMethod(m, a, p);

            }

        } catch (SystemException e) {
            invalidateAllHandlers(getRegistryId());
            throw convertException(getCause(e), m);
            /*
            * Application exceptions must be reported dirctly to the client. They
            * do not impact the viability of the proxy.
            */
        } catch (ApplicationException ae) {
            throw convertException(getCause(ae), m);
            /*
            * A system exception would be highly unusual and would indicate a sever
            * problem with the container system.
            */
        } catch (SystemError se) {
            invalidateReference();
            if (remote) {
                throw new RemoteException("Container has suffered a SystemException", getCause(se));
            } else {
                throw new EJBException("Container has suffered a SystemException").initCause(getCause(se));
            }
        } catch (Throwable throwable) {
            if (remote) {
                if (throwable instanceof RemoteException) {
                    throw throwable;
                }
                throw new RemoteException("Unknown Container Exception: " + throwable.getClass().getName() + ": " + throwable.getMessage(), getCause(throwable));
            } else {
                if (throwable instanceof EJBException) {
                    throw throwable;
                }
                throw new EJBException("Unknown Container Exception: " + throwable.getClass().getName() + ": " + throwable.getMessage()).initCause(getCause(throwable));
            }
        }
    }

    protected Object getEJBHome(final Method method, final Object[] args, final Object proxy) throws Throwable {
        if (ejbHome == null) {
            ejbHome = EJBHomeHandler.createEJBHomeHandler(executor, ejb, server, client, authenticationInfo).createEJBHomeProxy();
        }
        return ejbHome;
    }

    protected Object getHandle(final Method method, final Object[] args, final Object proxy) throws Throwable {
        return new EJBObjectHandle((EJBObjectProxy) proxy);
    }

    protected abstract Object getPrimaryKey(Method method, Object[] args, Object proxy) throws Throwable;

    protected abstract Object isIdentical(Method method, Object[] args, Object proxy) throws Throwable;

    protected abstract Object equals(Method method, Object[] args, Object proxy) throws Throwable;

    protected abstract Object remove(Method method, Object[] args, Object proxy) throws Throwable;

    @SuppressWarnings("unchecked")
    protected Object businessMethod(final Method method, final Object[] args, final Object proxy) throws Throwable {

        if (ejb.isAsynchronousMethod(method)) {
            try {
                final String requestId = UUID.randomUUID().toString();
                final EJBResponse response = new EJBResponse();
                final AsynchronousCall asynchronousCall = new AsynchronousCall(method, args, proxy, requestId, response);
                if (executor == null) {
                    executor = JNDIContext.newExecutor(-1, null);
                }
                return new FutureAdapter(executor.submit(asynchronousCall), response, requestId);
            } catch (RejectedExecutionException e) {
                throw new EJBException("failed to allocate internal resource to execute the target task", e);
            }
        } else {
            return _businessMethod(method, args, proxy, null);
        }
    }

    private Object _businessMethod(final Method method, final Object[] args, final Object proxy, final String requestId) throws Throwable {
        final EJBRequest req = new EJBRequest(RequestMethodCode.EJB_OBJECT_BUSINESS_METHOD, ejb, method, args, primaryKey, client.getSerializer());

        //Currently, we only set the requestId while the asynchronous invocation is called
        final EJBRequest.Body body = req.getBody();
        body.setRequestId(requestId);
        body.setAuthentication(this.authenticationInfo);
        final EJBResponse res = request(req);
        return _handleBusinessMethodResponse(res);
    }

    private Object _businessMethod(final Method method, final Object[] args, final Object proxy, final String requestId, final EJBResponse response) throws Throwable {
        final EJBRequest req = new EJBRequest(RequestMethodCode.EJB_OBJECT_BUSINESS_METHOD, ejb, method, args, primaryKey, client.getSerializer());

        //Currently, we only set the request while the asynchronous invocation is called
        req.getBody().setRequestId(requestId);
        final EJBResponse res = request(req, response);
        return _handleBusinessMethodResponse(res);
    }

    private Object _handleBusinessMethodResponse(final EJBResponse res) throws Throwable {
        switch (res.getResponseCode()) {
            case ResponseCodes.EJB_ERROR:
                throw new SystemError((ThrowableArtifact) getResult(res));
            case ResponseCodes.EJB_SYS_EXCEPTION:
                throw new SystemException((ThrowableArtifact) getResult(res));
            case ResponseCodes.EJB_APP_EXCEPTION:
                throw new ApplicationException((ThrowableArtifact) getResult(res));
            case ResponseCodes.EJB_OK:
                return getResult(res);
            case ResponseCodes.AUTH_DENIED:
                final Object result = res.getResult();
                if (ThrowableArtifact.class.isInstance(result)) {
                    throw ThrowableArtifact.class.cast(result).getThrowable();
                } // else it be a remote exception
            default:
                throw new RemoteException("Received invalid response code from server: " + res.getResponseCode());
        }
    }

    private Object getResult(final EJBResponse res) throws ClassNotFoundException {
        final Object result = res.getResult();
        if (client.getSerializer() != null && SerializationWrapper.class.isInstance(result)) {
            final SerializationWrapper wrapper = SerializationWrapper.class.cast(result);
            return client.getSerializer().deserialize(wrapper.getData(), Thread.currentThread().getContextClassLoader().loadClass(wrapper.getClassname()));
        }
        return res.getResult();
    }

    private class AsynchronousCall implements Callable {

        private Method method;

        private Object[] args;

        private Object proxy;

        private String requestId;

        private EJBResponse response;

        public AsynchronousCall(final Method method, final Object[] args, final Object proxy, final String requestId, final EJBResponse response) {
            this.method = method;
            this.args = args;
            this.proxy = proxy;
            this.requestId = requestId;
            this.response = response;
        }

        @Override
        public Object call() throws Exception {
            try {
                return _businessMethod(method, args, proxy, requestId, response);
            } catch (Exception e) {
                throw e;
            } catch (Throwable error) {
                throw new SystemException(error);
            }
        }
    }

    private class FutureAdapter<T> implements Future<T> {

        private Future<T> target;

        private String requestId;

        private EJBResponse response;

        private volatile boolean canceled;

        private AtomicBoolean lastMayInterruptIfRunningValue = new AtomicBoolean(false);

        public FutureAdapter(final Future<T> target, final EJBResponse response, final String requestId) {
            this.target = target;
            this.requestId = requestId;
            this.response = response;
        }

        @SuppressWarnings({"SuspiciousMethodCalls", "UnnecessaryBoxing"})
        @Override
        public boolean cancel(final boolean mayInterruptIfRunning) {
            /* In EJB 3.1 spec 3.4.8.1.1
             * a. If a client calls cancel on its Future object, the container will attempt to cancel 
             *    the associated asynchronous invocation only if that invocation has not already been dispatched.
             *    There is no guarantee that an asynchronous invocation can be cancelled, regardless of how quickly 
             *    cancel is called after the client receives its Future object.
             *    If the asynchronous invocation can not be cancelled, the method must return false.
             *    If the asynchronous invocation is successfully cancelled, the method must return true.
             *
             * b. The meaning of parameter mayInterruptIfRunning is changed.
             *
             *  So, we should never call cancel(true), or the underlying Future object will try to interrupt the target thread.
            */

            /**
             * We use our own flag canceled to identify whether the task is canceled successfully.
             */
            if (canceled) {
                return true;
            }
            if (executor.getQueue().remove(target)) {
                // We successfully remove the task from the queue
                canceled = true;
                return true;
            } else {
                // Did not find the task in the queue, the status might be ran/canceled or running
                // Future.isDone() will return true when the task has been ran or canceled,
                // Since we never call the Future.cancel method, the isDone method will only return true when the task has ran
                if (!target.isDone()) {
                    //The task is in the running state
                    if (lastMayInterruptIfRunningValue.getAndSet(mayInterruptIfRunning) == mayInterruptIfRunning) {
                        return false;
                    }
                    final EJBRequest req = new EJBRequest(RequestMethodCode.FUTURE_CANCEL,
                        ejb,
                        CANCEL,
                        new Object[]{Boolean.valueOf(mayInterruptIfRunning)},
                        primaryKey,
                        client.getSerializer());
                    req.getBody().setRequestId(requestId);
                    try {
                        final EJBResponse res = request(req);
                        if (res.getResponseCode() != ResponseCodes.EJB_OK) {
                            //TODO how do we notify the user that we fail to configure the value ?
                            Logger.getLogger(this.getClass().getName()).info("Unexpected response on cancel: " + res);
                        }
                    } catch (Exception e) {
                        //TODO how to handle
                        Logger.getLogger(this.getClass().getName()).log(Level.INFO, "Unexpected error on cancel", e);
                        return false;
                    }
                }
                return false;
            }
        }

        @Override
        public T get() throws InterruptedException, ExecutionException {
            if (canceled) {
                throw new CancellationException();
            }
            return target.get();
        }

        @Override
        public T get(final long timeout, final TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
            if (canceled) {
                throw new CancellationException();
            }
            return target.get(timeout, unit);
        }

        @Override
        public boolean isCancelled() {
            return canceled;
        }

        @Override
        public boolean isDone() {
            if (canceled) {
                return false;
            }
            return target.isDone();
        }
    }
}