package hostorg

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

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/integration/userclient"
	"hyapp-admin-server/internal/modules/shared"
)

type Reader struct {
	db       *sql.DB
	walletDB *sql.DB
	adminDB  *sql.DB
}

func NewReader(db *sql.DB, walletDB *sql.DB, adminDB *sql.DB) *Reader {
	return &Reader{db: db, walletDB: walletDB, adminDB: adminDB}
}

type CoinSellerListItem struct {
	UserID            int64  `json:"userId,string"`
	Status            string `json:"status"`
	MerchantAssetType string `json:"merchantAssetType"`
	MerchantBalance   int64  `json:"merchantBalance"`
	// TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位，只统计 counts_as_seller_recharge 的库存记录。
	TotalRechargeUSDTMicro  int64  `json:"totalRechargeUsdtMicro"`
	Contact                 string `json:"contact"`
	CanManageSubCoinSellers bool   `json:"canManageSubCoinSellers"`
	SubCoinSellerCount      int64  `json:"subCoinSellerCount"`
	DisplayUserID           string `json:"displayUserId"`
	Username                string `json:"username"`
	Avatar                  string `json:"avatar"`
	RegionID                int64  `json:"regionId"`
	RegionName              string `json:"regionName"`
	CreatedByUserID         int64  `json:"createdByUserId"`
	CreatedAtMs             int64  `json:"createdAtMs"`
	UpdatedAtMs             int64  `json:"updatedAtMs"`
}

type CoinSellerSubApplicationListItem struct {
	ApplicationID     int64        `json:"applicationId,string"`
	CommandID         string       `json:"commandId"`
	Parent            UserIdentity `json:"parent"`
	Target            UserIdentity `json:"target"`
	Status            string       `json:"status"`
	RelationID        int64        `json:"relationId,string"`
	ReviewedByAdminID int64        `json:"reviewedByAdminId,string"`
	ReviewReason      string       `json:"reviewReason"`
	ReviewedAtMs      int64        `json:"reviewedAtMs"`
	CreatedAtMs       int64        `json:"createdAtMs"`
	UpdatedAtMs       int64        `json:"updatedAtMs"`
}

type UserIdentity struct {
	UserID        int64  `json:"userId,string"`
	DisplayUserID string `json:"displayUserId"`
	Username      string `json:"username"`
	Avatar        string `json:"avatar"`
	RegionID      int64  `json:"regionId"`
	RegionName    string `json:"regionName"`
}

type ManagerListItem struct {
	UserID                         int64  `json:"userId,string"`
	DisplayUserID                  string `json:"displayUserId"`
	Username                       string `json:"username"`
	Avatar                         string `json:"avatar"`
	RegionID                       int64  `json:"regionId"`
	RegionName                     string `json:"regionName"`
	Status                         string `json:"status"`
	Contact                        string `json:"contact"`
	CanGrantAvatarFrame            bool   `json:"canGrantAvatarFrame"`
	CanGrantVehicle                bool   `json:"canGrantVehicle"`
	CanGrantBadge                  bool   `json:"canGrantBadge"`
	CanGrantVIP                    bool   `json:"canGrantVip"`
	CanUpdateUserLevel             bool   `json:"canUpdateUserLevel"`
	CanAddBDLeader                 bool   `json:"canAddBdLeader"`
	CanManageCrossRegion           bool   `json:"canManageCrossRegion"`
	LegacyBDLeaderInRegion         bool   `json:"canAddBdLeaderInRegion"`
	ManagerOperationBaseScope      string `json:"managerOperationBaseScope"`
	ManagerOperationEffectiveScope string `json:"managerOperationEffectiveScope"`
	ManagerCrossRegionConfigurable bool   `json:"managerCrossRegionConfigurable"`
	CanAddAdmin                    bool   `json:"canAddAdmin"`
	CanAddSuperadmin               bool   `json:"canAddSuperadmin"`
	CanBlockUser                   bool   `json:"canBlockUser"`
	CanTransferCountry             bool   `json:"canTransferUserCountry"`
	BDLeaderCount                  int64  `json:"bdLeaderCount"`
	LastInvitedAtMs                int64  `json:"lastInvitedAtMs"`
	CreatedAtMs                    int64  `json:"createdAtMs"`
	UpdatedAtMs                    int64  `json:"updatedAtMs"`
}

type ManagerRoleScopePolicy struct {
	BaseScope                   string `json:"baseScope"`
	ExpandedScope               string `json:"expandedScope"`
	RegionExpansionConfigurable bool   `json:"regionExpansionConfigurable"`
}

type ManagerPageResponse struct {
	Items                       []*ManagerListItem     `json:"items"`
	Page                        int                    `json:"page"`
	PageSize                    int                    `json:"pageSize"`
	Total                       int64                  `json:"total"`
	ManagerOperationScopePolicy ManagerRoleScopePolicy `json:"managerOperationScopePolicy"`
}

type CoinSellerSalaryRateTier struct {
	RegionID    int64  `json:"regionId,string"`
	MinUSDMinor int64  `json:"minUsdMinor"`
	MaxUSDMinor int64  `json:"maxUsdMinor"`
	MinUSD      string `json:"minUsd"`
	MaxUSD      string `json:"maxUsd"`
	CoinPerUSD  int64  `json:"coinPerUsd"`
	Status      string `json:"status"`
	Enabled     bool   `json:"enabled"`
	SortOrder   int    `json:"sortOrder"`
	UpdatedAtMs int64  `json:"updatedAtMs"`
}

func userCountryRegionJoin(userAlias, countryRegionAlias, regionAlias string) string {
	// 组织列表里的区域只用于后台展示、搜索和筛选，必须以用户当前国家归属的 active 区域为准。
	// 身份表不再保存区域；这里始终按用户国家映射 active 区域，避免用户国家调整后列表继续显示旧值。
	return fmt.Sprintf(`
		LEFT JOIN region_countries %s ON %s.app_code = %s.app_code
			AND %s.country_code = %s.country
			AND %s.status = 'active'
		LEFT JOIN regions %s ON %s.app_code = %s.app_code
			AND %s.region_id = %s.region_id
			AND %s.status = 'active'`,
		countryRegionAlias, countryRegionAlias, userAlias,
		countryRegionAlias, userAlias,
		countryRegionAlias,
		regionAlias, regionAlias, countryRegionAlias,
		regionAlias, countryRegionAlias,
		regionAlias,
	)
}

func userCountryRegionIDSQL(regionAlias string) string {
	return fmt.Sprintf("COALESCE(%s.region_id, 0)", regionAlias)
}

func userCountryRegionNameSQL(regionAlias string) string {
	return fmt.Sprintf("COALESCE(%s.name, '')", regionAlias)
}

// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表，后台列表按角色选择物理表，避免把两种身份重新混在一起。
func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	if role == "bd_leader" {
		return r.listBDLeaderProfiles(ctx, query)
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM bd_profiles bp
		LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
	` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
		LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
		LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
		WHERE bp.app_code = ? AND bp.role = ?`
	args := []any{appCode, role}
	if query.Status != "" {
		whereSQL += " AND bp.status = ?"
		args = append(args, query.Status)
	}
	if query.RegionID > 0 {
		whereSQL += " AND user_region.region_id = ?"
		args = append(args, query.RegionID)
	}
	if query.ParentLeaderUserID > 0 {
		whereSQL += " AND bp.parent_leader_user_id = ?"
		args = append(args, query.ParentLeaderUserID)
	} else if !query.ParentLeaderFilter.IsEmpty() {
		parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_leader", "bp.parent_leader_user_id", query.ParentLeaderFilter, time.Now().UnixMilli())
		whereSQL += " AND " + parentSQL
		args = append(args, parentArgs...)
	}
	if !query.UserFilter.IsEmpty() {
		matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli())
		whereSQL += " AND " + matchSQL
		args = append(args, matchArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
		whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
		args = append(args, matchArgs...)
		args = append(args, like, like)
	}

	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	limitSQL := "LIMIT ? OFFSET ?"
	queryArgs := append([]any{agencyStatusDeleted}, args...)
	queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
	if query.SortBy == sortBySalaryWallet {
		// 工资钱包余额来自 wallet-service 读模型，不能只排序 SQL 当前页；先取出所有匹配身份，补齐余额后再分页。
		limitSQL = ""
		queryArgs = append([]any{agencyStatusDeleted}, args...)
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
		       bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
		       COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
		       COALESCE(parent_leader.avatar, ''),
		       COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
		       COALESCE(creator.avatar, ''),
		       (
		         SELECT COUNT(1)
		         FROM agencies child_agency
		         WHERE child_agency.app_code = bp.app_code
		           AND child_agency.parent_bd_user_id = bp.user_id
		           AND child_agency.status <> ?
		       )
		%s
		ORDER BY bp.created_at_ms DESC, bp.user_id DESC
		%s
	`, whereSQL, limitSQL), queryArgs...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*userclient.BDProfile, 0, query.PageSize)
	for rows.Next() {
		item := &userclient.BDProfile{}
		if err := rows.Scan(
			&item.UserID,
			&item.Role,
			&item.RegionID,
			&item.ParentLeaderUserID,
			&item.Status,
			&item.CreatedByUserID,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.Contact,
			&item.RegionName,
			&item.ParentLeaderDisplayID,
			&item.ParentLeaderUsername,
			&item.ParentLeaderAvatar,
			&item.CreatedByDisplayUserID,
			&item.CreatedByUsername,
			&item.CreatedByAvatar,
			&item.AgencyCount,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.fillBDProfileCreators(ctx, items); err != nil {
		return nil, 0, err
	}
	if role == "bd_leader" {
		if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
			return nil, 0, err
		}
	}
	if err := r.fillBDProfileSalaryWallets(ctx, role, items); err != nil {
		return nil, 0, err
	}
	if query.SortBy == sortBySalaryWallet {
		sortBDProfilesBySalaryWallet(items, query.SortDirection)
		items = paginateBDProfiles(items, query.Page, query.PageSize)
	}
	return items, total, nil
}

func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) {
	appCode := appctx.FromContext(ctx)
	if query.ParentLeaderUserID > 0 {
		// Leader 身份没有上级 Leader 字段；带父级筛选时直接返回空结果，避免误用普通 BD 的父级语义。
		return []*userclient.BDProfile{}, 0, nil
	}
	whereSQL := `
		FROM bd_leader_profiles bp
		LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
	` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
		LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
		WHERE bp.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND bp.status = ?"
		args = append(args, query.Status)
	}
	if query.RegionID > 0 {
		whereSQL += " AND user_region.region_id = ?"
		args = append(args, query.RegionID)
	}
	if !query.UserFilter.IsEmpty() {
		matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli())
		whereSQL += " AND " + matchSQL
		args = append(args, matchArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
		whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
		args = append(args, matchArgs...)
		args = append(args, like, like)
	}

	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	limitSQL := "LIMIT ? OFFSET ?"
	queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
	if query.SortBy == sortBySalaryWallet {
		// BD Leader 工资余额同样在 wallet-service；排序必须等 Admin 工资钱包资产填充完成后再裁剪当前页。
		limitSQL = ""
		queryArgs = args
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
		       bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
		       '', '', '',
		       COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
		       COALESCE(creator.avatar, ''),
		       (
		         SELECT COUNT(1)
		         FROM bd_profiles child
		         WHERE child.app_code = bp.app_code
		           AND child.role = 'bd'
		           AND child.parent_leader_user_id = bp.user_id
		       )
		%s
		ORDER BY bp.created_at_ms DESC, bp.user_id DESC
		%s
	`, whereSQL, limitSQL), queryArgs...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*userclient.BDProfile, 0, query.PageSize)
	for rows.Next() {
		item := &userclient.BDProfile{}
		if err := rows.Scan(
			&item.UserID,
			&item.Role,
			&item.RegionID,
			&item.ParentLeaderUserID,
			&item.Status,
			&item.CreatedByUserID,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.Contact,
			&item.RegionName,
			&item.ParentLeaderDisplayID,
			&item.ParentLeaderUsername,
			&item.ParentLeaderAvatar,
			&item.CreatedByDisplayUserID,
			&item.CreatedByUsername,
			&item.CreatedByAvatar,
			&item.SubBDCount,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.fillBDProfileCreators(ctx, items); err != nil {
		return nil, 0, err
	}
	if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
		return nil, 0, err
	}
	if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
		return nil, 0, err
	}
	if query.SortBy == sortBySalaryWallet {
		sortBDProfilesBySalaryWallet(items, query.SortDirection)
		items = paginateBDProfiles(items, query.Page, query.PageSize)
	}
	return items, total, nil
}

func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
	if r == nil || r.db == nil {
		return nil, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	item := &userclient.BDProfile{}
	err := r.db.QueryRowContext(ctx, `
		SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
		       bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
		       COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
		       COALESCE(creator.avatar, ''),
		       (
		         SELECT COUNT(1)
		         FROM bd_profiles child
		         WHERE child.app_code = bp.app_code
		           AND child.role = 'bd'
		           AND child.parent_leader_user_id = bp.user_id
		       )
		FROM bd_leader_profiles bp
		LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
	`+userCountryRegionJoin("u", "user_country_region", "user_region")+`
		LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
		WHERE bp.app_code = ? AND bp.user_id = ?
	`, appCode, userID).Scan(
		&item.UserID,
		&item.Role,
		&item.RegionID,
		&item.ParentLeaderUserID,
		&item.Status,
		&item.CreatedByUserID,
		&item.CreatedAtMs,
		&item.UpdatedAtMs,
		&item.DisplayUserID,
		&item.Username,
		&item.Avatar,
		&item.Contact,
		&item.RegionName,
		&item.CreatedByDisplayUserID,
		&item.CreatedByUsername,
		&item.CreatedByAvatar,
		&item.SubBDCount,
	)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("bd leader not found")
	}
	if err != nil {
		return nil, err
	}
	items := []*userclient.BDProfile{item}
	if err := r.fillBDProfileCreators(ctx, items); err != nil {
		return nil, err
	}
	if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
		return nil, err
	}
	if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
		return nil, err
	}
	return item, nil
}

