/**
 * BSD-style license; for more info see http://pmd.sourceforge.net/license.html
 */

package net.sourceforge.pmd.lang.java.rule.bestpractices;

import java.util.HashSet;
import java.util.Set;

import org.checkerframework.checker.nullness.qual.NonNull;

import net.sourceforge.pmd.lang.ast.NodeStream;
import net.sourceforge.pmd.lang.java.ast.ASTAssignableExpr.ASTNamedReferenceExpr;
import net.sourceforge.pmd.lang.java.ast.ASTCastExpression;
import net.sourceforge.pmd.lang.java.ast.ASTCatchClause;
import net.sourceforge.pmd.lang.java.ast.ASTConditionalExpression;
import net.sourceforge.pmd.lang.java.ast.ASTConstructorCall;
import net.sourceforge.pmd.lang.java.ast.ASTExpression;
import net.sourceforge.pmd.lang.java.ast.ASTFieldDeclaration;
import net.sourceforge.pmd.lang.java.ast.ASTInitializer;
import net.sourceforge.pmd.lang.java.ast.ASTList;
import net.sourceforge.pmd.lang.java.ast.ASTMethodCall;
import net.sourceforge.pmd.lang.java.ast.ASTThrowStatement;
import net.sourceforge.pmd.lang.java.ast.ASTTypeDeclaration;
import net.sourceforge.pmd.lang.java.ast.ASTVariableAccess;
import net.sourceforge.pmd.lang.java.ast.ASTVariableId;
import net.sourceforge.pmd.lang.java.ast.InvocationNode;
import net.sourceforge.pmd.lang.java.ast.JavaNode;
import net.sourceforge.pmd.lang.java.ast.internal.JavaAstUtils;
import net.sourceforge.pmd.lang.java.rule.AbstractJavaRulechainRule;
import net.sourceforge.pmd.lang.java.rule.internal.JavaRuleUtil;
import net.sourceforge.pmd.lang.java.symbols.JVariableSymbol;
import net.sourceforge.pmd.lang.java.types.InvocationMatcher;
import net.sourceforge.pmd.lang.java.types.InvocationMatcher.CompoundInvocationMatcher;

public class PreserveStackTraceRule extends AbstractJavaRulechainRule {
    // todo dfa

    private static final InvocationMatcher INIT_CAUSE = InvocationMatcher.parse("java.lang.Throwable#initCause(_)");
    private static final CompoundInvocationMatcher ALLOWED_GETTERS = InvocationMatcher.parseAll(
        "java.lang.Throwable#fillInStackTrace()", // returns this
        "java.lang.reflect.InvocationTargetException#getTargetException()", // allowed, to unwrap reflection frames
        "java.lang.reflect.InvocationTargetException#getCause()", // this is equivalent to getTargetException, see javadoc
        // same rationale as for InvocationTargetException
        "java.security.PrivilegedActionException#getException()",
        "java.security.PrivilegedActionException#getCause()"
    );

    private final Set<ASTVariableId> recursingOnVars = new HashSet<>();

    public PreserveStackTraceRule() {
        super(ASTCatchClause.class);
    }

    @Override
    public Object visit(ASTCatchClause catchStmt, Object data) {
        ASTVariableId exceptionParam = catchStmt.getParameter().getVarId();
        if (JavaRuleUtil.isExplicitUnusedVarName(exceptionParam.getName())) {
            // ignore those
            return null;
        }

        // Inspect all the throw stmt inside the catch stmt
        for (ASTThrowStatement throwStatement : catchStmt.getBody().descendants(ASTThrowStatement.class)) {
            ASTExpression thrownExpr = throwStatement.getExpr();

            if (!exprConsumesException(exceptionParam, thrownExpr, true)) {
                asCtx(data).addViolation(thrownExpr, exceptionParam.getName());
            }
        }
        recursingOnVars.clear();
        return null;
    }

