/*
 * 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.maven.surefire.api.testset;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;

import static java.util.Collections.singleton;
import static java.util.Collections.unmodifiableSet;
import static org.apache.maven.surefire.api.testset.ResolvedTest.Type.CLASS;
import static org.apache.maven.surefire.api.testset.ResolvedTest.Type.METHOD;
import static org.apache.maven.surefire.shared.utils.StringUtils.isBlank;
import static org.apache.maven.surefire.shared.utils.StringUtils.isNotBlank;
import static org.apache.maven.surefire.shared.utils.StringUtils.split;
import static org.apache.maven.surefire.shared.utils.io.SelectorUtils.PATTERN_HANDLER_SUFFIX;
import static org.apache.maven.surefire.shared.utils.io.SelectorUtils.REGEX_HANDLER_PREFIX;

// TODO In Surefire 3.0 see SUREFIRE-1309 and use normal fully qualified class name regex instead.
/**
 * Resolved multi pattern filter e.g. -Dtest=MyTest#test,!AnotherTest#otherTest into an object model
 * composed of included and excluded tests.<br>
 * The methods {@link #shouldRun(String, String)} are filters easily used in JUnit filter or TestNG.
 * This class is independent of JUnit and TestNG API.<br>
 * It is accessed by Java Reflection API in {@code org.apache.maven.surefire.booter.SurefireReflector}
 * using specific ClassLoader.
 */
public class TestListResolver implements GenericTestPattern<ResolvedTest, String, String> {
    private static final String JAVA_CLASS_FILE_EXTENSION = ".class";

    private static final TestListResolver WILDCARD = new TestListResolver("*" + JAVA_CLASS_FILE_EXTENSION);

    private static final TestListResolver EMPTY = new TestListResolver("");

    private final Set<ResolvedTest> includedPatterns;

    private final Set<ResolvedTest> excludedPatterns;

    private final boolean hasIncludedMethodPatterns;

    private final boolean hasExcludedMethodPatterns;

    public TestListResolver(Collection<String> tests) {
        final IncludedExcludedPatterns patterns = new IncludedExcludedPatterns();
        final Set<ResolvedTest> includedFilters = new LinkedHashSet<>(0);
        final Set<ResolvedTest> excludedFilters = new LinkedHashSet<>(0);

        for (final String csvTests : tests) {
            if (isNotBlank(csvTests)) {
                for (String request : split(csvTests, ",")) {
                    request = request.trim();
                    if (!request.isEmpty() && !request.equals("!")) {
                        resolveTestRequest(request, patterns, includedFilters, excludedFilters);
                    }
                }
            }
        }

        this.includedPatterns = unmodifiableSet(includedFilters);
        this.excludedPatterns = unmodifiableSet(excludedFilters);
        this.hasIncludedMethodPatterns = patterns.hasIncludedMethodPatterns;
        this.hasExcludedMethodPatterns = patterns.hasExcludedMethodPatterns;
    }

    public TestListResolver(String csvTests) {
        this(csvTests == null ? Collections.<String>emptySet() : singleton(csvTests));
    }

    public TestListResolver(Collection<String> included, Collection<String> excluded) {
        this(mergeIncludedAndExcludedTests(included, excluded));
    }

    /**
     * Used only in method filter.
     */
    private TestListResolver(
            boolean hasIncludedMethodPatterns,
            boolean hasExcludedMethodPatterns,
            Set<ResolvedTest> includedPatterns,
            Set<ResolvedTest> excludedPatterns) {
        this.includedPatterns = includedPatterns;
        this.excludedPatterns = excludedPatterns;
        this.hasIncludedMethodPatterns = hasIncludedMethodPatterns;
        this.hasExcludedMethodPatterns = hasExcludedMethodPatterns;
    }

    public static TestListResolver newTestListResolver(
            Set<ResolvedTest> includedPatterns, Set<ResolvedTest> excludedPatterns) {
        return new TestListResolver(
                haveMethodPatterns(includedPatterns),
                haveMethodPatterns(excludedPatterns),
                includedPatterns,
                excludedPatterns);
    }

    @Override
    public boolean hasIncludedMethodPatterns() {
        return hasIncludedMethodPatterns;
    }

    @Override
    public boolean hasExcludedMethodPatterns() {
        return hasExcludedMethodPatterns;
    }

    @Override
    public boolean hasMethodPatterns() {
        return hasIncludedMethodPatterns() || hasExcludedMethodPatterns();
    }

    /**
     *
     * @param resolver    filter possibly having method patterns
     * @return {@code resolver} if {@link TestListResolver#hasMethodPatterns() resolver.hasMethodPatterns()}
     * returns {@code true}; Otherwise wildcard filter {@code *.class} is returned.
     */
    public static TestListResolver optionallyWildcardFilter(TestListResolver resolver) {
        return resolver.hasMethodPatterns() ? resolver : WILDCARD;
    }

