/*
 * Copyright 2018 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.errorprone.BugPattern.SeverityLevel.WARNING;
import static com.google.errorprone.matchers.Matchers.allOf;
import static com.google.errorprone.matchers.method.MethodMatchers.instanceMethod;

import com.google.auto.value.AutoValue;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.errorprone.BugPattern;
import com.google.errorprone.BugPattern.StandardTags;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.tools.javac.code.Symbol;
import com.sun.tools.javac.code.Symbol.MethodSymbol;
import com.sun.tools.javac.code.Type;
import java.util.Collection;
import java.util.Map;
import java.util.regex.Pattern;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
 * Checks that protocol buffers built with chained builders don't set the same field twice.
 *
 * @author ghm@google.com (Graeme Morgan)
 */
@BugPattern(
    summary = "A field on a protocol buffer was set twice in the same chained expression.",
    severity = WARNING,
    tags = StandardTags.FRAGILE_CODE)
public final class ProtoRedundantSet extends BugChecker implements MethodInvocationTreeMatcher {

  /** Matches a chainable proto builder method. */
  private static final Matcher<ExpressionTree> PROTO_FLUENT_METHOD =
      instanceMethod()
          .onDescendantOfAny(
              "com.google.protobuf.GeneratedMessage.Builder",
              "com.google.protobuf.GeneratedMessageLite.Builder")
          .withNameMatching(Pattern.compile("^(set|add|clear|put).+"));

  /**
   * Matches a terminal proto builder method. That is, a chainable builder method which is either
   * not followed by another method invocation, or by a method invocation which is not a {@link
   * #PROTO_FLUENT_METHOD}.
   */
  private static final Matcher<ExpressionTree> TERMINAL_PROTO_FLUENT_METHOD =
      allOf(
          PROTO_FLUENT_METHOD,
          (tree, state) ->
              !(state.getPath().getParentPath().getLeaf() instanceof MemberSelectTree
                  && PROTO_FLUENT_METHOD.matches(
                      (ExpressionTree) state.getPath().getParentPath().getParentPath().getLeaf(),
                      state)));

  @Override
  public Description matchMethodInvocation(MethodInvocationTree tree, VisitorState state) {
    if (!TERMINAL_PROTO_FLUENT_METHOD.matches(tree, state)) {
      return Description.NO_MATCH;
    }
    ListMultimap<ProtoField, FieldWithValue> setters = ArrayListMultimap.create();
    Type type = ASTHelpers.getReturnType(tree);
    for (ExpressionTree current = tree;
        PROTO_FLUENT_METHOD.matches(current, state);
        current = ASTHelpers.getReceiver(current)) {
      MethodInvocationTree method = (MethodInvocationTree) current;
      if (!ASTHelpers.isSameType(type, ASTHelpers.getReturnType(current), state)) {
        break;
      }
      Symbol symbol = ASTHelpers.getSymbol(current);
      if (!(symbol instanceof MethodSymbol)) {
        break;
      }
      String methodName = symbol.getSimpleName().toString();
      // Break on methods like "addFooBuilder", otherwise we might be building a nested proto of the
      // same type.
      if (methodName.endsWith("Builder")) {
        break;
      }
      match(method, methodName, setters);
    }

    setters.asMap().entrySet().removeIf(entry -> entry.getValue().size() <= 1);

    if (setters.isEmpty()) {
      return Description.NO_MATCH;
    }

    for (Map.Entry<ProtoField, Collection<FieldWithValue>> entry : setters.asMap().entrySet()) {
      ProtoField protoField = entry.getKey();
      Collection<FieldWithValue> values = entry.getValue();
      state.reportMatch(describe(protoField, values, state));
    }
    return Description.NO_MATCH;
  }

