package task

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"sort"
	"strconv"
	"strings"
	"time"

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

// Repository 是任务系统的唯一持久化边界；进度、领奖和事件幂等都必须落 activity MySQL。
type Repository interface {
	ListVisibleDefinitions(ctx context.Context, nowMS int64) ([]taskdomain.Definition, error)
	ListUserProgress(ctx context.Context, userID int64, cycleKeys []string) ([]taskdomain.Progress, error)
	ConsumeTaskEvent(ctx context.Context, event taskdomain.Event, cycleKey string, nowMS int64) (taskdomain.EventResult, error)
	ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error)
	UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error)
	SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error)
	PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error)
	MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, walletResourceGrantID string, grantedAtMS int64) (taskdomain.Claim, error)
	MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
}

// WalletClient 是 activity-service 发放金币奖励时依赖的钱包入账接口。
type WalletClient interface {
	CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
}

// ResourceWalletClient 单独声明资源目录和赠送能力，保留只测试金币链路的轻量 fake。
// 生产 wallet gRPC client 同时实现两个接口；资源任务在缺少该能力时明确失败，不能退化为金币入账。
type ResourceWalletClient interface {
	GetResource(ctx context.Context, req *walletv1.GetResourceRequest, opts ...grpc.CallOption) (*walletv1.GetResourceResponse, error)
	GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error)
	PinResourceGroupSnapshot(ctx context.Context, req *walletv1.PinResourceGroupSnapshotRequest, opts ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error)
	GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
	GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
	GrantPinnedResourceGroup(ctx context.Context, req *walletv1.GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}

// Service 承载任务查询、事件消费、领奖和后台配置用例。
type Service struct {
	repository     Repository
	wallet         WalletClient
	resourceWallet ResourceWalletClient
	notices        rewardnotice.Notifier
	now            func() time.Time
}

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

// New 创建任务服务；daily 周期固定按 UTC 自然日切分，不读取机器或客户端时区。
func New(repository Repository, wallet WalletClient) *Service {
	service := &Service{repository: repository, wallet: wallet, now: time.Now}
	// 生成的 WalletServiceClient 具备完整资源能力；拆分类型断言只为让纯资产测试和降级配置保持最小依赖。
	service.resourceWallet, _ = wallet.(ResourceWalletClient)
	return service
}

// SetClock 给测试注入稳定时间；生产路径始终使用 time.Now。
func (s *Service) SetClock(now func() time.Time) {
	if now != nil {
		s.now = now
	}
}

// ListUserTasks 只读 task_definitions + user_task_progress，不实时扫描钱包或房间事件。
func (s *Service) ListUserTasks(ctx context.Context, userID int64) (taskdomain.ListResult, error) {
	if err := s.requireRepository(); err != nil {
		return taskdomain.ListResult{}, err
	}
	if userID <= 0 {
		return taskdomain.ListResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	now := s.now()
	nowMS := now.UnixMilli()
	cycle := s.dailyCycle(now)
	definitions, err := s.repository.ListVisibleDefinitions(ctx, nowMS)
	if err != nil {
		return taskdomain.ListResult{}, err
	}
	progresses, err := s.repository.ListUserProgress(ctx, userID, []string{cycle.Key, taskdomain.CycleLifetime})
	if err != nil {
		return taskdomain.ListResult{}, err
	}
	progressByKey := make(map[string]taskdomain.Progress, len(progresses))
	for _, progress := range progresses {
		progressByKey[progressKey(progress.TaskID, progress.CycleKey)] = progress
	}

	daily := taskdomain.Section{Section: taskdomain.SectionDaily, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS}
	exclusive := taskdomain.Section{Section: taskdomain.SectionExclusive, ServerTimeMS: nowMS, NextRefreshAtMS: cycle.NextRefreshMS}
	for _, definition := range definitions {
		switch definition.TaskType {
		case taskdomain.TypeDaily:
			daily.Items = append(daily.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, cycle.Key)], cycle.Key, nowMS, cycle.NextRefreshMS))
		case taskdomain.TypeExclusive:
			exclusive.Items = append(exclusive.Items, s.itemFromDefinition(definition, progressByKey[progressKey(definition.TaskID, taskdomain.CycleLifetime)], taskdomain.CycleLifetime, nowMS, cycle.NextRefreshMS))
		}
	}
	sortTaskItems(daily.Items)
	sortTaskItems(exclusive.Items)
	return taskdomain.ListResult{
		Sections: []taskdomain.Section{
			daily,
			exclusive,
		},
		ServerTimeMS:    nowMS,
		NextRefreshAtMS: cycle.NextRefreshMS,
	}, nil
}

