package resource

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/integration/activityclient"
	"hyapp-admin-server/internal/integration/walletclient"
	"hyapp-admin-server/internal/middleware"
	"hyapp-admin-server/internal/modules/shared"
	"hyapp-admin-server/internal/repository"
	"hyapp-admin-server/internal/response"
	walletv1 "hyapp.local/api/proto/wallet/v1"

	"github.com/gin-gonic/gin"
)

type Handler struct {
	wallet         walletclient.Client
	activity       activityclient.Client
	store          *repository.Store
	userDB         *sql.DB
	requestTimeout time.Duration
	audit          shared.OperationLogger
}

func New(wallet walletclient.Client, store *repository.Store, userDB *sql.DB, requestTimeout time.Duration, audit shared.OperationLogger, activities ...activityclient.Client) *Handler {
	if requestTimeout <= 0 {
		requestTimeout = 3 * time.Second
	}
	var activity activityclient.Client
	if len(activities) > 0 {
		activity = activities[0]
	}
	return &Handler{wallet: wallet, activity: activity, store: store, userDB: userDB, requestTimeout: requestTimeout, audit: audit}
}

func (h *Handler) walletRequestContext(c *gin.Context) (context.Context, context.CancelFunc) {
	// 资源后台在广州 admin-server 跨区访问 Saudi wallet-service；每次 RPC 必须继承 HTTP 取消信号并设置硬上限，避免坏连接拖到前置 504。
	return context.WithTimeout(c.Request.Context(), h.requestTimeout)
}

func (h *Handler) ListResources(c *gin.Context) {
	options := shared.ListOptions(c)
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appctx.FromContext(c.Request.Context()),
		ResourceType: strings.TrimSpace(c.Query("resource_type")),
		Status:       options.Status,
		Keyword:      options.Keyword,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
	})
	if err != nil {
		response.ServerError(c, "获取资源列表失败")
		return
	}
	items := make([]resourceDTO, 0, len(resp.GetResources()))
	for _, item := range resp.GetResources() {
		items = append(items, resourceFromProto(item))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

// ListExternalGrantResources exposes only the owner-approved manager grant catalog.
// VIP trial cards remain on their dedicated API because a generic entitlement grant
// would not update the VIP state projection. The external route requires the complete
// grant permission bundle before this handler is reached.
func (h *Handler) ListExternalGrantResources(c *gin.Context) {
	options := shared.ListOptions(c)
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
		RequestId:        middleware.CurrentRequestID(c),
		AppCode:          appctx.FromContext(c.Request.Context()),
		ResourceType:     strings.TrimSpace(c.Query("resource_type")),
		Keyword:          options.Keyword,
		Page:             int32(options.Page),
		PageSize:         int32(options.PageSize),
		ActiveOnly:       true,
		ManagerGrantOnly: true,
	})
	if err != nil {
		response.ServerError(c, "获取可发放资源失败")
		return
	}
	items := make([]resourceDTO, 0, len(resp.GetResources()))
	excluded := int64(0)
	for _, item := range resp.GetResources() {
		if item.GetResourceType() == resourceTypeVIPTrialCard {
			excluded++
			continue
		}
		items = append(items, resourceFromProto(item))
	}
	// ManagerGrantOnly is indexed owner data; excluding a dedicated-flow item here is
	// defense in depth. Total can only be reduced by rows observed on this page because
	// the wallet contract does not expose a negative type filter.
	total := resp.GetTotal() - excluded
	if total < int64(len(items)) {
		total = int64(len(items))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}

func (h *Handler) GetResource(c *gin.Context) {
	resourceID, ok := parseID(c, "resource_id")
	if !ok {
		return
	}
	resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
		RequestId:  middleware.CurrentRequestID(c),
		AppCode:    appctx.FromContext(c.Request.Context()),
		ResourceId: resourceID,
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	response.OK(c, resourceFromProto(resp.GetResource()))
}

func (h *Handler) CreateResource(c *gin.Context) {
	var req resourceRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源参数不正确")
		return
	}
	if err := validateResourcePricing(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceBadgeForm(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceAvatarFrameKind(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateRoomDecorationResourceMaterial(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceMetadata(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resource := resourceFromProto(resp.GetResource())
	h.auditLog(c, "create-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode)
	response.Created(c, resource)
}

func (h *Handler) ListEmojiPacks(c *gin.Context) {
	options := shared.ListOptions(c)
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appctx.FromContext(c.Request.Context()),
		ResourceType: resourceTypeEmojiPack,
		Status:       options.Status,
		Keyword:      options.Keyword,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
	})
	if err != nil {
		response.ServerError(c, "获取表情包列表失败")
		return
	}
	items := make([]emojiPackDTO, 0, len(resp.GetResources()))
	for _, item := range resp.GetResources() {
		items = append(items, emojiPackFromProto(item))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) ListEmojiPackCategories(c *gin.Context) {
	const pageSize int32 = 100
	categories := map[string]struct{}{}
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	for page := int32(1); ; page++ {
		resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
			RequestId:    middleware.CurrentRequestID(c),
			AppCode:      appctx.FromContext(c.Request.Context()),
			ResourceType: resourceTypeEmojiPack,
			Page:         page,
			PageSize:     pageSize,
		})
		if err != nil {
			response.ServerError(c, "获取表情包分类失败")
			return
		}
		for _, item := range resp.GetResources() {
			category := emojiPackMetadataFromJSON(item.GetMetadataJson()).Category
			if category != "" {
				categories[category] = struct{}{}
			}
		}
		if len(resp.GetResources()) == 0 || int64(page)*int64(pageSize) >= resp.GetTotal() {
			break
		}
	}
	items := make([]string, 0, len(categories))
	for category := range categories {
		items = append(items, category)
	}
	sort.Strings(items)
	response.OK(c, items)
}

func (h *Handler) CreateEmojiPack(c *gin.Context) {
	var req emojiPackRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "表情包参数不正确")
		return
	}
	protoReq, err := req.createProto(c)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resp, err := h.wallet.CreateResource(c.Request.Context(), protoReq)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	item := emojiPackFromProto(resp.GetResource())
	h.auditLog(c, "create-emoji-pack", "resources", fmt.Sprintf("%d", item.ResourceID), "success", item.ResourceCode)
	response.Created(c, item)
}

