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"
	"strconv"
	"strings"
	"time"
)

// 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
	}
	outboxEvents := []walletOutboxEvent{
		resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs),
	}
	if resourcedomain.NormalizeResourceType(resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
		if command.Quantity != 1 {
			return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "vip trial card quantity must be 1")
		}
		metadata, durationMS, err := resolveVipTrialCardGrantFacts(resource, command.DurationMS)
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
			GrantID: grantID, CommandID: command.CommandID,
			RequestHash:  vipTrialCardGrantItemHash(grantID, command.CommandID, command.TargetUserID, resource.ResourceID, durationMS),
			TargetUserID: command.TargetUserID, Resource: resource, ExpectedLevel: metadata.Level,
			DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
			Reason: command.Reason, NowMS: nowMs, ValidateGrantSource: true,
		})
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(command.CommandID, result, command.TargetUserID, resource.ResourceID, nowMs))
	} else 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, outboxEvents); 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
	}
	outboxEvents := make([]walletOutboxEvent, 0, len(group.Items)+1)
	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 resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
			metadata, durationMS, err := resolveVipTrialCardGrantFacts(item.Resource, item.DurationMS)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			itemCommandID := resourceGroupVipTrialCardCommandID(command.CommandID, item.GroupItemID, item.ResourceID)
			result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
				GrantID: grantID, CommandID: itemCommandID,
				RequestHash:  vipTrialCardGrantItemHash(grantID, itemCommandID, command.TargetUserID, item.ResourceID, durationMS),
				TargetUserID: command.TargetUserID, Resource: item.Resource, ExpectedLevel: metadata.Level,
				DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
				Reason: command.Reason, NowMS: nowMs, ValidateGrantSource: true,
			})
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(itemCommandID, result, command.TargetUserID, item.ResourceID, nowMs))
			continue
		}
		if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
	}
	outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs))
	if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

// GrantPinnedResourceGroup executes only wallet-owned immutable snapshot bytes. Callers can choose a
// snapshot_id created by PinResourceGroupSnapshot, but can never submit arbitrary assets or amounts.
func (r *Repository) GrantPinnedResourceGroup(ctx context.Context, command resourcedomain.GrantPinnedResourceGroupCommand) (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 = normalizeGrantPinnedResourceGroupCommand(command)
	if err := validateGrantPinnedResourceGroupCommand(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 := grantPinnedResourceGroupRequestHash(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)
	}
	snapshot, found, err := getResourceGroupSnapshot(ctx, tx, command.AppCode, command.SnapshotID, true)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if !found {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource group snapshot not found")
	}
	if snapshot.SnapshotHash != command.ExpectedSnapshotHash {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group snapshot hash mismatch")
	}
	group := snapshot.Group
	if len(group.Items) == 0 {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot has no items")
	}
	groupSnapshot, err := json.Marshal(group)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	nowMS := time.Now().UTC().UnixMilli()
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource,
		resourcedomain.GrantSubjectGroupSnapshot, snapshot.SnapshotID, requestHash, string(groupSnapshot),
		command.Reason, command.OperatorUserID, nowMS); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	outboxEvents := make([]walletOutboxEvent, 0, len(group.Items)+1)
	for _, item := range group.Items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
			if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
				return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains an invalid wallet asset item")
			}
			if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMS); err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			continue
		}
		if item.ResourceID <= 0 || item.Resource.ResourceID != item.ResourceID || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 {
			return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains an invalid resource item")
		}
		if err := validatePinnedResourceGroupResource(item.Resource); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
			metadata, durationMS, err := resolveVipTrialCardGrantFacts(item.Resource, item.DurationMS)
			if err != nil {
				return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains invalid vip_trial_card: "+err.Error())
			}
			resourceSnapshotJSON, err := resourcedomain.CanonicalResourceJSON(item.Resource)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			resourceSnapshotHash, err := resourcedomain.ResourceContentHash(item.Resource)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			itemCommandID := resourceGroupVipTrialCardCommandID(command.CommandID, item.GroupItemID, item.ResourceID)
			result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
				GrantID: grantID, CommandID: itemCommandID,
				RequestHash:  vipTrialCardGrantItemHash(grantID, itemCommandID, command.TargetUserID, item.ResourceID, durationMS),
				TargetUserID: command.TargetUserID, Resource: item.Resource, ExpectedLevel: metadata.Level,
				DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
				Reason: command.Reason, NowMS: nowMS, ValidateGrantSource: false,
				EntitlementSnapshot: resourceEntitlementSnapshot{
					SourceSnapshotID: snapshot.SnapshotID, ResourceSnapshotHash: resourceSnapshotHash,
					ResourceSnapshotJSON: string(resourceSnapshotJSON),
				},
			})
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(itemCommandID, result, command.TargetUserID, item.ResourceID, nowMS))
			continue
		}
		giftSnapshotJSON := ""
		giftSnapshotHash := ""
		if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeGift {
			if err := validatePinnedGiftConfigs(command.AppCode, item.Resource, item.GiftConfigs); err != nil {
				return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group gift snapshot is invalid: "+err.Error())
			}
			giftJSON, err := resourcedomain.CanonicalGiftConfigsJSON(item.GiftConfigs)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			giftSnapshotJSON = string(giftJSON)
			giftSnapshotHash, err = resourcedomain.GiftConfigsContentHash(item.GiftConfigs)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
		} else if len(item.GiftConfigs) != 0 {
			return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "non-gift resource contains gift snapshot")
		}
		resourceSnapshotJSON, err := resourcedomain.CanonicalResourceJSON(item.Resource)
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		resourceSnapshotHash, err := resourcedomain.ResourceContentHash(item.Resource)
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		if _, err := r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, command.CommandID, command.TargetUserID,
			item.Resource, item.Quantity, item.DurationMS, nowMS, "", resourceEntitlementSnapshot{
				SourceSnapshotID: snapshot.SnapshotID, ResourceSnapshotHash: resourceSnapshotHash,
				ResourceSnapshotJSON: string(resourceSnapshotJSON), GiftSnapshotHash: giftSnapshotHash,
				GiftSnapshotJSON: giftSnapshotJSON,
			}); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
	}
	outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, snapshot.SourceGroupID, map[string]any{
		"grant_id": grantID, "snapshot_id": snapshot.SnapshotID, "snapshot_hash": snapshot.SnapshotHash,
		"snapshot_version": snapshot.VersionNo, "grant_subject_type": resourcedomain.GrantSubjectGroupSnapshot,
		"grant_subject_id": snapshot.SnapshotID, "group": group,
	}, nowMS))
	if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