func (r *Reader) fillBDProfileCreators(ctx context.Context, items []*userclient.BDProfile) error {
	if r == nil || r.adminDB == nil || len(items) == 0 {
		return nil
	}
	seen := make(map[int64]struct{}, len(items))
	ids := make([]any, 0, len(items))
	for _, item := range items {
		if item.CreatedByUserID <= 0 {
			continue
		}
		if _, ok := seen[item.CreatedByUserID]; ok {
			continue
		}
		seen[item.CreatedByUserID] = struct{}{}
		ids = append(ids, item.CreatedByUserID)
	}
	if len(ids) == 0 {
		return nil
	}

	rows, err := r.adminDB.QueryContext(ctx, `
		SELECT id, username, name
		FROM admin_users
		WHERE id IN (`+sqlPlaceholders(len(ids))+`)
	`, ids...)
	if err != nil {
		return err
	}
	defer rows.Close()

	type creator struct {
		account string
		name    string
	}
	creators := make(map[int64]creator, len(ids))
	for rows.Next() {
		var id int64
		var item creator
		if err := rows.Scan(&id, &item.account, &item.name); err != nil {
			return err
		}
		creators[id] = item
	}
	if err := rows.Err(); err != nil {
		return err
	}
	for _, item := range items {
		creator, ok := creators[item.CreatedByUserID]
		if !ok {
			continue
		}
		item.CreatedByDisplayUserID = creator.account
		item.CreatedByUsername = firstNonBlank(creator.name, creator.account)
	}
	return nil
}

func (r *Reader) fillBDLeaderPositionAliases(ctx context.Context, appCode string, items []*userclient.BDProfile) error {
	if r == nil || r.adminDB == nil || len(items) == 0 {
		return nil
	}
	ids := make([]any, 0, len(items))
	for _, item := range items {
		if item.UserID > 0 {
			ids = append(ids, item.UserID)
		}
	}
	if len(ids) == 0 {
		return nil
	}
	args := append([]any{appCode}, ids...)
	rows, err := r.adminDB.QueryContext(ctx, `
		SELECT user_id, position_alias
		FROM admin_bd_leader_position_aliases
		WHERE app_code = ? AND user_id IN (`+sqlPlaceholders(len(ids))+`)
	`, args...)
	if err != nil {
		return err
	}
	defer rows.Close()

	aliases := make(map[int64]string, len(ids))
	for rows.Next() {
		var userID int64
		var alias string
		if err := rows.Scan(&userID, &alias); err != nil {
			return err
		}
		aliases[userID] = strings.TrimSpace(alias)
	}
	if err := rows.Err(); err != nil {
		return err
	}
	for _, item := range items {
		item.PositionAlias = aliases[item.UserID]
	}
	return nil
}

func (r *Reader) SaveBDLeaderPositionAlias(ctx context.Context, userID int64, actorID int64, alias string) error {
	if r == nil || r.adminDB == nil {
		return fmt.Errorf("admin mysql is not configured")
	}
	if userID <= 0 {
		return fmt.Errorf("user_id is required")
	}
	appCode := appctx.FromContext(ctx)
	positionAlias := strings.TrimSpace(alias)
	if positionAlias == "" {
		// 空别名表示恢复 H5 配置表里的默认 admin 展示名，删除行比保留空值更清晰。
		_, err := r.adminDB.ExecContext(ctx, `
			DELETE FROM admin_bd_leader_position_aliases
			WHERE app_code = ? AND user_id = ?
		`, appCode, userID)
		return err
	}
	nowMs := time.Now().UnixMilli()
	_, err := r.adminDB.ExecContext(ctx, `
		INSERT INTO admin_bd_leader_position_aliases (
		  app_code, user_id, position_alias, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  position_alias = VALUES(position_alias),
		  updated_by_admin_id = VALUES(updated_by_admin_id),
		  updated_at_ms = VALUES(updated_at_ms)
	`, appCode, userID, positionAlias, actorID, actorID, nowMs, nowMs)
	return err
}

