// Copyright 2019 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.skyframe;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.devtools.build.lib.actions.FileValue;
import com.google.devtools.build.lib.actions.MissingInputFileException;
import com.google.devtools.build.lib.analysis.config.BuildOptions;
import com.google.devtools.build.lib.cmdline.Label;
import com.google.devtools.build.lib.cmdline.LabelSyntaxException;
import com.google.devtools.build.lib.cmdline.RepositoryName;
import com.google.devtools.build.lib.pkgcache.PathPackageLocator;
import com.google.devtools.build.lib.server.FailureDetails.BuildConfiguration;
import com.google.devtools.build.lib.server.FailureDetails.BuildConfiguration.Code;
import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
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;
import com.google.devtools.build.skyframe.SkyFunctionException;
import com.google.devtools.build.skyframe.SkyKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import net.starlark.java.syntax.Location;

/**
 * Function that reads the contents of a mapping file specified in {@code --platform_mappings} and
 * parses them for use in a {@link PlatformMappingValue}.
 *
 * <p>Note that this class only parses the mapping-file specific format, parsing (and validation) of
 * flags contained therein is left to the invocation of {@link
 * PlatformMappingValue#map(BuildConfigurationValue.Key, BuildOptions)}.
 */
public class PlatformMappingFunction implements SkyFunction {

  @Nullable
  @Override
  public PlatformMappingValue compute(SkyKey skyKey, Environment env)
      throws PlatformMappingException, InterruptedException {
    PlatformMappingValue.Key platformMappingKey = (PlatformMappingValue.Key) skyKey.argument();
    PathFragment workspaceRelativeMappingPath =
        platformMappingKey.getWorkspaceRelativeMappingPath();

    PathPackageLocator pkgLocator = PrecomputedValue.PATH_PACKAGE_LOCATOR.get(env);
    if (pkgLocator == null) {
      return null;
    }

    ImmutableList<Root> pathEntries = pkgLocator.getPathEntries();
    for (Root root : pathEntries) {
      RootedPath rootedMappingPath = RootedPath.toRootedPath(root, workspaceRelativeMappingPath);
      FileValue fileValue = (FileValue) env.getValue(FileValue.key(rootedMappingPath));
      if (fileValue == null) {
        return null;
      }

      if (!fileValue.exists()) {
        continue;
      }
      if (fileValue.isDirectory()) {
        throw new PlatformMappingException(
            new MissingInputFileException(
                createFailureDetail(
                    String.format(
                        "--platform_mappings was set to '%s' relative to the top-level workspace"
                            + " '%s' but that path refers to a directory, not a file",
                        workspaceRelativeMappingPath, root),
                    Code.PLATFORM_MAPPINGS_FILE_IS_DIRECTORY),
                Location.BUILTIN),
            SkyFunctionException.Transience.PERSISTENT);
      }

      Iterable<String> lines;
      try {
        lines =
            FileSystemUtils.readLines(fileValue.realRootedPath().asPath(), StandardCharsets.UTF_8);
      } catch (IOException e) {
        throw new PlatformMappingException(e, SkyFunctionException.Transience.TRANSIENT);
      }

      return new Parser(lines.iterator()).parse().toPlatformMappingValue();
    }

    if (!platformMappingKey.wasExplicitlySetByUser()) {
      // If no flag was passed and the default mapping file does not exist treat this as if the
      // mapping file was empty rather than an error.
      return PlatformMappingValue.EMPTY;
    }
    throw new PlatformMappingException(
        new MissingInputFileException(
            createFailureDetail(
                String.format(
                    "--platform_mappings was set to '%s' but no such file exists relative to the "
                        + "package path roots, '%s'",
                    workspaceRelativeMappingPath, pathEntries),
                Code.PLATFORM_MAPPINGS_FILE_NOT_FOUND),
            Location.BUILTIN),
        SkyFunctionException.Transience.PERSISTENT);
  }

  private static FailureDetail createFailureDetail(String message, Code detailedCode) {
    return FailureDetail.newBuilder()
        .setMessage(message)
        .setBuildConfiguration(BuildConfiguration.newBuilder().setCode(detailedCode))
        .build();
  }

  @Nullable
  @Override
  public String extractTag(SkyKey skyKey) {
    return null;
  }

  @VisibleForTesting
  static class PlatformMappingException extends SkyFunctionException {

    public PlatformMappingException(Exception cause, Transience transience) {
      super(cause, transience);
    }
  }

  @VisibleForTesting
  static class Parser {

    private final Iterator<String> lines;

    /**
     * Using an optional to represent the next line with contents, {@link Optional#empty()} if we
     * reached end of file.
     *
     * <p>Stores the current non-comment, non-empty, non-whitespace line. Don't access the field
     * directly, it can either be "used up" by calling {@link #consume()} or retrieved without
     * moving on by calling {@link #peek()}.
     */
    private Optional<String> line;

    Parser(Iterator<String> lines) {
      this.lines = lines;
    }

