package hostagencypolicy

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

	"hyapp-admin-server/internal/appctx"
	"hyapp-admin-server/internal/model"
	"hyapp-admin-server/internal/repository"

	mysqlDriver "github.com/go-sql-driver/mysql"
)

const (
	policyTypeSalaryDiamond = "SALARY_DIAMOND"
	policyTypePointDiamond  = "POINT_DIAMOND"
	// active/disabled 只表达后台政策是否可用于结算匹配；历史结算记录不应因为停用政策被回写改口径。
	policyStatusActive   = "active"
	policyStatusDisabled = "disabled"

	// 第一阶段只配置可选结算节奏，真正的日结/半月结算调度会在后续结算任务里消费该字段。
	settlementModeDaily     = "daily"
	settlementModeHalfMonth = "half_month"

	// 自动触发由 cron-service 扫描结算；手动触发只进入后台待结算池，避免运营要求人工复核时被定时任务提前发放。
	settlementTriggerAutomatic = "automatic"
	settlementTriggerManual    = "manual"

	// gift_diamond_ratio_configs 定义礼物类型基础倍率，Host 政策字段定义其叠加系数；1 表示不调整基础倍率。
	// 月底剩余钻石转美元默认不开启，由后台显式配置。
	defaultGiftCoinToDiamondRatio = "1"
	defaultResidualDiamondRate    = "0"

	publishStatusDraft     = "draft"
	publishStatusPublished = "published"
	publishStatusFailed    = "failed"

	withdrawalLimitPeriodDay   = "day"
	withdrawalLimitPeriodWeek  = "week"
	withdrawalLimitPeriodMonth = "month"
)

type Service struct {
	store    *repository.Store
	walletDB *sql.DB
}

func NewService(store *repository.Store, walletDB *sql.DB) *Service {
	return &Service{store: store, walletDB: walletDB}
}

func (s *Service) List(ctx context.Context, appCode string, options repository.HostAgencySalaryPolicyListOptions) ([]policyDTO, int64, error) {
	// 列表入口统一收敛 app_code、状态和结算方式，避免前端传别名或非法值时把无效条件下推到 DB。
	options.AppCode = appctx.Normalize(appCode)
	if cycleKey := strings.TrimSpace(options.CycleKey); cycleKey != "" {
		if _, err := normalizePolicyCycleKey(cycleKey); err != nil {
			return nil, 0, err
		}
		// 运营首次打开缺少配置的月份时，系统把紧邻上月的已发布政策复制成独立、可编辑的草稿版本。
		// wallet 同时保留请求时绑定继承，因此草稿发布前也沿用上月口径；运营调整后发布会安全接管继承绑定。
		if err := s.ensureMonthlyPolicyCopies(ctx, options.AppCode, cycleKey); err != nil {
			return nil, 0, err
		}
	}
	options.Status = normalizeStatusFilter(options.Status)
	if strings.TrimSpace(options.PolicyType) != "" {
		options.PolicyType = normalizePolicyType(options.PolicyType)
		if options.PolicyType == "" {
			return nil, 0, errors.New("政策类型不正确")
		}
	}
	options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode)
	options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
	items, total, err := s.store.ListHostAgencySalaryPolicies(options)
	if err != nil {
		return nil, 0, err
	}
	out := make([]policyDTO, 0, len(items))
	for _, item := range items {
		out = append(out, policyFromModel(item))
	}
	return out, total, nil
}

func (s *Service) ensureMonthlyPolicyCopies(_ context.Context, appCode string, cycleKey string) error {
	previousCycleKey, err := previousPolicyCycleKey(cycleKey)
	if err != nil {
		return err
	}
	sources, err := s.store.ListPublishedActiveHostAgencySalaryPoliciesForCycle(appCode, previousCycleKey)
	if err != nil || len(sources) == 0 {
		return err
	}
	for _, source := range sources {
		covered, err := s.store.ListHostAgencySalaryPolicyRegionIDsForCycle(appCode, cycleKey, source.RegionIDs)
		if err != nil {
			return err
		}
		missingRegionIDs := missingPolicyRegionIDs(source.RegionIDs, covered)
		if len(missingRegionIDs) == 0 {
			continue
		}
		clone := cloneHostAgencySalaryPolicyForCycle(source, cycleKey)
		clone.RegionIDs = missingRegionIDs
		clone.RegionID = missingRegionIDs[0]
		if err := s.store.CreateHostAgencySalaryPolicy(&clone); err != nil {
			// 人工创建或另一份自动副本可能刚占用同一区域/月；数据库占位是最终裁决，
			// 当前副本整包回滚后下一次列表会按最新覆盖范围补剩余区域。
			if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
				continue
			}
			// GET 可能被多个后台实例同时触发；月+名称唯一键只允许一个副本，竞争失败方直接读取胜者。
			if isDuplicatePolicyCopy(err) {
				continue
			}
			return err
		}
		// 自动副本保持 draft：直接发布会让运营无法修改 8 月副本；运行侧在此期间已通过上月 binding 继承保证不断档。
	}
	return nil
}

func missingPolicyRegionIDs(source []int64, covered []int64) []int64 {
	coveredSet := make(map[int64]struct{}, len(covered))
	for _, regionID := range covered {
		coveredSet[regionID] = struct{}{}
	}
	missing := make([]int64, 0, len(source))
	for _, regionID := range source {
		if _, exists := coveredSet[regionID]; !exists {
			missing = append(missing, regionID)
		}
	}
	return missing
}

func cloneHostAgencySalaryPolicyForCycle(source model.HostAgencySalaryPolicy, cycleKey string) model.HostAgencySalaryPolicy {
	clone := source
	clone.ID = 0
	clone.CycleKey = cycleKey
	clone.PolicyVersion = 0
	clone.PublishStatus = publishStatusDraft
	clone.PublishError = ""
	clone.PublishedAtMS = 0
	clone.PublishedByAdminID = 0
	clone.CopiedFromPolicyID = source.ID
	clone.CopiedFromCycleKey = source.CycleKey
	clone.CreatedByAdminID = 0
	clone.UpdatedByAdminID = 0
	clone.CreatedAtMS = 0
	clone.UpdatedAtMS = 0
	clone.RegionIDs = append([]int64(nil), source.RegionIDs...)
	clone.Levels = append([]model.HostAgencySalaryLevel(nil), source.Levels...)
	for index := range clone.Levels {
		clone.Levels[index].ID = 0
		clone.Levels[index].PolicyID = 0
		clone.Levels[index].CreatedAtMS = 0
		clone.Levels[index].UpdatedAtMS = 0
	}
	return clone
}

func previousPolicyCycleKey(cycleKey string) (string, error) {
	parsed, err := time.Parse("2006-01", strings.TrimSpace(cycleKey))
	if err != nil || parsed.UTC().Format("2006-01") != strings.TrimSpace(cycleKey) {
		return "", errors.New("工资周期格式必须是 YYYY-MM")
	}
	return parsed.AddDate(0, -1, 0).UTC().Format("2006-01"), nil
}