func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
	if r == nil || r.db == nil {
		return nil, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	item := &userclient.BDProfile{}
	err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
		SELECT bp.user_id, 'bd_leader', %s, 0,
		       bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
		FROM bd_leader_profiles bp
		LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
		%s
		WHERE bp.app_code = ? AND bp.user_id = ?
	`, userCountryRegionIDSQL("user_region"), userCountryRegionJoin("u", "user_country_region", "user_region")), appCode, userID).Scan(
		&item.UserID,
		&item.Role,
		&item.RegionID,
		&item.ParentLeaderUserID,
		&item.Status,
		&item.CreatedByUserID,
		&item.CreatedAtMs,
		&item.UpdatedAtMs,
	)
	if err == sql.ErrNoRows {
		return nil, fmt.Errorf("bd leader not found")
	}
	if err != nil {
		return nil, err
	}
	result, err := r.db.ExecContext(ctx, `
		DELETE FROM bd_leader_profiles
		WHERE app_code = ? AND user_id = ?
	`, appCode, userID)
	if err != nil {
		return nil, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return nil, err
	}
	if affected == 0 {
		return nil, fmt.Errorf("bd leader not found")
	}
	if r.adminDB != nil {
		_, _ = r.adminDB.ExecContext(ctx, `
			DELETE FROM admin_bd_leader_position_aliases
			WHERE app_code = ? AND user_id = ?
		`, appCode, userID)
	}
	return item, nil
}

// CreateManagerProfile 创建或恢复经理身份。
// Manager 是 manager_profiles 表里的独立身份；这里只写经理事实和联系方式，不改 Host/Agency/BD 任一关系。
func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, req createManagerRequest) (*ManagerListItem, error) {
	if r == nil || r.db == nil {
		return nil, errUserDBNotConfigured()
	}
	if userID <= 0 {
		return nil, fmt.Errorf("target_user_id is required")
	}
	contact := strings.TrimSpace(req.Contact)
	if len([]rune(contact)) > 128 {
		return nil, fmt.Errorf("contact is too long")
	}
	permissions := req.permissionsWithDefault()
	nowMs := time.Now().UnixMilli()
	appCode := appctx.FromContext(ctx)
	// ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态，
	// 不新增第二条身份，也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。
	if _, err := r.db.ExecContext(ctx, `
		INSERT INTO manager_profiles (
			app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
			can_grant_vip, can_update_user_level, can_add_bd_leader, can_manage_cross_region, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
			created_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			status = 'active',
			contact_info = VALUES(contact_info),
			can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
			can_grant_vehicle = VALUES(can_grant_vehicle),
			can_grant_badge = VALUES(can_grant_badge),
			can_grant_vip = VALUES(can_grant_vip),
			can_update_user_level = VALUES(can_update_user_level),
			can_add_bd_leader = VALUES(can_add_bd_leader),
			can_manage_cross_region = VALUES(can_manage_cross_region),
			can_add_admin = VALUES(can_add_admin),
			can_add_superadmin = VALUES(can_add_superadmin),
			can_block_user = VALUES(can_block_user),
			can_transfer_user_country = VALUES(can_transfer_user_country),
			updated_at_ms = VALUES(updated_at_ms)
		`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
		permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanManageCrossRegion, permissions.CanAddAdmin,
		permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
		return nil, err
	}
	items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
	if err != nil {
		return nil, err
	}
	if len(items) == 0 {
		return nil, fmt.Errorf("manager not found")
	}
	return items[0], nil
}

// UpdateManagerProfile 局部更新经理运营资料、身份状态和经理中心权限。
// 未传入的状态/布尔权限不参与 SET，避免后台只编辑联系方式时把现有开关误恢复成默认值。
func (r *Reader) UpdateManagerProfile(ctx context.Context, userID int64, actorID int64, req updateManagerRequest) (*ManagerListItem, error) {
	if r == nil || r.db == nil {
		return nil, errUserDBNotConfigured()
	}
	if userID <= 0 {
		return nil, fmt.Errorf("manager user_id is required")
	}
	assignments := []string{"updated_at_ms = ?"}
	args := []any{time.Now().UnixMilli()}
	if req.Contact != nil {
		contact := strings.TrimSpace(*req.Contact)
		if len([]rune(contact)) > 128 {
			return nil, fmt.Errorf("contact is too long")
		}
		assignments = append(assignments, "contact_info = ?")
		args = append(args, contact)
	}
	if err := req.appendStatusAssignment(&assignments, &args); err != nil {
		return nil, err
	}
	req.appendPermissionAssignments(&assignments, &args)
	args = append(args, appctx.FromContext(ctx), userID)
	// 权限更新只作用于已存在经理；RowsAffected=0 时返回 not found，避免 PUT 顺手创建新经理绕过创建审计。
	result, err := r.db.ExecContext(ctx, `
		UPDATE manager_profiles
		SET `+strings.Join(assignments, ", ")+`
		WHERE app_code = ? AND user_id = ?
	`, args...)
	if err != nil {
		return nil, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return nil, err
	}
	if affected == 0 {
		return nil, fmt.Errorf("manager not found")
	}
	_ = actorID
	items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1})
	if err != nil {
		return nil, err
	}
	if len(items) == 0 {
		return nil, fmt.Errorf("manager not found")
	}
	return items[0], nil
}

// ListManagers 按 manager_profiles 的独立身份事实读取经理列表。
// 经理是 BD Leader/Admin 的上级，不属于 Host/Agency/BD 链路；下级 BD Leader 只作为统计信息左连汇总。
func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM manager_profiles mp
		JOIN users manager ON manager.app_code = mp.app_code AND manager.user_id = mp.user_id
		LEFT JOIN bd_leader_profiles bl ON bl.app_code = mp.app_code AND bl.created_by_user_id = mp.user_id
	` + userCountryRegionJoin("manager", "manager_country_region", "manager_region") + `
		WHERE mp.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND mp.status = ?"
		args = append(args, query.Status)
	}
	if query.RegionID > 0 {
		whereSQL += " AND manager_region.region_id = ?"
		args = append(args, query.RegionID)
	}
	if !query.UserFilter.IsEmpty() {
		managerSQL, managerArgs := shared.UserIdentityFieldsSQL("manager", "mp.user_id", query.UserFilter, time.Now().UnixMilli())
		whereSQL += " AND " + managerSQL
		args = append(args, managerArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		managerSQL, managerArgs := shared.UserIdentityKeywordSQL("manager", "mp.user_id", keyword, time.Now().UnixMilli())
		whereSQL += " AND (" + managerSQL + " OR manager_region.name LIKE ?)"
		args = append(args, managerArgs...)
		args = append(args, like)
	}

	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT mp.user_id) `+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
		       COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
		       mp.status, COALESCE(mp.contact_info, ''),
		       mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
		       mp.can_add_bd_leader, mp.can_manage_cross_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
		       COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
		       mp.created_at_ms, mp.updated_at_ms
		%s
		GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
		         manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
		         mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
		         mp.can_add_bd_leader, mp.can_manage_cross_region, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
		         mp.created_at_ms, mp.updated_at_ms
		ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
		LIMIT ? OFFSET ?
	`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*ManagerListItem, 0, query.PageSize)
	for rows.Next() {
		item := &ManagerListItem{}
		if err := rows.Scan(
			&item.UserID,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.RegionID,
			&item.RegionName,
			&item.Status,
			&item.Contact,
			&item.CanGrantAvatarFrame,
			&item.CanGrantVehicle,
			&item.CanGrantBadge,
			&item.CanGrantVIP,
			&item.CanUpdateUserLevel,
			&item.CanAddBDLeader,
			&item.CanManageCrossRegion,
			&item.CanAddAdmin,
			&item.CanAddSuperadmin,
			&item.CanBlockUser,
			&item.CanTransferCountry,
			&item.BDLeaderCount,
			&item.LastInvitedAtMs,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

// ListAgencies 只读 user-service 的 Agency 事实表；后台关闭和入会开关仍走写命令。
func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclient.Agency, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM agencies a
		LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
	` + userCountryRegionJoin("owner", "owner_country_region", "owner_region") + `
		LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
		WHERE a.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND a.status = ?"
		args = append(args, query.Status)
	} else {
		whereSQL += " AND a.status <> ?"
		args = append(args, "deleted")
	}
	if query.RegionID > 0 {
		whereSQL += " AND owner_region.region_id = ?"
		args = append(args, query.RegionID)
	}
	if query.ParentBDUserID > 0 {
		whereSQL += " AND a.parent_bd_user_id = ?"
		args = append(args, query.ParentBDUserID)
	} else if !query.ParentBDFilter.IsEmpty() {
		parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_bd", "a.parent_bd_user_id", query.ParentBDFilter, time.Now().UnixMilli())
		whereSQL += " AND " + parentSQL
		args = append(args, parentArgs...)
	}
	if !query.OwnerFilter.IsEmpty() {
		ownerSQL, ownerArgs := shared.UserIdentityFieldsSQL("owner", "a.owner_user_id", query.OwnerFilter, time.Now().UnixMilli())
		whereSQL += " AND " + ownerSQL
		args = append(args, ownerArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		nowMs := time.Now().UnixMilli()
		ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs)
		parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_bd", "a.parent_bd_user_id", keyword, nowMs)
		whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ? OR owner.contact_info LIKE ?)"
		args = append(args, like, like)
		args = append(args, ownerArgs...)
		args = append(args, parentArgs...)
		args = append(args, like, like)
	}

	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	limitSQL := "LIMIT ? OFFSET ?"
	queryArgs := append([]any{membershipStatusActive}, args...)
	queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
	if query.SortBy == sortBySalaryWallet {
		// Agency 展示的是 owner 的 Agency 工资钱包；先补余额再排序，保证跨页顺序和真实余额一致。
		limitSQL = ""
		queryArgs = append([]any{membershipStatusActive}, args...)
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id,
		       a.name, a.status, a.join_enabled, a.max_hosts,
		       a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
		       COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
		       COALESCE(owner.avatar, ''), COALESCE(owner.contact_info, ''),
		       COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
		       COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`,
		       (
		         SELECT COUNT(*)
		         FROM agency_memberships active_membership
		         WHERE active_membership.app_code = a.app_code
		           AND active_membership.agency_id = a.agency_id
		           AND active_membership.status = ?
		       )
		%s
		ORDER BY a.created_at_ms DESC, a.agency_id DESC
		%s
	`, whereSQL, limitSQL), queryArgs...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*userclient.Agency, 0, query.PageSize)
	for rows.Next() {
		item := &userclient.Agency{}
		if err := rows.Scan(
			&item.AgencyID,
			&item.OwnerUserID,
			&item.RegionID,
			&item.ParentBDUserID,
			&item.Name,
			&item.Status,
			&item.JoinEnabled,
			&item.MaxHosts,
			&item.CreatedByUserID,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.OwnerDisplayUserID,
			&item.OwnerUsername,
			&item.OwnerAvatar,
			&item.Contact,
			&item.ParentBDDisplayUserID,
			&item.ParentBDUsername,
			&item.ParentBDAvatar,
			&item.RegionName,
			&item.ActiveHostCount,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.fillAgencySalaryWallets(ctx, items); err != nil {
		return nil, 0, err
	}
	if query.SortBy == sortBySalaryWallet {
		sortAgenciesBySalaryWallet(items, query.SortDirection)
		items = paginateAgencies(items, query.Page, query.PageSize)
	}
	return items, total, nil
}

// ListHostProfiles 只读主播身份事实；成员变更和主播状态变更不在这个列表接口里发生。
func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM host_profiles hp
		LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
	` + userCountryRegionJoin("u", "user_country_region", "user_region") + `
		LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
		LEFT JOIN users agency_owner ON agency_owner.app_code = a.app_code AND agency_owner.user_id = a.owner_user_id
		WHERE hp.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND hp.status = ?"
		args = append(args, query.Status)
	}
	if query.RegionID > 0 {
		whereSQL += " AND user_region.region_id = ?"
		args = append(args, query.RegionID)
	}
	if query.AgencyID > 0 {
		whereSQL += " AND hp.current_agency_id = ?"
		args = append(args, query.AgencyID)
	}
	if !query.UserFilter.IsEmpty() {
		hostSQL, hostArgs := shared.UserIdentityFieldsSQL("u", "hp.user_id", query.UserFilter, time.Now().UnixMilli())
		whereSQL += " AND " + hostSQL
		args = append(args, hostArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		nowMs := time.Now().UnixMilli()
		hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs)
		agencyOwnerSQL, agencyOwnerArgs := shared.UserIdentityKeywordSQL("agency_owner", "a.owner_user_id", keyword, nowMs)
		whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
		args = append(args, hostArgs...)
		args = append(args, like)
		args = append(args, agencyOwnerArgs...)
		args = append(args, like, like)
	}

	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	limitSQL := "LIMIT ? OFFSET ?"
	queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
	if query.SortBy == sortByDiamond || query.SortBy == sortBySalaryWallet {
		// 主播钻石和工资余额都是列表外部聚合值；排序时取全量匹配集，聚合后稳定排序再分页。
		limitSQL = ""
		queryArgs = args
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT hp.user_id, hp.status, `+userCountryRegionIDSQL("user_region")+`, COALESCE(hp.current_agency_id, 0),
		       COALESCE(hp.current_membership_id, 0), hp.source,
		       hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
		       COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
		       COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
		%s
		ORDER BY hp.created_at_ms DESC, hp.user_id DESC
		%s
	`, whereSQL, limitSQL), queryArgs...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*userclient.HostProfile, 0, query.PageSize)
	userIDs := make([]int64, 0, query.PageSize)
	for rows.Next() {
		item := &userclient.HostProfile{}
		if err := rows.Scan(
			&item.UserID,
			&item.Status,
			&item.RegionID,
			&item.CurrentAgencyID,
			&item.CurrentMembershipID,
			&item.Source,
			&item.FirstBecameHostAtMs,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.Contact,
			&item.RegionName,
			&item.CurrentAgencyName,
			&item.CurrentAgencyOwnerUserID,
			&item.CurrentAgencyOwnerDisplayUserID,
			&item.CurrentAgencyOwnerUsername,
			&item.CurrentAgencyOwnerAvatar,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
		userIDs = append(userIDs, item.UserID)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	diamonds, err := r.hostPeriodDiamonds(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	for _, item := range items {
		item.Diamond = diamonds[item.UserID]
	}
	if err := r.fillHostSalaryWallets(ctx, items); err != nil {
		return nil, 0, err
	}
	if query.SortBy == sortByDiamond {
		sortHostProfilesByDiamond(items, query.SortDirection)
		items = paginateHostProfiles(items, query.Page, query.PageSize)
	}
	if query.SortBy == sortBySalaryWallet {
		sortHostProfilesBySalaryWallet(items, query.SortDirection)
		items = paginateHostProfiles(items, query.Page, query.PageSize)
	}
	return items, total, nil
}

// ListCoinSellers 聚合 user-service 身份事实和 wallet-service 币商余额，只做后台展示读模型。
func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinSellerListItem, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
		return nil, 0, err
	}
	if err := r.ensureCoinSellerSubPermissionColumn(ctx); err != nil {
		return nil, 0, err
	}
	whereSQL := `
		FROM coin_seller_profiles csp
		LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
		LEFT JOIN regions r ON r.app_code = csp.app_code AND r.region_id = u.region_id
		WHERE csp.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND csp.status = ?"
		args = append(args, query.Status)
	}
	if query.RegionID > 0 {
		whereSQL += " AND u.region_id = ?"
		args = append(args, query.RegionID)
	}
	if !query.UserFilter.IsEmpty() {
		matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "csp.user_id", query.UserFilter, time.Now().UnixMilli())
		whereSQL += " AND " + matchSQL
		args = append(args, matchArgs...)
	} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		like := "%" + keyword + "%"
		matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
		whereSQL += " AND (" + matchSQL + " OR u.contact_info LIKE ? OR csp.contact_info LIKE ?)"
		args = append(args, matchArgs...)
		args = append(args, like, like)
	}

	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	limitSQL := "LIMIT ? OFFSET ?"
	queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
	if isCoinSellerComputedSort(query.SortBy) {
		// 币商余额和累充 USDT 都来自 wallet-service 聚合，必须先读取所有匹配币商再排序分页，避免只排序当前页。
		limitSQL = ""
		queryArgs = args
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT csp.user_id, csp.status, csp.merchant_asset_type,
		       COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
		       csp.can_manage_sub_coin_sellers,
		       COALESCE((
		         SELECT COUNT(*)
		         FROM coin_seller_relations csr
		         INNER JOIN coin_seller_profiles child_csp
		           ON child_csp.app_code = csr.app_code
		          AND child_csp.user_id = csr.child_user_id
		          AND child_csp.status = 'active'
		          AND child_csp.merchant_asset_type = ?
		         INNER JOIN users child_u
		           ON child_u.app_code = csr.app_code
		          AND child_u.user_id = csr.child_user_id
		          AND child_u.status = 'active'
		         WHERE csr.app_code = csp.app_code
		           AND csr.parent_user_id = csp.user_id
		           AND csr.status = 'active'
		       ), 0),
		       csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
		       COALESCE(r.name, '')
		%s
		ORDER BY csp.created_at_ms DESC, csp.user_id DESC
		%s
	`, whereSQL, limitSQL), append([]any{coinSellerMerchantAssetType}, queryArgs...)...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]*CoinSellerListItem, 0, query.PageSize)
	userIDs := make([]int64, 0, query.PageSize)
	for rows.Next() {
		item := &CoinSellerListItem{}
		if err := rows.Scan(
			&item.UserID,
			&item.Status,
			&item.MerchantAssetType,
			&item.Contact,
			&item.CanManageSubCoinSellers,
			&item.SubCoinSellerCount,
			&item.CreatedByUserID,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.RegionID,
			&item.RegionName,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
		userIDs = append(userIDs, item.UserID)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	balances, err := r.coinSellerBalances(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	for _, item := range items {
		item.MerchantBalance = balances[item.UserID]
		item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
	}
	if isCoinSellerComputedSort(query.SortBy) {
		sortCoinSellerListItems(items, query.SortBy, query.SortDirection)
		items = paginateCoinSellerListItems(items, query.Page, query.PageSize)
	}
	return items, total, nil
}

func (r *Reader) ListCoinSellerSubApplications(ctx context.Context, query listQuery) ([]*CoinSellerSubApplicationListItem, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM coin_seller_sub_applications app
		LEFT JOIN users parent_u ON parent_u.app_code = app.app_code AND parent_u.user_id = app.parent_user_id
		LEFT JOIN regions parent_r ON parent_r.app_code = parent_u.app_code AND parent_r.region_id = parent_u.region_id
		LEFT JOIN users target_u ON target_u.app_code = app.app_code AND target_u.user_id = app.target_user_id
		LEFT JOIN regions target_r ON target_r.app_code = target_u.app_code AND target_r.region_id = target_u.region_id
		WHERE app.app_code = ?`
	args := []any{appCode}
	if query.Status != "" {
		whereSQL += " AND app.status = ?"
		args = append(args, query.Status)
	}
	if query.ParentUserID > 0 {
		whereSQL += " AND app.parent_user_id = ?"
		args = append(args, query.ParentUserID)
	}
	if query.TargetUserID > 0 {
		whereSQL += " AND app.target_user_id = ?"
		args = append(args, query.TargetUserID)
	}
	if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
		parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_u", "app.parent_user_id", keyword, time.Now().UnixMilli())
		targetSQL, targetArgs := shared.UserIdentityKeywordSQL("target_u", "app.target_user_id", keyword, time.Now().UnixMilli())
		whereSQL += " AND (" + parentSQL + " OR " + targetSQL + " OR app.command_id LIKE ? OR app.review_reason LIKE ?)"
		args = append(args, parentArgs...)
		args = append(args, targetArgs...)
		like := "%" + keyword + "%"
		args = append(args, like, like)
	}
	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT app.application_id, app.command_id, app.status, app.relation_id,
		       app.reviewed_by_admin_id, app.review_reason, app.reviewed_at_ms,
		       app.created_at_ms, app.updated_at_ms,
		       app.parent_user_id, COALESCE(parent_u.current_display_user_id, ''),
		       COALESCE(parent_u.username, ''), COALESCE(parent_u.avatar, ''),
		       COALESCE(parent_u.region_id, 0), COALESCE(parent_r.name, ''),
		       app.target_user_id, COALESCE(target_u.current_display_user_id, ''),
		       COALESCE(target_u.username, ''), COALESCE(target_u.avatar, ''),
		       COALESCE(target_u.region_id, 0), COALESCE(target_r.name, '')
		%s
		ORDER BY app.created_at_ms DESC, app.application_id DESC
		LIMIT ? OFFSET ?
	`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]*CoinSellerSubApplicationListItem, 0, query.PageSize)
	for rows.Next() {
		item := &CoinSellerSubApplicationListItem{}
		if err := rows.Scan(
			&item.ApplicationID,
			&item.CommandID,
			&item.Status,
			&item.RelationID,
			&item.ReviewedByAdminID,
			&item.ReviewReason,
			&item.ReviewedAtMs,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.Parent.UserID,
			&item.Parent.DisplayUserID,
			&item.Parent.Username,
			&item.Parent.Avatar,
			&item.Parent.RegionID,
			&item.Parent.RegionName,
			&item.Target.UserID,
			&item.Target.DisplayUserID,
			&item.Target.Username,
			&item.Target.Avatar,
			&item.Target.RegionID,
			&item.Target.RegionName,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

func (r *Reader) ListCoinSellerSubSellers(ctx context.Context, parentUserID int64, query listQuery) ([]*CoinSellerListItem, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, errUserDBNotConfigured()
	}
	if parentUserID <= 0 {
		return nil, 0, fmt.Errorf("parent_user_id is required")
	}
	appCode := appctx.FromContext(ctx)
	whereSQL := `
		FROM coin_seller_relations relation
		INNER JOIN users u
		  ON u.app_code = relation.app_code
		 AND u.user_id = relation.child_user_id
		 AND u.status = 'active'
		INNER JOIN coin_seller_profiles csp
		  ON csp.app_code = relation.app_code
		 AND csp.user_id = relation.child_user_id
		 AND csp.status = 'active'
		 AND csp.merchant_asset_type = ?
		LEFT JOIN regions r ON r.app_code = u.app_code AND r.region_id = u.region_id
		WHERE relation.app_code = ?
		  AND relation.parent_user_id = ?
		  AND relation.status = 'active'`
	args := []any{coinSellerMerchantAssetType, appCode, parentUserID}
	total, err := countRows(ctx, r.db, whereSQL, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
		SELECT csp.user_id, csp.status, csp.merchant_asset_type,
		       COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
		       csp.can_manage_sub_coin_sellers,
		       COALESCE((
		         SELECT COUNT(*)
		         FROM coin_seller_relations csr
		         INNER JOIN coin_seller_profiles child_csp
		           ON child_csp.app_code = csr.app_code
		          AND child_csp.user_id = csr.child_user_id
		          AND child_csp.status = 'active'
		          AND child_csp.merchant_asset_type = ?
		         INNER JOIN users child_u
		           ON child_u.app_code = csr.app_code
		          AND child_u.user_id = csr.child_user_id
		          AND child_u.status = 'active'
		         WHERE csr.app_code = csp.app_code
		           AND csr.parent_user_id = csp.user_id
		           AND csr.status = 'active'
		       ), 0),
		       csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
		       COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
		       COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
		       COALESCE(r.name, '')
		%s
		ORDER BY relation.created_at_ms DESC, relation.relation_id DESC
		LIMIT ? OFFSET ?
	`, whereSQL), append([]any{coinSellerMerchantAssetType}, append(args, query.PageSize, offset(query.Page, query.PageSize))...)...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]*CoinSellerListItem, 0, query.PageSize)
	userIDs := make([]int64, 0, query.PageSize)
	for rows.Next() {
		item := &CoinSellerListItem{}
		if err := rows.Scan(
			&item.UserID,
			&item.Status,
			&item.MerchantAssetType,
			&item.Contact,
			&item.CanManageSubCoinSellers,
			&item.SubCoinSellerCount,
			&item.CreatedByUserID,
			&item.CreatedAtMs,
			&item.UpdatedAtMs,
			&item.DisplayUserID,
			&item.Username,
			&item.Avatar,
			&item.RegionID,
			&item.RegionName,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
		userIDs = append(userIDs, item.UserID)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	balances, err := r.coinSellerBalances(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	for _, item := range items {
		item.MerchantBalance = balances[item.UserID]
		item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
	}
	return items, total, nil
}

func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
	if r == nil || r.db == nil {
		return errUserDBNotConfigured()
	}
	// 币商联系方式现在是用户主资料，后台行内编辑和 H5 自助修改必须写同一个 users 字段，避免同一用户多身份展示不同联系方式。
	_, err := r.db.ExecContext(ctx, `
		UPDATE users
		SET contact_info = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ?
	`, strings.TrimSpace(contact), time.Now().UnixMilli(), appctx.FromContext(ctx), userID)
	return err
}

func (r *Reader) ListCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) {
	if r == nil || r.walletDB == nil {
		return nil, fmt.Errorf("wallet mysql is not configured")
	}
	if regionID <= 0 {
		return nil, fmt.Errorf("region_id is required")
	}
	if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
		return nil, err
	}
	rows, err := r.walletDB.QueryContext(ctx, `
		SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
		FROM coin_seller_salary_exchange_rate_tiers
		WHERE app_code = ? AND region_id = ?
		ORDER BY sort_order DESC, min_usd_minor ASC, tier_id ASC
	`, appctx.FromContext(ctx), regionID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]CoinSellerSalaryRateTier, 0)
	for rows.Next() {
		var item CoinSellerSalaryRateTier
		if err := rows.Scan(&item.RegionID, &item.MinUSDMinor, &item.MaxUSDMinor, &item.CoinPerUSD, &item.Status, &item.SortOrder, &item.UpdatedAtMs); err != nil {
			return nil, err
		}
		item.Enabled = item.Status == "active"
		item.MinUSD = formatUSDMinor(item.MinUSDMinor)
		item.MaxUSD = formatUSDMinor(item.MaxUSDMinor)
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

func (r *Reader) ReplaceCoinSellerSalaryRates(ctx context.Context, regionID int64, actorID int64, tiers []CoinSellerSalaryRateTier) ([]CoinSellerSalaryRateTier, error) {
	if r == nil || r.walletDB == nil {
		return nil, fmt.Errorf("wallet mysql is not configured")
	}
	if regionID <= 0 {
		return nil, fmt.Errorf("region_id is required")
	}
	if err := r.ensureCoinSellerSalaryRateTable(ctx); err != nil {
		return nil, err
	}
	tx, err := r.walletDB.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appctx.FromContext(ctx)
	nowMs := time.Now().UnixMilli()
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM coin_seller_salary_exchange_rate_tiers
		WHERE app_code = ? AND region_id = ?
	`, appCode, regionID); err != nil {
		return nil, err
	}
	for index, tier := range tiers {
		status := strings.ToLower(strings.TrimSpace(tier.Status))
		if status == "" {
			if tier.Enabled {
				status = "active"
			} else {
				status = "disabled"
			}
		}
		if status != "active" && status != "disabled" {
			return nil, fmt.Errorf("rate status is invalid")
		}
		sortOrder := tier.SortOrder
		if sortOrder == 0 {
			// 请求数组是后台从上到下的档位顺序；缺省排序按递减权重持久化，读回时保持原顺序。
			sortOrder = (len(tiers) - index) * 10
		}
		_, err := tx.ExecContext(ctx, `
			INSERT INTO coin_seller_salary_exchange_rate_tiers (
			  app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd,
			  status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		`, appCode, regionID, tier.MinUSDMinor, tier.MaxUSDMinor, tier.CoinPerUSD, status, sortOrder, actorID, actorID, nowMs, nowMs)
		if err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return r.ListCoinSellerSalaryRates(ctx, regionID)
}

func (r *Reader) ensureCoinSellerSalaryRateTable(ctx context.Context) error {
	if _, err := r.walletDB.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
		  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码，用于多租户隔离',
		  region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
		  tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
		  min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额，单位美分，包含',
		  max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额，单位美分，包含',
		  coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量',
		  status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态：active/disabled',
		  sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
		  created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
		  updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
		  created_at_ms BIGINT NOT NULL COMMENT '创建时间，UTC epoch ms',
		  updated_at_ms BIGINT NOT NULL COMMENT '更新时间，UTC epoch ms',
		  PRIMARY KEY (tier_id),
		  KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
		  KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`); err != nil {
		return err
	}
	return nil
}

func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
	var count int
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM information_schema.columns
		WHERE table_schema = DATABASE()
		  AND table_name = 'coin_seller_profiles'
		  AND column_name = 'contact_info'
	`).Scan(&count); err != nil {
		return err
	}
	if count > 0 {
		return nil
	}
	_, err := r.db.ExecContext(ctx, `
		ALTER TABLE coin_seller_profiles
		ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '' AFTER merchant_asset_type
	`)
	return err
}

func (r *Reader) ensureCoinSellerSubPermissionColumn(ctx context.Context) error {
	var count int
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM information_schema.columns
		WHERE table_schema = DATABASE()
		  AND table_name = 'coin_seller_profiles'
		  AND column_name = 'can_manage_sub_coin_sellers'
	`).Scan(&count); err != nil {
		return err
	}
	if count > 0 {
		return nil
	}
	_, err := r.db.ExecContext(ctx, `
		ALTER TABLE coin_seller_profiles
		ADD COLUMN can_manage_sub_coin_sellers TINYINT(1) NOT NULL DEFAULT 1 AFTER contact_info
	`)
	return err
}

func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
	result := make(map[int64]int64, len(userIDs))
	if r == nil || r.walletDB == nil || len(userIDs) == 0 {
		return result, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
	args := []any{appCode, "COIN_SELLER_COIN"}
	for _, userID := range userIDs {
		args = append(args, userID)
	}
	rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT user_id, available_amount
		FROM wallet_accounts
		WHERE app_code = ? AND asset_type = ? AND user_id IN (%s)
	`, placeholders), args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		var amount int64
		if err := rows.Scan(&userID, &amount); err != nil {
			return nil, err
		}
		result[userID] = amount
	}
	return result, rows.Err()
}

