/*
 * Copyright (C) 2015 Maciej Górski
 * Copyright (C) 2012 The Android Open Source Project
 *
 * 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 android.support.test.internal.runner;

import android.app.Instrumentation;
import android.os.Bundle;
import android.support.test.filters.RequiresDevice;
import android.support.test.filters.SdkSuppress;
import android.support.test.internal.runner.ClassPathScanner.ChainedClassNameFilter;
import android.support.test.internal.runner.ClassPathScanner.ExcludePackageNameFilter;
import android.support.test.internal.runner.ClassPathScanner.ExternalClassNameFilter;
import android.support.test.internal.runner.ClassPathScanner.InclusivePackageNameFilter;
import android.support.test.internal.util.AndroidRunnerParams;
import android.test.suitebuilder.annotation.LargeTest;
import android.test.suitebuilder.annotation.MediumTest;
import android.test.suitebuilder.annotation.SmallTest;
import android.test.suitebuilder.annotation.Suppress;
import android.util.Log;

import org.junit.runner.Computer;
import org.junit.runner.Description;
import org.junit.runner.Request;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.model.InitializationError;

import java.io.IOException;
import java.io.PrintStream;
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Builds a {@link org.junit.runner.Request} from test classes in given apk paths, filtered on provided set of
 * restrictions.
 */
public class SpockTestRequestBuilder {

    private static final String LOG_TAG = "TestRequestBuilder";

    public static final String LARGE_SIZE = "large";
    public static final String MEDIUM_SIZE = "medium";
    public static final String SMALL_SIZE = "small";

    static final String EMULATOR_HARDWARE = "goldfish";

    private String[] mApkPaths;
    private TestLoader mTestLoader;
    private ClassAndMethodFilter mClassMethodFilter = new ClassAndMethodFilter();
    private Filter mFilter = new AnnotationExclusionFilter(Suppress.class)
            .intersect(new SdkSuppressFilter())
            .intersect(new RequiresDeviceFilter())
            .intersect(mClassMethodFilter);
    private boolean mSkipExecution = false;
    private String mTestPackageName = null;
    private final DeviceBuild mDeviceBuild;
    private long mPerTestTimeout = 0;

    /**
     * Accessor interface for retrieving device build properties.
     * <p/>
     * Used so unit tests can mock calls
     */
    static interface DeviceBuild {
        /**
         * Returns the SDK API level for current device.
         */
        int getSdkVersionInt();

        /**
         * Returns the hardware type of the current device.
         */
        String getHardware();
    }

    private static class DeviceBuildImpl implements DeviceBuild {
        @Override
        public int getSdkVersionInt() {
            return android.os.Build.VERSION.SDK_INT;
        }

        @Override
        public String getHardware() {
            return android.os.Build.HARDWARE;
        }
    }

    /**
     * Helper parent class for {@link org.junit.runner.manipulation.Filter} that allows suites to run if any child matches.
     */
    private abstract static class ParentFilter extends Filter {
        /**
         * {@inheritDoc}
         */
        @Override
        public boolean shouldRun(Description description) {
            if (description.isTest()) {
                return evaluateTest(description);
            }
            // this is a suite, explicitly check if any children should run
            for (Description each : description.getChildren()) {
                if (shouldRun(each)) {
                    return true;
                }
            }
            // no children to run, filter this out
            return false;
        }

        /**
         * Determine if given test description matches filter.
         *
         * @param description the {@link org.junit.runner.Description} describing the test
         * @return <code>true</code> if matched
         */
        protected abstract boolean evaluateTest(Description description);
    }

    /**
     * Filter that only runs tests whose method or class has been annotated with given filter.
     */
    private static class AnnotationInclusionFilter extends ParentFilter {

        private final Class<? extends Annotation> mAnnotationClass;

        AnnotationInclusionFilter(Class<? extends Annotation> annotation) {
            mAnnotationClass = annotation;
        }

        /**
         * Determine if given test description matches filter.
         *
         * @param description the {@link org.junit.runner.Description} describing the test
         * @return <code>true</code> if matched
         */
        @Override
        protected boolean evaluateTest(Description description) {
            final Class<?> testClass = description.getTestClass();
            return description.getAnnotation(mAnnotationClass) != null ||
                    (testClass != null && testClass.isAnnotationPresent(mAnnotationClass));
        }

        protected Class<? extends Annotation> getAnnotationClass() {
            return mAnnotationClass;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String describe() {
            return String.format("annotation %s", mAnnotationClass.getName());
        }
    }