func isDuplicatePolicyCopy(err error) bool {
	var mysqlErr *mysqlDriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

func (s *Service) Create(appCode string, actorID uint, req policyRequest) (policyDTO, error) {
	// 创建时先把请求规整为模型，所有金额和等级递增规则都在这个边界完成，仓储层只负责持久化。
	item, err := policyModelFromRequest(appctx.Normalize(appCode), actorID, req)
	if err != nil {
		return policyDTO{}, err
	}
	// 启用政策必须逐区域做周期冲突校验，否则同一工资周期会在某个覆盖区域出现两套规则。
	if err := s.ensureNoActiveOverlap(item, 0); err != nil {
		return policyDTO{}, err
	}
	if err := s.store.CreateHostAgencySalaryPolicy(&item); err != nil {
		return policyDTO{}, normalizePolicyPersistenceError(err)
	}
	return policyFromModel(item), nil
}

func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id uint, req policyRequest) (policyDTO, error) {
	// 更新先读原记录，保证 app_code 和主键归属来自数据库，避免请求体越权改租户。
	item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
	if err != nil {
		return policyDTO{}, err
	}
	originalPolicyVersion := item.PolicyVersion
	runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
	if err != nil {
		return policyDTO{}, err
	}
	publishedRevision := item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished
	if strings.TrimSpace(req.PolicyType) == "" {
		// Update DTO 的 policy_type 可选；旧前端没有该字段时必须保留草稿原类型，不能按创建默认值覆盖为工资型。
		req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
	}
	if req.DiamondExchangeEnabled == nil {
		// PUT 仍兼容尚未提交新开关的旧后台：更新已关闭的政策时保留原值，不能因字段缺失自动重新开启。
		req.DiamondExchangeEnabled = boolPointer(item.DiamondExchangeEnabled)
	}
	// 请求体重新走创建同一套校验，编辑和新增保持完全一致的字段约束。
	updated, err := policyModelFromRequest(item.AppCode, actorID, req)
	if err != nil {
		return policyDTO{}, err
	}
	if publishedRevision {
		// 已结束月份仍完全锁定；当前月和未来月只允许修改业务参数，发布身份不变，随后生成新的不可变运行版本。
		if err := validatePublishedPolicyRevision(item, updated, time.Now().UTC()); err != nil {
			return policyDTO{}, err
		}
		item.PolicyVersion = nextHostSalaryPolicyVersion(item.PolicyVersion, time.Now().UTC())
	}
	item.Name = updated.Name
	item.PolicyType = updated.PolicyType
	item.RegionID = updated.RegionID
	item.RegionIDs = updated.RegionIDs
	item.CycleKey = updated.CycleKey
	item.Status = updated.Status
	item.SettlementMode = updated.SettlementMode
	item.SettlementTriggerMode = updated.SettlementTriggerMode
	item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio
	item.PointDiamondsPerUSD = updated.PointDiamondsPerUSD
	item.CoinsPerUSD = updated.CoinsPerUSD
	item.MinimumWithdrawUSDMinor = updated.MinimumWithdrawUSDMinor
	item.CoinSellerMinimumWithdrawUSDMinor = updated.CoinSellerMinimumWithdrawUSDMinor
	item.PlatformMinimumWithdrawUSDMinor = updated.PlatformMinimumWithdrawUSDMinor
	item.DiamondExchangeEnabled = updated.DiamondExchangeEnabled
	item.WithdrawFeeBPS = updated.WithdrawFeeBPS
	item.AgencyPointShareBPS = updated.AgencyPointShareBPS
	item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
	item.CoinSellerWithdrawalLimitPeriod = updated.CoinSellerWithdrawalLimitPeriod
	item.CoinSellerWithdrawalLimitCount = updated.CoinSellerWithdrawalLimitCount
	item.PlatformWithdrawalLimitPeriod = updated.PlatformWithdrawalLimitPeriod
	item.PlatformWithdrawalLimitCount = updated.PlatformWithdrawalLimitCount
	item.PlatformWithdrawalAllowedDays = updated.PlatformWithdrawalAllowedDays
	item.Description = updated.Description
	item.UpdatedByAdminID = actorID
	// 保存修订只更新 admin 草稿；运营重新发布后 wallet 才原子写入新快照并切换当前周期绑定。
	item.PublishStatus = publishStatusDraft
	item.PublishError = ""
	// 等级明细按整包替换处理：前端提交的是一套完整政策梯度，不做局部 patch，减少排序和递增口径漂移。
	item.Levels = updated.Levels
	if err := s.ensureNoActiveOverlap(item, id); err != nil {
		return policyDTO{}, err
	}
	if err := s.store.UpdateHostAgencySalaryPolicy(&item, originalPolicyVersion); err != nil {
		return policyDTO{}, normalizePolicyPersistenceError(err)
	}
	return policyFromModel(item), nil
}

func validatePublishedPolicyRevision(existing model.HostAgencySalaryPolicy, updated model.HostAgencySalaryPolicy, now time.Time) error {
	currentCycle := now.UTC().Format("2006-01")
	if existing.CycleKey < currentCycle {
		return errors.New("已结束工资月的政策不可修改")
	}
	// 类型、月份和区域共同决定钱包记账分流及周期绑定；修订这些身份字段会留下无法解释的旧绑定，必须新建政策。
	if normalizePolicyType(existing.PolicyType) != normalizePolicyType(updated.PolicyType) ||
		existing.CycleKey != updated.CycleKey || !samePolicyRegionIDs(existing.RegionIDs, updated.RegionIDs) {
		return errors.New("已发布政策的类型、周期和适用区域不可修改，请新建政策")
	}
	if updated.Status != policyStatusActive {
		return errors.New("已发布政策修订时必须保持启用")
	}
	return nil
}

func samePolicyRegionIDs(left []int64, right []int64) bool {
	if len(left) != len(right) {
		return false
	}
	for index := range left {
		if left[index] != right[index] {
			return false
		}
	}
	return true
}

func nextHostSalaryPolicyVersion(current uint64, now time.Time) uint64 {
	// epoch ms 对 JSON 安全且按时间可读；同一行在仓储事务中用旧版本 CAS，毫秒内并发也只允许一个提交成功。
	candidate := uint64(now.UTC().UnixMilli())
	if candidate <= current {
		return current + 1
	}
	return candidate
}

func policyTypeForUpdate(requested string, existing string) string {
	if strings.TrimSpace(requested) == "" {
		return existing
	}
	return requested
}