func (h *Handler) UpdateResource(c *gin.Context) {
	resourceID, ok := parseID(c, "resource_id")
	if !ok {
		return
	}
	var req resourceRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源参数不正确")
		return
	}
	if err := validateResourcePricing(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceBadgeForm(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceAvatarFrameKind(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateRoomDecorationResourceMaterial(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := validateResourceMetadata(req); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	protoReq := req.updateProto(c, resourceID)
	resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resource := resourceFromProto(resp.GetResource())
	h.auditLog(c, "update-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode)
	response.OK(c, resource)
}

func (h *Handler) DeleteResource(c *gin.Context) {
	resourceID, ok := parseID(c, "resource_id")
	if !ok {
		return
	}
	resp, err := h.wallet.DeleteResource(c.Request.Context(), &walletv1.DeleteResourceRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		ResourceId:     resourceID,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resource := resourceFromProto(resp.GetResource())
	h.auditLog(c, "delete-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode)
	response.OK(c, resource)
}

func (h *Handler) BatchDeleteResources(c *gin.Context) {
	var req resourceDeleteBatchRequest
	if err := c.ShouldBindJSON(&req); err != nil || len(req.ResourceIDs) == 0 {
		response.BadRequest(c, "资源参数不正确")
		return
	}
	resp, err := h.wallet.BatchDeleteResources(c.Request.Context(), &walletv1.BatchDeleteResourcesRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		ResourceIds:    req.ResourceIDs,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	items := make([]resourceDTO, 0, len(resp.GetResources()))
	deletedIDs := make([]string, 0, len(resp.GetResources()))
	for _, item := range resp.GetResources() {
		resource := resourceFromProto(item)
		items = append(items, resource)
		deletedIDs = append(deletedIDs, fmt.Sprintf("%d", resource.ResourceID))
	}
	h.auditLog(c, "delete-resources", "resources", "batch", "success", strings.Join(deletedIDs, ","))
	response.OK(c, items)
}

func (h *Handler) UpdateMP4ResourceLayouts(c *gin.Context) {
	var req resourceMP4LayoutBatchRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "MP4 布局参数不正确")
		return
	}
	items, err := normalizeMP4LayoutBatchItems(req.Items)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}

	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	type preparedMP4LayoutUpdate struct {
		resource     *walletv1.Resource
		metadataJSON string
	}
	prepared := make([]preparedMP4LayoutUpdate, 0, len(items))
	for _, item := range items {
		resp, err := h.wallet.GetResource(ctx, &walletv1.GetResourceRequest{
			RequestId:  middleware.CurrentRequestID(c),
			AppCode:    appctx.FromContext(c.Request.Context()),
			ResourceId: item.ResourceID,
		})
		if err != nil {
			response.BadRequest(c, err.Error())
			return
		}
		current := resp.GetResource()
		if current.GetResourceId() == 0 {
			response.BadRequest(c, "资源不存在")
			return
		}
		// 批量接口只更新真实 MP4 动效资源；非 MP4 行按产品要求自动跳过，
		// 这样前端全选资源后提交到后端，也不会把图片、SVGA、PAG 的 metadata 写坏。
		if !resourceAnimationSourceIsMP4(current) {
			continue
		}
		metadataJSON, err := mp4LayoutMetadataForResource(current, item.MetadataJSON)
		if err != nil {
			response.BadRequest(c, err.Error())
			return
		}
		prepared = append(prepared, preparedMP4LayoutUpdate{resource: current, metadataJSON: metadataJSON})
	}

	updated := make([]resourceDTO, 0, len(prepared))
	updatedIDs := make([]string, 0, len(prepared))
	for _, item := range prepared {
		// wallet-service 只有全量资源更新 RPC，所以这里用当前资源还原所有业务字段，
		// 只替换 metadata_json，避免批量重算 MP4 时顺手覆盖价格、状态、资产或经理赠送配置。
		resp, err := h.wallet.UpdateResource(ctx, updateResourceMP4MetadataProto(c, item.resource, item.metadataJSON))
		if err != nil {
			response.BadRequest(c, err.Error())
			return
		}
		resource := resourceFromProto(resp.GetResource())
		updated = append(updated, resource)
		updatedIDs = append(updatedIDs, fmt.Sprintf("%d", resource.ResourceID))
	}
	h.auditLog(c, "update-resource-mp4-layouts", "resources", "batch", "success", strings.Join(updatedIDs, ","))
	response.OK(c, updated)
}

func (h *Handler) EnableResource(c *gin.Context) {
	h.setResourceStatus(c, "active")
}

func (h *Handler) DisableResource(c *gin.Context) {
	h.setResourceStatus(c, "disabled")
}

func (h *Handler) ListResourceGroups(c *gin.Context) {
	options := shared.ListOptions(c)
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	resp, err := h.wallet.ListResourceGroups(ctx, &walletv1.ListResourceGroupsRequest{
		RequestId: middleware.CurrentRequestID(c),
		AppCode:   appctx.FromContext(c.Request.Context()),
		Status:    options.Status,
		Keyword:   options.Keyword,
		Page:      int32(options.Page),
		PageSize:  int32(options.PageSize),
	})
	if err != nil {
		response.ServerError(c, "获取资源组列表失败")
		return
	}
	items := make([]resourceGroupDTO, 0, len(resp.GetGroups()))
	for _, item := range resp.GetGroups() {
		items = append(items, resourceGroupFromProto(item))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) GetResourceGroup(c *gin.Context) {
	groupID, ok := parseID(c, "group_id")
	if !ok {
		return
	}
	resp, err := h.wallet.GetResourceGroup(c.Request.Context(), &walletv1.GetResourceGroupRequest{
		RequestId: middleware.CurrentRequestID(c),
		AppCode:   appctx.FromContext(c.Request.Context()),
		GroupId:   groupID,
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	response.OK(c, resourceGroupFromProto(resp.GetGroup()))
}

func (h *Handler) CreateResourceGroup(c *gin.Context) {
	var req resourceGroupRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源组参数不正确")
		return
	}
	resp, err := h.wallet.CreateResourceGroup(c.Request.Context(), req.createProto(c))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	group := resourceGroupFromProto(resp.GetGroup())
	h.auditLog(c, "create-resource-group", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", group.GroupCode)
	response.Created(c, group)
}

func (h *Handler) UpdateResourceGroup(c *gin.Context) {
	groupID, ok := parseID(c, "group_id")
	if !ok {
		return
	}
	var req resourceGroupRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源组参数不正确")
		return
	}
	resp, err := h.wallet.UpdateResourceGroup(c.Request.Context(), req.updateProto(c, groupID))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	group := resourceGroupFromProto(resp.GetGroup())
	h.auditLog(c, "update-resource-group", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", group.GroupCode)
	response.OK(c, group)
}

func (h *Handler) DeleteResourceGroup(c *gin.Context) {
	groupID, ok := parseID(c, "group_id")
	if !ok {
		return
	}
	// 删除资源组只关闭后续查询与发放入口，历史发放记录和已固化快照仍需保留用于审计与补偿。
	resp, err := h.wallet.SetResourceGroupStatus(c.Request.Context(), &walletv1.SetResourceGroupStatusRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		GroupId:        groupID,
		Status:         "deleted",
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	group := resourceGroupFromProto(resp.GetGroup())
	h.auditLog(c, "delete-resource-group", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", group.GroupCode)
	response.OK(c, group)
}

func (h *Handler) EnableResourceGroup(c *gin.Context) {
	h.setResourceGroupStatus(c, "active")
}

func (h *Handler) DisableResourceGroup(c *gin.Context) {
	h.setResourceGroupStatus(c, "disabled")
}

func (h *Handler) ListGifts(c *gin.Context) {
	options := shared.ListOptions(c)
	regionID, filterRegion, ok := optionalInt64QueryWithPresence(c, "regionId", "region_id")
	if !ok {
		return
	}
	resp, err := h.wallet.ListGiftConfigs(c.Request.Context(), &walletv1.ListGiftConfigsRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appctx.FromContext(c.Request.Context()),
		Status:       options.Status,
		Keyword:      options.Keyword,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
		RegionId:     regionID,
		FilterRegion: filterRegion,
		GiftTypeCode: giftTypeCodeQuery(c),
	})
	if err != nil {
		response.ServerError(c, "获取礼物列表失败")
		return
	}
	items := make([]giftDTO, 0, len(resp.GetGifts()))
	for _, item := range resp.GetGifts() {
		items = append(items, giftFromProto(item))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) ListGiftTypes(c *gin.Context) {
	resp, err := h.wallet.ListGiftTypeConfigs(c.Request.Context(), &walletv1.ListGiftTypeConfigsRequest{
		RequestId: middleware.CurrentRequestID(c),
		AppCode:   appctx.FromContext(c.Request.Context()),
	})
	if err != nil {
		response.ServerError(c, "获取礼物类型失败")
		return
	}
	items := make([]giftTypeDTO, 0, len(resp.GetGiftTypes()))
	for _, item := range resp.GetGiftTypes() {
		items = append(items, giftTypeFromProto(item))
	}
	response.OK(c, items)
}

func (h *Handler) UpdateGiftType(c *gin.Context) {
	typeCode := strings.TrimSpace(c.Param("type_code"))
	if typeCode == "" {
		response.BadRequest(c, "类型编码不正确")
		return
	}
	var req giftTypeRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "礼物类型参数不正确")
		return
	}
	resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), req.upsertProto(c, typeCode))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	item := giftTypeFromProto(resp.GetGiftType())
	h.auditLog(c, "update-gift-type", "gift_type_configs", item.TabKey, "success", item.TabName)
	response.OK(c, item)
}

func (h *Handler) UpdateGiftTypes(c *gin.Context) {
	var req giftTypesRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "礼物类型参数不正确")
		return
	}
	if len(req.Items) == 0 {
		response.BadRequest(c, "礼物类型不能为空")
		return
	}
	items := make([]giftTypeDTO, 0, len(req.Items))
	tabKeys := make([]string, 0, len(req.Items))
	for _, requestItem := range req.Items {
		tabKey := strings.TrimSpace(requestItem.TabKey)
		if tabKey == "" {
			response.BadRequest(c, "类型编码不正确")
			return
		}
		resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), requestItem.upsertProto(c))
		if err != nil {
			response.BadRequest(c, err.Error())
			return
		}
		item := giftTypeFromProto(resp.GetGiftType())
		items = append(items, item)
		tabKeys = append(tabKeys, item.TabKey)
	}
	h.auditLog(c, "update-gift-types", "gift_type_configs", "batch", "success", strings.Join(tabKeys, ","))
	response.OK(c, items)
}

func (h *Handler) CreateGift(c *gin.Context) {
	var req giftRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "礼物参数不正确")
		return
	}
	resp, err := h.wallet.CreateGiftConfig(c.Request.Context(), req.createProto(c))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	gift := giftFromProto(resp.GetGift())
	h.auditLog(c, "create-gift", "gift_configs", gift.GiftID, "success", gift.Name)
	response.Created(c, gift)
}

