// Package cpnotice consumes user-service CP outbox facts and delivers the IM side effects.
package cpnotice

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strconv"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/imgroup"
	"hyapp/pkg/logx"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/usermq"
)

const (
	eventApplicationCreated          = "UserCPApplicationCreated"
	eventApplicationAccepted         = "UserCPApplicationAccepted"
	eventApplicationRejected         = "UserCPApplicationRejected"
	eventRelationshipCreated         = "UserCPRelationshipCreated"
	eventRelationshipCapacityReached = "UserCPRelationshipCapacityReached"

	channelTencentIMC2C         = "tencent_im_c2c"
	channelTencentIMC2CAcceptor = "tencent_im_c2c_acceptor"
	channelTencentIMSystemC2C   = "tencent_im_system_c2c"
	channelTencentIMRoom        = "tencent_im_room"
	channelTencentIMRoomSender  = "tencent_im_room_sender"
	channelTencentIMRegion      = "tencent_im_region"

	noticeTypeApplicationCreated          = "cp_application_created"
	noticeTypeApplicationAccepted         = "cp_application_accepted"
	noticeTypeApplicationRejected         = "cp_application_rejected"
	noticeTypeRelationshipCreated         = "cp_relationship_created"
	noticeTypeRelationshipCapacityReached = "cp_relationship_capacity_reached"
)

