package roomturnoverreward

import (
	"context"
	"sort"
	"strconv"
	"strings"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
	messageservice "hyapp/services/activity-service/internal/service/message"
	"hyapp/services/activity-service/internal/service/rewardnotice"
)

const (
	rewardReason              = "room_turnover_reward"
	rewardNoticeProducer      = "room-turnover-reward"
	rewardNoticeEventType     = "room_turnover_reward_granted"
	rewardNoticeAggregateType = "room_turnover_reward_settlement"
)

// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
type Repository interface {
	GetRoomTurnoverRewardConfig(ctx context.Context, regionID int64) (domain.Config, bool, error)
	UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
	ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error)
	GetRoomTurnoverRewardProgress(ctx context.Context, regionID int64, roomID string, periodStartMS int64) (domain.Progress, bool, error)
	GetLatestRoomTurnoverRewardSettlement(ctx context.Context, regionID int64, roomID string) (domain.Settlement, bool, error)
	ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error)
	ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error)
	InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error)
	ListPendingRoomTurnoverRewardSettlements(ctx context.Context, limit int) ([]domain.Settlement, error)
	GetRoomTurnoverRewardSettlement(ctx context.Context, settlementID string) (domain.Settlement, bool, error)
	ResetRoomTurnoverRewardSettlementForRetry(ctx context.Context, settlementID string, ownerUserID int64, nowMS int64) (domain.Settlement, error)
	MarkRoomTurnoverRewardSettlementGranted(ctx context.Context, settlementID string, walletTransactionID string, grantedAtMS int64) (domain.Settlement, error)
	MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
}

// WalletClient 是房间流水奖励唯一外部发币副作用。
type WalletClient interface {
	CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
}

// NoticeService 是房间流水奖励发放后的用户站内信副作用；具体写入仍由 message service owner 完成。
type NoticeService interface {
	CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
}

// RoomQueryClient 只读取 room-service 的房主快照，不拥有房间状态。
type RoomQueryClient interface {
	AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
}

// Option 只注入可替换的外部副作用，避免发奖主流程在单测里依赖真实消息和房间客户端。
type Option func(*Service)

// WithNoticeService 连接系统消息公共函数；正金额发奖必须能写站内信，防止出现已发币但用户无通知的静默状态。
func WithNoticeService(notices NoticeService) Option {
	return func(s *Service) {
		s.notices = notices
	}
}

// WithRewardNotice connects the shared structured activity-message module used by production.
func WithRewardNotice(notices rewardnotice.Notifier) Option {
	return func(s *Service) {
		s.rewardNotices = notices
	}
}

// Service 承载房间贡献奖励 App 查询、后台配置、事件聚合和每周结算。
type Service struct {
	repository    Repository
	wallet        WalletClient
	room          RoomQueryClient
	notices       NoticeService
	rewardNotices rewardnotice.Notifier
	now           func() time.Time
}

func New(repository Repository, wallet WalletClient, room RoomQueryClient, options ...Option) *Service {
	service := &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
	for _, option := range options {
		option(service)
	}
	return service
}

func (s *Service) SetClock(now func() time.Time) {
	if now != nil {
		s.now = now
	}
}

func (s *Service) GetConfig(ctx context.Context, regionID int64) (domain.Config, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Config{}, err
	}
	if regionID < 0 {
		return domain.Config{}, xerr.New(xerr.InvalidArgument, "region_id must not be negative")
	}
	config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx, regionID)
	if err != nil {
		return domain.Config{}, err
	}
	if !exists {
		return domain.Config{AppCode: appcode.FromContext(ctx), RegionID: regionID}, nil
	}
	return config, nil
}

func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Config{}, err
	}
	config.AppCode = appcode.FromContext(ctx)
	config.Tiers = normalizeTiers(config.Tiers)
	if err := validateConfig(config); err != nil {
		return domain.Config{}, err
	}
	return s.repository.UpdateRoomTurnoverRewardConfig(ctx, config, s.now().UTC().UnixMilli())
}

