/*
 * Copyright 2008 the original author or authors.
 *
 * 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.powermock.modules.junit4.internal.impl;

import junit.framework.TestCase;
import junit.framework.TestSuite;
import org.junit.Before;
import org.junit.internal.runners.*;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.*;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunNotifier;
import org.powermock.core.classloader.annotations.MockPolicy;
import org.powermock.core.classloader.annotations.PrepareEverythingForTest;
import org.powermock.core.spi.PowerMockTestListener;
import org.powermock.modules.junit4.common.internal.PowerMockJUnitRunnerDelegate;
import org.powermock.modules.junit4.internal.impl.testcaseworkaround.PowerMockJUnit4MethodValidator;
import org.powermock.reflect.Whitebox;
import org.powermock.tests.utils.PowerMockTestNotifier;
import org.powermock.tests.utils.impl.MockPolicyInitializerImpl;
import org.powermock.tests.utils.impl.PowerMockTestNotifierImpl;
import org.powermock.tests.utils.impl.PrepareForTestExtractorImpl;
import org.powermock.tests.utils.impl.StaticConstructorSuppressExtractorImpl;

import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;

/**
 * A JUnit4 test runner that only runs a specified set of test methods in a test
 * class.
 * <p/>
 * <p/>
 * Many parts of this class are essentially a rip off from
 * {@link JUnit4ClassRunner} used in JUnit 4.4. It does however not extend this
 * class because we cannot let it perform the stuff it does in its constructor.
 * Another thing that different is that if an exception is thrown in the test we
 * add a tip to error message asking the user if they've not forgot to add a
 * class to test. Yet another difference is that this runner notifies the
 * PowerMock listeners of certain events.
 *
 * @see JUnit4ClassRunner
 */
@SuppressWarnings("deprecation")
public class PowerMockJUnit44RunnerDelegateImpl extends Runner implements Filterable, Sortable, PowerMockJUnitRunnerDelegate {
    private final List<Method> testMethods;
    private final TestClass testClass;
    private final PowerMockTestNotifier powerMockTestNotifier;

    public PowerMockJUnit44RunnerDelegateImpl(Class<?> klass, String[] methodsToRun, PowerMockTestListener[] listeners) throws InitializationError {
        this.powerMockTestNotifier = new PowerMockTestNotifierImpl(listeners == null ? new PowerMockTestListener[0] : listeners);
        testClass = new TestClass(klass);
        testMethods = getTestMethods(klass, methodsToRun);
        validate();
    }

    public PowerMockJUnit44RunnerDelegateImpl(Class<?> klass, String[] methodsToRun) throws InitializationError {
        this(klass, methodsToRun, null);
    }

    public PowerMockJUnit44RunnerDelegateImpl(Class<?> klass) throws InitializationError {
        this(klass, null);
    }