func (h *Handler) BatchCreateGifts(c *gin.Context) {
	var req giftBatchRequest
	if err := c.ShouldBindJSON(&req); err != nil || len(req.Items) == 0 {
		response.BadRequest(c, "礼物参数不正确")
		return
	}
	items := make([]*walletv1.CreateGiftConfigRequest, 0, len(req.Items))
	for _, item := range req.Items {
		items = append(items, item.createProto(c))
	}
	resp, err := h.wallet.BatchCreateGiftConfigs(c.Request.Context(), &walletv1.BatchCreateGiftConfigsRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		Items:          items,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	gifts := make([]giftDTO, 0, len(resp.GetGifts()))
	giftIDs := make([]string, 0, len(resp.GetGifts()))
	for _, item := range resp.GetGifts() {
		gift := giftFromProto(item)
		gifts = append(gifts, gift)
		giftIDs = append(giftIDs, gift.GiftID)
	}
	h.auditLog(c, "create-gifts", "gift_configs", "batch", "success", strings.Join(giftIDs, ","))
	response.Created(c, gifts)
}

func (h *Handler) UpdateGift(c *gin.Context) {
	giftID := strings.TrimSpace(c.Param("gift_id"))
	if giftID == "" {
		response.BadRequest(c, "ID 参数不正确")
		return
	}
	var req giftRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "礼物参数不正确")
		return
	}
	resp, err := h.wallet.UpdateGiftConfig(c.Request.Context(), req.updateProto(c, giftID))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	gift := giftFromProto(resp.GetGift())
	h.auditLog(c, "update-gift", "gift_configs", gift.GiftID, "success", gift.Name)
	response.OK(c, gift)
}

