package appuser

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strconv"
	"strings"
	"time"

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/config"
	"hyapp-admin-server/internal/integration/activityclient"
	"hyapp-admin-server/internal/integration/userclient"
	"hyapp-admin-server/internal/integration/walletclient"
	"hyapp-admin-server/internal/modules/shared"
	"hyapp-admin-server/internal/security"
	activityv1 "hyapp.local/api/proto/activity/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
)

var (
	ErrInvalidArgument = errors.New("invalid argument")
	ErrNotFound        = errors.New("not found")
)

type Service struct {
	userClient      userclient.Client
	userAdminClient userAdminProjectionClient
	// contentBlacklistClient 只开放 user-service 的内容屏蔽治理契约；admin 不直连用户库维护第二份状态。
	contentBlacklistClient userContentBlacklistAdminClient
	// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
	tokenIssuerClient accessTokenIssuerClient
	activityClient    activityclient.Client
	levelAdminClient  levelAdminProjectionClient
	walletClient      rechargeStatsClient
	jobStore          appUserExportJobStore
	jobsConfig        config.JobsConfig
	adminDB           *sql.DB
	userDB            *sql.DB
	walletDB          *sql.DB
}

// rechargeStatsClient 只暴露用户后台需要的累计充值聚合；列表不能为展示字段回扫钱包流水。
type rechargeStatsClient interface {
	BatchGetUserRechargeStats(context.Context, *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error)
}

// userAdminProjectionClient 保证生日、成功登录和封禁语义继续由 user-service 统一解释。
type userAdminProjectionClient interface {
	BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error)
	AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error)
	AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error)
}

// userContentBlacklistAdminClient 与通用 Client 分离，避免既有模块和测试替身被迫获得后台治理写能力。
type userContentBlacklistAdminClient interface {
	ResolveAdminUserIdentifier(context.Context, userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error)
	ListUserContentBlacklist(context.Context, *userv1.ListUserContentBlacklistRequest) (*userv1.ListUserContentBlacklistResponse, error)
	AdminAddUserContentBlacklist(context.Context, *userv1.AdminAddUserContentBlacklistRequest) (*userv1.AdminAddUserContentBlacklistResponse, error)
	AdminRemoveUserContentBlacklist(context.Context, *userv1.AdminRemoveUserContentBlacklistRequest) (*userv1.AdminRemoveUserContentBlacklistResponse, error)
}

// levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整，游戏等级没有写入口。
type levelAdminProjectionClient interface {
	BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error)
	AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error)
}

type ServiceOption func(*Service)

// WithWalletClient 通过 wallet-service 读取它拥有的充值聚合，保留 walletDB 仅用于现有余额与 VIP 展示。
func WithWalletClient(client walletclient.Client) ServiceOption {
	return func(service *Service) {
		service.walletClient = client
	}
}

// WithExportJobs 把任务创建留在 admin 数据库，把分批导出交给统一 job runner 执行。
func WithExportJobs(store appUserExportJobStore, cfg config.Config) ServiceOption {
	return func(service *Service) {
		service.jobStore = store
		service.jobsConfig = cfg.Jobs
	}
}

type AppUser struct {
	Age *int32 `json:"age,omitempty"`
	// AppVersion/BuildNumber/AppVersionAtMs 是最近一次真实登录（password/third_party/refresh，
	// 不含 access_resign 心跳重签）携带的客户端版本快照，仅在 GetUser 填充，列表不回扫审计表。
	AppVersion                 string                `json:"appVersion"`
	AppVersionAtMs             int64                 `json:"appVersionAtMs"`
	Avatar                     string                `json:"avatar"`
	Balances                   []AppUserAssetBalance `json:"balances,omitempty"`
	Ban                        AppUserBan            `json:"ban"`
	Birth                      string                `json:"birth"`
	BuildNumber                string                `json:"buildNumber"`
	Coin                       int64                 `json:"coin"`
	Country                    string                `json:"country"`
	CountryDisplayName         string                `json:"countryDisplayName"`
	CountryName                string                `json:"countryName"`
	CreatedAtMs                int64                 `json:"createdAtMs"`
	CumulativeRechargeUSDMinor int64                 `json:"cumulativeRechargeUsdMinor"`
	DefaultDisplayUserID       string                `json:"defaultDisplayUserId"`
	// Devices 是设备 tab 数据，来自 user_devices 认证成功聚合；仅在 GetUser 填充，按最近活跃倒序。
	Devices           []AppUserDevice   `json:"devices,omitempty"`
	Diamond           int64             `json:"diamond"`
	DisplayUserID     string            `json:"displayUserId"`
	EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
	Gender            string            `json:"gender"`
	LastActiveAtMs    int64             `json:"lastActiveAtMs"`
	LastLoginAtMs     int64             `json:"lastLoginAtMs"`
	// LastLoginIp* 来自 login_audit 全部登录类型（含 access_resign 心跳重签）的最新成功行，
	// 在线用户可精确到 25s 级新鲜度；仅在 GetUser 填充。
	LastLoginIP            string           `json:"lastLoginIp"`
	LastLoginIPAtMs        int64            `json:"lastLoginIpAtMs"`
	LastLoginIPCountryCode string           `json:"lastLoginIpCountryCode"`
	LastOperatedAtMs       int64            `json:"lastOperatedAtMs"`
	LastOperator           *AppUserOperator `json:"lastOperator,omitempty"`
	Levels                 AppUserLevels    `json:"levels"`
	PrettyDisplayUserID    string           `json:"prettyDisplayUserId"`
	PrettyID               string           `json:"prettyId"`
	RegisterDevice         string           `json:"registerDevice"`
	// RegisterDeviceID/RegisterOsVersion 是 users 表注册时一次性快照，配合设备 tab 的注册设备区块展示。
	RegisterDeviceID  string            `json:"registerDeviceId"`
	RegisterOsVersion string            `json:"registerOsVersion"`
	RegionID          int64             `json:"regionId"`
	RegionName        string            `json:"regionName"`
	Resources         []AppUserResource `json:"resources,omitempty"`
	Roles             []string          `json:"roles"`
	Status            string            `json:"status"`
	// ThirdPartyIdentities 暴露三方绑定 openid（provider_subject）；一个用户可能绑定多个 provider。
	ThirdPartyIdentities []AppUserThirdPartyIdentity `json:"thirdPartyIdentities,omitempty"`
	UpdatedAtMs          int64                       `json:"updatedAtMs"`
	UserID               string                      `json:"userId"`
	Username             string                      `json:"username"`
	VIP                  AppUserVIP                  `json:"vip"`
}