    @SuppressWarnings("unchecked")
    protected final List<Method> getTestMethods(Class<?> klass, String[] methodsToRun) {
        if (methodsToRun == null || methodsToRun.length == 0) {
            // The getTestMethods of TestClass is not visible so we need to look
            // it invoke it using reflection.
            try {
                return (List<Method>) Whitebox.invokeMethod(testClass, "getTestMethods");
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        } else {
            List<Method> foundMethods = new LinkedList<Method>();
            Method[] methods = klass.getMethods();
            for (Method method : methods) {
                for (String methodName : methodsToRun) {
                    if (method.getName().equals(methodName)) {
                        foundMethods.add(method);
                    }
                }
            }
            return foundMethods;
        }
    }

    protected final void validate() throws InitializationError {
        if (!TestCase.class.isAssignableFrom(testClass.getJavaClass())) {
            MethodValidator methodValidator = new PowerMockJUnit4MethodValidator(testClass);
            methodValidator.validateMethodsForDefaultRunner();
            methodValidator.assertValid();
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void run(final RunNotifier notifier) {
        new ClassRoadie(notifier, testClass, getDescription(), new Runnable() {
            @Override
            public void run() {
                runMethods(notifier);
            }
        }).runProtected();
    }

    protected void runMethods(final RunNotifier notifier) {
        final StaticConstructorSuppressExtractorImpl staticConstructorSuppressExtractorImpl = new StaticConstructorSuppressExtractorImpl();
        Class<?> testType = getTestClass();
        final ClassLoader thisClassLoader = getClass().getClassLoader();
        if (!thisClassLoader.equals(testType.getClassLoader())) {
            /*
             * The test is loaded from another classloader, this means that we
             * cannot get the correct annotations if we don't load the class
             * from the correct class loader
             */
            try {
                testType = thisClassLoader.loadClass(testType.getName());
            } catch (ClassNotFoundException e) {
                // This should never happen
                throw new RuntimeException("Internal error in PowerMock", e);
            }
        }
        for (Method method : testMethods) {
            if (staticConstructorSuppressExtractorImpl.getTestClasses(method) == null) {
                staticConstructorSuppressExtractorImpl.getTestClasses(testType);
            }
            invokeTestMethod(method, notifier);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Description getDescription() {
        Description spec = Description.createSuiteDescription(getName(), classAnnotations());
        List<Method> testMethods = this.testMethods;
        for (Method method : testMethods)
            spec.addChild(methodDescription(method));
        return spec;
    }

    protected Annotation[] classAnnotations() {
        return getTestClass().getAnnotations();
    }

    protected String getName() {
        return getTestWrappedClass().getName();
    }

    protected Object createTest() throws Exception {
        return createTestInstance();
    }

    private Object createTestInstance() throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
        final TestClass testWrappedClass = getTestWrappedClass();
        Constructor<?> constructor = null;
        final Class<?> javaClass = testWrappedClass.getJavaClass();
        if (TestCase.class.isAssignableFrom(javaClass)) {
            constructor = TestSuite.getTestConstructor(javaClass.asSubclass(TestCase.class));
            if (constructor.getParameterTypes().length == 1) {
                return constructor.newInstance(javaClass.getSimpleName());
            }
        } else {
            constructor = testWrappedClass.getConstructor();
        }
        return constructor.newInstance();
    }

    protected void invokeTestMethod(final Method method, RunNotifier notifier) {
        Description description = methodDescription(method);
        final Object testInstance;
        try {
            testInstance = createTest();
        } catch (InvocationTargetException e) {
            testAborted(notifier, description, e.getTargetException());
            return;
        } catch (Exception e) {
            testAborted(notifier, description, e);
            return;
        }

        // Check if we extend from TestClass, in that case we must run the setUp
        // and tearDown methods.
        final boolean extendsFromTestCase = TestCase.class.isAssignableFrom(testClass.getJavaClass()) ? true : false;

        final TestMethod testMethod = wrapMethod(method);
        createPowerMockRunner(testInstance, testMethod, notifier, description, extendsFromTestCase).run();
    }

    protected PowerMockJUnit44MethodRunner createPowerMockRunner(final Object testInstance, final TestMethod testMethod, RunNotifier notifier,
                                                                 Description description, final boolean extendsFromTestCase) {
        return new PowerMockJUnit44MethodRunner(testInstance, testMethod, notifier, description, extendsFromTestCase);
    }

    private void testAborted(RunNotifier notifier, Description description, Throwable e) {
        notifier.fireTestStarted(description);
        notifier.fireTestFailure(new Failure(description, e));
        notifier.fireTestFinished(description);
    }

    protected TestMethod wrapMethod(Method method) {
        return new TestMethod(method, testClass);
    }

    protected String testName(Method method) {
        return method.getName();
    }

    protected Description methodDescription(Method method) {
        return Description.createTestDescription(getTestWrappedClass().getJavaClass(), testName(method), testAnnotations(method));
    }

    protected Annotation[] testAnnotations(Method method) {
        return method.getAnnotations();
    }

    @Override
    public void filter(Filter filter) throws NoTestsRemainException {
        for (Iterator<Method> iter = testMethods.iterator(); iter.hasNext(); ) {
            Method method = iter.next();
            if (!filter.shouldRun(methodDescription(method)))
                iter.remove();
        }
        if (testMethods.isEmpty())
            throw new NoTestsRemainException();
    }

    @Override
    public void sort(final Sorter sorter) {
        Collections.sort(testMethods, new Comparator<Method>() {
            @Override
            public int compare(Method o1, Method o2) {
                return sorter.compare(methodDescription(o1), methodDescription(o2));
            }
        });
    }

    protected TestClass getTestWrappedClass() {
        return testClass;
    }

    @Override
    public int getTestCount() {
        return testMethods.size();
    }

    @Override
    public Class<?> getTestClass() {
        return testClass.getJavaClass();
    }

    protected class PowerMockJUnit44MethodRunner extends MethodRoadie {
        private final Object testInstance;
        private final boolean extendsFromTestCase;
        protected final TestMethod testMethod;

        protected PowerMockJUnit44MethodRunner(Object testInstance, TestMethod method, RunNotifier notifier, Description description,
                                               boolean extendsFromTestCase) {
            super(testInstance, method, notifier, description);
            this.testInstance = testInstance;
            this.extendsFromTestCase = extendsFromTestCase;
            this.testMethod = method;
        }

        @Override
        public void runBeforesThenTestThenAfters(final Runnable test) {
            executeTest(Whitebox.getInternalState(testMethod, Method.class), testInstance, test);
        }

        public void executeTest(final Method method, final Object testInstance, final Runnable test) {
            // Initialize mock policies for each test
            final ClassLoader classloader = this.getClass().getClassLoader();
            final Thread currentThread = Thread.currentThread();
            final ClassLoader originalClassLoader = currentThread.getContextClassLoader();
            currentThread.setContextClassLoader(classloader);
            new MockPolicyInitializerImpl(testClass.getJavaClass()).initialize(classloader);
            powerMockTestNotifier.notifyBeforeTestMethod(testInstance, method, new Object[0]);
            try {
                super.runBeforesThenTestThenAfters(test);
            } finally {
                currentThread.setContextClassLoader(originalClassLoader);
            }
        }

        @Override
        protected void runTestMethod() {
            try {
                try {
                    if (extendsFromTestCase) {
                        final Method setUp = Whitebox.getMethod(testInstance.getClass(), "setUp");
                        if (!setUp.isAnnotationPresent(Before.class)) {
                            Whitebox.invokeMethod(testInstance, "setUp");
                        }
                    }
                    testMethod.invoke(testInstance);
                    if ((Boolean) Whitebox.invokeMethod(testMethod, "expectsException")) {
                        addFailure(new AssertionError("Expected exception: " + getExpectedExceptionName(testMethod)));
                    }
                } catch (InvocationTargetException e) {
                    handleInvocationTargetException(testMethod, e);
                } catch (Throwable e) {
                    addFailure(e);
                } finally {
                    if (extendsFromTestCase) {
                        try {
                            Whitebox.invokeMethod(testInstance, "tearDown");
                        } catch (Throwable tearingDown) {
                            addFailure(tearingDown);
                        }
                    }
                }
            } catch (Throwable e) {
                throw new RuntimeException("Internal error in PowerMock.", e);
            }
        }

        private void handleInvocationTargetException(final TestMethod testMethod, InvocationTargetException e) throws Exception {
            Throwable actual = e.getTargetException();
            while (actual instanceof InvocationTargetException) {
                actual = ((InvocationTargetException) actual).getTargetException();
            }
            handleException(testMethod, actual);
        }

        protected void handleException(final TestMethod testMethod, Throwable actualFailure) {
            try {
                final String throwableName = actualFailure.getClass().getName();
                if (throwableName.equals("org.junit.internal.AssumptionViolatedException") || throwableName.startsWith("org.junit.Assume$AssumptionViolatedException")) {
                    return;
                } else if (!(Boolean) Whitebox.invokeMethod(testMethod, "expectsException")) {
                    final String className = actualFailure.getStackTrace()[0].getClassName();
                    final Class<?> testClassAsJavaClass = testClass.getJavaClass();
                    if (actualFailure instanceof NullPointerException
                            && !testClassAsJavaClass.getName().equals(className)
                            && !className.startsWith("java.lang")
                            && !className.startsWith("org.powermock")
                            && !className.startsWith("org.junit")
                            && !new PrepareForTestExtractorImpl().isPrepared(testClassAsJavaClass, className)
                            && !testClassAsJavaClass.isAnnotationPresent(PrepareEverythingForTest.class)
                            && !new MockPolicyInitializerImpl(testClassAsJavaClass.isAnnotationPresent(MockPolicy.class) ? testClassAsJavaClass
                            .getAnnotation(MockPolicy.class).value() : null).isPrepared(className)) {
                        Whitebox.setInternalState(actualFailure, "detailMessage", "Perhaps the class " + className + " must be prepared for test?",
                                Throwable.class);
                    }
                    addFailure(actualFailure);
                } else if ((Boolean) Whitebox.invokeMethod(testMethod, "isUnexpected", actualFailure)) {
                    String message = "Unexpected exception, expected<" + getExpectedExceptionName(testMethod) + "> but was<"
                            + actualFailure.getClass().getName() + ">";
                    addFailure(new Exception(message, actualFailure));
                }

            } catch (Exception e) {
                throw new RuntimeException("PowerMock internal error: Should never throw exception at this level", e);
            }
        }

        @SuppressWarnings("unchecked")
        private String getExpectedExceptionName(TestMethod fTestMethod) throws Exception {
            return ((Class<? extends Throwable>) Whitebox.invokeMethod(fTestMethod, "getExpectedException")).getName();
        }
    }
}