// coinSellerRechargeTotals 从币商进货专表汇总 USDT 付款，金币补偿不会计入累充口径。
func (r *Reader) coinSellerRechargeTotals(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
	result := make(map[int64]int64, len(userIDs))
	if r == nil || r.walletDB == nil || len(userIDs) == 0 {
		return result, nil
	}
	placeholders := sqlPlaceholders(len(userIDs))
	args := []any{appCode, paidCurrencyUSDT}
	for _, userID := range userIDs {
		args = append(args, userID)
	}
	rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT seller_user_id, COALESCE(SUM(paid_amount_micro), 0)
		FROM coin_seller_stock_records
		WHERE app_code = ?
		  AND counts_as_seller_recharge = TRUE
		  AND paid_currency_code = ?
		  AND seller_user_id IN (%s)
		GROUP BY seller_user_id
	`, placeholders), args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		var amount int64
		if err := rows.Scan(&userID, &amount); err != nil {
			return nil, err
		}
		result[userID] = amount
	}
	return result, rows.Err()
}

func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
	result := make(map[int64]int64, len(userIDs))
	if r == nil || r.walletDB == nil || len(userIDs) == 0 {
		return result, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
	args := []any{appCode, time.Now().UTC().Format("2006-01")}
	for _, userID := range userIDs {
		args = append(args, userID)
	}
	rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT user_id, total_diamonds
		FROM host_period_diamond_accounts
		WHERE app_code = ? AND cycle_key = ? AND user_id IN (%s)
	`, placeholders), args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		var amount int64
		if err := rows.Scan(&userID, &amount); err != nil {
			return nil, err
		}
		result[userID] = amount
	}
	return result, rows.Err()
}

