/*
 * 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.assembler.classic;

import org.apache.openejb.BeanContext;
import org.apache.openejb.BeanType;
import org.apache.openejb.Injection;
import org.apache.openejb.ModuleContext;
import org.apache.openejb.OpenEJBException;
import org.apache.openejb.core.cmp.CmpUtil;
import org.apache.openejb.dyni.DynamicSubclass;
import org.apache.openejb.loader.SystemInstance;
import org.apache.openejb.persistence.JtaEntityManager;
import org.apache.openejb.spi.ContainerSystem;
import org.apache.openejb.util.Duration;
import org.apache.openejb.util.Index;
import org.apache.openejb.util.Messages;

import javax.ejb.TimedObject;
import javax.ejb.Timer;
import javax.naming.Context;
import javax.naming.NamingException;
import javax.persistence.EntityManagerFactory;
import javax.persistence.SynchronizationType;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

class EnterpriseBeanBuilder {
    private final EnterpriseBeanInfo bean;
    private final BeanType ejbType;
    private final List<Exception> warnings = new ArrayList<>();
    private final ModuleContext moduleContext;
    private final List<Injection> moduleInjections;

    public EnterpriseBeanBuilder(final EnterpriseBeanInfo bean, final ModuleContext moduleContext, final List<Injection> moduleInjections) {
        this.moduleContext = moduleContext;
        this.bean = bean;
        this.moduleInjections = moduleInjections;

        if (bean.type == EnterpriseBeanInfo.STATEFUL) {
            ejbType = BeanType.STATEFUL;
        } else if (bean.type == EnterpriseBeanInfo.STATELESS) {
            ejbType = BeanType.STATELESS;
        } else if (bean.type == EnterpriseBeanInfo.SINGLETON) {
            ejbType = BeanType.SINGLETON;
        } else if (bean.type == EnterpriseBeanInfo.MANAGED) {
            ejbType = BeanType.MANAGED;
        } else if (bean.type == EnterpriseBeanInfo.MESSAGE) {
            ejbType = BeanType.MESSAGE_DRIVEN;
        } else if (bean.type == EnterpriseBeanInfo.ENTITY) {
            final String persistenceType = ((EntityBeanInfo) bean).persistenceType;
            ejbType = persistenceType.equalsIgnoreCase("Container") ? BeanType.CMP_ENTITY : BeanType.BMP_ENTITY;
        } else {
            throw new UnsupportedOperationException("No building support for bean type: " + bean);
        }
    }

    public BeanContext build() throws OpenEJBException {
        Class ejbClass = loadClass(bean.ejbClass, "classNotFound.ejbClass");

        if (DynamicSubclass.isDynamic(ejbClass)) {
            ejbClass = DynamicSubclass.createSubclass(ejbClass, moduleContext.getClassLoader());
        }

        Class home = null;
        Class remote = null;
        if (bean.home != null) {
            home = loadClass(bean.home, "classNotFound.home");
            remote = loadClass(bean.remote, "classNotFound.remote");
        }

        Class<?> proxy = null;
        if (bean.proxy != null) {
            proxy = loadClass(bean.proxy, "classNotFound.proxy");
        }

        Class<?> localhome = null;
        Class<?> local = null;
        if (bean.localHome != null) {
            localhome = loadClass(bean.localHome, "classNotFound.localHome");
            local = loadClass(bean.local, "classNotFound.local");
        }

        final List<Class> businessLocals = new ArrayList<>();
        for (final String businessLocal : bean.businessLocal) {
            businessLocals.add(loadClass(businessLocal, "classNotFound.businessLocal"));
        }

        final List<Class> businessRemotes = new ArrayList<>();
        for (final String businessRemote : bean.businessRemote) {
            businessRemotes.add(loadClass(businessRemote, "classNotFound.businessRemote"));
        }

        Class serviceEndpoint = null;
        if (BeanType.STATELESS == ejbType || BeanType.SINGLETON == ejbType) {
            if (bean.serviceEndpoint != null) {
                serviceEndpoint = loadClass(bean.serviceEndpoint, "classNotFound.sei");
            }
        }

        Class primaryKey = null;
        if (ejbType.isEntity() && ((EntityBeanInfo) bean).primKeyClass != null) {
            final String className = ((EntityBeanInfo) bean).primKeyClass;
            primaryKey = loadClass(className, "classNotFound.primaryKey");
        }

        final String transactionType = bean.transactionType;

        // determine the injections
        final InjectionBuilder injectionBuilder = new InjectionBuilder(moduleContext.getClassLoader());
        final List<Injection> injections = injectionBuilder.buildInjections(bean.jndiEnc);
        final Set<Class<?>> relevantClasses = new HashSet<>();
        Class c = ejbClass;
        do {
            relevantClasses.add(c);
            c = c.getSuperclass();
        } while (c != null && c != Object.class);

        for (final Injection injection : moduleInjections) {
            if (relevantClasses.contains(injection.getTarget())) {
                injections.add(injection);
            }
        }

        // build the enc
        final JndiEncBuilder jndiEncBuilder = new JndiEncBuilder(bean.jndiEnc, injections, transactionType, moduleContext.getId(), null,
                moduleContext.getUniqueId(), moduleContext.getClassLoader(), moduleContext.getAppContext() == null ? moduleContext.getProperties() : moduleContext.getAppContext().getProperties());
        final Context compJndiContext = jndiEncBuilder.build(JndiEncBuilder.JndiScope.comp);
        bind(compJndiContext, "module", moduleContext.getModuleJndiContext());
        bind(compJndiContext, "app", moduleContext.getAppContext().getAppJndiContext());
        bind(compJndiContext, "global", moduleContext.getAppContext().getGlobalJndiContext());

        final BeanContext deployment;
        if (BeanType.MESSAGE_DRIVEN != ejbType) {
            deployment = new BeanContext(bean.ejbDeploymentId, compJndiContext, moduleContext, ejbClass, home, remote, localhome, local, proxy, serviceEndpoint, businessLocals, businessRemotes, primaryKey, ejbType, bean.localbean && ejbType.isSession(), bean.passivable);
            if (bean instanceof ManagedBeanInfo) {
                deployment.setHidden(((ManagedBeanInfo) bean).hidden);
            }
        } else {
            final MessageDrivenBeanInfo messageDrivenBeanInfo = (MessageDrivenBeanInfo) bean;
            final Class mdbInterface = loadClass(messageDrivenBeanInfo.mdbInterface, "classNotFound.mdbInterface");
            deployment = new BeanContext(bean.ejbDeploymentId, compJndiContext, moduleContext, ejbClass, mdbInterface, messageDrivenBeanInfo.activationProperties);
            deployment.setDestinationId(messageDrivenBeanInfo.destinationId);
        }

        deployment.getProperties().putAll(bean.properties);

        deployment.setEjbName(bean.ejbName);

        deployment.setRunAs(bean.runAs);
        deployment.setRunAsUser(bean.runAsUser);

        deployment.getInjections().addAll(injections);

        // ejbTimeout
        deployment.setEjbTimeout(getTimeout(ejbClass, bean.timeoutMethod));

        if (bean.statefulTimeout != null) {
            deployment.setStatefulTimeout(new Duration(bean.statefulTimeout.time, TimeUnit.valueOf(bean.statefulTimeout.unit)));
        }

        if (bean instanceof StatefulBeanInfo) {
            final StatefulBeanInfo statefulBeanInfo = (StatefulBeanInfo) bean;

            for (final InitMethodInfo init : statefulBeanInfo.initMethods) {
                final Method beanMethod = MethodInfoUtil.toMethod(ejbClass, init.beanMethod);
                final List<Method> methods = new ArrayList<>();

                if (home != null) {
                    methods.addAll(Arrays.asList(home.getMethods()));
                }
                if (localhome != null) {
                    methods.addAll(Arrays.asList(localhome.getMethods()));
                }

                for (final Method homeMethod : methods) {
                    if (init.createMethod != null && !init.createMethod.methodName.equals(homeMethod.getName())) {
                        continue;
                    }

                    if (!homeMethod.getName().startsWith("create")) {
                        continue;
                    }

                    if (paramsMatch(beanMethod, homeMethod)) {
                        deployment.mapMethods(homeMethod, beanMethod);
                    }
                }
            }

            for (final RemoveMethodInfo removeMethod : statefulBeanInfo.removeMethods) {

                if (removeMethod.beanMethod.methodParams == null) {

                    final MethodInfo methodInfo = new MethodInfo();
                    methodInfo.methodName = removeMethod.beanMethod.methodName;
                    methodInfo.methodParams = removeMethod.beanMethod.methodParams;
                    methodInfo.className = removeMethod.beanMethod.className;
                    final List<Method> methods = MethodInfoUtil.matchingMethods(methodInfo, ejbClass);

                    for (final Method method : methods) {
                        deployment.getRemoveMethods().add(method);
                        deployment.setRetainIfExeption(method, removeMethod.retainIfException);
                    }

                } else {
                    final Method method = MethodInfoUtil.toMethod(ejbClass, removeMethod.beanMethod);
                    deployment.getRemoveMethods().add(method);
                    deployment.setRetainIfExeption(method, removeMethod.retainIfException);
                }

            }

            final Map<EntityManagerFactory, BeanContext.EntityManagerConfiguration> extendedEntityManagerFactories = new HashMap<>();
            for (final PersistenceContextReferenceInfo info : statefulBeanInfo.jndiEnc.persistenceContextRefs) {
                if (info.extended) {
                    try {
                        final ContainerSystem containerSystem = SystemInstance.get().getComponent(ContainerSystem.class);
                        final Object o = containerSystem.getJNDIContext().lookup(PersistenceBuilder.getOpenEJBJndiName(info.unitId));
                        final EntityManagerFactory emf = EntityManagerFactory.class.cast(o);
                        extendedEntityManagerFactories.put(
                            emf,
                            new BeanContext.EntityManagerConfiguration(
                                info.properties,
                                JtaEntityManager.isJPA21(emf) && info.synchronizationType != null ? SynchronizationType.valueOf(info.synchronizationType) : null));
                    } catch (final NamingException e) {
                        throw new OpenEJBException("PersistenceUnit '" + info.unitId + "' not found for EXTENDED ref '" + info.referenceName + "'");
                    }

                }
            }
            deployment.setExtendedEntityManagerFactories(new Index<>(extendedEntityManagerFactories));
        }

        if (ejbType.isSession() || ejbType.isMessageDriven()) {
            deployment.setBeanManagedTransaction("Bean".equalsIgnoreCase(bean.transactionType));
        }

        if (ejbType.isSession()) {
            // Allow dependsOn to work for all session beans
            deployment.getDependsOn().addAll(bean.dependsOn);

        }

        if (ejbType == BeanType.SINGLETON) {
            deployment.setBeanManagedConcurrency("Bean".equalsIgnoreCase(bean.concurrencyType));
            deployment.setLoadOnStartup(bean.loadOnStartup);
        }

        if (ejbType.isEntity()) {
            final EntityBeanInfo entity = (EntityBeanInfo) bean;

            deployment.setCmp2(entity.cmpVersion == 2);
            deployment.setIsReentrant(entity.reentrant.equalsIgnoreCase("true"));

            if (ejbType == BeanType.CMP_ENTITY) {
                Class cmpImplClass = null;
                final String cmpImplClassName = CmpUtil.getCmpImplClassName(entity.abstractSchemaName, entity.ejbClass);
                cmpImplClass = loadClass(cmpImplClassName, "classNotFound.cmpImplClass");
                deployment.setCmpImplClass(cmpImplClass);
                deployment.setAbstractSchemaName(entity.abstractSchemaName);

                for (final QueryInfo query : entity.queries) {

                    if (query.remoteResultType) {
                        final StringBuilder methodSignature = new StringBuilder();
                        methodSignature.append(query.method.methodName);
                        if (query.method.methodParams != null && !query.method.methodParams.isEmpty()) {
                            methodSignature.append('(');
                            boolean first = true;
                            for (final String methodParam : query.method.methodParams) {
                                if (!first) {
                                    methodSignature.append(",");
                                }
                                methodSignature.append(methodParam);
                                first = false;
                            }
                            methodSignature.append(')');
                        }
                        deployment.setRemoteQueryResults(methodSignature.toString());
                    }

                }

                if (entity.primKeyField != null) {
                    deployment.setPrimaryKeyField(entity.primKeyField);
                }
            }
        }

        deployment.createMethodMap();

        //Configure asynchronous tag after the method map is created, so while we check whether the method is asynchronous,
        //we could directly check the matching bean method.
        if (ejbType == BeanType.STATELESS || ejbType == BeanType.SINGLETON || ejbType == BeanType.STATEFUL) {
            for (final NamedMethodInfo methodInfo : bean.asynchronous) {
                final Method method = MethodInfoUtil.toMethod(ejbClass, methodInfo);
                deployment.getMethodContext(deployment.getMatchingBeanMethod(method)).setAsynchronous(true);
            }
            for (final String className : bean.asynchronousClasses) {
                deployment.getAsynchronousClasses().add(loadClass(className, "classNotFound.ejbClass"));
            }
            deployment.createAsynchronousMethodSet();
        }

        for (final SecurityRoleReferenceInfo securityRoleReference : bean.securityRoleReferences) {
            deployment.addSecurityRoleReference(securityRoleReference.roleName, securityRoleReference.roleLink);
        }

        return deployment;
    }

    private void bind(final Context compJndiContext, final String s, final Context moduleJndiContext) throws OpenEJBException {
        final Context c;
        try {
            c = (Context) moduleJndiContext.lookup(s);
        } catch (final NamingException e) {
            //ok, nothing there....
            return;
        }
        try {
            compJndiContext.bind(s, c);
        } catch (final NamingException e) {
            throw new OpenEJBException("Could not bind context at " + s, e);
        }
    }

    public static boolean paramsMatch(final Method methodA, final Method methodB) {
        if (methodA.getParameterTypes().length != methodB.getParameterTypes().length) {
            return false;
        }

        for (int i = 0; i < methodA.getParameterTypes().length; i++) {
            final Class<?> a = methodA.getParameterTypes()[i];
            final Class<?> b = methodB.getParameterTypes()[i];
            if (!a.equals(b)) {
                return false;
            }
        }
        return true;
    }

    public List<Exception> getWarnings() {
        return warnings;
    }

    private Method getTimeout(final Class ejbClass, final NamedMethodInfo info) {
        Method timeout = null;
        try {
            if (TimedObject.class.isAssignableFrom(ejbClass)) {
                timeout = ejbClass.getMethod("ejbTimeout", Timer.class);
            } else if (info != null) {
                try {
                    timeout = MethodInfoUtil.toMethod(ejbClass, info);
                } catch (final IllegalStateException e) {
                    //Spec 18.2.5.3 [102] For the compatibility of timeout method signature, if method-params is  not set, it is also required to search the method signaure below :
                    //void <METHOD> (Timer timer)

                    // TODO Lets move this fallback searching into the config side so people do
                    // not get 'Callback method does not exist' runtime exceptions and instead
                    // get a validation failure.  Then we can explicitly add the (Timer) param
                    // if the fallback method does exist.
                    if (info.methodParams == null) {
                        final NamedMethodInfo candidateInfo = new NamedMethodInfo();
                        candidateInfo.className = info.className;
                        candidateInfo.id = info.id;
                        candidateInfo.methodName = info.methodName;
                        candidateInfo.methodParams = Collections.singletonList(Timer.class.getName());
                        timeout = MethodInfoUtil.toMethod(ejbClass, candidateInfo);
                    }
                }
            }
        } catch (final Exception e) {
            warnings.add(e);
        }

        return timeout;
    }


    private Class loadClass(final String className, final String messageCode) throws OpenEJBException {
        final Class clazz = load(className, messageCode);
        try {
//            clazz.getDeclaredMethods();
//            clazz.getDeclaredFields();
//            clazz.getDeclaredConstructors();
//            clazz.getInterfaces();
            return clazz;
        } catch (final NoClassDefFoundError e) {
            if (clazz.getClassLoader() != moduleContext.getClassLoader()) {
                final String message = messages().format("cl0008", className, clazz.getClassLoader(), moduleContext.getClassLoader(), e.getMessage());
                throw new OpenEJBException(messages().format(messageCode, className, bean.ejbDeploymentId, message), e);
            } else {
                final String message = messages().format("cl0009", className, clazz.getClassLoader(), e.getMessage());
                throw new OpenEJBException(messages().format(messageCode, className, bean.ejbDeploymentId, message), e);
            }
        }
    }

    private Messages messages() { // new is fine cause for errors only
        return new Messages("org.apache.openejb.util.resources");
    }

    private Class load(final String className, final String messageCode) throws OpenEJBException {
        try {
            return Class.forName(className, true, moduleContext.getClassLoader());
        } catch (final ClassNotFoundException e) {
            final String message = messages().format("cl0007", className, bean.codebase);
            throw new OpenEJBException(messages().format(messageCode, className, bean.ejbDeploymentId, message));
        }
    }
}