    public static TestListResolver getEmptyTestListResolver() {
        return EMPTY;
    }

    public final boolean isWildcard() {
        return equals(WILDCARD);
    }

    public TestFilter<String, String> and(final TestListResolver another) {
        return new TestFilter<String, String>() {
            @Override
            public boolean shouldRun(String testClass, String methodName) {
                return TestListResolver.this.shouldRun(testClass, methodName)
                        && another.shouldRun(testClass, methodName);
            }
        };
    }

    public TestFilter<String, String> or(final TestListResolver another) {
        return new TestFilter<String, String>() {
            @Override
            public boolean shouldRun(String testClass, String methodName) {
                return TestListResolver.this.shouldRun(testClass, methodName)
                        || another.shouldRun(testClass, methodName);
            }
        };
    }

    public boolean shouldRun(Class<?> testClass, String methodName) {
        return shouldRun(toClassFileName(testClass), methodName);
    }

    /**
     * Returns {@code true} if satisfies {@code testClassFile} and {@code methodName} filter.
     *
     * @param testClassFile format must be e.g. "my/package/MyTest.class" including class extension; or null
     * @param methodName real test-method name; or null
     */
    @Override
    public boolean shouldRun(String testClassFile, String methodName) {
        if (isEmpty() || isBlank(testClassFile) && isBlank(methodName)) {
            return true;
        } else {
            boolean shouldRun = false;

            if (getIncludedPatterns().isEmpty()) {
                shouldRun = true;
            } else {
                for (ResolvedTest filter : getIncludedPatterns()) {
                    if (filter.matchAsInclusive(testClassFile, methodName)) {
                        shouldRun = true;
                        break;
                    }
                }
            }

            if (shouldRun) {
                for (ResolvedTest filter : getExcludedPatterns()) {
                    if (filter.matchAsExclusive(testClassFile, methodName)) {
                        shouldRun = false;
                        break;
                    }
                }
            }
            return shouldRun;
        }
    }

    @Override
    public boolean isEmpty() {
        return equals(EMPTY);
    }

    @Override
    public String getPluginParameterTest() {
        String aggregatedTest = aggregatedTest("", getIncludedPatterns());

        if (isNotBlank(aggregatedTest) && !getExcludedPatterns().isEmpty()) {
            aggregatedTest += ", ";
        }

        aggregatedTest += aggregatedTest("!", getExcludedPatterns());
        return aggregatedTest.isEmpty() ? "" : aggregatedTest;
    }

    @Override
    public Set<ResolvedTest> getIncludedPatterns() {
        return includedPatterns;
    }

    @Override
    public Set<ResolvedTest> getExcludedPatterns() {
        return excludedPatterns;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        TestListResolver that = (TestListResolver) o;

        return getIncludedPatterns().equals(that.getIncludedPatterns())
                && getExcludedPatterns().equals(that.getExcludedPatterns());
    }

    @Override
    public int hashCode() {
        int result = getIncludedPatterns().hashCode();
        result = 31 * result + getExcludedPatterns().hashCode();
        return result;
    }

    @Override
    public String toString() {
        return getPluginParameterTest();
    }

    public static String toClassFileName(Class<?> test) {
        return test == null ? null : toClassFileName(test.getName());
    }

    public static String toClassFileName(String fullyQualifiedTestClass) {
        return fullyQualifiedTestClass == null
                ? null
                : fullyQualifiedTestClass.replace('.', '/') + JAVA_CLASS_FILE_EXTENSION;
    }

    static String removeExclamationMark(String s) {
        return !s.isEmpty() && s.charAt(0) == '!' ? s.substring(1) : s;
    }

    private static void updatedFilters(
            boolean isExcluded,
            ResolvedTest test,
            IncludedExcludedPatterns patterns,
            Collection<ResolvedTest> includedFilters,
            Collection<ResolvedTest> excludedFilters) {
        if (isExcluded) {
            excludedFilters.add(test);
            patterns.hasExcludedMethodPatterns |= test.hasTestMethodPattern();
        } else {
            includedFilters.add(test);
            patterns.hasIncludedMethodPatterns |= test.hasTestMethodPattern();
        }
    }

    private static String aggregatedTest(String testPrefix, Set<ResolvedTest> tests) {
        StringBuilder aggregatedTest = new StringBuilder();
        for (ResolvedTest test : tests) {
            String readableTest = test.toString();
            if (!readableTest.isEmpty()) {
                if (aggregatedTest.length() != 0) {
                    aggregatedTest.append(", ");
                }
                aggregatedTest.append(testPrefix).append(readableTest);
            }
        }
        return aggregatedTest.toString();
    }