func isCoinSellerComputedSort(sortBy string) bool {
	return sortBy == sortByMerchantBalance || sortBy == sortByTotalRechargeUSDT
}

// sortCoinSellerListItems 只处理 wallet 聚合字段排序，基础身份字段仍使用 SQL 默认创建时间排序。
func sortCoinSellerListItems(items []*CoinSellerListItem, sortBy string, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		left := coinSellerSortValue(items[i], sortBy)
		right := coinSellerSortValue(items[j], sortBy)
		if left == right {
			return false
		}
		if direction == "asc" {
			return left < right
		}
		return left > right
	})
}

func coinSellerSortValue(item *CoinSellerListItem, sortBy string) int64 {
	if item == nil {
		return 0
	}
	switch sortBy {
	case sortByTotalRechargeUSDT:
		return item.TotalRechargeUSDTMicro
	case sortByMerchantBalance:
		return item.MerchantBalance
	default:
		return 0
	}
}

// sortBDProfilesBySalaryWallet 按当前身份的工资钱包可用余额排序；余额相同时保留 SQL 默认创建时间顺序，避免同值行跳动。
func sortBDProfilesBySalaryWallet(items []*userclient.BDProfile, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		left := bdProfileSalaryWalletValue(items[i])
		right := bdProfileSalaryWalletValue(items[j])
		if left == right {
			return false
		}
		if direction == "asc" {
			return left < right
		}
		return left > right
	})
}

