package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
	"math"
	"strings"
	"time"
)

func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
	items, err := r.listUserResources(ctx, query)
	return items, mapTransientResourceReadError(err)
}

func (r *Repository) listUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	if query.UserID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	nowMs := time.Now().UnixMilli()

	where := `WHERE e.app_code = ? AND e.user_id = ?`
	args := []any{appcode.FromContext(ctx), query.UserID}
	if strings.TrimSpace(query.ResourceType) != "" {
		resourceType := resourcedomain.NormalizeResourceType(query.ResourceType)
		if !resourcedomain.ValidResourceType(resourceType) {
			return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
		}
	}
	// 背包和资料读取只能过滤事实，不能顺带修改 equipment。过期/失效条件由下方
	// active 谓词和后台单写者清理共同收敛，避免同一用户并发读退化成 DELETE 写锁竞争。
	if query.ActiveOnly {

		where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0`
		args = append(args, nowMs, nowMs)
	}
	rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.UserResourceEntitlement, 0)
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		if strings.TrimSpace(query.ResourceType) == "" || resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.NormalizeResourceType(query.ResourceType) {
			items = append(items, item)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	// reconcile 会实际回收旧等级身份 entitlement；这里仍保留读取侧门禁，防止历史脏数据、
	// 主从延迟或尚未触发懒同步的老用户短暂暴露多等级称号、长徽章和短徽章。
	items, err = r.filterCurrentVipResourceList(ctx, query.UserID, items, nowMs)
	if err != nil {
		return nil, err
	}
	return items, nil
}

// filterCurrentVipResourceList 在一次固定批查询内同时收敛背包可见性和 equipped 标志。
// 非身份型收藏继续按历级持有；三类身份素材只允许 effective level 作为防御性返回结果。
func (r *Repository) filterCurrentVipResourceList(ctx context.Context, userID int64, items []resourcedomain.UserResourceEntitlement, nowMS int64) ([]resourcedomain.UserResourceEntitlement, error) {
	if len(items) == 0 {
		return items, nil
	}
	grouped := map[int64][]resourcedomain.UserResourceEntitlement{userID: items}
	if err := r.filterCurrentVipEquipmentBatch(ctx, []int64{userID}, grouped, nowMS, true); err != nil {
		return nil, err
	}
	return grouped[userID], nil
}

// ListVipBenefitRequirementsForResource 查询资源在 tiered VIP 中的当前或历史显式绑定。
// program disabled、权益删除和 A->B 改绑都不能让 A 退化成普通资源；是否当前可用仍由
// CheckVipBenefit 与仓储事务内 exact-binding 门禁判定。legacy_timed 快照始终排除。
func (r *Repository) ListVipBenefitRequirementsForResource(ctx context.Context, resourceID int64) ([]resourcedomain.VipBenefitRequirement, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if resourceID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	rows, err := r.db.QueryContext(ctx, `
			SELECT binding.benefit_code, MIN(binding.level), binding.resource_type
			FROM (
			  SELECT b.benefit_code, b.level, b.resource_type
			  FROM vip_level_benefits AS b
			  JOIN vip_program_configs AS p ON p.app_code = b.app_code
			   AND p.program_type = ?
			  WHERE b.app_code = ? AND b.resource_id = ?
			  UNION ALL
			  SELECT h.benefit_code, h.level, h.resource_type
			  FROM vip_benefit_resource_binding_history AS h
			  WHERE h.app_code = ? AND h.resource_id = ? AND h.program_type = ?
			) AS binding
			GROUP BY binding.benefit_code, binding.resource_type
			ORDER BY MIN(binding.level) ASC, binding.benefit_code ASC`,
		ledger.VipProgramTypeTieredPrivilegeV1,
		appcode.FromContext(ctx), resourceID,
		appcode.FromContext(ctx), resourceID, ledger.VipProgramTypeTieredPrivilegeV1,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.VipBenefitRequirement, 0, 1)
	for rows.Next() {
		var item resourcedomain.VipBenefitRequirement
		if err := rows.Scan(&item.BenefitCode, &item.RequiredLevel, &item.ResourceType); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

// ReplayEquipUserResource 在任何当前状态校验前按主键读取已完成命令。
// 该查询只触达 append-only command 表；未命中时首次/并发请求继续进入事务 guard 收敛。
func (r *Repository) ReplayEquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, bool, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UserResourceEntitlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.EntitlementID = strings.TrimSpace(command.EntitlementID)
	if command.CommandID == "" {
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	var storedHash string
	var responseJSON sql.NullString
	err := r.db.QueryRowContext(ctx, `
		SELECT request_hash, CAST(response_json AS CHAR)
		FROM user_resource_equipment_commands
		WHERE app_code = ? AND command_id = ?`, command.AppCode, command.CommandID,
	).Scan(&storedHash, &responseJSON)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, false, err
	}
	if storedHash != resourceEquipmentRequestHash(command) {
		return resourcedomain.UserResourceEntitlement{}, false, xerr.New(xerr.IdempotencyConflict, "resource equip command payload conflict")
	}
	if !responseJSON.Valid || strings.TrimSpace(responseJSON.String) == "" {
		// committed command rows always contain response_json；空值仅作为并发首次事务内的临时状态，
		// 普通读看不到未提交行，因此这里防御性地继续事务 guard。
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	var replayed resourcedomain.UserResourceEntitlement
	if err := json.Unmarshal([]byte(responseJSON.String), &replayed); err != nil {
		return resourcedomain.UserResourceEntitlement{}, false, xerr.New(xerr.Internal, "resource equip command response is invalid")
	}
	return replayed, true, nil
}

// RevokeUserResource forcibly invalidates exactly one entitlement. It deliberately does not mutate
// the source grant because a resource-group grant can contain sibling assets that must stay active.
func (r *Repository) RevokeUserResource(ctx context.Context, command resourcedomain.RevokeUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.RequestID = strings.TrimSpace(command.RequestID)
	command.EntitlementID = strings.TrimSpace(command.EntitlementID)
	command.Reason = strings.TrimSpace(command.Reason)
	if command.UserID <= 0 || command.EntitlementID == "" || command.Reason == "" || command.OperatorUserID <= 0 {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user resource revoke command is incomplete")
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	defer func() { _ = tx.Rollback() }()

	entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, command.EntitlementID)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if entitlement.UserID != command.UserID {
		// Do not reveal that another user's entitlement exists when a mismatched user path is supplied.
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}

	nowMs := time.Now().UnixMilli()
	changed := entitlement.Status == resourcedomain.StatusActive || entitlement.RemainingQuantity > 0
	if changed {
		result, err := tx.ExecContext(ctx, `
			UPDATE user_resource_entitlements
			SET status = ?, remaining_quantity = 0, updated_at_ms = ?
			WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
			resourcedomain.GrantStatusRevoked, nowMs, command.AppCode, command.EntitlementID, command.UserID,
		)
		if err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
		affected, err := result.RowsAffected()
		if err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
		if affected != 1 {
			return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.LedgerConflict, "user resource revoke state conflict")
		}
	}

	// Removing equipment is part of the same transaction as entitlement invalidation, so clients can
	// never observe a revoked badge/frame still selected as the user's current appearance.
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM user_resource_equipment
		WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
		command.AppCode, command.UserID, command.EntitlementID,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_vip_trial_cards
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND entitlement_id = ? AND status = ?`,
		ledger.VipTrialCardStatusRevoked, nowMs, command.AppCode, command.UserID,
		command.EntitlementID, ledger.VipTrialCardStatusActive,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}

	if changed {
		eventCommandID := "revoke-user-resource:" + stableHash(fmt.Sprintf("%s|%d|%s", command.AppCode, command.UserID, command.EntitlementID))
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
			resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, entitlement.ResourceID, map[string]any{
				"action":             "revoke",
				"app_code":           command.AppCode,
				"user_id":            command.UserID,
				"resource_id":        entitlement.ResourceID,
				"resource_type":      entitlement.Resource.ResourceType,
				"entitlement_id":     command.EntitlementID,
				"source_grant_id":    entitlement.SourceGrantID,
				"status":             resourcedomain.GrantStatusRevoked,
				"remaining_quantity": 0,
				"revoked_by_user_id": command.OperatorUserID,
				"revoke_reason":      command.Reason,
				"updated_at_ms":      nowMs,
				"event_command":      eventCommandID,
			}, nowMs),
		}); err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
	}

	revoked, err := r.getUserResourceEntitlementTx(ctx, tx, command.EntitlementID)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	return revoked, nil
}

func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.EntitlementID = strings.TrimSpace(command.EntitlementID)
	if command.UserID <= 0 || command.ResourceID <= 0 {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user_id and resource_id are required")
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	if command.CommandID != "" {
		replayed, exists, err := r.lockResourceEquipmentCommand(ctx, tx, command, nowMs)
		if err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
		if exists {
			return replayed, nil
		}
	}
	// 幂等 guard 之后、entitlement/equipment 写锁之前共享锁 VIP program，并在同一事务重做
	// exact resource binding 校验；admin 改绑事务不能再从 service 预检与最终写入之间穿透。
	if err := r.requireCurrentVipResourceBindingForUserTx(ctx, tx, command.UserID, command.ResourceID, nowMs); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
		// 只有 service 的通用背包适配层可以把体验卡委托给专用状态机；仓储直写会绕过 program 开关和 VIP outbox。
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "vip_trial_card must use the VIP trial-card state machine")
	}
	if !isEquipableResourceType(entitlement.Resource.ResourceType) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if err := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	entitlement.Equipped = true
	eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	if command.CommandID != "" {
		// command_id 已在同一事务的 guard 行中锁定，outbox 也复用稳定键，保证 HTTP 超时重试不重复投递装扮变化。
		eventCommandID = boundedDerivedCommandID("equip:", command.CommandID)
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{
			"action":        "equip",
			"app_code":      command.AppCode,
			"user_id":       command.UserID,
			"resource_id":   command.ResourceID,
			"resource_type": entitlement.Resource.ResourceType,
			"entitlement":   entitlement,
			"updated_at_ms": nowMs,
			"event_command": eventCommandID,
		}, nowMs),
	}); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if command.CommandID != "" {
		if err := r.completeResourceEquipmentCommandTx(ctx, tx, command, entitlement, nowMs); err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	return entitlement, nil
}

// lockResourceEquipmentCommand 在 entitlement/equipment 业务锁之前占用幂等键。
// 同键并发请求会等待首事务提交，然后读取不可变 response_json；payload 不同则稳定返回冲突。
func (r *Repository) lockResourceEquipmentCommand(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) {
	requestHash := resourceEquipmentRequestHash(command)
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_resource_equipment_commands (
		   app_code, command_id, request_hash, user_id, resource_id, requested_entitlement_id,
		   result_entitlement_id, resource_type, response_json, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, '', '', NULL, ?, ?)
		 ON DUPLICATE KEY UPDATE command_id = VALUES(command_id)`,
		command.AppCode, command.CommandID, requestHash, command.UserID, command.ResourceID,
		command.EntitlementID, nowMs, nowMs,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, false, err
	}
	var storedHash string
	var responseJSON sql.NullString
	if err := tx.QueryRowContext(ctx, `
		SELECT request_hash, CAST(response_json AS CHAR)
		FROM user_resource_equipment_commands
		WHERE app_code = ? AND command_id = ?
		FOR UPDATE`, command.AppCode, command.CommandID,
	).Scan(&storedHash, &responseJSON); err != nil {
		return resourcedomain.UserResourceEntitlement{}, false, err
	}
	if storedHash != requestHash {
		return resourcedomain.UserResourceEntitlement{}, false, xerr.New(xerr.IdempotencyConflict, "resource equip command payload conflict")
	}
	if !responseJSON.Valid || strings.TrimSpace(responseJSON.String) == "" {
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	var replayed resourcedomain.UserResourceEntitlement
	if err := json.Unmarshal([]byte(responseJSON.String), &replayed); err != nil {
		return resourcedomain.UserResourceEntitlement{}, false, xerr.New(xerr.Internal, "resource equip command response is invalid")
	}
	return replayed, true, nil
}

