/*
 * 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.cassandra.anttasks;

import java.io.File;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import org.apache.commons.lang3.function.Consumers;
import org.junit.Test;

import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;

import static java.util.stream.Collectors.toList;

public class TestNameCheckTask
{
    private String scanClassPath = "build/test/classes";
    private String packageName = "org.apache.cassandra";
    private String annotationName = Test.class.getName();
    private boolean expand = true;
    private boolean normalize = true;
    private boolean verbose = false;
    private String regex = ".*Test$";

    public TestNameCheckTask()
    {
    }

    public void setScanClassPath(String scanClassPath)
    {
        this.scanClassPath = scanClassPath;
    }

    public void setPackageName(String packageName)
    {
        this.packageName = packageName;
    }

    public void setAnnotationName(String annotationName)
    {
        this.annotationName = annotationName;
    }

    public void setExpand(boolean expand)
    {
        this.expand = expand;
    }

    public void setNormalize(boolean normalize)
    {
        this.normalize = normalize;
    }

    public void setVerbose(boolean verbose)
    {
        this.verbose = verbose;
    }

    public void setRegex(String regex)
    {
        this.regex = regex;
    }

    public void execute()
    {
        List<URL> scanClassPathUrls = Arrays.stream(scanClassPath.split(File.pathSeparator)).map(Paths::get).map(path -> {
            try
            {
                return path.toUri().toURL();
            }
            catch (MalformedURLException e)
            {
                throw new RuntimeException(e);
            }
        }).collect(toList());

        Reflections reflections = new Reflections(new ConfigurationBuilder()
                                                  .forPackage(packageName)
                                                  .setScanners(Scanners.MethodsAnnotated, Scanners.SubTypes)
                                                  .setUrls(scanClassPathUrls)
                                                  .setExpandSuperTypes(true)
                                                  .setParallel(true));

        Class<? extends Annotation> annotationClass;
        try
        {
            annotationClass = (Class<? extends Annotation>) Class.forName(annotationName);
        }
        catch (ClassNotFoundException e)
        {
            throw new RuntimeException(e);
        }
        Set<Method> methodsAnnotatedWith = reflections.getMethodsAnnotatedWith(annotationClass);
        Stream<? extends Class<?>> stream = methodsAnnotatedWith.stream().map(Method::getDeclaringClass).distinct();

        if (expand)
            stream = stream.flatMap(c -> expand(c, reflections));
        if (normalize)
            stream = stream.map(this::normalize);

        Predicate<String> patternPredicate = Predicate.not(Pattern.compile(regex).asMatchPredicate());
        List<String> classes = stream.map(Class::getCanonicalName)
                                     .distinct()
                                     .sorted()
                                     .peek(verbose ? System.out::println : Consumers.nop())
                                     .filter(patternPredicate)
                                     .collect(toList());

        if (!classes.isEmpty())
            throw new RuntimeException(String.format("Detected classes that have a bad naming convention. All classes from the following locations %s which have methods annotated with %s should have names that match %s: \n%s", scanClassPath, annotationName, regex, String.join("\n", classes)));
    }

    /**
     * Get top outer class if it is an inner class
     */
    private Class<?> normalize(Class<?> klass)
    {
        while (klass.getEnclosingClass() != null)
            klass = klass.getEnclosingClass();

        return klass;
    }

    /**
     * Expand a class to all its subtypes. We need this because it possible that there is a top level class with
     * annotated test methods and there are subclasses which modifies some configuration but do not introduce
     * any additional test methods. In such case, those subclasses would not be included in the result of
     * {@link Reflections#getMethodsAnnotatedWith(Class)}.
     */
    private Stream<? extends Class<?>> expand(Class<?> klass, Reflections reflections)
    {
        Set<Class<?>> concreteTypes = new HashSet<>();
        Deque<Class<?>> typeStack = new ArrayDeque<>();
        typeStack.push(klass);

        while (!typeStack.isEmpty())
        {
            Class<?> type = typeStack.pop();

            if (!Modifier.isAbstract(type.getModifiers()))
                concreteTypes.add(type);

            reflections.getSubTypesOf(type).forEach(typeStack::push);
        }

        return concreteTypes.stream();
    }

    public static void main(String[] args)
    {
        TestNameCheckTask check = new TestNameCheckTask();
        // checkstyle: suppress below 'blockSystemPropertyUsage'
        Optional.ofNullable(System.getProperty("scanClassPath")).ifPresent(check::setScanClassPath);
        Optional.ofNullable(System.getProperty("packageName")).ifPresent(check::setPackageName);
        Optional.ofNullable(System.getProperty("annotationName")).ifPresent(check::setAnnotationName);
        Optional.ofNullable(System.getProperty("regex")).ifPresent(check::setRegex);
        Optional.ofNullable(System.getProperty("expand")).map(Boolean::parseBoolean).ifPresent(check::setExpand);
        Optional.ofNullable(System.getProperty("normalize")).map(Boolean::parseBoolean).ifPresent(check::setNormalize);
        Optional.ofNullable(System.getProperty("verbose")).map(Boolean::parseBoolean).ifPresent(check::setVerbose);
        check.execute();
    }

}