// ClaimTaskReward 校验 activity 侧完成状态后，使用 wallet-service 幂等命令发放任务奖励资产。
func (s *Service) ClaimTaskReward(ctx context.Context, command taskdomain.ClaimCommand) (taskdomain.Claim, error) {
	if err := s.requireRepository(); err != nil {
		return taskdomain.Claim{}, err
	}
	command.TaskID = strings.TrimSpace(command.TaskID)
	command.TaskType = normalizeTaskType(command.TaskType)
	command.CommandID = strings.TrimSpace(command.CommandID)
	if command.UserID <= 0 || command.TaskID == "" || command.CommandID == "" {
		return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "claim command is incomplete")
	}
	cycle := s.dailyCycle(s.now())
	switch command.TaskType {
	case taskdomain.TypeDaily:
		if command.CycleKey != cycle.Key {
			return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "daily task can only claim current task_day")
		}
	case taskdomain.TypeExclusive:
		if command.CycleKey == "" {
			command.CycleKey = taskdomain.CycleLifetime
		}
		if command.CycleKey != taskdomain.CycleLifetime {
			return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "exclusive task cycle_key must be lifetime")
		}
	default:
		return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "task_type is invalid")
	}

	nowMS := s.now().UnixMilli()
	claim, err := s.repository.PrepareTaskClaim(ctx, command, nowMS)
	if err != nil {
		return taskdomain.Claim{}, err
	}
	if claim.Status == taskdomain.ClaimStatusGranted {
		return claim, nil
	}
	reason := "daily_task_reward"
	if command.TaskType == taskdomain.TypeExclusive {
		reason = "exclusive_task_reward"
	}
	walletTransactionID, walletResourceGrantID, grantedAtMS, err := s.grantClaimReward(ctx, claim, reason)
	if err != nil {
		// 钱包资产或资源发放失败时都不能把任务进度改成 claimed；同一 command_id 重试会复用 claim 和 wallet_command_id。
		_ = s.repository.MarkTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
		return taskdomain.Claim{}, err
	}
	if grantedAtMS <= 0 {
		grantedAtMS = s.now().UnixMilli()
	}
	if command.TaskType == taskdomain.TypeDaily && s.notices != nil {
		noticeCommand := rewardnotice.Command{
			Activity:        rewardnotice.DailyTask,
			TargetUserID:    claim.UserID,
			ProducerEventID: claim.ClaimID,
			AggregateType:   "daily_task_claim",
			AggregateID:     claim.ClaimID,
			SentAtMS:        grantedAtMS,
		}
		if walletResourceGrantID != "" {
			noticeCommand.GrantIDs = []string{walletResourceGrantID}
		} else {
			noticeCommand.CoinAmount = claim.RewardCoinAmount
		}
		if noticeErr := s.notices.NotifyReward(ctx, noticeCommand); noticeErr != nil {
			_ = s.repository.MarkTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(noticeErr), s.now().UnixMilli())
			return taskdomain.Claim{}, noticeErr
		}
	}
	return s.repository.MarkTaskClaimGranted(ctx, claim.ClaimID, walletTransactionID, walletResourceGrantID, grantedAtMS)
}

func (s *Service) grantClaimReward(ctx context.Context, claim taskdomain.Claim, reason string) (string, string, int64, error) {
	switch normalizeRewardType(claim.RewardType) {
	case taskdomain.RewardTypeWalletAsset:
		if s.wallet == nil {
			return "", "", 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
		}
		resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
			CommandId:       claim.WalletCommandID,
			AppCode:         appcode.FromContext(ctx),
			TargetUserId:    claim.UserID,
			Amount:          claim.RewardCoinAmount,
			RewardAssetType: claim.RewardAssetType,
			TaskType:        claim.TaskType,
			TaskId:          claim.TaskID,
			CycleKey:        claim.CycleKey,
			Reason:          reason,
		})
		if err != nil {
			return "", "", 0, err
		}
		return resp.GetTransactionId(), "", resp.GetGrantedAtMs(), nil
	case taskdomain.RewardTypeResource:
		if s.resourceWallet == nil {
			return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
		}
		resp, err := s.resourceWallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
			CommandId:      claim.WalletCommandID,
			AppCode:        appcode.FromContext(ctx),
			TargetUserId:   claim.UserID,
			ResourceId:     claim.RewardResourceID,
			Quantity:       claim.RewardQuantity,
			DurationMs:     claim.RewardDurationMS,
			Reason:         reason,
			OperatorUserId: claim.UserID,
			GrantSource:    "daily_task",
		})
		if err != nil {
			return "", "", 0, err
		}
		return resourceGrantReceipt(resp)
	case taskdomain.RewardTypeResourceGroup:
		if s.resourceWallet == nil {
			return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
		}
		snapshotID := strings.TrimSpace(claim.RewardSnapshotID)
		snapshotHash := strings.ToLower(strings.TrimSpace(claim.RewardSnapshotHash))
		if snapshotID == "" && snapshotHash == "" {
			// 兼容 016 上线前已产生进度的短暂历史数据：旧记录没有 owner 快照引用，只能按当时的 group_id 走原有赠送。
			// 新配置一律在保存时 pin，迁移默认空值不能伪造快照；该分支可在确认旧进度清空后再下线。
			resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
				CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID,
				GroupId: claim.RewardResourceGroupID, Reason: reason, OperatorUserId: claim.UserID, GrantSource: "daily_task",
			})
			if err != nil {
				return "", "", 0, err
			}
			return resourceGrantReceipt(resp)
		}
		if snapshotID == "" || !validRewardSnapshotHash(snapshotHash) {
			return "", "", 0, xerr.New(xerr.Conflict, "task resource group snapshot is missing")
		}
		// 资源组可能在任务完成后被运营编辑；领取必须使用进度中固化的 owner 快照，
		// 不能回读当前 group_id，否则 H5 已展示的奖励与实际到账资源会发生漂移。
		resp, err := s.resourceWallet.GrantPinnedResourceGroup(ctx, &walletv1.GrantPinnedResourceGroupRequest{
			CommandId:            claim.WalletCommandID,
			AppCode:              appcode.FromContext(ctx),
			TargetUserId:         claim.UserID,
			SnapshotId:           snapshotID,
			Reason:               reason,
			OperatorUserId:       claim.UserID,
			GrantSource:          "daily_task",
			ExpectedSnapshotHash: snapshotHash,
		})
		if err != nil {
			return "", "", 0, err
		}
		return resourceGrantReceipt(resp)
	default:
		return "", "", 0, xerr.New(xerr.InvalidArgument, "task reward_type is invalid")
	}
}