func resourceEquipmentRequestHash(command resourcedomain.EquipUserResourceCommand) string {
	return stableHash(fmt.Sprintf("resource_equip|%s|%d|%d|%s", appcode.Normalize(command.AppCode), command.UserID, command.ResourceID, strings.TrimSpace(command.EntitlementID)))
}

// completeResourceEquipmentCommandTx 与装备关系、VIP 体验卡切换共用同一事务提交回包快照。
// 因而资源随后到期或 VIP 等级变化时，相同 command_id 仍能在所有当前状态校验前原样重放。
func (r *Repository) completeResourceEquipmentCommandTx(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, entitlement resourcedomain.UserResourceEntitlement, nowMS int64) error {
	responseJSON, err := json.Marshal(entitlement)
	if err != nil {
		return err
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE user_resource_equipment_commands
		SET result_entitlement_id = ?, resource_type = ?, response_json = ?, updated_at_ms = ?
		WHERE app_code = ? AND command_id = ?`,
		entitlement.EntitlementID, entitlement.Resource.ResourceType, responseJSON, nowMS,
		command.AppCode, command.CommandID,
	)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected != 1 {
		return xerr.New(xerr.Internal, "resource equip command completion is missing")
	}
	return nil
}

func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	if command.UserID <= 0 || command.ResourceType == "" {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required")
	}
	if command.ResourceType == resourcedomain.TypeVIPTrialCard {
		// 防御性拒绝仓储直写；公开通用入口会在 service 层委托 UnequipVipTrialCard 并发布完整状态事件。
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "vip_trial_card must use the VIP trial-card state machine")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge {

		return resourcedomain.UnequipUserResourceResult{
			ResourceType: command.ResourceType,
			Unequipped:   false,
			UpdatedAtMS:  time.Now().UnixMilli(),
		}, nil
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		DELETE FROM user_resource_equipment
		WHERE app_code = ? AND user_id = ? AND resource_type = ?`,
		command.AppCode, command.UserID, command.ResourceType,
	)
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	if affected > 0 {

		eventCommandID := fmt.Sprintf("unequip:%d:%s:%d", command.UserID, command.ResourceType, time.Now().UnixNano())
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
			resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, 0, map[string]any{
				"action":        "unequip",
				"app_code":      command.AppCode,
				"user_id":       command.UserID,
				"resource_type": command.ResourceType,
				"updated_at_ms": nowMs,
				"event_command": eventCommandID,
			}, nowMs),
		}); err != nil {
			return resourcedomain.UnequipUserResourceResult{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	return resourcedomain.UnequipUserResourceResult{
		ResourceType: command.ResourceType,
		Unequipped:   affected > 0,
		UpdatedAtMS:  nowMs,
	}, nil
}

func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
	items, err := r.batchGetUserEquippedResources(ctx, query)
	return items, mapTransientResourceReadError(err)
}

