/*
 * Copyright (C) 2019 The Android Open Source Project
 *
 * 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.android.tools.sizereduction.analyzer.model;

import static com.google.common.collect.ImmutableMap.toImmutableMap;

import com.android.bundle.AppDependenciesOuterClass.Library;
import com.android.bundle.AppDependenciesOuterClass.MavenLibrary;
import com.google.auto.value.AutoValue;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.CodeVisitorSupport;
import org.codehaus.groovy.ast.builder.AstBuilder;
import org.codehaus.groovy.ast.expr.ArgumentListExpression;
import org.codehaus.groovy.ast.expr.BinaryExpression;
import org.codehaus.groovy.ast.expr.ClosureExpression;
import org.codehaus.groovy.ast.expr.ConstantExpression;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.MapEntryExpression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
import org.codehaus.groovy.ast.expr.NamedArgumentListExpression;
import org.codehaus.groovy.ast.expr.PropertyExpression;
import org.codehaus.groovy.ast.expr.TupleExpression;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.control.CompilePhase;

/**
 * This class parses a build.gradle file. It currently extracts the minSdkVersion along with the
 * proguard configurations for each buildType. In addition, it recognizes if the build.gradle file
 * is for an android application, dynamic-feature, or other build.gradle type. It can be extended to
 * add other dsl properties by modifying the checkDslPropertyAssignment function. For property
 * values that are defined in other build.gradle files, the value will just be the string
 * representation as it cannot evaluate the value.
 */
public final class GroovyGradleParser extends CodeVisitorSupport {

  private final List<MethodCallExpression> methodCallStack = new ArrayList<>();
  private int minSdkVersion = -1;
  private final Map<String, ProguardConfig.Builder> proguardConfigs = new HashMap<>();
  private final Set<Library> dependencySet = new HashSet<>();
  private final GradleContext.Builder gradleContextBuilder;
  private BundleConfig.Builder bundleConfigBuilder = BundleConfig.builder();
  private final BundleConfigLocation.Builder bundleConfigLocationBuilder =
      BundleConfigLocation.builder();
  private final String content;
  private int defaultMinSdkVersion = 1;

  private GroovyGradleParser(
      String content, int defaultMinSdkVersion, AndroidPluginVersion defaultAndroidPluginVersion) {
    this.content = content;
    this.defaultMinSdkVersion = defaultMinSdkVersion;
    this.gradleContextBuilder =
        GradleContext.builder().setAndroidPluginVersion(defaultAndroidPluginVersion);
  }

  public static GradleContext.Builder parseGradleBuildFile(
      String content,
      int defaultMinSdkVersion,
      @Nullable AndroidPluginVersion defaultAndroidPluginVersion) {
    // We need to have an abstract syntax tree, which is what the conversion phase produces,
    // Anything more will try to semantically understand the groovy code.
    List<ASTNode> astNodes = new AstBuilder().buildFromString(CompilePhase.CONVERSION, content);
    GroovyGradleParser parser =
        new GroovyGradleParser(content, defaultMinSdkVersion, defaultAndroidPluginVersion);

    for (ASTNode node : astNodes) {
      if (node instanceof ClassNode) {
        // class nodes do not implement the visit method, and will throw a runtime exception.
        continue;
      }
      node.visit(parser);
    }
    return parser.getGradleContextBuilder();
  }

  public GradleContext.Builder getGradleContextBuilder() {
    ImmutableMap<String, ProguardConfig> configs =
        proguardConfigs.entrySet().stream()
            .collect(toImmutableMap(entry -> entry.getKey(), entry -> entry.getValue().build()));
    gradleContextBuilder
        .setProguardConfigs(configs)
        .setBundleConfig(
            bundleConfigBuilder
                .setBundleConfigLocation(bundleConfigLocationBuilder.build())
                .build())
        .setMinSdkVersion(minSdkVersion > 0 ? minSdkVersion : defaultMinSdkVersion)
        .setLibraryDependencies(dependencySet);
    return gradleContextBuilder;
  }

  @Override
  public void visitMethodCallExpression(MethodCallExpression expression) {
    // get initial parent, parentParent values.
    String parent =
        methodCallStack.size() < 1 ? "" : Iterables.getLast(methodCallStack).getMethodAsString();
    String parentParent = getParentParent();
    methodCallStack.add(expression);

    if (expression.getArguments() instanceof ArgumentListExpression) {
      Expression objectExpression = expression.getObjectExpression();
      String newParent = getValidParentString(objectExpression);
      if (newParent != null) {
        parentParent = parent;
        parent = newParent;
        if (objectExpression instanceof PropertyExpression) {
          String newParentParent =
              getValidParentString(((PropertyExpression) objectExpression).getObjectExpression());
          if (newParentParent != null) {
            parentParent = newParentParent;
          }
        }
      }
      checkDslProperty(expression, parent, parentParent);
    }
    super.visitMethodCallExpression(expression);
    methodCallStack.remove(methodCallStack.size() - 1);
  }

