package cpweeklyrank

import (
	"context"
	"fmt"
	"log/slog"
	"sort"
	"strings"
	"time"

	"google.golang.org/grpc"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/logx"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/cpweeklyrank"
	"hyapp/services/activity-service/internal/service/rewardnotice"
)

const cpWeeklyRankRewardReason = "cp weekly rank reward"

// Repository is the activity-service storage boundary for CP weekly rank config and settlement facts.
type Repository interface {
	GetCPWeeklyRankConfig(ctx context.Context) (domain.Config, bool, error)
	UpdateCPWeeklyRankConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
	ListCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error)
	PrepareCPWeeklyRankSettlements(ctx context.Context, config domain.Config, entries []domain.Entry, periodStartMS int64, periodEndMS int64, nowMS int64) ([]domain.Settlement, error)
	ListPendingCPWeeklyRankSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64, limit int) ([]domain.Settlement, error)
	MarkCPWeeklyRankSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error
	MarkCPWeeklyRankSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
}

// RankSource reads user-service owned CP weekly score snapshots; activity-service never scans user DB directly.
type RankSource interface {
	ListCPWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]domain.Entry, error)
}

// WalletClient owns the external resource-group grant side effect.
type WalletClient interface {
	GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}

// Service owns CP weekly rank config, H5 reads and weekly reward settlement.
type Service struct {
	repository Repository
	rankSource RankSource
	wallet     WalletClient
	notices    rewardnotice.Notifier
	now        func() time.Time
}

func (s *Service) BindRewardNotice(notices rewardnotice.Notifier) {
	if s != nil {
		s.notices = notices
	}
}

func New(repository Repository, rankSource RankSource, wallet WalletClient) *Service {
	return &Service{repository: repository, rankSource: rankSource, wallet: wallet, now: time.Now}
}

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

func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Config{}, err
	}
	config, exists, err := s.repository.GetCPWeeklyRankConfig(ctx)
	if err != nil {
		return domain.Config{}, err
	}
	if !exists {
		return defaultConfig(ctx), nil
	}
	return normalizeConfigForRead(config), nil
}

func (s *Service) UpdateConfig(ctx context.Context, config domain.Config, operatorAdminID int64) (domain.Config, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Config{}, err
	}
	if operatorAdminID <= 0 {
		return domain.Config{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
	}
	config.AppCode = appcode.FromContext(ctx)
	config.ActivityCode = domain.ActivityCode
	config.RelationType = "cp"
	config.UpdatedByAdminID = operatorAdminID
	config = normalizeConfigForWrite(config)
	if err := validateConfig(config); err != nil {
		return domain.Config{}, err
	}
	return s.repository.UpdateCPWeeklyRankConfig(ctx, config, s.now().UTC().UnixMilli())
}

func (s *Service) GetStatus(ctx context.Context, limit int32, nowMS int64) (domain.Status, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Status{}, err
	}
	if s.rankSource == nil {
		return domain.Status{}, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured")
	}
	now := s.now().UTC()
	if nowMS > 0 {
		now = time.UnixMilli(nowMS).UTC()
	}
	start, end := WeekWindow(now)
	config, err := s.GetConfig(ctx)
	if err != nil {
		return domain.Status{}, err
	}
	result := domain.Status{
		Config:        config,
		PeriodStartMS: start.UnixMilli(),
		PeriodEndMS:   end.UnixMilli(),
		ServerTimeMS:  now.UnixMilli(),
	}
	if !config.Enabled {
		return result, nil
	}
	if limit <= 0 || limit > config.TopCount {
		limit = config.TopCount
	}
	if limit <= 0 {
		limit = 3
	}
	// H5 同时需要当前周期榜单和上一完整 UTC 周期的前三名；两个窗口都从 user-service 的周期新增 gift_value 聚合读取，避免 gateway/H5 误用累计亲密榜。
	entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, result.PeriodStartMS, result.PeriodEndMS, limit)
	if err != nil {
		return domain.Status{}, err
	}
	result.Entries = entries
	previousStart, previousEnd := PreviousWeekWindow(now)
	previousLimit := int32(3)
	previousEntries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, previousStart.UnixMilli(), previousEnd.UnixMilli(), previousLimit)
	if err != nil {
		return domain.Status{}, err
	}
	result.PreviousEntries = previousEntries
	return result, nil
}

