/*
 * Copyright 2012 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.gradle.integtests.fixtures;

import org.gradle.api.Nullable;
import org.gradle.internal.UncheckedException;
import org.junit.runner.Description;
import org.junit.runner.RunWith;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.Filterable;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;

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

/**
 * A base class for those test runners which execute a test multiple times.
 */
public abstract class AbstractMultiTestRunner extends Runner implements Filterable {
    protected final Class<?> target;
    private final List<Execution> executions = new ArrayList<Execution>();
    private Description description;
    private Description templateDescription;

    protected AbstractMultiTestRunner(Class<?> target) {
        this.target = target;
    }

    @Override
    public Description getDescription() {
        initDescription();
        return description;
    }

    @Override
    public void run(RunNotifier notifier) {
        initDescription();
        for (Execution execution : executions) {
            execution.run(notifier);
        }
    }

    public void filter(Filter filter) throws NoTestsRemainException {
        initExecutions();
        for (Execution execution : executions) {
            execution.filter(filter);
        }
        invalidateDescription();
    }

    private void initExecutions() {
        if (executions.isEmpty()) {
            try {
                Runner descriptionProvider = createRunnerFor(Arrays.asList(target), Collections.<Filter>emptyList());
                templateDescription = descriptionProvider.getDescription();
            } catch (InitializationError initializationError) {
                throw UncheckedException.throwAsUncheckedException(initializationError);
            }
            createExecutions();
            for (Execution execution : executions) {
                execution.init(target, templateDescription);
            }
        }
    }

    private void initDescription() {
        initExecutions();
        if (description == null) {
            description = Description.createSuiteDescription(target);
            for (Execution execution : executions) {
                execution.addDescriptions(description);
            }
        }
    }

    private void invalidateDescription() {
        description = null;
        templateDescription = null;
    }

    protected abstract void createExecutions();

    protected void add(Execution execution) {
        executions.add(execution);
    }

    private static Runner createRunnerFor(List<? extends Class<?>> targetClasses, final List<Filter> filters) throws InitializationError {
        RunnerBuilder runnerBuilder = new RunnerBuilder() {
            @Override
            public Runner runnerForClass(Class<?> testClass) throws Throwable {
                for (Class<?> candidate = testClass; candidate != null; candidate = candidate.getSuperclass()) {
                    RunWith runWith = candidate.getAnnotation(RunWith.class);
                    if (runWith != null && !AbstractMultiTestRunner.class.isAssignableFrom(runWith.value())) {
                        try {
                            Runner r = (Runner) runWith.value().getConstructors()[0].newInstance(testClass);
                            return filter(r);
                        } catch (InvocationTargetException e) {
                            throw e.getTargetException();
                        }
                    }
                }
                return filter(new BlockJUnit4ClassRunner(testClass));
            }

            //we need to filter at the level child runners because the suite is not doing the right thing here
            private Runner filter(Runner r) {
                for (Filter filter : filters) {
                    try {
                        ((Filterable)r).filter(filter);
                    } catch (NoTestsRemainException e) {
                        //ignore
                    }
                }
                return r;
            }
        };
        return new Suite(runnerBuilder, targetClasses.toArray(new Class<?>[targetClasses.size()]));
    }

    protected static abstract class Execution implements Filterable {
        protected Class<?> target;
        private Description templateDescription;
        private final Map<Description, Description> descriptionTranslations = new HashMap<Description, Description>();
        private final Set<Description> enabledTests = new LinkedHashSet<Description>();
        private final Set<Description> disabledTests = new LinkedHashSet<Description>();
        private final List<Filter> filters = new LinkedList<Filter>();

        final void init(Class<?> target, Description templateDescription) {
            this.target = target;
            this.templateDescription = templateDescription;
        }

        private Runner createExecutionRunner() throws InitializationError {
            List<? extends Class<?>> targetClasses = loadTargetClasses();
            return createRunnerFor(targetClasses, filters);
        }

        final void addDescriptions(Description parent) {
            map(templateDescription, parent);
        }

        final void run(final RunNotifier notifier) {
            RunNotifier nested = new RunNotifier();
            NestedRunListener nestedListener = new NestedRunListener(notifier);
            nested.addListener(nestedListener);

            try {
                runEnabledTests(nested);
            } finally {
                nestedListener.cleanup();
            }

            for (Description disabledTest : disabledTests) {
                nested.fireTestStarted(disabledTest);
                nested.fireTestIgnored(disabledTest);
            }
        }

        private void runEnabledTests(RunNotifier nested) {
            if (enabledTests.isEmpty()) {
                return;
            }

            Runner runner;
            try {
                runner = createExecutionRunner();
            } catch (Throwable t) {
                runner = new CannotExecuteRunner(getDisplayName(), target, t);
            }

            try {
                if (!disabledTests.isEmpty()) {
                    ((Filterable) runner).filter(new Filter() {
                        @Override
                        public boolean shouldRun(Description description) {
                            return !disabledTests.contains(description);
                        }

                        @Override
                        public String describe() {
                            return "disabled tests";
                        }
                    });
                }
            } catch (NoTestsRemainException e) {
                return;
            }

            runner.run(nested);
        }