  @Override
  public void visitTupleExpression(TupleExpression tupleExpression) {
    if (!methodCallStack.isEmpty()) {
      MethodCallExpression call = Iterables.getLast(methodCallStack);
      if (call.getArguments() == tupleExpression) {
        String parent = call.getMethodAsString();
        String parentParent = getParentParent();
        if (!(tupleExpression instanceof ArgumentListExpression)) {
          Map<String, String> namedArguments = new HashMap<>();
          for (Expression subExpr : tupleExpression.getExpressions()) {
            if (subExpr instanceof NamedArgumentListExpression) {
              NamedArgumentListExpression nale = (NamedArgumentListExpression) subExpr;
              for (MapEntryExpression mae : nale.getMapEntryExpressions()) {
                namedArguments.put(
                    mae.getKeyExpression().getText(), mae.getValueExpression().getText());
              }
            }
          }
          checkMethodCall(parent, parentParent, namedArguments);
        }
      }
    }
    super.visitTupleExpression(tupleExpression);
  }

  /** Handles a groovy BinaryExpression such as foo = true, or bar.baz.foo = true. */
  @Override
  public void visitBinaryExpression(BinaryExpression binaryExpression) {
    if (!methodCallStack.isEmpty()) {
      MethodCallExpression call = Iterables.getLast(methodCallStack);
      String parent = call.getMethodAsString();
      String parentParent = getParentParent();
      Expression leftExpression = binaryExpression.getLeftExpression();
      Expression rightExpression = binaryExpression.getRightExpression();
      if (rightExpression instanceof ConstantExpression
          && (leftExpression instanceof PropertyExpression
              || leftExpression instanceof VariableExpression)) {
        String value = rightExpression.getText();
        String property = "";
        if (leftExpression instanceof PropertyExpression) {
          Expression leftPropertyExpression = ((PropertyExpression) leftExpression).getProperty();
          if (!(leftPropertyExpression instanceof ConstantExpression)) {
            return;
          }
          property = ((ConstantExpression) leftPropertyExpression).getText();
          Expression leftObjectExpression =
              ((PropertyExpression) leftExpression).getObjectExpression();
          parentParent = parent;
          parent = getValidParentString(leftObjectExpression);
          if (leftObjectExpression instanceof PropertyExpression) {
            parentParent =
                getValidParentString(
                    ((PropertyExpression) leftObjectExpression).getObjectExpression());
          }
        } else {
          property = ((VariableExpression) leftExpression).getName();
        }
        checkDslPropertyAssignment(
            property, value, parent, parentParent, binaryExpression.getLineNumber());
      }
    }
    super.visitBinaryExpression(binaryExpression);
  }

  /**
   * This returns a String representation of the object if it is a valid expression for a parent
   * object. Otherwise, it will return null. For instance in `defaultConfig.minSdkVersion 14`,
   * defaultConfig would be a valid parent expression.
   */
  private String getValidParentString(Expression objectExpression) {
    if (objectExpression instanceof PropertyExpression) {
      return ((PropertyExpression) objectExpression).getPropertyAsString();
    } else if (objectExpression instanceof VariableExpression) {
      VariableExpression variableExpression = (VariableExpression) objectExpression;
      if (!variableExpression.getName().equals("this")) {
        return variableExpression.getName();
      }
    }
    return null;
  }

  /**
   * This will return an initial guess as to the string representation of the parent parent object,
   * based solely on the method callstack hierarchy. Any direct property or variable parents should
   * be resolved by using the getValidStringRepresentation function.
   */
  private String getParentParent() {
    for (int i = methodCallStack.size() - 2; i >= 0; i--) {
      MethodCallExpression expression = methodCallStack.get(i);
      Expression arguments = expression.getArguments();
      if (arguments instanceof ArgumentListExpression) {
        ArgumentListExpression ale = (ArgumentListExpression) arguments;
        List<Expression> expressions = ale.getExpressions();
        if (expressions.size() == 1 && expressions.get(0) instanceof ClosureExpression) {
          return expression.getMethodAsString();
        }
      }
    }

    return null;
  }

  /**
   * This will evaluate the dsl property assignment, to store the valid value. In
   * android.defaultConfig.minSdkVersion 15, "minSdkVersion 15" is the methodCall, defaultConfig is
   * the parent, and android is the parentParent expression. This can also be written as android {
   * defaultConfig { minSdkVersion 15 } } in the build.gradle file as well.
   *
   * @param call is a the method call expression.
   * @param parent is the string representation for the parent object.
   * @param parentParent is the string representation for the parent of the parent object.
   */
  private void checkDslProperty(MethodCallExpression call, String parent, String parentParent) {
    String property = call.getMethodAsString();
    if (property == null) {
      return;
    }
    String value = getText(call.getArguments(), content);
    checkDslPropertyAssignment(property, value, parent, parentParent, call.getLineNumber());
  }

