////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2020 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle.checks.metrics;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import com.puppycrawl.tools.checkstyle.FileStatefulCheck;
import com.puppycrawl.tools.checkstyle.api.AbstractCheck;
import com.puppycrawl.tools.checkstyle.api.DetailAST;
import com.puppycrawl.tools.checkstyle.api.FullIdent;
import com.puppycrawl.tools.checkstyle.api.TokenTypes;
import com.puppycrawl.tools.checkstyle.utils.CheckUtil;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * Base class for coupling calculation.
 *
 */
@FileStatefulCheck
public abstract class AbstractClassCouplingCheck extends AbstractCheck {

    /** A package separator - "." */
    private static final String DOT = ".";

    /** Class names to ignore. */
    private static final Set<String> DEFAULT_EXCLUDED_CLASSES = Collections.unmodifiableSet(
        Arrays.stream(new String[] {
            // primitives
            "boolean", "byte", "char", "double", "float", "int",
            "long", "short", "void",
            // wrappers
            "Boolean", "Byte", "Character", "Double", "Float",
            "Integer", "Long", "Short", "Void",
            // java.lang.*
            "Object", "Class",
            "String", "StringBuffer", "StringBuilder",
            // Exceptions
            "ArrayIndexOutOfBoundsException", "Exception",
            "RuntimeException", "IllegalArgumentException",
            "IllegalStateException", "IndexOutOfBoundsException",
            "NullPointerException", "Throwable", "SecurityException",
            "UnsupportedOperationException",
            // java.util.*
            "List", "ArrayList", "Deque", "Queue", "LinkedList",
            "Set", "HashSet", "SortedSet", "TreeSet",
            "Map", "HashMap", "SortedMap", "TreeMap",
            "Override", "Deprecated", "SafeVarargs", "SuppressWarnings", "FunctionalInterface",
        }).collect(Collectors.toSet()));

    /** Package names to ignore. */
    private static final Set<String> DEFAULT_EXCLUDED_PACKAGES = Collections.emptySet();

    /** Specify user-configured regular expressions to ignore classes. */
    private final List<Pattern> excludeClassesRegexps = new ArrayList<>();

    /** A map of (imported class name -> class name with package) pairs. */
    private final Map<String, String> importedClassPackages = new HashMap<>();

    /** Stack of class contexts. */
    private final Deque<ClassContext> classesContexts = new ArrayDeque<>();

    /** Specify user-configured class names to ignore. */
    private Set<String> excludedClasses = DEFAULT_EXCLUDED_CLASSES;

    /**
     * Specify user-configured packages to ignore. All excluded packages
     * should end with a period, so it also appends a dot to a package name.
     */
    private Set<String> excludedPackages = DEFAULT_EXCLUDED_PACKAGES;

    /** Specify the maximum threshold allowed. */
    private int max;

    /** Current file package. */
    private String packageName;

    /**
     * Creates new instance of the check.
     * @param defaultMax default value for allowed complexity.
     */
    protected AbstractClassCouplingCheck(int defaultMax) {
        max = defaultMax;
        excludeClassesRegexps.add(CommonUtil.createPattern("^$"));
    }

    /**
     * Returns message key we use for log violations.
     * @return message key we use for log violations.
     */
    protected abstract String getLogMessageId();

    @Override
    public final int[] getDefaultTokens() {
        return getRequiredTokens();
    }

    /**
     * Setter to specify the maximum threshold allowed.
     *
     * @param max allowed complexity.
     */
    public final void setMax(int max) {
        this.max = max;
    }

    /**
     * Setter to specify user-configured class names to ignore.
     * @param excludedClasses the list of classes to ignore.
     */
    public final void setExcludedClasses(String... excludedClasses) {
        this.excludedClasses =
            Collections.unmodifiableSet(Arrays.stream(excludedClasses).collect(Collectors.toSet()));
    }

    /**
     * Setter to specify user-configured regular expressions to ignore classes.
     *
     * @param from array representing regular expressions of classes to ignore.
     */
    public void setExcludeClassesRegexps(String... from) {
        excludeClassesRegexps.addAll(Arrays.stream(from.clone())
                .map(CommonUtil::createPattern)
                .collect(Collectors.toSet()));
    }