    private boolean exprConsumesException(ASTVariableId exceptionParam, ASTExpression expr, boolean mayBeSelf) {
        if (expr instanceof ASTConstructorCall) {
            // new Exception(e)
            return ctorConsumesException(exceptionParam, (ASTConstructorCall) expr);

        } else if (expr instanceof ASTMethodCall) {

            return methodConsumesException(exceptionParam, (ASTMethodCall) expr);

        } else if (expr instanceof ASTCastExpression) {

            ASTExpression innermost = JavaAstUtils.peelCasts(expr);
            return exprConsumesException(exceptionParam, innermost, mayBeSelf);

        } else if (expr instanceof ASTConditionalExpression) {

            ASTConditionalExpression ternary = (ASTConditionalExpression) expr;
            return exprConsumesException(exceptionParam, ternary.getThenBranch(), mayBeSelf)
                && exprConsumesException(exceptionParam, ternary.getElseBranch(), mayBeSelf);

        } else if (expr instanceof ASTVariableAccess) {
            JVariableSymbol referencedSym = ((ASTVariableAccess) expr).getReferencedSym();
            if (referencedSym == null) {
                return true; // invalid code, avoid FP
            }
            ASTVariableId decl = referencedSym.tryGetNode();

            if (decl == exceptionParam) {
                return mayBeSelf;
            } else if (decl == null || decl.isFormalParameter() || decl.isField()) {
                return false;
            }

            if (!this.recursingOnVars.add(decl)) {
                // already recursing on this variable, avoid stackoverflow
                return false;
            }

            // if any of the initializer and usages consumes the variable,
            // answer true.

            if (exprConsumesException(exceptionParam, decl.getInitializer(), mayBeSelf)) {
                return true;
            }

            for (ASTNamedReferenceExpr usage : decl.getLocalUsages()) {
                if (assignmentRhsConsumesException(exceptionParam, decl, usage)) {
                    return true;
                }

                if (JavaAstUtils.followingCallChain(usage).any(it -> consumesExceptionNonRecursive(exceptionParam, it))) {
                    return true;
                }
            }

            return false;
        } else {
            // assume it doesn't
            return false;
        }
    }

    private boolean assignmentRhsConsumesException(ASTVariableId exceptionParam, ASTVariableId lhsVariable, ASTNamedReferenceExpr usage) {
        if (usage.getIndexInParent() == 0) {
            ASTExpression assignmentRhs = JavaAstUtils.getOtherOperandIfInAssignmentExpr(usage);
            boolean rhsIsSelfReferential =
                NodeStream.of(assignmentRhs)
                          .descendantsOrSelf()
                          .filterIs(ASTVariableAccess.class)
                          .any(it -> JavaAstUtils.isReferenceToVar(it, lhsVariable.getSymbol()));
            return !rhsIsSelfReferential && exprConsumesException(exceptionParam, assignmentRhs, true);
        }
        return false;
    }

    private boolean ctorConsumesException(ASTVariableId exceptionParam, ASTConstructorCall ctorCall) {
        return ctorCall.isAnonymousClass() && callsInitCauseInAnonInitializer(exceptionParam, ctorCall)
            || anArgumentConsumesException(exceptionParam, ctorCall);
    }

    private boolean consumesExceptionNonRecursive(ASTVariableId exceptionParam, ASTExpression expr) {
        if (expr instanceof ASTConstructorCall) {
            return ctorConsumesException(exceptionParam, (ASTConstructorCall) expr);
        }
        return expr instanceof InvocationNode && anArgumentConsumesException(exceptionParam, (InvocationNode) expr);
    }

    private boolean methodConsumesException(ASTVariableId exceptionParam, ASTMethodCall call) {
        if (anArgumentConsumesException(exceptionParam, call)) {
            return true;
        }
        ASTExpression qualifier = call.getQualifier();
        if (qualifier == null) {
            return false;
        }
        boolean mayBeSelf = ALLOWED_GETTERS.anyMatch(call);
        return exprConsumesException(exceptionParam, qualifier, mayBeSelf);
    }

    private boolean callsInitCauseInAnonInitializer(ASTVariableId exceptionParam, ASTConstructorCall ctorCall) {
        return NodeStream.of(ctorCall.getAnonymousClassDeclaration())
                         .flatMap(ASTTypeDeclaration::getDeclarations)
                         .map(NodeStream.asInstanceOf(ASTFieldDeclaration.class, ASTInitializer.class))
                         .descendants().filterIs(ASTMethodCall.class)
                         .any(it -> isInitCauseWithTargetInArg(exceptionParam, it));
    }

    private boolean isInitCauseWithTargetInArg(ASTVariableId exceptionSym, JavaNode expr) {
        return INIT_CAUSE.matchesCall(expr) && anArgumentConsumesException(exceptionSym, (ASTMethodCall) expr);
    }

    private boolean anArgumentConsumesException(@NonNull ASTVariableId exceptionParam, InvocationNode thrownExpr) {
        for (ASTExpression arg : ASTList.orEmptyStream(thrownExpr.getArguments())) {
            if (exprConsumesException(exceptionParam, arg, true)) {
                return true;
            }
        }
        return false;
    }

}
