/*
 * Copyright (C) 2019 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 com.android.test.filters;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.Bundle;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;

import org.junit.runner.Description;
import org.junit.runner.manipulation.Filter;

import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;

/**
 * JUnit filter to select tests.
 *
 * <p>This filter selects tests specified by package name, class name, and method name. With this
 * filter, the package and the class options of AndroidJUnitRunner can be superseded. Also the
 * restriction that prevents using the package and the class options can be mitigated.
 *
 * <p><b>Select out tests from Java packages:</b> this option supersedes {@code -e package} option.
 * <pre>
 * adb shell am instrument -w \
 *     -e filter com.android.test.filters.SelectTest \
 *     -e selectTest package1.,package2. \
 *     com.tests.pkg/androidx.test.runner.AndroidJUnitRunner
 * </pre>
 * Note that the ending {@code .} in package name is mandatory.
 *
 * <p><b>Select out test classes:</b> this option supersedes {@code -e class} option.
 * <pre>
 * adb shell am instrument -w \
 *     -e filter com.android.test.filters.SelectTest      \
 *     -e selectTest package1.ClassA,package2.ClassB \
 *     com.tests.pkg/androidx.test.runner.AndroidJUnitRunner
 * </pre>
 *
 * <p><b>Select out test methods from Java classes:</b>
 * <pre>
 * adb shell am instrument -w \
 *     -e filter com.android.test.filters.SelectTest                      \
 *     -e selectTest package1.ClassA#methodX,package2.ClassB#methodY \
 *     com.tests.pkg/androidx.test.runner.AndroidJUnitRunner
 * </pre>
 *
 * Those options can be used simultaneously. For example
 * <pre>
 * adb shell am instrument -w \
 *     -e filter com.android.test.filters.SelectTest                        \
 *     -e selectTest package1.,package2.classA,package3.ClassB#methodZ \
 *     com.tests.pkg/androidx.test.runner.AndroidJUnitRunner
 * </pre>
 * will select out all tests in package1, all tests in classA, and ClassB#methodZ test.
 *
 * <p>Note that when this option is specified with either {@code -e package} or {@code -e class}
 * option, filtering behaves as logically conjunction. Other options, such as {@code -e notPackage},
 * {@code -e notClass}, {@code -e annotation}, and {@code -e notAnnotation}, should work as expected
 * with this SelectTest option.
 *
 * <p>When specified with {@code -e selectTest_verbose true} option, {@link SelectTest} verbosely
 * logs to logcat while parsing {@code -e selectTest} option.
 */
public class SelectTest extends Filter {

    private static final String TAG = SelectTest.class.getSimpleName();

    @VisibleForTesting
    static final String OPTION_SELECT_TEST = "selectTest";
    @VisibleForTesting
    static final String OPTION_SELECT_TEST_VERBOSE = OPTION_SELECT_TEST + "_verbose";

    private static final String ARGUMENT_ITEM_SEPARATOR = ",";
    private static final String PACKAGE_NAME_SEPARATOR = ".";
    private static final String METHOD_SEPARATOR = "#";

    @Nullable
    private final PackageSet mPackageSet;

    /**
     * Construct {@link SelectTest} filter from instrumentation arguments in {@link Bundle}.
     *
     * @param testArgs instrumentation test arguments.
     */
    public SelectTest(@NonNull Bundle testArgs) {
        mPackageSet = parseSelectTest(testArgs);
    }

    @Override
    public boolean shouldRun(Description description) {
        if (mPackageSet == null) {
            // Accept all tests because this filter is disabled.
            return true;
        }
        String testClassName = description.getClassName();
        String testMethodName = description.getMethodName();
        return mPackageSet.accept(testClassName, testMethodName);
    }

    @Override
    public String describe() {
        return OPTION_SELECT_TEST + "=" + mPackageSet;
    }

    /**
     * Create {@link #OPTION_SELECT_TEST} argument and add it to {@code testArgs}.
     *
     * <p>This method is intended to be used at constructor of extended {@link Filter} class.
     *
     * @param testArgs instrumentation test arguments.
     * @param selectTests array of class name to be selected to run.
     * @return modified instrumentation test arguments.
     */
    @NonNull
    protected static Bundle addSelectTest(
            @NonNull Bundle testArgs, @NonNull String... selectTests) {
        if (selectTests.length == 0) {
            return testArgs;
        }
        testArgs.putString(OPTION_SELECT_TEST, join(Arrays.asList(selectTests)));
        return testArgs;
    }

    /**
     * Parse {@code -e selectTest} argument.
     * @param testArgs instrumentation test arguments.
     * @return {@link PackageSet} that will filter tests. Returns {@code null} when no
     *     {@code -e selectTest} option is specified, thus this filter gets disabled.
     */
    @Nullable
    private static PackageSet parseSelectTest(Bundle testArgs) {
        final String selectTestArgs = testArgs.getString(OPTION_SELECT_TEST);
        if (selectTestArgs == null) {
            Log.w(TAG, "Disabled because no " + OPTION_SELECT_TEST + " option specified");
            return null;
        }

        final boolean verbose = new Boolean(testArgs.getString(OPTION_SELECT_TEST_VERBOSE));
        final PackageSet packageSet = new PackageSet(verbose);
        for (String selectTestArg : selectTestArgs.split(ARGUMENT_ITEM_SEPARATOR)) {
            packageSet.add(selectTestArg);
        }
        return packageSet;
    }