    private static Collection<String> mergeIncludedAndExcludedTests(
            Collection<String> included, Collection<String> excluded) {
        ArrayList<String> incExc = new ArrayList<>(included);
        incExc.removeAll(Collections.<String>singleton(null));
        for (String exc : excluded) {
            if (exc != null) {
                exc = exc.trim();
                if (!exc.isEmpty()) {
                    if (exc.contains("!")) {
                        throw new IllegalArgumentException("Exclamation mark not expected in 'exclusion': " + exc);
                    }
                    exc = exc.replace(",", ",!");
                    if (!exc.startsWith("!")) {
                        exc = "!" + exc;
                    }
                    incExc.add(exc);
                }
            }
        }
        return incExc;
    }

    static boolean isRegexPrefixedPattern(String pattern) {
        int indexOfRegex = pattern.indexOf(REGEX_HANDLER_PREFIX);
        int prefixLength = REGEX_HANDLER_PREFIX.length();
        if (indexOfRegex != -1) {
            if (indexOfRegex != 0
                    || !pattern.endsWith(PATTERN_HANDLER_SUFFIX)
                    || !isRegexMinLength(pattern)
                    || pattern.indexOf(REGEX_HANDLER_PREFIX, prefixLength) != -1) {
                String msg = "Illegal test|includes|excludes regex '%s'. Expected %%regex[class#method] "
                        + "or !%%regex[class#method] " + "with optional class or #method.";
                throw new IllegalArgumentException(String.format(msg, pattern));
            }
            return true;
        } else {
            return false;
        }
    }

    static boolean isRegexMinLength(String pattern) {
        // todo bug in maven-shared-utils: '+1' should not appear in the condition
        // todo cannot reuse code from SelectorUtils.java because method isRegexPrefixedPattern is in private package.
        return pattern.length() > REGEX_HANDLER_PREFIX.length() + PATTERN_HANDLER_SUFFIX.length() + 1;
    }

    static String[] unwrapRegex(String regex) {
        regex = regex.trim();
        int from = REGEX_HANDLER_PREFIX.length();
        int to = regex.length() - PATTERN_HANDLER_SUFFIX.length();
        return unwrap(regex.substring(from, to));
    }

    static String[] unwrap(final String request) {
        final String[] classAndMethod = {"", ""};
        final int indexOfHash = request.indexOf('#');
        if (indexOfHash == -1) {
            classAndMethod[0] = request.trim();
        } else {
            classAndMethod[0] = request.substring(0, indexOfHash).trim();
            classAndMethod[1] = request.substring(1 + indexOfHash).trim();
        }
        return classAndMethod;
    }

    static void nonRegexClassAndMethods(
            String clazz,
            String methods,
            boolean isExcluded,
            IncludedExcludedPatterns patterns,
            Collection<ResolvedTest> includedFilters,
            Collection<ResolvedTest> excludedFilters) {
        for (String method : split(methods, "+")) {
            method = method.trim();
            ResolvedTest test = new ResolvedTest(clazz, method, false);
            if (!test.isEmpty()) {
                updatedFilters(isExcluded, test, patterns, includedFilters, excludedFilters);
            }
        }
    }

    /**
     * Requires trimmed {@code request} been not equal to "!".
     */
    static void resolveTestRequest(
            String request,
            IncludedExcludedPatterns patterns,
            Collection<ResolvedTest> includedFilters,
            Collection<ResolvedTest> excludedFilters) {
        final boolean isExcluded = request.startsWith("!");
        ResolvedTest test = null;
        request = removeExclamationMark(request);
        if (isRegexPrefixedPattern(request)) {
            final String[] unwrapped = unwrapRegex(request);
            final boolean hasClass = !unwrapped[0].isEmpty();
            final boolean hasMethod = !unwrapped[1].isEmpty();
            if (hasClass && hasMethod) {
                test = new ResolvedTest(unwrapped[0], unwrapped[1], true);
            } else if (hasClass) {
                test = new ResolvedTest(CLASS, unwrapped[0], true);
            } else if (hasMethod) {
                test = new ResolvedTest(METHOD, unwrapped[1], true);
            }
        } else {
            final int indexOfMethodSeparator = request.indexOf('#');
            if (indexOfMethodSeparator == -1) {
                test = new ResolvedTest(CLASS, request, false);
            } else {
                String clazz = request.substring(0, indexOfMethodSeparator);
                String methods = request.substring(1 + indexOfMethodSeparator);
                nonRegexClassAndMethods(clazz, methods, isExcluded, patterns, includedFilters, excludedFilters);
            }
        }

        if (test != null && !test.isEmpty()) {
            updatedFilters(isExcluded, test, patterns, includedFilters, excludedFilters);
        }
    }

    private static boolean haveMethodPatterns(Set<ResolvedTest> patterns) {
        for (ResolvedTest pattern : patterns) {
            if (pattern.hasTestMethodPattern()) {
                return true;
            }
        }
        return false;
    }
}
