package hostsalarysettlement

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

	"hyapp-admin-server/internal/modules/shared"
	walletv1 "hyapp.local/api/proto/wallet/v1"

	"google.golang.org/grpc"
)

const (
	maxPageSize = 100

	settlementTypeDaily     = "daily"
	settlementTypeHalfMonth = "half_month"
	settlementTypeMonthEnd  = "month_end"
	triggerAutomatic        = "automatic"
	triggerManual           = "manual"
	settlementRoleAll       = "all"
	settlementRoleHost      = "host"
	settlementRoleAgency    = "agency"
	statusPending           = "pending"
	policyTypeSalaryDiamond = "SALARY_DIAMOND"
)

type Service struct {
	walletDB       *sql.DB
	userDB         *sql.DB
	walletCron     walletCronClient
	requestTimeout time.Duration
}

type userProfile struct {
	UserID        int64
	DisplayUserID string
	Username      string
	Avatar        string
}

type walletCronClient interface {
	ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
	ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
	ProcessHostSalaryMonthEndBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
}

type hostPolicy struct {
	PolicyID                 uint64
	CycleKey                 string
	PolicyVersion            uint64
	Name                     string
	PolicyType               string
	RegionID                 int64
	SettlementMode           string
	TriggerMode              string
	ResidualDiamondToUSDRate string
	Levels                   []hostPolicyLevel
}

type hostPolicyLevel struct {
	LevelNo              int
	RequiredDiamonds     int64
	HostSalaryUSDMinor   int64
	HostCoinReward       int64
	AgencySalaryUSDMinor int64
}

type hostProgress struct {
	SettledLevelNo              int
	SettledHostSalaryUSDMinor   int64
	SettledHostCoinReward       int64
	SettledAgencySalaryUSDMinor int64
	LastSettledTotalDiamonds    int64
	ResidualUSDMinor            int64
	MonthEndClearedAtMS         int64
}

type hostCandidate struct {
	UserID               int64
	AgencyOwnerUserID    int64
	Role                 string
	CycleKey             string
	RegionID             int64
	RegionName           string
	SettlementType       string
	TotalDiamonds        int64
	Policy               hostPolicy
	Level                hostPolicyLevel
	Progress             hostProgress
	HostSalaryUSDDelta   int64
	HostCoinRewardDelta  int64
	AgencySalaryUSDDelta int64
	ResidualUSDDelta     int64
}

func NewService(walletDB *sql.DB, userDB *sql.DB, walletCron walletCronClient, requestTimeout time.Duration) *Service {
	return &Service{walletDB: walletDB, userDB: userDB, walletCron: walletCron, requestTimeout: requestTimeout}
}

func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQuery) ([]pendingDTO, int64, error) {
	if err := s.ensureReady(ctx); err != nil {
		return nil, 0, err
	}
	req = normalizePendingQuery(req)
	// 待结算列表只做只读重算，不写 progress、不写钱包；真正入账必须走 Settle 调 wallet-service。
	candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), req)
	if err != nil {
		return nil, 0, err
	}
	sortHostCandidates(candidates)
	total := int64(len(candidates))
	pageItems := paginateHostCandidates(candidates, req.Page, req.PageSize)
	userIDs := make([]int64, 0, len(pageItems)*2)
	for _, item := range pageItems {
		userIDs = append(userIDs, item.UserID, item.AgencyOwnerUserID)
	}
	profiles, err := s.userProfiles(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	items := make([]pendingDTO, 0, len(pageItems))
	for _, item := range pageItems {
		items = append(items, pendingDTOFromCandidate(item, profiles[item.UserID], profiles[item.AgencyOwnerUserID]))
	}
	return items, total, nil
}