func (h *Handler) DeleteGift(c *gin.Context) {
	giftID := strings.TrimSpace(c.Param("gift_id"))
	if giftID == "" {
		response.BadRequest(c, "ID 参数不正确")
		return
	}
	resp, err := h.wallet.DeleteGiftConfig(c.Request.Context(), &walletv1.DeleteGiftConfigRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		GiftId:         giftID,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	gift := giftFromProto(resp.GetGift())
	h.auditLog(c, "delete-gift", "gift_configs", gift.GiftID, "success", gift.Name)
	response.OK(c, gift)
}

func (h *Handler) EnableGift(c *gin.Context) {
	h.setGiftStatus(c, "active")
}

func (h *Handler) DisableGift(c *gin.Context) {
	h.setGiftStatus(c, "disabled")
}

func (h *Handler) GrantResource(c *gin.Context) {
	h.grantResource(c, false)
}

// GrantExternalResource keeps main-admin behavior unchanged while closing the ID
// bypass on the external portal: callers may only submit an active resource that the
// owner explicitly marked grantable for managers.
func (h *Handler) GrantExternalResource(c *gin.Context) {
	h.grantResource(c, true)
}

func (h *Handler) grantResource(c *gin.Context, external bool) {
	var req grantResourceRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源赠送参数不正确")
		return
	}
	targetUserID, err := parseGrantTargetUserID(req.TargetUserID)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if external && !externalGrantTargetAllowed(c.Request.Context(), targetUserID) {
		response.Forbidden(c, "目标用户不在当前团队或区域")
		return
	}
	if err := h.validateGenericGrantResource(c, req.ResourceID, external); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	grantSource := "admin"
	if external {
		// Wallet atomically rechecks manager_grant_enabled for manager_center at grant
		// time. This closes the GetResource/GrantResource race and records the actual
		// external operator channel in the entitlement ledger.
		grantSource = grantSourceManagerCenter
	}
	resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
		CommandId:      strings.TrimSpace(req.CommandID),
		AppCode:        appctx.FromContext(c.Request.Context()),
		TargetUserId:   targetUserID,
		ResourceId:     req.ResourceID,
		Quantity:       req.Quantity,
		DurationMs:     req.DurationMS,
		Reason:         strings.TrimSpace(req.Reason),
		OperatorUserId: actorID(c),
		GrantSource:    grantSource,
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	grant := grantFromProto(resp.GetGrant())
	h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
	if err := h.createResourceGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), resp.GetGrant()); err != nil {
		response.ServerError(c, "资源已赠送，但系统消息发送失败，请使用相同命令重试")
		return
	}
	response.Created(c, grant)
}

// GrantResources keeps one Admin selection as one backend command so all resource images can be
// delivered in a single inbox card. Child wallet commands are deterministic, therefore an HTTP
// retry cannot duplicate entitlements that were already committed before a later item failed.
func (h *Handler) GrantResources(c *gin.Context) {
	h.grantResources(c, false)
}

// GrantExternalResources applies the same deterministic batch semantics as main admin
// while forcing manager_center and validating every target/resource before the first write.
func (h *Handler) GrantExternalResources(c *gin.Context) {
	h.grantResources(c, true)
}

