// Copyright 2022 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 static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.devtools.build.lib.packages.RuleClass.Builder.STARLARK_BUILD_SETTING_DEFAULT_ATTR_NAME;

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.analysis.starlark.StarlarkBuildSettingsDetailsValue;
import com.google.devtools.build.lib.analysis.starlark.StarlarkTransition.TransitionException;
import com.google.devtools.build.lib.cmdline.Label;
import com.google.devtools.build.lib.cmdline.PackageIdentifier;
import com.google.devtools.build.lib.packages.BuildType.SelectorList;
import com.google.devtools.build.lib.packages.NoSuchPackageException;
import com.google.devtools.build.lib.packages.NoSuchTargetException;
import com.google.devtools.build.lib.packages.Package;
import com.google.devtools.build.lib.packages.Rule;
import com.google.devtools.build.lib.packages.Target;
import com.google.devtools.build.lib.packages.Type;
import com.google.devtools.build.skyframe.SkyFunction;
import com.google.devtools.build.skyframe.SkyFunctionException;
import com.google.devtools.build.skyframe.SkyKey;
import com.google.devtools.build.skyframe.SkyValue;
import com.google.devtools.build.skyframe.SkyframeLookupResult;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;

/** A builder for {@link StarlarkBuildSettingsDetailsValue} instances. */
final class StarlarkBuildSettingsDetailsFunction implements SkyFunction {

  // Use the plain strings rather than reaching into the Alias class and adding a dependency edge.
  // TODO(blaze-configurability-team): We can probably afford the edge now that this is
  //   inside of skyframe_cluster.
  private static final String ALIAS_RULE_NAME = "alias";
  private static final String ALIAS_ACTUAL_ATTRIBUTE_NAME = "actual";

  StarlarkBuildSettingsDetailsFunction() {}

  @Override
  @Nullable
  public SkyValue compute(SkyKey skyKey, Environment env)
      throws InterruptedException, StarlarkBuildSettingsDetailsException {
    StarlarkBuildSettingsDetailsValue.Key key = (StarlarkBuildSettingsDetailsValue.Key) skyKey;

    // Ideally, callers would bypass StarlarkBuildSettingsDetailsFunction entirely when the
    // key is empty but provide a fast escape here just in case.
    if (key.buildSettings().isEmpty()) {
      return StarlarkBuildSettingsDetailsValue.EMPTY;
    }

    try {
      ImmutableMap<PackageIdentifier, PackageValue> buildSettingPackages =
          getBuildSettingPackages(env, key.buildSettings());
      if (buildSettingPackages == null) {
        return null;
      }

      // Each setting is unique so don't need a merge function.
      ImmutableMap<Label, Rule> rawSettingToActualRule =
          key.buildSettings().stream()
              .collect(
                  toImmutableMap(
                      setting -> setting,
                      setting -> getActual(buildSettingPackages, setting).getAssociatedRule()));
      ImmutableSet<Rule> actualRules = ImmutableSet.copyOf(rawSettingToActualRule.values());

      // Calculate info based on the actual rules
      // Different rules have different labels so don't need a merge function
      ImmutableMap<Label, Object> buildSettingToDefault =
          actualRules.stream()
              .collect(
                  toImmutableMap(
                      Rule::getLabel,
                      rule -> {
                        if (rule.getRuleClassObject().getBuildSetting().allowsMultiple()) {
                          return ImmutableList.of(
                              rule.getAttr(STARLARK_BUILD_SETTING_DEFAULT_ATTR_NAME));
                        }
                        return rule.getAttr(STARLARK_BUILD_SETTING_DEFAULT_ATTR_NAME);
                      }));
      ImmutableMap<Label, Type<?>> buildSettingToType =
          actualRules.stream()
              .collect(
                  toImmutableMap(
                      Rule::getLabel,
                      rule -> rule.getRuleClassObject().getBuildSetting().getType()));
      ImmutableSet<Label> buildSettingIsAllowsMultiple =
          actualRules.stream()
              .filter(rule -> rule.getRuleClassObject().getBuildSetting().allowsMultiple())
              .map(Rule::getLabel)
              .collect(toImmutableSet());

      // Calculate the alias table (filtering out non-aliases!)
      ImmutableMap<Label, Label> aliasToActual =
          rawSettingToActualRule.entrySet().stream()
              .filter(entry -> !entry.getKey().equals(entry.getValue().getLabel()))
              .collect(toImmutableMap(Map.Entry::getKey, entry -> entry.getValue().getLabel()));

      return StarlarkBuildSettingsDetailsValue.create(
          buildSettingToDefault, buildSettingToType, buildSettingIsAllowsMultiple, aliasToActual);

    } catch (TransitionException e) {
      throw new StarlarkBuildSettingsDetailsException(e);
    }
  }