func (s *Service) Delete(ctx context.Context, appCode string, id uint) error {
	// 先按 app_code 查一次，确保删除动作只能落在当前应用上下文内，随后仓储层事务删除主表和等级表。
	item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
	if err != nil {
		return err
	}
	runtimePublished, err := s.runtimePolicySnapshotExists(ctx, item)
	if err != nil {
		return err
	}
	if item.PublishStatus == publishStatusPublished || item.PublishedAtMS > 0 || runtimePublished {
		// 已发布版本及其周期绑定是历史结算依据；停用或删除都不能破坏已存在月份的可追溯性。
		return errors.New("已发布的周期政策版本不可删除")
	}
	return s.store.DeleteHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
}

func (s *Service) Publish(ctx context.Context, appCode string, actorID uint, id uint) (policyDTO, error) {
	appCode = appctx.Normalize(appCode)
	item, err := s.store.GetHostAgencySalaryPolicy(appCode, id)
	if err != nil {
		return policyDTO{}, err
	}
	if item.PublishStatus == publishStatusPublished {
		// 重复点击发布按幂等成功返回；不再覆盖运行表，也不生成新的周期绑定。
		return policyFromModel(item), nil
	}
	if item.Status != policyStatusActive {
		return policyDTO{}, errors.New("工资政策启用后才能发布周期版本")
	}
	if err := s.ensureNoActiveOverlap(item, id); err != nil {
		return policyDTO{}, err
	}
	publishedAtMS := time.Now().UTC().UnixMilli()
	if err := s.publishRuntimePolicy(ctx, item, publishedAtMS); err != nil {
		// 发布失败也写回状态，方便后台列表明确展示“配置未进入 wallet 运行态”的事实。
		_ = s.store.UpdateHostAgencySalaryPolicyPublishFailureIfNotPublished(appCode, id, truncateRunes(err.Error(), 255), actorID)
		return policyDTO{}, err
	}
	if err := s.store.UpdateHostAgencySalaryPolicyPublishState(appCode, id, publishStatusPublished, "", publishedAtMS, actorID); err != nil {
		return policyDTO{}, err
	}
	item.PublishStatus = publishStatusPublished
	item.PublishError = ""
	item.PublishedAtMS = publishedAtMS
	item.PublishedByAdminID = actorID
	return policyFromModel(item), nil
}

func normalizePolicyPersistenceError(err error) error {
	if errors.Is(err, repository.ErrHostAgencySalaryPolicyActiveScopeConflict) {
		return errors.New("同一区域同一工资周期只能存在一个启用政策")
	}
	if errors.Is(err, repository.ErrHostAgencySalaryPolicyConcurrentUpdate) {
		return errors.New("政策已被其他人修改，请刷新后重试")
	}
	return err
}

func (s *Service) runtimePolicySnapshotExists(ctx context.Context, item model.HostAgencySalaryPolicy) (bool, error) {
	if s == nil || s.walletDB == nil {
		// 编辑/删除属于破坏不可变发布事实的高风险动作；无法核对 wallet 时必须失败关闭，不能假定未发布。
		return false, errors.New("wallet mysql is not configured")
	}
	if item.ID == 0 {
		return false, nil
	}
	policyVersion := item.PolicyVersion
	if policyVersion == 0 {
		policyVersion = uint64(item.ID)
	}
	var exists int
	err := s.walletDB.QueryRowContext(ctx, `
		SELECT 1
		FROM host_agency_salary_policies
		WHERE app_code = ? AND policy_id = ? AND policy_version = ?
		LIMIT 1`, item.AppCode, item.ID, policyVersion).Scan(&exists)
	if errors.Is(err, sql.ErrNoRows) {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return true, nil
}

func (s *Service) ensureNoActiveOverlap(item model.HostAgencySalaryPolicy, excludeID uint) error {
	// 停用政策允许提前配置多套草稿；只有 active 才会参与结算匹配，所以冲突检查只约束启用态。
	if item.Status != policyStatusActive {
		return nil
	}
	for _, regionID := range item.RegionIDs {
		overlap, err := s.store.HasActiveHostAgencySalaryPolicyForCycle(item.AppCode, regionID, item.CycleKey, excludeID)
		if err != nil {
			return err
		}
		if overlap {
			return errors.New("同一区域同一工资周期只能存在一个启用政策")
		}
	}
	return nil
}

func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgencySalaryPolicy, publishedAtMS int64) error {
	if s == nil || s.walletDB == nil {
		return errors.New("wallet mysql is not configured")
	}
	if normalizePolicyType(item.PolicyType) == policyTypeSalaryDiamond && len(item.Levels) == 0 {
		return errors.New("至少需要配置一个等级")
	}
	if normalizePolicyType(item.PolicyType) == policyTypePointDiamond && len(item.Levels) != 0 {
		return errors.New("钻石积分政策不能配置工资等级")
	}
	tx, err := s.walletDB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	policyVersion := item.PolicyVersion
	if policyVersion == 0 {
		// 仅兼容迁移前创建但尚未发布的草稿；新记录在 admin 仓储创建事务中固定为 policy ID。
		policyVersion = uint64(item.ID)
	}
	// 跨库状态回写失败后重试时，wallet 可能已经拥有同版本快照；内容一致视为幂等成功，
	// 新修订使用新的 policy_version，因此旧版本始终保留且任何同版本内容都不能被覆盖。
	if err := ensureRuntimeHostSalaryPolicySnapshot(ctx, tx, item, policyVersion, publishedAtMS); err != nil {
		return err
	}
	// 每个区域各自拥有周期绑定；继承版本可由本月显式版本接管，同一政策的更高修订版本也可切换当前绑定。
	// 旧运行快照不删除，工资记录中的 policy_version 仍能准确指回当时使用的口径。
	for _, regionID := range item.RegionIDs {
		if err := bindExplicitHostSalaryPolicy(ctx, tx, item, regionID, policyVersion, publishedAtMS); err != nil {
			return err
		}
	}
	return tx.Commit()
}

type runtimeHostSalaryPolicySnapshot struct {
	CycleKey                          string
	PolicyVersion                     uint64
	Name                              string
	PolicyType                        string
	RegionID                          int64
	Status                            string
	SettlementMode                    string
	SettlementTriggerMode             string
	GiftCoinToDiamondRatio            string
	PointDiamondsPerUSD               int64
	CoinsPerUSD                       int64
	MinimumWithdrawUSDMinor           int64
	CoinSellerMinimumWithdrawUSDMinor int64
	PlatformMinimumWithdrawUSDMinor   int64
	DiamondExchangeEnabled            bool
	WithdrawFeeBPS                    int32
	AgencyPointShareBPS               int32
	ResidualDiamondToUSDRate          string
	CoinSellerWithdrawalLimitPeriod   string
	CoinSellerWithdrawalLimitCount    int64
	PlatformWithdrawalLimitPeriod     string
	PlatformWithdrawalLimitCount      int64
	PlatformWithdrawalAllowedDays     string
}

type runtimeHostSalaryLevelSnapshot struct {
	LevelNo              int32
	RequiredDiamonds     int64
	HostSalaryUSDMinor   int64
	HostCoinReward       int64
	AgencySalaryUSDMinor int64
	Status               string
	SortOrder            int32
}

