package org.apache.maven.surefire.api.testset;

/*
 * 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.
 */

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.unmodifiableSet;
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;
import static java.util.Collections.singleton;
import static org.apache.maven.surefire.api.testset.ResolvedTest.Type.CLASS;
import static org.apache.maven.surefire.api.testset.ResolvedTest.Type.METHOD;

// 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;
    }
}