    private static String getPackageName(String selectTestArg) {
        int endPackagePos = selectTestArg.lastIndexOf(PACKAGE_NAME_SEPARATOR);
        return (endPackagePos < 0) ? "" : selectTestArg.substring(0, endPackagePos);
    }

    @Nullable
    private static String getClassName(String selectTestArg) {
        if (selectTestArg.endsWith(PACKAGE_NAME_SEPARATOR)) {
            return null;
        }
        int methodSepPos = selectTestArg.indexOf(METHOD_SEPARATOR);
        return (methodSepPos < 0) ? selectTestArg : selectTestArg.substring(0, methodSepPos);
    }

    @Nullable
    private static String getMethodName(String selectTestArg) {
        int methodSepPos = selectTestArg.indexOf(METHOD_SEPARATOR);
        return (methodSepPos < 0) ? null : selectTestArg.substring(methodSepPos + 1);
    }

    /** Package level filter */
    private static class PackageSet {
        private final boolean mVerbose;
        /**
         * Java package name to {@link ClassSet} map. To represent package filtering, a map value
         * can be {@code null}.
         */
        private final Map<String, ClassSet> mClassSetMap = new LinkedHashMap<>();

        PackageSet(boolean verbose) {
            mVerbose = verbose;
        }

        void add(final String selectTestArg) {
            final String packageName = getPackageName(selectTestArg);
            final String className = getClassName(selectTestArg);

            if (className == null) {
                ClassSet classSet = mClassSetMap.put(packageName, null); // package filtering.
                if (mVerbose) {
                    logging("Select package " + selectTestArg, classSet != null,
                            "; supersede " + classSet);
                }
                return;
            }

            ClassSet classSet = mClassSetMap.get(packageName);
            if (classSet == null) {
                if (mClassSetMap.containsKey(packageName)) {
                    if (mVerbose) {
                        logging("Select package " + packageName + PACKAGE_NAME_SEPARATOR, true,
                                " ignore " + selectTestArg);
                    }
                    return;
                }
                classSet = new ClassSet(mVerbose);
                mClassSetMap.put(packageName, classSet);
            }
            classSet.add(selectTestArg);
        }

        boolean accept(String className, @Nullable String methodName) {
            String packageName = getPackageName(className);
            if (!mClassSetMap.containsKey(packageName)) {
                return false;
            }
            ClassSet classSet = mClassSetMap.get(packageName);
            return classSet == null || classSet.accept(className, methodName);
        }

        @Override
        public String toString() {
            StringJoiner joiner = new StringJoiner(ARGUMENT_ITEM_SEPARATOR);
            for (String packageName : mClassSetMap.keySet()) {
                ClassSet classSet = mClassSetMap.get(packageName);
                joiner.add(classSet == null
                        ? packageName + PACKAGE_NAME_SEPARATOR : classSet.toString());
            }
            return joiner.toString();
        }
    }

    /** Class level filter */
    private static class ClassSet {
        private final boolean mVerbose;
        /**
         * Java class name to set of method names map. To represent class filtering, a map value
         * can be {@code null}.
         */
        private final Map<String, Set<String>> mMethodSetMap = new LinkedHashMap<>();

        ClassSet(boolean verbose) {
            mVerbose = verbose;
        }

        void add(String selectTestArg) {
            final String className = getClassName(selectTestArg);
            final String methodName = getMethodName(selectTestArg);

            if (methodName == null) {
                Set<String> methodSet = mMethodSetMap.put(className, null); // class filtering.
                if (mVerbose) {
                    logging("Select class " + selectTestArg, methodSet != null,
                            "; supersede " + toString(className, methodSet));
                }
                return;
            }

            Set<String> methodSet = mMethodSetMap.get(className);
            if (methodSet == null) {
                if (mMethodSetMap.containsKey(className)) {
                    if (mVerbose) {
                        logging("Select class " + className, true, "; ignore " + selectTestArg);
                    }
                    return;
                }
                methodSet = new LinkedHashSet<>();
                mMethodSetMap.put(className, methodSet);
            }

            methodSet.add(methodName);
            if (mVerbose) {
                logging("Select method " + selectTestArg, false, null);
            }
        }

        boolean accept(String className, @Nullable String methodName) {
            if (!mMethodSetMap.containsKey(className)) {
                return false;
            }
            Set<String> methodSet = mMethodSetMap.get(className);
            return methodName == null || methodSet == null || methodSet.contains(methodName);
        }

        @Override
        public String toString() {
            StringJoiner joiner = new StringJoiner(ARGUMENT_ITEM_SEPARATOR);
            for (String className : mMethodSetMap.keySet()) {
                joiner.add(toString(className, mMethodSetMap.get(className)));
            }
            return joiner.toString();
        }

        private static String toString(String className, @Nullable Set<String> methodSet) {
            if (methodSet == null) {
                return className;
            }
            StringJoiner joiner = new StringJoiner(ARGUMENT_ITEM_SEPARATOR);
            for (String methodName : methodSet) {
                joiner.add(className + METHOD_SEPARATOR + methodName);
            }
            return joiner.toString();
        }
    }

    private static void logging(String infoLog, boolean isWarning, String warningLog) {
        if (isWarning) {
            Log.w(TAG, infoLog + warningLog);
        } else {
            Log.i(TAG, infoLog);
        }
    }

    private static String join(Collection<String> list) {
        StringJoiner joiner = new StringJoiner(ARGUMENT_ITEM_SEPARATOR);
        for (String text : list) {
            joiner.add(text);
        }
        return joiner.toString();
    }
}
