package mysql

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

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

	pruneResourceTypes := []string{}
	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")
		}

		if isEquipableResourceType(resourceType) {
			pruneResourceTypes = []string{resourceType}
		}
		where += ` AND r.resource_type = ?`
		args = append(args, resourceType)
	}
	if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil {
		return nil, err
	}
	if query.ActiveOnly {

		where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0`
		args = append(args, nowMs, nowMs)
	}
	rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.UserResourceEntitlement, 0)
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		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 := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	entitlement.Equipped = true
	eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	if 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) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	if command.UserID <= 0 || command.ResourceType == "" {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge {

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

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

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

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

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

func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	userIDs := compactPositiveInt64s(query.UserIDs)
	if len(userIDs) == 0 {
		return []resourcedomain.UserEquippedResources{}, nil
	}

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

	if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil {
		return nil, err
	}

	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
	args = append(args, nowMs, nowMs)
	typeFilter := ""
	if len(resourceTypes) > 0 {

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

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

	grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs))
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		item.Equipped = true
		grouped[item.UserID] = append(grouped[item.UserID], item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

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

func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (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) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
	if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
		return nil
	}
	entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
	if err != nil {
		return err
	}
	if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil {
		return err
	}
	entitlement.Equipped = true

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

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

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

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

func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, 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,
		       FALSE AS equipped,
		       `+resourceColumnsWithAlias("r")+`
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active'
		  AND e.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) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
		userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`,
		appcode.FromContext(ctx), entitlementID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func 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,
		&item.Equipped,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

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

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

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

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

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

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

func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error {
	userIDs = compactPositiveInt64s(userIDs)
	if r == nil || r.db == nil || len(userIDs) == 0 {
		return nil
	}

	resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes)
	if err != nil {
		return err
	}
	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
	args = append(args, nowMs, nowMs)
	typeFilter := ""
	if len(resourceTypes) > 0 {
		typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)`
		args = append(args, stringAnyArgs(resourceTypes)...)
	}

	_, err = r.db.ExecContext(ctx, `
		DELETE eq
		FROM user_resource_equipment eq
		LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code
		 AND e.user_id = eq.user_id
		 AND e.resource_id = eq.resource_id
		 AND e.entitlement_id = eq.entitlement_id
		LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id
		WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`)
		  AND (
		    e.entitlement_id IS NULL
		    OR e.status <> 'active'
		    OR e.effective_at_ms > ?
		    OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?)
		    OR e.remaining_quantity <= 0
		    OR r.resource_id IS NULL
		    OR r.status <> 'active'
		  )`+typeFilter,
		args...,
	)
	return err
}

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

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