/*
 * Copyright 2019 The gRPC Authors
 *
 * 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 io.grpc.internal;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import io.grpc.CallOptions;
import io.grpc.InternalConfigSelector;
import io.grpc.LoadBalancer.PickSubchannelArgs;
import io.grpc.MethodDescriptor;
import io.grpc.Status.Code;
import io.grpc.internal.RetriableStream.Throttle;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;

/**
 * {@link ManagedChannelServiceConfig} is a fully parsed and validated representation of service
 * configuration data.
 */
final class ManagedChannelServiceConfig {

  @Nullable
  private final MethodInfo defaultMethodConfig;
  private final Map<String, MethodInfo> serviceMethodMap;
  private final Map<String, MethodInfo> serviceMap;
  @Nullable
  private final Throttle retryThrottling;
  @Nullable
  private final Object loadBalancingConfig;
  @Nullable
  private final Map<String, ?> healthCheckingConfig;

  ManagedChannelServiceConfig(
      @Nullable MethodInfo defaultMethodConfig,
      Map<String, MethodInfo> serviceMethodMap,
      Map<String, MethodInfo> serviceMap,
      @Nullable Throttle retryThrottling,
      @Nullable Object loadBalancingConfig,
      @Nullable Map<String, ?> healthCheckingConfig) {
    this.defaultMethodConfig = defaultMethodConfig;
    this.serviceMethodMap = Collections.unmodifiableMap(new HashMap<>(serviceMethodMap));
    this.serviceMap = Collections.unmodifiableMap(new HashMap<>(serviceMap));
    this.retryThrottling = retryThrottling;
    this.loadBalancingConfig = loadBalancingConfig;
    this.healthCheckingConfig =
        healthCheckingConfig != null
            ? Collections.unmodifiableMap(new HashMap<>(healthCheckingConfig))
            : null;
  }

  /** Returns an empty {@link ManagedChannelServiceConfig}. */
  static ManagedChannelServiceConfig empty() {
    return
        new ManagedChannelServiceConfig(
            null,
            new HashMap<String, MethodInfo>(),
            new HashMap<String, MethodInfo>(),
            /* retryThrottling= */ null,
            /* loadBalancingConfig= */ null,
            /* healthCheckingConfig= */ null);
  }

  /**
   * Parses the Channel level config values (e.g. excludes load balancing)
   */
  static ManagedChannelServiceConfig fromServiceConfig(
      Map<String, ?> serviceConfig,
      boolean retryEnabled,
      int maxRetryAttemptsLimit,
      int maxHedgedAttemptsLimit,
      @Nullable Object loadBalancingConfig) {
    Throttle retryThrottling = null;
    if (retryEnabled) {
      retryThrottling = ServiceConfigUtil.getThrottlePolicy(serviceConfig);
    }
    Map<String, MethodInfo> serviceMethodMap = new HashMap<>();
    Map<String, MethodInfo> serviceMap = new HashMap<>();
    Map<String, ?> healthCheckingConfig =
        ServiceConfigUtil.getHealthCheckedService(serviceConfig);

    // Try and do as much validation here before we swap out the existing configuration.  In case
    // the input is invalid, we don't want to lose the existing configuration.
    List<Map<String, ?>> methodConfigs =
        ServiceConfigUtil.getMethodConfigFromServiceConfig(serviceConfig);

    if (methodConfigs == null) {
      // this is surprising, but possible.
      return
          new ManagedChannelServiceConfig(
              null,
              serviceMethodMap,
              serviceMap,
              retryThrottling,
              loadBalancingConfig,
              healthCheckingConfig);
    }

    MethodInfo defaultMethodConfig = null;
    for (Map<String, ?> methodConfig : methodConfigs) {
      MethodInfo info = new MethodInfo(
          methodConfig, retryEnabled, maxRetryAttemptsLimit, maxHedgedAttemptsLimit);

      List<Map<String, ?>> nameList =
          ServiceConfigUtil.getNameListFromMethodConfig(methodConfig);

      if (nameList == null || nameList.isEmpty()) {
        continue;
      }
      for (Map<String, ?> name : nameList) {
        String serviceName = ServiceConfigUtil.getServiceFromName(name);
        String methodName = ServiceConfigUtil.getMethodFromName(name);
        if (Strings.isNullOrEmpty(serviceName)) {
          checkArgument(
              Strings.isNullOrEmpty(methodName), "missing service name for method %s", methodName);
          checkArgument(
              defaultMethodConfig == null,
              "Duplicate default method config in service config %s",
              serviceConfig);
          defaultMethodConfig = info;
        } else if (Strings.isNullOrEmpty(methodName)) {
          // Service scoped config
          checkArgument(
              !serviceMap.containsKey(serviceName), "Duplicate service %s", serviceName);
          serviceMap.put(serviceName, info);
        } else {
          // Method scoped config
          String fullMethodName = MethodDescriptor.generateFullMethodName(serviceName, methodName);
          checkArgument(
              !serviceMethodMap.containsKey(fullMethodName),
              "Duplicate method name %s",
              fullMethodName);
          serviceMethodMap.put(fullMethodName, info);
        }
      }
    }

    return
        new ManagedChannelServiceConfig(
            defaultMethodConfig,
            serviceMethodMap,
            serviceMap,
            retryThrottling,
            loadBalancingConfig,
            healthCheckingConfig);
  }

