package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"slices"
	"sort"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
)

const (
	bizTypeResourceGrant = "resource_grant"
	resourceOutboxAsset  = "RESOURCE"
)

type scanTarget interface {
	Scan(dest ...any) error
}

type sqlRowQuerier interface {
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type sqlQuerier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

// ListResources 返回后台资源库列表；App 入口会设置 active_only 只读上架资源。
func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceListQuery(query)

	where, args := resourceWhereSQL(query)
	total, err := r.countResourceRows(ctx, "resources", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+`
		`+where+`
		ORDER BY sort_order ASC, resource_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]resourcedomain.Resource, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResource(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// GetResource 读取单个资源详情。
func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

// CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。
func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		INSERT INTO resources (
		   app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy,
		   wallet_asset_type, wallet_asset_amount, usage_scope_json, asset_url, preview_url,
		   animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
		   created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		command.OperatorUserID,
		nowMs,
		nowMs,
	)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	resourceID, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	resource, err := r.getResourceForUpdate(ctx, tx, resourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

// UpdateResource 全量更新资源配置；当前开发阶段不做兼容性合并。
func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ResourceID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		UPDATE resources
		SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?,
		    grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?,
		    usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?,
		    metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND resource_id = ?`,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		nowMs,
		command.AppCode,
		command.ResourceID,
	)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	nowMs := time.Now().UnixMilli()
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()
	result, err := tx.ExecContext(ctx,
		`UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`,
		command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID,
	)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceGroupListQuery(query)

	where, args := resourceGroupWhereSQL(query)
	total, err := r.countResourceRows(ctx, "resource_groups", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, group_id, group_code, name, status, description, sort_order,
		       created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		FROM resource_groups
		`+where+`
		ORDER BY sort_order ASC, group_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	groups := make([]resourcedomain.ResourceGroup, 0, query.PageSize)
	for rows.Next() {
		group, err := scanResourceGroup(rows)
		if err != nil {
			return nil, 0, err
		}
		group.Items, err = r.listResourceGroupItems(ctx, group.GroupID)
		if err != nil {
			return nil, 0, err
		}
		groups = append(groups, group)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return groups, total, nil
}

func (r *Repository) GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	group, err := r.getResourceGroup(ctx, r.db, groupID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	group.Items, err = r.listResourceGroupItems(ctx, groupID)
	return group, err
}

func (r *Repository) CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceGroupCommand(command)
	if err := validateResourceGroupCommand(command, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		INSERT INTO resource_groups (
		   app_code, group_code, name, status, description, sort_order,
		   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder,
		command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	groupID, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.replaceResourceGroupItemsTx(ctx, tx, groupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:create:%d", groupID, nowMs), 0, groupID, command, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return r.GetResourceGroup(ctx, groupID)
}

func (r *Repository) UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.GroupID <= 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceGroupCommand(command)
	if err := validateResourceGroupCommand(command, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		UPDATE resource_groups
		SET group_code = ?, name = ?, status = ?, description = ?, sort_order = ?,
		    updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND group_id = ?`,
		command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder,
		command.OperatorUserID, nowMs, command.AppCode, command.GroupID,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	if err := r.replaceResourceGroupItemsTx(ctx, tx, command.GroupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:update:%d", command.GroupID, nowMs), 0, command.GroupID, command, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return r.GetResourceGroup(ctx, command.GroupID)
}

func (r *Repository) SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ID <= 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := r.getResourceGroup(ctx, tx, command.ID, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if command.Status == resourcedomain.StatusActive {
		items, err := r.listResourceGroupItemsTx(ctx, tx, command.ID, true)
		if err != nil {
			return resourcedomain.ResourceGroup{}, err
		}
		if err := validateActiveResourceGroupItems(items); err != nil {
			return resourcedomain.ResourceGroup{}, err
		}
	}
	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx,
		`UPDATE resource_groups SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`,
		command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	group, err := r.getResourceGroup(ctx, tx, command.ID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.ID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, group, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return group, nil
}

func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeGiftConfigListQuery(query)
	where, args := giftConfigWhereSQL(query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM gift_configs gc
		JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
		`+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+`
		ORDER BY gc.sort_order ASC, gc.created_at_ms DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	nowMs := time.Now().UnixMilli()
	items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
	for rows.Next() {
		item, err := scanGiftConfig(rows)
		if err != nil {
			return nil, 0, err
		}
		item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID)
		if err != nil {
			return nil, 0, err
		}
		price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
		if priceErr == nil {
			item.PriceVersion = price.PriceVersion
			item.ChargeAssetType = price.ChargeAssetType
			item.CoinPrice = price.CoinPrice
			item.GiftPointAmount = price.GiftPointAmount
			item.HeatValue = price.HeatValue
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

func (r *Repository) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
	return r.upsertGiftConfig(ctx, command, false)
}

func (r *Repository) UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
	return r.upsertGiftConfig(ctx, command, true)
}

func (r *Repository) SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if strings.TrimSpace(command.StringID) == "" {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	status := resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(status) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	nowMs := time.Now().UnixMilli()
	result, err := r.db.ExecContext(ctx,
		`UPDATE gift_configs SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`,
		status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), strings.TrimSpace(command.StringID),
	)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.GiftConfig{}, err
	} else if affected == 0 {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}
	gift, err := r.getGiftConfig(ctx, strings.TrimSpace(command.StringID))
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	_ = r.insertResourceOutboxNoTx(ctx, "GiftConfigChanged", fmt.Sprintf("gift:%s:status:%s:%d", gift.GiftID, status, nowMs), 0, gift.ResourceID, gift)
	return gift, nil
}

// GrantResource 发放单个资源；金币资源和非金币权益在同一个事务里处理。
func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGrantResourceCommand(command)
	if err := validateGrantResourceCommand(command); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}

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

	requestHash := grantResourceRequestHash(command)
	if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		return r.getResourceGrantTx(ctx, tx, grantID)
	}
	nowMs := time.Now().UnixMilli()
	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

// GrantResourceGroup 锁定资源组快照并原子发放组内所有资源。
func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGrantResourceGroupCommand(command)
	if err := validateGrantResourceGroupCommand(command); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := grantResourceGroupRequestHash(command)
	if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		return r.getResourceGrantTx(ctx, tx, grantID)
	}
	nowMs := time.Now().UnixMilli()
	group, err := r.getResourceGroup(ctx, tx, command.GroupID, true)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if group.Status != resourcedomain.StatusActive {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group is disabled")
	}
	group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.GroupID, true)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if len(group.Items) == 0 {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	groupSnapshot, err := json.Marshal(group)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	for _, item := range group.Items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
			if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			continue
		}
		if err := validateActiveResourceGroupResource(item.Resource); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

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")
	}
	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")
		}
		where += ` AND r.resource_type = ?`
		args = append(args, resourceType)
	}
	if query.ActiveOnly {
		nowMs := time.Now().UnixMilli()
		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
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

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.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()
	entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if !isEquipableResourceType(entitlement.Resource.ResourceType) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if _, 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
		   entitlement_id = VALUES(entitlement_id),
		   resource_id = VALUES(resource_id),
		   updated_at_ms = VALUES(updated_at_ms)`,
		command.AppCode, command.UserID, entitlement.Resource.ResourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	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 err := tx.Commit(); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	return entitlement, nil
}

func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceGrantsQuery(query)
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.TargetUserID > 0 {
		where += ` AND target_user_id = ?`
		args = append(args, query.TargetUserID)
	}
	if strings.TrimSpace(query.Status) != "" {
		where += ` AND status = ?`
		args = append(args, strings.ToLower(strings.TrimSpace(query.Status)))
	}
	total, err := r.countResourceRows(ctx, "resource_grants", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceGrantSelectSQL()+where+` ORDER BY created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGrant, 0, query.PageSize)
	for rows.Next() {
		grant, err := scanResourceGrant(rows)
		if err != nil {
			return nil, 0, err
		}
		grant.Items, err = r.listResourceGrantItems(ctx, grant.GrantID)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, grant)
	}
	return items, total, rows.Err()
}

func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID)
	grant, err := scanResourceGrant(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found")
	}
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grant.Items, err = r.listResourceGrantItems(ctx, grantID)
	return grant, err
}

func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand, update bool) (resourcedomain.GiftConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGiftConfigCommand(command)
	if err := validateGiftConfigCommand(command); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if resource.ResourceType != resourcedomain.TypeGift {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource")
	}
	if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled")
	}
	nowMs := time.Now().UnixMilli()
	presentation := normalizeJSONObject(command.PresentationJSON)
	effectTypesJSON, err := json.Marshal(command.EffectTypes)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if !update {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO gift_configs (
			   app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
			   gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
			   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
			command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
			command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
		); err != nil {
			return resourcedomain.GiftConfig{}, err
		}
	} else {
		result, err := tx.ExecContext(ctx, `
			UPDATE gift_configs
			SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?,
			    gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?,
			    updated_by_user_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ?`,
			command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
			command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
			command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
		)
		if err != nil {
			return resourcedomain.GiftConfig{}, err
		}
		if affected, err := result.RowsAffected(); err != nil {
			return resourcedomain.GiftConfig{}, err
		} else if affected == 0 {
			return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
		}
	}
	if err := r.upsertGiftPriceTx(ctx, tx, command, nowMs); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := r.replaceGiftConfigRegionsTx(ctx, tx, command.GiftID, command.RegionIDs, nowMs); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:config:%d", command.GiftID, nowMs), 0, command.ResourceID, command, nowMs),
	}); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return r.getGiftConfig(ctx, command.GiftID)
}

func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) {
	if quantity <= 0 {
		return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive")
	}
	snapshot, err := json.Marshal(resource)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	item := resourcedomain.ResourceGrantItem{
		GrantID:              grantID,
		ResourceID:           resource.ResourceID,
		ResourceSnapshotJSON: string(snapshot),
		Quantity:             quantity,
		DurationMS:           durationMS,
		CreatedAtMS:          nowMs,
	}
	if resourcedomain.IsWalletCredit(resource) {
		assetType := resourcedomain.NormalizeWalletAssetType(resource.WalletAssetType)
		amount, err := checkedMul(resource.WalletAssetAmount, quantity)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		walletCommandID := fmt.Sprintf("resource_grant:%s:%d", commandID, resource.ResourceID)
		transactionID := transactionID(appcode.FromContext(ctx), walletCommandID)
		metadata := map[string]any{
			"app_code":        appcode.FromContext(ctx),
			"grant_id":        grantID,
			"command_id":      commandID,
			"target_user_id":  targetUserID,
			"resource_id":     resource.ResourceID,
			"resource_code":   resource.ResourceCode,
			"resource_type":   resource.ResourceType,
			"quantity":        quantity,
			"wallet_amount":   amount,
			"wallet_asset":    assetType,
			"operator_reason": "resource grant",
		}
		if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, resourceWalletCreditHash(grantID, resource.ResourceID, quantity), grantID, metadata, nowMs); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		availableAfter := account.AvailableAmount + amount
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:  transactionID,
			UserID:         targetUserID,
			AssetType:      assetType,
			AvailableDelta: amount,
			FrozenDelta:    0,
			AvailableAfter: availableAfter,
			FrozenAfter:    account.FrozenAmount,
			CreatedAtMS:    nowMs,
		}); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
			balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, metadata, nowMs),
		}); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		item.ResultType = resourcedomain.ResultWalletCredit
		item.WalletTransactionID = transactionID
	} else {
		entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		item.ResultType = resourcedomain.ResultEntitlement
		item.EntitlementID = entitlementID
	}
	inserted, err := r.insertResourceGrantItem(ctx, tx, item)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	return inserted, nil
}

func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, groupItem resourcedomain.ResourceGroupItem, nowMs int64) (resourcedomain.ResourceGrantItem, error) {
	assetType := resourcedomain.NormalizeWalletAssetType(groupItem.WalletAssetType)
	if !validGroupWalletAssetType(assetType) || groupItem.WalletAssetAmount <= 0 {
		return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
	}
	snapshot, err := json.Marshal(map[string]any{
		"group_item_id":       groupItem.GroupItemID,
		"group_id":            groupItem.GroupID,
		"item_type":           resourcedomain.GroupItemTypeWalletAsset,
		"wallet_asset_type":   assetType,
		"wallet_asset_amount": groupItem.WalletAssetAmount,
		"sort_order":          groupItem.SortOrder,
	})
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType)
	transactionID := transactionID(appcode.FromContext(ctx), walletCommandID)
	metadata := map[string]any{
		"app_code":        appcode.FromContext(ctx),
		"grant_id":        grantID,
		"command_id":      commandID,
		"target_user_id":  targetUserID,
		"group_id":        groupItem.GroupID,
		"group_item_id":   groupItem.GroupItemID,
		"item_type":       resourcedomain.GroupItemTypeWalletAsset,
		"wallet_amount":   groupItem.WalletAssetAmount,
		"wallet_asset":    assetType,
		"operator_reason": "resource group grant",
	}
	if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, groupItem.WalletAssetAmount, 0, nowMs); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	availableAfter := account.AvailableAmount + groupItem.WalletAssetAmount
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         targetUserID,
		AssetType:      assetType,
		AvailableDelta: groupItem.WalletAssetAmount,
		FrozenDelta:    0,
		AvailableAfter: availableAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, metadata, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	inserted, err := r.insertResourceGrantItem(ctx, tx, resourcedomain.ResourceGrantItem{
		GrantID:              grantID,
		ResourceID:           0,
		ResourceSnapshotJSON: string(snapshot),
		Quantity:             groupItem.WalletAssetAmount,
		DurationMS:           0,
		ResultType:           resourcedomain.ResultWalletCredit,
		WalletTransactionID:  transactionID,
		CreatedAtMS:          nowMs,
	})
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	return inserted, nil
}

func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (string, error) {
	expiresAt := int64(0)
	if durationMS > 0 {
		if durationMS > math.MaxInt64-nowMs {
			return "", xerr.New(xerr.InvalidArgument, "duration overflow")
		}
		expiresAt = nowMs + durationMS
	}
	strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy)
	if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag {
		existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, nowMs)
		if err != nil {
			return "", err
		}
		if exists {
			newQuantity := existing.Quantity
			newRemaining := existing.RemainingQuantity
			newExpiresAt := existing.ExpiresAtMS
			switch strategy {
			case resourcedomain.GrantStrategyExtendExpiry:
				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 {
					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, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID, nowMs, nowMs,
	); err != nil {
		return "", err
	}
	return entitlementID, nil
}

func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, 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.created_at_ms, e.updated_at_ms,
		       `+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.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, 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 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) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) {
	result, err := tx.ExecContext(ctx, `
		INSERT INTO resource_grant_items (
		   app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms,
		   result_type, wallet_transaction_id, entitlement_id, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), item.GrantID, item.ResourceID, item.ResourceSnapshotJSON,
		item.Quantity, item.DurationMS, item.ResultType, item.WalletTransactionID, item.EntitlementID, item.CreatedAtMS,
	)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	item.GrantItemID = id
	return item, nil
}

func (r *Repository) insertResourceGrant(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, grantSource string, subjectType string, subjectID string, requestHash string, groupSnapshotJSON string, reason string, operatorUserID int64, nowMs int64) error {
	var snapshot any
	if strings.TrimSpace(groupSnapshotJSON) == "" {
		snapshot = nil
	} else {
		snapshot = groupSnapshotJSON
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO resource_grants (
		   app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
		   grant_subject_id, status, request_hash, group_snapshot_json, reason,
		   operator_user_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), grantID, commandID, targetUserID, grantSource, subjectType,
		subjectID, resourcedomain.GrantStatusDone, requestHash, snapshot, reason, operatorUserID, nowMs, nowMs,
	)
	return err
}

func (r *Repository) lookupResourceGrant(ctx context.Context, tx *sql.Tx, commandID string, requestHash string) (string, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT grant_id, request_hash FROM resource_grants WHERE app_code = ? AND command_id = ? FOR UPDATE`,
		appcode.FromContext(ctx), commandID,
	)
	var grantID string
	var storedHash string
	if err := row.Scan(&grantID, &storedHash); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", false, nil
		}
		return "", false, err
	}
	if storedHash != requestHash {
		return "", true, xerr.New(xerr.LedgerConflict, "resource grant idempotency conflict")
	}
	return grantID, true, nil
}

func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) {
	row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID)
	grant, err := scanResourceGrant(row)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grantID)
	return grant, err
}