    Mappings parse() throws PlatformMappingException {
      goToNextContentLine();

      if (!line.isPresent()) {
        return new Mappings(ImmutableMap.of(), ImmutableMap.of());
      }

      Map<Label, Collection<String>> platformsToFlags = ImmutableMap.of();
      Map<Collection<String>, Label> flagsToPlatforms = ImmutableMap.of();

      if (!peek().equalsIgnoreCase("platforms:") && !peek().equalsIgnoreCase("flags:")) {
        throwParsingException("Expected 'platforms:' or 'flags:' but got " + peek());
      }

      if (peek().equalsIgnoreCase("platforms:")) {
        consume();
        platformsToFlags = platformsToFlags();
      }

      if (line.isPresent()) {
        if (!peek().equalsIgnoreCase("flags:")) {
          throwParsingException("Expected 'flags:' but got " + peek());
        }
        consume();
        flagsToPlatforms = flagsToPlatforms();
      }

      if (line.isPresent()) {
        throwParsingException("Expected end of file but got " + peek());
      }
      return new Mappings(platformsToFlags, flagsToPlatforms);
    }

    private Map<Label, Collection<String>> platformsToFlags() throws PlatformMappingException {
      ImmutableMap.Builder<Label, Collection<String>> platformsToFlags = ImmutableMap.builder();
      while (line.isPresent() && !peek().equalsIgnoreCase("flags:")) {
        Label platform = platform();
        Collection<String> flags = flags();
        platformsToFlags.put(platform, flags);
      }

      try {
        return platformsToFlags.build();
      } catch (IllegalArgumentException e) {
        throw throwParsingException(
            e, "Got duplicate platform entries but each platform key must be unique");
      }
    }

    private Label platform() throws PlatformMappingException {
      if (!line.isPresent()) {
        throwParsingException("Expected platform label but got end of file");
      }
      String label = consume();

      Label platform;
      try {
        ImmutableMap<RepositoryName, RepositoryName> emptyRepositoryMapping = ImmutableMap.of();
        // It is ok for us to use an empty repository mapping in this instance because all platform
        // labels used in the mapping file should be relative to the root repository. Repository
        // mappings however only apply within a repository imported by the root repository.
        platform = Label.parseAbsolute(label, emptyRepositoryMapping);
      } catch (LabelSyntaxException e) {
        throw throwParsingException(e, "Expected platform label but got " + label);
      }
      return platform;
    }

    private Collection<String> flags() throws PlatformMappingException {
      ImmutableSet.Builder<String> flags = ImmutableSet.builder();
      // Note: Short form flags are not supported.
      while (lineIsFlag()) {
        flags.add(consume());
      }
      ImmutableSet<String> parsedFlags = flags.build();
      if (parsedFlags.isEmpty()) {
        if (!line.isPresent()) {
          throwParsingException("Expected a flag but got end of file");
        }
        throwParsingException(
            "Expected a standard format flag (starting with --) but got " + peek());
      }

      return parsedFlags;
    }

    private Map<Collection<String>, Label> flagsToPlatforms() throws PlatformMappingException {
      ImmutableMap.Builder<Collection<String>, Label> flagsToPlatforms = ImmutableMap.builder();
      while (lineIsFlag()) {
        Collection<String> flags = flags();
        Label platform = platform();
        flagsToPlatforms.put(flags, platform);
      }
      try {
        return flagsToPlatforms.build();
      } catch (IllegalArgumentException e) {
        throw throwParsingException(
            e, "Got duplicate flags entries but each flags key must be unique");
      }
    }

    private String consume() {
      Preconditions.checkState(
          line.isPresent(), "Must make sure that a line exists before consuming.");
      String value = line.get();
      goToNextContentLine();
      return value;
    }

    private String peek() {
      Preconditions.checkState(
          line.isPresent(), "Must make sure that a line exists before peeking.");
      return line.get();
    }

    private AssertionError throwParsingException(Exception e, String message)
        throws PlatformMappingException {
      throw new PlatformMappingException(
          new PlatformMappingParsingException(message, e),
          SkyFunctionException.Transience.PERSISTENT);
    }

    private void throwParsingException(String message) throws PlatformMappingException {
      throw new PlatformMappingException(
          new PlatformMappingParsingException(message), SkyFunctionException.Transience.PERSISTENT);
    }

    private boolean lineIsFlag() {
      return line.isPresent() && peek().startsWith("--");
    }

    private void goToNextContentLine() {
      while (lines.hasNext()) {
        String line = lines.next().trim();
        if (line.isEmpty() || line.startsWith("#")) {
          continue;
        }
        this.line = Optional.of(line);
        return;
      }
      line = Optional.empty();
    }
  }

  /**
   * Simple data holder to make testing easier. Only for use internal to this file/tests thereof.
   */
  @VisibleForTesting
  static class Mappings {
    final Map<Label, Collection<String>> platformsToFlags;
    final Map<Collection<String>, Label> flagsToPlatforms;

    Mappings(
        Map<Label, Collection<String>> platformsToFlags,
        Map<Collection<String>, Label> flagsToPlatforms) {
      this.platformsToFlags = platformsToFlags;
      this.flagsToPlatforms = flagsToPlatforms;
    }

    PlatformMappingValue toPlatformMappingValue() {
      return new PlatformMappingValue(platformsToFlags, flagsToPlatforms);
    }
  }

  /**
   * Inner wrapper exception to work around the fact that {@link SkyFunctionException} cannot carry
   * a message of its own.
   */
  private static class PlatformMappingParsingException extends Exception {
    public PlatformMappingParsingException(String message) {
      super(message);
    }

    public PlatformMappingParsingException(String message, Throwable cause) {
      super(message, cause);
    }
  }
}