func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) {
	if err := s.ensureReady(ctx); err != nil {
		return settleResultDTO{}, err
	}
	req = normalizeSettleRequest(req)
	userIDs := uniquePositiveUserIDs(settlementUserIDValues(req.UserIDs))
	result := settleResultDTO{
		CycleKey:       req.CycleKey,
		SettlementType: req.SettlementType,
		TriggerMode:    req.TriggerMode,
		Role:           req.Role,
		RequestedCount: len(userIDs),
		Records:        []settlementDTO{},
	}
	if len(userIDs) == 0 {
		return result, nil
	}
	// 后台传来的 user_ids 必须先回到待结算候选里二次校验；客户端不能靠篡改区域或周期结算页面外用户。
	candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), pendingQuery{
		Page:           1,
		PageSize:       maxPageSize,
		Role:           req.Role,
		CycleKey:       req.CycleKey,
		SettlementType: req.SettlementType,
		TriggerMode:    req.TriggerMode,
		RegionID:       req.RegionID,
		CountryID:      req.CountryID,
		CountryCode:    req.CountryCode,
	})
	if err != nil {
		return settleResultDTO{}, err
	}
	allowed := map[int64]struct{}{}
	selected := map[int64]struct{}{}
	for _, id := range userIDs {
		selected[id] = struct{}{}
	}
	for _, candidate := range candidates {
		if _, ok := selected[candidate.UserID]; ok {
			allowed[candidate.UserID] = struct{}{}
		}
	}
	settleIDs := make([]int64, 0, len(allowed))
	for _, id := range userIDs {
		if _, ok := allowed[id]; ok {
			settleIDs = append(settleIDs, id)
		}
	}
	if len(settleIDs) == 0 {
		result.SkippedCount = len(userIDs)
		return result, nil
	}
	if s.requestTimeout <= 0 {
		s.requestTimeout = 3 * time.Second
	}
	callCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
	defer cancel()
	// wallet-service 是 Host/Agency 工资账务 owner；admin-server 只传人工选择范围，不复制钱包入账逻辑。
	resp, err := s.callWalletSettlement(callCtx, appCode, req, operatorAdminID, settleIDs)
	if err != nil {
		return settleResultDTO{}, err
	}
	result.ProcessedCount = int(resp.GetProcessedCount())
	result.SuccessCount = int(resp.GetSuccessCount())
	result.FailureCount = int(resp.GetFailureCount())
	result.SkippedCount = len(userIDs) - len(settleIDs) + result.ProcessedCount - result.SuccessCount - result.FailureCount
	if result.SkippedCount < 0 {
		result.SkippedCount = 0
	}
	return result, nil
}

func (s *Service) callWalletSettlement(ctx context.Context, appCode string, req settleRequest, operatorAdminID int64, userIDs []int64) (*walletv1.CronBatchResponse, error) {
	if s.walletCron == nil {
		return nil, fmt.Errorf("wallet cron client is not configured")
	}
	batchReq := &walletv1.CronBatchRequest{
		RequestId:      fmt.Sprintf("admin-host-salary-%d", time.Now().UTC().UnixNano()),
		AppCode:        strings.TrimSpace(appCode),
		RunId:          fmt.Sprintf("admin-host-salary-%s-%d", req.CycleKey, time.Now().UTC().UnixNano()),
		WorkerId:       fmt.Sprintf("admin-%d", operatorAdminID),
		BatchSize:      int32(len(userIDs)),
		TriggerMode:    req.TriggerMode,
		SettlementRole: req.Role,
		CycleKey:       req.CycleKey,
		UserIds:        append([]int64(nil), userIDs...),
	}
	switch req.SettlementType {
	case settlementTypeDaily:
		return s.walletCron.ProcessHostSalaryDailySettlementBatch(ctx, batchReq)
	case settlementTypeHalfMonth:
		return s.walletCron.ProcessHostSalaryHalfMonthSettlementBatch(ctx, batchReq)
	case settlementTypeMonthEnd:
		return s.walletCron.ProcessHostSalaryMonthEndBatch(ctx, batchReq)
	default:
		return nil, fmt.Errorf("settlement_type is invalid")
	}
}