// RevokeResourceGrant 撤销一次成功的资源发放；钱包扣回、权益扣减、装备清理和发放状态更新必须同事务提交。
func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (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 = normalizeRevokeResourceGrantCommand(command)
	if err := validateRevokeResourceGrantCommand(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() }()

	grant, err := r.lockResourceGrantTx(ctx, tx, command.GrantID)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found")
	}
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if grant.Status == resourcedomain.GrantStatusRevoked {
		grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grant.GrantID)
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		return grant, nil
	}
	if grant.Status != resourcedomain.GrantStatusDone {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource grant is not succeeded")
	}

	grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grant.GrantID)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	nowMs := time.Now().UnixMilli()
	outboxEvents := make([]walletOutboxEvent, 0, len(grant.Items)+1)
	for _, item := range grant.Items {
		switch item.ResultType {
		case resourcedomain.ResultWalletCredit:
			event, changed, err := r.revokeGrantedWalletCredit(ctx, tx, grant, item, command, nowMs)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			if changed {
				outboxEvents = append(outboxEvents, event)
			}
		case resourcedomain.ResultEntitlement:
			event, err := r.revokeGrantedEntitlement(ctx, tx, grant, item, command, nowMs)
			if err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			outboxEvents = append(outboxEvents, event)
		default:
			return resourcedomain.ResourceGrant{}, xerr.New(xerr.LedgerConflict, "resource grant item result type is invalid")
		}
	}

	result, err := tx.ExecContext(ctx, `
		UPDATE resource_grants
		SET status = ?, revoked_at_ms = ?, revoked_by_user_id = ?, revoke_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND grant_id = ? AND status = ?`,
		resourcedomain.GrantStatusRevoked, nowMs, command.OperatorUserID, command.Reason, nowMs,
		command.AppCode, grant.GrantID, resourcedomain.GrantStatusDone,
	)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if affected != 1 {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.LedgerConflict, "resource grant revoke state conflict")
	}

	revokeCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, 0)
	subjectID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
	outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, subjectID, map[string]any{
		"action":             "revoke",
		"app_code":           command.AppCode,
		"grant_id":           grant.GrantID,
		"target_user_id":     grant.TargetUserID,
		"grant_subject_type": grant.GrantSubjectType,
		"grant_subject_id":   grant.GrantSubjectID,
		"revoked_by_user_id": command.OperatorUserID,
		"revoke_reason":      command.Reason,
		"revoked_at_ms":      nowMs,
		"event_command":      revokeCommandID,
	}, nowMs))
	if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}

	revoked, err := r.getResourceGrantTx(ctx, tx, grant.GrantID)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return revoked, 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)))
	}
	if query.GrantSource != "" {
		where += ` AND grant_source = ?`
		args = append(args, query.GrantSource)
	}
	if len(query.OperatorUserIDs) > 0 {
		// 外管历史通常同时查询 linked App user 和旧 synthetic operator 两个 ID。
		// 在 count/分页之前下推过滤，既保证“只看自己”，也让组合索引承担范围扫描。
		where += ` AND operator_user_id IN (` + placeholders(len(query.OperatorUserIDs)) + `)`
		for _, operatorUserID := range query.OperatorUserIDs {
			args = append(args, operatorUserID)
		}
	}
	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, grant_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.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) 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) {
	return r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, "", resourceEntitlementSnapshot{})
}