  /**
   * Given a {@link ConfigurationTransition} find all build settings read or set by the transition
   * and load their packages.
   *
   * <p>In the case that build settings are referred to by aliases, we do a couple loops of package
   * loading. We generally don't expect build settings to be aliased multiple times so we don't
   * expect this while loop (and relevant null return) to happen more than two or three times (and
   * usually only once).
   *
   * @return the package keys and values of build settings or null if not all packages are
   *     available. if not null, and some build settings are referenced by alias, the returned map
   *     will include both alias and actual packages to allow for alias chain following at a later
   *     state.
   */
  @Nullable
  private static ImmutableMap<PackageIdentifier, PackageValue> getBuildSettingPackages(
      SkyFunction.Environment env, ImmutableSet<Label> buildSettings)
      throws InterruptedException, TransitionException {
    HashMap<PackageIdentifier, PackageValue> buildSettingPackages = new HashMap<>();
    // This happens before cycle detection so keep track of all seen build settings to ensure
    // we don't get stuck in endless loops (e.g. //alias1->//alias2 && //alias2->alias1)
    Set<Label> allSeenBuildSettings = new HashSet<>();
    ImmutableSet<Label> unverifiedBuildSettings = buildSettings;
    while (!unverifiedBuildSettings.isEmpty()) {
      for (Label buildSetting : unverifiedBuildSettings) {
        if (!allSeenBuildSettings.add(buildSetting)) {
          throw new TransitionException(
              String.format(
                  "Dependency cycle involving '%s' detected in aliased build settings",
                  buildSetting));
        }
      }
      ImmutableSet<PackageIdentifier> packageKeys =
          getPackageKeysFromLabels(unverifiedBuildSettings);
      SkyframeLookupResult newlyLoaded = env.getValuesAndExceptions(packageKeys);
      if (env.valuesMissing()) {
        return null;
      }
      for (PackageIdentifier packageKey : packageKeys) {
        try {
          SkyValue skyValue = newlyLoaded.getOrThrow(packageKey, NoSuchPackageException.class);
          if (skyValue == null) {
            return null;
          }
          buildSettingPackages.put(packageKey, (PackageValue) skyValue);
        } catch (NoSuchPackageException e) {
          throw new TransitionException(e);
        }
      }
      unverifiedBuildSettings =
          verifyBuildSettingsAndGetAliases(buildSettingPackages, unverifiedBuildSettings);
    }
    return ImmutableMap.copyOf(buildSettingPackages);
  }

  /** Given a set of labels, return a set of their package {@link PackageIdentifier} keys. */
  private static ImmutableSet<PackageIdentifier> getPackageKeysFromLabels(
      Set<Label> buildSettings) {
    ImmutableSet.Builder<PackageIdentifier> keyBuilder = new ImmutableSet.Builder<>();
    for (Label setting : buildSettings) {
      keyBuilder.add(setting.getPackageIdentifier());
    }
    return keyBuilder.build();
  }