// AppUserLevels 固定三个成长轨道，前端无需按数组位置猜测富豪、游戏和魅力列。
type AppUserLevels struct {
	Wealth AppUserLevel `json:"wealth"`
	Game   AppUserLevel `json:"game"`
	Charm  AppUserLevel `json:"charm"`
}

// AppUserLevel 同时暴露真实成长事实和当前临时展示事实，详情页可明确标出到期时间。
type AppUserLevel struct {
	Track                string `json:"track"`
	RealLevel            int32  `json:"realLevel"`
	RealTotalValue       int64  `json:"realTotalValue"`
	DisplayLevel         int32  `json:"displayLevel"`
	DisplayValue         int64  `json:"displayValue"`
	TemporaryLevelID     string `json:"temporaryLevelId,omitempty"`
	TemporaryTargetLevel int32  `json:"temporaryTargetLevel,omitempty"`
	StartedAtMs          int64  `json:"startedAtMs,omitempty"`
	ExpiresAtMs          int64  `json:"expiresAtMs,omitempty"`
}

type AppUserBan struct {
	ID          string `json:"id,omitempty"`
	Active      bool   `json:"active"`
	Permanent   bool   `json:"permanent"`
	ExpiresAtMs int64  `json:"expiresAtMs"`
	Source      string `json:"source,omitempty"`
	Reason      string `json:"reason,omitempty"`
}

type AppUserOperator struct {
	AdminID string `json:"adminId"`
	Name    string `json:"name"`
	Action  string `json:"action"`
}

type AppUserAssetBalance struct {
	AssetType       string `json:"assetType"`
	AvailableAmount int64  `json:"availableAmount"`
	FrozenAmount    int64  `json:"frozenAmount"`
	TotalAmount     int64  `json:"totalAmount"`
	Version         int64  `json:"version"`
	UpdatedAtMs     int64  `json:"updatedAtMs"`
}

type AppUserVIP struct {
	Level       int32  `json:"level"`
	Name        string `json:"name"`
	Active      bool   `json:"active"`
	StartedAtMs int64  `json:"startedAtMs"`
	ExpiresAtMs int64  `json:"expiresAtMs"`
	UpdatedAtMs int64  `json:"updatedAtMs"`
}

type AppUserResource struct {
	EntitlementID     string `json:"entitlementId"`
	ResourceID        int64  `json:"resourceId"`
	ResourceCode      string `json:"resourceCode"`
	ResourceType      string `json:"resourceType"`
	Name              string `json:"name"`
	Status            string `json:"status"`
	Quantity          int64  `json:"quantity"`
	RemainingQuantity int64  `json:"remainingQuantity"`
	EffectiveAtMs     int64  `json:"effectiveAtMs"`
	ExpiresAtMs       int64  `json:"expiresAtMs"`
	SourceGrantID     string `json:"sourceGrantId"`
	AssetURL          string `json:"assetUrl"`
	PreviewURL        string `json:"previewUrl"`
	AnimationURL      string `json:"animationUrl"`
	Equipped          bool   `json:"equipped"`
	CreatedAtMs       int64  `json:"createdAtMs"`
	UpdatedAtMs       int64  `json:"updatedAtMs"`
}

type SetUserStatusResult struct {
	User                AppUser `json:"user"`
	RevokedSessionCount int64   `json:"revokedSessionCount"`
	AccessTokenRevoked  bool    `json:"accessTokenRevoked"`
	AccessTokenError    string  `json:"accessTokenRevokeError,omitempty"`
	IMKicked            bool    `json:"imKicked"`
	IMKickError         string  `json:"imKickError,omitempty"`
	RoomEvicted         bool    `json:"roomEvicted"`
	RoomID              string  `json:"roomId,omitempty"`
	RTCKicked           bool    `json:"rtcKicked"`
	RTCKickError        string  `json:"rtcKickError,omitempty"`
	RoomEvictError      string  `json:"roomEvictError,omitempty"`
}

func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, opts ...ServiceOption) *Service {
	service := &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
	if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
		service.userAdminClient = adminClient
	}
	if blacklistClient, ok := any(userClient).(userContentBlacklistAdminClient); ok {
		service.contentBlacklistClient = blacklistClient
	}
	if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
		service.tokenIssuerClient = tokenClient
	}
	if adminClient, ok := any(activityClient).(levelAdminProjectionClient); ok {
		service.levelAdminClient = adminClient
	}
	for _, opt := range opts {
		if opt != nil {
			opt(service)
		}
	}
	return service
}

func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
	if s.userDB == nil {
		return nil, 0, fmt.Errorf("user mysql is not configured")
	}
	query = normalizeListQuery(query)
	appCode := appctx.FromContext(ctx)
	nowMs := query.SnapshotAtMs
	if nowMs <= 0 {
		nowMs = nowMillis()
	}
	whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs)

	total, err := countRows(ctx, s.userDB, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	if query.SortBy == "coin" {
		// 金币余额来自 wallet 库，必须先补齐当前筛选结果的余额再排序分页，避免只按当前页局部重排。
		items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args, nowMs)
		return items, total, err
	}
	rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT u.user_id,
		       u.current_display_user_id,
		       u.default_display_user_id,
		       COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''),
		       COALESCE(u.gender, ''),
		       COALESCE(u.country, ''),
		       COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 THEN 0
		         WHEN EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN u.region_id
		         ELSE 0
		       END,
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN 'GLOBAL'
		         ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
		       END,
		       u.status,
		       u.created_at_ms,
		       u.updated_at_ms,
		       GREATEST(
		         COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
		         COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
		       )
		       %s
		%s
		%s
		LIMIT ? OFFSET ?
	`, appUserPrettySelectColumns(), whereSQL, appUserOrderSQL(query)), append(appUserSelectArgs(nowMs, args), query.PageSize, offset(query.Page, query.PageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items, userIDs, err := scanAppUserRows(rows, query.PageSize)
	if err != nil {
		return nil, 0, err
	}
	if err := s.fillBalances(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	if err := s.fillVIPLevels(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	if err := s.fillUserAdminProfiles(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	if err := s.fillLevelAdminProfiles(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	if err := s.fillRechargeStats(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	if err := s.fillLatestOperators(ctx, items, userIDs); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any, nowMs int64) ([]AppUser, error) {
	rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT u.user_id,
		       u.current_display_user_id,
		       u.default_display_user_id,
		       COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''),
		       COALESCE(u.gender, ''),
		       COALESCE(u.country, ''),
		       COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 THEN 0
		         WHEN EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN u.region_id
		         ELSE 0
		       END,
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN 'GLOBAL'
		         ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
		       END,
		       u.status,
		       u.created_at_ms,
		       u.updated_at_ms,
		       GREATEST(
		         COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
		         COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
		       )
		       %s
		%s
	`, appUserPrettySelectColumns(), whereSQL), appUserSelectArgs(nowMs, args)...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items, userIDs, err := scanAppUserRows(rows, query.PageSize)
	if err != nil {
		return nil, err
	}
	if err := s.fillBalances(ctx, items, userIDs); err != nil {
		return nil, err
	}
	sortAppUsersByCoin(items, query.SortDirection)
	pagedItems := paginateAppUsers(items, query.Page, query.PageSize)
	if err := s.fillVIPLevels(ctx, pagedItems, appUserNumericIDs(pagedItems)); err != nil {
		return nil, err
	}
	pagedUserIDs := appUserNumericIDs(pagedItems)
	if err := s.fillUserAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
		return nil, err
	}
	if err := s.fillLevelAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil {
		return nil, err
	}
	if err := s.fillRechargeStats(ctx, pagedItems, pagedUserIDs); err != nil {
		return nil, err
	}
	if err := s.fillLatestOperators(ctx, pagedItems, pagedUserIDs); err != nil {
		return nil, err
	}
	return pagedItems, nil
}

