package mysql

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

// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖规格；价格优先取规格自己的持久值。
// 滚动发布期间旧实例仍可能写出 0，这类行必须沿用旧版“资源价 × 天数”，不能误套新建规格的默认价。
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, 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 = normalizeResourceShopItemsQuery(query)
	where, args := resourceShopItemsWhereSQL(query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM resource_shop_items si
		JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
		`+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	// 商店配置和 App 购买列表共用同一权重语义：sort_order 越大越靠前。
	rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+`
		ORDER BY si.sort_order DESC, si.shop_item_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.ResourceShopItem, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResourceShopItem(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

// ListResourceShopPurchaseOrders 返回后台售卖记录；订单事实取购买表，素材展示优先使用发放时的资源快照。
func (r *Repository) ListResourceShopPurchaseOrders(ctx context.Context, query resourcedomain.ListResourceShopPurchaseOrdersQuery) ([]resourcedomain.ResourceShopPurchaseOrder, 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 = normalizeResourceShopPurchaseOrdersQuery(query)
	where, args := resourceShopPurchaseOrdersWhereSQL(query)

	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM resource_shop_purchase_orders po
		JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
		`+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceShopPurchaseOrderSelectSQL()+where+`
		ORDER BY po.created_at_ms DESC, po.order_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.ResourceShopPurchaseOrder, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResourceShopPurchaseOrder(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

// UpsertResourceShopItems 批量添加或重配售卖规格；同一资源可同时配置 1/3/7 天，
// 但相同资源和相同天数只能保留一个规格，购买端继续用独立 shop_item_id 选择具体档位。
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceShopItemsCommand(command)
	if err := validateResourceShopItemsCommand(command); err != nil {
		return nil, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	items := append([]resourcedomain.ResourceShopItemInput(nil), command.Items...)
	// 所有写事务统一按 resource_id 再按规格身份加锁。购买同样先锁资源再锁商品，避免改价与购买形成反向等待。
	sort.SliceStable(items, func(left, right int) bool {
		if items[left].ResourceID != items[right].ResourceID {
			return items[left].ResourceID < items[right].ResourceID
		}
		if items[left].ShopItemID != items[right].ShopItemID {
			return items[left].ShopItemID < items[right].ShopItemID
		}
		return items[left].DurationDays < items[right].DurationDays
	})
	resourceIDs := make([]int64, 0, len(items))
	type resourceShopTierKey struct {
		resourceID   int64
		durationDays int32
	}
	seen := make(map[resourceShopTierKey]struct{}, len(items))
	for _, item := range items {
		tierKey := resourceShopTierKey{resourceID: item.ResourceID, durationDays: item.DurationDays}
		if _, exists := seen[tierKey]; exists {
			return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource duration")
		}
		seen[tierKey] = struct{}{}
		resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
		if err != nil {
			return nil, err
		}
		if err := validateResourceShopResource(resource); err != nil {
			return nil, err
		}
		if item.ShopItemID > 0 {
			current, err := r.getResourceShopItemTx(ctx, tx, item.ShopItemID, true)
			if err != nil {
				return nil, err
			}
			// shop_item_id 是既有规格的稳定身份，只允许在同一资源内调整天数和价格，防止误把另一资源的条目搬过来。
			if current.ResourceID != item.ResourceID {
				return nil, xerr.New(xerr.InvalidArgument, "resource shop item does not match resource")
			}
			// 旧管理端不会发送新增的 coin_price；更新已有规格时 0 必须保留当前售价，不能把自定义价重置为资源价。
			coinPrice := item.CoinPrice
			if coinPrice == 0 {
				coinPrice = current.CoinPrice
			}
			if _, err := tx.ExecContext(ctx, `
				UPDATE resource_shop_items
				SET status = ?, duration_days = ?, coin_price = ?, effective_from_ms = ?, effective_to_ms = ?,
				    sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
				WHERE app_code = ? AND shop_item_id = ?`,
				item.Status, item.DurationDays, coinPrice, item.EffectiveFromMS, item.EffectiveToMS,
				item.SortOrder, command.OperatorUserID, nowMs, command.AppCode, item.ShopItemID,
			); err != nil {
				if isMySQLDuplicateError(err) {
					return nil, xerr.New(xerr.Conflict, "resource shop duration already exists")
				}
				return nil, err
			}
			resourceIDs = append(resourceIDs, item.ResourceID)
			continue
		}
		// 新规格未传价格时复制资源列表价一次；落库后规格价格独立，不随资源目录调价变化。
		coinPrice := item.CoinPrice
		if coinPrice == 0 {
			current, exists, err := r.getResourceShopItemByTierTx(ctx, tx, item.ResourceID, item.DurationDays, true)
			if err != nil {
				return nil, err
			}
			if exists {
				// 兼容未发送 shop_item_id 的旧调用：自然键已存在时 0 表示未提供价格，必须保留当前售价。
				coinPrice = current.CoinPrice
			} else {
				coinPrice = resource.CoinPrice
			}
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO resource_shop_items (
			   app_code, resource_id, status, duration_days, coin_price, effective_from_ms, effective_to_ms, sort_order,
			   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
			   status = VALUES(status),
			   coin_price = VALUES(coin_price),
			   effective_from_ms = VALUES(effective_from_ms),
			   effective_to_ms = VALUES(effective_to_ms),
			   sort_order = VALUES(sort_order),
			   updated_by_user_id = VALUES(updated_by_user_id),
			   updated_at_ms = VALUES(updated_at_ms)`,
			command.AppCode, item.ResourceID, item.Status, item.DurationDays, coinPrice, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
			command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
		); err != nil {
			if isMySQLDuplicateError(err) {
				return nil, xerr.New(xerr.Conflict, "resource shop duration already exists")
			}
			return nil, err
		}
		resourceIDs = append(resourceIDs, item.ResourceID)
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs)
}

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

	item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	if command.Status == resourcedomain.StatusActive {
		if err := validateResourceShopResource(item.Resource); err != nil {
			return resourcedomain.ResourceShopItem{}, err
		}
	}
	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		UPDATE resource_shop_items
		SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND shop_item_id = ?`,
		command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID,
	)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	return r.getResourceShopItem(ctx, command.ShopItemID)
}

// PurchaseResourceShopItem 是 App 道具商店的唯一购买入口；扣金币、写订单和发资源权益必须共享同一事务。
func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.CommandID = strings.TrimSpace(command.CommandID)

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

	requestHash := resourceShopPurchaseRequestHash(command)
	// 首次查询不能对不存在的 command_id 加 gap lock：两笔不同订单随后会按资源行串行，
	// 若各自先持有交易唯一键间隙，先拿到资源锁的事务在 INSERT 时可能与等待资源锁的事务形成死锁。
	if txRow, exists, err := r.lookupTransactionConsistent(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase, xerr.LedgerConflict); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceShopPurchaseReceipt{}, err
		}
		return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
	}

	nowMs := time.Now().UnixMilli()
	item, err := r.lockResourceShopItemTx(ctx, tx, command.ShopItemID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := validatePurchasableResourceShopItem(item, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if account.AvailableAmount < item.CoinPrice {
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID)
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
	coinBalanceAfter := account.AvailableAmount - item.CoinPrice
	metadata := map[string]any{
		"app_code":          command.AppCode,
		"order_id":          orderID,
		"user_id":           command.UserID,
		"shop_item_id":      item.ShopItemID,
		"resource_id":       item.ResourceID,
		"resource_code":     item.Resource.ResourceCode,
		"resource_type":     item.Resource.ResourceType,
		"duration_days":     item.DurationDays,
		"duration_ms":       durationMS,
		"price_type":        item.PriceType,
		"coin_spent":        item.CoinPrice,
		"balance_after":     coinBalanceAfter,
		"balance_version":   account.Version + 1,
		"resource_grant_id": grantID,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil {
		if isMySQLDuplicateError(err) {
			// 同 command_id 的并发首发都可能在一致性读阶段看不到未提交行；唯一键决定胜者，
			// 败者必须回滚所有预备锁并重读胜者回执，不能重复扣费或把 duplicate 暴露成 500。
			_ = tx.Rollback()
			return r.replayResourceShopPurchase(ctx, command, requestHash)
		}
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -item.CoinPrice,
		FrozenDelta:    0,
		AvailableAfter: coinBalanceAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	// 商城商品卖的是 duration_days 对应的使用时长，重复购买必须从当前到期时间继续累加，
	// 不能受资源目录在活动、礼物等其他发放场景使用的 increase_quantity/new_entitlement 策略影响。
	// 同一资源的购买事务已由 lockResourceShopItemTx 锁住资源行，权益层再以 FOR UPDATE 合并，避免并发续期丢失时长。
	grantItem, err := r.applyGrantItemWithEntitlementStrategy(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs, resourcedomain.GrantStrategyExtendExpiry)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if grantItem.EntitlementID == "" {

		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement")
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO resource_shop_purchase_orders (
		   app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin,
		   status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`,
		command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice,
		transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs,
	); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	// 订单回执必须固化本次购买完成时的权益窗口；后续续期仍复用同一 entitlement，
	// 若重放时直接读当前行，旧 command_id 会错误返回后续订单已经延长的到期时间和数量。
	metadata["entitlement_snapshot_version"] = 1
	metadata["entitlement_id"] = resource.EntitlementID
	metadata["entitlement_status"] = resource.Status
	metadata["entitlement_quantity"] = resource.Quantity
	metadata["entitlement_remaining_quantity"] = resource.RemainingQuantity
	metadata["entitlement_effective_at_ms"] = resource.EffectiveAtMS
	metadata["entitlement_expires_at_ms"] = resource.ExpiresAtMS
	metadata["entitlement_source_grant_id"] = resource.SourceGrantID
	metadata["entitlement_created_at_ms"] = resource.CreatedAtMS
	metadata["entitlement_updated_at_ms"] = resource.UpdatedAtMS
	metadata["entitlement_equipped"] = resource.Equipped
	if err := r.updateTransactionMetadata(ctx, tx, transactionID, metadata, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizTypeResourceShopPurchase),
		resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs),
		{
			EventID:       eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset),
			EventType:     "ResourceShopItemPurchased",
			TransactionID: transactionID,
			CommandID:     command.CommandID,
			UserID:        command.UserID,
			AssetType:     resourceOutboxAsset,
			Payload:       metadata,
			CreatedAtMS:   nowMs,
		},
	}); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}

	return resourcedomain.ResourceShopPurchaseReceipt{
		OrderID:       orderID,
		TransactionID: transactionID,
		ShopItem:      item,
		Resource:      resource,
		Balance: ledger.AssetBalance{
			AppCode:         command.AppCode,
			UserID:          command.UserID,
			AssetType:       ledger.AssetCoin,
			AvailableAmount: coinBalanceAfter,
			FrozenAmount:    account.FrozenAmount,
			Version:         account.Version + 1,
		},
		CoinSpent: item.CoinPrice,
		GrantID:   grantID,
	}, nil
}

// replayResourceShopPurchase 在原事务回滚后读取已提交订单，供同 command_id 的并发请求稳定返回同一回执。
func (r *Repository) replayResourceShopPurchase(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand, requestHash string) (resourcedomain.ResourceShopPurchaseReceipt, error) {
	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	txRow, exists, err := r.lookupTransactionConsistent(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase, xerr.LedgerConflict)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if !exists {
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "resource shop purchase idempotent receipt is not visible")
	}
	return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
}

func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
	return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false)
}

func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
	return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
}

// lockResourceShopItemTx 先无锁定位不可变的 resource_id，再统一按“资源 -> 商品”顺序取写锁。
// 改价、启停和购买都走这里，避免某条链路先锁商品、另一条链路先锁资源而形成循环等待。
func (r *Repository) lockResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
	candidate, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	if _, err := r.getResourceForUpdate(ctx, tx, candidate.ResourceID); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, true)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	// resource_id 按业务约束不可变；若被旁路修改，不能在未锁定的新资源行上继续启停、改价或扣费。
	if item.ResourceID != candidate.ResourceID {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Conflict, "resource shop item changed while locking")
	}
	return item, nil
}

func (r *Repository) getResourceShopItemByTierTx(ctx context.Context, tx *sql.Tx, resourceID int64, durationDays int32, lock bool) (resourcedomain.ResourceShopItem, bool, error) {
	query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.resource_id = ? AND si.duration_days = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	item, err := scanResourceShopItem(tx.QueryRowContext(ctx, query, appcode.FromContext(ctx), resourceID, durationDays))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceShopItem{}, false, nil
	}
	return item, err == nil, err
}

func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
	query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID)
	item, err := scanResourceShopItem(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
	}
	return item, err
}

func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) {
	var orderID string
	var shopItemID int64
	var durationDays int32
	var priceCoin int64
	var grantID string
	var entitlementID string
	var metadataJSON string
	err := tx.QueryRowContext(ctx, `
		SELECT po.order_id, po.shop_item_id, po.duration_days, po.price_coin,
		       po.resource_grant_id, po.entitlement_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}')
		FROM resource_shop_purchase_orders po
		JOIN wallet_transactions wt ON wt.app_code = po.app_code AND wt.transaction_id = po.wallet_transaction_id
		WHERE po.app_code = ? AND po.wallet_transaction_id = ? AND po.user_id = ?`,
		appcode.FromContext(ctx), transactionID, userID,
	).Scan(&orderID, &shopItemID, &durationDays, &priceCoin, &grantID, &entitlementID, &metadataJSON)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	// 幂等重试必须返回原订单规格；运营在首单后改价或改配置时，不能让回执里的商品价格与 coin_spent 自相矛盾。
	item.DurationDays = durationDays
	item.CoinPrice = priceCoin
	resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	var metadata struct {
		EntitlementSnapshotVersion int    `json:"entitlement_snapshot_version"`
		EntitlementID              string `json:"entitlement_id"`
		EntitlementStatus          string `json:"entitlement_status"`
		EntitlementQuantity        int64  `json:"entitlement_quantity"`
		EntitlementRemaining       int64  `json:"entitlement_remaining_quantity"`
		EntitlementEffectiveAtMS   int64  `json:"entitlement_effective_at_ms"`
		EntitlementExpiresAtMS     int64  `json:"entitlement_expires_at_ms"`
		EntitlementSourceGrantID   string `json:"entitlement_source_grant_id"`
		EntitlementCreatedAtMS     int64  `json:"entitlement_created_at_ms"`
		EntitlementUpdatedAtMS     int64  `json:"entitlement_updated_at_ms"`
		EntitlementEquipped        bool   `json:"entitlement_equipped"`
		BalanceVersion             int64  `json:"balance_version"`
	}
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if metadata.EntitlementSnapshotVersion > 0 && metadata.EntitlementID == entitlementID {
		resource.Status = metadata.EntitlementStatus
		resource.Quantity = metadata.EntitlementQuantity
		resource.RemainingQuantity = metadata.EntitlementRemaining
		resource.EffectiveAtMS = metadata.EntitlementEffectiveAtMS
		resource.ExpiresAtMS = metadata.EntitlementExpiresAtMS
		resource.SourceGrantID = metadata.EntitlementSourceGrantID
		resource.CreatedAtMS = metadata.EntitlementCreatedAtMS
		resource.UpdatedAtMS = metadata.EntitlementUpdatedAtMS
		resource.Equipped = metadata.EntitlementEquipped
	}
	balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if metadata.BalanceVersion > 0 {
		balance.Version = metadata.BalanceVersion
	} else if err := tx.QueryRowContext(ctx, `
		SELECT version FROM wallet_accounts
		WHERE app_code = ? AND user_id = ? AND asset_type = ?`,
		appcode.FromContext(ctx), userID, ledger.AssetCoin,
	).Scan(&balance.Version); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	return resourcedomain.ResourceShopPurchaseReceipt{
		OrderID:       orderID,
		TransactionID: transactionID,
		ShopItem:      item,
		Resource:      resource,
		Balance:       balance,
		CoinSpent:     priceCoin,
		GrantID:       grantID,
	}, nil
}

func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) {
	resourceIDs = compactPositiveInt64s(resourceIDs)
	if len(resourceIDs) == 0 {
		return []resourcedomain.ResourceShopItem{}, nil
	}
	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...)
	rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+`
		WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`)
		ORDER BY si.sort_order DESC, si.shop_item_id DESC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs))
	for rows.Next() {
		item, err := scanResourceShopItem(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) {
	var item resourcedomain.ResourceShopItem
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.AppCode,
		&item.ShopItemID,
		&item.ResourceID,
		&item.Status,
		&item.DurationDays,
		&item.PriceType,
		&item.CoinPrice,
		&item.EffectiveFromMS,
		&item.EffectiveToMS,
		&item.SortOrder,
		&item.CreatedByUserID,
		&item.UpdatedByUserID,
		&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,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

func scanResourceShopPurchaseOrder(scanner scanTarget) (resourcedomain.ResourceShopPurchaseOrder, error) {
	var item resourcedomain.ResourceShopPurchaseOrder
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.AppCode,
		&item.OrderID,
		&item.CommandID,
		&item.UserID,
		&item.ShopItemID,
		&item.ResourceID,
		&item.DurationDays,
		&item.PriceCoin,
		&item.Status,
		&item.WalletTransactionID,
		&item.ResourceGrantID,
		&item.EntitlementID,
		&item.ResourceSnapshotJSON,
		&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,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.ResourceShopPurchaseOrder{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resourceFromSnapshotJSON(item.ResourceSnapshotJSON, resource)
	return item, nil
}

func resourceShopItemSelectSQL() string {
	return `
			SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
			       r.price_type,
			       CASE WHEN si.coin_price > 0 THEN si.coin_price ELSE r.coin_price * si.duration_days END AS coin_price,
		       si.effective_from_ms, si.effective_to_ms, si.sort_order,
		       si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM resource_shop_items si
		JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
		`
}

func resourceShopPurchaseOrderSelectSQL() string {
	return `
		SELECT po.app_code, po.order_id, po.command_id, po.user_id, po.shop_item_id, po.resource_id,
		       po.duration_days, po.price_coin, po.status, po.wallet_transaction_id, po.resource_grant_id,
		       po.entitlement_id, COALESCE(CAST(gi.resource_snapshot_json AS CHAR), ''), po.created_at_ms, po.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM resource_shop_purchase_orders po
		JOIN resources r ON r.app_code = po.app_code AND r.resource_id = po.resource_id
		LEFT JOIN resource_grant_items gi ON gi.app_code = po.app_code
		 AND gi.grant_id = po.resource_grant_id
		 AND gi.resource_id = po.resource_id
		`
}

func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) {
	// 固定类型白名单只增加一个等值分支，仍由 resource_shop_items 的 App/SKU 索引驱动并按
	// (app_code, resource_id) 关联 resources，不引入全表扫描或额外查询。
	where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'floating_screen', 'mic_seat_icon', 'mic_seat_animation')`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		nowMs := time.Now().UnixMilli()
		where += ` AND si.status = 'active' AND r.status = 'active'
			AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?)
			AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)`
		args = append(args, nowMs, nowMs)
	} else if query.Status != "" {
		where += ` AND si.status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND r.resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func resourceShopPurchaseOrdersWhereSQL(query resourcedomain.ListResourceShopPurchaseOrdersQuery) (string, []any) {
	where := `WHERE po.app_code = ?`
	args := []any{query.AppCode}
	if query.UserID > 0 {
		where += ` AND po.user_id = ?`
		args = append(args, query.UserID)
	}
	if query.Status != "" {
		where += ` AND po.status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND r.resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (po.order_id LIKE ? OR po.command_id LIKE ? OR r.resource_code LIKE ? OR r.name LIKE ?)`
		args = append(args, like, like, like, like)
	}
	return where, args
}

func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery {
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(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 normalizeResourceShopPurchaseOrdersQuery(query resourcedomain.ListResourceShopPurchaseOrdersQuery) resourcedomain.ListResourceShopPurchaseOrdersQuery {
	if query.UserID < 0 {
		query.UserID = 0
	}
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(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 normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand {
	for index := range command.Items {
		command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status)
	}
	return command
}

func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error {
	if len(command.Items) == 0 || command.OperatorUserID < 0 {
		return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete")
	}
	for _, item := range command.Items {
		if item.ResourceID <= 0 {
			return xerr.New(xerr.InvalidArgument, "resource_id is required")
		}
		if !resourcedomain.ValidStatus(item.Status) {
			return xerr.New(xerr.InvalidArgument, "status is invalid")
		}
		if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
			return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
		}
		if item.CoinPrice < 0 {
			return xerr.New(xerr.InvalidArgument, "resource shop coin price is invalid")
		}
		if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
			return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
		}
	}
	return nil
}

func validateResourceShopResource(resource resourcedomain.Resource) error {
	if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop")
	}
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource coin price is required")
	}
	return nil
}

func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error {
	if item.Status != resourcedomain.StatusActive {
		return xerr.New(xerr.Conflict, "resource shop item is disabled")
	}
	if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs {
		return xerr.New(xerr.Conflict, "resource shop item is not effective")
	}
	if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs {
		return xerr.New(xerr.Conflict, "resource shop item is expired")
	}
	if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid")
	}
	return validateResourceShopResource(item.Resource)
}

func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string {
	return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d",
		appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID,
	))
}