func bdProfileSalaryWalletValue(item *userclient.BDProfile) int64 {
	if item == nil {
		return 0
	}
	return item.SalaryWallet.AvailableAmount
}

// sortAgenciesBySalaryWallet 按 Agency owner 对应的 Agency 工资钱包余额排序，不混用 owner 的其他身份钱包。
func sortAgenciesBySalaryWallet(items []*userclient.Agency, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		left := agencySalaryWalletValue(items[i])
		right := agencySalaryWalletValue(items[j])
		if left == right {
			return false
		}
		if direction == "asc" {
			return left < right
		}
		return left > right
	})
}

func agencySalaryWalletValue(item *userclient.Agency) int64 {
	if item == nil {
		return 0
	}
	return item.SalaryWallet.AvailableAmount
}

func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].Diamond == items[j].Diamond {
			return false
		}
		if direction == "asc" {
			return items[i].Diamond < items[j].Diamond
		}
		return items[i].Diamond > items[j].Diamond
	})
}

// sortHostProfilesBySalaryWallet 按主播工资钱包可用余额排序，和钻石排序一样在分页前处理跨页顺序。
func sortHostProfilesBySalaryWallet(items []*userclient.HostProfile, direction string) {
	sort.SliceStable(items, func(i, j int) bool {
		left := hostProfileSalaryWalletValue(items[i])
		right := hostProfileSalaryWalletValue(items[j])
		if left == right {
			return false
		}
		if direction == "asc" {
			return left < right
		}
		return left > right
	})
}

