///////////////////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
// Copyright (C) 2001-2023 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.coding;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

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.TokenTypes;
import com.puppycrawl.tools.checkstyle.utils.CheckUtil;
import com.puppycrawl.tools.checkstyle.utils.UnmodifiableCollectionUtil;

/**
 * <p>
 * Checks that any combination of String literals
 * is on the left side of an {@code equals()} comparison.
 * Also checks for String literals assigned to some field
 * (such as {@code someString.equals(anotherString = "text")}).
 * </p>
 * <p>Rationale: Calling the {@code equals()} method on String literals
 * will avoid a potential {@code NullPointerException}. Also, it is
 * pretty common to see null checks right before equals comparisons
 * but following this rule such checks are not required.
 * </p>
 * <ul>
 * <li>
 * Property {@code ignoreEqualsIgnoreCase} - Control whether to ignore
 * {@code String.equalsIgnoreCase(String)} invocations.
 * Type is {@code boolean}.
 * Default value is {@code false}.
 * </li>
 * </ul>
 * <p>
 * To configure the check:
 * </p>
 * <pre>
 * &lt;module name=&quot;EqualsAvoidNull&quot;/&gt;
 * </pre>
 * <p>
 * Example:
 * </p>
 * <pre>
 * String nullString = null;
 * nullString.equals("My_Sweet_String");            // violation
 * "My_Sweet_String".equals(nullString);            // OK
 * nullString.equalsIgnoreCase("My_Sweet_String");  // violation
 * "My_Sweet_String".equalsIgnoreCase(nullString);  // OK
 * </pre>
 * <p>
 * To configure the check to allow ignoreEqualsIgnoreCase:
 * </p>
 * <pre>
 * &lt;module name=&quot;EqualsAvoidNull&quot;&gt;
 *   &lt;property name=&quot;ignoreEqualsIgnoreCase&quot; value=&quot;true&quot;/&gt;
 * &lt;/module&gt;
 * </pre>
 * <p>
 * Example:
 * </p>
 * <pre>
 * String nullString = null;
 * nullString.equals("My_Sweet_String");            // violation
 * "My_Sweet_String".equals(nullString);            // OK
 * nullString.equalsIgnoreCase("My_Sweet_String");  // OK
 * "My_Sweet_String".equalsIgnoreCase(nullString);  // OK
 * </pre>
 * <p>
 * Parent is {@code com.puppycrawl.tools.checkstyle.TreeWalker}
 * </p>
 * <p>
 * Violation Message Keys:
 * </p>
 * <ul>
 * <li>
 * {@code equals.avoid.null}
 * </li>
 * <li>
 * {@code equalsIgnoreCase.avoid.null}
 * </li>
 * </ul>
 *
 * @since 5.0
 */
@FileStatefulCheck
public class EqualsAvoidNullCheck extends AbstractCheck {

    /**
     * A key is pointing to the warning message text in "messages.properties"
     * file.
     */
    public static final String MSG_EQUALS_AVOID_NULL = "equals.avoid.null";

    /**
     * A key is pointing to the warning message text in "messages.properties"
     * file.
     */
    public static final String MSG_EQUALS_IGNORE_CASE_AVOID_NULL = "equalsIgnoreCase.avoid.null";

    /** Method name for comparison. */
    private static final String EQUALS = "equals";

    /** Type name for comparison. */
    private static final String STRING = "String";

    /** Curly for comparison. */
    private static final String LEFT_CURLY = "{";

    /** Control whether to ignore {@code String.equalsIgnoreCase(String)} invocations. */
    private boolean ignoreEqualsIgnoreCase;

    /** Stack of sets of field names, one for each class of a set of nested classes. */
    private FieldFrame currentFrame;

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

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

