package zero

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

	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"
	grpcmetadata "google.golang.org/grpc/metadata"
	"google.golang.org/grpc/peer"
	"google.golang.org/protobuf/types/known/timestamppb"
	"gorm.io/gorm"

	zerov1beta1 "github.com/tidbcloud/tidb-management-service/api/spec/tidb_cloud_open_api/zero/v1beta1"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-service/domain/entity"
	"github.com/tidbcloud/tidb-management-service/utilities/bizerr"
	"github.com/tidbcloud/tidb-management-service/utilities/randstr"
)

const tooManyRequestsMsg = "no available database, please try again later"
const generalInternalErrorMsg = "internal server error, please try again later"

// defaultTimeToFreeInHours is the fallback expiry duration (30 days) used when the
// shadow pool's AutoScalingConfig.TimeToFreeInHours is not configured.
const defaultTimeToFreeInHours = 720

// maxTagLength is the maximum number of characters allowed for the tag field.
const maxTagLength = 256

func (s *serverImpl) AllocateZeroInstance(
	ctx context.Context,
	req *zerov1beta1.AllocateZeroInstanceRequest,
) (*zerov1beta1.AllocateZeroInstanceResponse, error) {
	ctx, span := tracer.Start(ctx, "AllocateZeroInstance")
	defer span.End()

	sourceIP := extractClientIP(ctx)
	tag := truncateTag(req.GetTag())

	shadowPoolID, err := s.selectShadowPoolID(ctx)
	if err != nil {
		tilog.Error(ctx, "Failed to select shadow pool ID", zap.Error(err))
		rentZeroInstanceFailureTotal.WithLabelValues(
			"unknown",
			getBizErrCode(err),
		).Inc()
		return nil, err
	}

	shadowPool, err := s.shadowPoolRepo.Get(ctx, shadowPoolID)
	if err != nil {
		tilog.Error(ctx, "Get shadow pool failed", zap.Error(err))
		rentZeroInstanceFailureTotal.WithLabelValues(
			fmt.Sprintf("%d", shadowPoolID),
			getBizErrCode(err),
		).Inc()
		return nil, bizerr.New(bizerr.ErrCodeTooManyRequests, tooManyRequestsMsg)
	}

	tilog.Info(ctx, "Allocating zero instance from shadow pool",
		zap.Uint64("shadow_pool_id", shadowPoolID),
		zap.Uint64("target_project_id", shadowPool.ProjectID))

	passwd := randstr.GenWithCharset(16, randstr.AlphanumericCharset)

	startTime := time.Now()
	zeroId, cluster, err := s.serverlessClusterSvc.RentCluster(ctx, shadowPoolID, shadowPool.ProjectID, "", passwd, sourceIP, tag)
	rentZeroInstanceDuration.WithLabelValues(fmt.Sprintf("%d", shadowPoolID)).Observe(time.Since(startTime).Seconds())

	if err != nil {
		tilog.Error(ctx, "Failed to rent cluster from shadow pool", zap.Error(err))
		rentZeroInstanceFailureTotal.WithLabelValues(
			fmt.Sprintf("%d", shadowPoolID),
			getBizErrCode(err),
		).Inc()
		return nil, bizerr.New(bizerr.ErrCodeTooManyRequests, tooManyRequestsMsg)
	}

	instance, err := s.buildInstanceFromCluster(ctx, zeroId, cluster, passwd, shadowPool)
	if err != nil {
		tilog.Error(ctx, "Failed to build zero instance from cluster", zap.Error(err))
		rentZeroInstanceFailureTotal.WithLabelValues(
			fmt.Sprintf("%d", shadowPoolID),
			getBizErrCode(err),
		).Inc()
		return nil, bizerr.New(bizerr.ErrCodeTooManyRequests, tooManyRequestsMsg)
	}

	tilog.Info(ctx, "Successfully allocated zero instance",
		zap.Uint64("cluster_id", cluster.ClusterId),
		zap.String("display_name", cluster.DisplayName))

	rentZeroInstanceSuccessTotal.WithLabelValues(fmt.Sprintf("%d", shadowPoolID)).Inc()

	return &zerov1beta1.AllocateZeroInstanceResponse{Instance: instance}, nil
}

func (s *serverImpl) GetZeroInstance(
	ctx context.Context,
	req *zerov1beta1.GetZeroInstanceRequest,
) (*zerov1beta1.GetZeroInstanceResponse, error) {
	ctx, span := tracer.Start(ctx, "GetZeroInstance")
	defer span.End()

	if req.GetId() == "" {
		return nil, bizerr.New(bizerr.ErrCodeParams, "id must not be empty")
	}

	cluster, err := s.serverlessClusterSvc.GetClusterByZeroID(ctx, req.GetId())
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, bizerr.New(bizerr.ErrCodeRecordNotFound, "zero instance not found")
		}
		return nil, err
	}

	shadowPool, err := s.shadowPoolRepo.Get(ctx, cluster.ShadowPoolId)
	if err != nil {
		tilog.Error(ctx, "Failed to get shadow pool for cluster",
			zap.Uint64("cluster_id", cluster.ClusterId),
			zap.Uint64("shadow_pool_id", cluster.ShadowPoolId),
			zap.Error(err))
		return nil, bizerr.New(bizerr.GeneralInternalErrorCode, generalInternalErrorMsg)
	}

	instance, err := s.buildInstanceFromCluster(ctx, req.GetId(), cluster, "{YOUR_PASSWORD}", shadowPool)
	if err != nil {
		return nil, err
	}

	return &zerov1beta1.GetZeroInstanceResponse{Instance: instance}, nil
}