func resourceGrantReceipt(resp *walletv1.ResourceGrantResponse) (string, string, int64, error) {
	if resp == nil || resp.GetGrant() == nil || strings.TrimSpace(resp.GetGrant().GetGrantId()) == "" {
		return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource grant receipt is empty")
	}
	grant := resp.GetGrant()
	grantedAtMS := grant.GetUpdatedAtMs()
	if grantedAtMS <= 0 {
		grantedAtMS = grant.GetCreatedAtMs()
	}
	return "", grant.GetGrantId(), grantedAtMS, nil
}

// ConsumeTaskEvent 把服务端事实事件归属到 occurred_at_ms 所在的任务周期。
func (s *Service) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event) (taskdomain.EventResult, error) {
	if err := s.requireRepository(); err != nil {
		return taskdomain.EventResult{}, err
	}
	// 入口层只接受服务端事实事件，先统一裁剪字符串，避免上游空格导致同一个指标或事件 ID 被拆成两类。
	event.EventID = strings.TrimSpace(event.EventID)
	event.EventType = strings.TrimSpace(event.EventType)
	event.SourceService = strings.TrimSpace(event.SourceService)
	event.MetricType = strings.TrimSpace(event.MetricType)
	// dimensions_json 是后台维度过滤的唯一匹配依据；这里强制为对象 JSON，避免数组/字符串透传后让仓储层匹配语义变得不确定。
	dimensionsJSON, err := normalizeObjectJSON(event.DimensionsJSON)
	if err != nil {
		return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "dimensions_json is invalid")
	}
	event.DimensionsJSON = dimensionsJSON
	// 任务进度不接受客户端自报，也不接受 0 增量；没有可追溯的 event_id/source/metric 时不能写入幂等消费表。
	if event.EventID == "" || event.EventType == "" || event.SourceService == "" || event.UserID <= 0 || event.MetricType == "" || event.Value <= 0 {
		return taskdomain.EventResult{}, xerr.New(xerr.InvalidArgument, "task event is incomplete")
	}
	// 上游没有精确事件时间时使用 activity-service 当前时间；每日任务周期由 occurred_at_ms 决定，不由 MQ 消费时间决定。
	if event.OccurredAtMS <= 0 {
		event.OccurredAtMS = s.now().UnixMilli()
	}
	cycleKey := s.dailyCycle(time.UnixMilli(event.OccurredAtMS)).Key
	return s.repository.ConsumeTaskEvent(ctx, event, cycleKey, s.now().UnixMilli())
}