// applyGrantItemWithEntitlementStrategy 只覆盖本次发放如何合并用户权益，不改资源目录或 grant item
// 的资源快照。商城售卖的是明确时长，因此需要固定为续期语义，同时保留资源在其他发放场景中的原策略。
func (r *Repository) applyGrantItemWithEntitlementStrategy(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementStrategy string) (resourcedomain.ResourceGrantItem, error) {
	return r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, entitlementStrategy, resourceEntitlementSnapshot{})
}

type resourceEntitlementSnapshot struct {
	SourceSnapshotID     string
	ResourceSnapshotHash string
	ResourceSnapshotJSON string
	GiftSnapshotHash     string
	GiftSnapshotJSON     string
}

func (r *Repository) applyGrantItemWithEntitlementSnapshot(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementStrategy string, entitlementSnapshot resourceEntitlementSnapshot) (resourcedomain.ResourceGrantItem, error) {
	return r.applyGrantItemWithEntitlementSnapshotOptions(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, entitlementStrategy, entitlementSnapshot, true)
}

// applyVipGrantItemWithoutAutoEquipTx 只创建来源独立的 VIP entitlement。VIP 的 auto_equip
// 是 benefit 配置，不是 resource_type 默认值；最终装备必须统一交给 effective-state reconcile。
func (r *Repository) applyVipGrantItemWithoutAutoEquipTx(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) {
	return r.applyGrantItemWithEntitlementSnapshotOptions(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, resourcedomain.GrantStrategyNewEntitlement, resourceEntitlementSnapshot{}, false)
}

func (r *Repository) applyGrantItemWithEntitlementSnapshotOptions(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementStrategy string, entitlementSnapshot resourceEntitlementSnapshot, autoEquip bool) (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, account.Version+1, 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, entitlementStrategy, entitlementSnapshot)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		item.ResultType = resourcedomain.ResultEntitlement
		item.EntitlementID = entitlementID
		if autoEquip {
			if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil {
				return resourcedomain.ResourceGrantItem{}, err
			}
		}
	}
	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 := resourceGroupWalletAssetCommandID(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,
		"wallet_command_id": walletCommandID,
		"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, account.Version+1, 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) revokeGrantedWalletCredit(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, bool, error) {
	if strings.TrimSpace(item.WalletTransactionID) == "" {
		return walletOutboxEvent{}, false, xerr.New(xerr.LedgerConflict, "resource grant wallet transaction is missing")
	}
	// 反扣金额以原入账分录为准，避免资源组或资源定义后续变更导致撤销扣回的内容漂移。
	original, err := r.lockOriginalGrantCreditEntry(ctx, tx, item.WalletTransactionID, grant.TargetUserID)
	if err != nil {
		return walletOutboxEvent{}, false, err
	}
	if original.AvailableDelta <= 0 || original.FrozenDelta != 0 {
		return walletOutboxEvent{}, false, xerr.New(xerr.LedgerConflict, "resource grant wallet entry is not reversible")
	}
	account, err := r.lockAccount(ctx, tx, grant.TargetUserID, original.AssetType, false, nowMs)
	if err != nil {
		return walletOutboxEvent{}, false, err
	}
	revokeAmount := original.AvailableDelta
	if account.AvailableAmount < revokeAmount {
		revokeAmount = account.AvailableAmount
	}
	if revokeAmount <= 0 {
		return walletOutboxEvent{}, false, nil
	}

	walletCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
	transactionID := transactionID(command.AppCode, walletCommandID)
	metadata := map[string]any{
		"app_code":                command.AppCode,
		"grant_id":                grant.GrantID,
		"grant_item_id":           item.GrantItemID,
		"target_user_id":          grant.TargetUserID,
		"asset_type":              original.AssetType,
		"wallet_amount":           revokeAmount,
		"original_wallet_amount":  original.AvailableDelta,
		"original_transaction_id": item.WalletTransactionID,
		"revoked_by_user_id":      command.OperatorUserID,
		"revoke_reason":           command.Reason,
		"operator_reason":         "resource grant revoke",
	}
	if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrantRevoke, resourceGrantWalletRevokeHash(grant.GrantID, item.GrantItemID, original.AssetType, revokeAmount), grant.GrantID, metadata, nowMs); err != nil {
		return walletOutboxEvent{}, false, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -revokeAmount, 0, nowMs); err != nil {
		return walletOutboxEvent{}, false, err
	}
	availableAfter := account.AvailableAmount - revokeAmount
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         grant.TargetUserID,
		AssetType:      original.AssetType,
		AvailableDelta: -revokeAmount,
		FrozenDelta:    0,
		AvailableAfter: availableAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return walletOutboxEvent{}, false, err
	}
	return balanceChangedEvent(transactionID, walletCommandID, grant.TargetUserID, original.AssetType, -revokeAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), true, nil
}

