// Copyright 2018 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.remote;

import build.bazel.remote.execution.v2.CacheCapabilities;
import build.bazel.remote.execution.v2.CapabilitiesGrpc;
import build.bazel.remote.execution.v2.CapabilitiesGrpc.CapabilitiesBlockingStub;
import build.bazel.remote.execution.v2.DigestFunction;
import build.bazel.remote.execution.v2.ExecutionCapabilities;
import build.bazel.remote.execution.v2.GetCapabilitiesRequest;
import build.bazel.remote.execution.v2.PriorityCapabilities;
import build.bazel.remote.execution.v2.PriorityCapabilities.PriorityRange;
import build.bazel.remote.execution.v2.ServerCapabilities;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.devtools.build.lib.remote.options.RemoteOptions;
import com.google.devtools.build.lib.remote.util.TracingMetadataUtils;
import io.grpc.CallCredentials;
import io.grpc.Context;
import io.grpc.StatusRuntimeException;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;

/** Fetches the ServerCapabilities of the remote execution/cache server. */
class RemoteServerCapabilities {

  @Nullable private final String instanceName;
  private final ReferenceCountedChannel channel;
  @Nullable private final CallCredentials callCredentials;
  private final long callTimeoutSecs;
  private final RemoteRetrier retrier;

  public RemoteServerCapabilities(
      @Nullable String instanceName,
      ReferenceCountedChannel channel,
      @Nullable CallCredentials callCredentials,
      long callTimeoutSecs,
      RemoteRetrier retrier) {
    this.instanceName = instanceName;
    this.channel = channel;
    this.callCredentials = callCredentials;
    this.callTimeoutSecs = callTimeoutSecs;
    this.retrier = retrier;
  }

  private CapabilitiesBlockingStub capabilitiesBlockingStub() {
    return CapabilitiesGrpc.newBlockingStub(channel)
        .withInterceptors(TracingMetadataUtils.attachMetadataFromContextInterceptor())
        .withCallCredentials(callCredentials)
        .withDeadlineAfter(callTimeoutSecs, TimeUnit.SECONDS);
  }

  public ServerCapabilities get(String buildRequestId, String commandId)
      throws IOException, InterruptedException {
    Context withMetadata =
        TracingMetadataUtils.contextWithMetadata(buildRequestId, commandId, "capabilities");
    Context previous = withMetadata.attach();
    try {
      GetCapabilitiesRequest request =
          instanceName == null
              ? GetCapabilitiesRequest.getDefaultInstance()
              : GetCapabilitiesRequest.newBuilder().setInstanceName(instanceName).build();
      return retrier.execute(() -> capabilitiesBlockingStub().getCapabilities(request));
    } catch (StatusRuntimeException e) {
      if (e.getCause() instanceof IOException) {
        throw (IOException) e.getCause();
      }
      throw new IOException(e);
    } finally {
      withMetadata.detach(previous);
    }
  }

  static class ClientServerCompatibilityStatus {

    private final List<String> warnings;
    private final List<String> errors;

    private ClientServerCompatibilityStatus(List<String> warnings, List<String> errors) {
      this.warnings = warnings;
      this.errors = errors;
    }

    static class Builder {
      private final ImmutableList.Builder<String> warnings = ImmutableList.builder();
      private final ImmutableList.Builder<String> errors = ImmutableList.builder();

      public void addWarning(String message) {
        warnings.add(message);
      }

      public void addError(String message) {
        errors.add(message);
      }

      public ClientServerCompatibilityStatus build() {
        return new ClientServerCompatibilityStatus(warnings.build(), errors.build());
      }
    }

    public boolean isOk() {
      return warnings.isEmpty() && errors.isEmpty();
    }

    public List<String> getWarnings() {
      return warnings;
    }

    public List<String> getErrors() {
      return errors;
    }
  }

  private static void checkPriorityInRange(
      int priority,
      String optionName,
      PriorityCapabilities prCap,
      ClientServerCompatibilityStatus.Builder result) {
    if (priority != 0) {
      boolean found = false;
      StringBuilder rangeBuilder = new StringBuilder();
      for (PriorityRange pr : prCap.getPrioritiesList()) {
        rangeBuilder.append(String.format("%d-%d,", pr.getMinPriority(), pr.getMaxPriority()));
        if (pr.getMinPriority() <= priority && priority <= pr.getMaxPriority()) {
          found = true;
          break;
        }
      }
      if (!found) {
        String range = rangeBuilder.toString();
        if (!range.isEmpty()) {
          range = range.substring(0, range.length() - 1);
        }
        result.addError(
            String.format(
                "--%s %d is outside of server supported range %s.", optionName, priority, range));
      }
    }
  }