func (s *Service) collectPendingCandidates(ctx context.Context, appCode string, req pendingQuery) ([]hostCandidate, error) {
	req = normalizePendingQuery(req)
	regions, err := s.regionFilter(ctx, appCode, req.RegionID, req.CountryID, req.CountryCode)
	if err != nil {
		return nil, err
	}
	regionClause := ""
	userClause := ""
	progressClause := "AND a.total_diamonds > COALESCE(progress.last_settled_total_diamonds, 0)"
	if req.SettlementType == settlementTypeMonthEnd {
		progressClause = ""
	}
	args := []any{appCode, req.CycleKey}
	if len(regions) > 0 {
		ids := keysInt64FromSet(regions)
		regionClause = "AND a.region_id IN (" + placeholders(len(ids)) + ")"
		for _, id := range ids {
			args = append(args, id)
		}
	} else if req.RegionID > 0 || req.CountryID > 0 || req.CountryCode != "" {
		regionClause = "AND a.region_id = -1"
	}
	if req.UserID > 0 {
		userClause = "AND a.user_id = ?"
		args = append(args, req.UserID)
	}
	// 工资候选必须由工资型政策绑定产生；POINT_DIAMOND 不清空、不结算工资，不能出现在人工结算页。
	args = append(args, policyTypeSalaryDiamond)
	triggerClause := ""
	if req.TriggerMode != "" {
		triggerClause = "AND p.settlement_trigger_mode = ?"
		args = append(args, req.TriggerMode)
	}
	modeClause := ""
	if req.SettlementType != settlementTypeMonthEnd {
		modeClause = "AND p.settlement_mode = ?"
		args = append(args, req.SettlementType)
	}
	rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
		SELECT a.user_id, a.cycle_key, a.region_id, a.agency_owner_user_id, a.total_diamonds,
		       COALESCE(progress.settled_level_no, 0), COALESCE(progress.settled_host_salary_usd_minor, 0),
		       COALESCE(progress.settled_host_coin_reward, 0), COALESCE(progress.settled_agency_salary_usd_minor, 0),
		       COALESCE(progress.last_settled_total_diamonds, 0), COALESCE(progress.residual_usd_minor, 0),
		       COALESCE(progress.month_end_cleared_at_ms, 0)
		FROM host_period_diamond_accounts a
		LEFT JOIN host_salary_settlement_progress progress
		  ON progress.app_code = a.app_code AND progress.user_id = a.user_id AND progress.cycle_key = a.cycle_key
		WHERE a.app_code = ?
		  AND a.cycle_key = ?
		  AND a.total_diamonds > 0
		  AND COALESCE(progress.month_end_cleared_at_ms, 0) = 0
		  %s
		  %s
		  %s
		  AND EXISTS (
		    SELECT 1
		    FROM host_salary_policy_cycle_bindings binding
		    JOIN host_agency_salary_policies p
		      ON p.app_code = binding.app_code
		     AND p.policy_id = binding.policy_id
		     AND p.policy_version = binding.policy_version
		    WHERE binding.app_code = a.app_code
		      AND binding.region_id = a.region_id
		      AND binding.cycle_key = a.cycle_key
		      AND p.status = 'active'
		      AND p.policy_type = ?
		      %s
		      %s
		  )
		ORDER BY a.updated_at_ms ASC, a.user_id ASC`, regionClause, userClause, progressClause, triggerClause, modeClause), args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	regionNames, _ := s.regionNames(ctx, appCode)
	out := []hostCandidate{}
	policyCache := map[string]hostPolicy{}
	for rows.Next() {
		var item hostCandidate
		if err := rows.Scan(
			&item.UserID,
			&item.CycleKey,
			&item.RegionID,
			&item.AgencyOwnerUserID,
			&item.TotalDiamonds,
			&item.Progress.SettledLevelNo,
			&item.Progress.SettledHostSalaryUSDMinor,
			&item.Progress.SettledHostCoinReward,
			&item.Progress.SettledAgencySalaryUSDMinor,
			&item.Progress.LastSettledTotalDiamonds,
			&item.Progress.ResidualUSDMinor,
			&item.Progress.MonthEndClearedAtMS,
		); err != nil {
			return nil, err
		}
		cacheKey := fmt.Sprintf("%d:%s:%s:%s", item.RegionID, item.CycleKey, req.SettlementType, req.TriggerMode)
		policy, ok := policyCache[cacheKey]
		if !ok {
			policy, ok, err = s.resolvePolicy(ctx, appCode, item.RegionID, item.CycleKey, req.SettlementType, req.TriggerMode)
			if err != nil {
				return nil, err
			}
			if !ok {
				continue
			}
			policyCache[cacheKey] = policy
		}
		item.Policy = policy
		item.RegionName = regionNames[item.RegionID]
		item.Role = req.Role
		item.SettlementType = req.SettlementType
		item.Level = highestHostLevel(policy.Levels, item.TotalDiamonds)
		item.HostSalaryUSDDelta = positiveDelta(item.Level.HostSalaryUSDMinor, item.Progress.SettledHostSalaryUSDMinor)
		item.HostCoinRewardDelta = positiveDelta(item.Level.HostCoinReward, item.Progress.SettledHostCoinReward)
		item.AgencySalaryUSDDelta = positiveDelta(item.Level.AgencySalaryUSDMinor, item.Progress.SettledAgencySalaryUSDMinor)
		if item.AgencyOwnerUserID <= 0 {
			item.AgencySalaryUSDDelta = 0
		}
		if req.SettlementType == settlementTypeMonthEnd {
			residualDiamonds := residualDiamondRemainder(item.TotalDiamonds, item.Level)
			residualTarget, err := usdMinorFromDiamondRate(residualDiamonds, policy.ResidualDiamondToUSDRate)
			if err != nil {
				return nil, err
			}
			item.ResidualUSDDelta = positiveDelta(residualTarget, item.Progress.ResidualUSDMinor)
		}
		// 待结算按身份拆分：Host 只看主播工资/金币/剩余折美元，Agency 只看代理工资。
		if hostCandidateHasPendingRole(item, req.Role) {
			out = append(out, item)
		}
	}
	return out, rows.Err()
}

func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) {
	if err := s.ensureReady(ctx); err != nil {
		return nil, 0, err
	}
	req = normalizeQuery(req)
	var matched bool
	var err error
	req, matched, err = s.resolveListUserFilters(ctx, appCode, req)
	if err != nil {
		return nil, 0, err
	}
	if !matched {
		return []settlementDTO{}, 0, nil
	}
	whereSQL, args := settlementWhere(appCode, req)

	var total int64
	if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM host_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := s.walletDB.QueryContext(ctx, `
		SELECT settlement_id, command_id, transaction_id, settlement_type, trigger_mode, settlement_role, user_id, agency_owner_user_id,
		       cycle_key, policy_id, policy_version, level_no, total_diamonds, host_salary_usd_minor_delta,
		       host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta,
		       status, reason, created_at_ms
		FROM host_salary_settlement_records
		`+whereSQL+`
		ORDER BY created_at_ms DESC, settlement_id DESC
		LIMIT ? OFFSET ?`,
		append(args, req.PageSize, offset(req.Page, req.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]settlementDTO, 0, req.PageSize)
	userIDs := make([]int64, 0, req.PageSize*2)
	for rows.Next() {
		var item settlementDTO
		var userID int64
		var agencyOwnerUserID int64
		if err := rows.Scan(
			&item.SettlementID,
			&item.CommandID,
			&item.TransactionID,
			&item.SettlementType,
			&item.TriggerMode,
			&item.SettlementRole,
			&userID,
			&agencyOwnerUserID,
			&item.CycleKey,
			&item.PolicyID,
			&item.PolicyVersion,
			&item.LevelNo,
			&item.TotalDiamonds,
			&item.HostSalaryUSDMinorDelta,
			&item.HostCoinRewardDelta,
			&item.AgencySalaryUSDMinorDelta,
			&item.ResidualUSDMinorDelta,
			&item.Status,
			&item.Reason,
			&item.CreatedAtMS,
		); err != nil {
			return nil, 0, err
		}
		item.UserID = strconv.FormatInt(userID, 10)
		item.AgencyOwnerUserID = formatOptionalUserID(agencyOwnerUserID)
		item.User = userDTO{UserID: item.UserID}
		item.AgencyOwner = userDTO{UserID: item.AgencyOwnerUserID}
		item.HostSalaryUSDDelta = formatUSDMinor(item.HostSalaryUSDMinorDelta)
		item.AgencySalaryUSDDelta = formatUSDMinor(item.AgencySalaryUSDMinorDelta)
		item.ResidualUSDDelta = formatUSDMinor(item.ResidualUSDMinorDelta)
		items = append(items, item)
		if userID > 0 {
			userIDs = append(userIDs, userID)
		}
		if agencyOwnerUserID > 0 {
			userIDs = append(userIDs, agencyOwnerUserID)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}

	profiles, err := s.userProfiles(ctx, appCode, userIDs)
	if err != nil {
		return nil, 0, err
	}
	for i := range items {
		if userID, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil {
			items[i].User = userDTOFromProfile(items[i].UserID, profiles[userID])
		}
		if agencyID, err := strconv.ParseInt(items[i].AgencyOwnerUserID, 10, 64); err == nil && agencyID > 0 {
			items[i].AgencyOwner = userDTOFromProfile(items[i].AgencyOwnerUserID, profiles[agencyID])
		}
	}
	return items, total, nil
}

func (s *Service) resolveListUserFilters(ctx context.Context, appCode string, req query) (query, bool, error) {
	nowMS := time.Now().UnixMilli()
	if req.UserID == 0 && !req.UserFilter.IsEmpty() {
		userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), req.UserFilter, nowMS)
		if err != nil || !matched {
			return req, matched, err
		}
		req.UserID = userID
	}
	if req.AgencyOwnerUserID == 0 && !req.AgencyOwnerFilter.IsEmpty() {
		userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), req.AgencyOwnerFilter, nowMS)
		if err != nil || !matched {
			return req, matched, err
		}
		req.AgencyOwnerUserID = userID
	}
	return req, true, nil
}

func settlementWhere(appCode string, req query) (string, []any) {
	conditions := []string{"app_code = ?"}
	args := []any{strings.TrimSpace(appCode)}
	if req.CycleKey != "" {
		conditions = append(conditions, "cycle_key = ?")
		args = append(args, req.CycleKey)
	}
	if req.SettlementType != "" {
		conditions = append(conditions, "settlement_type = ?")
		args = append(args, req.SettlementType)
	}
	if req.Status != "" {
		conditions = append(conditions, "status = ?")
		args = append(args, req.Status)
	}
	if req.UserID > 0 {
		conditions = append(conditions, "user_id = ?")
		args = append(args, req.UserID)
	}
	if req.AgencyOwnerUserID > 0 {
		conditions = append(conditions, "agency_owner_user_id = ?")
		args = append(args, req.AgencyOwnerUserID)
	}
	if req.PolicyID > 0 {
		conditions = append(conditions, "policy_id = ?")
		args = append(args, req.PolicyID)
	}
	if req.StartAtMS > 0 {
		conditions = append(conditions, "created_at_ms >= ?")
		args = append(args, req.StartAtMS)
	}
	if req.EndAtMS > 0 {
		conditions = append(conditions, "created_at_ms < ?")
		args = append(args, req.EndAtMS)
	}
	// 查询只读 wallet-service 结算事实表，不回推钱包分录；账务对账需要单独走 entries/transactions 明细。
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID int64, cycleKey string, settlementType string, triggerMode string) (hostPolicy, bool, error) {
	conditions := []string{"binding.app_code = ?", "binding.region_id = ?", "binding.cycle_key = ?", "p.status = 'active'"}
	args := []any{appCode, regionID, cycleKey}
	if triggerMode != "" {
		conditions = append(conditions, "p.settlement_trigger_mode = ?")
		args = append(args, triggerMode)
	}
	if settlementType != settlementTypeMonthEnd {
		conditions = append(conditions, "p.settlement_mode = ?")
		args = append(args, settlementType)
	}
	row := s.walletDB.QueryRowContext(ctx, `
		SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, p.policy_type, binding.region_id,
		       p.settlement_mode, p.settlement_trigger_mode, CAST(p.residual_diamond_to_usd_rate AS CHAR)
		FROM host_salary_policy_cycle_bindings binding
		JOIN host_agency_salary_policies p
		  ON p.app_code = binding.app_code
		 AND p.policy_id = binding.policy_id
		 AND p.policy_version = binding.policy_version
		WHERE `+strings.Join(conditions, " AND ")+`
		LIMIT 1`, args...)
	var policy hostPolicy
	if err := row.Scan(&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return hostPolicy{}, false, nil
		}
		return hostPolicy{}, false, err
	}
	if strings.TrimSpace(policy.PolicyType) != policyTypeSalaryDiamond {
		return hostPolicy{}, false, fmt.Errorf("policy type %s is not eligible for salary settlement", policy.PolicyType)
	}
	levels, err := s.policyLevels(ctx, appCode, policy.PolicyID, policy.PolicyVersion)
	if err != nil {
		return hostPolicy{}, false, err
	}
	policy.Levels = levels
	return policy, true, nil
}

func (s *Service) policyLevels(ctx context.Context, appCode string, policyID uint64, policyVersion uint64) ([]hostPolicyLevel, error) {
	rows, err := s.walletDB.QueryContext(ctx, `
		SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor
		FROM host_agency_salary_policy_levels
		WHERE app_code = ? AND policy_id = ? AND policy_version = ? AND status = 'active'
		ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID, policyVersion)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []hostPolicyLevel
	for rows.Next() {
		var item hostPolicyLevel
		if err := rows.Scan(&item.LevelNo, &item.RequiredDiamonds, &item.HostSalaryUSDMinor, &item.HostCoinReward, &item.AgencySalaryUSDMinor); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, rows.Err()
}