        private Description translateDescription(Description description) {
            return descriptionTranslations.containsKey(description) ? descriptionTranslations.get(description) : description;
        }

        public void filter(Filter filter) throws NoTestsRemainException {
            filters.add(filter);
            for (Map.Entry<Description, Description> entry : descriptionTranslations.entrySet()) {
                if (!filter.shouldRun(entry.getKey())) {
                    enabledTests.remove(entry.getValue());
                    disabledTests.remove(entry.getValue());
                }
            }
        }

        protected void before() {
        }

        protected void after() {
        }

        private void map(Description source, Description parent) {
            for (Description child : source.getChildren()) {
                Description mappedChild;
                if (child.getMethodName() != null) {
                    mappedChild = Description.createSuiteDescription(String.format("%s [%s](%s)", child.getMethodName(), getDisplayName(), child.getClassName()));
                    parent.addChild(mappedChild);
                    if (!isTestEnabled(new TestDescriptionBackedTestDetails(source, child))) {
                        disabledTests.add(child);
                    } else {
                        enabledTests.add(child);
                    }
                } else {
                    mappedChild = Description.createSuiteDescription(child.getClassName());
                }
                descriptionTranslations.put(child, mappedChild);
                map(child, parent);
            }
        }

        /**
         * Returns a display name for this execution. Used in the JUnit descriptions for test execution.
         */
        protected abstract String getDisplayName();

        /**
         * Returns true if the given test should be executed, false if it should be ignored. Default is true.
         */
        protected boolean isTestEnabled(TestDetails testDetails) {
            return true;
        }

        /**
         * Checks that this execution can be executed, throwing an exception if not.
         */
        protected void assertCanExecute() {
        }

        /**
         * Loads the target classes for this execution. Default is the target class that this runner was constructed with.
         */
        protected List<? extends Class<?>> loadTargetClasses() {
            return Collections.singletonList(target);
        }

        private static class CannotExecuteRunner extends Runner {
            private final Description description;
            private final Throwable failure;

            public CannotExecuteRunner(String displayName, Class<?> testClass, Throwable failure) {
                description = Description.createSuiteDescription(String.format("%s(%s)", displayName, testClass.getName()));
                this.failure = failure;
            }

            @Override
            public Description getDescription() {
                return description;
            }

            @Override
            public void run(RunNotifier notifier) {
                Description description = getDescription();
                notifier.fireTestStarted(description);
                notifier.fireTestFailure(new Failure(description, failure));
                notifier.fireTestFinished(description);
            }
        }

        private class NestedRunListener extends RunListener {
            private final RunNotifier notifier;
            boolean started;
            boolean complete;

            public NestedRunListener(RunNotifier notifier) {
                this.notifier = notifier;
            }

            @Override
            public void testStarted(Description description) {
                Description translated = translateDescription(description);
                notifier.fireTestStarted(translated);
                if (!started && !complete) {
                    try {
                        assertCanExecute();
                        started = true;
                        before();
                    } catch (Throwable t) {
                        notifier.fireTestFailure(new Failure(translated, t));
                    }
                }
            }

            @Override
            public void testFailure(Failure failure) {
                Description translated = translateDescription(failure.getDescription());
                notifier.fireTestFailure(new Failure(translated, failure.getException()));
            }

            @Override
            public void testAssumptionFailure(Failure failure) {
                Description translated = translateDescription(failure.getDescription());
                notifier.fireTestAssumptionFailed(new Failure(translated, failure.getException()));
            }

            @Override
            public void testIgnored(Description description) {
                Description translated = translateDescription(description);
                notifier.fireTestIgnored(translated);
            }

            @Override
            public void testFinished(Description description) {
                Description translated = translateDescription(description);
                notifier.fireTestFinished(translated);
            }

            public void cleanup() {
                if (started) {
                    after();
                }
                // Prevent further tests (ignored) from triggering start actions
                complete = true;
            }
        }
    }

    public interface TestDetails {
        /**
         * Locates the given annotation for the test. May be inherited from test class.
         */
        @Nullable
        <A extends Annotation> A getAnnotation(Class<A> type);
    }

    private static class TestDescriptionBackedTestDetails implements TestDetails {
        private final Description parent;
        private final Description test;

        private TestDescriptionBackedTestDetails(Description parent, Description test) {
            this.parent = parent;
            this.test = test;
        }

        @Override
        public String toString() {
            return test.toString();
        }

        public <A extends Annotation> A getAnnotation(Class<A> type) {
            A annotation = test.getAnnotation(type);
            if (annotation != null) {
                return annotation;
            }
            return parent.getAnnotation(type);
        }
    }
}
