/*
 * Copyright 2017 The Error Prone Authors.
 *
 * 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.common.base.Preconditions.checkState;
import static com.google.common.base.Verify.verify;
import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
import static com.google.errorprone.util.ASTHelpers.findSuperMethods;
import static com.google.errorprone.util.ASTHelpers.getErasedTypeTree;
import static com.google.errorprone.util.ASTHelpers.getSymbol;
import static com.google.errorprone.util.ASTHelpers.getType;
import static com.google.errorprone.util.ASTHelpers.methodCanBeOverridden;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.BugPattern;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker.MethodTreeMatcher;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.fixes.SuggestedFixes;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.InjectMatchers;
import com.google.errorprone.matchers.Matcher;
import com.sun.source.tree.ClassTree;
import com.sun.source.tree.LambdaExpressionTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.ReturnTree;
import com.sun.source.tree.Tree;
import com.sun.source.util.TreeScanner;
import com.sun.tools.javac.code.Symbol.MethodSymbol;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.code.Type.ClassType;
import java.util.Optional;
import java.util.function.Predicate;

/** @author dorir@google.com (Dori Reuveni) */
@BugPattern(
    name = "MutableMethodReturnType",
    summary =
        "Method return type should use the immutable type (such as ImmutableList) instead of"
            + " the general collection interface type (such as List).",
    severity = WARNING)
public final class MutableMethodReturnType extends BugChecker implements MethodTreeMatcher {

  private static final Matcher<MethodTree> ANNOTATED_WITH_PRODUCES_OR_PROVIDES =
      InjectMatchers.hasProvidesAnnotation();

  private static final String OVERRIDE_NOTE =
      " Note that it is legal to narrow the return type when overriding a parent method. And"
          + " because this method cannot be overridden, doing so cannot cause problems for any"
          + " subclasses.";

  @Override
  public Description matchMethod(MethodTree methodTree, VisitorState state) {
    MethodSymbol methodSymbol = getSymbol(methodTree);

    if (methodSymbol.isConstructor()) {
      return Description.NO_MATCH;
    }

    if (methodCanBeOverridden(methodSymbol)) {
      return Description.NO_MATCH;
    }

    if (ANNOTATED_WITH_PRODUCES_OR_PROVIDES.matches(methodTree, state)) {
      return Description.NO_MATCH;
    }

    Type returnType = methodSymbol.getReturnType();
    if (ImmutableCollections.isImmutableType(returnType)) {
      return Description.NO_MATCH;
    }

    ImmutableSet<ClassType> returnStatementsTypes = getMethodReturnTypes(methodTree);
    if (returnStatementsTypes.isEmpty()) {
      return Description.NO_MATCH;
    }
    boolean alwaysReturnsImmutableType =
        returnStatementsTypes.stream().allMatch(ImmutableCollections::isImmutableType);
    if (!alwaysReturnsImmutableType) {
      return Description.NO_MATCH;
    }

    Optional<String> immutableReturnType =
        ImmutableCollections.mutableToImmutable(getTypeQualifiedName(returnType));
    if (!immutableReturnType.isPresent()) {
      immutableReturnType =
          getCommonImmutableTypeForAllReturnStatementsTypes(returnStatementsTypes);
    }
    if (!immutableReturnType.isPresent()) {
      return Description.NO_MATCH;
    }

    Type newReturnType = state.getTypeFromString(immutableReturnType.get());
    SuggestedFix.Builder fixBuilder = SuggestedFix.builder();
    Tree typeTree = getErasedTypeTree(methodTree.getReturnType());
    verify(typeTree != null, "Could not find return type of %s", methodTree);
    fixBuilder.replace(
        typeTree, SuggestedFixes.qualifyType(state, fixBuilder, newReturnType.asElement()));

    String message =
        message()
            + (findSuperMethods(getSymbol(methodTree), state.getTypes()).isEmpty()
                ? ""
                : OVERRIDE_NOTE);
    return buildDescription(methodTree.getReturnType())
        .setMessage(message)
        .addFix(fixBuilder.build())
        .build();
  }

  private static Optional<String> getCommonImmutableTypeForAllReturnStatementsTypes(
      ImmutableSet<ClassType> returnStatementsTypes) {
    checkState(!returnStatementsTypes.isEmpty());

    ClassType arbitraryClassType = returnStatementsTypes.asList().get(0);
    ImmutableList<String> superTypes = getImmutableSuperTypesForClassType(arbitraryClassType);

    return superTypes.stream()
        .filter(areAllReturnStatementsAssignable(returnStatementsTypes))
        .findFirst();
  }

  private static Predicate<String> areAllReturnStatementsAssignable(
      ImmutableSet<ClassType> returnStatementsTypes) {
    return s ->
        returnStatementsTypes.stream()
            .map(MutableMethodReturnType::getImmutableSuperTypesForClassType)
            .allMatch(c -> c.contains(s));
  }

  private static ImmutableList<String> getImmutableSuperTypesForClassType(ClassType classType) {
    ImmutableList.Builder<String> immutableSuperTypes = ImmutableList.builder();

    ClassType superType = classType;
    while (superType.supertype_field instanceof ClassType) {
      if (ImmutableCollections.isImmutableType(superType)) {
        immutableSuperTypes.add(getTypeQualifiedName(superType.asElement().type));
      }
      superType = (ClassType) superType.supertype_field;
    }

    return immutableSuperTypes.build();
  }

  private static String getTypeQualifiedName(Type type) {
    return type.tsym.getQualifiedName().toString();
  }

  private static ImmutableSet<ClassType> getMethodReturnTypes(MethodTree methodTree) {
    ImmutableSet.Builder<ClassType> returnTypes = ImmutableSet.builder();
    methodTree.accept(
        new TreeScanner<Void, Void>() {
          @Override
          public Void visitReturn(ReturnTree node, Void unused) {
            Type type = getType(node.getExpression());
            if (type instanceof ClassType) {
              returnTypes.add((ClassType) type);
            }

            return null;
          }

          @Override
          public Void visitClass(ClassTree tree, Void unused) {
            // Don't continue into nested classes.
            return null;
          }

          @Override
          public Void visitLambdaExpression(LambdaExpressionTree tree, Void unused) {
            // Don't continue into nested lambdas.
            return null;
          }
        },
        null /* unused */);
    return returnTypes.build();
  }
}