func (s *Service) regionFilter(ctx context.Context, appCode string, regionID int64, countryID int64, countryCode string) (map[int64]struct{}, error) {
	if regionID > 0 {
		return map[int64]struct{}{regionID: {}}, nil
	}
	countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
	if countryID > 0 && countryCode == "" {
		if err := s.userDB.QueryRowContext(ctx, `SELECT country_code FROM countries WHERE app_code = ? AND country_id = ?`, appCode, countryID).Scan(&countryCode); err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				return map[int64]struct{}{}, nil
			}
			return nil, err
		}
	}
	if countryCode == "" {
		return nil, nil
	}
	rows, err := s.userDB.QueryContext(ctx, `
		SELECT region_id
		FROM region_countries
		WHERE app_code = ? AND country_code = ? AND status = 'active'`, appCode, countryCode)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[int64]struct{}{}
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		out[id] = struct{}{}
	}
	return out, rows.Err()
}

func (s *Service) regionNames(ctx context.Context, appCode string) (map[int64]string, error) {
	rows, err := s.userDB.QueryContext(ctx, `SELECT region_id, name FROM regions WHERE app_code = ?`, appCode)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[int64]string{}
	for rows.Next() {
		var id int64
		var name string
		if err := rows.Scan(&id, &name); err != nil {
			return nil, err
		}
		out[id] = name
	}
	return out, rows.Err()
}