func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
	if strings.TrimSpace(item.EntitlementID) == "" {
		return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is missing")
	}
	entitlement, err := r.getUserResourceEntitlementForUpdateTx(ctx, tx, item.EntitlementID)
	if err != nil {
		return walletOutboxEvent{}, err
	}
	if entitlement.UserID != grant.TargetUserID {
		return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement owner mismatch")
	}
	nextQuantity := entitlement.Quantity - item.Quantity
	if nextQuantity < 0 {
		nextQuantity = 0
	}
	nextRemaining := entitlement.RemainingQuantity - item.Quantity
	if nextRemaining < 0 {
		nextRemaining = 0
	}
	nextExpiresAt := entitlement.ExpiresAtMS
	if item.DurationMS > 0 && nextExpiresAt > 0 {
		nextExpiresAt -= item.DurationMS
		if nextExpiresAt < nowMs {
			nextExpiresAt = nowMs
		}
	}
	nextStatus := resourcedomain.StatusActive
	if nextRemaining == 0 || (nextExpiresAt > 0 && nextExpiresAt <= nowMs) {
		nextStatus = resourcedomain.GrantStatusRevoked
	}
	// 发放策略可能把多次同资源发放合并到同一权益；撤销必须扣回本次发放贡献的天数/数量，
	// 不能直接删除整条权益，否则会误伤其它仍然有效的发放。
	result, err := tx.ExecContext(ctx, `
		UPDATE user_resource_entitlements
		SET status = ?, quantity = ?, remaining_quantity = ?, expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
		nextStatus, nextQuantity, nextRemaining, nextExpiresAt, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
	)
	if err != nil {
		return walletOutboxEvent{}, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return walletOutboxEvent{}, err
	}
	if affected != 1 {
		return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is not reversible")
	}
	if nextStatus != resourcedomain.StatusActive {
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM user_resource_equipment
			WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
			command.AppCode, grant.TargetUserID, item.EntitlementID,
		); err != nil {
			return walletOutboxEvent{}, err
		}
		// VIP 体验卡的业务元数据与通用 entitlement 同事务撤销；否则后台审计会残留 active 卡片，
		// 虽然状态查询因 entitlement 失效不会继续授权，仍会形成两套互相矛盾的事实。
		if _, err := tx.ExecContext(ctx, `
			UPDATE user_vip_trial_cards
			SET status = ?, updated_at_ms = ?
			WHERE app_code = ? AND user_id = ? AND entitlement_id = ? AND status = ?`,
			ledger.VipTrialCardStatusRevoked, nowMs, command.AppCode, grant.TargetUserID,
			item.EntitlementID, ledger.VipTrialCardStatusActive,
		); err != nil {
			return walletOutboxEvent{}, err
		}
	}

	eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
	return resourceOutboxEvent("UserResourceChanged", eventCommandID, grant.TargetUserID, item.ResourceID, map[string]any{
		"action":             "revoke",
		"app_code":           command.AppCode,
		"user_id":            grant.TargetUserID,
		"grant_id":           grant.GrantID,
		"grant_item_id":      item.GrantItemID,
		"resource_id":        item.ResourceID,
		"entitlement_id":     item.EntitlementID,
		"status":             nextStatus,
		"quantity":           nextQuantity,
		"remaining_quantity": nextRemaining,
		"expires_at_ms":      nextExpiresAt,
		"revoked_by_user_id": command.OperatorUserID,
		"revoke_reason":      command.Reason,
		"updated_at_ms":      nowMs,
		"event_command":      eventCommandID,
	}, nowMs), nil
}

func (r *Repository) lockOriginalGrantCreditEntry(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (walletEntry, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT asset_type, available_delta, frozen_delta
		FROM wallet_entries
		WHERE app_code = ? AND transaction_id = ? AND user_id = ?
		ORDER BY entry_id DESC LIMIT 1
		FOR UPDATE`,
		appcode.FromContext(ctx), transactionID, userID,
	)
	var entry walletEntry
	entry.TransactionID = transactionID
	entry.UserID = userID
	if err := row.Scan(&entry.AssetType, &entry.AvailableDelta, &entry.FrozenDelta); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return walletEntry{}, xerr.New(xerr.LedgerConflict, "resource grant wallet entry not found")
		}
		return walletEntry{}, err
	}
	return entry, nil
}

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) lockResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) {
	row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ? FOR UPDATE`, appcode.FromContext(ctx), grantID)
	return scanResourceGrant(row)
}

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 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.RevokedAtMS,
		&grant.RevokedByUserID,
		&grant.RevokeReason,
		&grant.CreatedAtMS,
		&grant.UpdatedAtMS,
	)
	return grant, err
}

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,
		       revoked_at_ms, revoked_by_user_id, revoke_reason, created_at_ms, updated_at_ms
		FROM resource_grants
		`
}

