// Copyright 2015 The Bazel Authors. 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.devtools.build.lib.bazel.rules.android;

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

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.devtools.build.lib.actions.FileValue;
import com.google.devtools.build.lib.analysis.BlazeDirectories;
import com.google.devtools.build.lib.analysis.RuleDefinition;
import com.google.devtools.build.lib.bugreport.BugReport;
import com.google.devtools.build.lib.cmdline.LabelConstants;
import com.google.devtools.build.lib.io.InconsistentFilesystemException;
import com.google.devtools.build.lib.packages.Rule;
import com.google.devtools.build.lib.packages.Type;
import com.google.devtools.build.lib.rules.repository.RepoRecordedInput;
import com.google.devtools.build.lib.rules.repository.RepositoryDirectoryValue;
import com.google.devtools.build.lib.rules.repository.WorkspaceAttributeMapper;
import com.google.devtools.build.lib.skyframe.DirectoryListingValue;
import com.google.devtools.build.lib.skyframe.Dirents;
import com.google.devtools.build.lib.util.ResourceFileLoader;
import com.google.devtools.build.lib.vfs.Dirent;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.Root;
import com.google.devtools.build.lib.vfs.RootedPath;
import com.google.devtools.build.skyframe.SkyFunction.Environment;
import com.google.devtools.build.skyframe.SkyFunctionException.Transience;
import com.google.devtools.build.skyframe.SkyKey;
import com.google.devtools.build.skyframe.SkyValue;
import com.google.devtools.build.skyframe.SkyframeLookupResult;
import java.io.IOException;
import java.io.InputStream;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import javax.annotation.Nullable;
import net.starlark.java.eval.EvalException;
import net.starlark.java.eval.Starlark;

/** Implementation of the {@code android_sdk_repository} rule. */
public class AndroidSdkRepositoryFunction extends AndroidRepositoryFunction {

  static final class AndroidRevision implements Comparable<AndroidRevision> {

    private final String original;
    private final int major;
    private final int minor;
    private final int micro;
    private final int previewType;
    private final int preview;

    private AndroidRevision(
        String original, int major, int minor, int micro, int previewType, int preview) {
      this.original = original;
      this.major = major;
      this.minor = minor;
      this.micro = micro;
      this.previewType = previewType;
      this.preview = preview;
    }

    static AndroidRevision parse(String revisionString) {
      revisionString = revisionString.trim();
      String[] revisionAndPreview = revisionString.split("-|([ ]+)", 2);
      if (revisionAndPreview.length < 1) {
        throw new NumberFormatException("Invalid revision: " + revisionString);
      }

      Iterator<String> revision = Splitter.on('.').split(revisionAndPreview[0]).iterator();

      if (!revision.hasNext()) {
        throw new NumberFormatException("Invalid revision: " + revisionString);
      }

      int major = Integer.parseInt(revision.next());
      int minor = 0;
      int micro = 0;
      // Revisions without preview are larger than those with, so set these to MAX_VALUE and
      // if there's a preview value, these will get set below.
      int previewType = Integer.MAX_VALUE;
      int preview = Integer.MAX_VALUE;

      if (revision.hasNext()) {
        minor = Integer.parseInt(revision.next());
      }

      if (revision.hasNext()) {
        micro = Integer.parseInt(revision.next());
      }

      if (revisionAndPreview.length == 2) {
        String p = revisionAndPreview[1];
        if (p.contains("rc")) {
          previewType = 3;
        } else if (p.contains("beta")) {
          previewType = 2;
        } else if (p.contains("alpha")) {
          previewType = 1;
        } else {
          throw new NumberFormatException("Invalid revision: " + revisionString);
        }
        p = p.replace("rc", "").replace("alpha", "").replace("beta", "");
        preview = Integer.parseInt(p);
      }
      return new AndroidRevision(revisionString, major, minor, micro, previewType, preview);
    }

    @Override
    public int compareTo(AndroidRevision other) {
      int major = this.major - other.major;
      if (major != 0) {
        return major;
      }

      int minor = this.minor - other.minor;
      if (minor != 0) {
        return minor;
      }

      int micro = this.micro - other.micro;
      if (micro != 0) {
        return micro;
      }

      int previewType = this.previewType - other.previewType;
      if (previewType != 0) {
        return previewType;
      }

      int preview = this.preview - other.preview;
      if (preview != 0) {
        return preview;
      }

      return 0;
    }