  private Description describe(
      ProtoField protoField, Collection<FieldWithValue> locations, VisitorState state) {
    // We flag up all duplicate sets, but only suggest a fix if the setter is given the same
    // argument (based on source code). This is to avoid the temptation to apply the fix in
    // cases like,
    //   MyProto.newBuilder().setFoo(copy.getFoo()).setFoo(copy.getBar())
    // where the correct fix is probably to replace the second 'setFoo' with 'setBar'.
    SuggestedFix.Builder fix = SuggestedFix.builder();
    long values =
        locations.stream().map(l -> state.getSourceForNode(l.getArgument())).distinct().count();
    if (values == 1) {
      for (FieldWithValue field : Iterables.skip(locations, 1)) {
        MethodInvocationTree method = field.getMethodInvocation();
        int startPos = state.getEndPosition(ASTHelpers.getReceiver(method));
        int endPos = state.getEndPosition(method);
        fix.replace(startPos, endPos, "");
      }
    }
    return buildDescription(locations.iterator().next().getArgument())
        .setMessage(
            String.format(
                "%s was called %s with %s. Setting the same field multiple times is redundant, and "
                    + "could mask a bug.",
                protoField,
                nTimes(locations.size()),
                values == 1 ? "the same argument" : "different arguments"))
        .addFix(fix.build())
        .build();
  }

  private static void match(
      MethodInvocationTree method,
      String methodName,
      ListMultimap<ProtoField, FieldWithValue> setters) {
    for (FieldType fieldType : FieldType.values()) {
      FieldWithValue match = fieldType.match(methodName, method);
      if (match != null) {
        setters.put(match.getField(), match);
      }
    }
  }

  private static String nTimes(int n) {
    return n == 2 ? "twice" : String.format("%d times", n);
  }

  interface ProtoField {}

  enum FieldType {
    SINGLE {
      @Override
      @Nullable FieldWithValue match(String name, MethodInvocationTree tree) {
        if (name.startsWith("set") && tree.getArguments().size() == 1) {
          return FieldWithValue.of(SingleField.of(name), tree, tree.getArguments().get(0));
        }
        return null;
      }
    },
    REPEATED {
      @Override
      @Nullable FieldWithValue match(String name, MethodInvocationTree tree) {
        if (name.startsWith("set") && tree.getArguments().size() == 2) {
          Integer index = ASTHelpers.constValue(tree.getArguments().get(0), Integer.class);
          if (index != null) {
            return FieldWithValue.of(
                RepeatedField.of(name, index), tree, tree.getArguments().get(1));
          }
        }
        return null;
      }
    },
    MAP {
      @Override
      @Nullable FieldWithValue match(String name, MethodInvocationTree tree) {
        if (name.startsWith("put") && tree.getArguments().size() == 2) {
          Object key = ASTHelpers.constValue(tree.getArguments().get(0), Object.class);
          if (key != null) {
            return FieldWithValue.of(MapField.of(name, key), tree, tree.getArguments().get(1));
          }
        }
        return null;
      }
    };

    abstract FieldWithValue match(String name, MethodInvocationTree tree);
  }

  @AutoValue
  abstract static class SingleField implements ProtoField {
    abstract String getName();

    static SingleField of(String name) {
      return new AutoValue_ProtoRedundantSet_SingleField(name);
    }

    @Override
    public final String toString() {
      return String.format("%s(..)", getName());
    }
  }

  @AutoValue
  abstract static class RepeatedField implements ProtoField {
    abstract String getName();

    abstract int getIndex();

    static RepeatedField of(String name, int index) {
      return new AutoValue_ProtoRedundantSet_RepeatedField(name, index);
    }

    @Override
    public final String toString() {
      return String.format("%s(%s, ..)", getName(), getIndex());
    }
  }

  @AutoValue
  abstract static class MapField implements ProtoField {
    abstract String getName();

    abstract Object getKey();

    static MapField of(String name, Object key) {
      return new AutoValue_ProtoRedundantSet_MapField(name, key);
    }

    @Override
    public final String toString() {
      return String.format("%s(%s, ..)", getName(), getKey());
    }
  }

  @AutoValue
  abstract static class FieldWithValue {
    abstract ProtoField getField();

    abstract MethodInvocationTree getMethodInvocation();

    abstract ExpressionTree getArgument();

    static FieldWithValue of(
        ProtoField field, MethodInvocationTree methodInvocationTree, ExpressionTree argumentTree) {
      return new AutoValue_ProtoRedundantSet_FieldWithValue(
          field, methodInvocationTree, argumentTree);
    }
  }
}
