/*
 * JBoss, Home of Professional Open Source
 * Copyright 2009 Red Hat Inc. and/or its affiliates and other contributors
 * by the @authors tag. See the copyright.txt in the distribution for a
 * full listing of individual contributors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 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.jboss.arquillian.testenricher.ejb;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.MessageFormat;
import java.util.List;
import java.util.logging.Logger;
import javax.ejb.EJB;
import javax.naming.Context;
import javax.naming.NamingException;
import org.jboss.arquillian.core.api.Instance;
import org.jboss.arquillian.core.api.annotation.Inject;
import org.jboss.arquillian.core.spi.Validate;
import org.jboss.arquillian.test.spi.TestEnricher;

/**
 * Enricher that provide EJB class and setter method injection.
 *
 * @author <a href="mailto:aknutsen@redhat.com">Aslak Knutsen</a>
 * @version $Revision: $
 */
public class EJBInjectionEnricher implements TestEnricher {
    private static final String ANNOTATION_NAME = "javax.ejb.EJB";

    private static final Logger log = Logger.getLogger(TestEnricher.class.getName());

    @Inject
    private Instance<Context> contextInst;

    /*
     * (non-Javadoc)
     *
     * @see org.jboss.arquillian.spi.TestEnricher#enrich(org.jboss.arquillian.spi .Context, java.lang.Object)
     */
    public void enrich(Object testCase) {
        if (SecurityActions.isClassPresent(ANNOTATION_NAME)) {
            try {
                if (createContext() != null) {
                    injectClass(testCase);
                }
            } catch (Exception e) {
                log.throwing(EJBInjectionEnricher.class.getName(), "enrich", e);
            }
        }
    }

    /*
     * (non-Javadoc)
     *
     * @see org.jboss.arquillian.spi.TestEnricher#resolve(org.jboss.arquillian.spi .Context, java.lang.reflect.Method)
     */
    public Object[] resolve(Method method) {
        return new Object[method.getParameterTypes().length];
    }

    /**
     * Obtains all field in the specified class which contain the specified annotation
     *
     * @throws IllegalArgumentException
     *     If either argument is not specified
     */
    // TODO Hack, this leaks out privileged operations outside the package. Extract out properly.
    protected List<Field> getFieldsWithAnnotation(final Class<?> clazz, final Class<? extends Annotation> annotation)
        throws IllegalArgumentException {
        // Precondition checks
        if (clazz == null) {
            throw new IllegalArgumentException("clazz must be specified");
        }
        if (annotation == null) {
            throw new IllegalArgumentException("annotation must be specified");
        }

        // Delegate to the privileged operations
        return SecurityActions.getFieldsWithAnnotation(clazz, annotation);
    }

    protected void injectClass(Object testCase) {
        try {
            @SuppressWarnings("unchecked")
            Class<? extends Annotation> ejbAnnotation =
                (Class<? extends Annotation>) SecurityActions.getThreadContextClassLoader().loadClass(ANNOTATION_NAME);

            List<Field> annotatedFields = SecurityActions.getFieldsWithAnnotation(
                testCase.getClass(),
                ejbAnnotation);

            for (Field field : annotatedFields) {
                if (field.get(testCase) == null) // only try to lookup fields that are not already set
                {
                    EJB fieldAnnotation = (EJB) field.getAnnotation(ejbAnnotation);
                    try {
                        String mappedName = fieldAnnotation.mappedName();
                        ;
                        String beanName = fieldAnnotation.beanName();
                        String lookup = attemptToGet31LookupField(fieldAnnotation);

                        String[] jndiNames = resolveJNDINames(field.getType(), mappedName, beanName, lookup);
                        Object ejb = lookupEJB(jndiNames);
                        field.set(testCase, ejb);
                    } catch (Exception e) {
                        log.fine("Could not lookup " + fieldAnnotation + ", other Enrichers might, move on. Exception: "
                            + e.getMessage());
                    }
                }
            }

            List<Method> methods = SecurityActions.getMethodsWithAnnotation(testCase.getClass(), ejbAnnotation);

            for (Method method : methods) {
                if (method.getParameterTypes().length != 1) {
                    throw new RuntimeException("@EJB only allowed on single argument methods");
                }
                if (!method.getName().startsWith("set")) {
                    throw new RuntimeException("@EJB only allowed on 'set' methods");
                }
                EJB parameterAnnotation = null; // method.getParameterAnnotations()[0]
                for (Annotation annotation : method.getParameterAnnotations()[0]) {
                    if (EJB.class.isAssignableFrom(annotation.annotationType())) {
                        parameterAnnotation = (EJB) annotation;
                    }
                }

                // Default values of the annotation attributes.
                String mappedName = null;
                String beanName = null;
                String lookup = null;

                if (parameterAnnotation != null) {
                    mappedName = parameterAnnotation.mappedName();
                    beanName = parameterAnnotation.beanName();
                    lookup = attemptToGet31LookupField(parameterAnnotation);
                }

                String[] jndiNames = resolveJNDINames(method.getParameterTypes()[0], mappedName, beanName, lookup);
                Object ejb = lookupEJB(jndiNames);
                method.invoke(testCase, ejb);
            }
        } catch (Exception e) {
            throw new RuntimeException("Could not inject members", e);
        }
    }

