package activityapi

import (
	"encoding/json"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"

	"golang.org/x/text/language"
	activityv1 "hyapp.local/api/proto/activity/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	"hyapp/services/gateway-service/internal/auth"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
)

const activityTemplateUTCDayLayout = "2006-01-02"

type activityTemplateLocaleData struct {
	Locale string `json:"locale"`
	Title  string `json:"title"`
	Rules  string `json:"rules"`
}

type activityTemplateGiftData struct {
	GiftID       string `json:"gift_id"`
	SortOrder    int32  `json:"sort_order"`
	Name         string `json:"name"`
	IconURL      string `json:"icon_url"`
	CoinPrice    int64  `json:"coin_price"`
	PriceVersion string `json:"price_version"`
}

type activityTemplateRewardItemData struct {
	ItemType          string `json:"item_type"`
	ResourceID        int64  `json:"resource_id"`
	ResourceType      string `json:"resource_type"`
	Name              string `json:"name"`
	IconURL           string `json:"icon_url"`
	Quantity          int64  `json:"quantity"`
	DurationMS        int64  `json:"duration_ms"`
	WalletAssetType   string `json:"wallet_asset_type"`
	WalletAssetAmount int64  `json:"wallet_asset_amount"`
	SortOrder         int32  `json:"sort_order"`
}

type activityTemplateTaskDefinitionData struct {
	TaskKey               string                           `json:"task_key"`
	TaskType              string                           `json:"task_type"`
	TargetValue           int64                            `json:"target_value"`
	RewardResourceGroupID int64                            `json:"reward_resource_group_id"`
	SortOrder             int32                            `json:"sort_order"`
	RewardSnapshotID      string                           `json:"reward_snapshot_id"`
	RewardName            string                           `json:"reward_name"`
	RewardItems           []activityTemplateRewardItemData `json:"reward_items"`
}

type activityTemplateRankRewardData struct {
	BoardType        string                           `json:"board_type"`
	RankFrom         int32                            `json:"rank_from"`
	RankTo           int32                            `json:"rank_to"`
	ResourceGroupID  int64                            `json:"resource_group_id"`
	SortOrder        int32                            `json:"sort_order"`
	RewardSnapshotID string                           `json:"reward_snapshot_id"`
	RewardName       string                           `json:"reward_name"`
	RewardItems      []activityTemplateRewardItemData `json:"reward_items"`
}

type activityTemplateAssetData struct {
	AssetKey  string `json:"asset_key"`
	Locale    string `json:"locale"`
	URL       string `json:"url"`
	MediaType string `json:"media_type"`
	SortOrder int32  `json:"sort_order"`
}

// activityTemplateRuntimeData is deliberately smaller than the admin aggregate. App clients receive
// only the immutable published presentation/rule snapshot and runtime window, never admin IDs,
// optimistic revisions or mutable draft metadata.
type activityTemplateRuntimeData struct {
	TemplateID           string                               `json:"template_id"`
	TemplateCode         string                               `json:"template_code"`
	ActivityType         string                               `json:"activity_type"`
	Status               string                               `json:"status"`
	LifecycleStatus      string                               `json:"lifecycle_status"`
	StartMS              int64                                `json:"start_ms"`
	EndMS                int64                                `json:"end_ms"`
	AllRegions           bool                                 `json:"all_regions"`
	RegionIDs            []int64                              `json:"region_ids"`
	Locales              []activityTemplateLocaleData         `json:"locales"`
	Locale               *activityTemplateLocaleData          `json:"locale,omitempty"`
	Gifts                []activityTemplateGiftData           `json:"gifts"`
	Tasks                []activityTemplateTaskDefinitionData `json:"tasks"`
	DailyLeaderboardSize int32                                `json:"daily_leaderboard_size"`
	TotalLeaderboardSize int32                                `json:"total_leaderboard_size"`
	RankRewards          []activityTemplateRankRewardData     `json:"rank_rewards"`
	Assets               []activityTemplateAssetData          `json:"assets"`
	DisplayConfig        json.RawMessage                      `json:"display_config"`
	VersionNo            int64                                `json:"version_no"`
	RuntimeFromMS        int64                                `json:"runtime_from_ms"`
	RuntimeToMS          int64                                `json:"runtime_to_ms"`
	ServerTimeMS         int64                                `json:"server_time_ms"`
}

