/*
 * 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.junit.context;

import org.apache.openejb.OpenEJB;
import org.apache.openejb.OpenEJBRuntimeException;
import org.apache.openejb.api.LocalClient;
import org.apache.openejb.junit.ContextConfig;
import org.apache.openejb.junit.Property;
import org.apache.openejb.junit.RunTestAs;
import org.apache.openejb.junit.TestResource;
import org.apache.openejb.junit.TestResourceTypes;
import org.apache.openejb.loader.SystemInstance;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URLDecoder;
import java.util.Properties;

/**
 * To implement your own context, you need to create an implementation of TestContext
 * which would configure the test when instructed. You can then use whatever method
 * of configuration you choose.
 */
public class OpenEjbTestContext implements TestContext {
    protected static final String REALM_PROPERTY_KEY = "openejb.authentication.realmName";

    protected static final String LOGIN_CONFIG_RESOURCE = "/META-INF/openejb-test-login.config";

    protected static final String DEFAULT_CONFIG_FILE_RESOURCE = "/META-INF/default-openejb-test-config.properties";

    /**
     * Properties object used to initialize InitialContext
     */
    protected Properties contextConfig;

    /**
     * Context's InitialContext
     */
    private InitialContext initialContext;

    /**
     * Test class
     */
    private final Class<?> clazz;

    /**
     * Method being run on test
     */
    private Method method;

    /**
     * Security role to execute as
     */
    private String securityRole;

    /**
     * Constructs a context for a class
     *
     * @param testClazz
     */
    public OpenEjbTestContext(final Class clazz) {
        this(clazz, null);
    }

    /**
     * Constructs a context for a method
     *
     * @param testClazz
     * @param method
     */
    public OpenEjbTestContext(final Method method) {
        this(method, null);
    }

    /**
     * Constructs a context for a class
     *
     * @param testClazz
     */
    public OpenEjbTestContext(final Class clazz, final String securityRole) {
        this.clazz = clazz;
        this.securityRole = securityRole;
    }

    /**
     * Constructs a context for a method
     *
     * @param testClazz
     * @param method
     */
    public OpenEjbTestContext(final Method method, final String securityRole) {
        this.clazz = method.getDeclaringClass();
        this.method = method;
        this.securityRole = securityRole;
    }

    public void configureTest(final Object testObj) {
        try {
            if (testObj.getClass().isAnnotationPresent(LocalClient.class)) {
                getInitialContext().bind("inject", testObj);
            }

            // perform custom injections
            performInjections(testObj);
        } catch (final IOException e) {
            throw new OpenEJBRuntimeException("Failed to load configuration.", e);
        } catch (final NamingException e) {
            throw new OpenEJBRuntimeException("Failed to configure object.", e);
        } catch (final Exception e) {
            throw new OpenEJBRuntimeException("Unknown error trying to configure object.", e);
        }
    }

    @Override
    public void close() {
        try {
            initialContext.close();
        } catch (final NamingException e) {
            // ignored
        }
        OpenEJB.destroy();
        SystemInstance.reset();
    }

    /**
     * Returns this context's InitialContext, creating it if necessary.
     *
     * @return InitialContext for this TestContext
     * @throws NamingException
     */
    protected InitialContext getInitialContext() throws NamingException {
        if (initialContext == null) {
            // set the property for security realm RIGHT before we load the InitialContext
            final String loginConfig = OpenEjbTestContext.class.getResource(LOGIN_CONFIG_RESOURCE).toExternalForm();
            System.setProperty("java.security.auth.login.config", URLDecoder.decode(loginConfig));

            try {
                final Properties config = getContextConfig();
                initialContext = new InitialContext(config);
            } catch (final IOException e) {
                throw new NamingException("Failed to load initial context configuration: " + e.getMessage());
            }
        }

        return initialContext;
    }

    /**
     * Constructs the configuration needed to create the InitialContext. This will
     * be determined from the class/method supplied to the constructor.
     *
     * @return
     */
    protected Properties getContextConfig() throws IOException {
        if (contextConfig != null) {
            return contextConfig;
        }

        final Properties env = new Properties();
        boolean loadedConfig = false;

        if (clazz.isAnnotationPresent(ContextConfig.class)) {
            loadedConfig |= loadConfig(env, clazz.getAnnotation(ContextConfig.class));
        }

        if (method != null && method.isAnnotationPresent(ContextConfig.class)) {
            loadedConfig |= loadConfig(env, method.getAnnotation(ContextConfig.class));
        }

        // no properties loaded, use the "default" configuration
        if (!loadedConfig) {
            final InputStream in = OpenEjbTestContext.class.getResourceAsStream(DEFAULT_CONFIG_FILE_RESOURCE);
            if (in == null) {
                throw new FileNotFoundException("Default configuration file not found. Specify configuration " +
                    "properties to initialize OpenEJB using @ContextConfig.");
            }
            env.load(in);

            // if it's still empty, something bad has happened, and OpenEJB won't initialize. Complain.
            if (env.size() == 0) {
                throw new IOException("Context configuration failed to load, so OpenEJB won't load either. Specify configuration " +
                    "properties for initializing OpenEJB using @ContextConfig.");
            }
        }

        configureSecurity(env);

        contextConfig = env;
        return env;
    }

    /**
     * Interprets and loads InitialContext properties from the ContextConfig annotation
     *
     * @param env
     * @param contextConfig
     * @return true if any properties were loaded
     */
    protected boolean loadConfig(final Properties env, final ContextConfig contextConfig) throws IOException {
        boolean loadedConfig = false;

        loadedConfig = loadConfigFile(env, contextConfig);
        loadedConfig |= loadConfigProperties(env, contextConfig);

        return loadedConfig;
    }