func (s *Service) GetStatus(ctx context.Context, userID int64, regionID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.StatusResult{}, err
	}
	if userID <= 0 {
		return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	// App 展示和每周结算必须使用同一套 UTC 周窗口；这里用 service clock 统一可测试时间源，避免 H5、本地测试和 cron 看到不同周期。
	now := s.now().UTC()
	start, end := WeekWindow(now)
	result := domain.StatusResult{
		RoomID:        strings.TrimSpace(roomID),
		OwnerUserID:   ownerUserID,
		PeriodStartMS: start.UnixMilli(),
		PeriodEndMS:   end.UnixMilli(),
		ServerTimeMS:  now.UnixMilli(),
	}
	result.Config = domain.Config{AppCode: appcode.FromContext(ctx), RegionID: regionID}
	config, exists, err := s.effectiveConfig(ctx, regionID)
	if err != nil {
		return domain.StatusResult{}, err
	}
	if exists {
		config.Tiers = sortTiersForDisplay(config.Tiers)
		result.Config = config
	}
	if result.RoomID == "" {
		// 当前用户没有自己的房间时不能查询流水聚合；直接返回当前区域实际命中的配置和周期。
		return result, nil
	}
	progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, regionID, result.RoomID, result.PeriodStartMS)
	if err != nil {
		return domain.StatusResult{}, err
	}
	if found {
		result.CurrentCoinSpent = progress.CoinSpent
	}
	if tier, matched := MatchHighestTier(activeTiers(result.Config.Tiers), result.CurrentCoinSpent); matched {
		result.MatchedTier = tier
		result.ExpectedRewardCoinAmount = tier.RewardCoinAmount
	}
	if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, regionID, result.RoomID); err != nil {
		return domain.StatusResult{}, err
	} else if found {
		// 最近结算不限定当前周期；周切换后当前流水会清零，但 H5 仍要展示上一周期是否已经发奖或失败。
		result.LatestSettlement = settlement
	}
	return result, nil
}

// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应贡献周期房间贡献值。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (domain.EventResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.EventResult{}, err
	}
	if envelope == nil {
		return domain.EventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
	}
	if envelope.GetEventType() != "RoomGiftSent" {
		// activity-service 订阅的是 room outbox 大类事件；非送礼事实不参与流水奖励，直接标记 skipped，避免无意义重试。
		return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
	}
	var gift roomeventsv1.RoomGiftSent
	if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
		return domain.EventResult{}, err
	}
	if gift.GetIsRobotGift() {
		return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
	}
	contributionValue := gift.GetGiftValue()
	regionID := gift.GetVisibleRegionId()
	if strings.TrimSpace(envelope.GetRoomId()) == "" || contributionValue <= 0 {
		// 房间流水奖励只消费 wallet 结算后的 HeatValue，也就是 RoomGiftSent.gift_value；原始扣费 coin_spent 不再参与达标口径。
		return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
	}
	occurred := time.UnixMilli(envelope.GetOccurredAtMs()).UTC()
	if envelope.GetOccurredAtMs() <= 0 {
		// 老事件缺 occurred_at 时使用消费时间兜底，只影响本地兼容，正常 outbox 事件仍以事实发生时间归属周期。
		occurred = s.now().UTC()
	}
	start, end := WeekWindow(occurred)
	// AppCode 以事件信封为准，防止消费者默认上下文把不同 App 的房间流水写进同一组配置和聚合表。
	eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
	return s.repository.ConsumeRoomTurnoverGiftEvent(eventCtx, domain.RoomGiftEvent{
		EventID:      envelope.GetEventId(),
		EventType:    envelope.GetEventType(),
		AppCode:      envelope.GetAppCode(),
		RegionID:     regionID,
		RoomID:       strings.TrimSpace(envelope.GetRoomId()),
		CoinSpent:    contributionValue,
		OccurredAtMS: occurred.UnixMilli(),
	}, start.UnixMilli(), end.UnixMilli(), s.now().UTC().UnixMilli())
}

func (s *Service) ListSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) {
	if err := s.requireRepository(); err != nil {
		return nil, 0, err
	}
	query.Status = strings.TrimSpace(query.Status)
	query.RoomID = strings.TrimSpace(query.RoomID)
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	return s.repository.ListRoomTurnoverRewardSettlements(ctx, query)
}