func (s *Service) ensureReady(ctx context.Context) error {
	if s == nil || s.walletDB == nil || s.userDB == nil {
		return fmt.Errorf("host salary settlement dependencies are not configured")
	}
	// 老库没有 trigger_mode 时，查询和手动结算记录都会失败；这里补齐列，历史记录默认视为 automatic。
	if _, err := s.walletDB.ExecContext(ctx, `
		ALTER TABLE host_salary_settlement_records
		ADD COLUMN trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '触发方式：automatic/manual' AFTER settlement_type`); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
		return err
	}
	if _, err := s.walletDB.ExecContext(ctx, `
		ALTER TABLE host_salary_settlement_records
		ADD COLUMN settlement_role VARCHAR(24) NOT NULL DEFAULT 'all' COMMENT '结算身份：all/host/agency' AFTER trigger_mode`); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
		return err
	}
	return nil
}

func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
	if s == nil || s.userDB == nil || len(userIDs) == 0 {
		return map[int64]userProfile{}, nil
	}
	uniqueIDs := uniquePositiveUserIDs(userIDs)
	if len(uniqueIDs) == 0 {
		return map[int64]userProfile{}, nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
	args := make([]any, 0, len(uniqueIDs)+1)
	args = append(args, strings.TrimSpace(appCode))
	for _, id := range uniqueIDs {
		args = append(args, id)
	}
	rows, err := s.userDB.QueryContext(ctx, `
		SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
		FROM users
		WHERE app_code = ? AND user_id IN (`+placeholders+`)`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	profiles := make(map[int64]userProfile, len(uniqueIDs))
	for rows.Next() {
		var item userProfile
		if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil {
			return nil, err
		}
		profiles[item.UserID] = item
	}
	return profiles, rows.Err()
}

func normalizeQuery(req query) query {
	req.CycleKey = strings.TrimSpace(req.CycleKey)
	req.SettlementType = normalizeSettlementType(req.SettlementType)
	req.Status = normalizeStatus(req.Status)
	if req.Page <= 0 {
		req.Page = 1
	}
	if req.PageSize <= 0 {
		req.PageSize = 50
	}
	if req.PageSize > maxPageSize {
		req.PageSize = maxPageSize
	}
	return req
}

func normalizePendingQuery(req pendingQuery) pendingQuery {
	req.CycleKey = strings.TrimSpace(req.CycleKey)
	req.Role = normalizeSettlementRole(req.Role)
	if req.Role == "" {
		req.Role = settlementRoleHost
	}
	req.SettlementType = normalizeSettlementType(req.SettlementType)
	if req.SettlementType == "" {
		req.SettlementType = settlementTypeHalfMonth
	}
	req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
	if req.TriggerMode == "" {
		req.TriggerMode = triggerManual
	}
	req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
	if req.CycleKey == "" {
		req.CycleKey = currentMonthCycleKey(time.Now().UTC())
	}
	if req.Page <= 0 {
		req.Page = 1
	}
	if req.PageSize <= 0 {
		req.PageSize = 50
	}
	if req.PageSize > maxPageSize {
		req.PageSize = maxPageSize
	}
	return req
}

func normalizeSettleRequest(req settleRequest) settleRequest {
	req.CycleKey = strings.TrimSpace(req.CycleKey)
	req.SettlementType = normalizeSettlementType(req.SettlementType)
	if req.SettlementType == "" {
		req.SettlementType = settlementTypeHalfMonth
	}
	req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
	if req.TriggerMode == "" {
		req.TriggerMode = triggerManual
	}
	req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
	if req.CycleKey == "" {
		req.CycleKey = currentMonthCycleKey(time.Now().UTC())
	}
	return req
}

func normalizeSettlementType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "all":
		return ""
	case "daily", "day":
		return "daily"
	case "half_month", "half-month", "halfmonth":
		return "half_month"
	case "month_end", "month-end", "monthend":
		return "month_end"
	default:
		return ""
	}
}