// HandleRoomEvent 把 room-service 已提交送礼事实投影为任务进度事件。
// 这里不读取房间或礼物当前状态，所有判断都基于 outbox 快照，保证重放时进度和第一次消费完全一致。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
	if s == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
		return 0, nil
	}
	var gift roomeventsv1.RoomGiftSent
	if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
		return 0, err
	}
	if gift.GetIsRobotGift() {
		return 0, nil
	}
	if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetGiftCount() <= 0 {
		return 0, nil
	}
	// coin_spent 是钱包真实扣费金额；旧 outbox 可能只有礼物面值，兜底用 gift_value 保持历史事件可重放。
	coinSpent := gift.GetCoinSpent()
	if coinSpent == 0 {
		coinSpent = gift.GetGiftValue()
	}
	dimensions, err := roomGiftTaskDimensions(envelope, &gift)
	if err != nil {
		return 0, err
	}
	eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
	// 零价礼物是已提交的合法房间事实：它不能制造 Value=0 的金币任务事件，但仍应累计
	// 送礼次数。因此转换层只过滤恰好为 0 的 spend；负值仍交给 ConsumeTaskEvent 拒绝，
	// 避免这个兼容修复顺带吞掉上游坏数据。
	events := make([]taskdomain.Event, 0, 4)
	if coinSpent != 0 {
		events = append(events, taskdomain.Event{
			EventID:        envelope.GetEventId() + ":task:gift_spend",
			EventType:      envelope.GetEventType(),
			SourceService:  "room-service",
			UserID:         gift.GetSenderUserId(),
			MetricType:     taskdomain.MetricGiftSpendCoin,
			Value:          coinSpent,
			OccurredAtMS:   envelope.GetOccurredAtMs(),
			DimensionsJSON: dimensions,
		})
	}
	// 次数指标来自已校验为正数的 gift_count，与礼物是否免费无关；独立 event_id 保证重放幂等。
	events = append(events, taskdomain.Event{
		EventID:        envelope.GetEventId() + ":task:gift_count",
		EventType:      envelope.GetEventType(),
		SourceService:  "room-service",
		UserID:         gift.GetSenderUserId(),
		MetricType:     taskdomain.MetricGiftSendCount,
		Value:          int64(gift.GetGiftCount()),
		OccurredAtMS:   envelope.GetOccurredAtMs(),
		DimensionsJSON: dimensions,
	})
	// 幸运礼物是普通送礼的子集；普通礼物指标照常累计，额外再写幸运礼物专属指标，方便后台用不同任务口径配置。
	if isLuckyGiftTaskEvent(&gift) {
		if coinSpent != 0 {
			events = append(events, taskdomain.Event{
				EventID:        envelope.GetEventId() + ":task:lucky_gift_spend",
				EventType:      envelope.GetEventType(),
				SourceService:  "room-service",
				UserID:         gift.GetSenderUserId(),
				MetricType:     taskdomain.MetricLuckyGiftSpendCoin,
				Value:          coinSpent,
				OccurredAtMS:   envelope.GetOccurredAtMs(),
				DimensionsJSON: dimensions,
			})
		}
		events = append(events, taskdomain.Event{
			EventID:        envelope.GetEventId() + ":task:lucky_gift_count",
			EventType:      envelope.GetEventType(),
			SourceService:  "room-service",
			UserID:         gift.GetSenderUserId(),
			MetricType:     taskdomain.MetricLuckyGiftSendCount,
			Value:          int64(gift.GetGiftCount()),
			OccurredAtMS:   envelope.GetOccurredAtMs(),
			DimensionsJSON: dimensions,
		})
	}
	consumed := int32(0)
	for _, event := range events {
		// 任意一个指标写入失败都返回错误给 MQ/gRPC 调用方重试；事件幂等表保证已成功的指标不会重复累加。
		result, err := s.ConsumeTaskEvent(eventCtx, event)
		if err != nil {
			return consumed, err
		}
		if result.Status == taskdomain.EventStatusConsumed {
			consumed += result.MatchedTaskCount
		}
	}
	return consumed, nil
}

// ListTaskDefinitions 返回后台任务配置列表；状态筛选不影响 App 查询口径。
func (s *Service) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) {
	if err := s.requireRepository(); err != nil {
		return nil, 0, err
	}
	query.TaskType = normalizeTaskType(query.TaskType)
	query.Category = strings.TrimSpace(query.Category)
	query.Status = strings.TrimSpace(query.Status)
	query.Keyword = strings.TrimSpace(query.Keyword)
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	return s.repository.ListTaskDefinitions(ctx, query)
}

// UpsertTaskDefinition 创建或编辑后台任务配置；关键领奖字段变更由 repository 生成版本快照。
func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand) (taskdomain.Definition, bool, error) {
	if err := s.requireRepository(); err != nil {
		return taskdomain.Definition{}, false, err
	}
	command = normalizeDefinitionCommand(command)
	if err := validateDefinitionCommand(ctx, command); err != nil {
		return taskdomain.Definition{}, false, err
	}
	var err error
	command, err = s.resolveRewardItems(ctx, command)
	if err != nil {
		return taskdomain.Definition{}, false, err
	}
	return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli())
}

// SetTaskDefinitionStatus 只做状态流转，不删除历史进度和领奖事实。
func (s *Service) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64) (taskdomain.Definition, error) {
	if err := s.requireRepository(); err != nil {
		return taskdomain.Definition{}, err
	}
	taskID = strings.TrimSpace(taskID)
	status = strings.TrimSpace(status)
	if taskID == "" || !validDefinitionStatus(status) || operatorAdminID <= 0 {
		return taskdomain.Definition{}, xerr.New(xerr.InvalidArgument, "task status command is invalid")
	}
	return s.repository.SetTaskDefinitionStatus(ctx, taskID, status, operatorAdminID, s.now().UnixMilli())
}

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