    /**
     * A filter for test sizes.
     * <p/>
     * Will match if test method has given size annotation, or class does, but only if method does
     * not have any other size annotations. ie method size annotation overrides class size
     * annotation.
     */
    private static class SizeFilter extends AnnotationInclusionFilter {
        @SuppressWarnings("unchecked")
        private static final Set<Class<?>> ALL_SIZES = Collections.unmodifiableSet(new
                HashSet<Class<?>>(Arrays.asList(SmallTest.class, MediumTest.class,
                        LargeTest.class)));

        SizeFilter(Class<? extends Annotation> annotation) {
            super(annotation);
        }

        @Override
        protected boolean evaluateTest(Description description) {
            final Class<?> testClass = description.getTestClass();
            if (description.getAnnotation(getAnnotationClass()) != null) {
                return true;
            } else if (testClass != null && testClass.isAnnotationPresent(getAnnotationClass())) {
                // size annotation matched at class level. Make sure method doesn't have any other
                // size annotations
                for (Annotation a : description.getAnnotations()) {
                    if (ALL_SIZES.contains(a.annotationType())) {
                        return false;
                    }
                }
                return true;
            }
            return false;
        }
    }

    /**
     * Filter out tests whose method or class has been annotated with given filter.
     */
    private static class AnnotationExclusionFilter extends ParentFilter {

        private final Class<? extends Annotation> mAnnotationClass;

        AnnotationExclusionFilter(Class<? extends Annotation> annotation) {
            mAnnotationClass = annotation;
        }

        @Override
        protected boolean evaluateTest(Description description) {
            final Class<?> testClass = description.getTestClass();
            if ((testClass != null && testClass.isAnnotationPresent(mAnnotationClass))
                    || (description.getAnnotation(mAnnotationClass) != null)) {
                return false;
            }
            return true;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String describe() {
            return String.format("not annotation %s", mAnnotationClass.getName());
        }
    }

    private class SdkSuppressFilter extends ParentFilter {

        @Override
        protected boolean evaluateTest(Description description) {
            final SdkSuppress s = getAnnotationForTest(description);
            if (s != null && getDeviceSdkInt() < s.minSdkVersion()) {
                return false;
            }
            return true;
        }

        private SdkSuppress getAnnotationForTest(Description description) {
            final SdkSuppress s = description.getAnnotation(SdkSuppress.class);
            if (s != null) {
                return s;
            }
            final Class<?> testClass = description.getTestClass();
            if (testClass != null) {
                return testClass.getAnnotation(SdkSuppress.class);
            }
            return null;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String describe() {
            return String.format("skip tests annotated with SdkSuppress if necessary");
        }
    }

    /**
     * Class that filters out tests annotated with {@link android.support.test.filters.RequiresDevice} when running on emulator
     */
    private class RequiresDeviceFilter extends AnnotationExclusionFilter {

        RequiresDeviceFilter() {
            super(RequiresDevice.class);
        }

        @Override
        protected boolean evaluateTest(Description description) {
            if (!super.evaluateTest(description)) {
                // annotation is present - check if device is an emulator
                return !EMULATOR_HARDWARE.equals(getDeviceHardware());
            }
            return true;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String describe() {
            return String.format("skip tests annotated with RequiresDevice if necessary");
        }
    }

    private static class ShardingFilter extends Filter {
        private final int mNumShards;
        private final int mShardIndex;

        ShardingFilter(int numShards, int shardIndex) {
            mNumShards = numShards;
            mShardIndex = shardIndex;
        }

        @Override
        public boolean shouldRun(Description description) {
            if (description.isTest()) {
                return (Math.abs(description.hashCode()) % mNumShards) == mShardIndex;
            }
            // this is a suite, explicitly check if any children should run
            for (Description each : description.getChildren()) {
                if (shouldRun(each)) {
                    return true;
                }
            }
            // no children to run, filter this out
            return false;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public String describe() {
            return String.format("Shard %s of %s shards", mShardIndex, mNumShards);
        }
    }

    /**
     * A {@link org.junit.runner.Request} that doesn't report an error if all tests are filtered out. Done for
     * consistency with InstrumentationTestRunner.
     */
    private static class LenientFilterRequest extends Request {
        private final Request mRequest;
        private final Filter mFilter;

        public LenientFilterRequest(Request classRequest, Filter filter) {
            mRequest = classRequest;
            mFilter = filter;
        }

        @Override
        public Runner getRunner() {
            try {
                Runner runner = mRequest.getRunner();
                mFilter.apply(runner);
                return runner;
            } catch (NoTestsRemainException e) {
                // don't treat filtering out all tests as an error
                return new BlankRunner();
            }
        }
    }

    /**
     * A {@link org.junit.runner.Runner} that doesn't do anything
     */
    private static class BlankRunner extends Runner {
        @Override
        public Description getDescription() {
            return Description.createSuiteDescription("no tests found");
        }

        @Override
        public void run(RunNotifier notifier) {
            // do nothing
        }
    }