    @Override
    public int[] getRequiredTokens() {
        return new int[] {
            TokenTypes.METHOD_CALL,
            TokenTypes.CLASS_DEF,
            TokenTypes.METHOD_DEF,
            TokenTypes.LITERAL_FOR,
            TokenTypes.LITERAL_CATCH,
            TokenTypes.LITERAL_TRY,
            TokenTypes.LITERAL_SWITCH,
            TokenTypes.VARIABLE_DEF,
            TokenTypes.PARAMETER_DEF,
            TokenTypes.CTOR_DEF,
            TokenTypes.SLIST,
            TokenTypes.OBJBLOCK,
            TokenTypes.ENUM_DEF,
            TokenTypes.ENUM_CONSTANT_DEF,
            TokenTypes.LITERAL_NEW,
            TokenTypes.LAMBDA,
            TokenTypes.PATTERN_VARIABLE_DEF,
            TokenTypes.RECORD_DEF,
            TokenTypes.COMPACT_CTOR_DEF,
            TokenTypes.RECORD_COMPONENT_DEF,
        };
    }

    /**
     * Setter to control whether to ignore {@code String.equalsIgnoreCase(String)} invocations.
     *
     * @param newValue whether to ignore checking
     *     {@code String.equalsIgnoreCase(String)}.
     */
    public void setIgnoreEqualsIgnoreCase(boolean newValue) {
        ignoreEqualsIgnoreCase = newValue;
    }

    @Override
    public void beginTree(DetailAST rootAST) {
        currentFrame = new FieldFrame(null);
    }

    @Override
    public void visitToken(final DetailAST ast) {
        switch (ast.getType()) {
            case TokenTypes.VARIABLE_DEF:
            case TokenTypes.PARAMETER_DEF:
            case TokenTypes.PATTERN_VARIABLE_DEF:
            case TokenTypes.RECORD_COMPONENT_DEF:
                currentFrame.addField(ast);
                break;
            case TokenTypes.METHOD_CALL:
                processMethodCall(ast);
                break;
            case TokenTypes.SLIST:
                processSlist(ast);
                break;
            case TokenTypes.LITERAL_NEW:
                processLiteralNew(ast);
                break;
            case TokenTypes.OBJBLOCK:
                final int parentType = ast.getParent().getType();
                if (!astTypeIsClassOrEnumOrRecordDef(parentType)) {
                    processFrame(ast);
                }
                break;
            default:
                processFrame(ast);
        }
    }

    @Override
    public void leaveToken(DetailAST ast) {
        switch (ast.getType()) {
            case TokenTypes.SLIST:
                leaveSlist(ast);
                break;
            case TokenTypes.LITERAL_NEW:
                leaveLiteralNew(ast);
                break;
            case TokenTypes.OBJBLOCK:
                final int parentType = ast.getParent().getType();
                if (!astTypeIsClassOrEnumOrRecordDef(parentType)) {
                    currentFrame = currentFrame.getParent();
                }
                break;
            case TokenTypes.VARIABLE_DEF:
            case TokenTypes.PARAMETER_DEF:
            case TokenTypes.RECORD_COMPONENT_DEF:
            case TokenTypes.METHOD_CALL:
            case TokenTypes.PATTERN_VARIABLE_DEF:
                break;
            default:
                currentFrame = currentFrame.getParent();
                break;
        }
    }

    @Override
    public void finishTree(DetailAST ast) {
        traverseFieldFrameTree(currentFrame);
    }

    /**
     * Determine whether SLIST begins a block, determined by braces, and add it as
     * a frame in this case.
     *
     * @param ast SLIST ast.
     */
    private void processSlist(DetailAST ast) {
        if (LEFT_CURLY.equals(ast.getText())) {
            final FieldFrame frame = new FieldFrame(currentFrame);
            currentFrame.addChild(frame);
            currentFrame = frame;
        }
    }

    /**
     * Determine whether SLIST begins a block, determined by braces.
     *
     * @param ast SLIST ast.
     */
    private void leaveSlist(DetailAST ast) {
        if (LEFT_CURLY.equals(ast.getText())) {
            currentFrame = currentFrame.getParent();
        }
    }

    /**
     * Process CLASS_DEF, METHOD_DEF, LITERAL_IF, LITERAL_FOR, LITERAL_WHILE, LITERAL_DO,
     * LITERAL_CATCH, LITERAL_TRY, CTOR_DEF, ENUM_DEF, ENUM_CONSTANT_DEF.
     *
     * @param ast processed ast.
     */
    private void processFrame(DetailAST ast) {
        final FieldFrame frame = new FieldFrame(currentFrame);
        final int astType = ast.getType();
        if (astTypeIsClassOrEnumOrRecordDef(astType)) {
            frame.setClassOrEnumOrRecordDef(true);
            frame.setFrameName(ast.findFirstToken(TokenTypes.IDENT).getText());
        }
        currentFrame.addChild(frame);
        currentFrame = frame;
    }