  /**
   * Given a preliminary set of alleged build setting labels and relevant packages, verify that the
   * given {@link Label}s actually correspond to build setting targets.
   *
   * <p>This method is meant to be run in a loop to handle aliased build settings. It also
   * explicitly bans configured 'actual' values for aliased build settings. Since build settings are
   * used to define configuration, there should be better ways to accomplish disparate
   * configurations than configured aliases. Also from a technical standpoint, it's unclear what
   * configuration is correct to use to resolve configured attributes.
   *
   * @param buildSettingPackages packages that include {@code buildSettingsToVerify}'s packages
   * @param buildSettingsToVerify alleged build setting labels
   * @return a set of "actual" labels of any build settings that are referenced by aliases (note -
   *     if the "actual" value of aliasA is aliasB, this method returns aliasB AKA we only follow
   *     one link in the alias chain per call of this method)
   */
  private static ImmutableSet<Label> verifyBuildSettingsAndGetAliases(
      Map<PackageIdentifier, PackageValue> buildSettingPackages, Set<Label> buildSettingsToVerify)
      throws TransitionException {
    ImmutableSet.Builder<Label> actualSettingBuilder = new ImmutableSet.Builder<>();
    for (Label allegedBuildSetting : buildSettingsToVerify) {
      Package buildSettingPackage =
          buildSettingPackages.get(allegedBuildSetting.getPackageIdentifier()).getPackage();
      Preconditions.checkNotNull(
          buildSettingPackage, "Reading build setting for which we don't have a package");
      Target buildSettingTarget;
      try {
        buildSettingTarget = buildSettingPackage.getTarget(allegedBuildSetting.getName());
      } catch (NoSuchTargetException e) {
        throw new TransitionException(e);
      }
      if (buildSettingTarget.getAssociatedRule() == null) {
        throw new TransitionException(
            String.format(
                "attempting to transition on '%s' which is not a build setting",
                allegedBuildSetting));
      }
      if (buildSettingTarget.getAssociatedRule().getRuleClass().equals(ALIAS_RULE_NAME)) {
        Object actualValue =
            buildSettingTarget.getAssociatedRule().getAttr(ALIAS_ACTUAL_ATTRIBUTE_NAME);
        if (actualValue instanceof Label label) {
          actualSettingBuilder.add(label);
          continue;
        } else if (actualValue instanceof SelectorList) {
          // configured "actual" value
          throw new TransitionException(
              String.format(
                  "attempting to transition on aliased build setting '%s', the actual value of"
                      + " which uses select(). Aliased build settings with configured actual values"
                      + " is not supported.",
                  allegedBuildSetting));
        } else {
          throw new IllegalStateException(
              String.format(
                  "Alias target '%s' with 'actual' attr value not equals to "
                      + "a label or a selectorlist",
                  allegedBuildSetting));
        }
      }
      if (!buildSettingTarget.getAssociatedRule().isBuildSetting()) {
        throw new TransitionException(
            String.format(
                "attempting to transition on '%s' which is not a build setting",
                allegedBuildSetting));
      }
    }
    return actualSettingBuilder.build();
  }

  /**
   * Given a {@link Label} that could be an {@link com.google.devtools.build.lib.rules.Alias} and a
   * set of packages, find the actual target that {@link Label} ultimately points to.
   *
   * <p>This method assumes that the packages of the entire {@link
   * com.google.devtools.build.lib.rules.Alias} chain (if {@code setting} is indeed an alias) are
   * included in {@code buildSettingPackages}
   *
   * <p>This checking is likely done in {@link #verifyBuildSettingsAndGetAliases}.
   */
  private static Target getActual(
      Map<PackageIdentifier, PackageValue> buildSettingPackages, Label setting) {
    Target target = getTarget(buildSettingPackages, setting);
    while (target.getAssociatedRule().getRuleClass().equals(ALIAS_RULE_NAME)) {
      target =
          getTarget(
              buildSettingPackages,
              (Label) target.getAssociatedRule().getAttr(ALIAS_ACTUAL_ATTRIBUTE_NAME));
    }
    return target;
  }

  /**
   * Return a target given its label and a set of package values we know to contain the target.
   *
   * <p>This method is essentially a wrapper around PackageValue.getTarget.
   *
   * @param buildSettingPackages packages that include {@code setting}'s package
   */
  private static Target getTarget(
      Map<PackageIdentifier, PackageValue> buildSettingPackages, Label setting) {
    Package buildSettingPackage =
        buildSettingPackages.get(setting.getPackageIdentifier()).getPackage();
    Preconditions.checkNotNull(
        buildSettingPackage, "Reading build setting for which we don't have a package");
    Target target;
    try {
      target = buildSettingPackage.getTarget(setting.getName());
    } catch (NoSuchTargetException e) {
      // This should never happen, see javadoc.
      throw new IllegalStateException(e);
    }
    return target;
  }

  private static final class StarlarkBuildSettingsDetailsException extends SkyFunctionException {
    StarlarkBuildSettingsDetailsException(Exception e) {
      super(e, Transience.PERSISTENT);
    }
  }
}