  public enum ServerCapabilitiesRequirement {
    NONE,
    CACHE,
    EXECUTION,
    EXECUTION_AND_CACHE,
  }

  /** Compare the remote server capabilities with those requested by current execution. */
  public static ClientServerCompatibilityStatus checkClientServerCompatibility(
      ServerCapabilities capabilities,
      RemoteOptions remoteOptions,
      DigestFunction.Value digestFunction,
      ServerCapabilitiesRequirement requirement) {
    ClientServerCompatibilityStatus.Builder result = new ClientServerCompatibilityStatus.Builder();
    boolean shouldCheckExecutionCapabilities =
        (requirement == ServerCapabilitiesRequirement.EXECUTION
            || requirement == ServerCapabilitiesRequirement.EXECUTION_AND_CACHE);
    boolean shouldCheckCacheCapabilities =
        (requirement == ServerCapabilitiesRequirement.CACHE
            || requirement == ServerCapabilitiesRequirement.EXECUTION_AND_CACHE);
    if (!(shouldCheckCacheCapabilities || shouldCheckExecutionCapabilities)) {
      return result.build();
    }

    // Check API version.
    ApiVersion.ServerSupportedStatus st =
        ApiVersion.current.checkServerSupportedVersions(capabilities);
    if (st.isUnsupported()) {
      result.addError(st.getMessage());
    }
    if (st.isDeprecated()) {
      result.addWarning(st.getMessage());
    }

    if (shouldCheckExecutionCapabilities) {
      // Check remote execution is enabled.
      ExecutionCapabilities execCap = capabilities.getExecutionCapabilities();
      if (!execCap.getExecEnabled()) {
        result.addError(
            "Remote execution is not supported by the remote server, or the current "
                + "account is not authorized to use remote execution.");
        return result.build(); // No point checking other execution fields.
      }

      // Check execution digest function.
      if (execCap.getDigestFunction() == DigestFunction.Value.UNKNOWN) {
        // Server side error -- this is not supposed to happen.
        result.addError("Remote server error: UNKNOWN execution digest function.");
      }
      if (execCap.getDigestFunction() != digestFunction) {
        result.addError(
            String.format(
                "Cannot use hash function %s with remote execution. "
                    + "Server supported function is %s",
                digestFunction, execCap.getDigestFunction()));
      }

      // Check execution priority is in the supported range.
      checkPriorityInRange(
          remoteOptions.remoteExecutionPriority,
          "remote_execution_priority",
          execCap.getExecutionPriorityCapabilities(),
          result);
    }

    if (shouldCheckCacheCapabilities) {
      // Check cache digest function.
      CacheCapabilities cacheCap = capabilities.getCacheCapabilities();
      if (!cacheCap.getDigestFunctionList().contains(digestFunction)) {
        result.addError(
            String.format(
                "Cannot use hash function %s with remote cache. "
                    + "Server supported functions are: %s",
                digestFunction, cacheCap.getDigestFunctionList()));
      }

      // Check updating remote cache is allowed, if we ever need to do that.
      boolean remoteExecution = !Strings.isNullOrEmpty(remoteOptions.remoteExecutor);
      if (remoteExecution) {
        if (remoteOptions.remoteLocalFallback
            && remoteOptions.remoteUploadLocalResults
            && !cacheCap.getActionCacheUpdateCapabilities().getUpdateEnabled()) {
          result.addError(
              "--remote_local_fallback and --remote_upload_local_results are set, "
                  + "but the current account is not authorized to write local results "
                  + "to the remote cache.");
        }
      } else {
        // Local execution: check updating remote cache is allowed.
        if (remoteOptions.remoteUploadLocalResults
            && !cacheCap.getActionCacheUpdateCapabilities().getUpdateEnabled()) {
          result.addError(
              "--remote_upload_local_results is set, but the current account is not authorized "
                  + "to write local results to the remote cache.");
        }
      }

      // Check result cache priority is in the supported range.
      checkPriorityInRange(
          remoteOptions.remoteResultCachePriority,
          "remote_result_cache_priority",
          cacheCap.getCachePriorityCapabilities(),
          result);
    }

    return result.build();
  }
}