func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID)
}

func (r *Repository) listResourceGrantItemsTx(ctx context.Context, tx *sql.Tx, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	return r.listResourceGrantItemsWithQuery(ctx, tx, grantID)
}

func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querier interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT grant_item_id, grant_id, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'),
		       quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms
		FROM resource_grant_items
		WHERE app_code = ? AND grant_id = ?
		ORDER BY grant_item_id ASC`,
		appcode.FromContext(ctx), grantID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGrantItem, 0)
	for rows.Next() {
		var item resourcedomain.ResourceGrantItem
		if err := rows.Scan(&item.GrantItemID, &item.GrantID, &item.ResourceID, &item.ResourceSnapshotJSON, &item.Quantity, &item.DurationMS, &item.ResultType, &item.WalletTransactionID, &item.EntitlementID, &item.CreatedAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO wallet_gift_prices (
		   app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount,
		   heat_value, effective_at_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		   status = VALUES(status),
		   charge_asset_type = VALUES(charge_asset_type),
		   coin_price = VALUES(coin_price),
		   gift_point_amount = VALUES(gift_point_amount),
		   heat_value = VALUES(heat_value),
		   effective_at_ms = VALUES(effective_at_ms),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice,
		command.GiftPointAmount, command.HeatValue, command.EffectiveAtMS, nowMs, nowMs,
	)
	return err
}

func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) {
	row := querier.QueryRowContext(ctx,
		`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
		 FROM wallet_gift_prices
		 WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
		 ORDER BY effective_at_ms DESC, price_version DESC
		 LIMIT 1`,
		appcode.FromContext(ctx), giftID, nowMs,
	)
	var price giftPrice
	if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
		}
		return giftPrice{}, err
	}
	price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
	return price, nil
}

func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) {
	row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID)
	gift, err := scanGiftConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
	if priceErr == nil {
		gift.PriceVersion = price.PriceVersion
		gift.ChargeAssetType = price.ChargeAssetType
		gift.CoinPrice = price.CoinPrice
		gift.GiftPointAmount = price.GiftPointAmount
		gift.HeatValue = price.HeatValue
	}
	gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return gift, nil
}

func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
	nowMs := time.Now().UnixMilli()
	row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`
		WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
		  AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
		  AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)
		  AND EXISTS (
		    SELECT 1
		    FROM gift_config_regions gcr
		    WHERE gcr.app_code = gc.app_code
		      AND gcr.gift_id = gc.gift_id
		      AND (gcr.region_id = 0 OR gcr.region_id = ?)
		  )
		FOR UPDATE`,
		appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID,
	)
	gift, err := scanGiftConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config is not active")
	}
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
	return gift, err
}

func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) {
	row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) {
	query := `
		SELECT app_code, group_id, group_code, name, status, description, sort_order,
		       created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		FROM resource_groups
		WHERE app_code = ? AND group_id = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), groupID)
	group, err := scanResourceGroup(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	return group, err
}

