package sevendaycheckin

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"time"

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

const (
	grantSource = "seven_day_checkin"
	grantReason = "seven_day_checkin"
)

// Repository 是七日签到的唯一持久化边界；连续天数和 claim 幂等都必须在 activity MySQL。
type Repository interface {
	GetSevenDayCheckInConfig(ctx context.Context) (domain.Config, bool, error)
	GetSevenDayCheckInRewardsForVersion(ctx context.Context, version int64) ([]domain.Reward, error)
	UpdateSevenDayCheckInConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
	GetSevenDayCheckInAccount(ctx context.Context, userID int64) (domain.Account, bool, error)
	GetSevenDayCheckInClaimByDay(ctx context.Context, userID int64, checkinDay string) (domain.Claim, bool, error)
	ListSevenDayCheckInClaimsForUserCycle(ctx context.Context, userID int64, cycleNo int64) ([]domain.Claim, error)
	PrepareSevenDayCheckInClaim(ctx context.Context, userID int64, commandID string, checkinDay string, yesterday string, nowMS int64) (domain.PrepareResult, error)
	MarkSevenDayCheckInClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error)
	MarkSevenDayCheckInClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
	ListSevenDayCheckInClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
}

// WalletClient 是签到奖励的唯一外部副作用；资源组展开和钱包资产入账由 wallet-service 保证原子。
type WalletClient interface {
	GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}

// Service 承载七日签到 App 查询、签到命令和后台配置管理用例。
type Service struct {
	repository Repository
	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, wallet WalletClient) *Service {
	return &Service{repository: repository, wallet: wallet, now: time.Now}
}

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

// GetStatus 只读配置、用户账户和本轮 claims，不扫描钱包流水或资源赠送记录。
func (s *Service) GetStatus(ctx context.Context, userID 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")
	}
	now := s.now()
	cycle := dailyCycleOf(now)
	base := domain.StatusResult{
		Reason:        domain.ReasonNotConfigured,
		CheckinDay:    cycle.Key,
		ServerTimeMS:  now.UnixMilli(),
		NextRefreshMS: cycle.NextRefreshMS,
	}
	config, exists, err := s.repository.GetSevenDayCheckInConfig(ctx)
	if err != nil {
		return domain.StatusResult{}, err
	}
	if !exists {
		return base, nil
	}

	account, accountExists, err := s.repository.GetSevenDayCheckInAccount(ctx, userID)
	if err != nil {
		return domain.StatusResult{}, err
	}
	todayClaim, todaySigned, err := s.repository.GetSevenDayCheckInClaimByDay(ctx, userID, cycle.Key)
	if err != nil {
		return domain.StatusResult{}, err
	}
	next := nextSlot(account, accountExists, config.ConfigVersion, cycle.Key, cycle.YesterdayKey)
	displayVersion := next.ConfigVersion
	displayCycle := next.CycleNo
	if todaySigned {
		displayVersion = todayClaim.ConfigVersion
		displayCycle = todayClaim.CycleNo
	}
	rewards, err := s.rewardsForVersion(ctx, config, displayVersion)
	if err != nil {
		return domain.StatusResult{}, err
	}
	claims, err := s.repository.ListSevenDayCheckInClaimsForUserCycle(ctx, userID, displayCycle)
	if err != nil {
		return domain.StatusResult{}, err
	}

	result := base
	result.Enabled = config.Enabled
	result.ConfigVersion = displayVersion
	result.CycleNo = displayCycle
	result.CurrentDayIndex = currentDayIndex(account, accountExists, cycle.Key, cycle.YesterdayKey)
	result.NextDayIndex = next.DayIndex
	result.LastCheckinDay = account.LastCheckinDay
	result.Rewards = rewardStatuses(rewards, claims, result.NextDayIndex)
	result.CanSign, result.Reason = signEligibility(config, exists, rewards, todayClaim, todaySigned)
	return result, nil
}

