// The MIT License
//
// Copyright (c) 2020 Temporal Technologies Inc.  All rights reserved.
//
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package internal

import (
	"context"
	"time"

	"github.com/gogo/status"
	grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
	"go.temporal.io/api/serviceerror"
	"go.temporal.io/sdk/internal/common/metrics"
	"go.temporal.io/sdk/internal/common/retry"
	"google.golang.org/grpc"
	"google.golang.org/grpc/backoff"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/keepalive"
	"google.golang.org/grpc/metadata"
)

type (
	// dialParameters are passed to GRPCDialer and must be used to create gRPC connection.
	dialParameters struct {
		HostPort              string
		UserConnectionOptions ConnectionOptions
		RequiredInterceptors  []grpc.UnaryClientInterceptor
		DefaultServiceConfig  string
	}
)

const (
	// LocalHostPort is a default host:port for worker and client to connect to.
	LocalHostPort = "localhost:7233"

	// defaultServiceConfig is a default gRPC connection service config which enables DNS round-robin between IPs.
	defaultServiceConfig = `{"loadBalancingConfig": [{"round_robin":{}}]}`

	// minConnectTimeout is the minimum amount of time we are willing to give a connection to complete.
	minConnectTimeout = 20 * time.Second

	// attemptSuffix is a suffix added to the metric name for individual call attempts made to the server, which includes retries.
	attemptSuffix = "_attempt"
	// mb is a number of bytes in a megabyte
	mb = 1024 * 1024
	// defaultMaxPayloadSize is a maximum size of the payload that grpc client would allow.
	defaultMaxPayloadSize = 64 * mb
)

func dial(params dialParameters) (*grpc.ClientConn, error) {
	var securityOptions []grpc.DialOption
	if params.UserConnectionOptions.TLS != nil {
		securityOptions = []grpc.DialOption{
			grpc.WithTransportCredentials(credentials.NewTLS(params.UserConnectionOptions.TLS)),
		}
	} else {
		securityOptions = []grpc.DialOption{
			grpc.WithInsecure(),
			grpc.WithAuthority(params.UserConnectionOptions.Authority),
		}
	}

	maxPayloadSize := defaultMaxPayloadSize
	if params.UserConnectionOptions.MaxPayloadSize != 0 {
		maxPayloadSize = params.UserConnectionOptions.MaxPayloadSize
	}

	// gRPC maintains connection pool inside grpc.ClientConn.
	// This connection pool has auto reconnect feature.
	// If connection goes down, gRPC will try to reconnect using exponential backoff strategy:
	// https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md.
	// Default MaxDelay is 120 seconds which is too high.
	// Setting it to retryPollOperationMaxInterval here will correlate with poll reconnect interval.
	var cp = grpc.ConnectParams{
		Backoff:           backoff.DefaultConfig,
		MinConnectTimeout: minConnectTimeout,
	}
	cp.Backoff.BaseDelay = retryPollOperationInitialInterval
	cp.Backoff.MaxDelay = retryPollOperationMaxInterval
	opts := []grpc.DialOption{
		grpc.WithChainUnaryInterceptor(params.RequiredInterceptors...),
		grpc.WithDefaultServiceConfig(params.DefaultServiceConfig),
		grpc.WithConnectParams(cp),
	}

	opts = append(opts, securityOptions...)
	opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(maxPayloadSize)))
	opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxPayloadSize)))

	if params.UserConnectionOptions.EnableKeepAliveCheck {
		// gRPC utilizes keep alive mechanism to detect dead connections in case if server didn't close them
		// gracefully. Client would ping the server periodically and expect replies withing the specified timeout.
		// Learn more by reading https://github.com/grpc/grpc/blob/master/doc/keepalive.md
		var kap = keepalive.ClientParameters{
			Time:                params.UserConnectionOptions.KeepAliveTime,
			Timeout:             params.UserConnectionOptions.KeepAliveTimeout,
			PermitWithoutStream: params.UserConnectionOptions.KeepAlivePermitWithoutStream,
		}
		opts = append(opts, grpc.WithKeepaliveParams(kap))
	}

	// Append any user-supplied options
	opts = append(opts, params.UserConnectionOptions.DialOptions...)

	return grpc.Dial(params.HostPort, opts...)
}

func requiredInterceptors(
	metricsHandler metrics.Handler,
	headersProvider HeadersProvider,
	controller TrafficController,
) []grpc.UnaryClientInterceptor {
	interceptors := []grpc.UnaryClientInterceptor{
		errorInterceptor,
		// Report aggregated metrics for the call, this is done outside of the retry loop.
		metrics.NewGRPCInterceptor(metricsHandler, ""),
		// By default the grpc retry interceptor *is disabled*, preventing accidental use of retries.
		// We add call options for retry configuration based on the values present in the context.
		retry.NewRetryOptionsInterceptor(),
		// Performs retries *IF* retry options are set for the call.
		grpc_retry.UnaryClientInterceptor(),
		// Report metrics for every call made to the server.
		metrics.NewGRPCInterceptor(metricsHandler, attemptSuffix),
	}
	if headersProvider != nil {
		interceptors = append(interceptors, headersProviderInterceptor(headersProvider))
	}
	if controller != nil {
		interceptors = append(interceptors, trafficControllerInterceptor(controller))
	}
	return interceptors
}

func trafficControllerInterceptor(controller TrafficController) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		err := controller.CheckCallAllowed(ctx, method, req, reply)
		// Break execution chain and return an error without sending actual request to the server.
		if err != nil {
			return err
		}
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

func headersProviderInterceptor(headersProvider HeadersProvider) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		headers, err := headersProvider.GetHeaders(ctx)
		if err != nil {
			return err
		}
		for k, v := range headers {
			ctx = metadata.AppendToOutgoingContext(ctx, k, v)
		}
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

func errorInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	err := invoker(ctx, method, req, reply, cc, opts...)
	err = serviceerror.FromStatus(status.Convert(err))
	return err
}