func (r *Repository) listResourceGroupItems(ctx context.Context, groupID int64) ([]resourcedomain.ResourceGroupItem, error) {
	return r.listResourceGroupItemsWithQuery(ctx, r.db, groupID)
}

func (r *Repository) listResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, lock bool) ([]resourcedomain.ResourceGroupItem, error) {
	if lock {
		if err := r.lockResourceGroupItemRows(ctx, tx, groupID); err != nil {
			return nil, err
		}
	}
	items, err := r.listResourceGroupItemsWithQuery(ctx, tx, groupID)
	if err != nil || !lock {
		return items, err
	}
	for index := range items {
		if resourcedomain.NormalizeGroupItemType(items[index].ItemType) != resourcedomain.GroupItemTypeResource {
			continue
		}
		resource, err := r.getResourceForUpdate(ctx, tx, items[index].ResourceID)
		if err != nil {
			return nil, err
		}
		items[index].Resource = resource
	}
	return items, nil
}

func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querier interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, groupID int64) ([]resourcedomain.ResourceGroupItem, error) {
	query := `
		SELECT i.group_item_id, i.group_id, i.item_type, i.resource_id,
		       i.wallet_asset_type, i.wallet_asset_amount,
		       ` + nullableResourceColumnsWithAlias("r") + `,
		       i.quantity, i.duration_ms, i.sort_order, i.created_at_ms, i.updated_at_ms
		FROM resource_group_items i
		LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
		WHERE i.app_code = ? AND i.group_id = ?
		ORDER BY i.sort_order ASC, i.group_item_id ASC`
	rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGroupItem, 0)
	for rows.Next() {
		item, err := scanResourceGroupItem(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx, groupID int64) error {
	rows, err := tx.QueryContext(ctx, `
		SELECT group_item_id
		FROM resource_group_items
		WHERE app_code = ? AND group_id = ?
		ORDER BY sort_order ASC, group_item_id ASC
		FOR UPDATE`,
		appcode.FromContext(ctx), groupID,
	)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return err
		}
	}
	return rows.Err()
}

