package grpc

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"
	"google.golang.org/genproto/googleapis/rpc/errdetails"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"

	"github.com/tidbcloud/tidb-management-service/utilities/bizerr"
)

func ServerPayloadLogInterceptor(
	ctx context.Context,
	req any,
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (any, error) {
	startTime := time.Now()

	reqSnapshot := proto.Clone(req.(proto.Message))

	resp, err := handler(ctx, req)

	defer func() {
		d := time.Since(startTime)
		marshaller := protojson.MarshalOptions{
			UseProtoNames:   true,
			EmitUnpopulated: true,
		}
		// Process the request
		reqJson := marshaller.Format(reqSnapshot)
		// Process the response
		respBp, ok := resp.(proto.Message)
		if !ok {
			tilog.Warn(
				ctx,
				"response is not proto.Message",
				zap.String("resp_type", fmt.Sprintf("%T", resp)),
				zap.Any("response", resp),
			)
		}
		respJson := marshaller.Format(respBp)
		// Remove sensitive information
		reqJson = RedactSecrets(reqJson)
		respJson = RedactSecrets(respJson)

		if err != nil {
			// Process the error
			errStatus, ok := status.FromError(err)
			if !ok {
				tilog.Warn(ctx, "error is not grpc status", zap.Error(err))
				errStatus = status.New(codes.Unknown, err.Error())
			}
			errJson := marshaller.Format(errStatus.Proto())
			errJson = RedactSecrets(errJson)

			// Disable stack trace here.
			// The Go error stack and the call stack of the current goroutine:
			//  - Capturing the call stack directly in the gRPC log interceptor does not yield the business call stack, as the business function has already returned by that point.
			//  - The error should include the stack where the error was returned.
			GetPayloadLogger(ctx).Error(
				"<--- grpc server",
				zap.String("method", info.FullMethod),
				zap.String("request", reqJson),
				zap.String("response", respJson),
				zap.Duration("duration", d),
				zap.String("error", errJson),
			)

			return
		}

		GetPayloadLogger(ctx).Info(
			"<--- grpc server",
			zap.String("method", info.FullMethod),
			zap.String("request", reqJson),
			zap.String("response", respJson),
			zap.Duration("duration", d),
		)
	}()

	return resp, err
}

var passwordFieldPattern = regexp.MustCompile(`(?i)(passwd|password|credential|token|access_key_id|access_key_secret)`)

func RedactSecrets(msgJson string) string {
	if msgJson == "" || msgJson == "null" || msgJson == "<nil>" {
		return msgJson
	}

	// Creating the map for JSON
	m := map[string]any{}

	dec := json.NewDecoder(strings.NewReader(msgJson))
	dec.UseNumber()

	// Unmarshalling JSON
	if err := dec.Decode(&m); err != nil {
		tilog.PlainWarn("Failed to unmarshal JSON", zap.String("msg", msgJson), zap.Error(err))
		return msgJson
	}

	// Do dfs redact
	dfsRedactMap(m)

	redacted, err := json.Marshal(m)
	if err != nil {
		tilog.PlainError("Failed to marshal JSON", zap.Error(err))
		return msgJson
	}

	return string(redacted)
}

func dfsRedactMap(aMap map[string]any) {
	for key, val := range aMap {
		switch concreteVal := val.(type) {
		case map[string]any:
			dfsRedactMap(concreteVal)
		case []any:
			dfsRedactSlice(concreteVal)
		case string:
			if passwordFieldPattern.MatchString(key) {
				aMap[key] = "***"
			}
		default:
		}
	}
}

func dfsRedactSlice(anArray []any) {
	for _, val := range anArray {
		switch concreteVal := val.(type) {
		case map[string]any:
			dfsRedactMap(concreteVal)
		case []any:
			dfsRedactSlice(concreteVal)
		default:
		}
	}
}

// ErrorConvertInterceptor converts different types of errors to gRPC status errors.
func ErrorConvertInterceptor() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		resp, err := handler(ctx, req)
		if err == nil {
			return resp, nil
		}

		// If the error is a gRPC status error, return it directly
		if _, ok := status.FromError(err); ok {
			return resp, err
		}

		// If the error is a biz error, return its error code and message.
		var be bizerr.BizError
		if errors.As(err, &be) {
			if be.ErrCode() == bizerr.ErrCodeOK {
				return resp, nil
			}
			return resp, newError(be.ErrCode(), be.ErrMsg(), be.Error(), be.Stack())
		}

		// Otherwise, return the error code unknown.
		return resp, newError(bizerr.ErrCodeUnknown, err.Error(), "", "")
	}
}

func newError(code bizerr.ErrCode, msg, debugDetail, stacks string) error {
	s, err := status.New(codes.Code(code), msg).
		WithDetails(&errdetails.DebugInfo{Detail: debugDetail, StackEntries: []string{stacks}})
	if err != nil {
		return err
	}
	return s.Err()
}