func (s *Service) ListSettlements(ctx context.Context, periodStartMS int64, periodEndMS int64) ([]domain.Settlement, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	return s.repository.ListCPWeeklyRankSettlements(ctx, periodStartMS, periodEndMS)
}

// ProcessSettlementBatch freezes the previous complete UTC week and grants each Top relationship reward to both users.
// The rank source already returns weekly delta score, so settlement only persists that frozen score and never re-reads cumulative intimacy.
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
	if err := s.requireRepository(); err != nil {
		return 0, 0, 0, 0, false, err
	}
	if s.rankSource == nil {
		return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "cp weekly rank source is not configured")
	}
	if s.wallet == nil {
		return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	if batchSize <= 0 {
		batchSize = 50
	}
	config, err := s.GetConfig(ctx)
	if err != nil {
		return 0, 0, 0, 0, false, err
	}
	if !config.Enabled || len(config.Rewards) == 0 {
		return 0, 0, 0, 0, false, nil
	}
	periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
	entries, err := s.rankSource.ListCPWeeklyRankEntries(ctx, config.RelationType, periodStart.UnixMilli(), periodEnd.UnixMilli(), config.TopCount)
	if err != nil {
		return 0, 0, 0, 0, false, err
	}
	settlements, err := s.repository.PrepareCPWeeklyRankSettlements(ctx, config, entries, periodStart.UnixMilli(), periodEnd.UnixMilli(), s.now().UTC().UnixMilli())
	if err != nil {
		return 0, 0, 0, 0, false, err
	}
	claimed = int32(len(settlements))
	pending, err := s.repository.ListPendingCPWeeklyRankSettlements(ctx, periodStart.UnixMilli(), periodEnd.UnixMilli(), int(batchSize))
	if err != nil {
		return claimed, 0, 0, 0, false, err
	}
	hasMore = len(pending) >= int(batchSize)
	for _, settlement := range pending {
		processed++
		// activity-service 只决定 CP 周榜获奖关系和资源组，wallet-service 负责展开资源组和写 grant 流水。
		// 失败时保持同一个 wallet_command_id 回到 failed，下一轮 cron 继续重试，防止网络抖动造成重复发奖。
		resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
			CommandId:      settlement.WalletCommandID,
			AppCode:        appcode.FromContext(ctx),
			TargetUserId:   settlement.UserID,
			GroupId:        settlement.ResourceGroupID,
			Reason:         cpWeeklyRankRewardReason,
			OperatorUserId: settlement.UserID,
			GrantSource:    domain.GrantSourceCPWeeklyRank,
		})
		if grantErr != nil {
			logx.Error(ctx, "cp_weekly_rank_grant_resource_group_failed", grantErr, slog.String("settlement_id", settlement.SettlementID), slog.String("run_id", runID), slog.String("worker_id", workerID))
			failure++
			_ = s.repository.MarkCPWeeklyRankSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(grantErr), s.now().UTC().UnixMilli())
			continue
		}
		walletGrantID := ""
		if resp.GetGrant() != nil {
			walletGrantID = resp.GetGrant().GetGrantId()
		}
		if s.notices != nil {
			if noticeErr := s.notices.NotifyReward(ctx, rewardnotice.Command{
				Activity:        rewardnotice.CPRanking,
				TargetUserID:    settlement.UserID,
				ProducerEventID: settlement.SettlementID,
				AggregateType:   "cp_weekly_rank_settlement",
				AggregateID:     settlement.SettlementID,
				Grants:          []*walletv1.ResourceGrant{resp.GetGrant()},
				SentAtMS:        s.now().UTC().UnixMilli(),
			}); noticeErr != nil {
				logx.Error(ctx, "cp_weekly_rank_create_reward_notice_failed", noticeErr, slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID))
				failure++
				_ = s.repository.MarkCPWeeklyRankSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(noticeErr), s.now().UTC().UnixMilli())
				continue
			}
		}
		if err := s.repository.MarkCPWeeklyRankSettlementGranted(ctx, settlement.SettlementID, walletGrantID, s.now().UTC().UnixMilli()); err != nil {
			logx.Error(ctx, "cp_weekly_rank_mark_granted_failed", err, slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID))
			failure++
			continue
		}
		success++
	}
	return claimed, processed, success, failure, hasMore, nil
}

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