func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdomain.Progress, cycleKey string, nowMS int64, nextRefreshMS int64) taskdomain.Item {
	status := taskdomain.StatusInProgress
	progressValue := int64(0)
	if progress.TaskID != "" {
		status = progress.Status
		progressValue = progress.ProgressValue
		def.TargetValue = progress.TargetValue
		def.RewardType = normalizeRewardType(progress.RewardType)
		def.RewardCoinAmount = progress.RewardCoinAmount
		def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType)
		def.RewardResourceID = progress.RewardResourceID
		def.RewardResourceGroupID = progress.RewardResourceGroupID
		def.RewardSnapshotID = progress.RewardSnapshotID
		def.RewardSnapshotHash = progress.RewardSnapshotHash
		def.RewardQuantity = progress.RewardQuantity
		def.RewardDurationMS = progress.RewardDurationMS
		def.RewardItemsJSON = progress.RewardItemsJSON
	}
	if status == "" {
		status = taskdomain.StatusInProgress
	}
	def.RewardType = normalizeRewardType(def.RewardType)
	if def.RewardType == taskdomain.RewardTypeWalletAsset {
		def.RewardAssetType = normalizeRewardAssetType(def.RewardAssetType)
	}
	return taskdomain.Item{
		Definition:      def,
		ProgressValue:   progressValue,
		UserStatus:      status,
		Claimable:       status == taskdomain.StatusCompleted,
		TaskDay:         cycleKey,
		ServerTimeMS:    nowMS,
		NextRefreshAtMS: nextRefreshMS,
	}
}

type dailyCycle struct {
	Key           string
	NextRefreshMS int64
}

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

func progressKey(taskID string, cycleKey string) string {
	return taskID + "\x00" + cycleKey
}

func sortTaskItems(items []taskdomain.Item) {
	sort.SliceStable(items, func(i, j int) bool {
		leftRank := taskStatusRank(items[i])
		rightRank := taskStatusRank(items[j])
		if leftRank != rightRank {
			return leftRank < rightRank
		}
		if items[i].SortOrder != items[j].SortOrder {
			return items[i].SortOrder > items[j].SortOrder
		}
		return items[i].TaskID < items[j].TaskID
	})
}

func taskStatusRank(item taskdomain.Item) int {
	if item.Claimable {
		return 0
	}
	switch item.UserStatus {
	case taskdomain.StatusCompleted:
		return 1
	case taskdomain.StatusInProgress:
		return 2
	case taskdomain.StatusClaimed:
		return 3
	default:
		return 4
	}
}

func normalizeDefinitionCommand(command taskdomain.DefinitionCommand) taskdomain.DefinitionCommand {
	command.TaskID = strings.TrimSpace(command.TaskID)
	command.TaskType = normalizeTaskType(command.TaskType)
	command.Category = strings.ToLower(strings.TrimSpace(command.Category))
	command.MetricType = strings.ToLower(strings.TrimSpace(command.MetricType))
	command.Title = strings.TrimSpace(command.Title)
	command.Description = strings.TrimSpace(command.Description)
	command.AudienceType = normalizeAudienceType(command.AudienceType)
	command.IconKey = strings.TrimSpace(command.IconKey)
	command.IconURL = strings.TrimSpace(command.IconURL)
	command.ActionType = normalizeActionType(command.ActionType)
	command.ActionParam = strings.TrimSpace(command.ActionParam)
	command.ActionPayloadJSON = normalizeJSONForCommand(command.ActionPayloadJSON)
	command.DimensionFilterJSON = normalizeJSONForCommand(command.DimensionFilterJSON)
	command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit))
	command.RewardType = normalizeRewardType(command.RewardType)
	command.RewardAssetType = normalizeRewardAssetType(command.RewardAssetType)
	command.Status = strings.TrimSpace(command.Status)
	if command.Status == "" {
		command.Status = taskdomain.StatusDraft
	}
	return command
}

func normalizeTaskType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case taskdomain.TypeDaily:
		return taskdomain.TypeDaily
	case taskdomain.TypeExclusive:
		return taskdomain.TypeExclusive
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func validateDefinitionCommand(ctx context.Context, command taskdomain.DefinitionCommand) error {
	// daily 按 UTC 日周期生成进度，exclusive 使用 lifetime 周期；新增“新人专属+每日”用 audience_type 表达，不新增 task_type。
	if command.TaskType != taskdomain.TypeDaily && command.TaskType != taskdomain.TypeExclusive {
		return xerr.New(xerr.InvalidArgument, "task_type is invalid")
	}
	if command.Category == "" || command.MetricType == "" || command.Title == "" {
		return xerr.New(xerr.InvalidArgument, "task category, metric_type and title are required")
	}
	// audience/action 只允许固定枚举，避免后台写入 App 无法识别的展示人群或跳转类型。
	if !validAudienceType(command.AudienceType) {
		return xerr.New(xerr.InvalidArgument, "audience_type is invalid")
	}
	if !validActionType(command.ActionType) {
		return xerr.New(xerr.InvalidArgument, "action_type is invalid")
	}
	// 扩展跳转和维度过滤必须是对象 JSON；空值会在 normalizeDefinitionCommand 中落成 {}，App 和事件匹配都按对象处理。
	if _, err := normalizeObjectJSON(command.ActionPayloadJSON); err != nil {
		return xerr.New(xerr.InvalidArgument, "action_payload_json is invalid")
	}
	if _, err := normalizeObjectJSON(command.DimensionFilterJSON); err != nil {
		return xerr.New(xerr.InvalidArgument, "dimension_filter_json is invalid")
	}
	if command.TargetValue <= 0 {
		return xerr.New(xerr.InvalidArgument, "target_value must be positive")
	}
	switch command.RewardType {
	case taskdomain.RewardTypeWalletAsset:
		// 旧 Admin 请求没有 reward_type 时归一为 wallet_asset；这条分支仍要求显式资产，避免编辑 POINT 任务时空值覆盖。
		if strings.TrimSpace(command.RewardAssetType) == "" || command.RewardCoinAmount <= 0 {
			return xerr.New(xerr.InvalidArgument, "reward_asset_type and reward_coin_amount are required")
		}
		if !validRewardAssetType(command.RewardAssetType) {
			return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
		}
	case taskdomain.RewardTypeResource:
		if command.RewardResourceID <= 0 || command.RewardQuantity <= 0 || command.RewardDurationMS < 0 {
			return xerr.New(xerr.InvalidArgument, "resource reward is incomplete")
		}
	case taskdomain.RewardTypeResourceGroup:
		if command.RewardResourceGroupID <= 0 {
			return xerr.New(xerr.InvalidArgument, "reward_resource_group_id is required")
		}
	default:
		return xerr.New(xerr.InvalidArgument, "reward_type is invalid")
	}
	if !validTargetUnit(command.TargetUnit) {
		return xerr.New(xerr.InvalidArgument, "target_unit is invalid")
	}
	if !validDefinitionStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "task status is invalid")
	}
	if command.EffectiveToMS > 0 && command.EffectiveFromMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
		return xerr.New(xerr.InvalidArgument, "effective_to_ms must be after effective_from_ms")
	}
	if command.OperatorAdminID <= 0 {
		return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
	}
	return nil
}