func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	// grant_source is an optional list filter. Unlike write commands, an omitted
	// value must remain empty so the main admin keeps seeing all historical sources
	// (admin, activity, manager_center, and system grants).
	query.GrantSource = strings.ToLower(strings.TrimSpace(query.GrantSource))
	query.OperatorUserIDs = positiveUniqueInt64s(query.OperatorUserIDs)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

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

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 normalizeGrantPinnedResourceGroupCommand(command resourcedomain.GrantPinnedResourceGroupCommand) resourcedomain.GrantPinnedResourceGroupCommand {
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.SnapshotID = strings.TrimSpace(command.SnapshotID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
	command.ExpectedSnapshotHash = strings.ToLower(strings.TrimSpace(command.ExpectedSnapshotHash))
	return command
}

func validateGrantPinnedResourceGroupCommand(command resourcedomain.GrantPinnedResourceGroupCommand) error {
	if command.CommandID == "" || command.TargetUserID <= 0 || command.SnapshotID == "" || command.OperatorUserID <= 0 || command.Reason == "" || !validSHA256(command.ExpectedSnapshotHash) {
		return xerr.New(xerr.InvalidArgument, "pinned resource group grant command is incomplete")
	}
	if command.GrantSource == resourcedomain.GrantSourceManagerCenter {
		return xerr.New(xerr.InvalidArgument, "manager center cannot grant pinned resource group")
	}
	return nil
}

func validatePinnedResourceGroupResource(resource resourcedomain.Resource) error {
	// Status is deliberately not re-read: the active/grantable owner facts were validated and pinned at
	// publish time. Structural corruption still fails closed instead of producing an undefined asset.
	if resource.ResourceID <= 0 || !resource.Grantable || !resourcedomain.ValidGrantStrategy(resource.GrantStrategy) {
		return xerr.New(xerr.Internal, "resource group snapshot contains an invalid resource")
	}
	if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) {
		return xerr.New(xerr.Internal, "resource group snapshot contains an invalid wallet credit")
	}
	return nil
}

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 normalizeRevokeResourceGrantCommand(command resourcedomain.RevokeResourceGrantCommand) resourcedomain.RevokeResourceGrantCommand {
	command.RequestID = strings.TrimSpace(command.RequestID)
	command.GrantID = strings.TrimSpace(command.GrantID)
	command.Reason = strings.TrimSpace(command.Reason)
	return command
}