    /**
     * Add the method call to the current frame if it should be processed.
     *
     * @param methodCall METHOD_CALL ast.
     */
    private void processMethodCall(DetailAST methodCall) {
        final DetailAST dot = methodCall.getFirstChild();
        if (dot.getType() == TokenTypes.DOT) {
            final String methodName = dot.getLastChild().getText();
            if (EQUALS.equals(methodName)
                    || !ignoreEqualsIgnoreCase && "equalsIgnoreCase".equals(methodName)) {
                currentFrame.addMethodCall(methodCall);
            }
        }
    }

    /**
     * Determine whether LITERAL_NEW is an anonymous class definition and add it as
     * a frame in this case.
     *
     * @param ast LITERAL_NEW ast.
     */
    private void processLiteralNew(DetailAST ast) {
        if (ast.findFirstToken(TokenTypes.OBJBLOCK) != null) {
            final FieldFrame frame = new FieldFrame(currentFrame);
            currentFrame.addChild(frame);
            currentFrame = frame;
        }
    }

    /**
     * Determine whether LITERAL_NEW is an anonymous class definition and leave
     * the frame it is in.
     *
     * @param ast LITERAL_NEW ast.
     */
    private void leaveLiteralNew(DetailAST ast) {
        if (ast.findFirstToken(TokenTypes.OBJBLOCK) != null) {
            currentFrame = currentFrame.getParent();
        }
    }

    /**
     * Traverse the tree of the field frames to check all equals method calls.
     *
     * @param frame to check method calls in.
     */
    private void traverseFieldFrameTree(FieldFrame frame) {
        for (FieldFrame child: frame.getChildren()) {
            traverseFieldFrameTree(child);

            currentFrame = child;
            child.getMethodCalls().forEach(this::checkMethodCall);
        }
    }

    /**
     * Check whether the method call should be violated.
     *
     * @param methodCall method call to check.
     */
    private void checkMethodCall(DetailAST methodCall) {
        DetailAST objCalledOn = methodCall.getFirstChild().getFirstChild();
        if (objCalledOn.getType() == TokenTypes.DOT) {
            objCalledOn = objCalledOn.getLastChild();
        }
        final DetailAST expr = methodCall.findFirstToken(TokenTypes.ELIST).getFirstChild();
        if (containsOneArgument(methodCall)
                && containsAllSafeTokens(expr)
                && isCalledOnStringFieldOrVariable(objCalledOn)) {
            final String methodName = methodCall.getFirstChild().getLastChild().getText();
            if (EQUALS.equals(methodName)) {
                log(methodCall, MSG_EQUALS_AVOID_NULL);
            }
            else {
                log(methodCall, MSG_EQUALS_IGNORE_CASE_AVOID_NULL);
            }
        }
    }

    /**
     * Verify that method call has one argument.
     *
     * @param methodCall METHOD_CALL DetailAST
     * @return true if method call has one argument.
     */
    private static boolean containsOneArgument(DetailAST methodCall) {
        final DetailAST elist = methodCall.findFirstToken(TokenTypes.ELIST);
        return elist.getChildCount() == 1;
    }

    /**
     * Looks for all "safe" Token combinations in the argument
     * expression branch.
     *
     * @param expr the argument expression
     * @return - true if any child matches the set of tokens, false if not
     */
    private static boolean containsAllSafeTokens(final DetailAST expr) {
        DetailAST arg = expr.getFirstChild();
        arg = skipVariableAssign(arg);

        boolean argIsNotNull = false;
        if (arg.getType() == TokenTypes.PLUS) {
            DetailAST child = arg.getFirstChild();
            while (child != null
                    && !argIsNotNull) {
                argIsNotNull = child.getType() == TokenTypes.STRING_LITERAL
                        || child.getType() == TokenTypes.TEXT_BLOCK_LITERAL_BEGIN
                        || child.getType() == TokenTypes.IDENT;
                child = child.getNextSibling();
            }
        }
        else {
            argIsNotNull = arg.getType() == TokenTypes.STRING_LITERAL
                    || arg.getType() == TokenTypes.TEXT_BLOCK_LITERAL_BEGIN;
        }

        return argIsNotNull;
    }