func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, policyVersion uint64, publishedAtMS int64) error {
	coinSellerMinimum, platformMinimum := resolvedPolicyWithdrawalMinimums(item)
	// 先以完整快照尝试原子占位；复合主键包含 policy_version，已存在时只做自赋值，绝不改写业务字段。
	// 两个首次发布请求会在同一主键写锁上排队，避免“都 SELECT 到不存在，再 INSERT”形成 gap-lock 死锁。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO host_agency_salary_policies (
		  app_code, policy_id, cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
		  gift_coin_to_diamond_ratio, point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor,
		  coin_seller_minimum_withdraw_usd_minor, platform_minimum_withdraw_usd_minor, diamond_exchange_enabled,
		  withdraw_fee_bps, agency_point_share_bps,
		  residual_diamond_to_usd_rate,
		  coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
		  platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
		  created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
		item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, normalizePolicyType(item.PolicyType), item.RegionID, item.Status,
		item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
		item.GiftCoinToDiamondRatio, item.PointDiamondsPerUSD, item.CoinsPerUSD, platformMinimum,
		coinSellerMinimum, platformMinimum, item.DiamondExchangeEnabled, item.WithdrawFeeBPS, item.AgencyPointShareBPS,
		item.ResidualDiamondToUSDRate,
		firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
		firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
		item.PlatformWithdrawalAllowedDays,
		nonZeroMS(item.CreatedAtMS, publishedAtMS), publishedAtMS,
	); err != nil {
		return err
	}

	var existing runtimeHostSalaryPolicySnapshot
	err := tx.QueryRowContext(ctx, `
		SELECT cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
		       CAST(gift_coin_to_diamond_ratio AS CHAR), point_diamonds_per_usd, coins_per_usd,
		       minimum_withdraw_usd_minor, coin_seller_minimum_withdraw_usd_minor, platform_minimum_withdraw_usd_minor,
		       diamond_exchange_enabled, withdraw_fee_bps, agency_point_share_bps, CAST(residual_diamond_to_usd_rate AS CHAR),
		       coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
		       platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
		FROM host_agency_salary_policies
		WHERE app_code = ? AND policy_id = ? AND policy_version = ?
		FOR UPDATE`, item.AppCode, item.ID, policyVersion).Scan(
		&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
		&existing.SettlementMode, &existing.SettlementTriggerMode,
		&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
		&existing.MinimumWithdrawUSDMinor, &existing.CoinSellerMinimumWithdrawUSDMinor, &existing.PlatformMinimumWithdrawUSDMinor,
		&existing.DiamondExchangeEnabled, &existing.WithdrawFeeBPS, &existing.AgencyPointShareBPS, &existing.ResidualDiamondToUSDRate,
		&existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
		&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
		&existing.PlatformWithdrawalAllowedDays,
	)
	if err != nil {
		return err
	}
	if !runtimeHostSalaryPolicyMatches(existing, item, policyVersion) {
		return errors.New("wallet 已存在内容不一致的工资政策快照")
	}

	expectedLevels, err := runtimeHostSalaryLevelsFromModel(item.Levels)
	if err != nil {
		return err
	}
	existingLevels, err := loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
	if err != nil {
		return err
	}
	if len(existingLevels) == 0 {
		// 主快照写锁仍由当前事务持有；只有第一个成功占位的事务会看到空等级并整包插入，
		// 后续并发发布等待主键锁后会读取完整等级，不会重复插入。
		for _, level := range expectedLevels {
			// 运行表保存美分整数，避免 wallet-service 结算时再次解析 decimal 字符串造成口径漂移。
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO host_agency_salary_policy_levels (
				  app_code, policy_id, policy_version, level_no, required_diamonds, host_salary_usd_minor,
				  host_coin_reward, agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
				) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				item.AppCode, item.ID, policyVersion, level.LevelNo, level.RequiredDiamonds, level.HostSalaryUSDMinor,
				level.HostCoinReward, level.AgencySalaryUSDMinor, level.Status, level.SortOrder,
				publishedAtMS, publishedAtMS,
			); err != nil {
				return err
			}
		}
		existingLevels, err = loadRuntimeHostSalaryLevels(ctx, tx, item.AppCode, item.ID, policyVersion)
		if err != nil {
			return err
		}
	}
	if !runtimeHostSalaryLevelsMatch(existingLevels, expectedLevels) {
		return errors.New("wallet 已存在内容不一致的工资政策等级快照")
	}
	return nil
}

func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, item model.HostAgencySalaryPolicy, policyVersion uint64) bool {
	itemCoinSellerMinimum, itemPlatformMinimum := resolvedPolicyWithdrawalMinimums(item)
	existingCoinSellerMinimum := existing.CoinSellerMinimumWithdrawUSDMinor
	if existingCoinSellerMinimum <= 0 {
		existingCoinSellerMinimum = existing.MinimumWithdrawUSDMinor
	}
	existingPlatformMinimum := existing.PlatformMinimumWithdrawUSDMinor
	if existingPlatformMinimum <= 0 {
		existingPlatformMinimum = existing.MinimumWithdrawUSDMinor
	}
	return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
		existing.PolicyType == normalizePolicyType(item.PolicyType) &&
		existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
		existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
		trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
		existing.PointDiamondsPerUSD == item.PointDiamondsPerUSD && existing.CoinsPerUSD == item.CoinsPerUSD &&
		existingPlatformMinimum == itemPlatformMinimum && existingCoinSellerMinimum == itemCoinSellerMinimum &&
		existing.DiamondExchangeEnabled == item.DiamondExchangeEnabled && existing.WithdrawFeeBPS == item.WithdrawFeeBPS &&
		existing.AgencyPointShareBPS == item.AgencyPointShareBPS &&
		trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
		existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
		existing.CoinSellerWithdrawalLimitCount == item.CoinSellerWithdrawalLimitCount &&
		existing.PlatformWithdrawalLimitPeriod == firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
		existing.PlatformWithdrawalLimitCount == item.PlatformWithdrawalLimitCount &&
		existing.PlatformWithdrawalAllowedDays == item.PlatformWithdrawalAllowedDays
}

func runtimeHostSalaryLevelsFromModel(levels []model.HostAgencySalaryLevel) ([]runtimeHostSalaryLevelSnapshot, error) {
	models := append([]model.HostAgencySalaryLevel(nil), levels...)
	sort.Slice(models, func(i, j int) bool { return models[i].LevelNo < models[j].LevelNo })
	out := make([]runtimeHostSalaryLevelSnapshot, 0, len(models))
	for _, level := range models {
		_, hostSalaryMinor, err := parseFixedDecimal(level.HostSalaryUSD, 2, true, "主播美元工资")
		if err != nil {
			return nil, err
		}
		_, agencySalaryMinor, err := parseFixedDecimal(level.AgencySalaryUSD, 2, true, "代理美元工资")
		if err != nil {
			return nil, err
		}
		out = append(out, runtimeHostSalaryLevelSnapshot{
			LevelNo: level.LevelNo, RequiredDiamonds: level.RequiredDiamonds, HostSalaryUSDMinor: hostSalaryMinor,
			HostCoinReward: level.HostCoinReward, AgencySalaryUSDMinor: agencySalaryMinor, Status: level.Status, SortOrder: level.SortOrder,
		})
	}
	return out, nil
}