func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) {
	items := make([]AppUser, 0, capacity)
	userIDs := make([]int64, 0, capacity)
	for rows.Next() {
		var item AppUser
		var userID int64
		if err := rows.Scan(
			&userID,
			&item.DisplayUserID,
			&item.DefaultDisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.Gender,
			&item.Country,
			&item.CountryName,
			&item.CountryDisplayName,
			&item.RegionID,
			&item.RegionName,
			&item.Status,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.LastActiveAtMs,
			&item.PrettyDisplayUserID,
			&item.PrettyID,
		); err != nil {
			return nil, nil, err
		}
		item.UserID = strconv.FormatInt(userID, 10)
		items = append(items, item)
		userIDs = append(userIDs, userID)
	}
	if err := rows.Err(); err != nil {
		return nil, nil, err
	}
	return items, userIDs, nil
}

func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
	if s.userDB == nil {
		return AppUser{}, fmt.Errorf("user mysql is not configured")
	}
	var item AppUser
	var scannedUserID int64
	appCode := appctx.FromContext(ctx)
	nowMs := nowMillis()
	err := s.userDB.QueryRowContext(ctx, `
		SELECT u.user_id,
		       u.current_display_user_id,
		       u.default_display_user_id,
		       COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''),
		       COALESCE(u.gender, ''),
		       COALESCE(u.country, ''),
		       COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''),
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 THEN 0
		         WHEN EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN u.region_id
		         ELSE 0
		       END,
		       CASE
		         WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = u.app_code
		             AND rg.region_id = u.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         ) THEN 'GLOBAL'
		         ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '')
		       END,
		       u.status,
		       u.created_at_ms,
		       u.updated_at_ms,
		       GREATEST(
		         COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
		         COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
		       ),
		       COALESCE(u.register_device_id, ''),
		       COALESCE(u.os_version, '')
		       `+appUserPrettySelectColumns()+`
		FROM users u
		WHERE u.app_code = ? AND u.user_id = ?
	`, nowMs, nowMs, appCode, userID).Scan(
		&scannedUserID,
		&item.DisplayUserID,
		&item.DefaultDisplayUserID,
		&item.Username,
		&item.Avatar,
		&item.Gender,
		&item.Country,
		&item.CountryName,
		&item.CountryDisplayName,
		&item.RegionID,
		&item.RegionName,
		&item.Status,
		&item.CreatedAtMs,
		&item.UpdatedAtMs,
		&item.LastActiveAtMs,
		&item.RegisterDeviceID,
		&item.RegisterOsVersion,
		&item.PrettyDisplayUserID,
		&item.PrettyID,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return AppUser{}, ErrNotFound
	}
	if err != nil {
		return AppUser{}, err
	}
	item.UserID = strconv.FormatInt(scannedUserID, 10)
	items := []AppUser{item}
	if err := s.fillBalances(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillVIPLevels(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillUserAdminProfiles(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillLevelAdminProfiles(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillRechargeStats(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillLatestOperators(ctx, items, []int64{userID}); err != nil {
		return AppUser{}, err
	}
	if err := s.fillUserResources(ctx, items, userID); err != nil {
		return AppUser{}, err
	}
	// 状态记录/设备 tab 的补充事实只在详情页读取，列表接口不承担这三组查询。
	if err := s.fillLoginSnapshots(ctx, &items[0], userID); err != nil {
		return AppUser{}, err
	}
	if err := s.fillThirdPartyIdentities(ctx, &items[0], userID); err != nil {
		return AppUser{}, err
	}
	if err := s.fillDevices(ctx, &items[0], userID); err != nil {
		return AppUser{}, err
	}
	return items[0], nil
}

func (s *Service) fillUserAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
	initializeAdminProjectionDefaults(items)
	if s.userAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
		return nil
	}
	nowMs := nowMillis()
	response, err := s.userAdminClient.BatchGetUserAdminProfiles(ctx, &userv1.BatchGetUserAdminProfilesRequest{
		Meta: &userv1.RequestMeta{
			RequestId: fmt.Sprintf("admin-app-user-profile-%d", nowMs),
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMs,
			AppCode:   appctx.FromContext(ctx),
		},
		UserIds: userIDs,
	})
	if err != nil {
		return err
	}
	index := appUserIndex(items)
	for userID, profile := range response.GetProfiles() {
		if profile == nil {
			continue
		}
		i, ok := index[userID]
		if !ok {
			continue
		}
		items[i].Birth = strings.TrimSpace(profile.GetBirth())
		items[i].Age = ageAtUTC(items[i].Birth, time.UnixMilli(nowMs).UTC())
		items[i].RegisterDevice = strings.TrimSpace(profile.GetRegisterDevice())
		items[i].LastLoginAtMs = profile.GetLastSuccessLoginAtMs()
		items[i].Roles = displayUserRoles(profile.GetRoles())
		if ban := profile.GetBan(); ban != nil {
			items[i].Ban = AppUserBan{
				ID:          strings.TrimSpace(ban.GetBanId()),
				Active:      ban.GetActive(),
				Permanent:   ban.GetPermanent(),
				ExpiresAtMs: ban.GetExpiresAtMs(),
				Source:      strings.TrimSpace(ban.GetSource()),
				Reason:      strings.TrimSpace(ban.GetReason()),
			}
		}
		if profile.GetLastOperationAtMs() > items[i].LastOperatedAtMs {
			items[i].LastOperatedAtMs = profile.GetLastOperationAtMs()
			items[i].LastOperator = &AppUserOperator{
				AdminID: strconv.FormatInt(profile.GetLastOperatorUserId(), 10),
				Name:    displayOperatorType(profile.GetLastOperatorType()),
				Action:  strings.TrimSpace(profile.GetLastOperationReason()),
			}
		}
	}
	return nil
}

func (s *Service) fillLevelAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error {
	initializeAdminProjectionDefaults(items)
	if s.levelAdminClient == nil || len(items) == 0 || len(userIDs) == 0 {
		return nil
	}
	nowMs := nowMillis()
	response, err := s.levelAdminClient.BatchGetUserLevelAdminProfiles(ctx, &activityv1.BatchGetUserLevelAdminProfilesRequest{
		Meta: &activityv1.RequestMeta{
			RequestId: fmt.Sprintf("admin-app-user-level-%d", nowMs),
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMs,
			AppCode:   appctx.FromContext(ctx),
		},
		UserIds: userIDs,
	})
	if err != nil {
		return err
	}
	index := appUserIndex(items)
	for _, profile := range response.GetProfiles() {
		if profile == nil {
			continue
		}
		i, ok := index[profile.GetUserId()]
		if !ok {
			continue
		}
		for _, track := range profile.GetTracks() {
			if track == nil {
				continue
			}
			level := AppUserLevel{
				Track:                strings.ToLower(strings.TrimSpace(track.GetTrack())),
				RealLevel:            track.GetRealLevel(),
				RealTotalValue:       track.GetRealTotalValue(),
				DisplayLevel:         track.GetDisplayLevel(),
				DisplayValue:         track.GetDisplayValue(),
				TemporaryLevelID:     strings.TrimSpace(track.GetTemporaryLevelId()),
				TemporaryTargetLevel: track.GetTemporaryTargetLevel(),
				StartedAtMs:          track.GetStartedAtMs(),
				ExpiresAtMs:          track.GetExpiresAtMs(),
			}
			switch level.Track {
			case "wealth":
				items[i].Levels.Wealth = level
			case "charm":
				items[i].Levels.Charm = level
			case "game":
				items[i].Levels.Game = level
			}
		}
	}
	return nil
}

func initializeAdminProjectionDefaults(items []AppUser) {
	for i := range items {
		if len(items[i].Roles) == 0 {
			items[i].Roles = []string{"普通用户"}
		}
		if items[i].Levels.Wealth.Track == "" {
			items[i].Levels.Wealth.Track = "wealth"
		}
		if items[i].Levels.Charm.Track == "" {
			items[i].Levels.Charm.Track = "charm"
		}
		if items[i].Levels.Game.Track == "" {
			items[i].Levels.Game.Track = "game"
		}
	}
}

func displayUserRoles(roles []string) []string {
	displayByRole := map[string]string{
		"host":        "主播",
		"agency":      "公会长",
		"bd":          "BD",
		"bd_leader":   "BD Leader",
		"coin_seller": "币商",
		"manager":     "经理",
	}
	seen := make(map[string]struct{}, len(roles))
	for _, role := range roles {
		role = strings.ToLower(strings.TrimSpace(role))
		if _, ok := displayByRole[role]; ok {
			seen[role] = struct{}{}
		}
	}
	order := []string{"host", "agency", "bd", "bd_leader", "coin_seller", "manager"}
	result := make([]string, 0, len(seen))
	for _, role := range order {
		if _, ok := seen[role]; ok {
			result = append(result, displayByRole[role])
		}
	}
	if len(result) == 0 {
		return []string{"普通用户"}
	}
	return result
}

func displayOperatorType(operatorType string) string {
	switch strings.ToLower(strings.TrimSpace(operatorType)) {
	case "admin":
		return "管理员"
	case "manager":
		return "经理"
	default:
		return strings.TrimSpace(operatorType)
	}
}

func ageAtUTC(birth string, now time.Time) *int32 {
	birthDate, err := time.Parse("2006-01-02", strings.TrimSpace(birth))
	if err != nil || birthDate.After(now) {
		return nil
	}
	age := now.Year() - birthDate.Year()
	if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) {
		age--
	}
	if age < 0 {
		return nil
	}
	value := int32(age)
	return &value
}

func (s *Service) fillRechargeStats(ctx context.Context, items []AppUser, userIDs []int64) error {
	if s.walletClient == nil || len(items) == 0 || len(userIDs) == 0 {
		return nil
	}
	response, err := s.walletClient.BatchGetUserRechargeStats(ctx, &walletv1.BatchGetUserRechargeStatsRequest{
		RequestId: fmt.Sprintf("admin-app-user-recharge-%d", nowMillis()),
		AppCode:   appctx.FromContext(ctx),
		UserIds:   userIDs,
	})
	if err != nil {
		return err
	}
	index := appUserIndex(items)
	for _, stat := range response.GetItems() {
		if stat == nil {
			continue
		}
		if i, ok := index[stat.GetUserId()]; ok {
			items[i].CumulativeRechargeUSDMinor = stat.GetTotalUsdMinorAmount()
		}
	}
	return nil
}

func (s *Service) fillLatestOperators(ctx context.Context, items []AppUser, userIDs []int64) error {
	if s.adminDB == nil || len(items) == 0 || len(userIDs) == 0 {
		return nil
	}
	args := make([]any, 0, len(userIDs)+1)
	args = append(args, "app_users")
	for _, userID := range userIDs {
		args = append(args, strconv.FormatInt(userID, 10))
	}
	rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT resource_id, user_id, username, action, created_at_ms
		FROM admin_operation_logs
		WHERE resource = ? AND resource_id IN (%s) AND status = 'success'
		ORDER BY resource_id ASC, created_at_ms DESC, id DESC
	`, placeholders(len(userIDs))), args...)
	if err != nil {
		return err
	}
	defer rows.Close()

	index := appUserIndex(items)
	seen := make(map[int64]struct{}, len(userIDs))
	for rows.Next() {
		var resourceID string
		var adminID uint64
		var name string
		var action string
		var operatedAtMs int64
		if err := rows.Scan(&resourceID, &adminID, &name, &action, &operatedAtMs); err != nil {
			return err
		}
		userID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
		if err != nil {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		i, ok := index[userID]
		if !ok {
			continue
		}
		seen[userID] = struct{}{}
		if operatedAtMs <= items[i].LastOperatedAtMs {
			continue
		}
		items[i].LastOperatedAtMs = operatedAtMs
		items[i].LastOperator = &AppUserOperator{
			AdminID: strconv.FormatUint(adminID, 10),
			Name:    strings.TrimSpace(name),
			Action:  strings.TrimSpace(action),
		}
	}
	return rows.Err()
}

func appUserIndex(items []AppUser) map[int64]int {
	index := make(map[int64]int, len(items))
	for i := range items {
		userID, err := strconv.ParseInt(strings.TrimSpace(items[i].UserID), 10, 64)
		if err == nil && userID > 0 {
			index[userID] = i
		}
	}
	return index
}

func (s *Service) UpdateUser(ctx context.Context, userID int64, adminUserID int64, requestID string, req updateUserRequest) (AppUser, error) {
	if s.userDB == nil {
		return AppUser{}, fmt.Errorf("user mysql is not configured")
	}
	sets := make([]string, 0, 4)
	args := make([]any, 0, 8)
	if req.Username != nil {
		value := strings.TrimSpace(*req.Username)
		if len(value) > 64 {
			return AppUser{}, fmt.Errorf("%w: 用户名称不能超过 64 个字符", ErrInvalidArgument)
		}
		sets = append(sets, "username = ?")
		args = append(args, nullableString(value))
	}
	if req.Avatar != nil {
		value := strings.TrimSpace(*req.Avatar)
		if len(value) > 512 {
			return AppUser{}, fmt.Errorf("%w: 头像不能超过 512 个字符", ErrInvalidArgument)
		}
		sets = append(sets, "avatar = ?")
		args = append(args, nullableString(value))
	}
	if req.Gender != nil {
		value := strings.ToLower(strings.TrimSpace(*req.Gender))
		if len(value) > 32 {
			return AppUser{}, fmt.Errorf("%w: 性别不能超过 32 个字符", ErrInvalidArgument)
		}
		sets = append(sets, "gender = ?")
		args = append(args, nullableString(value))
	}

	if len(sets) > 0 {
		sets = append(sets, "updated_at_ms = ?")
		appCode := appctx.FromContext(ctx)
		args = append(args, nowMillis())
		args = append(args, appCode, userID)
		result, err := s.userDB.ExecContext(ctx, "UPDATE users SET "+strings.Join(sets, ", ")+" WHERE app_code = ? AND user_id = ?", args...)
		if err != nil {
			return AppUser{}, err
		}
		affected, err := result.RowsAffected()
		if err != nil {
			return AppUser{}, err
		}
		if affected == 0 {
			return AppUser{}, ErrNotFound
		}
	}

	if req.Country != nil {
		if s.userClient == nil {
			return AppUser{}, fmt.Errorf("user client is not configured")
		}
		change, err := s.userClient.AdminChangeUserCountry(ctx, userclient.AdminChangeUserCountryRequest{
			RequestID:    strings.TrimSpace(requestID),
			Caller:       "hyapp-admin-server",
			TargetUserID: userID,
			Country:      *req.Country,
			AdminUserID:  adminUserID,
			Reason:       "admin_user_country_changed",
		})
		if err != nil {
			return AppUser{}, err
		}
		if change != nil && change.RegionChanged {
			s.removeOldRegionBroadcastMemberBestEffort(ctx, userID, change.OldRegionID, change.NewRegionID, strings.TrimSpace(requestID), "admin_user_country_changed")
		}
	}
	if len(sets) == 0 && req.Country == nil {
		return s.GetUser(ctx, userID)
	}
	return s.GetUser(ctx, userID)
}

func (s *Service) removeOldRegionBroadcastMemberBestEffort(ctx context.Context, userID int64, oldRegionID int64, newRegionID int64, requestID string, reason string) {
	if s == nil || s.activityClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID {
		return
	}
	_, err := s.activityClient.RemoveRegionBroadcastMember(ctx, &activityv1.RemoveRegionBroadcastMemberRequest{
		Meta: &activityv1.RequestMeta{
			RequestId: strings.TrimSpace(requestID),
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMillis(),
			AppCode:   appctx.FromContext(ctx),
		},
		UserId:   userID,
		RegionId: oldRegionID,
		Reason:   reason,
	})
	if err != nil {
		// 后台国家更新已经写入 users；IM 成员移除是外部副作用，失败不回滚资料，callback 会阻止用户再次加入旧区域群。
		slog.WarnContext(ctx, "admin_im_region_group_member_remove_failed",
			slog.Int64("user_id", userID),
			slog.Int64("old_region_id", oldRegionID),
			slog.Int64("new_region_id", newRegionID),
			slog.String("request_id", requestID),
			slog.String("error", err.Error()),
		)
	}
}

func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string, operatorUserID int64, requestID string) (SetUserStatusResult, error) {
	status = strings.ToLower(strings.TrimSpace(status))
	if status != "active" && status != "banned" && status != "disabled" {
		return SetUserStatusResult{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument)
	}
	if s.userClient == nil {
		return SetUserStatusResult{}, fmt.Errorf("user service client is not configured")
	}
	result, err := s.userClient.SetUserStatus(ctx, userclient.SetUserStatusRequest{
		RequestID:      strings.TrimSpace(requestID),
		Caller:         "hyapp-admin-server",
		TargetUserID:   userID,
		Status:         status,
		OperatorType:   "admin",
		OperatorUserID: operatorUserID,
		Reason:         "admin_app_user_status",
	})
	if err != nil {
		return SetUserStatusResult{}, err
	}
	user, err := s.GetUser(ctx, userID)
	if err != nil {
		return SetUserStatusResult{}, err
	}
	output := SetUserStatusResult{User: user}
	if result != nil {
		output.RevokedSessionCount = result.RevokedSessionCount
		output.AccessTokenRevoked = result.AccessTokenRevoked
		output.AccessTokenError = result.AccessTokenError
		output.IMKicked = result.IMKicked
		output.IMKickError = result.IMKickError
		output.RoomEvicted = result.RoomEvicted
		output.RoomID = result.RoomID
		output.RTCKicked = result.RTCKicked
		output.RTCKickError = result.RTCKickError
		output.RoomEvictError = result.RoomEvictError
	}
	return output, nil
}

func (s *Service) AdjustTemporaryLevels(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req adjustTemporaryLevelsRequest) (AppUser, error) {
	if s.levelAdminClient == nil {
		return AppUser{}, fmt.Errorf("activity service level admin client is not configured")
	}
	if userID <= 0 || operatorAdminID <= 0 || len(req.Adjustments) == 0 || len(req.Adjustments) > 2 {
		return AppUser{}, fmt.Errorf("%w: 等级调整参数不正确", ErrInvalidArgument)
	}
	seen := make(map[string]struct{}, len(req.Adjustments))
	adjustments := make([]*activityv1.TemporaryLevelAdjustment, 0, len(req.Adjustments))
	for _, adjustment := range req.Adjustments {
		track := strings.ToLower(strings.TrimSpace(adjustment.Track))
		if track != "wealth" && track != "charm" {
			return AppUser{}, fmt.Errorf("%w: 只允许调整富豪或魅力等级", ErrInvalidArgument)
		}
		if _, duplicated := seen[track]; duplicated {
			return AppUser{}, fmt.Errorf("%w: 同一等级轨道不能重复提交", ErrInvalidArgument)
		}
		if adjustment.Level < 1 || adjustment.Level > 50 || adjustment.DurationDays <= 0 {
			return AppUser{}, fmt.Errorf("%w: 等级必须为 1-50 且有效期必须大于 0 天", ErrInvalidArgument)
		}
		seen[track] = struct{}{}
		adjustments = append(adjustments, &activityv1.TemporaryLevelAdjustment{
			Track:        track,
			Level:        adjustment.Level,
			DurationDays: adjustment.DurationDays,
		})
	}
	requestID = strings.TrimSpace(requestID)
	if requestID == "" {
		return AppUser{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
	}
	nowMs := nowMillis()
	reason := strings.TrimSpace(req.Reason)
	if reason == "" {
		reason = "admin_temporary_user_level"
	}
	_, err := s.levelAdminClient.AdjustTemporaryUserLevels(ctx, &activityv1.AdjustTemporaryUserLevelsRequest{
		Meta: &activityv1.RequestMeta{
			RequestId: requestID,
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMs,
			AppCode:   appctx.FromContext(ctx),
		},
		CommandId:       fmt.Sprintf("app-user-level:%s:%d", requestID, userID),
		UserId:          userID,
		Adjustments:     adjustments,
		OperatorAdminId: operatorAdminID,
		Reason:          reason,
	})
	if err != nil {
		return AppUser{}, err
	}
	return s.GetUser(ctx, userID)
}

func (s *Service) AdminBanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req banUserRequest) (SetUserStatusResult, error) {
	if s.userAdminClient == nil {
		return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
	}
	nowMs := nowMillis()
	if userID <= 0 || operatorAdminID <= 0 || req.ExpiresAtMs < 0 || (req.ExpiresAtMs > 0 && req.ExpiresAtMs <= nowMs) {
		return SetUserStatusResult{}, fmt.Errorf("%w: 封禁截止时间必须为 0 或晚于当前 UTC 时间", ErrInvalidArgument)
	}
	requestID = strings.TrimSpace(requestID)
	if requestID == "" {
		return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
	}
	reason := strings.TrimSpace(req.Reason)
	if reason == "" {
		reason = "admin_app_user_ban"
	}
	response, err := s.userAdminClient.AdminBanUser(ctx, &userv1.AdminBanUserRequest{
		Meta: &userv1.RequestMeta{
			RequestId: requestID,
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMs,
			AppCode:   appctx.FromContext(ctx),
		},
		CommandId:       fmt.Sprintf("app-user-ban:%s:%d", requestID, userID),
		TargetUserId:    userID,
		ExpiresAtMs:     req.ExpiresAtMs,
		OperatorAdminId: operatorAdminID,
		Reason:          reason,
	})
	if err != nil {
		return SetUserStatusResult{}, err
	}
	return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
}

func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req unbanUserRequest) (SetUserStatusResult, error) {
	if s.userAdminClient == nil {
		return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured")
	}
	if userID <= 0 || operatorAdminID <= 0 {
		return SetUserStatusResult{}, fmt.Errorf("%w: 解封参数不正确", ErrInvalidArgument)
	}
	requestID = strings.TrimSpace(requestID)
	if requestID == "" {
		return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
	}
	reason := strings.TrimSpace(req.Reason)
	if reason == "" {
		reason = "admin_app_user_unban"
	}
	nowMs := nowMillis()
	response, err := s.userAdminClient.AdminUnbanUser(ctx, &userv1.AdminUnbanUserRequest{
		Meta: &userv1.RequestMeta{
			RequestId: requestID,
			Caller:    "hyapp-admin-server",
			SentAtMs:  nowMs,
			AppCode:   appctx.FromContext(ctx),
		},
		// user-service 以 target_user_id 为边界原子释放全部 admin/manager 事实；不再由聚合摘要猜测具体来源。
		TargetUserId:    userID,
		OperatorAdminId: operatorAdminID,
		Reason:          reason,
	})
	if err != nil {
		return SetUserStatusResult{}, err
	}
	return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus())
}

func (s *Service) userStatusResultFromAdminResponse(ctx context.Context, userID int64, status *userv1.SetUserStatusResponse) (SetUserStatusResult, error) {
	user, err := s.GetUser(ctx, userID)
	if err != nil {
		return SetUserStatusResult{}, err
	}
	result := SetUserStatusResult{User: user}
	if status == nil {
		return result, nil
	}
	result.RevokedSessionCount = status.GetRevokedSessionCount()
	result.AccessTokenRevoked = status.GetAccessTokenRevoked()
	result.AccessTokenError = status.GetAccessTokenRevokeError()
	result.IMKicked = status.GetImKicked()
	result.IMKickError = status.GetImKickError()
	result.RoomEvicted = status.GetRoomEvicted()
	result.RoomID = status.GetRoomId()
	result.RTCKicked = status.GetRtcKicked()
	result.RTCKickError = status.GetRtcKickError()
	result.RoomEvictError = status.GetRoomEvictError()
	return result, nil
}

func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error {
	if s.userDB == nil {
		return fmt.Errorf("user mysql is not configured")
	}
	password = strings.TrimSpace(password)
	if len(password) > 128 {
		return fmt.Errorf("%w: 密码不能超过 128 个字符", ErrInvalidArgument)
	}
	hash, err := security.HashPassword(password)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidArgument, err)
	}
	var exists int
	appCode := appctx.FromContext(ctx)
	if err := s.userDB.QueryRowContext(ctx, `SELECT 1 FROM users WHERE app_code = ? AND user_id = ?`, appCode, userID).Scan(&exists); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ErrNotFound
		}
		return err
	}
	now := nowMillis()
	_, err = s.userDB.ExecContext(ctx, `
		INSERT INTO password_accounts (app_code, user_id, password_hash, hash_alg, created_at_ms, updated_at_ms)
		VALUES (?, ?, ?, 'bcrypt', ?, ?)
		ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), hash_alg = VALUES(hash_alg), updated_at_ms = VALUES(updated_at_ms)
	`, appCode, userID, hash, now, now)
	return err
}

func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []int64) error {
	if s.walletDB == nil || len(userIDs) == 0 {
		return nil
	}
	index := make(map[int64]int, len(userIDs))
	for i, id := range userIDs {
		index[id] = i
	}
	appCode := appctx.FromContext(ctx)
	const chunkSize = 500
	for start := 0; start < len(userIDs); start += chunkSize {
		end := start + chunkSize
		if end > len(userIDs) {
			end = len(userIDs)
		}
		// 用户列表金币排序会对完整筛选集补余额，按块查询可以避免 IN 参数过长导致后台列表失败。
		chunk := userIDs[start:end]
		args := make([]any, 0, len(chunk)+1)
		args = append(args, appCode)
		for _, id := range chunk {
			args = append(args, id)
		}
		rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
			SELECT user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
			FROM wallet_accounts
			WHERE app_code = ? AND user_id IN (%s)
			ORDER BY user_id ASC, asset_type ASC
		`, placeholders(len(chunk))), args...)
		if err != nil {
			return err
		}
		for rows.Next() {
			var userID int64
			var balance AppUserAssetBalance
			if err := rows.Scan(
				&userID,
				&balance.AssetType,
				&balance.AvailableAmount,
				&balance.FrozenAmount,
				&balance.Version,
				&balance.UpdatedAtMs,
			); err != nil {
				_ = rows.Close()
				return err
			}
			i, ok := index[userID]
			if !ok {
				continue
			}
			balance.AssetType = strings.ToUpper(strings.TrimSpace(balance.AssetType))
			balance.TotalAmount = balance.AvailableAmount + balance.FrozenAmount
			// 钱包资产明细作为详情页“全部资产”的余额来源；金币和钻石仍同步到顶层字段，兼容列表排序和旧页面读取。
			items[i].Balances = append(items[i].Balances, balance)
			switch balance.AssetType {
			case "COIN":
				items[i].Coin = balance.AvailableAmount
			case "DIAMOND":
				items[i].Diamond = balance.AvailableAmount
			}
		}
		if err := rows.Err(); err != nil {
			_ = rows.Close()
			return err
		}
		if err := rows.Close(); err != nil {
			return err
		}
	}
	return nil
}