  @Nullable
  Map<String, ?> getHealthCheckingConfig() {
    return healthCheckingConfig;
  }

  /**
   * Used as a fallback per-RPC config supplier when the attributes value of {@link
   * InternalConfigSelector#KEY} is not available. Returns {@code null} if there is no method
   * config in this service config.
   */
  @Nullable
  InternalConfigSelector getDefaultConfigSelector() {
    if (serviceMap.isEmpty() && serviceMethodMap.isEmpty() && defaultMethodConfig == null) {
      return null;
    }
    return new ServiceConfigConvertedSelector(this);
  }

  @VisibleForTesting
  @Nullable
  Object getLoadBalancingConfig() {
    return loadBalancingConfig;
  }

  @Nullable
  Throttle getRetryThrottling() {
    return retryThrottling;
  }

  @Nullable
  MethodInfo getMethodConfig(MethodDescriptor<?, ?> method) {
    MethodInfo methodInfo = serviceMethodMap.get(method.getFullMethodName());
    if (methodInfo == null) {
      String serviceName = method.getServiceName();
      methodInfo = serviceMap.get(serviceName);
    }
    if (methodInfo == null) {
      methodInfo = defaultMethodConfig;
    }
    return methodInfo;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    ManagedChannelServiceConfig that = (ManagedChannelServiceConfig) o;
    return Objects.equal(defaultMethodConfig, that.defaultMethodConfig)
        && Objects.equal(serviceMethodMap, that.serviceMethodMap)
        && Objects.equal(serviceMap, that.serviceMap)
        && Objects.equal(retryThrottling, that.retryThrottling)
        && Objects.equal(loadBalancingConfig, that.loadBalancingConfig);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(
        defaultMethodConfig, serviceMethodMap, serviceMap, retryThrottling, loadBalancingConfig);
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(this)
        .add("defaultMethodConfig", defaultMethodConfig)
        .add("serviceMethodMap", serviceMethodMap)
        .add("serviceMap", serviceMap)
        .add("retryThrottling", retryThrottling)
        .add("loadBalancingConfig", loadBalancingConfig)
        .toString();
  }

  /**
   * Equivalent of MethodConfig from a ServiceConfig with restrictions from Channel setting.
   */
  static final class MethodInfo {
    static final CallOptions.Key<MethodInfo> KEY =
        CallOptions.Key.create("io.grpc.internal.ManagedChannelServiceConfig.MethodInfo");

    // TODO(carl-mastrangelo): add getters for these fields and make them private.
    final Long timeoutNanos;
    final Boolean waitForReady;
    final Integer maxInboundMessageSize;
    final Integer maxOutboundMessageSize;
    final RetryPolicy retryPolicy;
    final HedgingPolicy hedgingPolicy;

    /**
     * Constructor.
     *
     * @param retryEnabled when false, the argument maxRetryAttemptsLimit will have no effect.
     */
    MethodInfo(
        Map<String, ?> methodConfig, boolean retryEnabled, int maxRetryAttemptsLimit,
        int maxHedgedAttemptsLimit) {
      timeoutNanos = ServiceConfigUtil.getTimeoutFromMethodConfig(methodConfig);
      waitForReady = ServiceConfigUtil.getWaitForReadyFromMethodConfig(methodConfig);
      maxInboundMessageSize =
          ServiceConfigUtil.getMaxResponseMessageBytesFromMethodConfig(methodConfig);
      if (maxInboundMessageSize != null) {
        checkArgument(
            maxInboundMessageSize >= 0,
            "maxInboundMessageSize %s exceeds bounds", maxInboundMessageSize);
      }
      maxOutboundMessageSize =
          ServiceConfigUtil.getMaxRequestMessageBytesFromMethodConfig(methodConfig);
      if (maxOutboundMessageSize != null) {
        checkArgument(
            maxOutboundMessageSize >= 0,
            "maxOutboundMessageSize %s exceeds bounds", maxOutboundMessageSize);
      }

      Map<String, ?> retryPolicyMap =
          retryEnabled ? ServiceConfigUtil.getRetryPolicyFromMethodConfig(methodConfig) : null;
      retryPolicy = retryPolicyMap == null
          ? null : retryPolicy(retryPolicyMap, maxRetryAttemptsLimit);

      Map<String, ?> hedgingPolicyMap =
          retryEnabled ? ServiceConfigUtil.getHedgingPolicyFromMethodConfig(methodConfig) : null;
      hedgingPolicy = hedgingPolicyMap == null
          ? null : hedgingPolicy(hedgingPolicyMap, maxHedgedAttemptsLimit);
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(
          timeoutNanos,
          waitForReady,
          maxInboundMessageSize,
          maxOutboundMessageSize,
          retryPolicy,
          hedgingPolicy);
    }