  /**
   * This will evaluate the dsl property assignment, to store the valid value. In
   * android.defaultConfig.minSdkVersion 15, "minSdkVersion" is the property, 15 is the value,
   * defaultConfig is the parent, and android is the parentParent expression. This can also be
   * written as android { defaultConfig { minSdkVersion 15 } } in the build.gradle file as well.
   *
   * @param property is the property being assigned.
   * @param value is the value for the dsl assignment.
   * @param parent is the string representation for the parent of the property being assigned.
   * @param parentParent is the string representation for the parent of the parent object.
   */
  private void checkDslPropertyAssignment(
      String property, String value, String parent, String parentParent, int lineNumber) {
    String buildType =
        "buildTypes".equals(parentParent) ? parent : ProguardConfig.DEFAULT_CONFIG_NAME;
    ProguardConfig.Builder proguardConfig =
        proguardConfigs.containsKey(buildType)
            ? proguardConfigs.get(buildType)
            : ProguardConfig.builder();
    switch (property) {
      case "minSdkVersion":
        int curMinSdkVersion = getSdkVersion(value);
        minSdkVersion =
            (minSdkVersion > 0 && minSdkVersion < curMinSdkVersion)
                ? minSdkVersion
                : curMinSdkVersion;
        break;
      case "minifyEnabled":
        proguardConfig.setMinifyEnabled(value.equals("true"));
        proguardConfigs.put(buildType, proguardConfig);
        break;
      case "proguardFiles":
        proguardConfig.setHasProguardRules(true);
        proguardConfigs.put(buildType, proguardConfig);
        break;
      case "useProguard":
        // useProguard may use proguard or r8, but this effectively controls whether
        // obfuscation is enabled for this project.
        proguardConfig.setObfuscationEnabled(value.equals("true"));
        proguardConfigs.put(buildType, proguardConfig);
        break;
      case "enableSplit":
        if (parentParent.equals("bundle")) {
          switch (parent) {
            case "abi": // 3.2 and above.
              bundleConfigBuilder.setAbiSplitEnabled(value.equals("true"));
              bundleConfigLocationBuilder.setAbiSplitLineNumber(lineNumber);
              break;
            case "density":
              bundleConfigBuilder.setDensitySplitEnabled(value.equals("true"));
              bundleConfigLocationBuilder.setDensitySplitLineNumber(lineNumber);
              break;
            case "language":
              bundleConfigBuilder.setLanguageSplitEnabled(value.equals("true"));
              bundleConfigLocationBuilder.setLanguageSplitLineNumber(lineNumber);
              break;
            default:
              // ignore other proprties
              break;
          }
        }
        break;
      case "classpath":
        if (parent.equals("dependencies") && parentParent.equals("buildscript")) {
          if (isStringLiteral(value)) {
            String classPath = getStringLiteralValue(value);
            if (classPath.startsWith("com.android.tools.build:gradle:")) {
              String version = classPath.substring("com.android.tools.build:gradle:".length());
              gradleContextBuilder.setAndroidPluginVersion(AndroidPluginVersion.create(version));
            }
          }
        }
        break;
      case "wearApp":
        if (parent.equals("dependencies")) {
          gradleContextBuilder.setEmbedsWearApk(true);
        }
        break;
      case "implementation":
      case "api":
      case "compile":
        if (parent.equals("dependencies")) {
          if (isStringLiteral(value)) {
            List<String> dependency = Splitter.on(':').splitToList(getStringLiteralValue(value));
            if (isValidDependencyString(dependency)) {
              addDependencyToGradleContext(
                  /* group= */ dependency.get(0),
                  /* name= */ dependency.get(1),
                  /* version= */ dependency.get(2));
            }
          }
        }
        break;
      default:
        // there are many other valid properties, but we do not care to store them yet.
        break;
    }
  }