func (r *Repository) batchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	userIDs := compactPositiveInt64s(query.UserIDs)
	if len(userIDs) == 0 {
		return []resourcedomain.UserEquippedResources{}, nil
	}
	benefitCodes := normalizeVipBenefitCodes(query.VIPBenefitCodes)
	// 展示批次只服务一页用户；显式限制矩阵规模，避免内部误用把动态 IN 查询扩大成无界扫描。
	if len(benefitCodes) > 16 || (len(benefitCodes) > 0 && len(userIDs) > 100) {
		return nil, xerr.New(xerr.InvalidArgument, "vip benefit presentation batch is too large")
	}

	resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes)
	if err != nil {
		return nil, err
	}
	nowMs := time.Now().UnixMilli()

	// appearance/在线用户批量读不再物理清理 equipment；查询本身完整过滤失效权益，
	// 后台任务按稳定主键分页删除残留行，读请求不会因为清理死锁成为 1213 受害者。
	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
	args = append(args, nowMs, nowMs)
	typeFilter := ""
	if len(resourceTypes) > 0 {

		typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)`
		args = append(args, stringAnyArgs(resourceTypes)...)
	}

	rows, err := r.db.QueryContext(ctx, `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.source_snapshot_id, e.resource_snapshot_hash,
		       CAST(e.resource_snapshot_json AS CHAR), e.gift_snapshot_hash,
		       CAST(e.gift_snapshot_json AS CHAR), e.created_at_ms, e.updated_at_ms,
		       TRUE AS equipped,
		       `+resourceColumnsWithAlias("r")+`
		FROM user_resource_equipment eq
		JOIN user_resource_entitlements e ON e.app_code = eq.app_code
		 AND e.user_id = eq.user_id
		 AND e.resource_id = eq.resource_id
		 AND e.entitlement_id = eq.entitlement_id
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`)
		  AND e.status = 'active'
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		  AND e.remaining_quantity > 0
		  AND (e.source_snapshot_id <> '' OR r.status = 'active')`+typeFilter+`
		ORDER BY eq.user_id ASC, eq.updated_at_ms DESC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs))
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		item.Equipped = true
		grouped[item.UserID] = append(grouped[item.UserID], item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	// appearance 是高频批量读，不能逐用户调用 CheckVipBenefit。这里用固定三次、全部走
	// app/user/resource 覆盖索引的批查询，过滤掉改绑、删除、program disabled 后的历史 VIP 装备。
	if err := r.filterCurrentVipEquipmentBatch(ctx, userIDs, grouped, nowMs, false); err != nil {
		return nil, err
	}
	allowedBenefits, effectiveVIPLevels, err := r.batchAllowedVipBenefits(ctx, userIDs, benefitCodes, nowMs)
	if err != nil {
		return nil, err
	}

	result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs))
	for _, userID := range userIDs {
		result = append(result, resourcedomain.UserEquippedResources{
			UserID:                 userID,
			Resources:              grouped[userID],
			AllowedVIPBenefitCodes: allowedBenefits[userID],
			EffectiveVIPLevel:      effectiveVIPLevels[userID],
		})
	}
	return result, nil
}

func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64, strategyOverride string, snapshot resourceEntitlementSnapshot) (string, error) {
	if snapshot.SourceSnapshotID != "" {
		if !validSHA256(snapshot.ResourceSnapshotHash) || strings.TrimSpace(snapshot.ResourceSnapshotJSON) == "" {
			return "", xerr.New(xerr.Internal, "pinned entitlement resource snapshot is incomplete")
		}
		var pinned resourcedomain.Resource
		if err := json.Unmarshal([]byte(snapshot.ResourceSnapshotJSON), &pinned); err != nil || pinned.ResourceID != resource.ResourceID {
			return "", xerr.New(xerr.Internal, "pinned entitlement resource snapshot is invalid")
		}
		contentHash, err := resourcedomain.ResourceContentHash(pinned)
		if err != nil || contentHash != snapshot.ResourceSnapshotHash {
			return "", xerr.New(xerr.Internal, "pinned entitlement resource snapshot hash mismatch")
		}
		if err := validatePinnedEntitlementGiftSnapshot(appcode.FromContext(ctx), pinned, snapshot.GiftSnapshotHash, snapshot.GiftSnapshotJSON); err != nil {
			return "", err
		}
	} else {
		snapshot = resourceEntitlementSnapshot{}
	}
	expiresAt := int64(0)
	if durationMS > 0 {
		if durationMS > math.MaxInt64-nowMs {
			return "", xerr.New(xerr.InvalidArgument, "duration overflow")
		}
		expiresAt = nowMs + durationMS
	}
	strategy := resourcedomain.NormalizeGrantStrategy(strategyOverride)
	if strategy == "" {
		strategy = resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy)
	} else if !resourcedomain.ValidGrantStrategy(strategy) {
		// 覆盖策略只允许由 owner service 的专用业务入口传入；无效值属于服务端编排错误，不能静默回退目录配置。
		return "", xerr.New(xerr.Internal, "entitlement strategy override is invalid")
	}
	if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag {
		existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, snapshot.ResourceSnapshotHash, snapshot.GiftSnapshotHash, nowMs)
		if err != nil {
			return "", err
		}
		if exists {
			newQuantity := existing.Quantity
			newRemaining := existing.RemainingQuantity
			newExpiresAt := existing.ExpiresAtMS
			switch strategy {
			case resourcedomain.GrantStrategyExtendExpiry:
				if strategyOverride != "" && durationMS > 0 && existing.ExpiresAtMS == 0 {
					// 商城不能向永久权益继续出售有限天数：合并后无法区分永久与付费时长的撤销贡献，直接拒绝并回滚扣费。
					return "", xerr.New(xerr.Conflict, "resource is already permanently owned")
				}
				newRemaining += quantity
				newQuantity += quantity
				if durationMS > 0 {
					base := max(existing.ExpiresAtMS, nowMs)
					if durationMS > math.MaxInt64-base {
						return "", xerr.New(xerr.InvalidArgument, "duration overflow")
					}
					newExpiresAt = base + durationMS
				} else if durationMS == 0 {
					newExpiresAt = 0
				}
			case resourcedomain.GrantStrategyIncreaseQuantity:
				newQuantity += quantity
				newRemaining += quantity
			case resourcedomain.GrantStrategySetActiveFlag:
				newQuantity = 1
				newRemaining = 1
				newExpiresAt = expiresAt
			}
			if _, err := tx.ExecContext(ctx, `
				UPDATE user_resource_entitlements
				SET status = 'active', quantity = ?, remaining_quantity = ?, expires_at_ms = ?,
				    source_grant_id = ?, updated_at_ms = ?
				WHERE app_code = ? AND entitlement_id = ?`,
				newQuantity, newRemaining, newExpiresAt, grantID, nowMs, appcode.FromContext(ctx), existing.EntitlementID,
			); err != nil {
				return "", err
			}
			return existing.EntitlementID, nil
		}
	}
	entitlementID := entitlementID(appcode.FromContext(ctx), grantID, resource.ResourceID, nowMs)
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_resource_entitlements (
		   app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
		   effective_at_ms, expires_at_ms, source_grant_id, source_snapshot_id, resource_snapshot_hash,
		   resource_snapshot_json, gift_snapshot_hash, gift_snapshot_json, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, NULLIF(?, ''), ?, ?)`,
		appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID,
		snapshot.SourceSnapshotID, snapshot.ResourceSnapshotHash, snapshot.ResourceSnapshotJSON,
		snapshot.GiftSnapshotHash, snapshot.GiftSnapshotJSON, nowMs, nowMs,
	); err != nil {
		return "", err
	}
	return entitlementID, nil
}