    @Override
    public boolean equals(Object other) {
      if (!(other instanceof MethodInfo)) {
        return false;
      }
      MethodInfo that = (MethodInfo) other;
      return Objects.equal(this.timeoutNanos, that.timeoutNanos)
          && Objects.equal(this.waitForReady, that.waitForReady)
          && Objects.equal(this.maxInboundMessageSize, that.maxInboundMessageSize)
          && Objects.equal(this.maxOutboundMessageSize, that.maxOutboundMessageSize)
          && Objects.equal(this.retryPolicy, that.retryPolicy)
          && Objects.equal(this.hedgingPolicy, that.hedgingPolicy);
    }

    @Override
    public String toString() {
      return MoreObjects.toStringHelper(this)
          .add("timeoutNanos", timeoutNanos)
          .add("waitForReady", waitForReady)
          .add("maxInboundMessageSize", maxInboundMessageSize)
          .add("maxOutboundMessageSize", maxOutboundMessageSize)
          .add("retryPolicy", retryPolicy)
          .add("hedgingPolicy", hedgingPolicy)
          .toString();
    }

    private static RetryPolicy retryPolicy(Map<String, ?> retryPolicy, int maxAttemptsLimit) {
      int maxAttempts = checkNotNull(
          ServiceConfigUtil.getMaxAttemptsFromRetryPolicy(retryPolicy),
          "maxAttempts cannot be empty");
      checkArgument(maxAttempts >= 2, "maxAttempts must be greater than 1: %s", maxAttempts);
      maxAttempts = Math.min(maxAttempts, maxAttemptsLimit);

      long initialBackoffNanos = checkNotNull(
          ServiceConfigUtil.getInitialBackoffNanosFromRetryPolicy(retryPolicy),
          "initialBackoff cannot be empty");
      checkArgument(
          initialBackoffNanos > 0,
          "initialBackoffNanos must be greater than 0: %s",
          initialBackoffNanos);

      long maxBackoffNanos = checkNotNull(
          ServiceConfigUtil.getMaxBackoffNanosFromRetryPolicy(retryPolicy),
          "maxBackoff cannot be empty");
      checkArgument(
          maxBackoffNanos > 0, "maxBackoff must be greater than 0: %s", maxBackoffNanos);

      double backoffMultiplier = checkNotNull(
          ServiceConfigUtil.getBackoffMultiplierFromRetryPolicy(retryPolicy),
          "backoffMultiplier cannot be empty");
      checkArgument(
          backoffMultiplier > 0,
          "backoffMultiplier must be greater than 0: %s",
          backoffMultiplier);

      Long perAttemptRecvTimeout =
          ServiceConfigUtil.getPerAttemptRecvTimeoutNanosFromRetryPolicy(retryPolicy);
      checkArgument(
          perAttemptRecvTimeout == null || perAttemptRecvTimeout >= 0,
          "perAttemptRecvTimeout cannot be negative: %s",
          perAttemptRecvTimeout);

      Set<Code> retryableCodes =
          ServiceConfigUtil.getRetryableStatusCodesFromRetryPolicy(retryPolicy);
      checkArgument(
          perAttemptRecvTimeout != null || !retryableCodes.isEmpty(),
          "retryableStatusCodes cannot be empty without perAttemptRecvTimeout");

      return new RetryPolicy(
          maxAttempts, initialBackoffNanos, maxBackoffNanos, backoffMultiplier,
          perAttemptRecvTimeout, retryableCodes);
    }

    private static HedgingPolicy hedgingPolicy(
        Map<String, ?> hedgingPolicy, int maxAttemptsLimit) {
      int maxAttempts = checkNotNull(
          ServiceConfigUtil.getMaxAttemptsFromHedgingPolicy(hedgingPolicy),
          "maxAttempts cannot be empty");
      checkArgument(maxAttempts >= 2, "maxAttempts must be greater than 1: %s", maxAttempts);
      maxAttempts = Math.min(maxAttempts, maxAttemptsLimit);

      long hedgingDelayNanos = checkNotNull(
          ServiceConfigUtil.getHedgingDelayNanosFromHedgingPolicy(hedgingPolicy),
          "hedgingDelay cannot be empty");
      checkArgument(
          hedgingDelayNanos >= 0, "hedgingDelay must not be negative: %s", hedgingDelayNanos);

      return new HedgingPolicy(
          maxAttempts, hedgingDelayNanos,
          ServiceConfigUtil.getNonFatalStatusCodesFromHedgingPolicy(hedgingPolicy));
    }
  }

  static final class ServiceConfigConvertedSelector extends InternalConfigSelector {

    final ManagedChannelServiceConfig config;

    /** Converts the service config to config selector. */
    private ServiceConfigConvertedSelector(ManagedChannelServiceConfig config) {
      this.config = config;
    }

    @Override
    public Result selectConfig(PickSubchannelArgs args) {
      return Result.newBuilder()
          .setConfig(config)
          .build();
    }
  }
}