// ProcessSettlementBatch 先为上一完整贡献周期补齐 settlement，再处理 pending 发币。
func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (domain.BatchResult, error) {
	if err := s.requireRepository(); err != nil {
		return domain.BatchResult{}, err
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	// 只结算上一个完整贡献周期；当前周期仍在持续接收 RoomGiftSent，不能提前冻结或清空。
	periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
	result := domain.BatchResult{}
	progresses, err := s.repository.ListUnsettledRoomTurnoverRewardProgress(ctx, periodStart.UnixMilli(), batchSize)
	if err != nil {
		return domain.BatchResult{}, err
	}
	nowMS := s.now().UTC().UnixMilli()
	configs := make(map[int64]domain.Config)
	for _, progress := range progresses {
		config, cached := configs[progress.RegionID]
		if !cached {
			var exists bool
			config, exists, err = s.effectiveConfig(ctx, progress.RegionID)
			if err != nil {
				return result, err
			}
			if !exists || !config.Enabled {
				// 扫描查询已只关联启用配置；这里再次校验用于收敛配置在扫描后被停用的并发窗口。
				continue
			}
			configs[progress.RegionID] = config
		}
		// settlement 先固化当周流水和命中档位快照，后续运营改配置不会改变已经进入结算队列的周期结果。
		settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS)
		if settlement.TierID <= 0 {
			// 有流水但未达档位也要落失败记录，后台可以解释为什么该房间本周期没有发奖。
			settlement.Status = domain.SettlementStatusFailed
			settlement.FailureReason = "tier_not_matched"
		}
		if settlement.TierID > 0 && settlement.OwnerUserID <= 0 {
			// 房主归属以结算时 room-service 快照为准；activity-service 不复制房间 owner 状态，只在需要发奖时同步读取。
			ownerID, ownerErr := s.fetchRoomOwner(ctx, progress.RoomID)
			if ownerErr != nil || ownerID <= 0 {
				settlement.Status = domain.SettlementStatusFailed
				settlement.FailureReason = "room_owner_not_found"
			} else {
				settlement.OwnerUserID = ownerID
			}
		}
		if _, created, err := s.repository.InsertRoomTurnoverRewardSettlement(ctx, settlement, nowMS); err != nil {
			return result, err
		} else if created {
			// Insert 使用 room_id + period_start_ms 唯一键兜底；重复 cron 只会读回旧记录，不会重复认领同一周期。
			result.ClaimedCount++
		}
	}
	remaining := batchSize - int(result.ClaimedCount)
	if remaining <= 0 {
		remaining = batchSize
	}
	pending, err := s.repository.ListPendingRoomTurnoverRewardSettlements(ctx, remaining)
	if err != nil {
		return result, err
	}
	for _, settlement := range pending {
		result.ProcessedCount++
		if _, err := s.grantSettlement(ctx, settlement); err != nil {
			// grantSettlement 已把失败原因写回 settlement；batch 继续处理后续房间，避免单房间阻塞整周结算。
			result.FailureCount++
			continue
		}
		result.SuccessCount++
	}
	result.HasMore = len(progresses) >= batchSize || len(pending) >= remaining
	return result, nil
}