func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, items []resourcedomain.ResourceGroupItemInput, requireGrantable bool, nowMs int64) error {
	seenResources := make(map[int64]struct{}, len(items))
	seenWalletAssets := make(map[string]struct{}, len(items))
	if _, err := tx.ExecContext(ctx, `DELETE FROM resource_group_items WHERE app_code = ? AND group_id = ?`, appcode.FromContext(ctx), groupID); err != nil {
		return err
	}
	for _, item := range items {
		item = normalizeResourceGroupItemInput(item)
		switch item.ItemType {
		case resourcedomain.GroupItemTypeResource:
			if item.ResourceID <= 0 || item.Quantity <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group item is invalid")
			}
			if _, ok := seenResources[item.ResourceID]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource")
			}
			seenResources[item.ResourceID] = struct{}{}
			resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
			if err != nil {
				return err
			}
			if requireGrantable {
				if err := validateActiveResourceGroupResource(resource); err != nil {
					return err
				}
			}
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO resource_group_items (
				   app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
				   quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				appcode.FromContext(ctx), groupID, item.ItemType, item.ResourceID, "", int64(0),
				item.Quantity, item.DurationMS, item.SortOrder, nowMs, nowMs,
			); err != nil {
				return err
			}
		case resourcedomain.GroupItemTypeWalletAsset:
			if !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
			}
			if _, ok := seenWalletAssets[item.WalletAssetType]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset")
			}
			seenWalletAssets[item.WalletAssetType] = struct{}{}
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO resource_group_items (
				   app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
				   quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				appcode.FromContext(ctx), groupID, item.ItemType, int64(0), item.WalletAssetType, item.WalletAssetAmount,
				int64(1), int64(0), item.SortOrder, nowMs, nowMs,
			); err != nil {
				return err
			}
		default:
			return xerr.New(xerr.InvalidArgument, "resource group item type is invalid")
		}
	}
	return nil
}