    /**
     * Skips over an inner assign portion of an argument expression.
     *
     * @param currentAST current token in the argument expression
     * @return the next relevant token
     */
    private static DetailAST skipVariableAssign(final DetailAST currentAST) {
        DetailAST result = currentAST;
        while (result.getType() == TokenTypes.LPAREN) {
            result = result.getNextSibling();
        }
        if (result.getType() == TokenTypes.ASSIGN) {
            result = result.getFirstChild().getNextSibling();
        }
        return result;
    }

    /**
     * Determine, whether equals method is called on a field of String type.
     *
     * @param objCalledOn object ast.
     * @return true if the object is of String type.
     */
    private boolean isCalledOnStringFieldOrVariable(DetailAST objCalledOn) {
        final boolean result;
        final DetailAST previousSiblingAst = objCalledOn.getPreviousSibling();
        if (previousSiblingAst == null) {
            result = isStringFieldOrVariable(objCalledOn);
        }
        else {
            if (previousSiblingAst.getType() == TokenTypes.LITERAL_THIS) {
                result = isStringFieldOrVariableFromThisInstance(objCalledOn);
            }
            else {
                final String className = previousSiblingAst.getText();
                result = isStringFieldOrVariableFromClass(objCalledOn, className);
            }
        }
        return result;
    }

    /**
     * Whether the field or the variable is of String type.
     *
     * @param objCalledOn the field or the variable to check.
     * @return true if the field or the variable is of String type.
     */
    private boolean isStringFieldOrVariable(DetailAST objCalledOn) {
        boolean result = false;
        final String name = objCalledOn.getText();
        FieldFrame frame = currentFrame;
        while (frame != null) {
            final DetailAST field = frame.findField(name);
            if (field != null
                    && (frame.isClassOrEnumOrRecordDef()
                            || CheckUtil.isBeforeInSource(field, objCalledOn))) {
                result = STRING.equals(getFieldType(field));
                break;
            }
            frame = frame.getParent();
        }
        return result;
    }

    /**
     * Whether the field or the variable from THIS instance is of String type.
     *
     * @param objCalledOn the field or the variable from THIS instance to check.
     * @return true if the field or the variable from THIS instance is of String type.
     */
    private boolean isStringFieldOrVariableFromThisInstance(DetailAST objCalledOn) {
        final String name = objCalledOn.getText();
        final DetailAST field = getObjectFrame(currentFrame).findField(name);
        return field != null && STRING.equals(getFieldType(field));
    }

    /**
     * Whether the field or the variable from the specified class is of String type.
     *
     * @param objCalledOn the field or the variable from the specified class to check.
     * @param className the name of the class to check in.
     * @return true if the field or the variable from the specified class is of String type.
     */
    private boolean isStringFieldOrVariableFromClass(DetailAST objCalledOn,
            final String className) {
        boolean result = false;
        final String name = objCalledOn.getText();
        FieldFrame frame = currentFrame;
        while (frame != null) {
            if (className.equals(frame.getFrameName())) {
                final DetailAST field = frame.findField(name);
                result = STRING.equals(getFieldType(field));
                break;
            }
            frame = frame.getParent();
        }
        return result;
    }

    /**
     * Get the nearest parent frame which is CLASS_DEF, ENUM_DEF or ENUM_CONST_DEF.
     *
     * @param frame to start the search from.
     * @return the nearest parent frame which is CLASS_DEF, ENUM_DEF or ENUM_CONST_DEF.
     */
    private static FieldFrame getObjectFrame(FieldFrame frame) {
        FieldFrame objectFrame = frame;
        while (!objectFrame.isClassOrEnumOrRecordDef()) {
            objectFrame = objectFrame.getParent();
        }
        return objectFrame;
    }