func (s *Service) RetrySettlement(ctx context.Context, settlementID string) (domain.Settlement, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Settlement{}, err
	}
	settlementID = strings.TrimSpace(settlementID)
	if settlementID == "" {
		return domain.Settlement{}, xerr.New(xerr.InvalidArgument, "settlement_id is required")
	}
	settlement, exists, err := s.repository.GetRoomTurnoverRewardSettlement(ctx, settlementID)
	if err != nil {
		return domain.Settlement{}, err
	}
	if !exists {
		return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
	}
	if settlement.Status == domain.SettlementStatusGranted {
		// 已发奖记录直接返回，后台重复点击 retry 不会再次调用钱包。
		return settlement, nil
	}
	if settlement.TierID <= 0 {
		// tier_not_matched 不是外部依赖失败，而是该房间当周没有达到任何档位；retry 不能把这类 0 档位记录重置成 granted。
		return settlement, xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
	}
	ownerID := settlement.OwnerUserID
	if ownerID <= 0 {
		// 失败记录可能是当时没读到房主；retry 时重新取 room-service 快照，允许房间修复后继续发奖。
		ownerID, err = s.fetchRoomOwner(ctx, settlement.RoomID)
		if err != nil || ownerID <= 0 {
			_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, "room_owner_not_found", s.now().UTC().UnixMilli())
			return domain.Settlement{}, xerr.New(xerr.NotFound, "room owner not found")
		}
	}
	settlement, err = s.repository.ResetRoomTurnoverRewardSettlementForRetry(ctx, settlement.SettlementID, ownerID, s.now().UTC().UnixMilli())
	if err != nil {
		return domain.Settlement{}, err
	}
	return s.grantSettlement(ctx, settlement)
}

func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Progress, periodEndMS int64, tiers []domain.Tier, nowMS int64) domain.Settlement {
	tier, matched := MatchHighestTier(tiers, progress.CoinSpent)
	settlement := domain.Settlement{
		AppCode:       progress.AppCode,
		RegionID:      progress.RegionID,
		RoomID:        progress.RoomID,
		PeriodStartMS: progress.PeriodStartMS,
		PeriodEndMS:   periodEndMS,
		CoinSpent:     progress.CoinSpent,
		Status:        domain.SettlementStatusPending,
		CreatedAtMS:   nowMS,
		UpdatedAtMS:   nowMS,
	}
	if matched {
		// 结算记录保存档位快照；后续配置变更不反推历史奖励金额。
		settlement.TierID = tier.TierID
		settlement.TierCode = tier.TierCode
		settlement.ThresholdCoinSpent = tier.ThresholdCoinSpent
		settlement.RewardCoinAmount = tier.RewardCoinAmount
	}
	return settlement
}

func (s *Service) effectiveConfig(ctx context.Context, regionID int64) (domain.Config, bool, error) {
	// 具体区域配置优先；只要该区域已经存在配置（包括显式关闭），就不再回退全区域，避免全区域开关覆盖区域运营决策。
	config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx, regionID)
	if err != nil || exists || regionID == 0 {
		return config, exists, err
	}
	return s.repository.GetRoomTurnoverRewardConfig(ctx, 0)
}

func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) {
	if settlement.Status == domain.SettlementStatusGranted {
		// 发奖入口保持幂等，避免 cron 和人工 retry 同时进来时重复入账。
		return settlement, nil
	}
	if settlement.OwnerUserID <= 0 || settlement.RewardCoinAmount < 0 || settlement.WalletCommandID == "" {
		err := xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
		_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
		return domain.Settlement{}, err
	}
	if settlement.RewardCoinAmount == 0 {
		// 0 金币档位是合法配置，用于运营只记录达标但不发币的周期；钱包不接受 0 金额命令，所以这里直接收敛结算状态。
		nowMS := s.now().UTC().UnixMilli()
		return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
	}
	if s.rewardNotices == nil && s.notices == nil {
		err := xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured")
		_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
		return domain.Settlement{}, err
	}
	if s.wallet == nil {
		err := xerr.New(xerr.Unavailable, "wallet client is not configured")
		_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
		return domain.Settlement{}, err
	}
	// 钱包 command_id 使用 app + 区域 + 周期 + room_id；房间同周迁区时两份区域流水独立结算，cron 重跑和 retry 仍保持幂等。
	resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{
		CommandId:     settlement.WalletCommandID,
		AppCode:       appcode.FromContext(ctx),
		TargetUserId:  settlement.OwnerUserID,
		Amount:        settlement.RewardCoinAmount,
		SettlementId:  settlement.SettlementID,
		RoomId:        settlement.RoomID,
		PeriodStartMs: settlement.PeriodStartMS,
		PeriodEndMs:   settlement.PeriodEndMS,
		CoinSpent:     settlement.CoinSpent,
		TierId:        settlement.TierID,
		TierCode:      settlement.TierCode,
		Reason:        rewardReason,
	})
	if err != nil {
		_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
		return domain.Settlement{}, err
	}
	// 系统消息用 settlement_id 做生产者幂等键；钱包成功但消息写入失败时，settlement 仍保持可 retry，下一次会用同一钱包 command_id 和同一消息键补齐。
	if err := s.createRewardGrantedNotice(ctx, settlement, resp.GetTransactionId(), resp.GetGrantedAtMs()); err != nil {
		_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
		return domain.Settlement{}, err
	}
	return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}