    /**
     * Setter to specify user-configured packages to ignore. All excluded packages
     * should end with a period, so it also appends a dot to a package name.
     *
     * @param excludedPackages the list of packages to ignore.
     */
    public final void setExcludedPackages(String... excludedPackages) {
        final List<String> invalidIdentifiers = Arrays.stream(excludedPackages)
            .filter(excludedPackageName -> !CommonUtil.isName(excludedPackageName))
            .collect(Collectors.toList());
        if (!invalidIdentifiers.isEmpty()) {
            throw new IllegalArgumentException(
                "the following values are not valid identifiers: "
                    + invalidIdentifiers.stream().collect(Collectors.joining(", ", "[", "]")));
        }

        this.excludedPackages = Collections.unmodifiableSet(
            Arrays.stream(excludedPackages).collect(Collectors.toSet()));
    }

    @Override
    public final void beginTree(DetailAST ast) {
        importedClassPackages.clear();
        classesContexts.clear();
        classesContexts.push(new ClassContext("", null));
        packageName = "";
    }

    @Override
    public void visitToken(DetailAST ast) {
        switch (ast.getType()) {
            case TokenTypes.PACKAGE_DEF:
                visitPackageDef(ast);
                break;
            case TokenTypes.IMPORT:
                registerImport(ast);
                break;
            case TokenTypes.CLASS_DEF:
            case TokenTypes.INTERFACE_DEF:
            case TokenTypes.ANNOTATION_DEF:
            case TokenTypes.ENUM_DEF:
                visitClassDef(ast);
                break;
            case TokenTypes.EXTENDS_CLAUSE:
            case TokenTypes.IMPLEMENTS_CLAUSE:
            case TokenTypes.TYPE:
                visitType(ast);
                break;
            case TokenTypes.LITERAL_NEW:
                visitLiteralNew(ast);
                break;
            case TokenTypes.LITERAL_THROWS:
                visitLiteralThrows(ast);
                break;
            case TokenTypes.ANNOTATION:
                visitAnnotationType(ast);
                break;
            default:
                throw new IllegalArgumentException("Unknown type: " + ast);
        }
    }

    @Override
    public void leaveToken(DetailAST ast) {
        switch (ast.getType()) {
            case TokenTypes.CLASS_DEF:
            case TokenTypes.INTERFACE_DEF:
            case TokenTypes.ANNOTATION_DEF:
            case TokenTypes.ENUM_DEF:
                leaveClassDef();
                break;
            default:
                // Do nothing
        }
    }

    /**
     * Stores package of current class we check.
     * @param pkg package definition.
     */
    private void visitPackageDef(DetailAST pkg) {
        final FullIdent ident = FullIdent.createFullIdent(pkg.getLastChild().getPreviousSibling());
        packageName = ident.getText();
    }

    /**
     * Creates new context for a given class.
     * @param classDef class definition node.
     */
    private void visitClassDef(DetailAST classDef) {
        final String className = classDef.findFirstToken(TokenTypes.IDENT).getText();
        createNewClassContext(className, classDef);
    }

    /** Restores previous context. */
    private void leaveClassDef() {
        checkCurrentClassAndRestorePrevious();
    }

    /**
     * Registers given import. This allows us to track imported classes.
     * @param imp import definition.
     */
    private void registerImport(DetailAST imp) {
        final FullIdent ident = FullIdent.createFullIdent(
            imp.getLastChild().getPreviousSibling());
        final String fullName = ident.getText();
        final int lastDot = fullName.lastIndexOf(DOT);
        importedClassPackages.put(fullName.substring(lastDot + 1), fullName);
    }

    /**
     * Creates new inner class context with given name and location.
     * @param className The class name.
     * @param ast The class ast.
     */
    private void createNewClassContext(String className, DetailAST ast) {
        classesContexts.push(new ClassContext(className, ast));
    }

    /** Restores previous context. */
    private void checkCurrentClassAndRestorePrevious() {
        classesContexts.pop().checkCoupling();
    }

    /**
     * Visits type token for the current class context.
     * @param ast TYPE token.
     */
    private void visitType(DetailAST ast) {
        classesContexts.peek().visitType(ast);
    }

    /**
     * Visits NEW token for the current class context.
     * @param ast NEW token.
     */
    private void visitLiteralNew(DetailAST ast) {
        classesContexts.peek().visitLiteralNew(ast);
    }

    /**
     * Visits THROWS token for the current class context.
     * @param ast THROWS token.
     */
    private void visitLiteralThrows(DetailAST ast) {
        classesContexts.peek().visitLiteralThrows(ast);
    }

    /**
     * Visit ANNOTATION literal and get its type to referenced classes of context.
     * @param annotationAST Annotation ast.
     */
    private void visitAnnotationType(DetailAST annotationAST) {
        final DetailAST children = annotationAST.getFirstChild();
        final DetailAST type = children.getNextSibling();
        classesContexts.peek().addReferencedClassName(type.getText());
    }