func defaultConfig(ctx context.Context) domain.Config {
	return domain.Config{
		AppCode:      appcode.FromContext(ctx),
		ActivityCode: domain.ActivityCode,
		Enabled:      true,
		RelationType: "cp",
		TopCount:     3,
	}
}

func normalizeConfigForRead(config domain.Config) domain.Config {
	config.ActivityCode = domain.ActivityCode
	config.RelationType = "cp"
	if config.TopCount <= 0 {
		config.TopCount = 3
	}
	config.Rewards = normalizeRewards(config.Rewards, config.TopCount)
	return config
}

func normalizeConfigForWrite(config domain.Config) domain.Config {
	if config.TopCount <= 0 {
		config.TopCount = 3
	}
	if config.TopCount > 20 {
		config.TopCount = 20
	}
	config.Rewards = normalizeRewards(config.Rewards, config.TopCount)
	return config
}

func normalizeRewards(rewards []domain.Reward, topCount int32) []domain.Reward {
	seen := make(map[int32]domain.Reward, len(rewards))
	for _, reward := range rewards {
		if reward.RankNo <= 0 || reward.RankNo > topCount || reward.ResourceGroupID <= 0 {
			continue
		}
		reward.RankNo = int32(reward.RankNo)
		seen[reward.RankNo] = reward
	}
	normalized := make([]domain.Reward, 0, len(seen))
	for _, reward := range seen {
		normalized = append(normalized, reward)
	}
	sort.Slice(normalized, func(i, j int) bool { return normalized[i].RankNo < normalized[j].RankNo })
	return normalized
}

func validateConfig(config domain.Config) error {
	if config.Enabled && len(config.Rewards) == 0 {
		return xerr.New(xerr.InvalidArgument, "cp weekly rank rewards are required when enabled")
	}
	for _, reward := range config.Rewards {
		if reward.RankNo <= 0 || reward.RankNo > config.TopCount {
			return xerr.New(xerr.InvalidArgument, "cp weekly rank reward rank_no is invalid")
		}
		if reward.ResourceGroupID <= 0 {
			return xerr.New(xerr.InvalidArgument, "cp weekly rank reward resource_group_id is required")
		}
	}
	return nil
}

// WeekWindow returns the current UTC week [Monday 00:00, next Monday 00:00).
func WeekWindow(now time.Time) (time.Time, time.Time) {
	dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
	weekday := int(dayStart.Weekday())
	if weekday == 0 {
		weekday = 7
	}
	start := dayStart.AddDate(0, 0, 1-weekday)
	return start, start.AddDate(0, 0, 7)
}

// PreviousWeekWindow returns the complete UTC week immediately before now.
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
	currentStart, _ := WeekWindow(now)
	previousStart := currentStart.AddDate(0, 0, -7)
	return previousStart, currentStart
}

func WalletCommandID(periodStartMS int64, rankNo int32, relationshipID string, userID int64) string {
	return fmt.Sprintf("cp_weekly_rank:%d:%d:%s:%d", periodStartMS, rankNo, strings.TrimSpace(relationshipID), userID)
}