func (s *Service) createRewardGrantedNotice(ctx context.Context, settlement domain.Settlement, walletTransactionID string, grantedAtMS int64) error {
	if s.rewardNotices == nil && s.notices == nil {
		return xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured")
	}
	if grantedAtMS <= 0 {
		grantedAtMS = s.now().UTC().UnixMilli()
	}
	metadata := map[string]any{
		"settlement_id":         settlement.SettlementID,
		"room_id":               settlement.RoomID,
		"period_start_ms":       settlement.PeriodStartMS,
		"period_end_ms":         settlement.PeriodEndMS,
		"coin_spent":            settlement.CoinSpent,
		"reward_coin_amount":    settlement.RewardCoinAmount,
		"tier_id":               settlement.TierID,
		"tier_code":             settlement.TierCode,
		"wallet_transaction_id": walletTransactionID,
	}
	if s.rewardNotices != nil {
		return s.rewardNotices.NotifyReward(ctx, rewardnotice.Command{
			Activity:        rewardnotice.RoomTurnover,
			TargetUserID:    settlement.OwnerUserID,
			ProducerEventID: settlement.SettlementID,
			AggregateType:   rewardNoticeAggregateType,
			AggregateID:     settlement.SettlementID,
			CoinAmount:      settlement.RewardCoinAmount,
			SentAtMS:        grantedAtMS,
			Metadata:        metadata,
		})
	}
	// Legacy injection remains only for isolated older tests; production always supplies rewardNotices.
	summary := roomTurnoverRewardNoticeSummary(settlement.RewardCoinAmount)
	_, err := s.notices.CreateSystemNotice(ctx, messageservice.NoticeCommand{
		TargetUserID:      settlement.OwnerUserID,
		Producer:          rewardNoticeProducer,
		ProducerEventID:   settlement.SettlementID,
		ProducerEventType: rewardNoticeEventType,
		AggregateType:     rewardNoticeAggregateType,
		AggregateID:       settlement.SettlementID,
		Title:             "Room turnover reward",
		Summary:           summary,
		Body:              summary,
		SentAtMS:          grantedAtMS,
		Metadata:          metadata,
	})
	return err
}

func roomTurnoverRewardNoticeSummary(amount int64) string {
	return "Congratulations! You earned " + strconv.FormatInt(amount, 10) + " coins from this week's room turnover reward."
}

func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
	if s.room == nil {
		return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
	}
	roomID = strings.TrimSpace(roomID)
	if roomID == "" {
		return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
	}
	resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
		Meta: &roomv1.RequestMeta{
			// 这里不是用户请求链路，使用可读的内部 request_id 方便按房间追踪结算时的 owner 查询。
			RequestId: "room-turnover-reward-owner:" + roomID,
			AppCode:   appcode.FromContext(ctx),
			SentAtMs:  s.now().UTC().UnixMilli(),
		},
		RoomId: roomID,
	})
	if err != nil {
		return 0, err
	}
	return resp.GetRoom().GetOwnerUserId(), nil
}

func (s *Service) requireRepository() error {
	if s == nil || s.repository == nil {
		return xerr.New(xerr.Unavailable, "room turnover reward repository is not configured")
	}
	return nil
}