  private void checkMethodCall(
      String statement, String parent, Map<String, String> namedArguments) {
    if (statement.equals("apply") && parent == null && namedArguments.containsKey("plugin")) {
      String plugin = namedArguments.get("plugin");
      switch (plugin) {
        case "com.android.application":
          gradleContextBuilder.setPluginType(GradleContext.PluginType.APPLICATION);
          break;
        case "com.android.dynamic-feature":
          gradleContextBuilder.setPluginType(GradleContext.PluginType.DYNAMIC_FEATURE);
          break;
        case "com.android.feature":
          gradleContextBuilder.setPluginType(GradleContext.PluginType.FEATURE);
          break;
        default:
          // there are other plugins that can be applied, ignore them.
          break;
      }
    } else if (parent != null && parent.equals("dependencies")
        && (statement.equals("implementation")
            || statement.equals("api")
            || statement.equals("compile"))) {
      String group = "";
      String name = "";
      String version = "";
      for (Map.Entry<String, String> entry : namedArguments.entrySet()) {
        switch (entry.getKey()) {
          case "group":
            group = entry.getValue();
            break;
          case "name":
            name = entry.getValue();
            break;
          case "version":
            version = entry.getValue();
            break;
          default:
            break;
        }
      }
      if (!group.isEmpty() && !name.isEmpty() && !version.isEmpty()) {
        addDependencyToGradleContext(group, name, version);
      }
    }
  }

  private static String getText(ASTNode node, String content) {
    Offsets offset = getTextOffsets(node, content);
    return content.substring(offset.getStart(), offset.getEnd());
  }

  private static Offsets getTextOffsets(ASTNode node, String content) {
    if (node.getLastLineNumber() == -1 && node instanceof TupleExpression) {
      // Workaround: TupleExpressions yield bogus offsets, so use its
      // children instead
      TupleExpression exp = (TupleExpression) node;
      List<Expression> expressions = exp.getExpressions();
      if (!expressions.isEmpty()) {
        return Offsets.create(
            getTextOffsets(expressions.get(0), content).getStart(),
            getTextOffsets(expressions.get(expressions.size() - 1), content).getEnd());
      }
    }

    if (node instanceof ArgumentListExpression) {
      List<Expression> expressions = ((ArgumentListExpression) node).getExpressions();
      if (expressions.size() == 1) {
        return getTextOffsets(expressions.get(0), content);
      }
    }

    int start = 0;
    int end = content.length();
    int line = 1;
    int startLine = node.getLineNumber();
    int startColumn = node.getColumnNumber();
    int endLine = node.getLastLineNumber();
    int endColumn = node.getLastColumnNumber();
    int column = 1;
    for (int index = 0, len = end; index < len; index++) {
      if (line == startLine && column == startColumn) {
        start = index;
      }
      if (line == endLine && column == endColumn) {
        end = index;
        break;
      }

      char c = content.charAt(index);
      if (c == '\n') {
        line++;
        column = 1;
      } else {
        column++;
      }
    }
    return Offsets.create(start, end);
  }

  private int getSdkVersion(String value) {
    int version = defaultMinSdkVersion;
    if (isStringLiteral(value)) {
      String codeName = getStringLiteralValue(value);
      if (codeName != null) {
        if (isNumberString(codeName)) {
          return getIntLiteralValue(codeName, defaultMinSdkVersion);
        }
      }
    } else {
      version = getIntLiteralValue(value, defaultMinSdkVersion);
    }
    return version;
  }

  private static boolean isNumberString(String token) {
    if (token == null || token.isEmpty()) {
      return false;
    }
    for (int index = 0; index < token.length(); ++index) {
      if (!Character.isDigit(token.charAt(index))) {
        return false;
      }
    }
    return true;
  }

  private static boolean isStringLiteral(String token) {
    return (token.startsWith("\"") && token.endsWith("\""))
        || (token.startsWith("'") && token.endsWith("'"));
  }

  private static String getStringLiteralValue(String value) {
    if (value.length() > 2
        && ((value.startsWith("'") && value.endsWith("'"))
            || (value.startsWith("\"") && value.endsWith("\"")))) {
      return value.substring(1, value.length() - 1);
    }
    return null;
  }

  private static int getIntLiteralValue(String value, int defaultValue) {
    try {
      return Integer.parseInt(value);
    } catch (NumberFormatException e) {
      return defaultValue;
    }
  }

  private static boolean isValidDependencyString(List<String> dependency) {
    return dependency.size() >= 3;
  }

  private void addDependencyToGradleContext(String group, String name, String version) {
    dependencySet.add(
        Library.newBuilder()
            .setMavenLibrary(
                MavenLibrary.newBuilder()
                    .setGroupId(group)
                    .setArtifactId(name)
                    .setVersion(version)
                    .build())
            .build());
  }

  /** The start and end string offsets, for a valid substring within the content being parsed. */
  @AutoValue
  abstract static class Offsets {

    public static Offsets create(int start, int end) {
      return new AutoValue_GroovyGradleParser_Offsets.Builder().setStart(start).setEnd(end).build();
    }

    public abstract int getStart();

    public abstract int getEnd();

    /** Builder for the {@link Offsets}. */
    @AutoValue.Builder
    abstract static class Builder {

      public abstract Builder setStart(int start);

      public abstract Builder setEnd(int end);

      public abstract Offsets build();
    }
  }
}