    /**
     * Get field type.
     *
     * @param field to get the type from.
     * @return type of the field.
     */
    private static String getFieldType(DetailAST field) {
        String fieldType = null;
        final DetailAST identAst = field.findFirstToken(TokenTypes.TYPE)
                .findFirstToken(TokenTypes.IDENT);
        if (identAst != null) {
            fieldType = identAst.getText();
        }
        return fieldType;
    }

    /**
     * Verify that a token is either CLASS_DEF, RECORD_DEF, or ENUM_DEF.
     *
     * @param tokenType the type of token
     * @return true if token is of specified type.
     */
    private static boolean astTypeIsClassOrEnumOrRecordDef(int tokenType) {
        return tokenType == TokenTypes.CLASS_DEF
                || tokenType == TokenTypes.RECORD_DEF
                || tokenType == TokenTypes.ENUM_DEF;
    }

    /**
     * Holds the names of fields of a type.
     */
    private static final class FieldFrame {

        /** Parent frame. */
        private final FieldFrame parent;

        /** Set of frame's children. */
        private final Set<FieldFrame> children = new HashSet<>();

        /** Map of field name to field DetailAst. */
        private final Map<String, DetailAST> fieldNameToAst = new HashMap<>();

        /** Set of equals calls. */
        private final Set<DetailAST> methodCalls = new HashSet<>();

        /** Name of the class, enum or enum constant declaration. */
        private String frameName;

        /** Whether the frame is CLASS_DEF, ENUM_DEF, ENUM_CONST_DEF, or RECORD_DEF. */
        private boolean classOrEnumOrRecordDef;

        /**
         * Creates new frame.
         *
         * @param parent parent frame.
         */
        private FieldFrame(FieldFrame parent) {
            this.parent = parent;
        }

        /**
         * Set the frame name.
         *
         * @param frameName value to set.
         */
        public void setFrameName(String frameName) {
            this.frameName = frameName;
        }

        /**
         * Getter for the frame name.
         *
         * @return frame name.
         */
        public String getFrameName() {
            return frameName;
        }

        /**
         * Getter for the parent frame.
         *
         * @return parent frame.
         */
        public FieldFrame getParent() {
            return parent;
        }

        /**
         * Getter for frame's children.
         *
         * @return children of this frame.
         */
        public Set<FieldFrame> getChildren() {
            return UnmodifiableCollectionUtil.unmodifiableSet(children);
        }

        /**
         * Add child frame to this frame.
         *
         * @param child frame to add.
         */
        public void addChild(FieldFrame child) {
            children.add(child);
        }

        /**
         * Add field to this FieldFrame.
         *
         * @param field the ast of the field.
         */
        public void addField(DetailAST field) {
            if (field.findFirstToken(TokenTypes.IDENT) != null) {
                fieldNameToAst.put(getFieldName(field), field);
            }
        }

        /**
         * Sets isClassOrEnumOrRecordDef.
         *
         * @param value value to set.
         */
        public void setClassOrEnumOrRecordDef(boolean value) {
            classOrEnumOrRecordDef = value;
        }

        /**
         * Getter for classOrEnumOrRecordDef.
         *
         * @return classOrEnumOrRecordDef.
         */
        public boolean isClassOrEnumOrRecordDef() {
            return classOrEnumOrRecordDef;
        }

        /**
         * Add method call to this frame.
         *
         * @param methodCall METHOD_CALL ast.
         */
        public void addMethodCall(DetailAST methodCall) {
            methodCalls.add(methodCall);
        }

        /**
         * Determines whether this FieldFrame contains the field.
         *
         * @param name name of the field to check.
         * @return true if this FieldFrame contains instance field.
         */
        public DetailAST findField(String name) {
            return fieldNameToAst.get(name);
        }

        /**
         * Getter for frame's method calls.
         *
         * @return method calls of this frame.
         */
        public Set<DetailAST> getMethodCalls() {
            return UnmodifiableCollectionUtil.unmodifiableSet(methodCalls);
        }

        /**
         * Get the name of the field.
         *
         * @param field to get the name from.
         * @return name of the field.
         */
        private static String getFieldName(DetailAST field) {
            return field.findFirstToken(TokenTypes.IDENT).getText();
        }

    }

}