func loadRuntimeHostSalaryLevels(ctx context.Context, tx *sql.Tx, appCode string, policyID uint, policyVersion uint64) ([]runtimeHostSalaryLevelSnapshot, error) {
	rows, err := tx.QueryContext(ctx, `
		SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
		       agency_salary_usd_minor, status, sort_order
		FROM host_agency_salary_policy_levels
		WHERE app_code = ? AND policy_id = ? AND policy_version = ?
		ORDER BY level_no ASC`, appCode, policyID, policyVersion)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var levels []runtimeHostSalaryLevelSnapshot
	for rows.Next() {
		var level runtimeHostSalaryLevelSnapshot
		if err := rows.Scan(&level.LevelNo, &level.RequiredDiamonds, &level.HostSalaryUSDMinor, &level.HostCoinReward,
			&level.AgencySalaryUSDMinor, &level.Status, &level.SortOrder); err != nil {
			return nil, err
		}
		levels = append(levels, level)
	}
	return levels, rows.Err()
}

func runtimeHostSalaryLevelsMatch(existing []runtimeHostSalaryLevelSnapshot, expected []runtimeHostSalaryLevelSnapshot) bool {
	if len(existing) != len(expected) {
		return false
	}
	for index := range expected {
		if existing[index] != expected[index] {
			return false
		}
	}
	return true
}