type activityTemplateRuntimeTaskData struct {
	TemplateID            string                           `json:"template_id"`
	TemplateCode          string                           `json:"template_code"`
	VersionNo             int64                            `json:"version_no"`
	TaskDay               string                           `json:"task_day"`
	TaskKey               string                           `json:"task_key"`
	TaskType              string                           `json:"task_type"`
	TargetValue           int64                            `json:"target_value"`
	ProgressValue         int64                            `json:"progress_value"`
	RewardResourceGroupID int64                            `json:"reward_resource_group_id"`
	Status                string                           `json:"status"`
	Claimable             bool                             `json:"claimable"`
	CompletedAtMS         int64                            `json:"completed_at_ms"`
	ClaimedAtMS           int64                            `json:"claimed_at_ms"`
	SortOrder             int32                            `json:"sort_order"`
	RewardSnapshotID      string                           `json:"reward_snapshot_id"`
	RewardName            string                           `json:"reward_name"`
	RewardItems           []activityTemplateRewardItemData `json:"reward_items"`
}

type activityTemplateTaskClaimData struct {
	ClaimID               string                           `json:"claim_id"`
	TemplateID            string                           `json:"template_id"`
	TemplateCode          string                           `json:"template_code"`
	VersionNo             int64                            `json:"version_no"`
	TaskDay               string                           `json:"task_day"`
	TaskKey               string                           `json:"task_key"`
	UserID                string                           `json:"user_id"`
	RewardResourceGroupID int64                            `json:"reward_resource_group_id"`
	Status                string                           `json:"status"`
	CreatedAtMS           int64                            `json:"created_at_ms"`
	UpdatedAtMS           int64                            `json:"updated_at_ms"`
	GrantedAtMS           int64                            `json:"granted_at_ms"`
	RewardSnapshotID      string                           `json:"reward_snapshot_id"`
	RewardName            string                           `json:"reward_name"`
	RewardItems           []activityTemplateRewardItemData `json:"reward_items"`
}

type activityTemplateLeaderboardUserData struct {
	UserID        string `json:"user_id"`
	DisplayUserID string `json:"display_user_id"`
	Username      string `json:"username"`
	Avatar        string `json:"avatar"`
}

type activityTemplateLeaderboardEntryData struct {
	RankNo          int32                                `json:"rank_no"`
	UserID          string                               `json:"user_id"`
	Score           int64                                `json:"score"`
	GiftCount       int64                                `json:"gift_count"`
	CoinAmount      int64                                `json:"coin_amount"`
	FirstScoredAtMS int64                                `json:"first_scored_at_ms"`
	Snapshot        bool                                 `json:"snapshot"`
	User            *activityTemplateLeaderboardUserData `json:"user,omitempty"`
}

type activityTemplateVisitBody struct {
	CommandID string `json:"command_id"`
	VersionNo int64  `json:"version_no"`
}

type activityTemplateTaskClaimBody struct {
	TaskDay   string `json:"task_day"`
	CommandID string `json:"command_id"`
	VersionNo int64  `json:"version_no"`
}

type activityTemplateCurrentData struct {
	Found   bool                         `json:"found"`
	Runtime *activityTemplateRuntimeData `json:"runtime,omitempty"`
}

type activityTemplateVisitData struct {
	Runtime    *activityTemplateRuntimeData      `json:"runtime,omitempty"`
	Tasks      []activityTemplateRuntimeTaskData `json:"tasks"`
	FirstVisit bool                              `json:"first_visit"`
}

type activityTemplateTasksData struct {
	Runtime         *activityTemplateRuntimeData      `json:"runtime,omitempty"`
	Tasks           []activityTemplateRuntimeTaskData `json:"tasks"`
	NextRefreshAtMS int64                             `json:"next_refresh_at_ms"`
}

type activityTemplateClaimData struct {
	Claim   *activityTemplateTaskClaimData `json:"claim,omitempty"`
	Claimed bool                           `json:"claimed"`
}