// Sign 创建或复用当天签到 claim，只有 wallet-service 资源组发放成功后才推进连续签到账号。
func (s *Service) Sign(ctx context.Context, userID int64, commandID string) (domain.Claim, error) {
	if err := s.requireRepository(); err != nil {
		return domain.Claim{}, err
	}
	commandID = strings.TrimSpace(commandID)
	if userID <= 0 || commandID == "" {
		return domain.Claim{}, xerr.New(xerr.InvalidArgument, "checkin command is incomplete")
	}
	if s.wallet == nil {
		return domain.Claim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	now := s.now()
	cycle := dailyCycleOf(now)
	prepared, err := s.repository.PrepareSevenDayCheckInClaim(ctx, userID, commandID, cycle.Key, cycle.YesterdayKey, now.UnixMilli())
	if err != nil {
		return domain.Claim{}, err
	}
	if !prepared.Prepared {
		if prepared.Claim.ClaimID != "" {
			return prepared.Claim, nil
		}
		return domain.Claim{}, checkinUnavailableError(prepared.Reason)
	}
	if prepared.Claim.Status == domain.StatusGranted {
		return prepared.Claim, nil
	}

	claim := prepared.Claim
	resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
		CommandId:      claim.WalletCommandID,
		AppCode:        appcode.FromContext(ctx),
		TargetUserId:   claim.UserID,
		GroupId:        claim.ResourceGroupID,
		Reason:         grantReason,
		OperatorUserId: claim.UserID,
		GrantSource:    grantSource,
	})
	if err != nil {
		// 账户不在失败分支推进；同一 command_id 重试会复用 claim 和 wallet_command_id。
		_ = s.repository.MarkSevenDayCheckInClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
		return domain.Claim{}, err
	}
	walletGrantID := ""
	if resp.GetGrant() != nil {
		walletGrantID = resp.GetGrant().GetGrantId()
	}
	if s.notices != nil {
		if noticeErr := s.notices.NotifyReward(ctx, rewardnotice.Command{
			Activity:        rewardnotice.SevenDayCheckIn,
			TargetUserID:    claim.UserID,
			ProducerEventID: claim.ClaimID,
			AggregateType:   "seven_day_checkin_claim",
			AggregateID:     claim.ClaimID,
			Grants:          []*walletv1.ResourceGrant{resp.GetGrant()},
			SentAtMS:        s.now().UnixMilli(),
		}); noticeErr != nil {
			_ = s.repository.MarkSevenDayCheckInClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(noticeErr), s.now().UnixMilli())
			return domain.Claim{}, noticeErr
		}
	}
	return s.repository.MarkSevenDayCheckInClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UnixMilli())
}

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.GetSevenDayCheckInConfig(ctx)
	if err != nil {
		return domain.Config{}, err
	}
	if !exists {
		return domain.Config{AppCode: appcode.FromContext(ctx), Rewards: defaultRewards()}, 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.Rewards = normalizeRewards(config.Rewards)
	if err := validateConfig(config); err != nil {
		return domain.Config{}, err
	}
	return s.repository.UpdateSevenDayCheckInConfig(ctx, config, s.now().UnixMilli())
}

func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
	if err := s.requireRepository(); err != nil {
		return nil, 0, err
	}
	query.Status = strings.TrimSpace(query.Status)
	query.CheckinDay = strings.TrimSpace(query.CheckinDay)
	if query.DayIndex < 0 || query.DayIndex > domain.DayCount {
		return nil, 0, xerr.New(xerr.InvalidArgument, "day_index is invalid")
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	return s.repository.ListSevenDayCheckInClaims(ctx, query)
}

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

func (s *Service) rewardsForVersion(ctx context.Context, config domain.Config, version int64) ([]domain.Reward, error) {
	if version == config.ConfigVersion {
		return config.Rewards, nil
	}
	rewards, err := s.repository.GetSevenDayCheckInRewardsForVersion(ctx, version)
	if err != nil {
		return nil, err
	}
	return rewards, nil
}

type dailyCycle struct {
	Key           string
	YesterdayKey  string
	NextRefreshMS int64
}

func dailyCycleOf(t time.Time) dailyCycle {
	utc := t.UTC()
	start := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
	return dailyCycle{
		Key:           start.Format("2006-01-02"),
		YesterdayKey:  start.AddDate(0, 0, -1).Format("2006-01-02"),
		NextRefreshMS: start.AddDate(0, 0, 1).UnixMilli(),
	}
}

type slot struct {
	CycleNo       int64
	DayIndex      int32
	ConfigVersion int64
}

func nextSlot(account domain.Account, exists bool, currentVersion int64, today string, yesterday string) slot {
	if !exists || account.UserID <= 0 {
		return slot{CycleNo: 1, DayIndex: 1, ConfigVersion: currentVersion}
	}
	if account.LastCheckinDay == today {
		return slot{CycleNo: account.CycleNo, DayIndex: account.LastDayIndex, ConfigVersion: account.ConfigVersion}
	}
	if account.LastCheckinDay == yesterday && account.LastDayIndex > 0 && account.LastDayIndex < domain.DayCount {
		return slot{CycleNo: account.CycleNo, DayIndex: account.LastDayIndex + 1, ConfigVersion: account.ConfigVersion}
	}
	nextCycle := account.CycleNo + 1
	if nextCycle <= 0 {
		nextCycle = 1
	}
	return slot{CycleNo: nextCycle, DayIndex: 1, ConfigVersion: currentVersion}
}