// Repository owns notice_delivery_events for CP IM deliveries; user-service remains the CP fact owner.
type Repository interface {
	ClaimCPDelivery(ctx context.Context, workerID string, delivery CPDelivery, lockTTL time.Duration) (CPDelivery, bool, error)
	MarkCPDelivered(ctx context.Context, delivery CPDelivery, deliveredPayload []byte, nowMs int64) error
	MarkCPRetryable(ctx context.Context, delivery CPDelivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
	MarkCPFailed(ctx context.Context, delivery CPDelivery, retryCount int, lastErr string, nowMs int64) error
}

// Publisher is the Tencent IM boundary needed by CP notices.
type Publisher interface {
	PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
	PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
	EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
}

// Config carries process-level delivery settings that must match gateway broadcast group generation.
type Config struct {
	NodeID        string
	GroupIDPrefix string
}

// Service maps CP facts to concrete IM messages and stores per-channel delivery state.
type Service struct {
	cfg        Config
	repository Repository
	publisher  Publisher
}

// New creates CP notice service.
func New(cfg Config, repository Repository, publisher Publisher) *Service {
	return &Service{cfg: cfg, repository: repository, publisher: publisher}
}

// ProcessUserOutboxMessage handles one user_outbox MQ message. Unknown events are acknowledged so that unrelated
// user-service facts can share the same topic without notice-service poisoning the consumer group.
func (s *Service) ProcessUserOutboxMessage(ctx context.Context, message usermq.UserOutboxMessage, options CPNoticeWorkerOptions) (bool, error) {
	if !isCPNoticeEvent(message.EventType) {
		return false, nil
	}
	options = normalizeCPNoticeWorkerOptions(options, s.cfg.NodeID)
	if s.repository == nil {
		return true, fmt.Errorf("notice repository is not configured")
	}
	if s.publisher == nil {
		return true, fmt.Errorf("notice publisher is not configured")
	}
	deliveries, err := deliveriesFromMessage(message, s.cfg.GroupIDPrefix)
	if err != nil {
		return true, err
	}
	for _, delivery := range deliveries {
		claimedDelivery, claimed, err := s.repository.ClaimCPDelivery(ctx, options.WorkerID, delivery, options.LockTTL)
		if err != nil {
			return true, err
		}
		if !claimed {
			// 一条 user_outbox 事实会展开为多个按 channel 独立幂等的投递。当前 channel 已终态、
			// 被其他 worker 持有或尚未到重试时间时，只能跳过这一项；提前返回会把整条 MQ
			// 消息确认掉，导致其后尚未落库或仍需补发的 channel 永久漏发。
			continue
		}
		if err := s.publishDelivery(ctx, claimedDelivery, options); err != nil {
			return true, err
		}
	}
	return true, nil
}

func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions) error {
	publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
	defer cancel()
	var err error
	switch delivery.Channel {
	case channelTencentIMC2C, channelTencentIMC2CAcceptor:
		err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
			ToAccount:   tencentim.FormatUserID(delivery.TargetUserID),
			FromAccount: tencentim.FormatUserID(delivery.SenderUserID),
			EventID:     delivery.EventID,
			Desc:        delivery.NoticeType,
			Ext:         "cp_relation_notice",
			PayloadJSON: delivery.PayloadJSON,
		})
	case channelTencentIMSystemC2C:
		// FromAccount 留空时腾讯 IM client 会使用配置的管理员账号；容量告警属于系统事实，
		// 不能伪装成收礼人发出的 C2C 消息，也不能污染双方的关系会话方向。
		err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
			ToAccount:   tencentim.FormatUserID(delivery.TargetUserID),
			EventID:     delivery.EventID,
			Desc:        delivery.NoticeType,
			Ext:         "system_notice",
			PayloadJSON: delivery.PayloadJSON,
		})
	case channelTencentIMRoom, channelTencentIMRoomSender:
		err = s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
			GroupID:     delivery.GroupID,
			EventID:     delivery.EventID,
			Desc:        delivery.NoticeType,
			Ext:         "cp_room_notice",
			PayloadJSON: delivery.PayloadJSON,
		})
	case channelTencentIMRegion:
		// 区域群是长期播报群；发送前幂等创建，避免本地真实测试或新区域缺群导致关系主流程事实无法通知。
		if ensureErr := s.publisher.EnsureGroup(publishCtx, s.regionGroupSpec(delivery.GroupID)); ensureErr != nil {
			err = ensureErr
			break
		}
		err = s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
			GroupID:     delivery.GroupID,
			EventID:     delivery.EventID,
			Desc:        delivery.NoticeType,
			Ext:         "cp_region_broadcast",
			PayloadJSON: delivery.PayloadJSON,
		})
		if err == nil {
			s.dualSendLegacyRegion(publishCtx, delivery)
		}
	default:
		err = fmt.Errorf("unsupported cp notice channel %q", delivery.Channel)
	}
	nowMs := time.Now().UnixMilli()
	if err == nil {
		if markErr := s.repository.MarkCPDelivered(ctx, delivery, delivery.PayloadJSON, nowMs); markErr != nil {
			return markErr
		}
		logx.Info(ctx, "notice_cp_delivered",
			slog.String("event_id", delivery.EventID),
			slog.String("app_code", delivery.AppCode),
			slog.String("notice_type", delivery.NoticeType),
			slog.String("channel", delivery.Channel),
			slog.Int64("target_user_id", delivery.TargetUserID),
			slog.String("group_id", delivery.GroupID),
		)
		return nil
	}
	return s.markFailed(ctx, delivery, options, err)
}

// regionGroupSpec 按群 ID 版本决定建群参数：v2 区域群是 AVChatRoom（ChatRoom 套餐成员上限
// 曾在线上触发 10038 拒绝加群），迁移前入队的存量 v1 投递记录仍按 ChatRoom 幂等创建。
func (s *Service) regionGroupSpec(groupID string) tencentim.GroupSpec {
	parsed := imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix)
	if parsed.Kind == imgroup.KindRegionBroadcast {
		if legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID); err == nil && legacyID != groupID {
			return tencentim.GroupSpec{
				GroupID: groupID,
				Name:    groupID,
				Type:    tencentim.BroadcastGroupType,
			}
		}
	}
	return tencentim.GroupSpec{
		GroupID:         groupID,
		Name:            groupID,
		Type:            tencentim.DefaultGroupType,
		ApplyJoinOption: "FreeAccess",
	}
}