    public SpockTestRequestBuilder(PrintStream writer, String... apkPaths) {
        this(new DeviceBuildImpl(), writer, apkPaths);
    }

    SpockTestRequestBuilder(DeviceBuild deviceBuildAccessor, PrintStream writer, String... apkPaths) {
        mDeviceBuild = deviceBuildAccessor;
        mApkPaths = apkPaths;
        mTestLoader = new TestLoader(writer);
    }

    /**
     * Add a test class to be executed. All test methods in this class will be executed.
     *
     * @param className
     */
    public void addTestClass(String className) {
        mTestLoader.loadClass(className);
    }

    /**
     * Adds a test method to run.
     * <p/>
     * Currently only supports one test method to be run.
     */
    public void addTestMethod(String testClassName, String testMethodName) {
        Class<?> clazz = mTestLoader.loadClass(testClassName);
        if (clazz != null) {
            mClassMethodFilter.add(testClassName, testMethodName);
        }
    }

    /**
     * A {@link org.junit.runner.manipulation.Filter} to support the ability to filter out multiple classes#methodes combinations.
     */
    private static class ClassAndMethodFilter extends Filter {

        private Map<String, MethodFilter> mClassMethodFilterMap
                = new HashMap<String, MethodFilter>();

        @Override
        public boolean shouldRun(Description description) {
            if (mClassMethodFilterMap.isEmpty()) {
                return true;
            }
            if (description.isTest()) {
                MethodFilter mf = mClassMethodFilterMap.get(description.getClassName());
                if (mf != null) {
                    return mf.shouldRun(description);
                }
            } else {
                // Check all children, if any
                for (Description child : description.getChildren()) {
                    if (shouldRun(child)) {
                        return true;
                    }
                }
            }
            return false;
        }

        @Override
        public String describe() {
            return "Class and method filter";
        }

        public void add(String className, String methodName) {
            MethodFilter mf = mClassMethodFilterMap.get(className);
            if (mf == null) {
                mf = new MethodFilter(className);
                mClassMethodFilterMap.put(className, mf);
            }
            mf.add(methodName);
        }
    }

    /**
     * A {@link org.junit.runner.manipulation.Filter} used to filter out desired test methods from a given class
     */
    private static class MethodFilter extends Filter {

        private final String mClassName;
        private Set<String> mMethodNames = new HashSet<String>();

        /**
         * Constructs a method filter for a given class
         * @param className  name of the class the method belongs to
         */
        public MethodFilter(String className) {
            mClassName = className;
        }

        @Override
        public String describe() {
            return "Method filter for " + mClassName + " class";
        }

        @Override
        public boolean shouldRun(Description description) {
            if (description.isTest()) {
                String methodName = description.getMethodName();
                // Parameterized tests append "[#]" at the end of the method names.
                // For instance, "getFoo" would become "getFoo[0]".
                methodName = stripParameterizedSuffix(methodName);
                return mMethodNames.contains(methodName);
            }
            // At this point, this could only be a description of this filter
            return true;

        }

        // Strips out the parameterized suffix if it exists
        private String stripParameterizedSuffix(String name) {
            Pattern suffixPattern = Pattern.compile(".+(\\[[0-9]+\\])$");
            if (suffixPattern.matcher(name).matches()) {
                name = name.substring(0, name.lastIndexOf('['));
            }
            return name;
        }

        public void add(String methodName) {
            mMethodNames.add(methodName);
        }
    }

    /**
     * Run only tests within given java package
     * @param testPackage
     */
    public void addTestPackageFilter(String testPackage) {
        mTestPackageName = testPackage;
    }

    /**
     * Run only tests with given size
     * @param testSize
     */
    public void addTestSizeFilter(String testSize) {
        if (SMALL_SIZE.equals(testSize)) {
            mFilter = mFilter.intersect(new SizeFilter(SmallTest.class));
        } else if (MEDIUM_SIZE.equals(testSize)) {
            mFilter = mFilter.intersect(new SizeFilter(MediumTest.class));
        } else if (LARGE_SIZE.equals(testSize)) {
            mFilter = mFilter.intersect(new SizeFilter(LargeTest.class));
        } else {
            Log.e(LOG_TAG, String.format("Unrecognized test size '%s'", testSize));
        }
    }

    /**
     * Only run tests annotated with given annotation class.
     *
     * @param annotation the full class name of annotation
     */
    public void addAnnotationInclusionFilter(String annotation) {
        Class<? extends Annotation> annotationClass = loadAnnotationClass(annotation);
        if (annotationClass != null) {
            mFilter = mFilter.intersect(new AnnotationInclusionFilter(annotationClass));
        }
    }