func normalizeRewardAssetType(value string) string {
	value = strings.ToUpper(strings.TrimSpace(value))
	return value
}

func normalizeRewardType(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	if value == "" {
		// 没有 reward_type 的历史请求和历史数据都属于原有 COIN/POINT 钱包资产奖励。
		return taskdomain.RewardTypeWalletAsset
	}
	return value
}

func validRewardAssetType(value string) bool {
	switch normalizeRewardAssetType(value) {
	case taskdomain.RewardAssetCoin, taskdomain.RewardAssetPoint:
		// 资产由每条任务定义显式配置；业务校验只验证通用资产能力，不按应用名复制规则。
		return true
	default:
		return false
	}
}

func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.DefinitionCommand) (taskdomain.DefinitionCommand, error) {
	switch command.RewardType {
	case taskdomain.RewardTypeWalletAsset:
		// 非当前奖励类型字段必须清空，避免任务从资源切回金币后旧 ID 继续出现在 Admin/App 响应中。
		command.RewardResourceID = 0
		command.RewardResourceGroupID = 0
		command.RewardSnapshotID = ""
		command.RewardSnapshotHash = ""
		command.RewardQuantity = 0
		command.RewardDurationMS = 0
		return withRewardItems(command, []taskdomain.RewardItem{{
			ItemType:          taskdomain.RewardItemTypeWalletAsset,
			WalletAssetType:   command.RewardAssetType,
			WalletAssetAmount: command.RewardCoinAmount,
		}})
	case taskdomain.RewardTypeResource:
		if s.resourceWallet == nil {
			return taskdomain.DefinitionCommand{}, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
		}
		resp, err := s.resourceWallet.GetResource(ctx, &walletv1.GetResourceRequest{
			RequestId:  rewardCatalogRequestID(command),
			AppCode:    appcode.FromContext(ctx),
			ResourceId: command.RewardResourceID,
		})
		if err != nil {
			return taskdomain.DefinitionCommand{}, err
		}
		item, err := rewardItemFromResource(resp.GetResource(), command.RewardQuantity, command.RewardDurationMS)
		if err != nil {
			return taskdomain.DefinitionCommand{}, err
		}
		command.RewardCoinAmount = 0
		command.RewardAssetType = ""
		command.RewardResourceGroupID = 0
		command.RewardSnapshotID = ""
		command.RewardSnapshotHash = ""
		return withRewardItems(command, []taskdomain.RewardItem{item})
	case taskdomain.RewardTypeResourceGroup:
		if s.resourceWallet == nil {
			return taskdomain.DefinitionCommand{}, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
		}
		resp, err := s.resourceWallet.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
			RequestId: rewardCatalogRequestID(command),
			AppCode:   appcode.FromContext(ctx),
			GroupId:   command.RewardResourceGroupID,
		})
		if err != nil {
			return taskdomain.DefinitionCommand{}, err
		}
		group := resp.GetGroup()
		sourceContentHash := strings.ToLower(strings.TrimSpace(resp.GetSourceContentHash()))
		if group == nil || group.GetGroupId() != command.RewardResourceGroupID ||
			strings.ToLower(strings.TrimSpace(group.GetStatus())) != "active" ||
			group.GetUpdatedAtMs() <= 0 || !validRewardSnapshotHash(sourceContentHash) {
			return taskdomain.DefinitionCommand{}, xerr.New(xerr.Conflict, "reward resource group is unavailable")
		}
		if len(group.GetItems()) == 0 {
			return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group has no items")
		}
		pinKey := rewardSnapshotPinKey(ctx, command.RewardResourceGroupID, sourceContentHash)
		pinned, err := s.resourceWallet.PinResourceGroupSnapshot(ctx, &walletv1.PinResourceGroupSnapshotRequest{
			RequestId:                 pinKey,
			AppCode:                   appcode.FromContext(ctx),
			GroupId:                   command.RewardResourceGroupID,
			PinKey:                    pinKey,
			OperatorUserId:            command.OperatorAdminID,
			ExpectedGroupUpdatedAtMs:  group.GetUpdatedAtMs(),
			ExpectedSourceContentHash: sourceContentHash,
			// 每日任务没有区域定向字段，配置必须保证该快照对 App 全区域有效。
			RequiredAllRegions: true,
		})
		if err != nil {
			return taskdomain.DefinitionCommand{}, err
		}
		snapshot := pinned.GetSnapshot()
		snapshotGroup := snapshot.GetGroup()
		snapshotHash := strings.ToLower(strings.TrimSpace(snapshot.GetSnapshotHash()))
		snapshotSourceHash := strings.ToLower(strings.TrimSpace(snapshot.GetSourceContentHash()))
		if snapshot == nil || snapshotGroup == nil || snapshot.GetSnapshotId() == "" ||
			snapshot.GetSourceGroupId() != command.RewardResourceGroupID ||
			snapshot.GetSourceGroupUpdatedAtMs() != group.GetUpdatedAtMs() ||
			snapshotSourceHash != sourceContentHash || snapshotHash != sourceContentHash ||
			len(snapshotGroup.GetItems()) == 0 {
			return taskdomain.DefinitionCommand{}, xerr.New(xerr.Internal, "wallet returned an incomplete task reward snapshot")
		}
		items := make([]taskdomain.RewardItem, 0, len(snapshotGroup.GetItems()))
		for _, groupItem := range snapshotGroup.GetItems() {
			if strings.ToLower(strings.TrimSpace(groupItem.GetItemType())) == taskdomain.RewardItemTypeWalletAsset {
				if groupItem.GetWalletAssetType() == "" || groupItem.GetWalletAssetAmount() <= 0 {
					return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group contains an invalid wallet asset")
				}
				items = append(items, taskdomain.RewardItem{
					ItemType:          taskdomain.RewardItemTypeWalletAsset,
					WalletAssetType:   strings.ToUpper(strings.TrimSpace(groupItem.GetWalletAssetType())),
					WalletAssetAmount: groupItem.GetWalletAssetAmount(),
				})
				continue
			}
			item, itemErr := rewardItemFromResource(groupItem.GetResource(), groupItem.GetQuantity(), groupItem.GetDurationMs())
			if itemErr != nil {
				return taskdomain.DefinitionCommand{}, itemErr
			}
			items = append(items, item)
		}
		command.RewardCoinAmount = 0
		command.RewardAssetType = ""
		command.RewardResourceID = 0
		command.RewardSnapshotID = snapshot.GetSnapshotId()
		command.RewardSnapshotHash = snapshotHash
		command.RewardQuantity = 0
		command.RewardDurationMS = 0
		return withRewardItems(command, items)
	default:
		return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward_type is invalid")
	}
}