func normalizeTriggerMode(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "all":
		return ""
	case triggerAutomatic:
		return triggerAutomatic
	case triggerManual:
		return triggerManual
	default:
		return ""
	}
}

func normalizeSettlementRole(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", settlementRoleAll:
		return ""
	case settlementRoleHost:
		return settlementRoleHost
	case settlementRoleAgency:
		return settlementRoleAgency
	default:
		return ""
	}
}

func normalizeStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", "all":
		return ""
	case "succeeded", "skipped", "failed":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return ""
	}
}

func uniquePositiveUserIDs(ids []int64) []int64 {
	seen := map[int64]struct{}{}
	out := make([]int64, 0, len(ids))
	for _, id := range ids {
		if id <= 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func userDTOFromProfile(fallbackID string, profile userProfile) userDTO {
	if profile.UserID <= 0 {
		return userDTO{UserID: fallbackID}
	}
	return userDTO{
		UserID:        strconv.FormatInt(profile.UserID, 10),
		DisplayUserID: profile.DisplayUserID,
		Username:      profile.Username,
		Avatar:        profile.Avatar,
	}
}

func formatOptionalUserID(value int64) string {
	if value <= 0 {
		return ""
	}
	return strconv.FormatInt(value, 10)
}

func formatUSDMinor(value int64) string {
	sign := ""
	if value < 0 {
		sign = "-"
		value = -value
	}
	whole := value / 100
	fraction := value % 100
	if fraction == 0 {
		return sign + strconv.FormatInt(whole, 10)
	}
	if fraction < 10 {
		return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10)
	}
	return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10)
}

