// 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.packages;

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableRangeMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Range;
import com.google.common.collect.RangeMap;
import com.google.devtools.common.options.Converter;
import com.google.devtools.common.options.OptionsParsingException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;

/** Symbolic labels of test timeout. Borrows heavily from {@link TestSize}. */
public enum TestTimeout {

  // These symbolic labels are used in the build files.
  SHORT(60),
  MODERATE(300),
  LONG(900),
  ETERNAL(3600);

  /** Default --test_timeout flag, used when collecting code coverage. */
  // Do not increase these values without consulting b/459811767#comment3.
  public static final String COVERAGE_CMD_TIMEOUT = "--test_timeout=300,600,1200,3600";

  /** Map from test time to suggested TestTimeout. */
  private static final RangeMap<Integer, TestTimeout> SUGGESTED_TIMEOUT;

  /**
   * Map from TestTimeout to fuzzy range.
   *
   * <p>The fuzzy range is used to check whether the actual timeout is close to the upper bound of
   * the current timeout or much smaller than the next shorter timeout. This is used to give
   * suggestions to developers to update their timeouts.
   */
  private static final Map<TestTimeout, Range<Integer>> TIMEOUT_FUZZY_RANGE;

  static {
    // For the largest timeout, cap suggested and fuzzy ranges at one year.
    final int maxTimeout = 365 * 24 * 60 * 60 /* One year */;

    ImmutableRangeMap.Builder<Integer, TestTimeout> suggestedTimeoutBuilder =
        ImmutableRangeMap.builder();
    ImmutableMap.Builder<TestTimeout, Range<Integer>> timeoutFuzzyRangeBuilder =
        ImmutableMap.builder();

    int previousMaxSuggested = 0;
    int previousTimeout = 0;

    Iterator<TestTimeout> timeoutIterator = Arrays.asList(values()).iterator();
    while (timeoutIterator.hasNext()) {
      TestTimeout timeout = timeoutIterator.next();

      // Set up time ranges for suggested timeouts and fuzzy timeouts. Fuzzy timeout ranges should
      // be looser than suggested timeout ranges in order to make sure that after a test size is
      // adjusted, it's difficult for normal time variance to push it outside the fuzzy timeout
      // range.

      // This should be exactly the previous max because there should be exactly one suggested
      // timeout for any given time.
      final int minSuggested = previousMaxSuggested;
      // Only suggest timeouts that are less than 75% of the actual timeout (unless there are no
      // higher timeouts). This should be low enough to prevent suggested times from causing test
      // timeout flakiness.
      final int maxSuggested =
          timeoutIterator.hasNext() ? (int) (timeout.timeout * 0.75) : maxTimeout;

      // Set fuzzy minimum timeout to half the previous timeout. If the test is that fast, it should
      // be safe to use the shorter timeout.
      final int minFuzzy = previousTimeout / 2;
      // Set fuzzy maximum timeout to 90% of the timeout. A test this close to the limit can easily
      // become timeout flaky.
      final int maxFuzzy = timeoutIterator.hasNext() ? (int) (timeout.timeout * 0.9) : maxTimeout;

      timeoutFuzzyRangeBuilder.put(timeout, Range.closedOpen(minFuzzy, maxFuzzy));

      suggestedTimeoutBuilder.put(Range.closedOpen(minSuggested, maxSuggested), timeout);

      previousMaxSuggested = maxSuggested;
      previousTimeout = timeout.timeout;
    }
    SUGGESTED_TIMEOUT = suggestedTimeoutBuilder.build();
    TIMEOUT_FUZZY_RANGE = timeoutFuzzyRangeBuilder.buildOrThrow();
  }

  private final int timeout;

  TestTimeout(int timeout) {
    this.timeout = timeout;
  }

  /**
   * Returns the enum associated with a test's timeout or null if the tag is not lower case or an
   * unknown size.
   */
  @Nullable
  public static TestTimeout getTestTimeout(String attr) {
    if (!attr.equals(attr.toLowerCase())) {
      return null;
    }
    try {
      return TestTimeout.valueOf(attr.toUpperCase(Locale.ENGLISH));
    } catch (IllegalArgumentException e) {
      return null;
    }
  }