    /**
     * Loads the direct properties from the annotation configuration into the given Properties object
     *
     * @param env
     * @param contextConfig
     * @return true if any properties were loaded
     */
    protected boolean loadConfigProperties(final Properties env, final ContextConfig contextConfig) {
        boolean loadedConfig = false;

        if (contextConfig.properties().length > 0) {
            for (final Property p : contextConfig.properties()) {
                if (p.value() != null) {
                    loadedConfig = true;
                    Util.addProperty(env, p.value());
                }
            }
        }

        return loadedConfig;
    }

    /**
     * Loads the configuration file specified in the {@link org.apache.openejb.junit.ContextConfig} annotation
     * into the specified Properties instance
     *
     * @param env
     * @param contextConfig
     * @return true if any properties were loaded
     */
    protected boolean loadConfigFile(final Properties env, final ContextConfig contextConfig)
        throws IOException {
        // properties file
        if (contextConfig.configFile().length() > 0) {
            final InputStream in = clazz.getResourceAsStream(contextConfig.configFile());
            if (in == null) {
                throw new FileNotFoundException("Cannot find resource '" + contextConfig.configFile() + "' in classpath: " + clazz.getName());
            }
            env.load(in);

            return env.size() > 0;
        }

        return false;
    }

    /**
     * Loads the security configuration into the given Properties object
     *
     * @param env
     */
    protected void configureSecurity(final Properties env) {
        // if a securityRole isn't already configured, use the RunTestAs annotation if available
        if (securityRole == null) {
            if (method != null && method.isAnnotationPresent(RunTestAs.class)) {
                securityRole = method.getAnnotation(RunTestAs.class).value();
            } else if (clazz.isAnnotationPresent(RunTestAs.class)) {
                securityRole = clazz.getAnnotation(RunTestAs.class).value();
            }
        }

        if (securityRole != null) {
            env.put(REALM_PROPERTY_KEY, "OpenEjbJunitSecurityRealm");
            env.put(InitialContext.SECURITY_PRINCIPAL, securityRole);
            env.put(InitialContext.SECURITY_CREDENTIALS, "[no-password-needed]");
        }
    }

    /**
     * Performs any non-OpenEJB type injections on the test object. It will "prefer"
     * a setter, and therefore I made it work on private fields as well. So if you
     * are injecting to a private field and wish to have some control over it, create
     * a setter according to the JavaBeans idioms.
     * <p/>
     * If the setter isn't found OR it fails, then an attempt will be made to set
     * it directly, and a message will be printed when it fails.
     */
    private void performInjections(final Object testObj) throws Exception {
        for (final Field field : clazz.getDeclaredFields()) {
            final Object injectValue = getInjectionValue(field);

            // no value determined, try next field
            if (injectValue == null) {
                continue;
            }

            // now inject it through the setter
            try {
                final Method setterMethod = Util.findSetter(clazz, field, injectValue);
                if (setterMethod != null) {
                    setterMethod.invoke(testObj, injectValue);
                    continue;
                }
            } catch (final Exception e) {
                System.err.println("Failed to perform setter injection on: " + clazz.getCanonicalName() + "." + field.getName());
                e.printStackTrace();
            }

            // do direct injection
            try {
                if (!Modifier.isPublic(field.getModifiers())) {
                    field.setAccessible(true);
                }

                field.set(testObj, injectValue);
            } catch (final Exception e) {
                throw new OpenEJBRuntimeException("Failed to inject on: " + clazz.getCanonicalName() + "." + field.getName(), e);
            }
        }
    }

    /**
     * Analyzes the field and returns any values which should be injected on it
     *
     * @param field
     * @return reference to value to inject, or null if nothing should be injected
     */
    protected Object getInjectionValue(final Field field) throws Exception {
        // determine the value to inject
        if (field.isAnnotationPresent(TestResource.class)) {
            final TestResource resourceConfig = field.getAnnotation(TestResource.class);
            final String resourceType = resourceConfig.value();

            if (resourceType == null) {
                throw new IllegalArgumentException("Null TestResource type '" + resourceType +
                    "' on field: " + clazz.getCanonicalName() + "." + field.getName());
            } else {
                if (TestResourceTypes.CONTEXT_CONFIG.equals(resourceType)) {
                    return getContextConfig();
                } else if (TestResourceTypes.INITIALCONTEXT.equals(resourceType)) {
                    return getInitialContext();
                } else {
                    return getOtherTestResource(resourceConfig);
                }
            }
        }

        return null;
    }

    /**
     * Override to perform custom resource types injection. This method will be called
     * when whatever value was specified in the {@link TestResource} annotation wasn't
     * understood by the {@link #performInjections(java.lang.Object) } method. This
     * method will be called, supplying the annotation, and you can then interpret and
     * create the value to be injected. By default this method just returns null.
     * <p/>
     * You can use this to inject values into annotated fields which contain custom
     * values in their names.
     *
     * @param resourceConfig
     * @return instance to inject into annotated field.
     */
    protected Object getOtherTestResource(final TestResource resourceConfig) {
        return null;
    }

    /**
     * @return the test class
     */
    protected Class<?> getTestClass() {
        return clazz;
    }

    /**
     * @return the test method for which this context was created
     */
    protected Method getTestMethod() {
        return method;
    }
}