func (h *Handler) grantResources(c *gin.Context, external bool) {
	var req grantResourcesRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源批量赠送参数不正确")
		return
	}
	if len(req.Resources) == 0 && len(req.ResourceIDs) > 0 {
		req.Resources = make([]grantResourcesItemRequest, 0, len(req.ResourceIDs))
		for _, resourceID := range req.ResourceIDs {
			req.Resources = append(req.Resources, grantResourcesItemRequest{
				ResourceID: resourceID, Quantity: req.Quantity, DurationMS: req.DurationMS,
			})
		}
	}
	targetUserID, err := parseGrantTargetUserID(req.TargetUserID)
	if err != nil || strings.TrimSpace(req.CommandID) == "" || len(strings.TrimSpace(req.CommandID)) > 96 || strings.TrimSpace(req.Reason) == "" || len(req.Resources) == 0 || len(req.Resources) > 100 {
		response.BadRequest(c, "资源批量赠送参数不正确")
		return
	}
	if external && !externalGrantTargetAllowed(c.Request.Context(), targetUserID) {
		response.Forbidden(c, "目标用户不在当前团队或区域")
		return
	}
	seenResourceIDs := make(map[int64]struct{}, len(req.Resources))
	for _, item := range req.Resources {
		if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 {
			response.BadRequest(c, "资源批量赠送参数不正确")
			return
		}
		if _, duplicated := seenResourceIDs[item.ResourceID]; duplicated {
			response.BadRequest(c, "同一资源不能重复选择")
			return
		}
		seenResourceIDs[item.ResourceID] = struct{}{}
		if err := h.validateGenericGrantResource(c, item.ResourceID, external); err != nil {
			response.BadRequest(c, err.Error())
			return
		}
	}

	appCode := appctx.FromContext(c.Request.Context())
	grantSource := grantSourceAdmin
	if external {
		grantSource = grantSourceManagerCenter
	}
	grants := make([]*walletv1.ResourceGrant, 0, len(req.Resources))
	grantDTOs := make([]grantDTO, 0, len(req.Resources))
	for index, item := range req.Resources {
		resp, grantErr := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
			CommandId:      resourceBatchChildCommandID(req.CommandID, index, item.ResourceID),
			AppCode:        appCode,
			TargetUserId:   targetUserID,
			ResourceId:     item.ResourceID,
			Quantity:       item.Quantity,
			DurationMs:     item.DurationMS,
			Reason:         strings.TrimSpace(req.Reason),
			OperatorUserId: actorID(c),
			GrantSource:    grantSource,
		})
		if grantErr != nil {
			response.BadRequest(c, grantErr.Error())
			return
		}
		grant := resp.GetGrant()
		grants = append(grants, grant)
		grantDTOs = append(grantDTOs, grantFromProto(grant))
	}
	for _, grant := range grantDTOs {
		h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
	}
	if err := h.createResourceBatchGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appCode, strings.TrimSpace(req.CommandID), targetUserID, grants); err != nil {
		response.ServerError(c, "资源已赠送，但系统消息发送失败，请使用相同命令重试")
		return
	}
	response.Created(c, map[string]any{"grants": grantDTOs})
}

func externalGrantTargetAllowed(ctx context.Context, targetUserID int64) bool {
	scope, external := appctx.ExternalScopeFromContext(ctx)
	if !external {
		return true
	}
	if scope.RegionID <= 0 || targetUserID <= 0 {
		return false
	}
	for _, userID := range scope.OwnerUserIDs {
		if userID == targetUserID {
			return true
		}
	}
	return false
}

func legacyExternalOperatorID(accountID uint64) int64 {
	const legacyNamespace uint64 = 1 << 62
	if accountID == 0 || accountID >= legacyNamespace {
		return 0
	}
	return int64(legacyNamespace | accountID)
}

func resourceBatchChildCommandID(batchCommandID string, index int, resourceID int64) string {
	sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.TrimSpace(batchCommandID), index, resourceID)))
	return "admin_resource_grant:" + hex.EncodeToString(sum[:])
}

func (h *Handler) GrantResourceGroup(c *gin.Context) {
	var req grantGroupRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "资源组赠送参数不正确")
		return
	}
	targetUserID, err := parseGrantTargetUserID(req.TargetUserID)
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	if err := h.validateGenericGrantGroup(c, req.GroupID); err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resp, err := h.wallet.GrantResourceGroup(c.Request.Context(), &walletv1.GrantResourceGroupRequest{
		CommandId:      strings.TrimSpace(req.CommandID),
		AppCode:        appctx.FromContext(c.Request.Context()),
		TargetUserId:   targetUserID,
		GroupId:        req.GroupID,
		Reason:         strings.TrimSpace(req.Reason),
		OperatorUserId: actorID(c),
		GrantSource:    "admin",
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	grant := grantFromProto(resp.GetGrant())
	h.auditLog(c, "grant-resource-group", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
	if err := h.createResourceGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), resp.GetGrant()); err != nil {
		response.ServerError(c, "资源组已赠送，但系统消息发送失败，请使用相同命令重试")
		return
	}
	response.Created(c, grant)
}

func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64, requireManagerGrant bool) error {
	resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
		RequestId:  middleware.CurrentRequestID(c),
		AppCode:    appctx.FromContext(c.Request.Context()),
		ResourceId: resourceID,
	})
	if err != nil {
		return err
	}
	if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) {
		return fmt.Errorf("该资源未开放外管发放")
	}
	if requireManagerGrant && resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
		// 外管资源目录仍不展示体验卡，因此 ID 直提也必须保持同一边界；本次放开的是
		// 主 Admin 与活动资源组，不扩大经理中心的可操作资源集合。
		return fmt.Errorf("该资源未开放外管发放")
	}
	// Wallet owner 已将 vip_trial_card 的 entitlement、卡片元数据和 VIP 投影收敛到
	// 同一事务；Admin 不再按入口屏蔽它，直接资源赠送与资源组活动可以复用同一语义。
	return nil
}

func (h *Handler) validateGenericGrantGroup(c *gin.Context, groupID int64) error {
	resp, err := h.wallet.GetResourceGroup(c.Request.Context(), &walletv1.GetResourceGroupRequest{
		RequestId: middleware.CurrentRequestID(c),
		AppCode:   appctx.FromContext(c.Request.Context()),
		GroupId:   groupID,
	})
	if err != nil {
		return err
	}
	if resp.GetGroup() == nil {
		return fmt.Errorf("资源组不存在")
	}
	return nil
}