  /**
   * Returns test timeout of the given target using explicitly specified timeout or default through
   * the size label's associated default or null if the target is not a test.
   */
  @Nullable
  public static TestTimeout getTestTimeout(Rule target) {
    String attr = NonconfigurableAttributeMapper.attributeOrNull(target, "timeout", Type.STRING);
    if (attr == null) {
      // The target is not a test. This is reached by serialization code as it tries to serialize
      // essential target fields. There's not enough context there to pre-determine whether a
      // target is a test or not, so it simply serializes any String timeout field.
      //
      // TODO(b/297857068): refactor ConfiguredTargetAndData and remove this branch.
      return null;
    }
    if (!attr.equals(attr.toLowerCase())) {
      return null; // attribute values must be lowercase
    }
    try {
      return TestTimeout.valueOf(attr.toUpperCase(Locale.ENGLISH));
    } catch (IllegalArgumentException e) {
      return null;
    }
  }

  @Override
  public String toString() {
    return super.toString().toLowerCase();
  }

  /** We print to upper case to make the test timeout warnings more readable. */
  public String prettyPrint() {
    return super.toString().toUpperCase();
  }

  @Deprecated // use getTimeout instead
  public int getTimeoutSeconds() {
    return timeout;
  }

  public Duration getTimeout() {
    return Duration.ofSeconds(timeout);
  }

  /**
   * Returns true iff the given time is not close to the upper bound timeout and is so short that it
   * should be assigned a different timeout.
   *
   * <p>This is used to give suggestions to developers to update their timeouts. If this returns
   * true, a more reasonable timeout can be selected with {@link #getSuggestedTestTimeout(int)}
   */
  public boolean isInRangeFuzzy(int timeInSeconds) {
    return TIMEOUT_FUZZY_RANGE.get(this).contains(timeInSeconds);
  }

  /**
   * Returns suggested test size for the given time in seconds.
   *
   * <p>Will suggest times that are unlikely to result in timeout flakiness even if the test has a
   * significant amount of time variance.
   */
  public static TestTimeout getSuggestedTestTimeout(int timeInSeconds) {
    return SUGGESTED_TIMEOUT.get(timeInSeconds);
  }

  /** Converter for the --test_timeout option. */
  public static class TestTimeoutConverter
      extends Converter.Contextless<Map<TestTimeout, Duration>> {
    public TestTimeoutConverter() {}

    @Override
    public Map<TestTimeout, Duration> convert(String input) throws OptionsParsingException {
      List<Duration> values = new ArrayList<>();
      for (String token : Splitter.on(',').limit(6).split(input)) {
        // Handle the case of "2," which is accepted as legal... Because Splitter.split is lazy,
        // there's no way of knowing if an empty string is a trailing or an intermediate one,
        // so we can't fully emulate String.split(String, 0).
        if (!token.isEmpty() || values.size() > 1) {
          try {
            values.add(Duration.ofSeconds(Integer.parseInt(token)));
          } catch (NumberFormatException e) {
            throw new OptionsParsingException("'" + input + "' is not an int", e);
          }
        }
      }
      EnumMap<TestTimeout, Duration> timeouts = Maps.newEnumMap(TestTimeout.class);
      if (values.size() == 1) {
        timeouts.put(SHORT, values.get(0));
        timeouts.put(MODERATE, values.get(0));
        timeouts.put(LONG, values.get(0));
        timeouts.put(ETERNAL, values.get(0));
      } else if (values.size() == 4) {
        timeouts.put(SHORT, values.get(0));
        timeouts.put(MODERATE, values.get(1));
        timeouts.put(LONG, values.get(2));
        timeouts.put(ETERNAL, values.get(3));
      } else {
        throw new OptionsParsingException("Invalid number of comma-separated entries");
      }
      for (TestTimeout label : values()) {
        if (!timeouts.containsKey(label) || timeouts.get(label).compareTo(Duration.ZERO) <= 0) {
          timeouts.put(label, label.getTimeout());
        }
      }
      return timeouts;
    }

    @Override
    public String getTypeDescription() {
      return "a single integer or comma-separated list of 4 integers";
    }
  }

  /** Converter for the --test_timeout_filters option. */
  public static class TestTimeoutFilterConverter extends EnumFilterConverter<TestTimeout> {
    public TestTimeoutFilterConverter() {
      super(TestTimeout.class, "test timeout");
    }

    /**
     * {@inheritDoc}
     *
     * <p>This override is necessary to prevent OptionsData from throwing a "must be assignable from
     * the converter return type" exception. OptionsData doesn't recognize the generic type and
     * actual type are the same.
     */
    @Override
    public final Set<TestTimeout> convert(String input) throws OptionsParsingException {
      return super.convert(input);
    }
  }
}