func (s *serverImpl) buildInstanceFromCluster(
	ctx context.Context,
	zeroId string,
	cluster *entity.Cluster,
	passwd string,
	shadowPool *entity.ShadowPool,
) (*zerov1beta1.ZeroInstance, error) {
	if cluster == nil {
		return nil, bizerr.New(bizerr.GeneralInternalErrorCode, generalInternalErrorMsg)
	}

	serverlessCluster, err := cluster.GetServerlessCluster()
	if err != nil {
		tilog.Error(ctx, "Get serverless cluster failed", zap.Error(err))
		return nil, bizerr.New(bizerr.GeneralInternalErrorCode, generalInternalErrorMsg)
	}

	if serverlessCluster == nil || serverlessCluster.Cluster.GetEndpoints() == nil {
		tilog.Error(ctx, "Empty serverless cluster or endpoints", zap.Any("cluster", cluster))
		return nil, bizerr.New(bizerr.GeneralInternalErrorCode, generalInternalErrorMsg)
	}

	host := serverlessCluster.Cluster.GetEndpoints().GetPublic().GetHost()
	port := serverlessCluster.Cluster.GetEndpoints().GetPublic().GetPort()
	userName := fmt.Sprintf("%s.%s", serverlessCluster.Cluster.GetUserPrefix(), "root")

	timeToFreeInHours := shadowPool.AutoScalingConfig.TimeToFreeInHours
	if timeToFreeInHours == 0 {
		timeToFreeInHours = defaultTimeToFreeInHours
	}
	expireAt := cluster.UpdatedAt.Add(time.Duration(timeToFreeInHours) * time.Hour)

	instance := &zerov1beta1.ZeroInstance{
		Id:               zeroId,
		ConnectionString: fmt.Sprintf("mysql://%s:%s@%s:%d/", userName, passwd, host, port),
		Connection: &zerov1beta1.ZeroInstance_Connection{
			Host:     host,
			Port:     int32(port),
			Username: userName,
			Password: passwd,
		},
		ClaimInfo: &zerov1beta1.ZeroInstance_ClaimInfo{
			ZeroId:   zeroId,
			ClaimUrl: s.buildClaimURL(zeroId),
		},
		ExpiresAt: timestamppb.New(expireAt),
	}

	return instance, nil
}

// buildClaimURL constructs the claim URL for a zero instance.
func (s *serverImpl) buildClaimURL(zeroId string) string {
	if s.config == nil || s.config.ClaimUrlHost == "" {
		return ""
	}
	return fmt.Sprintf("https://%s/tidbs/claim/%s", s.config.ClaimUrlHost, zeroId)
}

// selectShadowPoolID selects a shadow pool ID from config.
// TODO(shadowpool): Implement IP-based pool selection for geographic proximity.
// Currently returns the first available pool ID as a simple fallback.
func (s *serverImpl) selectShadowPoolID(ctx context.Context) (uint64, error) {
	if s.config == nil || len(s.config.PoolIDs) == 0 {
		return 0, bizerr.New(bizerr.ErrCodeTooManyRequests, tooManyRequestsMsg)
	}
	selectedPoolID := s.config.PoolIDs[0]
	tilog.Info(ctx, "Selected shadow pool ID", zap.Uint64("pool_id", selectedPoolID))
	return selectedPoolID, nil
}

// getBizErrCode extracts the error code from a bizerr error, or returns "unknown" for other errors
func getBizErrCode(err error) string {
	var bizErr *bizerr.BizErr
	if errors.As(err, &bizErr) {
		return fmt.Sprintf("%d", bizErr.ErrCode())
	}
	return "unknown"
}

// extractClientIP extracts the client's source IP address from the gRPC context.
// It first tries the "grpcgateway-x-forwarded-for" metadata key (set by grpc-gateway
// when translating the HTTP X-Forwarded-For header), then falls back to the gRPC peer address.
func extractClientIP(ctx context.Context) string {
	if md, ok := grpcmetadata.FromIncomingContext(ctx); ok {
		// grpc-gateway forwards HTTP X-Forwarded-For as "grpcgateway-x-forwarded-for"
		if vals := md.Get("grpcgateway-x-forwarded-for"); len(vals) > 0 && vals[0] != "" {
			// X-Forwarded-For may be a comma-separated list; take the first (original client) IP.
			ip := strings.SplitN(vals[0], ",", 2)[0]
			return strings.TrimSpace(ip)
		}
		// Fallback: plain x-forwarded-for in case it was set directly
		if vals := md.Get("x-forwarded-for"); len(vals) > 0 && vals[0] != "" {
			ip := strings.SplitN(vals[0], ",", 2)[0]
			return strings.TrimSpace(ip)
		}
	}

	// Last resort: use the gRPC peer address (typically the immediate caller, not the end user)
	if p, ok := peer.FromContext(ctx); ok && p.Addr != nil {
		addr := p.Addr.String()
		if idx := strings.LastIndex(addr, ":"); idx != -1 {
			return addr[:idx]
		}
		return addr
	}

	return ""
}

// truncateTag ensures the tag does not exceed maxTagLength characters.
func truncateTag(tag string) string {
	runes := []rune(tag)
	if len(runes) > maxTagLength {
		return string(runes[:maxTagLength])
	}
	return tag
}