func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error {
	if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil {
		return err
	}
	for _, regionID := range regionIDs {
		if regionID < 0 {
			return xerr.New(xerr.InvalidArgument, "gift region is invalid")
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO gift_config_regions (
			   app_code, gift_id, region_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT region_id
		FROM gift_config_regions
		WHERE app_code = ? AND gift_id = ?
		ORDER BY region_id ASC`,
		appcode.FromContext(ctx), giftID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	regionIDs := make([]int64, 0)
	for rows.Next() {
		var regionID int64
		if err := rows.Scan(&regionID); err != nil {
			return nil, err
		}
		regionIDs = append(regionIDs, regionID)
	}
	return regionIDs, rows.Err()
}

func (r *Repository) countResourceRows(ctx context.Context, table string, where string, args ...any) (int64, error) {
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` `+where, args...).Scan(&total); err != nil {
		return 0, err
	}
	return total, nil
}

func scanResource(scanner scanTarget) (resourcedomain.Resource, error) {
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.Resource{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	return resource, nil
}

func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
	var group resourcedomain.ResourceGroup
	err := scanner.Scan(
		&group.AppCode,
		&group.GroupID,
		&group.GroupCode,
		&group.Name,
		&group.Status,
		&group.Description,
		&group.SortOrder,
		&group.CreatedByUserID,
		&group.UpdatedByUserID,
		&group.CreatedAtMS,
		&group.UpdatedAtMS,
	)
	return group, err
}

func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem, error) {
	var item resourcedomain.ResourceGroupItem
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.GroupItemID,
		&item.GroupID,
		&item.ItemType,
		&item.ResourceID,
		&item.WalletAssetType,
		&item.WalletAssetAmount,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
		&item.Quantity,
		&item.DurationMS,
		&item.SortOrder,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
	); err != nil {
		return resourcedomain.ResourceGroupItem{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
	var gift resourcedomain.GiftConfig
	var resource resourcedomain.Resource
	var scopesJSON string
	var effectTypesJSON string
	if err := scanner.Scan(
		&gift.AppCode,
		&gift.GiftID,
		&gift.ResourceID,
		&gift.Status,
		&gift.Name,
		&gift.SortOrder,
		&gift.PresentationJSON,
		&gift.GiftTypeCode,
		&gift.EffectiveFromMS,
		&gift.EffectiveToMS,
		&effectTypesJSON,
		&gift.CreatedByUserID,
		&gift.UpdatedByUserID,
		&gift.CreatedAtMS,
		&gift.UpdatedAtMS,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	gift.Resource = resource
	gift.EffectTypes = parseStringArray(effectTypesJSON)
	return gift, nil
}

func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) {
	var item resourcedomain.UserResourceEntitlement
	var resource resourcedomain.Resource
	var scopesJSON string
	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.CreatedAtMS,
		&item.UpdatedAtMS,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&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.Resource = resource
	return item, nil
}

func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error) {
	var grant resourcedomain.ResourceGrant
	err := scanner.Scan(
		&grant.AppCode,
		&grant.GrantID,
		&grant.CommandID,
		&grant.TargetUserID,
		&grant.GrantSource,
		&grant.GrantSubjectType,
		&grant.GrantSubjectID,
		&grant.Status,
		&grant.Reason,
		&grant.OperatorUserID,
		&grant.CreatedAtMS,
		&grant.UpdatedAtMS,
	)
	return grant, err
}

func resourceSelectSQL() string {
	return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources`
}

func resourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` +
		prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` +
		prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` +
		prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms`
}

func nullableResourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` +
		`COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` +
		`COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` +
		`COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		`COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` +
		`COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` +
		`COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` +
		`COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)`
}

func giftConfigSelectSQL() string {
	return `
		SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
		       COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
		       COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
		       COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'),
		       gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM gift_configs gc
		JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
		`
}

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.created_at_ms, e.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		`
}

func resourceGrantSelectSQL() string {
	return `
		SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
		       grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms
		FROM resource_grants
		`
}

func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) {
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.ManagerGrantOnly {
		where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE`
	} else if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (resource_code LIKE ? OR name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func resourceGroupWhereSQL(query resourcedomain.ListResourceGroupsQuery) (string, []any) {
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (group_code LIKE ? OR name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) {
	where := `WHERE gc.app_code = ?`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
			AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
			AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)`
		nowMs := time.Now().UnixMilli()
		args = append(args, nowMs, nowMs)
	} else if query.Status != "" {
		where += ` AND gc.status = ?`
		args = append(args, query.Status)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)`
		args = append(args, like, like, like)
	}
	if query.FilterRegion {
		where += ` AND EXISTS (
			SELECT 1
			FROM gift_config_regions gcr
			WHERE gcr.app_code = gc.app_code
			  AND gcr.gift_id = gc.gift_id
			  AND (gcr.region_id = 0 OR gcr.region_id = ?)
		)`
		args = append(args, query.RegionID)
	}
	return where, args
}