type activityTemplateLeaderboardData struct {
	Runtime       *activityTemplateRuntimeData           `json:"runtime,omitempty"`
	BoardType     string                                 `json:"board_type"`
	PeriodKey     string                                 `json:"period_key"`
	PeriodStartMS int64                                  `json:"period_start_ms"`
	PeriodEndMS   int64                                  `json:"period_end_ms"`
	SettleAfterMS int64                                  `json:"settle_after_ms"`
	PeriodStatus  string                                 `json:"period_status"`
	Entries       []activityTemplateLeaderboardEntryData `json:"entries"`
	MyEntry       *activityTemplateLeaderboardEntryData  `json:"my_entry,omitempty"`
	Total         int64                                  `json:"total"`
	Page          int32                                  `json:"page"`
	PageSize      int32                                  `json:"page_size"`
}

// getCurrentActivityTemplate resolves region from the authenticated user's current profile. A client
// cannot probe another region by crafting a query parameter, which keeps rollout targeting identical
// between page configuration, task progress and gift-event attribution.
func (h *Handler) getCurrentActivityTemplate(writer http.ResponseWriter, request *http.Request) {
	if h.activityTemplateRuntime == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	regionID, ok := h.resolveActivityTemplateRegionID(writer, request, userID)
	if !ok {
		return
	}
	locale := activityTemplateRequestLocale(request)
	templateCode := strings.TrimSpace(request.PathValue("template_code"))
	resp, err := h.activityTemplateRuntime.GetCurrentActivityTemplate(request.Context(), &activityv1.GetCurrentActivityTemplateRequest{
		Meta: httpkit.ActivityMeta(request), UserId: userID, RegionId: regionID, Locale: locale, TemplateCode: templateCode,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	data := activityTemplateCurrentData{Found: resp.GetFound()}
	if resp.GetRuntime() != nil {
		runtime := activityTemplateRuntimeFromProto(resp.GetRuntime(), locale)
		data.Runtime = &runtime
	}
	httpkit.WriteOK(writer, request, data)
}

// visitActivityTemplate records the explicit daily_visit fact. request_id remains tracing metadata;
// only the client-provided command_id is allowed to define replay identity across HTTP retries.
func (h *Handler) visitActivityTemplate(writer http.ResponseWriter, request *http.Request) {
	if h.activityTemplateRuntime == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	templateCode := strings.TrimSpace(request.PathValue("template_code"))
	var body activityTemplateVisitBody
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.CommandID = strings.TrimSpace(body.CommandID)
	if templateCode == "" || body.CommandID == "" || body.VersionNo <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	regionID, ok := h.resolveActivityTemplateRegionID(writer, request, userID)
	if !ok {
		return
	}
	resp, err := h.activityTemplateRuntime.VisitActivityTemplate(request.Context(), &activityv1.VisitActivityTemplateRequest{
		Meta: httpkit.ActivityMeta(request), UserId: userID, RegionId: regionID, TemplateCode: templateCode,
		CommandId: body.CommandID, VersionNo: body.VersionNo,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, activityTemplateVisitData{
		Runtime: activityTemplateRuntimePointerFromProto(resp.GetRuntime(), activityTemplateRequestLocale(request)),
		Tasks:   activityTemplateRuntimeTasksFromProto(resp.GetTasks()), FirstVisit: resp.GetFirstVisit(),
	})
}

func (h *Handler) listActivityTemplateTasks(writer http.ResponseWriter, request *http.Request) {
	if h.activityTemplateRuntime == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	templateCode := strings.TrimSpace(request.PathValue("template_code"))
	taskDay, ok := normalizeOptionalActivityTemplateUTCDay(request.URL.Query().Get("task_day"))
	versionNo, versionOK := optionalActivityTemplateVersionQuery(request)
	if templateCode == "" || !ok || !versionOK {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	regionID := int64(0)
	if versionNo == 0 {
		var regionOK bool
		regionID, regionOK = h.resolveActivityTemplateRegionID(writer, request, userID)
		if !regionOK {
			return
		}
	}
	resp, err := h.activityTemplateRuntime.ListActivityTemplateTasks(request.Context(), &activityv1.ListActivityTemplateTasksRequest{
		Meta: httpkit.ActivityMeta(request), UserId: userID, RegionId: regionID, TemplateCode: templateCode,
		TaskDay: taskDay, VersionNo: versionNo,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, activityTemplateTasksData{
		Runtime: activityTemplateRuntimePointerFromProto(resp.GetRuntime(), activityTemplateRequestLocale(request)),
		Tasks:   activityTemplateRuntimeTasksFromProto(resp.GetTasks()), NextRefreshAtMS: resp.GetNextRefreshAtMs(),
	})
}

func (h *Handler) claimActivityTemplateTask(writer http.ResponseWriter, request *http.Request) {
	if h.activityTemplateRuntime == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	templateCode := strings.TrimSpace(request.PathValue("template_code"))
	taskKey := strings.TrimSpace(request.PathValue("task_key"))
	var body activityTemplateTaskClaimBody
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	taskDay, validDay := normalizeRequiredActivityTemplateUTCDay(body.TaskDay)
	body.CommandID = strings.TrimSpace(body.CommandID)
	if templateCode == "" || taskKey == "" || !validDay || body.CommandID == "" || body.VersionNo <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	resp, err := h.activityTemplateRuntime.ClaimActivityTemplateTaskReward(request.Context(), &activityv1.ClaimActivityTemplateTaskRewardRequest{
		Meta: httpkit.ActivityMeta(request), UserId: userID, RegionId: 0, TemplateCode: templateCode,
		TaskDay: taskDay, TaskKey: taskKey, CommandId: body.CommandID, VersionNo: body.VersionNo,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, activityTemplateClaimData{
		Claim: activityTemplateTaskClaimPointerFromProto(resp.GetClaim()), Claimed: resp.GetClaimed(),
	})
}

func (h *Handler) listActivityTemplateDailyLeaderboard(writer http.ResponseWriter, request *http.Request) {
	h.listActivityTemplateLeaderboard(writer, request, "daily")
}

func (h *Handler) listActivityTemplateTotalLeaderboard(writer http.ResponseWriter, request *http.Request) {
	h.listActivityTemplateLeaderboard(writer, request, "total")
}

func (h *Handler) listActivityTemplateLeaderboard(writer http.ResponseWriter, request *http.Request, boardType string) {
	if h.activityTemplateRuntime == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	templateCode := strings.TrimSpace(request.PathValue("template_code"))
	page, pageOK := httpkit.PositiveInt32Query(request, "page", 1)
	pageSize, pageSizeOK := httpkit.PositiveInt32Query(request, "page_size", 50)
	versionNo, versionOK := optionalActivityTemplateVersionQuery(request)
	if pageSize > 100 {
		pageSize = 100
	}
	periodKey := "total"
	periodOK := true
	if boardType == "daily" {
		periodKey, periodOK = normalizeOptionalActivityTemplateUTCDay(request.URL.Query().Get("period_key"))
	}
	if templateCode == "" || !pageOK || !pageSizeOK || !periodOK || !versionOK {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	regionID := int64(0)
	if versionNo == 0 {
		var regionOK bool
		regionID, regionOK = h.resolveActivityTemplateRegionID(writer, request, userID)
		if !regionOK {
			return
		}
	}
	resp, err := h.activityTemplateRuntime.ListActivityTemplateLeaderboard(request.Context(), &activityv1.ListActivityTemplateLeaderboardRequest{
		Meta: httpkit.ActivityMeta(request), UserId: userID, RegionId: regionID, TemplateCode: templateCode,
		BoardType: boardType, PeriodKey: periodKey, Page: page, PageSize: pageSize, VersionNo: versionNo,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	ids := activityTemplateLeaderboardUserIDs(resp.GetEntries(), resp.GetMyEntry())
	users := h.resolveActivityTemplateLeaderboardUsers(request, ids)
	data := activityTemplateLeaderboardData{
		Runtime:   activityTemplateRuntimePointerFromProto(resp.GetRuntime(), activityTemplateRequestLocale(request)),
		BoardType: resp.GetBoardType(), PeriodKey: resp.GetPeriodKey(), PeriodStartMS: resp.GetPeriodStartMs(),
		PeriodEndMS: resp.GetPeriodEndMs(), SettleAfterMS: resp.GetSettleAfterMs(), PeriodStatus: resp.GetPeriodStatus(),
		Entries: activityTemplateLeaderboardEntriesFromProto(resp.GetEntries(), users),
		Total:   resp.GetTotal(), Page: page, PageSize: pageSize,
	}
	if resp.GetMyEntry() != nil {
		entry := activityTemplateLeaderboardEntryFromProto(resp.GetMyEntry(), users)
		data.MyEntry = &entry
	}
	httpkit.WriteOK(writer, request, data)
}

func optionalActivityTemplateVersionQuery(request *http.Request) (int64, bool) {
	raw := strings.TrimSpace(request.URL.Query().Get("version_no"))
	if raw == "" {
		return 0, true
	}
	versionNo, err := strconv.ParseInt(raw, 10, 64)
	return versionNo, err == nil && versionNo > 0
}

func (h *Handler) resolveActivityTemplateRegionID(writer http.ResponseWriter, request *http.Request, userID int64) (int64, bool) {
	if h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return 0, false
	}
	resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta: httpkit.UserMeta(request, ""), UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return 0, false
	}
	regionID := resp.GetUser().GetRegionId()
	if regionID <= 0 {
		// A completed profile without a canonical region is inconsistent upstream state. Do not accept a
		// caller supplied fallback, because that would bypass regional publication targeting.
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return 0, false
	}
	return regionID, true
}

func (h *Handler) resolveActivityTemplateLeaderboardUsers(request *http.Request, ids []int64) map[int64]activityTemplateLeaderboardUserData {
	users := make(map[int64]activityTemplateLeaderboardUserData)
	ids = uniquePositiveIDs(ids)
	if len(ids) == 0 || h.userProfileClient == nil {
		return users
	}
	resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
		Meta: httpkit.UserMeta(request, ""), UserIds: ids,
	})
	if err != nil {
		// Ranking score/order are owned by activity-service. Profile decoration is best effort so a
		// transient user-service read failure cannot make an otherwise valid ranking page unavailable.
		return users
	}
	for _, item := range resp.GetUsers() {
		users[item.GetUserId()] = activityTemplateLeaderboardUserData{
			UserID: strconv.FormatInt(item.GetUserId(), 10), DisplayUserID: item.GetDisplayUserId(),
			Username: item.GetUsername(), Avatar: item.GetAvatar(),
		}
	}
	return users
}

func activityTemplateRuntimeFromProto(runtime *activityv1.ActivityTemplateRuntime, requestedLocale string) activityTemplateRuntimeData {
	data := activityTemplateRuntimeData{
		RegionIDs: []int64{}, Locales: []activityTemplateLocaleData{}, Gifts: []activityTemplateGiftData{},
		Tasks: []activityTemplateTaskDefinitionData{}, RankRewards: []activityTemplateRankRewardData{},
		Assets: []activityTemplateAssetData{}, DisplayConfig: json.RawMessage(`{}`),
	}
	if runtime == nil || runtime.GetTemplate() == nil {
		return data
	}
	template := runtime.GetTemplate()
	data.TemplateID = template.GetTemplateId()
	data.TemplateCode = template.GetTemplateCode()
	data.ActivityType = template.GetActivityType()
	data.Status = template.GetStatus()
	data.LifecycleStatus = template.GetLifecycleStatus()
	data.StartMS = template.GetStartMs()
	data.EndMS = template.GetEndMs()
	data.AllRegions = template.GetAllRegions()
	data.RegionIDs = append(data.RegionIDs, template.GetRegionIds()...)
	seenLocales := make(map[string]struct{}, len(template.GetLocales()))
	for _, item := range template.GetLocales() {
		locale, valid := canonicalActivityTemplateLanguageTag(item.GetLocale())
		if !valid {
			// 发布写路径会校验 locale；读取侧仍需失败关闭，避免旧数据或跨版本脏数据把非法 tag 暴露给 App。
			continue
		}
		key := strings.ToLower(locale)
		if _, exists := seenLocales[key]; exists {
			continue
		}
		seenLocales[key] = struct{}{}
		data.Locales = append(data.Locales, activityTemplateLocaleData{Locale: locale, Title: item.GetTitle(), Rules: item.GetRules()})
	}
	if selected, ok := selectActivityTemplateLocale(data.Locales, requestedLocale); ok {
		data.Locale = &selected
	}
	for _, item := range template.GetGifts() {
		data.Gifts = append(data.Gifts, activityTemplateGiftData{
			GiftID: item.GetGiftId(), SortOrder: item.GetSortOrder(), Name: item.GetName(), IconURL: item.GetIconUrl(),
			CoinPrice: item.GetCoinPrice(), PriceVersion: item.GetPriceVersion(),
		})
	}
	for _, item := range template.GetTasks() {
		data.Tasks = append(data.Tasks, activityTemplateTaskDefinitionData{
			TaskKey: item.GetTaskKey(), TaskType: item.GetTaskType(), TargetValue: item.GetTargetValue(),
			RewardResourceGroupID: item.GetRewardResourceGroupId(), SortOrder: item.GetSortOrder(),
			RewardSnapshotID: item.GetRewardSnapshotId(), RewardName: item.GetRewardName(),
			RewardItems: activityTemplateRewardItemsFromProto(item.GetRewardItems()),
		})
	}
	data.DailyLeaderboardSize = template.GetDailyLeaderboardSize()
	data.TotalLeaderboardSize = template.GetTotalLeaderboardSize()
	for _, item := range template.GetRankRewards() {
		data.RankRewards = append(data.RankRewards, activityTemplateRankRewardData{
			BoardType: item.GetBoardType(), RankFrom: item.GetRankFrom(), RankTo: item.GetRankTo(),
			ResourceGroupID: item.GetResourceGroupId(), SortOrder: item.GetSortOrder(),
			RewardSnapshotID: item.GetRewardSnapshotId(), RewardName: item.GetRewardName(),
			RewardItems: activityTemplateRewardItemsFromProto(item.GetRewardItems()),
		})
	}
	for _, item := range template.GetAssets() {
		assetLocale := strings.TrimSpace(item.GetLocale())
		if assetLocale != "*" {
			var valid bool
			assetLocale, valid = canonicalActivityTemplateLanguageTag(assetLocale)
			if !valid {
				continue
			}
		}
		data.Assets = append(data.Assets, activityTemplateAssetData{
			AssetKey: item.GetAssetKey(), Locale: assetLocale, URL: item.GetUrl(), MediaType: item.GetMediaType(), SortOrder: item.GetSortOrder(),
		})
	}
	if raw := []byte(strings.TrimSpace(template.GetDisplayConfigJson())); len(raw) > 0 && json.Valid(raw) {
		data.DisplayConfig = append(json.RawMessage(nil), raw...)
	}
	data.VersionNo = runtime.GetVersionNo()
	data.RuntimeFromMS = runtime.GetRuntimeFromMs()
	data.RuntimeToMS = runtime.GetRuntimeToMs()
	data.ServerTimeMS = runtime.GetServerTimeMs()
	return data
}

func activityTemplateRuntimePointerFromProto(runtime *activityv1.ActivityTemplateRuntime, requestedLocale string) *activityTemplateRuntimeData {
	if runtime == nil {
		return nil
	}
	data := activityTemplateRuntimeFromProto(runtime, requestedLocale)
	return &data
}

func activityTemplateRuntimeTasksFromProto(items []*activityv1.ActivityTemplateRuntimeTask) []activityTemplateRuntimeTaskData {
	result := make([]activityTemplateRuntimeTaskData, 0, len(items))
	for _, item := range items {
		result = append(result, activityTemplateRuntimeTaskData{
			TemplateID: item.GetTemplateId(), TemplateCode: item.GetTemplateCode(), VersionNo: item.GetVersionNo(),
			TaskDay: item.GetTaskDay(), TaskKey: item.GetTaskKey(), TaskType: item.GetTaskType(), TargetValue: item.GetTargetValue(),
			ProgressValue: item.GetProgressValue(), RewardResourceGroupID: item.GetRewardResourceGroupId(),
			Status: item.GetStatus(), Claimable: item.GetClaimable(), CompletedAtMS: item.GetCompletedAtMs(),
			ClaimedAtMS: item.GetClaimedAtMs(), SortOrder: item.GetSortOrder(),
			RewardSnapshotID: item.GetRewardSnapshotId(), RewardName: item.GetRewardName(),
			RewardItems: activityTemplateRewardItemsFromProto(item.GetRewardItems()),
		})
	}
	return result
}

func activityTemplateTaskClaimPointerFromProto(item *activityv1.ActivityTemplateTaskClaim) *activityTemplateTaskClaimData {
	if item == nil {
		return nil
	}
	return &activityTemplateTaskClaimData{
		ClaimID: item.GetClaimId(), TemplateID: item.GetTemplateId(),
		TemplateCode: item.GetTemplateCode(), VersionNo: item.GetVersionNo(), TaskDay: item.GetTaskDay(),
		TaskKey: item.GetTaskKey(), UserID: strconv.FormatInt(item.GetUserId(), 10),
		RewardResourceGroupID: item.GetRewardResourceGroupId(), Status: item.GetStatus(),
		CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), GrantedAtMS: item.GetGrantedAtMs(),
		RewardSnapshotID: item.GetRewardSnapshotId(), RewardName: item.GetRewardName(),
		RewardItems: activityTemplateRewardItemsFromProto(item.GetRewardItems()),
	}
}

func activityTemplateRewardItemsFromProto(items []*activityv1.ActivityTemplateRewardItem) []activityTemplateRewardItemData {
	out := make([]activityTemplateRewardItemData, 0, len(items))
	for _, item := range items {
		out = append(out, activityTemplateRewardItemData{
			ItemType: item.GetItemType(), ResourceID: item.GetResourceId(), ResourceType: item.GetResourceType(),
			Name: item.GetName(), IconURL: item.GetIconUrl(), Quantity: item.GetQuantity(), DurationMS: item.GetDurationMs(),
			WalletAssetType: item.GetWalletAssetType(), WalletAssetAmount: item.GetWalletAssetAmount(), SortOrder: item.GetSortOrder(),
		})
	}
	return out
}

func activityTemplateLeaderboardEntriesFromProto(items []*activityv1.ActivityTemplateLeaderboardEntry, users map[int64]activityTemplateLeaderboardUserData) []activityTemplateLeaderboardEntryData {
	result := make([]activityTemplateLeaderboardEntryData, 0, len(items))
	for _, item := range items {
		result = append(result, activityTemplateLeaderboardEntryFromProto(item, users))
	}
	return result
}

func activityTemplateLeaderboardEntryFromProto(item *activityv1.ActivityTemplateLeaderboardEntry, users map[int64]activityTemplateLeaderboardUserData) activityTemplateLeaderboardEntryData {
	data := activityTemplateLeaderboardEntryData{}
	if item == nil {
		return data
	}
	data.RankNo = item.GetRankNo()
	data.UserID = strconv.FormatInt(item.GetUserId(), 10)
	data.Score = item.GetScore()
	data.GiftCount = item.GetGiftCount()
	data.CoinAmount = item.GetCoinAmount()
	data.FirstScoredAtMS = item.GetFirstScoredAtMs()
	data.Snapshot = item.GetSnapshot()
	if user, ok := users[item.GetUserId()]; ok {
		data.User = &user
	}
	return data
}

func activityTemplateLeaderboardUserIDs(items []*activityv1.ActivityTemplateLeaderboardEntry, myEntry *activityv1.ActivityTemplateLeaderboardEntry) []int64 {
	ids := make([]int64, 0, len(items)+1)
	for _, item := range items {
		ids = append(ids, item.GetUserId())
	}
	if myEntry != nil {
		ids = append(ids, myEntry.GetUserId())
	}
	return ids
}

func normalizeOptionalActivityTemplateUTCDay(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", true
	}
	return normalizeRequiredActivityTemplateUTCDay(raw)
}

func normalizeRequiredActivityTemplateUTCDay(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	parsed, err := time.Parse(activityTemplateUTCDayLayout, raw)
	if err != nil || parsed.Location() != time.UTC || parsed.Format(activityTemplateUTCDayLayout) != raw {
		return "", false
	}
	return raw, true
}

func activityTemplateRequestLocale(request *http.Request) string {
	// 显式 query 优先于客户端语言头；每一层只有合法 BCP-47 才能截断回退链，非法输入不会原样进入 gRPC 或响应。
	if locale, valid := canonicalActivityTemplateLanguageTag(request.URL.Query().Get("locale")); valid {
		return locale
	}
	if locale, valid := canonicalActivityTemplateLanguageTag(request.Header.Get("X-App-Language")); valid {
		return locale
	}
	// X-Language 是旧客户端兼容入口，优先级低于规范的 X-App-Language，但仍高于浏览器协商头。
	if locale, valid := canonicalActivityTemplateLanguageTag(request.Header.Get("X-Language")); valid {
		return locale
	}
	if locale, valid := preferredActivityTemplateAcceptLanguage(request.Header.Get("Accept-Language")); valid {
		return locale
	}
	return "en"
}

func selectActivityTemplateLocale(locales []activityTemplateLocaleData, requested string) (activityTemplateLocaleData, bool) {
	requested, valid := canonicalActivityTemplateLanguageTag(requested)
	if !valid {
		requested = "en"
	}
	// exact 保留地区、脚本和扩展差异，例如 zh-CN 与 zh-TW、pt-BR 与 pt-PT 不能提前折叠为基础语言。
	for _, item := range locales {
		if strings.EqualFold(item.Locale, requested) {
			return item, true
		}
	}
	requestedBase := activityTemplateLanguageBase(requested)
	if requestedBase != "" {
		// 优先使用运营明确配置的 language-only 文案，再选择同语言的第一个候选；配置顺序即稳定决胜顺序。
		for _, item := range locales {
			if strings.EqualFold(item.Locale, requestedBase) {
				return item, true
			}
		}
		for _, item := range locales {
			if activityTemplateLanguageBase(item.Locale) == requestedBase {
				return item, true
			}
		}
	}
	for _, item := range locales {
		if strings.EqualFold(strings.TrimSpace(item.Locale), "en") {
			return item, true
		}
	}
	for _, item := range locales {
		if activityTemplateLanguageBase(item.Locale) == "en" {
			return item, true
		}
	}
	if len(locales) > 0 {
		return locales[0], true
	}
	return activityTemplateLocaleData{}, false
}

// canonicalActivityTemplateLanguageTag 在 language.Parse 前做 ASCII/分段约束。x/text 会为兼容 POSIX
// 接受下划线并尝试修复；HTTP 边界不能把这类非 BCP-47 输入静默改写后继续暴露。
func canonicalActivityTemplateLanguageTag(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" || len(raw) > 255 || raw == "*" || raw[0] == '-' || raw[len(raw)-1] == '-' {
		return "", false
	}
	segmentLength := 0
	for index := 0; index < len(raw); index++ {
		char := raw[index]
		if char == '-' {
			if segmentLength == 0 || segmentLength > 8 {
				return "", false
			}
			segmentLength = 0
			continue
		}
		if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
			return "", false
		}
		segmentLength++
	}
	if segmentLength == 0 || segmentLength > 8 {
		return "", false
	}
	tag, err := language.Parse(raw)
	if err != nil || tag.String() == "" || tag.String() == "mul" {
		return "", false
	}
	return tag.String(), true
}

func activityTemplateLanguageBase(locale string) string {
	tag, err := language.Parse(locale)
	if err != nil {
		return ""
	}
	base, confidence := tag.Base()
	if confidence == language.No || base.String() == "und" {
		return ""
	}
	return base.String()
}

// preferredActivityTemplateAcceptLanguage applies RFC quality weights without accepting wildcard or malformed
// language ranges. Equal weights preserve header order; q=0 means explicitly unacceptable and is skipped.
func preferredActivityTemplateAcceptLanguage(raw string) (string, bool) {
	bestLocale := ""
	bestQuality := -1.0
	for _, entry := range strings.Split(raw, ",") {
		parts := strings.Split(entry, ";")
		locale, valid := canonicalActivityTemplateLanguageTag(parts[0])
		if !valid {
			continue
		}
		quality := 1.0
		validQuality := true
		for _, parameter := range parts[1:] {
			name, value, found := strings.Cut(strings.TrimSpace(parameter), "=")
			if !found || !strings.EqualFold(strings.TrimSpace(name), "q") {
				continue
			}
			parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
			if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 0 || parsed > 1 {
				validQuality = false
				break
			}
			quality = parsed
		}
		if !validQuality || quality <= 0 || quality <= bestQuality {
			continue
		}
		bestLocale = locale
		bestQuality = quality
	}
	return bestLocale, bestLocale != ""
}