func currentDayIndex(account domain.Account, exists bool, today string, yesterday string) int32 {
	if !exists || account.UserID <= 0 {
		return 0
	}
	if account.LastCheckinDay == today || account.LastCheckinDay == yesterday {
		return account.LastDayIndex
	}
	return 0
}

func signEligibility(config domain.Config, exists bool, rewards []domain.Reward, todayClaim domain.Claim, todaySigned bool) (bool, string) {
	if !exists || len(rewards) != domain.DayCount {
		return false, domain.ReasonNotConfigured
	}
	if !config.Enabled {
		return false, domain.ReasonDisabled
	}
	if !validRewards(rewards) {
		return false, domain.ReasonInvalidConfig
	}
	if todaySigned {
		switch todayClaim.Status {
		case domain.StatusGranted:
			return false, domain.ReasonAlreadySigned
		case domain.StatusPending, domain.StatusFailed:
			return false, domain.ReasonPendingReward
		default:
			return false, domain.ReasonAlreadySigned
		}
	}
	return true, domain.ReasonEligible
}

func rewardStatuses(rewards []domain.Reward, claims []domain.Claim, nextDay int32) []domain.RewardStatus {
	claimsByDay := make(map[int32]domain.Claim, len(claims))
	for _, claim := range claims {
		claimsByDay[claim.DayIndex] = claim
	}
	items := make([]domain.RewardStatus, 0, len(rewards))
	for _, reward := range rewards {
		status := domain.RewardStatusLocked
		if reward.DayIndex == nextDay {
			status = domain.RewardStatusAvailable
		}
		item := domain.RewardStatus{Reward: reward, Status: status}
		if claim, ok := claimsByDay[reward.DayIndex]; ok {
			item.ClaimID = claim.ClaimID
			item.WalletGrantID = claim.WalletGrantID
			item.GrantedAtMS = claim.GrantedAtMS
			switch claim.Status {
			case domain.StatusGranted:
				item.Status = domain.RewardStatusGranted
			case domain.StatusPending:
				item.Status = domain.RewardStatusPending
			case domain.StatusFailed:
				item.Status = domain.RewardStatusFailed
			}
		}
		items = append(items, item)
	}
	sort.SliceStable(items, func(i, j int) bool { return items[i].DayIndex < items[j].DayIndex })
	return items
}

func normalizeRewards(rewards []domain.Reward) []domain.Reward {
	items := make([]domain.Reward, 0, len(rewards))
	for _, reward := range rewards {
		items = append(items, domain.Reward{DayIndex: reward.DayIndex, ResourceGroupID: reward.ResourceGroupID})
	}
	sort.SliceStable(items, func(i, j int) bool { return items[i].DayIndex < items[j].DayIndex })
	return items
}

func validateConfig(config domain.Config) error {
	if config.UpdatedByAdminID <= 0 {
		return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
	}
	if len(config.Rewards) != domain.DayCount {
		return xerr.New(xerr.InvalidArgument, "seven rewards are required")
	}
	seen := make(map[int32]struct{}, domain.DayCount)
	for _, reward := range config.Rewards {
		if reward.DayIndex < 1 || reward.DayIndex > domain.DayCount || reward.ResourceGroupID <= 0 {
			return xerr.New(xerr.InvalidArgument, "checkin reward is invalid")
		}
		if _, ok := seen[reward.DayIndex]; ok {
			return xerr.New(xerr.InvalidArgument, "checkin reward day is duplicated")
		}
		seen[reward.DayIndex] = struct{}{}
	}
	return nil
}

func validRewards(rewards []domain.Reward) bool {
	if len(rewards) != domain.DayCount {
		return false
	}
	seen := make(map[int32]struct{}, domain.DayCount)
	for _, reward := range rewards {
		if reward.DayIndex < 1 || reward.DayIndex > domain.DayCount || reward.ResourceGroupID <= 0 {
			return false
		}
		if _, ok := seen[reward.DayIndex]; ok {
			return false
		}
		seen[reward.DayIndex] = struct{}{}
	}
	return len(seen) == domain.DayCount
}

func checkinUnavailableError(reason string) error {
	switch reason {
	case domain.ReasonInvalidConfig:
		return xerr.New(xerr.InvalidArgument, "seven day checkin rewards are invalid")
	case domain.ReasonDisabled:
		return xerr.New(xerr.RuleNotActive, "seven day checkin is disabled")
	default:
		return xerr.New(xerr.RuleNotActive, "seven day checkin is not configured")
	}
}

func defaultRewards() []domain.Reward {
	rewards := make([]domain.Reward, 0, domain.DayCount)
	for day := int32(1); day <= domain.DayCount; day++ {
		rewards = append(rewards, domain.Reward{DayIndex: day})
	}
	return rewards
}

func WalletCommandID(claimID string) string {
	return fmt.Sprintf("wcheckin_%s", claimID)
}