func (h *Handler) RevokeResourceGrant(c *gin.Context) {
	grantID := strings.TrimSpace(c.Param("grant_id"))
	if grantID == "" {
		response.BadRequest(c, "资源赠送记录不存在")
		return
	}
	resp, err := h.wallet.RevokeResourceGrant(c.Request.Context(), &walletv1.RevokeResourceGrantRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		GrantId:        grantID,
		Reason:         "admin revoke resource grant",
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	grant := grantFromProto(resp.GetGrant())
	h.auditLog(c, "revoke-resource-grant", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
	response.OK(c, grant)
}

func (h *Handler) RevokeUserResource(c *gin.Context) {
	userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
	if err != nil || userID <= 0 {
		response.BadRequest(c, "用户 ID 不正确")
		return
	}
	entitlementID := strings.TrimSpace(c.Param("entitlement_id"))
	if entitlementID == "" {
		response.BadRequest(c, "用户素材权益不存在")
		return
	}
	var req revokeUserResourceRequest
	if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Reason) == "" {
		response.BadRequest(c, "请填写素材撤回原因")
		return
	}

	resp, err := h.wallet.RevokeUserResource(c.Request.Context(), &walletv1.RevokeUserResourceRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		UserId:         userID,
		EntitlementId:  entitlementID,
		Reason:         strings.TrimSpace(req.Reason),
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resource := resp.GetResource()
	// 审计对象使用 entitlement 而不是 source grant：同一资源组发放可包含多个素材，必须能追溯本次只撤回了哪一个。
	h.auditLog(c, "revoke-user-resource", "user_resource_entitlements", entitlementID, "success", fmt.Sprintf("target_user_id=%d resource_id=%d", userID, resource.GetResourceId()))
	response.OK(c, gin.H{
		"entitlementId":     resource.GetEntitlementId(),
		"resourceId":        resource.GetResourceId(),
		"status":            resource.GetStatus(),
		"remainingQuantity": resource.GetRemainingQuantity(),
		"equipped":          resource.GetEquipped(),
		"updatedAtMs":       resource.GetUpdatedAtMs(),
	})
}

func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
	keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
	if keyword == "" {
		response.BadRequest(c, "请输入用户 ID 或短号")
		return
	}
	if h == nil || h.userDB == nil {
		response.ServerError(c, "用户数据库未配置")
		return
	}
	// 资源赠送页允许输入短号，但 wallet 只认内部 user_id；这里在 admin 侧先解析，
	// 避免前端把短号直接发给 wallet，也避免 JS number 对长 user_id 造成精度损失。
	nowMs := time.Now().UnixMilli()
	matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
	orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
	args := append([]any{appctx.FromContext(c.Request.Context())}, matchArgs...)
	args = append(args, orderArgs...)
	row := h.userDB.QueryRowContext(c.Request.Context(), `
		SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')
		FROM users u
		WHERE u.app_code = ? AND `+matchSQL+`
		ORDER BY
		  `+orderSQL+`,
		  u.user_id DESC
		LIMIT 1`,
		args...,
	)
	user := grantUserDTO{}
	if err := row.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
		if err == sql.ErrNoRows {
			response.BadRequest(c, "用户不存在")
			return
		}
		response.ServerError(c, "查询用户失败")
		return
	}
	if !externalGrantTargetAllowed(c.Request.Context(), user.UserID) {
		response.Forbidden(c, "目标用户不在当前团队或区域")
		return
	}
	response.OK(c, user)
}

func (h *Handler) ListResourceGrants(c *gin.Context) {
	options := shared.ListOptions(c)
	appCode := appctx.FromContext(c.Request.Context())
	targetFilter := shared.UserIdentityFilterFromQuery(c, "target")
	targetUserID, _, err := shared.ResolveExactUserIDOrNumericFallback(
		c.Request.Context(),
		h.userDB,
		appCode,
		firstQuery(c, "target_user_id", "targetUserId"),
		time.Now().UnixMilli(),
	)
	if err != nil {
		if errors.Is(err, shared.ErrInvalidUserIdentityFilter) {
			response.BadRequest(c, "target_user_id 参数不正确")
			return
		}
		response.ServerError(c, "查询赠送用户失败")
		return
	}
	if targetUserID == 0 && !targetFilter.IsEmpty() {
		var matched bool
		targetUserID, matched, err = shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, targetFilter, time.Now().UnixMilli())
		if err != nil {
			response.ServerError(c, "查询赠送用户失败")
			return
		}
		if !matched {
			response.OK(c, response.Page{Items: []grantDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
			return
		}
	}
	request := &walletv1.ListResourceGrantsRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appCode,
		TargetUserId: targetUserID,
		Status:       options.Status,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
	}
	if scope, external := appctx.ExternalScopeFromContext(c.Request.Context()); external {
		// Current grants use the linked App user. The high-bit synthetic ID is retained
		// only in historical rows written before portal operators were corrected.
		request.GrantSource = grantSourceManagerCenter
		request.OperatorUserIds = []int64{scope.LinkedAppUserID, legacyExternalOperatorID(scope.AccountID)}
	}
	resp, err := h.wallet.ListResourceGrants(c.Request.Context(), request)
	if err != nil {
		response.ServerError(c, "获取资源赠送记录失败")
		return
	}
	items := make([]grantDTO, 0, len(resp.GetGrants()))
	for _, item := range resp.GetGrants() {
		items = append(items, grantFromProto(item))
	}
	if err := h.enrichGrantOperators(c.Request.Context(), items); err != nil {
		response.ServerError(c, "获取资源赠送操作人失败")
		return
	}
	if err := h.enrichGrantTargets(c.Request.Context(), items); err != nil {
		response.ServerError(c, "获取资源赠送用户信息失败")
		return
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) ListResourceShopItems(c *gin.Context) {
	options := shared.ListOptions(c)
	resp, err := h.wallet.ListResourceShopItems(c.Request.Context(), &walletv1.ListResourceShopItemsRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appctx.FromContext(c.Request.Context()),
		ResourceType: strings.TrimSpace(c.Query("resource_type")),
		Status:       options.Status,
		Keyword:      options.Keyword,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
	})
	if err != nil {
		response.ServerError(c, "获取道具商店列表失败")
		return
	}
	items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
	for _, item := range resp.GetItems() {
		items = append(items, resourceShopItemFromProto(item))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) {
	options := shared.ListOptions(c)
	appCode := appctx.FromContext(c.Request.Context())
	userID, ok := optionalInt64Query(c, "user_id", "userId")
	if !ok {
		return
	}
	userFilter := shared.UserIdentityFilterFromQuery(c, "")
	userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user"))
	if userID == 0 && !userFilter.IsEmpty() {
		resolvedUserID, matched, err := shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, userFilter, time.Now().UnixMilli())
		if err != nil {
			response.ServerError(c, "查询购买用户失败")
			return
		}
		if !matched {
			response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
			return
		}
		userID = resolvedUserID
	}
	if userID == 0 && userKeyword != "" {
		resolvedUserID, matched, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword)
		if err != nil {
			response.ServerError(c, "查询购买用户失败")
			return
		}
		if !matched {
			response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
			return
		}
		userID = resolvedUserID
	}
	ctx, cancel := h.walletRequestContext(c)
	defer cancel()
	resp, err := h.wallet.ListResourceShopPurchaseOrders(ctx, &walletv1.ListResourceShopPurchaseOrdersRequest{
		RequestId:    middleware.CurrentRequestID(c),
		AppCode:      appCode,
		UserId:       userID,
		ResourceType: strings.TrimSpace(c.Query("resource_type")),
		Status:       options.Status,
		Keyword:      options.Keyword,
		Page:         int32(options.Page),
		PageSize:     int32(options.PageSize),
	})
	if err != nil {
		response.ServerError(c, "获取售卖记录失败")
		return
	}
	items := make([]resourceShopPurchaseOrderDTO, 0, len(resp.GetOrders()))
	for _, item := range resp.GetOrders() {
		items = append(items, resourceShopPurchaseOrderFromProto(item))
	}
	if err := h.enrichResourceShopPurchaseUsers(c.Request.Context(), items); err != nil {
		response.ServerError(c, "获取购买用户信息失败")
		return
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) UpsertResourceShopItems(c *gin.Context) {
	var req resourceShopItemsRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "道具商店参数不正确")
		return
	}
	resp, err := h.wallet.UpsertResourceShopItems(c.Request.Context(), req.upsertProto(c))
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	items := make([]resourceShopItemDTO, 0, len(resp.GetItems()))
	resourceIDs := make([]string, 0, len(resp.GetItems()))
	for _, item := range resp.GetItems() {
		dto := resourceShopItemFromProto(item)
		items = append(items, dto)
		resourceIDs = append(resourceIDs, fmt.Sprintf("%d", dto.ResourceID))
	}
	h.auditLog(c, "upsert-resource-shop-items", "resource_shop_items", "batch", "success", strings.Join(resourceIDs, ","))
	response.OK(c, items)
}