    @Override
    public String toString() {
      return original;
    }
  }

  private static final PathFragment BUILD_TOOLS_DIR = PathFragment.create("build-tools");
  private static final PathFragment PLATFORMS_DIR = PathFragment.create("platforms");
  private static final PathFragment SYSTEM_IMAGES_DIR = PathFragment.create("system-images");
  private static final AndroidRevision MIN_BUILD_TOOLS_REVISION = AndroidRevision.parse("30.0.0");
  private static final String PATH_ENV_VAR = "ANDROID_HOME";
  private static final ImmutableSet<String> PATH_ENV_VAR_AS_SET = ImmutableSet.of(PATH_ENV_VAR);
  private static final ImmutableList<String> LOCAL_MAVEN_REPOSITORIES =
      ImmutableList.of(
          "extras/android/m2repository", "extras/google/m2repository", "extras/m2repository");

  @Override
  public boolean isLocal(Rule rule) {
    return true;
  }

  @Override
  public boolean verifyRecordedInputs(
      Rule rule,
      BlazeDirectories directories,
      Map<RepoRecordedInput, String> recordedInputValues,
      Environment env)
      throws InterruptedException {
    WorkspaceAttributeMapper attributes = WorkspaceAttributeMapper.of(rule);
    if (attributes.isAttributeValueExplicitlySpecified("path")) {
      return true;
    }
    return super.verifyRecordedInputs(rule, directories, recordedInputValues, env);
  }