func pendingDTOFromCandidate(item hostCandidate, host userProfile, agency userProfile) pendingDTO {
	userID := strconv.FormatInt(item.UserID, 10)
	agencyID := formatOptionalUserID(item.AgencyOwnerUserID)
	hostSalaryUSDDelta := item.HostSalaryUSDDelta
	hostCoinRewardDelta := item.HostCoinRewardDelta
	agencySalaryUSDDelta := item.AgencySalaryUSDDelta
	residualUSDDelta := item.ResidualUSDDelta
	switch item.Role {
	case settlementRoleAgency:
		hostSalaryUSDDelta = 0
		hostCoinRewardDelta = 0
		residualUSDDelta = 0
	case settlementRoleHost:
		agencySalaryUSDDelta = 0
	}
	totalUSDDelta, _ := checkedAdd(hostSalaryUSDDelta, agencySalaryUSDDelta, residualUSDDelta)
	return pendingDTO{
		UserID:                      userID,
		User:                        userDTOFromProfile(userID, host),
		AgencyOwnerUserID:           agencyID,
		AgencyOwner:                 userDTOFromProfile(agencyID, agency),
		Role:                        item.Role,
		CycleKey:                    item.CycleKey,
		RegionID:                    item.RegionID,
		RegionName:                  item.RegionName,
		PolicyID:                    item.Policy.PolicyID,
		PolicyVersion:               item.Policy.PolicyVersion,
		PolicyName:                  item.Policy.Name,
		SettlementType:              item.SettlementType,
		TriggerMode:                 item.Policy.TriggerMode,
		LevelNo:                     int32(item.Level.LevelNo),
		RequiredDiamonds:            item.Level.RequiredDiamonds,
		TotalDiamonds:               item.TotalDiamonds,
		HostSalaryUSDMinorTarget:    item.Level.HostSalaryUSDMinor,
		HostSalaryUSDTarget:         formatUSDMinor(item.Level.HostSalaryUSDMinor),
		HostSalaryUSDMinorDelta:     hostSalaryUSDDelta,
		HostSalaryUSDDelta:          formatUSDMinor(hostSalaryUSDDelta),
		HostCoinRewardTarget:        item.Level.HostCoinReward,
		HostCoinRewardDelta:         hostCoinRewardDelta,
		AgencySalaryUSDMinorTarget:  item.Level.AgencySalaryUSDMinor,
		AgencySalaryUSDTarget:       formatUSDMinor(item.Level.AgencySalaryUSDMinor),
		AgencySalaryUSDMinorDelta:   agencySalaryUSDDelta,
		AgencySalaryUSDDelta:        formatUSDMinor(agencySalaryUSDDelta),
		ResidualUSDMinorDelta:       residualUSDDelta,
		ResidualUSDDelta:            formatUSDMinor(residualUSDDelta),
		TotalUSDMinorDelta:          totalUSDDelta,
		TotalUSDDelta:               formatUSDMinor(totalUSDDelta),
		SettledLevelNo:              int32(item.Progress.SettledLevelNo),
		SettledHostSalaryUSDMinor:   item.Progress.SettledHostSalaryUSDMinor,
		SettledHostSalaryUSD:        formatUSDMinor(item.Progress.SettledHostSalaryUSDMinor),
		SettledHostCoinReward:       item.Progress.SettledHostCoinReward,
		SettledAgencySalaryUSDMinor: item.Progress.SettledAgencySalaryUSDMinor,
		SettledAgencySalaryUSD:      formatUSDMinor(item.Progress.SettledAgencySalaryUSDMinor),
		LastSettledTotalDiamonds:    item.Progress.LastSettledTotalDiamonds,
		ResidualUSDMinor:            item.Progress.ResidualUSDMinor,
		ResidualUSD:                 formatUSDMinor(item.Progress.ResidualUSDMinor),
		MonthEndClearedAtMS:         item.Progress.MonthEndClearedAtMS,
		Status:                      statusPending,
	}
}

