// Copyright 2014 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.runtime;

import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.events.EventHandler;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.common.options.OpaqueOptionsData;
import com.google.devtools.common.options.OptionValueDescription;
import com.google.devtools.common.options.OptionsParser;
import com.google.devtools.common.options.OptionsParser.ArgAndFallbackData;
import com.google.devtools.common.options.OptionsParsingException;
import com.google.devtools.common.options.ParsedOptionDescription;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import javax.annotation.Nullable;

/** Encapsulates logic for performing --config option expansion. */
final class ConfigExpander {

  private ConfigExpander() {}

  private static String getPlatformName() {
    switch (OS.getCurrent()) {
      case LINUX:
        return "linux";
      case DARWIN:
        return "macos";
      case WINDOWS:
        return "windows";
      case FREEBSD:
        return "freebsd";
      case OPENBSD:
        return "openbsd";
      default:
        return OS.getCurrent().getCanonicalName();
    }
  }

  /**
   * If --enable_platform_specific_config is true and the corresponding config definition exists, we
   * should enable the platform specific config.
   */
  private static boolean shouldEnablePlatformSpecificConfig(
      OptionValueDescription enablePlatformSpecificConfigDescription,
      ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
      List<String> commandsToParse) {
    if (enablePlatformSpecificConfigDescription == null
        || !(boolean) enablePlatformSpecificConfigDescription.getValue()) {
      return false;
    }

    for (String commandName : commandsToParse) {
      String defaultConfigDef = commandName + ":" + getPlatformName();
      if (commandToRcArgs.containsKey(defaultConfigDef)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Expands --config options present in the requested commands using the options configuration
   * provided in commandToRcArgs.
   *
   * @param eventHandler collects any warnings encountered.
   * @param rcFileNotesConsumer collects any informational messages encountered.
   * @param optionsParser will parse the expanded --config representations.
   * @throws OptionsParsingException if a fatal problem with the configuration is encountered.
   */
  static void expandConfigOptions(
      EventHandler eventHandler,
      ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
      String currentCommand,
      List<String> commandsToParse,
      Consumer<String> rcFileNotesConsumer,
      OptionsParser optionsParser,
      @Nullable OpaqueOptionsData fallbackData)
      throws OptionsParsingException {

    OptionValueDescription configValueDescription =
        optionsParser.getOptionValueDescription("config");
    if (configValueDescription != null && configValueDescription.getCanonicalInstances() != null) {
      // Find the base set of configs. This does not include the config options that might be
      // recursively included.
      ImmutableList<ParsedOptionDescription> configInstances =
          ImmutableList.copyOf(configValueDescription.getCanonicalInstances());

      // Expand the configs that are mentioned in the input. Flatten these expansions before parsing
      // them, to preserve order.
      for (ParsedOptionDescription configInstance : configInstances) {
        String configValueToExpand = (String) configInstance.getConvertedValue();
        var expansion =
            getExpansion(
                eventHandler,
                commandToRcArgs,
                commandsToParse,
                configValueToExpand,
                rcFileNotesConsumer,
                fallbackData);
        var ignoredArgs =
            optionsParser.parseArgsAsExpansionOfOption(
                configInstance,
                String.format("expanded from --config=%s", configValueToExpand),
                expansion);
        if (!ignoredArgs.isEmpty()) {
          rcFileNotesConsumer.accept(
              String.format(
                  "Ignored as unsupported by '%s': %s",
                  currentCommand, Joiner.on(' ').join(ignoredArgs)));
        }
      }
    }

    OptionValueDescription enablePlatformSpecificConfigDescription =
        optionsParser.getOptionValueDescription("enable_platform_specific_config");
    if (shouldEnablePlatformSpecificConfig(
        enablePlatformSpecificConfigDescription, commandToRcArgs, commandsToParse)) {
      var expansion =
          getExpansion(
              eventHandler,
              commandToRcArgs,
              commandsToParse,
              getPlatformName(),
              rcFileNotesConsumer,
              fallbackData);
      ParsedOptionDescription optionToExpand =
          Iterables.getOnlyElement(enablePlatformSpecificConfigDescription.getCanonicalInstances());
      var ignoredArgs =
          optionsParser.parseArgsAsExpansionOfOption(
              optionToExpand, "enabled by --enable_platform_specific_config", expansion);
      if (!ignoredArgs.isEmpty()) {
        rcFileNotesConsumer.accept(
            String.format(
                "Ignored as unsupported by '%s': %s",
                currentCommand, Joiner.on(' ').join(ignoredArgs)));
      }
    }

    // At this point, we've expanded everything, identify duplicates, if any, to warn about
    // re-application.
    List<String> configs = optionsParser.getOptions(CommonCommandOptions.class).configs;
    Set<String> configSet = new HashSet<>();
    LinkedHashSet<String> duplicateConfigs = new LinkedHashSet<>();
    for (String configValue : configs) {
      if (!configSet.add(configValue)) {
        duplicateConfigs.add(configValue);
      }
    }
    if (!duplicateConfigs.isEmpty()) {
      eventHandler.handle(
          Event.warn(
              String.format(
                  "The following configs were expanded more than once: %s. For repeatable flags, "
                      + "repeats are counted twice and may lead to unexpected behavior.",
                  duplicateConfigs)));
    }
  }

  private static List<ArgAndFallbackData> getExpansion(
      EventHandler eventHandler,
      ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
      List<String> commandsToParse,
      String configToExpand,
      Consumer<String> rcFileNotesConsumer,
      @Nullable OpaqueOptionsData fallbackData)
      throws OptionsParsingException {
    LinkedHashSet<String> configAncestorSet = new LinkedHashSet<>();
    configAncestorSet.add(configToExpand);
    List<String> longestChain = new ArrayList<>();
    List<ArgAndFallbackData> finalExpansion =
        getExpansion(
            commandToRcArgs,
            commandsToParse,
            configAncestorSet,
            configToExpand,
            longestChain,
            rcFileNotesConsumer,
            fallbackData);

    // In order to prevent warning about a long chain of 13 configs at the 10, 11, 12, and 13
    // point, we identify the longest chain for this 'high-level' --config found and only warn
    // about it once. This may mean we missed a fork where each branch was independently long
    // enough to warn, but the single warning should convey the message reasonably.
    if (longestChain.size() >= 10) {
      eventHandler.handle(
          Event.warn(
              String.format(
                  "There is a recursive chain of configs %s configs long: %s. This seems "
                      + "excessive, and might be hiding errors.",
                  longestChain.size(), longestChain)));
    }
    return finalExpansion;
  }

  /**
   * @param configAncestorSet is the chain of configs that have led to this one getting expanded.
   *     This should only contain the configs that expanded, recursively, to this one, and should
   *     not contain "siblings," as it is used to detect cycles. {@code build:foo --config=bar},
   *     {@code build:bar --config=foo}, is a cycle, detected because this list will be [foo, bar]
   *     when we find another 'foo' to expand. However, {@code build:foo --config=bar}, {@code
   *     build:foo --config=bar} is not a cycle just because bar is expanded twice, and the 1st bar
   *     should not be in the parents list of the second bar.
   * @param longestChain will be populated with the longest inheritance chain of configs.
   */
  private static List<ArgAndFallbackData> getExpansion(
      ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
      List<String> commandsToParse,
      LinkedHashSet<String> configAncestorSet,
      String configToExpand,
      List<String> longestChain,
      Consumer<String> rcFileNotesConsumer,
      @Nullable OpaqueOptionsData fallbackData)
      throws OptionsParsingException {
    List<ArgAndFallbackData> expansion = new ArrayList<>();
    boolean foundDefinition = false;
    // The expansion order of rc files is first by command priority, and then in the order the
    // rc files were read, respecting import statement placement.
    for (String commandToParse : commandsToParse) {
      String configDef = commandToParse + ":" + configToExpand;
      for (RcChunkOfArgs rcArgs : commandToRcArgs.get(configDef)) {
        foundDefinition = true;
        rcFileNotesConsumer.accept(
            String.format(
                "Found applicable config definition %s in file %s: %s",
                configDef, rcArgs.getRcFile(), String.join(" ", rcArgs.getArgs())));

        // For each arg in the rcARgs chunk, we first check if it is a config, and if so, expand
        // it in place. We avoid cycles by tracking the parents of this config.
        for (String arg : rcArgs.getArgs()) {
          expansion.add(
              new ArgAndFallbackData(
                  arg,
                  commandToParse.equals(BlazeOptionHandler.COMMON_PSEUDO_COMMAND)
                      ? fallbackData
                      : null));
          if (arg.length() >= 8 && arg.substring(0, 8).equals("--config")) {
            // We have a config. Because we don't want to worry about formatting,
            // we will only accept --config=value, and will not accept value on a following line.
            int charOfConfigValue = arg.indexOf('=');
            if (charOfConfigValue < 0) {
              throw new OptionsParsingException(
                  String.format(
                      "In file %s, the definition of config %s expands to another config "
                          + "that either has no value or is not in the form --config=value. For "
                          + "recursive config definitions, please do not provide the value in a "
                          + "separate token, such as in the form '--config value'.",
                      rcArgs.getRcFile(), configToExpand));
            }
            String newConfigValue = arg.substring(charOfConfigValue + 1);
            LinkedHashSet<String> extendedConfigAncestorSet =
                new LinkedHashSet<>(configAncestorSet);
            if (!extendedConfigAncestorSet.add(newConfigValue)) {
              throw new OptionsParsingException(
                  String.format(
                      "Config expansion has a cycle: config value %s expands to itself, "
                          + "see inheritance chain %s",
                      newConfigValue, extendedConfigAncestorSet));
            }
            if (extendedConfigAncestorSet.size() > longestChain.size()) {
              longestChain.clear();
              longestChain.addAll(extendedConfigAncestorSet);
            }

            expansion.addAll(
                getExpansion(
                    commandToRcArgs,
                    commandsToParse,
                    extendedConfigAncestorSet,
                    newConfigValue,
                    longestChain,
                    rcFileNotesConsumer,
                    fallbackData));
          }
        }
      }
    }

    if (!foundDefinition) {
      throw new OptionsParsingException(
          "Config value '" + configToExpand + "' is not defined in any .rc file");
    }
    return expansion;
  }
}