    protected String attemptToGet31LookupField(EJB annotation) throws IllegalAccessException,
        InvocationTargetException {
        String lookup = null;
        try {
            Method m = EJB.class.getMethod("lookup");
            lookup = String.valueOf(m.invoke(annotation));
        } catch (NoSuchMethodException e) {
            // No op, running on < 3.1 EJB lib
        }
        return lookup;
    }

    /**
     * Resolves the JNDI name of the given field.
     * <p>
     * If <tt>mappedName</tt>, <tt>lookup</tt> or <tt>beanName</tt> are specified, they're used to resolve JNDI name.
     * Otherwise, default policy
     * applies.
     * <p>
     * If more than one of the <tt>mappedName</tt>, <tt>lookup</tt> and <tt>beanName</tt> {@link EJB} annotation
     * attributes is specified at the same time, an {@link IllegalStateException}
     * will be thrown.
     *
     * @param fieldType
     *     annotated field which JNDI name should be resolved.
     * @param mappedName
     *     Value of {@link EJB}'s <tt>mappedName</tt> attribute.
     * @param beanName
     *     Value of {@link EJB}'s <tt>beanName</tt> attribute.
     * @param lookup
     *     Value of {@link EJB}'s <tt>lookup</tt> attribute.
     *
     * @return possible JNDI names which should be looked up to access the proper object.
     */
    protected String[] resolveJNDINames(Class<?> fieldType, String mappedName, String beanName, String lookup) {

        MessageFormat msg = new MessageFormat(
            "Trying to resolve JNDI name for field \"{0}\" with mappedName=\"{1}\" and beanName=\"{2}\"");
        log.finer(msg.format(new Object[] {fieldType, mappedName, beanName}));

        Validate.notNull(fieldType, "EJB enriched field cannot to be null.");

        boolean isMappedNameSet = hasValue(mappedName);
        boolean isBeanNameSet = hasValue(beanName);
        boolean isLookupSet = hasValue(lookup);

        if (isMoreThanOneValueTrue(isMappedNameSet, isBeanNameSet, isLookupSet)) {
            throw new IllegalStateException(
                "Only one of the @EJB annotation attributes 'mappedName', 'lookup' and 'beanName' can be specified at the same time.");
        }

        String[] jndiNames;

        // If set, use only mapped name or bean name to lookup the EJB.
        if (isMappedNameSet) {
            jndiNames = new String[] {mappedName};
        } else if (isLookupSet) {
            jndiNames = new String[] {lookup};
        } else if (isBeanNameSet) {
            jndiNames = new String[] {"java:module/" + beanName + "!" + fieldType.getName()};
        } else {
            jndiNames = getJndiNamesForAnonymousEJB(fieldType);
        }
        return jndiNames;
    }

    protected String[] getJndiNamesForAnonymousEJB(Class<?> fieldType) {
        String[] jndiNames;
        // TODO: These names are not spec compliant; fieldType needs to be a bean type here, but usually is just an interface of a bean. These seldom work.
        jndiNames = new String[] {
            "java:global/test.ear/test/" + fieldType.getSimpleName() + "Bean",
            "java:global/test.ear/test/" + fieldType.getSimpleName(),
            "java:global/test/" + fieldType.getSimpleName(),
            "java:global/test/" + fieldType.getSimpleName() + "Bean",
            "java:global/test/" + fieldType.getSimpleName() + "/no-interface",
            "java:module/" + fieldType.getSimpleName(),
            "test/" + fieldType.getSimpleName() + "Bean/local",
            "test/" + fieldType.getSimpleName() + "Bean/remote",
            "test/" + fieldType.getSimpleName() + "/no-interface",
            fieldType.getSimpleName() + "Bean/local",
            fieldType.getSimpleName() + "Bean/remote",
            fieldType.getSimpleName() + "/no-interface",
            // WebSphere Application Server Local EJB default binding
            "ejblocal:" + fieldType.getCanonicalName(),
            // WebSphere Application Server Remote EJB default binding
            fieldType.getCanonicalName()};
        return jndiNames;
    }

    protected Object lookupEJB(String[] jndiNames) throws Exception {
        // TODO: figure out test context ?
        Context initcontext = createContext();

        for (String jndiName : jndiNames) {
            try {
                return initcontext.lookup(jndiName);
            } catch (NamingException e) {
                // no-op, try next
            }
        }
        throw new NamingException("No EJB found in JNDI, tried the following names: " + joinJndiNames(jndiNames));
    }

    protected Context createContext() throws Exception {
        return contextInst.get();
    }

    // Simple helper for printing the jndi names
    private String joinJndiNames(String[] strings) {
        StringBuilder sb = new StringBuilder();

        for (String string : strings) {
            sb.append(string).append(", ");
        }
        return sb.toString();
    }

    /**
     * Helper method that checks if the given String has a non-empty value.
     *
     * @param string
     *     String to be checked.
     *
     * @return true if <tt>string</tt> is not null and has non-empty value; false otherwise.
     */
    private boolean hasValue(String string) {
        if (string != null && (!string.trim().equals(""))) {
            return true;
        } else {
            return false;
        }
    }

    private boolean isMoreThanOneValueTrue(boolean... values) {
        boolean trueFound = false;
        for (boolean value : values) {
            if (value) {
                if (trueFound) {
                    return true;
                }
                trueFound = true;
            }
        }
        return false;
    }
}