func hostCandidateHasPendingRole(item hostCandidate, role string) bool {
	switch role {
	case settlementRoleAgency:
		return item.AgencySalaryUSDDelta > 0
	case settlementRoleHost:
		return item.HostSalaryUSDDelta > 0 || item.HostCoinRewardDelta > 0 || item.ResidualUSDDelta > 0
	default:
		return item.HostSalaryUSDDelta > 0 || item.HostCoinRewardDelta > 0 || item.AgencySalaryUSDDelta > 0 || item.ResidualUSDDelta > 0
	}
}

func sortHostCandidates(items []hostCandidate) {
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].CycleKey != items[j].CycleKey {
			return items[i].CycleKey > items[j].CycleKey
		}
		if items[i].RegionID != items[j].RegionID {
			return items[i].RegionID < items[j].RegionID
		}
		return items[i].UserID < items[j].UserID
	})
}

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

func keysInt64FromSet(values map[int64]struct{}) []int64 {
	out := make([]int64, 0, len(values))
	for id := range values {
		out = append(out, id)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

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

func highestHostLevel(levels []hostPolicyLevel, totalDiamonds int64) hostPolicyLevel {
	var selected hostPolicyLevel
	for _, level := range levels {
		if level.RequiredDiamonds <= totalDiamonds {
			selected = level
		}
	}
	return selected
}

func residualDiamondRemainder(totalDiamonds int64, level hostPolicyLevel) int64 {
	if level.LevelNo <= 0 || totalDiamonds <= level.RequiredDiamonds {
		return 0
	}
	return totalDiamonds - level.RequiredDiamonds
}

func usdMinorFromDiamondRate(diamonds int64, rate string) (int64, error) {
	if diamonds <= 0 || strings.TrimSpace(rate) == "" {
		return 0, nil
	}
	rat, ok := new(big.Rat).SetString(strings.TrimSpace(rate))
	if !ok || rat.Sign() < 0 {
		return 0, fmt.Errorf("residual diamond rate is invalid")
	}
	value := new(big.Rat).Mul(new(big.Rat).SetInt64(diamonds), rat)
	value.Mul(value, big.NewRat(100, 1))
	result := new(big.Int).Quo(value.Num(), value.Denom())
	if !result.IsInt64() || result.Int64() > math.MaxInt64 {
		return 0, fmt.Errorf("residual diamond amount overflow")
	}
	return result.Int64(), nil
}

func positiveDelta(target int64, current int64) int64 {
	if target <= current {
		return 0
	}
	return target - current
}

func checkedAdd(values ...int64) (int64, error) {
	var total int64
	for _, value := range values {
		if value > 0 && total > math.MaxInt64-value {
			return 0, fmt.Errorf("amount overflow")
		}
		total += value
	}
	return total, nil
}

func currentMonthCycleKey(now time.Time) string {
	return now.UTC().Format("2006-01")
}

func offset(page int, pageSize int) int {
	if page <= 1 {
		return 0
	}
	return (page - 1) * pageSize
}