  @Override
  @Nullable
  public RepositoryDirectoryValue.Builder fetch(
      Rule rule,
      final Path outputDirectory,
      BlazeDirectories directories,
      Environment env,
      Map<RepoRecordedInput, String> recordedInputValues,
      SkyKey key)
      throws RepositoryFunctionException, InterruptedException {
    Map<String, String> environ =
        declareEnvironmentDependencies(recordedInputValues, env, PATH_ENV_VAR_AS_SET);
    if (environ == null) {
      return null;
    }
    try {
      outputDirectory.createDirectoryAndParents();
      FileSystemUtils.createEmptyFile(outputDirectory.getRelative(LabelConstants.REPO_FILE_NAME));
    } catch (IOException e) {
      throw new RepositoryFunctionException(e, Transience.TRANSIENT);
    }
    WorkspaceAttributeMapper attributes = WorkspaceAttributeMapper.of(rule);
    FileSystem fs = directories.getOutputBase().getFileSystem();
    Path androidSdkPath;
    String userDefinedPath = null;
    if (attributes.isAttributeValueExplicitlySpecified("path")) {
      userDefinedPath = getPathAttr(rule);
      androidSdkPath = fs.getPath(getTargetPath(userDefinedPath, directories.getWorkspace()));
    } else if (environ.get(PATH_ENV_VAR) != null) {
      userDefinedPath = environ.get(PATH_ENV_VAR);
      androidSdkPath =
          fs.getPath(getAndroidHomeEnvironmentVar(directories.getWorkspace(), environ));
    } else {
      // Write an empty BUILD file that declares errors when referred to.
      String buildFile = getStringResource("android_sdk_repository_empty_template.txt");
      writeBuildFile(outputDirectory, buildFile);
      return RepositoryDirectoryValue.builder().setPath(outputDirectory);
    }

    if (!symlinkLocalRepositoryContents(outputDirectory, androidSdkPath, userDefinedPath)) {
      return null;
    }

    DirectoryListingValue platformsDirectoryValue =
        getDirectoryListing(androidSdkPath, PLATFORMS_DIR, env);
    if (platformsDirectoryValue == null) {
      return null;
    }

    ImmutableSortedSet<Integer> apiLevels = getApiLevels(platformsDirectoryValue.getDirents());
    if (apiLevels.isEmpty()) {
      throw new RepositoryFunctionException(
          Starlark.errorf(
              "android_sdk_repository requires that at least one Android SDK Platform is"
                  + " installed in the Android SDK. Please install an Android SDK Platform through"
                  + " the Android SDK manager."),
          Transience.PERSISTENT);
    }

    Integer defaultApiLevel;
    if (attributes.isAttributeValueExplicitlySpecified("api_level")) {
      try {
        defaultApiLevel = attributes.get("api_level", Type.INTEGER).toIntUnchecked();
      } catch (EvalException e) {
        throw new RepositoryFunctionException(e, Transience.PERSISTENT);
      }
      if (!apiLevels.contains(defaultApiLevel)) {
        throw new RepositoryFunctionException(
            Starlark.errorf(
                "Android SDK api level %s was requested but it is not installed in the"
                    + " Android SDK at %s. The api levels found were %s. Please choose an"
                    + " available api level or install api level %s from the Android SDK Manager.",
                defaultApiLevel, androidSdkPath, apiLevels, defaultApiLevel),
            Transience.PERSISTENT);
      }
    } else {
      // If the api_level attribute is not explicitly set, we select the highest api level that is
      // available in the SDK.
      defaultApiLevel = apiLevels.first();
    }

    String buildToolsDirectory;
    if (attributes.isAttributeValueExplicitlySpecified("build_tools_version")) {
      try {
        buildToolsDirectory = attributes.get("build_tools_version", Type.STRING);
      } catch (EvalException e) {
        throw new RepositoryFunctionException(e, Transience.PERSISTENT);
      }
    } else {
      // If the build_tools_version attribute is not explicitly set, we select the highest version
      // installed in the SDK.
      DirectoryListingValue directoryValue =
          getDirectoryListing(androidSdkPath, BUILD_TOOLS_DIR, env);
      if (directoryValue == null) {
        return null;
      }
      buildToolsDirectory = getNewestBuildToolsDirectory(directoryValue.getDirents());
    }

    // android_sdk_repository.build_tools_version is technically actually the name of the
    // directory in $sdk/build-tools. Most of the time this is just the actual build tools
    // version, but for preview build tools, the directory is something like 24.0.0-preview, and
    // the actual version is something like "24 rc3". The android_sdk rule in the template needs
    // the real version.
    String buildToolsVersion;
    if (buildToolsDirectory.contains("-preview")) {

      Properties sourceProperties =
          getBuildToolsSourceProperties(outputDirectory, buildToolsDirectory, env);
      if (env.valuesMissing()) {
        return null;
      }

      buildToolsVersion = sourceProperties.getProperty("Pkg.Revision");

    } else {
      buildToolsVersion = buildToolsDirectory;
    }

    try {
      assertValidBuildToolsVersion(buildToolsVersion);
    } catch (EvalException e) {
      throw new RepositoryFunctionException(e, Transience.PERSISTENT);
    }

    ImmutableSortedSet<PathFragment> androidDeviceSystemImageDirs =
        getAndroidDeviceSystemImageDirs(androidSdkPath, env);
    if (androidDeviceSystemImageDirs == null) {
      return null;
    }

    StringBuilder systemImageDirsList = new StringBuilder();
    for (PathFragment systemImageDir : androidDeviceSystemImageDirs) {
      systemImageDirsList.append(String.format("        \"%s\",\n", systemImageDir));
    }

    String template = getStringResource("android_sdk_repository_template.txt");

    String buildFile =
        template
            .replace("%repository_name%", rule.getName())
            .replace("%build_tools_version%", buildToolsVersion)
            .replace("%build_tools_directory%", buildToolsDirectory)
            .replace("%api_levels%", Iterables.toString(apiLevels))
            .replace("%default_api_level%", String.valueOf(defaultApiLevel))
            .replace("%system_image_dirs%", systemImageDirsList);

    // All local maven repositories that are shipped in the Android SDK.
    // TODO(ajmichael): Create SkyKeys so that if the SDK changes, this function will get rerun.
    Iterable<Path> localMavenRepositories =
        Lists.transform(LOCAL_MAVEN_REPOSITORIES, outputDirectory::getRelative);
    try {
      SdkMavenRepository sdkExtrasRepository =
          SdkMavenRepository.create(Iterables.filter(localMavenRepositories, Path::isDirectory));
      sdkExtrasRepository.writeBuildFiles(outputDirectory);
      buildFile =
          buildFile.replace(
              "%exported_files%", sdkExtrasRepository.getExportsFiles(outputDirectory));
    } catch (IOException e) {
      throw new RepositoryFunctionException(e, Transience.TRANSIENT);
    }

    writeBuildFile(outputDirectory, buildFile);
    return RepositoryDirectoryValue.builder().setPath(outputDirectory);
  }