func normalizeTiers(tiers []domain.Tier) []domain.Tier {
	items := make([]domain.Tier, 0, len(tiers))
	for _, tier := range tiers {
		status := strings.ToLower(strings.TrimSpace(tier.Status))
		if status == "" {
			// 后台表单可以省略 status；默认 active 保持配置体验简单，但 validate 仍会拦截非法状态。
			status = domain.TierStatusActive
		}
		items = append(items, domain.Tier{
			TierID:             tier.TierID,
			TierCode:           strings.TrimSpace(tier.TierCode),
			TierName:           strings.TrimSpace(tier.TierName),
			ThresholdCoinSpent: tier.ThresholdCoinSpent,
			RewardCoinAmount:   tier.RewardCoinAmount,
			Status:             status,
			SortOrder:          tier.SortOrder,
		})
	}
	return sortTiersForDisplay(items)
}

func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
	items := append([]domain.Tier(nil), tiers...)
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].SortOrder == items[j].SortOrder {
			return items[i].ThresholdCoinSpent < items[j].ThresholdCoinSpent
		}
		return items[i].SortOrder > items[j].SortOrder
	})
	return items
}

func validateConfig(config domain.Config) error {
	if config.RegionID < 0 {
		// region_id=0 是全区域默认配置；正数是覆盖该区域的配置。
		return xerr.New(xerr.InvalidArgument, "region_id must not be negative")
	}
	if config.UpdatedByAdminID <= 0 {
		return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
	}
	// tier_code 是后台配置稳定标识，阈值和奖励可以修改，但同一 App 内不能出现两个相同编码。
	seen := make(map[string]struct{}, len(config.Tiers))
	activeCount := 0
	for _, tier := range config.Tiers {
		if tier.TierCode == "" {
			return xerr.New(xerr.InvalidArgument, "tier_code is required")
		}
		if _, exists := seen[tier.TierCode]; exists {
			return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
		}
		seen[tier.TierCode] = struct{}{}
		if tier.TierName == "" {
			return xerr.New(xerr.InvalidArgument, "tier_name is required")
		}
		if tier.ThresholdCoinSpent <= 0 {
			return xerr.New(xerr.InvalidArgument, "threshold_coin_spent must be greater than zero")
		}
		if tier.RewardCoinAmount < 0 {
			return xerr.New(xerr.InvalidArgument, "reward_coin_amount must not be negative")
		}
		switch tier.Status {
		case domain.TierStatusActive:
			activeCount++
		case domain.TierStatusInactive:
		default:
			return xerr.New(xerr.InvalidArgument, "tier status is invalid")
		}
	}
	if config.Enabled && activeCount == 0 {
		// 开启活动但没有有效档位会让 H5 显示可参与却永远无法发奖，直接在配置入口拒绝。
		return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
	}
	return nil
}

func activeTiers(tiers []domain.Tier) []domain.Tier {
	out := make([]domain.Tier, 0, len(tiers))
	for _, tier := range tiers {
		if tier.Status == domain.TierStatusActive {
			out = append(out, tier)
		}
	}
	return out
}

// MatchHighestTier 返回达到流水阈值的最高有效档。
func MatchHighestTier(tiers []domain.Tier, coinSpent int64) (domain.Tier, bool) {
	var matched domain.Tier
	found := false
	for _, tier := range tiers {
		if tier.Status != domain.TierStatusActive || tier.ThresholdCoinSpent <= 0 || tier.RewardCoinAmount < 0 {
			continue
		}
		if coinSpent >= tier.ThresholdCoinSpent && (!found || tier.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
			matched = tier
			found = true
		}
	}
	return matched, found
}

// WeekWindow 返回给定时间所在 UTC 周一 01:00 到下一周一 01:00 的左闭右开窗口。
func WeekWindow(now time.Time) (time.Time, time.Time) {
	utc := now.UTC()
	dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
	// 贡献周期比 UTC 0 点延后一小时切换，确保 00:00 后的结算任务仍能稳定处理上一周期榜单。
	if utc.Before(dayStart.Add(time.Hour)) {
		dayStart = dayStart.AddDate(0, 0, -1)
	}
	weekday := int(dayStart.Weekday())
	if weekday == 0 {
		weekday = 7
	}
	start := dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour)
	return start, start.AddDate(0, 0, 7)
}

// PreviousWeekWindow 返回当前时间之前的完整 UTC 周期。
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
	start, _ := WeekWindow(now)
	previousStart := start.AddDate(0, 0, -7)
	return previousStart, start
}