// dualSendLegacyRegion 在区域播报群 v1→v2 过渡期内，把已成功投递到 v2 群的 CP 播报补发到 v1 旧群，
// 覆盖尚未重新拉取 join 列表的老客户端。补发尽力而为，失败不影响主投递状态，与 activity 播报双发同一模式。
func (s *Service) dualSendLegacyRegion(ctx context.Context, delivery CPDelivery) {
	parsed := imgroup.ParseWithPrefix(delivery.GroupID, s.cfg.GroupIDPrefix)
	if parsed.Kind != imgroup.KindRegionBroadcast {
		return
	}
	legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
	if err != nil || legacyID == delivery.GroupID {
		return
	}
	_ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
		GroupID:     legacyID,
		EventID:     delivery.EventID + "_legacy",
		Desc:        delivery.NoticeType,
		Ext:         "cp_region_broadcast",
		PayloadJSON: delivery.PayloadJSON,
	})
}

func (s *Service) markFailed(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions, cause error) error {
	nowMs := time.Now().UnixMilli()
	nextRetryCount := delivery.RetryCount + 1
	if nextRetryCount >= options.MaxRetryCount {
		if err := s.repository.MarkCPFailed(ctx, delivery, nextRetryCount, cause.Error(), nowMs); err != nil {
			return err
		}
		logx.Error(ctx, "notice_cp_dead_letter", cause,
			slog.String("event_id", delivery.EventID),
			slog.String("app_code", delivery.AppCode),
			slog.String("notice_type", delivery.NoticeType),
			slog.String("channel", delivery.Channel),
			slog.Int("retry_count", nextRetryCount),
		)
		return nil
	}
	nextRetryAtMS := nowMs + cpNoticeBackoff(nextRetryCount, options).Milliseconds()
	if err := s.repository.MarkCPRetryable(ctx, delivery, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil {
		return err
	}
	logx.Error(ctx, "notice_cp_retryable", cause,
		slog.String("event_id", delivery.EventID),
		slog.String("app_code", delivery.AppCode),
		slog.String("notice_type", delivery.NoticeType),
		slog.String("channel", delivery.Channel),
		slog.Int("retry_count", nextRetryCount),
		slog.Int64("next_retry_at_ms", nextRetryAtMS),
	)
	return nil
}

func isCPNoticeEvent(eventType string) bool {
	switch eventType {
	case eventApplicationCreated, eventApplicationAccepted, eventApplicationRejected, eventRelationshipCreated, eventRelationshipCapacityReached:
		return true
	default:
		return false
	}
}

func deliveriesFromMessage(message usermq.UserOutboxMessage, groupIDPrefix string) ([]CPDelivery, error) {
	appCode := appcode.Normalize(message.AppCode)
	root, err := decodeObject(message.PayloadJSON)
	if err != nil {
		return nil, err
	}
	switch message.EventType {
	case eventApplicationCreated:
		application, err := applicationFromObject(root)
		if err != nil {
			return nil, err
		}
		basePayload, err := cpApplicationPayload(message, application, noticeTypeApplicationCreated, "pending")
		if err != nil {
			return nil, err
		}
		roomPayload := cloneJSONMap(basePayload)
		roomPayload["recipient_user_id"] = strconv.FormatInt(application.Target.UserID, 10)
		requesterRoomPayload := cloneJSONMap(basePayload)
		requesterRoomPayload["recipient_user_id"] = strconv.FormatInt(application.Requester.UserID, 10)
		return []CPDelivery{
			{
				AppCode:      appCode,
				EventID:      message.EventID,
				EventType:    message.EventType,
				Channel:      channelTencentIMC2C,
				NoticeType:   noticeTypeApplicationCreated,
				SenderUserID: application.Requester.UserID,
				TargetUserID: application.Target.UserID,
				PayloadJSON:  mustJSON(basePayload),
				CreatedAtMS:  message.OccurredAtMS,
			},
			{
				AppCode:      appCode,
				EventID:      message.EventID,
				EventType:    message.EventType,
				Channel:      channelTencentIMRoom,
				NoticeType:   noticeTypeApplicationCreated,
				TargetUserID: application.Target.UserID,
				GroupID:      application.RoomID,
				PayloadJSON:  mustJSON(roomPayload),
				CreatedAtMS:  message.OccurredAtMS,
			},
			{
				// 创建申请时 target 需要接收态按钮，requester 也需要同一房间上下文里的等待态弹窗。
				// notice_delivery_events 的主键包含 channel，同一 event_id 不能复用 tencent_im_room，
				// 因此发起人房间通知用独立内部 channel，发布边界仍走腾讯 IM 群自定义消息。
				AppCode:      appCode,
				EventID:      message.EventID,
				EventType:    message.EventType,
				Channel:      channelTencentIMRoomSender,
				NoticeType:   noticeTypeApplicationCreated,
				TargetUserID: application.Requester.UserID,
				GroupID:      application.RoomID,
				PayloadJSON:  mustJSON(requesterRoomPayload),
				CreatedAtMS:  message.OccurredAtMS,
			},
		}, nil
	case eventApplicationAccepted:
		application, err := applicationFromObject(root)
		if err != nil {
			return nil, err
		}
		payload, err := cpApplicationPayload(message, application, noticeTypeApplicationAccepted, "accepted")
		if err != nil {
			return nil, err
		}
		return applicationAcceptedDeliveries(appCode, message, application, payload), nil
	case eventApplicationRejected:
		application, err := applicationFromObject(firstObject(root, "application", "Application"))
		if err != nil {
			return nil, err
		}
		payload, err := cpApplicationPayload(message, application, noticeTypeApplicationRejected, "rejected")
		if err != nil {
			return nil, err
		}
		payload["reason"] = stringValue(root, "reason", "Reason")
		return applicationDecisionDeliveries(appCode, message, application, payload, noticeTypeApplicationRejected), nil
	case eventRelationshipCreated:
		application, err := applicationFromObject(firstObject(root, "application", "Application"))
		if err != nil {
			return nil, err
		}
		relationship := firstObject(root, "relationship", "Relationship")
		relationshipID := stringValue(relationship, "relationship_id", "RelationshipID")
		groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(groupIDPrefix, appCode, application.RoomRegionID)
		if err != nil {
			return nil, err
		}
		return relationshipCreatedDelivery(appCode, message, application, relationshipID, groupID)
	case eventRelationshipCapacityReached:
		payload, targetUserID, err := cpRelationshipCapacityReachedPayload(message, root)
		if err != nil {
			return nil, err
		}
		return []CPDelivery{{
			AppCode:      appCode,
			EventID:      message.EventID,
			EventType:    message.EventType,
			Channel:      channelTencentIMSystemC2C,
			NoticeType:   noticeTypeRelationshipCapacityReached,
			TargetUserID: targetUserID,
			PayloadJSON:  mustJSON(payload),
			CreatedAtMS:  message.OccurredAtMS,
		}}, nil
	default:
		return nil, nil
	}
}

func cpRelationshipCapacityReachedPayload(message usermq.UserOutboxMessage, root map[string]any) (map[string]any, int64, error) {
	senderUserID := int64Value(root, "sender_user_id", "SenderUserID")
	targetUserID := int64Value(root, "target_user_id", "TargetUserID")
	limitedUserID := int64Value(root, "limited_user_id", "LimitedUserID")
	relationType := stringValue(root, "relation_type", "RelationType")
	messageText := stringValue(root, "message_text", "MessageText")
	if senderUserID <= 0 || targetUserID <= 0 || limitedUserID <= 0 || relationType == "" || messageText == "" {
		return nil, 0, fmt.Errorf("cp relationship capacity payload is incomplete")
	}
	// 最终 IM 使用字符串承载 user_id，避免 Dart/JavaScript 客户端经过 JSON number 时丢失 BIGINT 精度。
	payload := map[string]any{
		"event_id":             message.EventID,
		"event_type":           noticeTypeRelationshipCapacityReached,
		"source_event_type":    message.EventType,
		"app_code":             appcode.Normalize(message.AppCode),
		"relation_type":        relationType,
		"sender_user_id":       strconv.FormatInt(senderUserID, 10),
		"target_user_id":       strconv.FormatInt(targetUserID, 10),
		"limited_user_id":      strconv.FormatInt(limitedUserID, 10),
		"recipient_user_id":    strconv.FormatInt(senderUserID, 10),
		"current_count":        int64Value(root, "current_count", "CurrentCount"),
		"max_count":            int64Value(root, "max_count", "MaxCount"),
		"room_id":              stringValue(root, "room_id", "RoomID"),
		"source_room_event_id": stringValue(root, "source_room_event_id", "SourceRoomEventID"),
		"message_text":         messageText,
		"source_created_at_ms": message.OccurredAtMS,
	}
	return payload, senderUserID, nil
}

func applicationDecisionDeliveries(appCode string, message usermq.UserOutboxMessage, application cpApplication, payload map[string]any, noticeType string) []CPDelivery {
	// 同意/拒绝结果必须同步覆盖两个入口：
	// 1. A 在私聊里看到 C2C 结果；
	// 2. A 在申请来源房间上下文看到房间 IM，客户端用 recipient_user_id 只给 A 展示按钮态变化。
	deliveries := []CPDelivery{{
		AppCode:      appCode,
		EventID:      message.EventID,
		EventType:    message.EventType,
		Channel:      channelTencentIMC2C,
		NoticeType:   noticeType,
		SenderUserID: application.Target.UserID,
		TargetUserID: application.Requester.UserID,
		PayloadJSON:  mustJSON(payload),
		CreatedAtMS:  message.OccurredAtMS,
	}}
	if application.RoomID == "" {
		return deliveries
	}
	roomPayload := cloneJSONMap(payload)
	roomPayload["recipient_user_id"] = strconv.FormatInt(application.Requester.UserID, 10)
	deliveries = append(deliveries, CPDelivery{
		AppCode:      appCode,
		EventID:      message.EventID,
		EventType:    message.EventType,
		Channel:      channelTencentIMRoom,
		NoticeType:   noticeType,
		TargetUserID: application.Requester.UserID,
		GroupID:      application.RoomID,
		PayloadJSON:  mustJSON(roomPayload),
		CreatedAtMS:  message.OccurredAtMS,
	})
	return deliveries
}

func applicationAcceptedDeliveries(appCode string, message usermq.UserOutboxMessage, application cpApplication, payload map[string]any) []CPDelivery {
	// 同意成功后双方都要在 C2C 会话里看到一条消息；notice_delivery_events 的幂等键只有 event_id + channel，
	// 所以第二条 C2C 使用独立内部 channel，但 publishDelivery 仍按腾讯 IM 单聊投递。
	requesterPayload := cloneJSONMap(payload)
	requesterPayload["c2c_notice_role"] = "accepted_to_requester"
	requesterPayload["message_text"] = "我同意了你"
	acceptorPayload := cloneJSONMap(payload)
	acceptorPayload["c2c_notice_role"] = "accepted_to_acceptor"
	acceptorPayload["message_text"] = "对方已同意你的申请"
	deliveries := []CPDelivery{
		{
			AppCode:      appCode,
			EventID:      message.EventID,
			EventType:    message.EventType,
			Channel:      channelTencentIMC2C,
			NoticeType:   noticeTypeApplicationAccepted,
			SenderUserID: application.Target.UserID,
			TargetUserID: application.Requester.UserID,
			PayloadJSON:  mustJSON(requesterPayload),
			CreatedAtMS:  message.OccurredAtMS,
		},
		{
			AppCode:      appCode,
			EventID:      message.EventID,
			EventType:    message.EventType,
			Channel:      channelTencentIMC2CAcceptor,
			NoticeType:   noticeTypeApplicationAccepted,
			SenderUserID: application.Requester.UserID,
			TargetUserID: application.Target.UserID,
			PayloadJSON:  mustJSON(acceptorPayload),
			CreatedAtMS:  message.OccurredAtMS,
		},
	}
	if application.RoomID == "" {
		return deliveries
	}
	roomPayload := cloneJSONMap(payload)
	roomPayload["recipient_user_id"] = strconv.FormatInt(application.Requester.UserID, 10)
	deliveries = append(deliveries, CPDelivery{
		AppCode:      appCode,
		EventID:      message.EventID,
		EventType:    message.EventType,
		Channel:      channelTencentIMRoom,
		NoticeType:   noticeTypeApplicationAccepted,
		TargetUserID: application.Requester.UserID,
		GroupID:      application.RoomID,
		PayloadJSON:  mustJSON(roomPayload),
		CreatedAtMS:  message.OccurredAtMS,
	})
	return deliveries
}

func relationshipCreatedDelivery(appCode string, message usermq.UserOutboxMessage, application cpApplication, relationshipID string, groupID string) ([]CPDelivery, error) {
	payload, err := cpRelationshipBroadcastPayload(message, application, relationshipID, groupID)
	if err != nil {
		return nil, err
	}
	return []CPDelivery{{
		AppCode:      appCode,
		EventID:      message.EventID,
		EventType:    message.EventType,
		Channel:      channelTencentIMRegion,
		NoticeType:   noticeTypeRelationshipCreated,
		TargetUserID: 0,
		GroupID:      groupID,
		PayloadJSON:  mustJSON(payload),
		CreatedAtMS:  message.OccurredAtMS,
	}}, nil
}

func cpApplicationPayload(message usermq.UserOutboxMessage, application cpApplication, noticeType string, status string) (map[string]any, error) {
	if application.ApplicationID == "" || application.Requester.UserID <= 0 || application.Target.UserID <= 0 {
		return nil, fmt.Errorf("cp application payload is incomplete")
	}
	return map[string]any{
		"event_id":             message.EventID,
		"event_type":           noticeType,
		"source_event_type":    message.EventType,
		"app_code":             appcode.Normalize(message.AppCode),
		"application_id":       application.ApplicationID,
		"relation_type":        application.RelationType,
		"status":               status,
		"requester":            userPayload(application.Requester),
		"target":               userPayload(application.Target),
		"room_id":              application.RoomID,
		"room_region_id":       application.RoomRegionID,
		"room_locked":          application.RoomLocked,
		"gift":                 giftPayload(application.Gift),
		"expires_at_ms":        application.ExpiresAtMS,
		"decided_at_ms":        application.DecidedAtMS,
		"source_created_at_ms": message.OccurredAtMS,
	}, nil
}

func cpRelationshipBroadcastPayload(message usermq.UserOutboxMessage, application cpApplication, relationshipID string, groupID string) (map[string]any, error) {
	if application.Requester.UserID <= 0 || application.Target.UserID <= 0 || application.RoomRegionID <= 0 {
		return nil, fmt.Errorf("cp relationship broadcast payload is incomplete")
	}
	return map[string]any{
		"event_id":             message.EventID,
		"event_type":           noticeTypeRelationshipCreated,
		"source_event_type":    message.EventType,
		"app_code":             appcode.Normalize(message.AppCode),
		"relationship_id":      relationshipID,
		"application_id":       application.ApplicationID,
		"relation_type":        application.RelationType,
		"requester":            userPayload(application.Requester),
		"target":               userPayload(application.Target),
		"gift":                 giftPayload(application.Gift),
		"room_id":              application.RoomID,
		"room_region_id":       application.RoomRegionID,
		"room_locked":          application.RoomLocked,
		"group_id":             groupID,
		"source_created_at_ms": message.OccurredAtMS,
	}, nil
}

func userPayload(user cpUser) map[string]any {
	return map[string]any{
		"user_id":         strconv.FormatInt(user.UserID, 10),
		"display_user_id": user.DisplayUserID,
		"username":        user.Username,
		"avatar":          user.Avatar,
	}
}

func giftPayload(gift cpGift) map[string]any {
	return map[string]any{
		"gift_id":            gift.GiftID,
		"gift_name":          gift.GiftName,
		"gift_icon_url":      gift.GiftIconURL,
		"gift_animation_url": gift.GiftAnimationURL,
		"gift_count":         gift.GiftCount,
		"gift_value":         gift.GiftValue,
		"billing_receipt_id": gift.BillingReceiptID,
	}
}

func mustJSON(payload map[string]any) json.RawMessage {
	body, _ := json.Marshal(payload)
	return body
}

func cloneJSONMap(input map[string]any) map[string]any {
	out := make(map[string]any, len(input))
	for key, value := range input {
		out[key] = value
	}
	return out
}