    /**
     * Skip tests annotated with given annotation class.
     *
     * @param notAnnotation the full class name of annotation
     */
    public void addAnnotationExclusionFilter(String notAnnotation) {
        Class<? extends Annotation> annotationClass = loadAnnotationClass(notAnnotation);
        if (annotationClass != null) {
            mFilter = mFilter.intersect(new AnnotationExclusionFilter(annotationClass));
        }
    }

    public void addShardingFilter(int numShards, int shardIndex) {
        mFilter = mFilter.intersect(new ShardingFilter(numShards, shardIndex));
    }

    /**
     * Build a request that will generate test started and test ended events, but will skip actual
     * test execution.
     */
    public void setSkipExecution(boolean b) {
        mSkipExecution = b;
    }

    /**
     * Sets milliseconds timeout value applied to each test where 0 means no timeout
     */
    public void setPerTestTimeout(long millis) {
        mPerTestTimeout = millis;
    }

    /**
     * Builds the {@link android.support.test.internal.runner.TestRequest} based on current contents of added classes and methods.
     * <p/>
     * If no classes have been explicitly added, will scan the classpath for all tests.
     */
    public TestRequest build(Instrumentation instr, Bundle bundle) {
        if (mTestLoader.isEmpty()) {
            // no class restrictions have been specified. Load all classes
            loadClassesFromClassPath();
        }

        Request request = classes(
                new AndroidRunnerParams(instr, bundle, mSkipExecution, mPerTestTimeout),
                new Computer(),
                mTestLoader.getLoadedClasses().toArray(new Class[0]));
        return new TestRequest(mTestLoader.getLoadFailures(),
                new LenientFilterRequest(request, mFilter));
    }

    /**
     * Create a <code>Request</code> that, when processed, will run all the tests
     * in a set of classes.
     *
     * @param runnerParams {@link android.support.test.internal.util.AndroidRunnerParams} that stores common runner parameters
     * @param computer Helps construct Runners from classes
     * @param classes the classes containing the tests
     * @return a <code>Request</code> that will cause all tests in the classes to be run
     */
    private static Request classes(AndroidRunnerParams runnerParams, Computer computer,
                                   Class<?>... classes) {
        try {
            Runner suite = computer.getSuite(new AndroidRunnerBuilder(runnerParams), classes);
            return Request.runner(suite);
        } catch (InitializationError e) {
            throw new RuntimeException(
                    "Suite constructor, called as above, should always complete");
        }
    }

    private void loadClassesFromClassPath() {
        Collection<String> classNames = getClassNamesFromClassPath();
        for (String className : classNames) {
            mTestLoader.loadIfTest(className);
        }
    }

    private Collection<String> getClassNamesFromClassPath() {
        Log.i(LOG_TAG, String.format("Scanning classpath to find tests in apks %s",
                Arrays.toString(mApkPaths)));
        ClassPathScanner scanner = new ClassPathScanner(mApkPaths);

        ChainedClassNameFilter filter =   new ChainedClassNameFilter();
        // exclude inner classes
        filter.add(new ExternalClassNameFilter());
        if (mTestPackageName != null) {
            // request to run only a specific java package, honor that
            filter.add(new InclusivePackageNameFilter(mTestPackageName));
        } else {
            // scan all packages, but exclude junit packages
            filter.addAll(new ExcludePackageNameFilter("junit"),
                    new ExcludePackageNameFilter("org.junit"),
                    new ExcludePackageNameFilter("org.hamcrest"),
                    new ExcludePackageNameFilter("spock"),
                    new ExcludePackageNameFilter("org.spockframework"),
                    // always skip AndroidTestSuite
                    new ExcludePackageNameFilter("android.support.test.internal.runner.junit3"));
        }

        try {
            return scanner.getClassPathEntries(filter);
        } catch (IOException e) {
            Log.e(LOG_TAG, "Failed to scan classes", e);
        }
        return Collections.emptyList();
    }

    /**
     * Factory method for {@link android.support.test.internal.runner.ClassPathScanner}.
     * <p/>
     * Exposed so unit tests can mock.
     */
    ClassPathScanner createClassPathScanner(String... apkPaths) {
        return new ClassPathScanner(apkPaths);
    }

    @SuppressWarnings("unchecked")
    private Class<? extends Annotation> loadAnnotationClass(String className) {
        try {
            Class<?> clazz = Class.forName(className);
            return (Class<? extends Annotation>)clazz;
        } catch (ClassNotFoundException e) {
            Log.e(LOG_TAG, String.format("Could not find annotation class: %s", className));
        } catch (ClassCastException e) {
            Log.e(LOG_TAG, String.format("Class %s is not an annotation", className));
        }
        return null;
    }

    private int getDeviceSdkInt() {
        return mDeviceBuild.getSdkVersionInt();
    }

    private String getDeviceHardware() {
        return mDeviceBuild.getHardware();
    }
}