/*
 * Copyright 2016 Google Inc. All Rights Reserved.
 *
 * Licensed 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 com.google.errorprone.bugpatterns;

import static com.google.errorprone.BugPattern.Category.JUNIT;
import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;

import com.google.common.collect.Iterables;
import com.google.errorprone.BugPattern;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher;
import com.google.errorprone.fixes.Fix;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.matchers.method.MethodMatchers;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.code.Types;
import com.sun.tools.javac.tree.JCTree;
import java.util.ArrayList;
import java.util.List;
import javax.lang.model.type.TypeKind;

/**
 * Detects floating-point assertEquals() calls that will not work in JUnit 4.
 *
 * <p>JUnit 4 bans most but not all floating-point comparisons without a delta argument. This check
 * will be as strict as JUnit 4, no more and no less.
 *
 * @author mwacker@google.com (Mike Wacker)
 */
@BugPattern(
  name = "JUnit3FloatingPointComparisonWithoutDelta",
  summary = "Floating-point comparison without error tolerance",
  // First sentence copied directly from JUnit 4.
  explanation =
      "Use assertEquals(expected, actual, delta) to compare floating-point numbers. "
          + "This call to assertEquals() will either fail or not compile in JUnit 4.",
  category = JUNIT,
  severity = WARNING
)
public class JUnit3FloatingPointComparisonWithoutDelta extends BugChecker
    implements MethodInvocationTreeMatcher {

  private static final Matcher<ExpressionTree> ASSERT_EQUALS_MATCHER =
      MethodMatchers.staticMethod().onClass("junit.framework.TestCase").named("assertEquals");

  @Override
  public Description matchMethodInvocation(
      MethodInvocationTree methodInvocationTree, VisitorState state) {
    if (!ASSERT_EQUALS_MATCHER.matches(methodInvocationTree, state)) {
      return Description.NO_MATCH;
    }
    List<Type> argumentTypes = getArgumentTypesWithoutMessage(methodInvocationTree, state);
    if (canBeConvertedToJUnit4(state, argumentTypes)) {
      return Description.NO_MATCH;
    }
    Fix fix = addDeltaArgument(methodInvocationTree, state, argumentTypes);
    return describeMatch(methodInvocationTree, fix);
  }

  /**
   * Gets the argument types, excluding the message argument if present.
   */
  private List<Type> getArgumentTypesWithoutMessage(
      MethodInvocationTree methodInvocationTree, VisitorState state) {
    List<Type> argumentTypes = new ArrayList<>();
    for (ExpressionTree argument : methodInvocationTree.getArguments()) {
      JCTree tree = (JCTree) argument;
      argumentTypes.add(tree.type);
    }
    removeMessageArgumentIfPresent(state, argumentTypes);
    return argumentTypes;
  }

  /**
   * Removes the message argument if it is present.
   */
  private void removeMessageArgumentIfPresent(VisitorState state, List<Type> argumentTypes) {
    if (argumentTypes.size() == 2) {
      return;
    }
    Types types = state.getTypes();
    Type firstType = argumentTypes.get(0);
    if (types.isSameType(firstType, state.getSymtab().stringType)) {
      argumentTypes.remove(0);
    }
  }

  /**
   * Determines if the invocation can be safely converted to JUnit 4 based on its argument types.
   */
  private boolean canBeConvertedToJUnit4(VisitorState state, List<Type> argumentTypes) {
    // Delta argument is used.
    if (argumentTypes.size() > 2) {
      return true;
    }
    Type firstType = argumentTypes.get(0);
    Type secondType = argumentTypes.get(1);
    // Neither argument is floating-point.
    if (!isFloatingPoint(state, firstType) && !isFloatingPoint(state, secondType)) {
      return true;
    }
    // One argument is not numeric.
    if (!isNumeric(state, firstType) || !isNumeric(state, secondType)) {
      return true;
    }
    // Neither argument is primitive.
    if (!firstType.isPrimitive() && !secondType.isPrimitive()) {
      return true;
    }
    return false;
  }

  /**
   * Determines if the type is a floating-point type, including reference types.
   */
  private boolean isFloatingPoint(VisitorState state, Type type) {
    Type trueType = unboxedTypeOrType(state, type);
    return (trueType.getKind() == TypeKind.DOUBLE) || (trueType.getKind() == TypeKind.FLOAT);
  }

  /**
   * Determines if the type is a numeric type, including reference types.
   *
   * <p>Type.isNumeric() does not handle reference types properly.
   */
  private boolean isNumeric(VisitorState state, Type type) {
    Type trueType = unboxedTypeOrType(state, type);
    return trueType.isNumeric();
  }

  /**
   * Gets the unboxed type, or the original type if it is not unboxable.
   */
  private Type unboxedTypeOrType(VisitorState state, Type type) {
    Types types = state.getTypes();
    return types.unboxedTypeOrType(type);
  }

  /**
   * Creates the fix to add a delta argument.
   */
  private Fix addDeltaArgument(
      MethodInvocationTree methodInvocationTree,
      VisitorState state,
      List<Type> argumentTypes) {
    int insertionIndex = getDeltaInsertionIndex(methodInvocationTree, state);
    String deltaArgument = getDeltaArgument(state, argumentTypes);
    return SuggestedFix.replace(insertionIndex, insertionIndex, deltaArgument);
  }

  /**
   * Gets the index of where to insert the delta argument.
   */
  private int getDeltaInsertionIndex(
      MethodInvocationTree methodInvocationTree, VisitorState state) {
    JCTree lastArgument = (JCTree) Iterables.getLast(methodInvocationTree.getArguments());
    return state.getEndPosition(lastArgument);
  }

  /**
   * Gets the text for the delta argument to be added.
   */
  private String getDeltaArgument(VisitorState state, List<Type> argumentTypes) {
    Type firstType = argumentTypes.get(0);
    Type secondType = argumentTypes.get(1);
    boolean doublePrecisionUsed = isDouble(state, firstType) || isDouble(state, secondType);
    return doublePrecisionUsed ? ", 0.0" : ", 0.0f";
  }

  /**
   * Determines if the type is a double, including reference types.
   */
  private boolean isDouble(VisitorState state, Type type) {
    Type trueType = unboxedTypeOrType(state, type);
    return trueType.getKind() == TypeKind.DOUBLE;
  }
}

@@ -2203,16 +2203,95 @@
 JUnit 4.
+ %22%0A          + %22Use assertEquals(expected, actual, 0.0) if the delta must be 0.
 %22,%0A  cat

Tweak docs in @BugPattern.