func hostProfileSalaryWalletValue(item *userclient.HostProfile) int64 {
	if item == nil {
		return 0
	}
	return item.SalaryWallet.AvailableAmount
}

func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize int) []*CoinSellerListItem {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	start := (page - 1) * pageSize
	if start >= len(items) {
		return []*CoinSellerListItem{}
	}
	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}
	return items[start:end]
}

func paginateBDProfiles(items []*userclient.BDProfile, page int, pageSize int) []*userclient.BDProfile {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	start := (page - 1) * pageSize
	if start >= len(items) {
		return []*userclient.BDProfile{}
	}
	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}
	return items[start:end]
}

func paginateAgencies(items []*userclient.Agency, page int, pageSize int) []*userclient.Agency {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	start := (page - 1) * pageSize
	if start >= len(items) {
		return []*userclient.Agency{}
	}
	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}
	return items[start:end]
}

func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	start := (page - 1) * pageSize
	if start >= len(items) {
		return []*userclient.HostProfile{}
	}
	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}
	return items[start:end]
}

func sqlPlaceholders(count int) string {
	if count <= 0 {
		return ""
	}
	return strings.TrimRight(strings.Repeat("?,", count), ",")
}

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 errUserDBNotConfigured() error {
	return fmt.Errorf("user mysql is not configured")
}
