// Package grpcclient provides one production dial policy for internal gRPC
// clients. The policy keeps transport behavior out of business handlers.
package grpcclient

import (
	"context"
	"fmt"
	"strings"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/backoff"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/keepalive"
)

// Config controls connection backoff, keepalive, retry and per-RPC deadlines.
type Config struct {
	DefaultTimeout         time.Duration
	ConnectTimeout         time.Duration
	KeepaliveTime          time.Duration
	KeepaliveTimeout       time.Duration
	RetryMaxAttempts       int
	RetryInitialBackoff    time.Duration
	RetryMaxBackoff        time.Duration
	RetryBackoffMultiplier float64
	RetryableStatusCodes   []string
}

// DefaultConfig is conservative: retries are limited to one retry on
// UNAVAILABLE and must complete inside the caller's deadline.
func DefaultConfig() Config {
	return Config{
		DefaultTimeout:         5 * time.Second,
		ConnectTimeout:         2 * time.Second,
		KeepaliveTime:          30 * time.Second,
		KeepaliveTimeout:       10 * time.Second,
		RetryMaxAttempts:       2,
		RetryInitialBackoff:    100 * time.Millisecond,
		RetryMaxBackoff:        500 * time.Millisecond,
		RetryBackoffMultiplier: 2,
		RetryableStatusCodes:   []string{"UNAVAILABLE"},
	}
}

// Normalize fills safe transport defaults and clamps retry attempts to grpc-go's
// supported range. A value below two disables explicit retry.
func Normalize(cfg Config) Config {
	defaults := DefaultConfig()
	if cfg.DefaultTimeout <= 0 {
		cfg.DefaultTimeout = defaults.DefaultTimeout
	}
	if cfg.ConnectTimeout <= 0 {
		cfg.ConnectTimeout = defaults.ConnectTimeout
	}
	if cfg.KeepaliveTime <= 0 {
		cfg.KeepaliveTime = defaults.KeepaliveTime
	}
	if cfg.KeepaliveTimeout <= 0 {
		cfg.KeepaliveTimeout = defaults.KeepaliveTimeout
	}
	if cfg.RetryMaxAttempts < 0 {
		cfg.RetryMaxAttempts = 0
	}
	if cfg.RetryMaxAttempts == 0 {
		cfg.RetryMaxAttempts = defaults.RetryMaxAttempts
	}
	if cfg.RetryMaxAttempts > 5 {
		cfg.RetryMaxAttempts = 5
	}
	if cfg.RetryInitialBackoff <= 0 {
		cfg.RetryInitialBackoff = defaults.RetryInitialBackoff
	}
	if cfg.RetryMaxBackoff <= 0 {
		cfg.RetryMaxBackoff = defaults.RetryMaxBackoff
	}
	if cfg.RetryMaxBackoff < cfg.RetryInitialBackoff {
		cfg.RetryMaxBackoff = cfg.RetryInitialBackoff
	}
	if cfg.RetryBackoffMultiplier <= 0 {
		cfg.RetryBackoffMultiplier = defaults.RetryBackoffMultiplier
	}
	if len(cfg.RetryableStatusCodes) == 0 {
		cfg.RetryableStatusCodes = defaults.RetryableStatusCodes
	}
	return cfg
}

// Dial creates a ClientConn with deadlines, TCP reconnect backoff, keepalive and
// a minimal retry policy. Startup does not block on the remote endpoint; ready
// probes are responsible for proving the connection can become READY.
func Dial(target string, cfg Config, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
	cfg = Normalize(cfg)
	dialOptions := []grpc.DialOption{
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithConnectParams(grpc.ConnectParams{
			Backoff: backoff.Config{
				BaseDelay:  100 * time.Millisecond,
				Multiplier: 1.6,
				Jitter:     0.2,
				MaxDelay:   5 * time.Second,
			},
			MinConnectTimeout: cfg.ConnectTimeout,
		}),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                cfg.KeepaliveTime,
			Timeout:             cfg.KeepaliveTimeout,
			PermitWithoutStream: true,
		}),
		grpc.WithUnaryInterceptor(timeoutUnaryInterceptor(cfg.DefaultTimeout)),
	}
	if serviceConfig := defaultServiceConfig(cfg); serviceConfig != "" {
		// The retry budget is intentionally transport-level and narrow. Business
		// handlers still rely on command IDs/idempotency for mutation safety.
		dialOptions = append(dialOptions, grpc.WithDefaultServiceConfig(serviceConfig))
	}
	dialOptions = append(dialOptions, opts...)
	return grpc.Dial(strings.TrimSpace(target), dialOptions...)
}

func timeoutUnaryInterceptor(timeout time.Duration) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req any, reply any, conn *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		if timeout > 0 {
			if _, ok := ctx.Deadline(); !ok {
				var cancel context.CancelFunc
				ctx, cancel = context.WithTimeout(ctx, timeout)
				defer cancel()
			}
		}
		return invoker(ctx, method, req, reply, conn, opts...)
	}
}

func defaultServiceConfig(cfg Config) string {
	if cfg.RetryMaxAttempts < 2 {
		return `{"methodConfig":[{"name":[{}],"waitForReady":true}]}`
	}

	return fmt.Sprintf(
		`{"methodConfig":[{"name":[{}],"waitForReady":true,"retryPolicy":{"maxAttempts":%d,"initialBackoff":"%s","maxBackoff":"%s","backoffMultiplier":%.2f,"retryableStatusCodes":[%s]}}]}`,
		cfg.RetryMaxAttempts,
		durationString(cfg.RetryInitialBackoff),
		durationString(cfg.RetryMaxBackoff),
		cfg.RetryBackoffMultiplier,
		statusCodeList(cfg.RetryableStatusCodes),
	)
}

func durationString(value time.Duration) string {
	return fmt.Sprintf("%.3fs", value.Seconds())
}

func statusCodeList(codes []string) string {
	quoted := make([]string, 0, len(codes))
	for _, code := range codes {
		code = strings.ToUpper(strings.TrimSpace(code))
		if code == "" {
			continue
		}
		quoted = append(quoted, fmt.Sprintf("%q", code))
	}
	if len(quoted) == 0 {
		return `"UNAVAILABLE"`
	}
	return strings.Join(quoted, ",")
}