func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
	if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
		return nil
	}
	vipBound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resource.ResourceID)
	if err != nil {
		return err
	}
	if vipBound {
		// 目录资源只要曾属于 tiered VIP，就不能再由 generic grant 按类型自动装备；否则
		// trial_enabled=false 或 benefit.auto_equip=false 会被 mic_seat_animation 默认值绕过。
		return nil
	}
	entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
	if err != nil {
		return err
	}
	if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil {
		return err
	}
	entitlement.Equipped = true

	eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("UserResourceChanged", eventCommandID, userID, resource.ResourceID, map[string]any{
			"action":        "auto_equip",
			"app_code":      appcode.FromContext(ctx),
			"user_id":       userID,
			"resource_id":   resource.ResourceID,
			"resource_type": resource.ResourceType,
			"entitlement":   entitlement,
			"updated_at_ms": nowMs,
			"event_command": eventCommandID,
		}, nowMs),
	})
}

func (r *Repository) equipUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement, nowMs int64) error {
	resourceType := resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType)
	if !isEquipableResourceType(resourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if !allowsMultipleEquippedResources(resourceType) {

		if _, err := tx.ExecContext(ctx, `
			DELETE FROM user_resource_equipment
			WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id <> ?`,
			appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID,
		); err != nil {
			return err
		}
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_resource_equipment (
		   app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		   resource_id = VALUES(resource_id),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs,
	)
	return err
}

func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error {
	if strings.TrimSpace(entitlementID) == "" {
		return nil
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE user_resource_entitlements
		SET expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND entitlement_id = ?`,
		expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID,
	)
	return err
}

func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, resourceSnapshotHash string, giftSnapshotHash string, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.source_snapshot_id, e.resource_snapshot_hash,
		       CAST(e.resource_snapshot_json AS CHAR), e.gift_snapshot_hash,
		       CAST(e.gift_snapshot_json AS CHAR), e.created_at_ms, e.updated_at_ms,
		       FALSE AS equipped,
		       `+resourceColumnsWithAlias("r")+`
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active'
		  AND e.resource_snapshot_hash = ? AND e.gift_snapshot_hash = ?
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC
		LIMIT 1
		FOR UPDATE`,
		appcode.FromContext(ctx), userID, resourceID, resourceSnapshotHash, giftSnapshotHash, nowMs, nowMs,
	)
	item, err := scanUserResourceEntitlement(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	return item, err == nil, err
}

func (r *Repository) queryEquipableEntitlementForUpdate(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, error) {
	where := `
		WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active'
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		  AND e.remaining_quantity > 0 AND (e.source_snapshot_id <> '' OR r.status = 'active')`
	args := []any{appcode.FromContext(ctx), command.UserID, command.ResourceID, nowMs, nowMs}
	if command.EntitlementID != "" {
		where += ` AND e.entitlement_id = ?`
		args = append(args, command.EntitlementID)
	}
	query := userResourceSelectSQL() + where + `
		ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC
		LIMIT 1
		FOR UPDATE`
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, query, args...))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
		userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`,
		appcode.FromContext(ctx), entitlementID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func (r *Repository) getUserResourceEntitlementForUpdateTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
		userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ? FOR UPDATE`,
		appcode.FromContext(ctx), entitlementID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) {
	var item resourcedomain.UserResourceEntitlement
	var resource resourcedomain.Resource
	var scopesJSON string
	var resourceSnapshotJSON sql.NullString
	var giftSnapshotJSON sql.NullString
	if err := scanner.Scan(
		&item.AppCode,
		&item.EntitlementID,
		&item.UserID,
		&item.ResourceID,
		&item.Status,
		&item.Quantity,
		&item.RemainingQuantity,
		&item.EffectiveAtMS,
		&item.ExpiresAtMS,
		&item.SourceGrantID,
		&item.SourceSnapshotID,
		&item.ResourceSnapshotHash,
		&resourceSnapshotJSON,
		&item.GiftSnapshotHash,
		&giftSnapshotJSON,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
		&item.Equipped,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.ResourceSnapshotJSON = resourceSnapshotJSON.String
	item.GiftSnapshotJSON = giftSnapshotJSON.String
	if item.SourceSnapshotID != "" {
		if !resourceSnapshotJSON.Valid || !validSHA256(item.ResourceSnapshotHash) {
			return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Internal, "pinned entitlement resource snapshot is incomplete")
		}
		var pinned resourcedomain.Resource
		if err := json.Unmarshal([]byte(resourceSnapshotJSON.String), &pinned); err != nil {
			return resourcedomain.UserResourceEntitlement{}, fmt.Errorf("decode entitlement resource snapshot: %w", err)
		}
		contentHash, err := resourcedomain.ResourceContentHash(pinned)
		if err != nil || contentHash != item.ResourceSnapshotHash || pinned.ResourceID != item.ResourceID {
			return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Internal, "entitlement resource snapshot integrity check failed")
		}
		item.Resource = pinned
		if err := validatePinnedEntitlementGiftSnapshot(item.AppCode, pinned, item.GiftSnapshotHash, item.GiftSnapshotJSON); err != nil {
			return resourcedomain.UserResourceEntitlement{}, err
		}
		if resourcedomain.NormalizeResourceType(pinned.ResourceType) == resourcedomain.TypeGift {
			if err := json.Unmarshal([]byte(item.GiftSnapshotJSON), &item.PinnedGiftConfigs); err != nil {
				return resourcedomain.UserResourceEntitlement{}, fmt.Errorf("decode pinned gift configs: %w", err)
			}
		}
	} else {
		if item.GiftSnapshotHash != "" || giftSnapshotJSON.Valid {
			return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Internal, "legacy entitlement contains a pinned gift snapshot")
		}
		item.Resource = resource
	}
	return item, nil
}