func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
		query.ResourceType = "__invalid__"
	}
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeResourceGroupListQuery(query resourcedomain.ListResourceGroupsQuery) resourcedomain.ListResourceGroupsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizePage(page int32, pageSize int32) (int32, int32) {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return page, pageSize
}

func resourceOffset(page int32, pageSize int32) int32 {
	return (page - 1) * pageSize
}

func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
	command.ResourceCode = strings.TrimSpace(command.ResourceCode)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	command.Name = strings.TrimSpace(command.Name)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy)
	command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType)
	command.AssetURL = strings.TrimSpace(command.AssetURL)
	command.PreviewURL = strings.TrimSpace(command.PreviewURL)
	command.AnimationURL = strings.TrimSpace(command.AnimationURL)
	command.MetadataJSON = normalizeJSONObject(command.MetadataJSON)
	command.UsageScopes = normalizeStringList(command.UsageScopes)
	return command
}

func validateResourceCommand(command resourcedomain.ResourceCommand) error {
	if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 {
		return xerr.New(xerr.InvalidArgument, "resource command is incomplete")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type is invalid")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) {
		return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid")
	}
	if command.ResourceType == resourcedomain.TypeCoin {
		if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 {
			return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN")
		}
		return nil
	}
	if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 {
		return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit")
	}
	return nil
}