func (s *Service) fillVIPLevels(ctx context.Context, items []AppUser, userIDs []int64) error {
	if s.walletDB == nil || len(userIDs) == 0 {
		return nil
	}
	index := make(map[int64]int, len(userIDs))
	for i, id := range userIDs {
		index[id] = i
	}
	appCode := appctx.FromContext(ctx)
	nowMs := nowMillis()
	const chunkSize = 500
	for start := 0; start < len(userIDs); start += chunkSize {
		end := start + chunkSize
		if end > len(userIDs) {
			end = len(userIDs)
		}
		chunk := userIDs[start:end]
		args := make([]any, 0, len(chunk)+1)
		args = append(args, appCode)
		for _, id := range chunk {
			args = append(args, id)
		}
		rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
			SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms
			FROM user_vip_memberships m
			LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level
			WHERE m.app_code = ? AND m.user_id IN (%s)
		`, placeholders(len(chunk))), args...)
		if err != nil {
			return err
		}
		for rows.Next() {
			var userID int64
			var vip AppUserVIP
			if err := rows.Scan(&userID, &vip.Level, &vip.Name, &vip.StartedAtMs, &vip.ExpiresAtMs, &vip.UpdatedAtMs); err != nil {
				_ = rows.Close()
				return err
			}
			i, ok := index[userID]
			if !ok {
				continue
			}
			// 这里刻意沿用 wallet-service GetMyVip 的展示口径：只看 level 和 expires_at_ms 计算当前 VIP，
			// 过期会员仍保留历史行，但后台用户列表不展示为有效等级。
			vip.Active = vip.Level > 0 && vip.ExpiresAtMs > nowMs
			if !vip.Active {
				vip.Level = 0
				vip.Name = ""
			}
			items[i].VIP = vip
		}
		if err := rows.Err(); err != nil {
			_ = rows.Close()
			return err
		}
		if err := rows.Close(); err != nil {
			return err
		}
	}
	return nil
}

func (s *Service) fillUserResources(ctx context.Context, items []AppUser, userID int64) error {
	if s.walletDB == nil || len(items) == 0 || userID <= 0 {
		return nil
	}
	appCode := appctx.FromContext(ctx)
	nowMs := nowMillis()
	rows, err := s.walletDB.QueryContext(ctx, `
		SELECT e.entitlement_id, e.resource_id, r.resource_code, r.resource_type, r.name, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id,
		       COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, ''),
		       CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped,
		       e.created_at_ms, e.updated_at_ms
		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_id = e.resource_id
		 AND eq.entitlement_id = e.entitlement_id
		WHERE e.app_code = ? AND e.user_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'
		ORDER BY equipped DESC, r.resource_type ASC, e.updated_at_ms DESC, e.created_at_ms DESC
	`, appCode, userID, nowMs, nowMs)
	if err != nil {
		return err
	}
	defer rows.Close()

	resources := make([]AppUserResource, 0)
	equipped := make([]AppUserResource, 0)
	for rows.Next() {
		var resource AppUserResource
		if err := rows.Scan(
			&resource.EntitlementID,
			&resource.ResourceID,
			&resource.ResourceCode,
			&resource.ResourceType,
			&resource.Name,
			&resource.Status,
			&resource.Quantity,
			&resource.RemainingQuantity,
			&resource.EffectiveAtMs,
			&resource.ExpiresAtMs,
			&resource.SourceGrantID,
			&resource.AssetURL,
			&resource.PreviewURL,
			&resource.AnimationURL,
			&resource.Equipped,
			&resource.CreatedAtMs,
			&resource.UpdatedAtMs,
		); err != nil {
			return err
		}
		resources = append(resources, resource)
		if resource.Equipped {
			// 装备状态只从 user_resource_equipment 读取；前端可以直接展示当前佩戴，不需要再按类型猜测。
			equipped = append(equipped, resource)
		}
	}
	if err := rows.Err(); err != nil {
		return err
	}
	items[0].Resources = resources
	items[0].EquippedResources = equipped
	return nil
}

func normalizeListQuery(query listQuery) listQuery {
	if query.Page < 1 {
		query.Page = 1
	}
	if query.PageSize < 1 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	query.Country = normalizeCountryCode(query.Country)
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID)
	query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID)
	query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username)
	if query.RegionID < 0 {
		query.RegionID = 0
		query.RegionIDSet = false
	}
	if query.StartMs < 0 {
		query.StartMs = 0
	}
	if query.EndMs < 0 {
		query.EndMs = 0
	}
	if query.SnapshotAtMs < 0 {
		query.SnapshotAtMs = 0
	}
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.SortBy = normalizeAppUserSortBy(query.SortBy)
	query.SortDirection = normalizeSortDirection(query.SortDirection)
	return query
}

func appUserListWhereSQL(appCode string, query listQuery) (string, []any) {
	return appUserListWhereSQLAt(appCode, query, nowMillis())
}

func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string, []any) {
	whereSQL := "FROM users u WHERE u.app_code = ?"
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND u.status = ?"
		args = append(args, query.Status)
	}
	if !query.UserFilter.IsEmpty() {
		matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", query.UserFilter, nowMs)
		whereSQL += " AND " + matchSQL
		args = append(args, matchArgs...)
	} else if query.Keyword != "" {
		matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs)
		whereSQL += " AND " + matchSQL
		args = append(args, matchArgs...)
	}
	if query.Country != "" {
		// 国家筛选用 users.country 的 ISO code 精确命中；前端传展示名时不会被猜测转换，避免误筛到错误国家。
		whereSQL += " AND UPPER(u.country) = ?"
		args = append(args, query.Country)
	}
	if query.RegionIDSet {
		if query.RegionID == 0 {
			// 列表展示里无区域或无有效业务区域都会显示为 GLOBAL；筛选 GLOBAL 时保持同一套展示口径。
			whereSQL += " AND (COALESCE(u.region_id, 0) = 0 OR NOT " + appUserValidRegionExistsSQL("u") + ")"
		} else {
			whereSQL += " AND u.region_id = ?"
			args = append(args, query.RegionID)
		}
	}
	if query.StartMs > 0 {
		// 时间区间筛选的是用户创建时间，和列表“创建 / 活跃”列的第一行保持一致。
		whereSQL += " AND u.created_at_ms >= ?"
		args = append(args, query.StartMs)
	}
	if query.EndMs > 0 {
		whereSQL += " AND u.created_at_ms < ?"
		args = append(args, query.EndMs)
	}
	if query.SnapshotAtMs > 0 {
		// 导出只读取任务创建时已经存在的用户，避免长任务执行期间的新注册插入到头部并改变分页集合。
		whereSQL += " AND u.created_at_ms <= ?"
		args = append(args, query.SnapshotAtMs)
	}
	if query.ExportCursorCreatedAtMs > 0 && query.ExportCursorUserID > 0 {
		// 导出固定 created_at DESC/user_id DESC，组合游标处理同毫秒注册且不依赖 OFFSET。
		whereSQL += " AND (u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?))"
		args = append(args, query.ExportCursorCreatedAtMs, query.ExportCursorCreatedAtMs, query.ExportCursorUserID)
	}
	return whereSQL, args
}

func appUserValidRegionExistsSQL(userAlias string) string {
	return fmt.Sprintf(`EXISTS (
		           SELECT 1 FROM regions rg
		           WHERE rg.app_code = %[1]s.app_code
		             AND rg.region_id = %[1]s.region_id
		             AND rg.status = 'active'
		             AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
		           LIMIT 1
		         )`, userAlias)
}

func appUserPrettySelectColumns() string {
	return `,
		       COALESCE((
		         SELECT lease.display_user_id
		         FROM pretty_display_user_id_leases lease
		         WHERE lease.app_code = u.app_code
		           AND lease.user_id = u.user_id
		           AND lease.status = 'active'
		           AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
		         ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
		         LIMIT 1
		       ), ''),
		       COALESCE((
		         SELECT pdi.pretty_id
		         FROM pretty_display_user_id_leases lease
		         JOIN pretty_display_ids pdi
		           ON pdi.app_code = lease.app_code
		          AND pdi.assigned_lease_id = lease.lease_id
		          AND pdi.status = 'assigned'
		         WHERE lease.app_code = u.app_code
		           AND lease.user_id = u.user_id
		           AND lease.status = 'active'
		           AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
		         ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
		         LIMIT 1
		       ), '')`
}

func appUserSelectArgs(nowMs int64, whereArgs []any) []any {
	// 用户资料页展示“原始短号 + 靓号”时，靓号以租约表为准读取，不能只依赖 users.current_display_user_id。
	// 本地 initdb 或历史修复脚本可能只重放 users 快照；active lease 和 pretty_display_ids 才是当前靓号归属的审计来源。
	args := make([]any, 0, len(whereArgs)+2)
	args = append(args, nowMs, nowMs)
	args = append(args, whereArgs...)
	return args
}

func normalizeAppUserSortBy(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "coin", "coins":
		return "coin"
	case "created_at", "createdat", "created_at_ms", "createdatms", "created":
		return "created_at"
	default:
		return "created_at"
	}
}

func normalizeSortDirection(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "asc":
		return "asc"
	default:
		return "desc"
	}
}

func appUserOrderSQL(query listQuery) string {
	if query.SortBy == "created_at" && query.SortDirection == "asc" {
		return "ORDER BY u.created_at_ms ASC, u.user_id ASC"
	}
	return "ORDER BY u.created_at_ms DESC, u.user_id DESC"
}

func sortAppUsersByCoin(items []AppUser, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		left := items[i]
		right := items[j]
		if left.Coin != right.Coin {
			if direction == "asc" {
				return left.Coin < right.Coin
			}
			return left.Coin > right.Coin
		}
		// 金币相同的时候用内部用户 ID 做稳定排序，避免同余额用户在翻页和刷新时抖动。
		leftID, _ := strconv.ParseInt(left.UserID, 10, 64)
		rightID, _ := strconv.ParseInt(right.UserID, 10, 64)
		if direction == "asc" {
			return leftID < rightID
		}
		return leftID > rightID
	})
}

func paginateAppUsers(items []AppUser, page int, pageSize int) []AppUser {
	start := offset(page, pageSize)
	if start >= len(items) {
		return []AppUser{}
	}
	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}
	return items[start:end]
}

func appUserNumericIDs(items []AppUser) []int64 {
	ids := make([]int64, 0, len(items))
	for _, item := range items {
		id, err := strconv.ParseInt(item.UserID, 10, 64)
		if err != nil || id <= 0 {
			continue
		}
		ids = append(ids, id)
	}
	return ids
}

func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
	var total int64
	err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
	return total, err
}

func offset(page int, pageSize int) int {
	return (page - 1) * pageSize
}

func nullableString(value string) sql.Null[string] {
	return sql.Null[string]{V: value, Valid: value != ""}
}

func normalizeCountryCode(value string) string {
	return strings.ToUpper(strings.TrimSpace(value))
}

func nowMillis() int64 {
	return time.Now().UnixMilli()
}

func rollbackIfOpen(tx *sql.Tx) {
	_ = tx.Rollback()
}