func userResourceSelectSQL() string {
	return `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.source_snapshot_id, e.resource_snapshot_hash,
		       CAST(e.resource_snapshot_json AS CHAR), e.gift_snapshot_hash,
		       CAST(e.gift_snapshot_json AS CHAR), e.created_at_ms, e.updated_at_ms,
		       CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped,
		       ` + resourceColumnsWithAlias("r") + `
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code
		 AND eq.user_id = e.user_id
		 AND eq.entitlement_id = e.entitlement_id
		`
}

func normalizeEquipableResourceTypes(values []string) ([]string, error) {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		resourceType := resourcedomain.NormalizeResourceType(value)
		if resourceType == "" {

			continue
		}
		if !resourcedomain.ValidResourceType(resourceType) || !isEquipableResourceType(resourceType) {
			return nil, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
		}
		if _, exists := seen[resourceType]; exists {

			continue
		}
		seen[resourceType] = struct{}{}
		out = append(out, resourceType)
	}
	return out, nil
}

func allowsMultipleEquippedResources(resourceType string) bool {
	return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge
}

func isAutoEquippedOnGrantResourceType(resourceType string) bool {
	switch resourcedomain.NormalizeResourceType(resourceType) {
	case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation:
		return true
	default:
		return false
	}
}

func isEquipableResourceType(resourceType string) bool {
	switch resourcedomain.NormalizeResourceType(resourceType) {
	// 麦位中心图标和声波动效必须使用两个独立的 resource_type 单选槽；用户切换 mic_seat_icon
	// 时只替换旧图标，不能删除 mic_seat_animation。badge 的多选和 VIP 体验卡的专用状态机
	// 仍由各自分支处理。
	case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeFloatingScreen, resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeVIPTrialCard:
		return true
	default:
		return false
	}
}

func entitlementID(appCode string, grantID string, resourceID int64, nowMs int64) string {
	return "ent_" + stableHash(fmt.Sprintf("%s|%s|%d|%d", appcode.Normalize(appCode), grantID, resourceID, nowMs))
}