func (h *Handler) EnableResourceShopItem(c *gin.Context) {
	h.setResourceShopItemStatus(c, "active")
}

func (h *Handler) DisableResourceShopItem(c *gin.Context) {
	h.setResourceShopItemStatus(c, "disabled")
}

func (h *Handler) setResourceStatus(c *gin.Context, status string) {
	resourceID, ok := parseID(c, "resource_id")
	if !ok {
		return
	}
	resp, err := h.wallet.SetResourceStatus(c.Request.Context(), &walletv1.SetResourceStatusRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		ResourceId:     resourceID,
		Status:         status,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	resource := resourceFromProto(resp.GetResource())
	h.auditLog(c, "set-resource-status", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", status)
	response.OK(c, resource)
}

func (h *Handler) setResourceGroupStatus(c *gin.Context, status string) {
	groupID, ok := parseID(c, "group_id")
	if !ok {
		return
	}
	resp, err := h.wallet.SetResourceGroupStatus(c.Request.Context(), &walletv1.SetResourceGroupStatusRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		GroupId:        groupID,
		Status:         status,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	group := resourceGroupFromProto(resp.GetGroup())
	h.auditLog(c, "set-resource-group-status", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", status)
	response.OK(c, group)
}

func (h *Handler) setGiftStatus(c *gin.Context, status string) {
	giftID := strings.TrimSpace(c.Param("gift_id"))
	if giftID == "" {
		response.BadRequest(c, "ID 参数不正确")
		return
	}
	resp, err := h.wallet.SetGiftConfigStatus(c.Request.Context(), &walletv1.SetGiftConfigStatusRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		GiftId:         giftID,
		Status:         status,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	gift := giftFromProto(resp.GetGift())
	h.auditLog(c, "set-gift-status", "gift_configs", gift.GiftID, "success", status)
	response.OK(c, gift)
}

func (h *Handler) setResourceShopItemStatus(c *gin.Context, status string) {
	shopItemID, ok := parseID(c, "shop_item_id")
	if !ok {
		return
	}
	resp, err := h.wallet.SetResourceShopItemStatus(c.Request.Context(), &walletv1.SetResourceShopItemStatusRequest{
		RequestId:      middleware.CurrentRequestID(c),
		AppCode:        appctx.FromContext(c.Request.Context()),
		ShopItemId:     shopItemID,
		Status:         status,
		OperatorUserId: actorID(c),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	item := resourceShopItemFromProto(resp.GetItem())
	h.auditLog(c, "set-resource-shop-item-status", "resource_shop_items", fmt.Sprintf("%d", item.ShopItemID), "success", status)
	response.OK(c, item)
}

func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, status string, detail string) {
	shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, status, detail)
}

func actorID(c *gin.Context) int64 {
	return int64(shared.ActorFromContext(c).UserID)
}

func normalizeMP4LayoutBatchItems(items []resourceMP4LayoutUpdateRequest) ([]resourceMP4LayoutUpdateRequest, error) {
	const maxMP4LayoutBatchSize = 50
	if len(items) == 0 {
		return nil, fmt.Errorf("请选择要更新的 MP4 资源")
	}
	if len(items) > maxMP4LayoutBatchSize {
		return nil, fmt.Errorf("单次最多更新 %d 个 MP4 资源", maxMP4LayoutBatchSize)
	}
	seen := map[int64]struct{}{}
	normalized := make([]resourceMP4LayoutUpdateRequest, 0, len(items))
	for _, item := range items {
		if item.ResourceID <= 0 {
			return nil, fmt.Errorf("资源 ID 不正确")
		}
		if _, ok := seen[item.ResourceID]; ok {
			continue
		}
		seen[item.ResourceID] = struct{}{}
		normalized = append(normalized, resourceMP4LayoutUpdateRequest{
			ResourceID:   item.ResourceID,
			MetadataJSON: strings.TrimSpace(item.MetadataJSON),
		})
	}
	if len(normalized) == 0 {
		return nil, fmt.Errorf("请选择要更新的 MP4 资源")
	}
	return normalized, nil
}

func resourceAnimationSourceIsMP4(item *walletv1.Resource) bool {
	if item == nil {
		return false
	}
	return resourceSourceIsMP4(item.GetAnimationUrl()) || resourceSourceIsMP4(item.GetAssetUrl())
}

func resourceSourceIsMP4(value string) bool {
	source := strings.TrimSpace(value)
	if source == "" {
		return false
	}
	if before, _, ok := strings.Cut(source, "?"); ok {
		source = before
	}
	if before, _, ok := strings.Cut(source, "#"); ok {
		source = before
	}
	return strings.HasSuffix(strings.ToLower(source), ".mp4")
}

func mp4LayoutMetadataForResource(current *walletv1.Resource, metadataJSON string) (string, error) {
	incoming, fields, err := parseResourceMetadataPayload(metadataJSON)
	if err != nil {
		return "", fmt.Errorf("资源元数据格式不正确")
	}
	if incoming == nil {
		return "", fmt.Errorf("MP4 透明布局参数不完整")
	}
	if _, ok := fields["mp4_alpha_layout"]; !ok {
		return "", fmt.Errorf("MP4 透明布局参数不完整")
	}
	if err := validateResourceMetadata(resourceRequest{
		ResourceType: current.GetResourceType(),
		MetadataJSON: metadataJSON,
	}); err != nil {
		return "", err
	}
	layout, err := sanitizeMP4AlphaLayoutMetadata(incoming.MP4AlphaLayout)
	if err != nil {
		return "", err
	}
	// 这个接口是“只更新 MP4 布局”的窄接口，所以其它 metadata 不信任前端请求；
	// 资料卡布局从当前资源保留，MP4 布局使用本次重算结果，最终再走统一 marshal 过滤空字段。
	merged := resourceMetadataPayload{
		AnimationFormat: resourceAnimationFormatMP4,
		MP4AlphaLayout:  layout,
	}
	if current.GetResourceType() == resourceTypeProfileCard {
		currentPayload, _, _ := parseResourceMetadataPayload(current.GetMetadataJson())
		if currentPayload != nil {
			merged.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(currentPayload.ProfileCardLayout)
		}
	}
	return marshalResourceMetadataPayload(merged), nil
}

func updateResourceMP4MetadataProto(c *gin.Context, current *walletv1.Resource, metadataJSON string) *walletv1.UpdateResourceRequest {
	managerGrantEnabled := current.GetManagerGrantEnabled()
	return &walletv1.UpdateResourceRequest{
		RequestId:           middleware.CurrentRequestID(c),
		AppCode:             appctx.FromContext(c.Request.Context()),
		ResourceId:          current.GetResourceId(),
		ResourceCode:        current.GetResourceCode(),
		ResourceType:        current.GetResourceType(),
		Name:                current.GetName(),
		Status:              current.GetStatus(),
		Grantable:           current.GetGrantable(),
		GrantStrategy:       current.GetGrantStrategy(),
		WalletAssetType:     current.GetWalletAssetType(),
		WalletAssetAmount:   current.GetWalletAssetAmount(),
		UsageScopes:         append([]string(nil), current.GetUsageScopes()...),
		AssetUrl:            current.GetAssetUrl(),
		PreviewUrl:          current.GetPreviewUrl(),
		AnimationUrl:        current.GetAnimationUrl(),
		MetadataJson:        metadataJSON,
		SortOrder:           current.GetSortOrder(),
		OperatorUserId:      actorID(c),
		ManagerGrantEnabled: &managerGrantEnabled,
		PriceType:           current.GetPriceType(),
		CoinPrice:           current.GetCoinPrice(),
	}
}

func parseID(c *gin.Context, name string) (int64, bool) {
	raw := strings.TrimSpace(c.Param(name))
	value, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || value <= 0 {
		response.BadRequest(c, "ID 参数不正确")
		return 0, false
	}
	return value, true
}

func optionalInt64Query(c *gin.Context, primary string, fallback string) (int64, bool) {
	value, _, ok := optionalInt64QueryWithPresence(c, primary, fallback)
	return value, ok
}

func giftTypeCodeQuery(c *gin.Context) string {
	value := strings.TrimSpace(c.Query("giftTypeCode"))
	if value == "" {
		value = strings.TrimSpace(c.Query("gift_type_code"))
	}
	return value
}

func optionalInt64QueryWithPresence(c *gin.Context, primary string, fallback string) (int64, bool, bool) {
	raw := strings.TrimSpace(c.Query(primary))
	if raw == "" {
		raw = strings.TrimSpace(c.Query(fallback))
	}
	if raw == "" {
		return 0, false, true
	}
	value, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || value < 0 {
		response.BadRequest(c, primary+" 参数不正确")
		return 0, false, false
	}
	return value, true, true
}

func firstQuery(c *gin.Context, keys ...string) string {
	for _, key := range keys {
		value := strings.TrimSpace(c.Query(key))
		if value != "" {
			return value
		}
	}
	return ""
}