  @Override
  public Class<? extends RuleDefinition> getRuleDefinition() {
    return AndroidSdkRepositoryRule.class;
  }

  private static PathFragment getAndroidHomeEnvironmentVar(
      Path workspace, Map<String, String> env) {
    return workspace.getRelative(PathFragment.create(env.get(PATH_ENV_VAR))).asFragment();
  }

  private static String getStringResource(String name) {
    try {
      return ResourceFileLoader.loadResource(AndroidSdkRepositoryFunction.class, name);
    } catch (IOException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Gets the newest build tools directory according to {@link Revision}.
   *
   * @throws RepositoryFunctionException if none of the buildToolsDirectories are directories and
   *     have names that are parsable as build tools version.
   */
  private static String getNewestBuildToolsDirectory(Dirents buildToolsDirectories)
      throws RepositoryFunctionException {
    String newestBuildToolsDirectory = null;
    AndroidRevision newestBuildToolsRevision = null;
    for (Dirent buildToolsDirectory : buildToolsDirectories) {
      if (buildToolsDirectory.getType() != Dirent.Type.DIRECTORY) {
        continue;
      }
      try {
        AndroidRevision buildToolsRevision = AndroidRevision.parse(buildToolsDirectory.getName());
        if (newestBuildToolsRevision == null
            || buildToolsRevision.compareTo(newestBuildToolsRevision) > 0) {
          newestBuildToolsDirectory = buildToolsDirectory.getName();
          newestBuildToolsRevision = buildToolsRevision;
        }
      } catch (NumberFormatException e) {
        // Ignore unparsable build tools directories.
      }
    }
    if (newestBuildToolsDirectory == null) {
      throw new RepositoryFunctionException(
          Starlark.errorf(
              "Bazel requires Android build tools version %s or newer but none are"
                  + " installed. Please install a recent version through the Android SDK manager.",
              MIN_BUILD_TOOLS_REVISION),
          Transience.PERSISTENT);
    }
    return newestBuildToolsDirectory;
  }

  private static Properties getBuildToolsSourceProperties(
      Path directory, String buildToolsDirectory, Environment env)
      throws RepositoryFunctionException, InterruptedException {

    Path sourcePropertiesFilePath =
        directory.getRelative("build-tools/" + buildToolsDirectory + "/source.properties");

    SkyKey releaseFileKey =
        FileValue.key(RootedPath.toRootedPath(Root.fromPath(directory), sourcePropertiesFilePath));

    try {
      env.getValueOrThrow(releaseFileKey, IOException.class);

      Properties properties = new Properties();
      try (InputStream in = sourcePropertiesFilePath.getInputStream()) {
        properties.load(in);
      }
      return properties;
    } catch (IOException e) {
      String error =
          String.format(
              "Could not read %s in Android SDK: %s", sourcePropertiesFilePath, e.getMessage());
      throw new RepositoryFunctionException(new IOException(error), Transience.PERSISTENT);
    }
  }

  private static void assertValidBuildToolsVersion(String buildToolsVersion) throws EvalException {
    try {
      AndroidRevision buildToolsRevision = AndroidRevision.parse(buildToolsVersion);
      if (buildToolsRevision.compareTo(MIN_BUILD_TOOLS_REVISION) < 0) {
        throw Starlark.errorf(
            "Bazel requires Android build tools version %s or newer, %s was provided",
            MIN_BUILD_TOOLS_REVISION, buildToolsRevision);
      }
    } catch (NumberFormatException e) {
      throw Starlark.errorf(
          "Bazel does not recognize Android build tools version %s: %s",
          buildToolsVersion, e.getMessage());
    }
  }

  /**
   * Gets PathFragments for /sdk/system-images/*&#47;*&#47;*, which are the directories in the SDK
   * that contain system images needed for android_device.
   *
   * <p>If the sdk/system-images directory does not exist, an empty set is returned.
   */
  @Nullable
  private ImmutableSortedSet<PathFragment> getAndroidDeviceSystemImageDirs(
      Path androidSdkPath, Environment env)
      throws RepositoryFunctionException, InterruptedException {
    if (!androidSdkPath.getRelative(SYSTEM_IMAGES_DIR).exists()) {
      return ImmutableSortedSet.of();
    }
    DirectoryListingValue systemImagesDirectoryValue =
        getDirectoryListing(androidSdkPath, SYSTEM_IMAGES_DIR, env);
    if (systemImagesDirectoryValue == null) {
      return null;
    }
    ImmutableMap<PathFragment, DirectoryListingValue> apiLevelSystemImageDirs =
        getSubdirectoryListingValues(
            androidSdkPath, SYSTEM_IMAGES_DIR, systemImagesDirectoryValue, env);
    if (apiLevelSystemImageDirs == null) {
      return null;
    }

    ImmutableSortedSet.Builder<PathFragment> pathFragments = ImmutableSortedSet.naturalOrder();
    for (PathFragment apiLevelDir : apiLevelSystemImageDirs.keySet()) {
      ImmutableMap<PathFragment, DirectoryListingValue> apiTypeSystemImageDirs =
          getSubdirectoryListingValues(
              androidSdkPath, apiLevelDir, apiLevelSystemImageDirs.get(apiLevelDir), env);
      if (apiTypeSystemImageDirs == null) {
        return null;
      }
      for (PathFragment apiTypeDir : apiTypeSystemImageDirs.keySet()) {
        for (Dirent architectureSystemImageDir :
            apiTypeSystemImageDirs.get(apiTypeDir).getDirents()) {
          pathFragments.add(apiTypeDir.getRelative(architectureSystemImageDir.getName()));
        }
      }
    }
    return pathFragments.build();
  }

  /**
   * Gets DirectoryListingValues for subdirectories of the directory or returns null.
   *
   * <p>Ignores all non-directory files.
   */
  @Nullable
  private static ImmutableMap<PathFragment, DirectoryListingValue> getSubdirectoryListingValues(
      final Path root, final PathFragment path, DirectoryListingValue directory, Environment env)
      throws RepositoryFunctionException, InterruptedException {
    Map<PathFragment, SkyKey> skyKeysForSubdirectoryLookups =
        stream(directory.getDirents())
            .filter(dirent -> dirent.getType().equals(Dirent.Type.DIRECTORY))
            .collect(
                toImmutableMap(
                    input -> path.getRelative(input.getName()),
                    input ->
                        DirectoryListingValue.key(
                            RootedPath.toRootedPath(
                                Root.fromPath(root),
                                root.getRelative(path).getRelative(input.getName())))));

    SkyframeLookupResult values =
        env.getValuesAndExceptions(skyKeysForSubdirectoryLookups.values());
    boolean valuesMissing = env.valuesMissing();

    ImmutableMap.Builder<PathFragment, DirectoryListingValue> directoryListingValues =
        new ImmutableMap.Builder<>();
    for (PathFragment pathFragment : skyKeysForSubdirectoryLookups.keySet()) {
      try {
        SkyKey skyKey = skyKeysForSubdirectoryLookups.get(pathFragment);
        SkyValue skyValue = values.getOrThrow(skyKey, InconsistentFilesystemException.class);
        if (skyValue == null) {
          if (!valuesMissing) {
            BugReport.sendBugReport(
                new IllegalStateException(
                    "SkyValue " + skyKey + " was missing, this should never happern"));
          }
          return null;
        }
        directoryListingValues.put(pathFragment, (DirectoryListingValue) skyValue);
      } catch (InconsistentFilesystemException e) {
        throw new RepositoryFunctionException(e, Transience.PERSISTENT);
      }
    }
    return directoryListingValues.buildOrThrow();
  }

  @Override
  protected void throwInvalidPathException(Path path, Exception e)
      throws RepositoryFunctionException {
    throw new RepositoryFunctionException(
        new IOException(
            String.format(
                "%s Unable to read the Android SDK at %s, the path may be invalid. Is "
                    + "the path in android_sdk_repository() or %s set correctly? If the path is "
                    + "correct, the contents in the Android SDK directory may have been modified.",
                e.getMessage(), path, PATH_ENV_VAR),
            e),
        Transience.PERSISTENT);
  }
}