func validateRevokeResourceGrantCommand(command resourcedomain.RevokeResourceGrantCommand) error {
	if command.GrantID == "" || command.OperatorUserID <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource grant revoke command is incomplete")
	}
	return nil
}

func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {

	if grantSource == resourcedomain.GrantSourceGameRobotInit {
		if resource.Status != resourcedomain.StatusActive {
			return xerr.New(xerr.Conflict, "resource is disabled")
		}
		if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) {
			return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin")
		}
		return nil
	}
	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 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 grantPinnedResourceGroupRequestHash(command resourcedomain.GrantPinnedResourceGroupCommand) string {
	return stableHash(fmt.Sprintf("pinned_resource_group_grant|%s|%d|%s|%s|%s|%d|%s",
		appcode.Normalize(command.AppCode), command.TargetUserID, command.SnapshotID,
		command.ExpectedSnapshotHash, 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 resolveVipTrialCardGrantFacts(resource resourcedomain.Resource, requestedDurationMS int64) (vipTrialCardMetadata, int64, error) {
	metadata, err := vipTrialCardResourceMetadata(resource.MetadataJSON)
	if err != nil || metadata.Level <= 0 {
		return vipTrialCardMetadata{}, 0, xerr.New(xerr.Conflict, "vip trial card resource metadata is invalid")
	}
	// 新资源把天数固化在 metadata；历史标准资源只有 vip_level，继续兼容调用方或
	// 老资源组明确携带的 duration_ms，避免上线后让既有专用发卡和已发布活动失效。
	durationMS := metadata.DurationMS
	if durationMS <= 0 {
		durationMS = requestedDurationMS
	}
	if durationMS <= 0 {
		return vipTrialCardMetadata{}, 0, xerr.New(xerr.InvalidArgument, "vip trial card duration is required")
	}
	return metadata, durationMS, nil
}

func resourceGroupVipTrialCardCommandID(parentCommandID string, groupItemID int64, resourceID int64) string {
	hash := stableHash(fmt.Sprintf("resource_group_vip_trial_card|%s|%d|%d", strings.TrimSpace(parentCommandID), groupItemID, resourceID))
	return fmt.Sprintf("rgg_vip:%d:%d:%s", groupItemID, resourceID, hash[:24])
}

func vipTrialCardGrantItemHash(grantID string, commandID string, targetUserID int64, resourceID int64, durationMS int64) string {
	return stableHash(fmt.Sprintf("vip_trial_card_grant_item|%s|%s|%d|%d|%d", grantID, commandID, targetUserID, resourceID, durationMS))
}

func vipTrialCardGrantedEvent(commandID string, result vipTrialCardGrantItemResult, targetUserID int64, resourceID int64, nowMS int64) walletOutboxEvent {
	return resourceOutboxEvent("VipTrialCardGranted", commandID, targetUserID, resourceID, map[string]any{
		"trial_card": result.Card, "program_type": result.Program.ProgramType, "config_version": result.Program.ConfigVersion,
	}, nowMS)
}

func resourceGroupWalletAssetCommandID(commandID string, groupItemID int64, assetType string) string {
	// wallet_transactions.command_id 是对外幂等键字段，长度固定为 128；CP 周榜等上游命令本身可能很长，
	// 组内钱包资产 item 使用稳定短 hash 生成内部交易命令，同时在 metadata.command_id 保留完整上游命令用于审计。
	hash := stableHash(fmt.Sprintf("resource_group_grant_item|%s|%d|%s", strings.TrimSpace(commandID), groupItemID, resourcedomain.NormalizeWalletAssetType(assetType)))
	return fmt.Sprintf("rgg:%d:%s:%s", groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), hash[:24])
}

func resourceGrantWalletRevokeHash(grantID string, grantItemID int64, assetType string, amount int64) string {
	return stableHash(fmt.Sprintf("resource_grant_wallet_revoke|%s|%d|%s|%d", grantID, grantItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
}

func resourceGrantRevokeCommandID(command resourcedomain.RevokeResourceGrantCommand, grantID string, grantItemID int64) string {
	base := strings.TrimSpace(command.RequestID)
	if base == "" {
		base = strings.TrimSpace(grantID)
	}
	if grantItemID > 0 {
		return fmt.Sprintf("resource_grant_revoke:%s:item:%d", base, grantItemID)
	}
	return fmt.Sprintf("resource_grant_revoke:%s", base)
}

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