func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
	command.GroupCode = strings.TrimSpace(command.GroupCode)
	command.Name = strings.TrimSpace(command.Name)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.Description = strings.TrimSpace(command.Description)
	for index := range command.Items {
		command.Items[index] = normalizeResourceGroupItemInput(command.Items[index])
	}
	return command
}

func validateResourceGroupCommand(command resourcedomain.ResourceGroupCommand, requireItems bool) error {
	if command.GroupCode == "" || command.Name == "" {
		return xerr.New(xerr.InvalidArgument, "resource group command is incomplete")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if requireItems && len(command.Items) == 0 {
		return xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	seenResources := make(map[int64]struct{}, len(command.Items))
	seenWalletAssets := make(map[string]struct{}, len(command.Items))
	for _, item := range command.Items {
		if !resourcedomain.ValidGroupItemType(item.ItemType) {
			return xerr.New(xerr.InvalidArgument, "resource group item type is invalid")
		}
		switch item.ItemType {
		case resourcedomain.GroupItemTypeResource:
			if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 {
				return xerr.New(xerr.InvalidArgument, "resource group item is invalid")
			}
			if _, ok := seenResources[item.ResourceID]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource")
			}
			seenResources[item.ResourceID] = struct{}{}
		case resourcedomain.GroupItemTypeWalletAsset:
			if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
			}
			if _, ok := seenWalletAssets[item.WalletAssetType]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset")
			}
			seenWalletAssets[item.WalletAssetType] = struct{}{}
		}
	}
	return nil
}

func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) resourcedomain.ResourceGroupItemInput {
	item.ItemType = resourcedomain.NormalizeGroupItemType(item.ItemType)
	item.WalletAssetType = resourcedomain.NormalizeWalletAssetType(item.WalletAssetType)
	if item.ItemType == resourcedomain.GroupItemTypeWalletAsset {
		item.ResourceID = 0
		item.Quantity = 1
		item.DurationMS = 0
		return item
	}
	item.ItemType = resourcedomain.GroupItemTypeResource
	item.WalletAssetType = ""
	item.WalletAssetAmount = 0
	return item
}

func validGroupWalletAssetType(assetType string) bool {
	switch resourcedomain.NormalizeWalletAssetType(assetType) {
	case ledger.AssetCoin, ledger.AssetDiamond:
		return true
	default:
		return false
	}
}

func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand {
	command.GiftID = strings.TrimSpace(command.GiftID)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.Name = strings.TrimSpace(command.Name)
	command.PresentationJSON = normalizeJSONObject(command.PresentationJSON)
	command.PriceVersion = strings.TrimSpace(command.PriceVersion)
	command.RegionIDs = normalizeRegionIDs(command.RegionIDs)
	command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode)
	command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType))
	if command.ChargeAssetType == "" {
		command.ChargeAssetType = ledger.AssetCoin
	}
	command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes)
	if command.EffectiveAtMS < 0 {
		command.EffectiveAtMS = 0
	}
	if command.EffectiveFromMS < 0 {
		command.EffectiveFromMS = 0
	}
	if command.EffectiveToMS < 0 {
		command.EffectiveToMS = 0
	}
	return command
}

func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error {
	if command.GiftID == "" || command.ResourceID <= 0 || command.Name == "" || command.PriceVersion == "" {
		return xerr.New(xerr.InvalidArgument, "gift config command is incomplete")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if command.CoinPrice <= 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 {
		return xerr.New(xerr.InvalidArgument, "gift price is invalid")
	}
	if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) {
		return xerr.New(xerr.InvalidArgument, "gift type is invalid")
	}
	if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) {
		return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid")
	}
	if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
		return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid")
	}
	for _, effectType := range command.EffectTypes {
		if !resourcedomain.ValidGiftEffectType(effectType) {
			return xerr.New(xerr.InvalidArgument, "gift effect type is invalid")
		}
	}
	if len(command.RegionIDs) == 0 {
		return xerr.New(xerr.InvalidArgument, "gift regions are required")
	}
	for _, regionID := range command.RegionIDs {
		if regionID < 0 {
			return xerr.New(xerr.InvalidArgument, "gift region is invalid")
		}
	}
	return nil
}

func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand {
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
	if command.Quantity == 0 {
		command.Quantity = 1
	}
	return command
}