func rewardItemFromResource(resource *walletv1.Resource, quantity int64, durationMS int64) (taskdomain.RewardItem, error) {
	if resource == nil || resource.GetResourceId() <= 0 || strings.ToLower(strings.TrimSpace(resource.GetStatus())) != "active" || !resource.GetGrantable() {
		return taskdomain.RewardItem{}, xerr.New(xerr.Conflict, "reward resource is unavailable")
	}
	coverURL := strings.TrimSpace(resource.GetPreviewUrl())
	if coverURL == "" {
		coverURL = strings.TrimSpace(resource.GetAssetUrl())
	}
	if coverURL == "" {
		// H5 的奖励区明确只展示封面，不展示名称；没有可渲染封面的资源不能被保存为任务奖励。
		return taskdomain.RewardItem{}, xerr.New(xerr.InvalidArgument, "reward resource cover is required")
	}
	if quantity <= 0 || durationMS < 0 {
		return taskdomain.RewardItem{}, xerr.New(xerr.InvalidArgument, "reward resource quantity or duration is invalid")
	}
	return taskdomain.RewardItem{
		ItemType:     taskdomain.RewardItemTypeResource,
		ResourceID:   resource.GetResourceId(),
		ResourceType: strings.ToLower(strings.TrimSpace(resource.GetResourceType())),
		CoverURL:     coverURL,
		Quantity:     quantity,
		DurationMS:   durationMS,
	}, nil
}

func withRewardItems(command taskdomain.DefinitionCommand, items []taskdomain.RewardItem) (taskdomain.DefinitionCommand, error) {
	payload, err := json.Marshal(items)
	if err != nil {
		return taskdomain.DefinitionCommand{}, err
	}
	command.RewardItemsJSON = string(payload)
	return command, nil
}

func rewardCatalogRequestID(command taskdomain.DefinitionCommand) string {
	taskID := strings.TrimSpace(command.TaskID)
	if taskID == "" {
		taskID = "new"
	}
	return "task-reward-config-" + taskID
}