func bindExplicitHostSalaryPolicy(ctx context.Context, tx *sql.Tx, item model.HostAgencySalaryPolicy, regionID int64, policyVersion uint64, publishedAtMS int64) error {
	var existingPolicyID, existingPolicyVersion uint64
	var sourceCycleKey string
	err := tx.QueryRowContext(ctx, `
		SELECT policy_id, policy_version, source_cycle_key
		FROM host_salary_policy_cycle_bindings
		WHERE app_code = ? AND region_id = ? AND cycle_key = ?
		FOR UPDATE`, item.AppCode, regionID, item.CycleKey,
	).Scan(&existingPolicyID, &existingPolicyVersion, &sourceCycleKey)
	if errors.Is(err, sql.ErrNoRows) {
		_, err = tx.ExecContext(ctx, `
			INSERT INTO host_salary_policy_cycle_bindings (
			  app_code, region_id, cycle_key, policy_id, policy_version, source_cycle_key, created_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			item.AppCode, regionID, item.CycleKey, item.ID, policyVersion, item.CycleKey, publishedAtMS,
		)
		return err
	}
	if err != nil {
		return err
	}
	if existingPolicyID == uint64(item.ID) && existingPolicyVersion == policyVersion {
		return nil
	}
	if existingPolicyID == uint64(item.ID) && existingPolicyVersion < policyVersion && strings.TrimSpace(sourceCycleKey) == item.CycleKey {
		// 当前/未来月份的同一逻辑政策允许发布更高版本；WHERE 带旧版本，防止并发发布把更新顺序倒置。
		result, updateErr := tx.ExecContext(ctx, `
			UPDATE host_salary_policy_cycle_bindings
			SET policy_version = ?, created_at_ms = ?
			WHERE app_code = ? AND region_id = ? AND cycle_key = ? AND policy_id = ? AND policy_version = ? AND source_cycle_key = cycle_key`,
			policyVersion, publishedAtMS, item.AppCode, regionID, item.CycleKey, item.ID, existingPolicyVersion,
		)
		if updateErr != nil {
			return updateErr
		}
		affected, updateErr := result.RowsAffected()
		if updateErr != nil {
			return updateErr
		}
		if affected != 1 {
			return errors.New("工资月政策版本已被并发修改，请重试")
		}
		return nil
	}
	if strings.TrimSpace(sourceCycleKey) == "" || strings.TrimSpace(sourceCycleKey) == item.CycleKey {
		return errors.New("该区域工资月已经绑定其他显式政策版本")
	}
	// 只替换明确标记为跨月继承的绑定；WHERE 再次约束来源，防止未来代码改动误覆盖显式历史版本。
	result, err := tx.ExecContext(ctx, `
		UPDATE host_salary_policy_cycle_bindings
		SET policy_id = ?, policy_version = ?, source_cycle_key = ?, created_at_ms = ?
		WHERE app_code = ? AND region_id = ? AND cycle_key = ? AND source_cycle_key <> cycle_key`,
		item.ID, policyVersion, item.CycleKey, publishedAtMS, item.AppCode, regionID, item.CycleKey,
	)
	if err != nil {
		return err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected != 1 {
		return errors.New("工资月继承绑定已被并发修改，请重试")
	}
	return nil
}

func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (model.HostAgencySalaryPolicy, error) {
	name := strings.TrimSpace(req.Name)
	if name == "" || len([]rune(name)) > 120 {
		return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
	}
	policyType := normalizePolicyType(req.PolicyType)
	if policyType == "" {
		return model.HostAgencySalaryPolicy{}, errors.New("政策类型不正确")
	}
	regionIDs, err := normalizePolicyRegionIDs(req.RegionID, req.RegionIDs)
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	cycleKey, err := normalizePolicyCycleKey(req.CycleKey)
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	status := normalizeStatus(req.Status)
	if status == "" {
		// 新建默认停用，避免运营保存草稿时立即影响结算口径。
		status = policyStatusDisabled
	}
	if !validStatus(status) {
		return model.HostAgencySalaryPolicy{}, errors.New("政策状态不正确")
	}
	settlementMode := settlementModeDaily
	settlementTriggerMode := settlementTriggerAutomatic
	if policyType == policyTypeSalaryDiamond {
		settlementMode = normalizeSettlementMode(req.SettlementMode)
		if settlementMode == "" {
			// 产品当前默认日结；半月结算只是调度节奏差异，等级和差额发放规则相同。
			settlementMode = settlementModeDaily
		}
		if !validSettlementMode(settlementMode) {
			return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确")
		}
		settlementTriggerMode = normalizeSettlementTriggerMode(req.SettlementTriggerMode)
		if settlementTriggerMode == "" {
			// 默认自动触发，兼容现有政策和现有 cron 日结/半月结任务；需要人工复核时由后台显式切到 manual。
			settlementTriggerMode = settlementTriggerAutomatic
		}
		if !validSettlementTriggerMode(settlementTriggerMode) {
			return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
		}
	}
	// 礼物配置负责 normal/lucky/super_lucky 等类型基础倍率，Host 工资政策只叠加一个周期系数；
	// 发布后 wallet 按周期不可变快照读取，系数 1 保持原礼物类型倍率不变。
	giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio)
	giftRatio, _, err = parseFixedDecimal(giftRatio, 6, false, "周期钻石政策系数")
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	pointDiamondsPerUSD := req.PointDiamondsPerUSD
	coinsPerUSD := req.CoinsPerUSD
	// 旧请求只有一个 minimum 字段时同时用于两个渠道；新请求分别配置，旧别名固定投影平台金额。
	coinSellerMinimumWithdrawUSDMinor := req.CoinSellerMinimumWithdrawUSDMinor
	if coinSellerMinimumWithdrawUSDMinor == 0 {
		coinSellerMinimumWithdrawUSDMinor = req.MinimumWithdrawUSDMinor
	}
	platformMinimumWithdrawUSDMinor := req.PlatformMinimumWithdrawUSDMinor
	if platformMinimumWithdrawUSDMinor == 0 {
		platformMinimumWithdrawUSDMinor = req.MinimumWithdrawUSDMinor
	}
	minimumWithdrawUSDMinor := platformMinimumWithdrawUSDMinor
	diamondExchangeEnabled := true
	if req.DiamondExchangeEnabled != nil {
		diamondExchangeEnabled = *req.DiamondExchangeEnabled
	}
	withdrawFeeBPS := req.WithdrawFeeBPS
	agencyPointShareBPS := req.AgencyPointShareBPS
	if policyType == policyTypeSalaryDiamond {
		// 工资型政策不消费积分兑换字段；即使旧/恶意客户端提交也统一清零，避免后台出现“保存成功但不生效”的幽灵配置。
		pointDiamondsPerUSD = 0
		coinsPerUSD = 0
		minimumWithdrawUSDMinor = 0
		coinSellerMinimumWithdrawUSDMinor = 0
		platformMinimumWithdrawUSDMinor = 0
		diamondExchangeEnabled = false
		withdrawFeeBPS = 0
		agencyPointShareBPS = 0
	}
	if pointDiamondsPerUSD < 0 || coinsPerUSD < 0 || coinSellerMinimumWithdrawUSDMinor < 0 || platformMinimumWithdrawUSDMinor < 0 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 || agencyPointShareBPS < 0 || agencyPointShareBPS > 10_000 {
		return model.HostAgencySalaryPolicy{}, errors.New("钻石积分兑换或提现配置不正确")
	}
	if policyType == policyTypePointDiamond && (pointDiamondsPerUSD <= 0 || coinsPerUSD <= 0 || coinSellerMinimumWithdrawUSDMinor <= 0 || platformMinimumWithdrawUSDMinor <= 0) {
		// POINT_DIAMOND 的金额入口全部读取这份月度不可变快照；缺少任一比例时必须拒绝发布前保存，不能回退代码常量。
		return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币、币商最低提现金额和平台最低提现金额")
	}
	if policyType == policyTypePointDiamond && pointDiamondsPerUSD%100 != 0 {
		// HTTP 只接受美元分整数；比例必须能精确映射每一美分，禁止依赖向下取整吞掉用户积分。
		return model.HostAgencySalaryPolicy{}, errors.New("每美元钻石积分必须是 100 的整数倍")
	}
	residualRateInput := req.ResidualDiamondToUSDRate
	if policyType == policyTypePointDiamond {
		// 永久积分不参与工资结算和月底剩余钻石折算；隐藏字段无论提交什么都固化为中性零值。
		residualRateInput = defaultResidualDiamondRate
	}
	residualRate := firstNonBlank(residualRateInput, defaultResidualDiamondRate)
	residualRate, _, err = parseFixedDecimal(residualRate, 12, true, "剩余钻石转美元比例")
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	coinSellerLimitPeriod, coinSellerLimitCount, err := normalizeWithdrawalLimit(req.CoinSellerWithdrawalLimitPeriod, req.CoinSellerWithdrawalLimitCount, "用户找币商提现")
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	platformLimitPeriod, platformLimitCount, err := normalizeWithdrawalLimit(req.PlatformWithdrawalLimitPeriod, req.PlatformWithdrawalLimitCount, "用户找平台提现")
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	platformAllowedDays, err := normalizeWithdrawalDays(req.PlatformWithdrawalAllowedDays)
	if err != nil {
		return model.HostAgencySalaryPolicy{}, err
	}
	description := strings.TrimSpace(req.Description)
	if len([]rune(description)) > 255 {
		return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符")
	}
	// 等级表保存的是累计门槛和累计权益；实际发薪时应减去上一已结算等级，只发差额。
	var levels []model.HostAgencySalaryLevel
	if policyType == policyTypePointDiamond {
		if len(req.Levels) != 0 {
			return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策不能配置工资等级")
		}
		levels = []model.HostAgencySalaryLevel{}
	} else {
		levels, err = levelModelsFromRequest(req.Levels)
		if err != nil {
			return model.HostAgencySalaryPolicy{}, err
		}
	}
	return model.HostAgencySalaryPolicy{
		AppCode:    appCode,
		Name:       name,
		PolicyType: policyType,
		// RegionID 保留首个区域以兼容旧索引和旧客户端；实际覆盖范围只由 RegionIDs 关联表决定。
		RegionID:                          regionIDs[0],
		RegionIDs:                         regionIDs,
		CycleKey:                          cycleKey,
		Status:                            status,
		SettlementMode:                    settlementMode,
		SettlementTriggerMode:             settlementTriggerMode,
		GiftCoinToDiamondRatio:            giftRatio,
		PointDiamondsPerUSD:               pointDiamondsPerUSD,
		CoinsPerUSD:                       coinsPerUSD,
		MinimumWithdrawUSDMinor:           minimumWithdrawUSDMinor,
		CoinSellerMinimumWithdrawUSDMinor: coinSellerMinimumWithdrawUSDMinor,
		PlatformMinimumWithdrawUSDMinor:   platformMinimumWithdrawUSDMinor,
		DiamondExchangeEnabled:            diamondExchangeEnabled,
		WithdrawFeeBPS:                    withdrawFeeBPS,
		AgencyPointShareBPS:               agencyPointShareBPS,
		ResidualDiamondToUSDRate:          residualRate,
		CoinSellerWithdrawalLimitPeriod:   coinSellerLimitPeriod,
		CoinSellerWithdrawalLimitCount:    coinSellerLimitCount,
		PlatformWithdrawalLimitPeriod:     platformLimitPeriod,
		PlatformWithdrawalLimitCount:      platformLimitCount,
		PlatformWithdrawalAllowedDays:     platformAllowedDays,
		Description:                       description,
		CreatedByAdminID:                  actorID,
		UpdatedByAdminID:                  actorID,
		Levels:                            levels,
	}, nil
}

func boolPointer(value bool) *bool {
	return &value
}

// resolvedPolicyWithdrawalMinimums 兼容迁移前快照：渠道字段为 0 时回退原单一门槛。
// POINT_DIAMOND 新保存会强制两个渠道都大于 0，因此 0 不会与合法的新配置产生歧义。
func resolvedPolicyWithdrawalMinimums(item model.HostAgencySalaryPolicy) (int64, int64) {
	coinSellerMinimum := item.CoinSellerMinimumWithdrawUSDMinor
	if coinSellerMinimum <= 0 {
		coinSellerMinimum = item.MinimumWithdrawUSDMinor
	}
	platformMinimum := item.PlatformMinimumWithdrawUSDMinor
	if platformMinimum <= 0 {
		platformMinimum = item.MinimumWithdrawUSDMinor
	}
	return coinSellerMinimum, platformMinimum
}

func normalizePolicyType(value string) string {
	value = strings.ToUpper(strings.TrimSpace(value))
	if value == "" {
		return policyTypeSalaryDiamond
	}
	switch value {
	case policyTypeSalaryDiamond, policyTypePointDiamond:
		return value
	default:
		return ""
	}
}

// normalizeWithdrawalLimit 固定月度政策允许的周期枚举；次数为 0 时保留周期但运行侧直接按不限处理。
func normalizeWithdrawalLimit(period string, count int64, field string) (string, int64, error) {
	period = strings.ToLower(strings.TrimSpace(period))
	if period == "" {
		period = withdrawalLimitPeriodMonth
	}
	if period != withdrawalLimitPeriodDay && period != withdrawalLimitPeriodWeek && period != withdrawalLimitPeriodMonth {
		return "", 0, fmt.Errorf("%s限制周期不正确", field)
	}
	if count < 0 {
		return "", 0, fmt.Errorf("%s限制次数不能小于 0", field)
	}
	return period, count, nil
}

// normalizeWithdrawalDays 把后台多选日规整为不可变快照中的稳定逗号串；空集合表示不限制日期。
func normalizeWithdrawalDays(days []int32) (string, error) {
	if len(days) == 0 {
		return "", nil
	}
	unique := make(map[int32]struct{}, len(days))
	for _, day := range days {
		if day < 1 || day > 31 {
			return "", errors.New("平台每月可提现日期必须在 1 到 31 号之间")
		}
		unique[day] = struct{}{}
	}
	normalized := make([]int, 0, len(unique))
	for day := range unique {
		normalized = append(normalized, int(day))
	}
	sort.Ints(normalized)
	parts := make([]string, 0, len(normalized))
	for _, day := range normalized {
		parts = append(parts, strconv.Itoa(day))
	}
	return strings.Join(parts, ","), nil
}

func parseWithdrawalDays(raw string) []int32 {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return []int32{}
	}
	parts := strings.Split(raw, ",")
	days := make([]int32, 0, len(parts))
	for _, part := range parts {
		day, err := strconv.Atoi(strings.TrimSpace(part))
		if err != nil || day < 1 || day > 31 {
			// 数据库异常值不回传给表单，发布和 Wallet 运行侧仍会拒绝不合法快照。
			return []int32{}
		}
		days = append(days, int32(day))
	}
	return days
}

func levelModelsFromRequest(requests []levelRequest) ([]model.HostAgencySalaryLevel, error) {
	if len(requests) == 0 {
		return nil, errors.New("至少需要配置一个等级")
	}
	// 先按等级号排序再校验，允许前端在编辑过程中临时乱序提交，但最终持久化必须是稳定梯度。
	levels := append([]levelRequest(nil), requests...)
	sortLevelRequests(levels)
	seen := map[int32]struct{}{}
	out := make([]model.HostAgencySalaryLevel, 0, len(levels))
	var previousRequiredDiamonds int64
	var previousHostSalaryCents int64
	var previousHostCoinReward int64
	var previousAgencySalaryCents int64
	for index, req := range levels {
		// 等级号是结算差额的顺序锚点，不能为 0，也不能重复。
		if req.Level <= 0 {
			return nil, errors.New("等级必须大于 0")
		}
		if _, ok := seen[req.Level]; ok {
			return nil, fmt.Errorf("等级 %d 重复", req.Level)
		}
		seen[req.Level] = struct{}{}
		if req.RequiredDiamonds <= 0 {
			return nil, fmt.Errorf("等级 %d 所需钻石必须大于 0", req.Level)
		}
		// 钻石门槛必须严格递增，否则用户达到高等级时无法确定唯一命中等级。
		if index > 0 && req.RequiredDiamonds <= previousRequiredDiamonds {
			return nil, fmt.Errorf("等级 %d 所需钻石必须大于上一等级", req.Level)
		}
		// 主播美元工资是“累计应得”，后续结算以当前等级累计值减去已结算累计值发差额。
		hostSalary, hostSalaryCents, err := parseFixedDecimal(req.HostSalaryUSD, 2, true, "主播美元工资")
		if err != nil {
			return nil, fmt.Errorf("等级 %d %w", req.Level, err)
		}
		if index > 0 && hostSalaryCents < previousHostSalaryCents {
			return nil, fmt.Errorf("等级 %d 主播美元工资不能小于上一等级", req.Level)
		}
		if req.HostCoinReward < 0 {
			return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于 0", req.Level)
		}
		// 金币奖励同样是累计配置，不能比上一等级低，否则差额发放会变成负数。
		if index > 0 && req.HostCoinReward < previousHostCoinReward {
			return nil, fmt.Errorf("等级 %d 主播金币奖励不能小于上一等级", req.Level)
		}
		// 代理美元工资跟随主播结算同时发放，也按累计值减已结算累计值计算差额。
		agencySalary, agencySalaryCents, err := parseFixedDecimal(req.AgencySalaryUSD, 2, true, "代理美元工资")
		if err != nil {
			return nil, fmt.Errorf("等级 %d %w", req.Level, err)
		}
		if index > 0 && agencySalaryCents < previousAgencySalaryCents {
			return nil, fmt.Errorf("等级 %d 代理美元工资不能小于上一等级", req.Level)
		}
		status := normalizeStatus(req.Status)
		if status == "" {
			// 单个等级默认启用；需要临时屏蔽某个等级时由运营显式停用。
			status = policyStatusActive
		}
		if !validStatus(status) {
			return nil, fmt.Errorf("等级 %d 状态不正确", req.Level)
		}
		sortOrder := req.SortOrder
		if sortOrder == 0 {
			// 默认排序跟等级号一致，前端展示和后续结算扫描都能保持自然顺序。
			sortOrder = req.Level
		}
		out = append(out, model.HostAgencySalaryLevel{
			LevelNo:          req.Level,
			RequiredDiamonds: req.RequiredDiamonds,
			HostSalaryUSD:    hostSalary,
			HostCoinReward:   req.HostCoinReward,
			AgencySalaryUSD:  agencySalary,
			Status:           status,
			SortOrder:        sortOrder,
		})
		previousRequiredDiamonds = req.RequiredDiamonds
		previousHostSalaryCents = hostSalaryCents
		previousHostCoinReward = req.HostCoinReward
		previousAgencySalaryCents = agencySalaryCents
	}
	return out, nil
}

func sortLevelRequests(items []levelRequest) {
	for i := 1; i < len(items); i++ {
		item := items[i]
		j := i - 1
		for j >= 0 && items[j].Level > item.Level {
			items[j+1] = items[j]
			j--
		}
		items[j+1] = item
	}
}

func parseFixedDecimal(raw string, scale int, allowZero bool, field string) (string, int64, error) {
	value := strings.TrimSpace(raw)
	if strings.HasPrefix(value, "+") {
		value = strings.TrimPrefix(value, "+")
	}
	if value == "" || strings.HasPrefix(value, "-") {
		return "", 0, fmt.Errorf("%s不正确", field)
	}
	parts := strings.Split(value, ".")
	if len(parts) > 2 || parts[0] == "" || !allDigits(parts[0]) {
		return "", 0, fmt.Errorf("%s不正确", field)
	}
	if len(parts) == 2 && (parts[1] == "" || len(parts[1]) > scale || !allDigits(parts[1])) {
		return "", 0, fmt.Errorf("%s最多支持 %d 位小数", field, scale)
	}
	whole, err := strconv.ParseInt(parts[0], 10, 64)
	if err != nil {
		return "", 0, fmt.Errorf("%s过大", field)
	}
	multiplier := pow10(scale)
	if whole > (1<<62)/multiplier {
		return "", 0, fmt.Errorf("%s过大", field)
	}
	var fraction int64
	if len(parts) == 2 {
		fractionText := parts[1] + strings.Repeat("0", scale-len(parts[1]))
		fraction, err = strconv.ParseInt(fractionText, 10, 64)
		if err != nil {
			return "", 0, fmt.Errorf("%s不正确", field)
		}
	}
	scaled := whole*multiplier + fraction
	if !allowZero && scaled <= 0 {
		return "", 0, fmt.Errorf("%s必须大于 0", field)
	}
	return strconv.FormatInt(whole, 10) + "." + fixedDigits(fraction, scale), scaled, nil
}

func fixedDigits(value int64, scale int) string {
	text := strconv.FormatInt(value, 10)
	if len(text) >= scale {
		return text
	}
	return strings.Repeat("0", scale-len(text)) + text
}

func trimDecimalZeros(value string) string {
	value = strings.TrimSpace(value)
	if !strings.Contains(value, ".") {
		return value
	}
	value = strings.TrimRight(value, "0")
	return strings.TrimRight(value, ".")
}

func pow10(scale int) int64 {
	var out int64 = 1
	for i := 0; i < scale; i++ {
		out *= 10
	}
	return out
}

func allDigits(value string) bool {
	for _, r := range value {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

func firstNonBlank(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}

func nonZeroMS(value int64, fallback int64) int64 {
	if value > 0 {
		return value
	}
	return fallback
}

func normalizePolicyRegionIDs(legacyRegionID int64, rawRegionIDs []int64) ([]int64, error) {
	// region_ids 是新接口的唯一覆盖集合；region_id 仅在旧客户端未提交数组时回退，避免旧值悄悄扩大新配置范围。
	regionIDs := rawRegionIDs
	if len(regionIDs) == 0 && legacyRegionID > 0 {
		regionIDs = []int64{legacyRegionID}
	}
	seen := make(map[int64]struct{}, len(regionIDs))
	normalized := make([]int64, 0, len(regionIDs))
	for _, regionID := range regionIDs {
		if regionID <= 0 {
			return nil, errors.New("请选择适用区域")
		}
		if _, exists := seen[regionID]; exists {
			continue
		}
		seen[regionID] = struct{}{}
		normalized = append(normalized, regionID)
	}
	if len(normalized) == 0 {
		return nil, errors.New("请选择适用区域")
	}
	// 固定顺序让首个兼容 region_id 和 API 返回值稳定，避免相同集合因提交顺序不同制造无意义更新。
	for i := 1; i < len(normalized); i++ {
		for j := i; j > 0 && normalized[j-1] > normalized[j]; j-- {
			normalized[j-1], normalized[j] = normalized[j], normalized[j-1]
		}
	}
	return normalized, nil
}

func normalizePolicyCycleKey(raw string) (string, error) {
	cycleKey := strings.TrimSpace(raw)
	// 工资周期是唯一生效边界，不能从历史 Effective* 字段反推：后台的“7 月 1 日 00:00”会按
	// 浏览器时区转成 6 月 30 日 UTC，再取 UTC 月份会把 7 月政策错绑到 6 月。
	// 请求必须显式提交 cycle_key；历史时间字段仅兼容既有表结构，不再参与配置、展示或选政策。
	if len(cycleKey) != len("2006-01") {
		return "", errors.New("请选择工资周期")
	}
	parsed, err := time.Parse("2006-01", cycleKey)
	if err != nil || parsed.UTC().Format("2006-01") != cycleKey {
		return "", errors.New("工资周期格式必须为 YYYY-MM")
	}
	return cycleKey, nil
}

func truncateRunes(value string, limit int) string {
	if limit <= 0 {
		return ""
	}
	runes := []rune(strings.TrimSpace(value))
	if len(runes) <= limit {
		return string(runes)
	}
	return string(runes[:limit])
}

func normalizeStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", policyStatusActive, "enabled":
		if strings.TrimSpace(value) == "" {
			return ""
		}
		return policyStatusActive
	case policyStatusDisabled, "inactive":
		return policyStatusDisabled
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func normalizeStatusFilter(value string) string {
	status := normalizeStatus(value)
	if !validStatus(status) {
		return ""
	}
	return status
}

func validStatus(value string) bool {
	return value == policyStatusActive || value == policyStatusDisabled
}

func normalizeSettlementMode(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", settlementModeDaily, "day":
		if strings.TrimSpace(value) == "" {
			return ""
		}
		return settlementModeDaily
	case settlementModeHalfMonth, "half-month", "semi_month":
		return settlementModeHalfMonth
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func normalizeSettlementModeFilter(value string) string {
	mode := normalizeSettlementMode(value)
	if !validSettlementMode(mode) {
		return ""
	}
	return mode
}

func validSettlementMode(value string) bool {
	return value == settlementModeDaily || value == settlementModeHalfMonth
}

func normalizeSettlementTriggerMode(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", settlementTriggerAutomatic, "auto":
		if strings.TrimSpace(value) == "" {
			return ""
		}
		return settlementTriggerAutomatic
	case settlementTriggerManual, "manual_settlement":
		return settlementTriggerManual
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func normalizeSettlementTriggerModeFilter(value string) string {
	mode := normalizeSettlementTriggerMode(value)
	if !validSettlementTriggerMode(mode) {
		return ""
	}
	return mode
}

func validSettlementTriggerMode(value string) bool {
	return value == settlementTriggerAutomatic || value == settlementTriggerManual
}