func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) error {
	if command.CommandID == "" || command.TargetUserID <= 0 || command.ResourceID <= 0 || command.Quantity <= 0 || command.OperatorUserID <= 0 || command.Reason == "" {
		return xerr.New(xerr.InvalidArgument, "resource grant command is incomplete")
	}
	if command.DurationMS < 0 {
		return xerr.New(xerr.InvalidArgument, "duration_ms is invalid")
	}
	return nil
}

func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand {
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
	return command
}

func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) error {
	if command.CommandID == "" || command.TargetUserID <= 0 || command.GroupID <= 0 || command.OperatorUserID <= 0 || command.Reason == "" {
		return xerr.New(xerr.InvalidArgument, "resource group grant command is incomplete")
	}
	if command.GrantSource == resourcedomain.GrantSourceManagerCenter {
		return xerr.New(xerr.InvalidArgument, "manager center cannot grant resource group")
	}
	return nil
}

func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if grantSource == resourcedomain.GrantSourceManagerCenter && !resource.ManagerGrantEnabled {
		return xerr.New(xerr.PermissionDenied, "resource is not enabled for manager center grant")
	}
	return nil
}

func validateGrantableResource(resource resourcedomain.Resource) error {
	if resource.Status != resourcedomain.StatusActive {
		return xerr.New(xerr.Conflict, "resource is disabled")
	}
	if !resource.Grantable {
		return xerr.New(xerr.Conflict, "resource is not grantable")
	}
	if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) {
		return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin")
	}
	return nil
}

func validateActiveResourceGroupItems(items []resourcedomain.ResourceGroupItem) error {
	if len(items) == 0 {
		return xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	for _, item := range items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource {
			continue
		}
		if err := validateActiveResourceGroupResource(item.Resource); err != nil {
			return err
		}
	}
	return nil
}

func validateActiveResourceGroupResource(resource resourcedomain.Resource) error {
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if resourcedomain.IsCoinResource(resource) {
		return xerr.New(xerr.InvalidArgument, "coin resource must use wallet asset item")
	}
	return nil
}

func isEquipableResourceType(resourceType string) bool {
	switch resourcedomain.NormalizeResourceType(resourceType) {
	case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge:
		return true
	default:
		return false
	}
}

func resourceJSONPayload(scopes []string, metadata string) (string, string, error) {
	scopes = normalizeStringList(scopes)
	scopeJSON, err := json.Marshal(scopes)
	if err != nil {
		return "", "", err
	}
	return string(scopeJSON), normalizeJSONObject(metadata), nil
}

func normalizeStringList(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.ToLower(strings.TrimSpace(value))
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func normalizeRegionIDs(values []int64) []int64 {
	if len(values) == 0 {
		return []int64{0}
	}
	seen := make(map[int64]struct{}, len(values))
	out := make([]int64, 0, len(values))
	for _, value := range values {
		if value < 0 {
			out = append(out, value)
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	slices.Sort(out)
	return out
}

func normalizeGiftEffectTypes(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = resourcedomain.NormalizeGiftEffectType(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	sort.Strings(out)
	return out
}

func parseStringArray(value string) []string {
	var out []string
	if err := json.Unmarshal([]byte(value), &out); err != nil {
		return nil
	}
	return normalizeStringList(out)
}

func normalizeJSONObject(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "{}"
	}
	var raw any
	if err := json.Unmarshal([]byte(value), &raw); err != nil {
		return "{}"
	}
	if _, ok := raw.(map[string]any); !ok {
		return "{}"
	}
	return value
}

func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent {
	eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID)
	return walletOutboxEvent{
		EventID:       "wev_" + stableHash(eventKey),
		EventType:     eventType,
		TransactionID: "",
		CommandID:     commandID,
		UserID:        userID,
		AssetType:     resourceOutboxAsset,
		Payload:       payload,
		CreatedAtMS:   nowMs,
	}
}

func (r *Repository) insertResourceOutboxNoTx(ctx context.Context, eventType string, commandID string, userID int64, resourceID int64, payload any) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	nowMs := time.Now().UnixMilli()
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{resourceOutboxEvent(eventType, commandID, userID, resourceID, payload, nowMs)}); err != nil {
		return err
	}
	return tx.Commit()
}

func grantResourceRequestHash(command resourcedomain.GrantResourceCommand) string {
	return stableHash(fmt.Sprintf("resource_grant|%s|%d|%d|%d|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode), command.TargetUserID, command.ResourceID, command.Quantity, command.DurationMS,
		command.Reason, command.OperatorUserID, command.GrantSource,
	))
}

func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupCommand) string {
	return stableHash(fmt.Sprintf("resource_group_grant|%s|%d|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode), command.TargetUserID, command.GroupID,
		command.Reason, command.OperatorUserID, command.GrantSource,
	))
}

func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string {
	return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity))
}

func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType string, amount int64) string {
	return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
}

func resourceGrantID(appCode string, commandID string) string {
	return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}

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))
}