func rewardSnapshotPinKey(ctx context.Context, groupID int64, sourceContentHash string) string {
	// 同一 App、同一资源组版本可安全复用相同 owner 快照；哈希后的稳定 key 也让 Admin 重试不会生成重复快照。
	identity := appcode.FromContext(ctx) + "\x00" + strconv.FormatInt(groupID, 10) + "\x00" + sourceContentHash
	sum := sha256.Sum256([]byte(identity))
	return "daily-task-reward-pin:" + hex.EncodeToString(sum[:])
}

func validRewardSnapshotHash(value string) bool {
	if len(value) != sha256.Size*2 {
		return false
	}
	decoded, err := hex.DecodeString(value)
	return err == nil && len(decoded) == sha256.Size
}

func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
	// 维度只记录任务匹配和排障需要的事实字段；后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
	payload := map[string]any{
		"room_id":            envelope.GetRoomId(),
		"room_version":       envelope.GetRoomVersion(),
		"gift_id":            gift.GetGiftId(),
		"gift_count":         gift.GetGiftCount(),
		"gift_value":         gift.GetGiftValue(),
		"billing_receipt_id": gift.GetBillingReceiptId(),
		"command_id":         gift.GetCommandId(),
		"pool_id":            gift.GetPoolId(),
		"coin_spent":         gift.GetCoinSpent(),
		"visible_region_id":  gift.GetVisibleRegionId(),
		"country_id":         gift.GetCountryId(),
		"region_id":          gift.GetRegionId(),
		"gift_type_code":     strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode())),
		"cp_relation_type":   gift.GetCpRelationType(),
		"target_user_id":     gift.GetTargetUserId(),
	}
	data, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

func isLuckyGiftTaskEvent(gift *roomeventsv1.RoomGiftSent) bool {
	if gift == nil {
		return false
	}
	// 兼容两种上游表达：礼物类型直接标 lucky，或通过幸运池 pool_id 表明这次送礼进入幸运玩法。
	giftType := strings.ToLower(strings.TrimSpace(gift.GetGiftTypeCode()))
	poolID := strings.ToLower(strings.TrimSpace(gift.GetPoolId()))
	return strings.Contains(giftType, "lucky") || strings.Contains(poolID, "lucky")
}

func normalizeAudienceType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", taskdomain.AudienceAll:
		return taskdomain.AudienceAll
	case taskdomain.AudienceNewbie:
		return taskdomain.AudienceNewbie
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func validAudienceType(value string) bool {
	switch value {
	case taskdomain.AudienceAll, taskdomain.AudienceNewbie:
		return true
	default:
		return false
	}
}

func normalizeActionType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "", taskdomain.ActionNone:
		return taskdomain.ActionNone
	case taskdomain.ActionRoom:
		return taskdomain.ActionRoom
	case taskdomain.ActionRoomRandom:
		return taskdomain.ActionRoomRandom
	case taskdomain.ActionRoomWindow:
		return taskdomain.ActionRoomWindow
	case taskdomain.ActionRoomRandomWindow:
		return taskdomain.ActionRoomRandomWindow
	case taskdomain.ActionRoomGame:
		return taskdomain.ActionRoomGame
	case taskdomain.ActionRoomRandomGame:
		return taskdomain.ActionRoomRandomGame
	case taskdomain.ActionExploreGame:
		return taskdomain.ActionExploreGame
	case taskdomain.ActionWallet:
		return taskdomain.ActionWallet
	case taskdomain.ActionGiftPanel:
		return taskdomain.ActionGiftPanel
	case taskdomain.ActionGiftPanelSpecific:
		return taskdomain.ActionGiftPanelSpecific
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func validActionType(value string) bool {
	switch value {
	case taskdomain.ActionNone,
		taskdomain.ActionRoom,
		taskdomain.ActionRoomRandom,
		taskdomain.ActionRoomWindow,
		taskdomain.ActionRoomRandomWindow,
		taskdomain.ActionRoomGame,
		taskdomain.ActionRoomRandomGame,
		taskdomain.ActionExploreGame,
		taskdomain.ActionWallet,
		taskdomain.ActionGiftPanel,
		taskdomain.ActionGiftPanelSpecific:
		return true
	default:
		return false
	}
}

func normalizeObjectJSON(value string) (string, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "{}", nil
	}
	var decoded map[string]any
	if err := json.Unmarshal([]byte(value), &decoded); err != nil {
		return "", err
	}
	data, err := json.Marshal(decoded)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

func normalizeJSONForCommand(value string) string {
	normalized, err := normalizeObjectJSON(value)
	if err != nil {
		return strings.TrimSpace(value)
	}
	return normalized
}

func validTargetUnit(value string) bool {
	switch value {
	case "count", "minute", "coin":
		return true
	default:
		return false
	}
}

func validDefinitionStatus(value string) bool {
	switch value {
	case taskdomain.StatusDraft, taskdomain.StatusActive, taskdomain.StatusPaused, taskdomain.StatusArchived:
		return true
	default:
		return false
	}
}