    /**
     * Encapsulates information about class coupling.
     *
     */
    private class ClassContext {

        /**
         * Set of referenced classes.
         * Sorted by name for predictable violation messages in unit tests.
         */
        private final Set<String> referencedClassNames = new TreeSet<>();
        /** Own class name. */
        private final String className;
        /* Location of own class. (Used to log violations) */
        /** AST of class definition. */
        private final DetailAST classAst;

        /**
         * Create new context associated with given class.
         * @param className name of the given class.
         * @param ast ast of class definition.
         */
        /* package */ ClassContext(String className, DetailAST ast) {
            this.className = className;
            classAst = ast;
        }

        /**
         * Visits throws clause and collects all exceptions we throw.
         * @param literalThrows throws to process.
         */
        public void visitLiteralThrows(DetailAST literalThrows) {
            for (DetailAST childAST = literalThrows.getFirstChild();
                 childAST != null;
                 childAST = childAST.getNextSibling()) {
                if (childAST.getType() != TokenTypes.COMMA) {
                    addReferencedClassName(childAST);
                }
            }
        }

        /**
         * Visits type.
         * @param ast type to process.
         */
        public void visitType(DetailAST ast) {
            final String fullTypeName = CheckUtil.createFullType(ast).getText();
            addReferencedClassName(fullTypeName);
        }

        /**
         * Visits NEW.
         * @param ast NEW to process.
         */
        public void visitLiteralNew(DetailAST ast) {
            addReferencedClassName(ast.getFirstChild());
        }

        /**
         * Adds new referenced class.
         * @param ast a node which represents referenced class.
         */
        private void addReferencedClassName(DetailAST ast) {
            final String fullIdentName = FullIdent.createFullIdent(ast).getText();
            addReferencedClassName(fullIdentName);
        }

        /**
         * Adds new referenced class.
         * @param referencedClassName class name of the referenced class.
         */
        private void addReferencedClassName(String referencedClassName) {
            if (isSignificant(referencedClassName)) {
                referencedClassNames.add(referencedClassName);
            }
        }

        /** Checks if coupling less than allowed or not. */
        public void checkCoupling() {
            referencedClassNames.remove(className);
            referencedClassNames.remove(packageName + DOT + className);

            if (referencedClassNames.size() > max) {
                log(classAst, getLogMessageId(),
                        referencedClassNames.size(), max,
                        referencedClassNames.toString());
            }
        }

        /**
         * Checks if given class shouldn't be ignored and not from java.lang.
         * @param candidateClassName class to check.
         * @return true if we should count this class.
         */
        private boolean isSignificant(String candidateClassName) {
            return !excludedClasses.contains(candidateClassName)
                && !isFromExcludedPackage(candidateClassName)
                && !isExcludedClassRegexp(candidateClassName);
        }

        /**
         * Checks if given class should be ignored as it belongs to excluded package.
         * @param candidateClassName class to check
         * @return true if we should not count this class.
         */
        private boolean isFromExcludedPackage(String candidateClassName) {
            String classNameWithPackage = candidateClassName;
            if (!candidateClassName.contains(DOT)) {
                classNameWithPackage = getClassNameWithPackage(candidateClassName)
                    .orElse("");
            }
            boolean isFromExcludedPackage = false;
            if (classNameWithPackage.contains(DOT)) {
                final int lastDotIndex = classNameWithPackage.lastIndexOf(DOT);
                final String candidatePackageName =
                    classNameWithPackage.substring(0, lastDotIndex);
                isFromExcludedPackage = candidatePackageName.startsWith("java.lang")
                    || excludedPackages.contains(candidatePackageName);
            }
            return isFromExcludedPackage;
        }

        /**
         * Retrieves class name with packages. Uses previously registered imports to
         * get the full class name.
         * @param examineClassName Class name to be retrieved.
         * @return Class name with package name, if found, {@link Optional#empty()} otherwise.
         */
        private Optional<String> getClassNameWithPackage(String examineClassName) {
            return Optional.ofNullable(importedClassPackages.get(examineClassName));
        }

        /**
         * Checks if given class should be ignored as it belongs to excluded class regexp.
         * @param candidateClassName class to check.
         * @return true if we should not count this class.
         */
        private boolean isExcludedClassRegexp(String candidateClassName) {
            boolean result = false;
            for (Pattern pattern : excludeClassesRegexps) {
                if (pattern.matcher(candidateClassName).matches()) {
                    result = true;
                    break;
                }
            }
            return result;
        }

    }

}
