// Package message implements the App system/activity message inbox owner.
package message

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"net/url"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	messagedomain "hyapp/services/activity-service/internal/domain/message"
)

const (
	defaultPageSize  = 20
	maxPageSize      = 100
	defaultBatchSize = 500
	maxBatchSize     = 5000
)

// Repository is the storage boundary for MySQL-backed inbox facts.
type Repository interface {
	GetActiveMessageTemplate(ctx context.Context, templateID string, templateVersion string, messageType string) (messagedomain.MessageTemplate, bool, error)
	SaveInboxMessage(ctx context.Context, message messagedomain.InboxMessage) (bool, messagedomain.InboxMessage, error)
	CountUnreadInboxMessages(ctx context.Context, userID int64, section string, nowMS int64) (int64, error)
	ListInboxMessages(ctx context.Context, query messagedomain.ListQuery) ([]messagedomain.InboxMessage, error)
	MarkInboxMessageRead(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, error)
	MarkInboxSectionRead(ctx context.Context, userID int64, section string, nowMS int64) (int64, error)
	DeleteInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) error
	SaveFanoutJob(ctx context.Context, job messagedomain.FanoutJob) (bool, messagedomain.FanoutJob, error)
	ClaimFanoutJob(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, maxRetry int) (messagedomain.FanoutJob, bool, error)
	FinishFanoutJobBatch(ctx context.Context, jobID string, workerID string, cursorUserID int64, totalDelta int64, successDelta int64, failureDelta int64, done bool, nowMS int64, errorMessage string) error
	FailFanoutJob(ctx context.Context, jobID string, workerID string, nowMS int64, nextRunAtMS int64, maxRetry int, errorMessage string) error
}

// Config keeps service-level knobs that do not belong in transport code.
type Config struct {
	NodeID string
	// AppCodes 只供通用确认消息 outbox 使用；普通 inbox API 始终以请求 context 的 app_code 隔离。
	AppCodes []string
}

// TargetQuery describes the user-service cursor query needed by fanout worker scopes.
type TargetQuery struct {
	TargetScope  string
	CursorUserID int64
	PageSize     int
	RegionID     int64
	Country      string
}

// TargetSource hides user-service gRPC behind a minimal page query.
type TargetSource interface {
	ListFanoutUserIDs(ctx context.Context, query TargetQuery) ([]int64, int64, bool, error)
}

// Option adjusts message service dependencies.
type Option func(*Service)

// Service owns system/activity inbox business rules.
type Service struct {
	cfg          Config
	repository   Repository
	targetSource TargetSource
	now          func() time.Time
}

// New creates a message inbox service using MySQL as source of truth.
func New(cfg Config, repository Repository, options ...Option) *Service {
	service := &Service{cfg: cfg, repository: repository, now: time.Now}
	for _, option := range options {
		option(service)
	}
	return service
}

// WithTargetSource connects fanout worker scopes to user-service without coupling to its storage.
func WithTargetSource(source TargetSource) Option {
	return func(s *Service) {
		s.targetSource = source
	}
}

// SetClock injects deterministic time for tests.
func (s *Service) SetClock(now func() time.Time) {
	if now != nil {
		s.now = now
	}
}

// ListMessageTabs returns the fixed App message tab summary.
func (s *Service) ListMessageTabs(ctx context.Context, userID int64) ([]messagedomain.TabSection, error) {
	if err := s.requireRepository(); err != nil {
		return nil, err
	}
	if userID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	nowMS := s.now().UnixMilli()
	systemUnread, err := s.repository.CountUnreadInboxMessages(ctx, userID, messagedomain.SectionSystem, nowMS)
	if err != nil {
		return nil, err
	}
	activityUnread, err := s.repository.CountUnreadInboxMessages(ctx, userID, messagedomain.SectionActivity, nowMS)
	if err != nil {
		return nil, err
	}

	return []messagedomain.TabSection{
		{Section: messagedomain.SectionUser, Title: "用户", UnreadCount: 0, Source: messagedomain.SourceTencentIMSDK},
		{Section: messagedomain.SectionSystem, Title: "系统", UnreadCount: systemUnread, Source: messagedomain.SourceBackend},
		{Section: messagedomain.SectionActivity, Title: "活动", UnreadCount: activityUnread, Source: messagedomain.SourceBackend},
	}, nil
}

// ListInboxMessages reads one cursor page for a backend-owned section.
func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section string, pageSize int32, pageToken string) ([]messagedomain.InboxMessage, string, error) {
	if err := s.requireRepository(); err != nil {
		return nil, "", err
	}
	if userID <= 0 {
		return nil, "", xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	section = normalizeSection(section)
	if !backendSection(section) {
		return nil, "", xerr.New(xerr.InvalidSection, "section is invalid")
	}
	limit := normalizePageSize(pageSize)
	cursor, err := decodeCursor(section, pageToken)
	if err != nil {
		return nil, "", err
	}

	messages, err := s.repository.ListInboxMessages(ctx, messagedomain.ListQuery{
		UserID:          userID,
		Section:         section,
		Limit:           limit + 1,
		CursorSentAtMS:  cursor.SentAtMS,
		CursorMessageID: cursor.MessageID,
		NowMS:           s.now().UnixMilli(),
	})
	if err != nil {
		return nil, "", err
	}
	for index := range messages {
		messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL)
		messages[index].RewardItems = inboxRewardItems(messages[index].MetadataJSON)
		messages[index].Actions = inboxActions(messages[index].MetadataJSON)
		// 旧返现消息没有 actions；读取投影补齐英文文案和后端驱动按钮，不回写历史钱包事实。
		messages[index] = normalizeVIPDailyCoinRebateNoticeCopy(messages[index])
	}
	if len(messages) <= limit {
		return messages, "", nil
	}
	page := messages[:limit]
	next := encodeCursor(section, page[len(page)-1])
	return page, next, nil
}

// MarkInboxMessageRead marks a single visible message read while preserving first read time.
func (s *Service) MarkInboxMessageRead(ctx context.Context, userID int64, messageID string) (messagedomain.InboxMessage, error) {
	if err := s.requireRepository(); err != nil {
		return messagedomain.InboxMessage{}, err
	}
	messageID = strings.TrimSpace(messageID)
	if userID <= 0 || messageID == "" {
		return messagedomain.InboxMessage{}, xerr.New(xerr.InvalidArgument, "user_id and message_id are required")
	}
	return s.repository.MarkInboxMessageRead(ctx, userID, messageID, s.now().UnixMilli())
}

// MarkInboxSectionRead marks all visible unread messages in a backend section.
func (s *Service) MarkInboxSectionRead(ctx context.Context, userID int64, section string) (int64, error) {
	if err := s.requireRepository(); err != nil {
		return 0, err
	}
	section = normalizeSection(section)
	if userID <= 0 || !backendSection(section) {
		return 0, xerr.New(xerr.InvalidSection, "section is invalid")
	}
	return s.repository.MarkInboxSectionRead(ctx, userID, section, s.now().UnixMilli())
}

// DeleteInboxMessage hides a visible message for the current user.
func (s *Service) DeleteInboxMessage(ctx context.Context, userID int64, messageID string) error {
	if err := s.requireRepository(); err != nil {
		return err
	}
	messageID = strings.TrimSpace(messageID)
	if userID <= 0 || messageID == "" {
		return xerr.New(xerr.InvalidArgument, "user_id and message_id are required")
	}
	return s.repository.DeleteInboxMessage(ctx, userID, messageID, s.now().UnixMilli())
}

// CreateInboxMessage materializes one producer event into a user inbox row.
func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.CreateInput) (messagedomain.InboxMessage, bool, error) {
	if err := s.requireRepository(); err != nil {
		return messagedomain.InboxMessage{}, false, err
	}
	input = normalizeCreateInput(input)
	if err := s.fillTemplateSnapshot(ctx, &input); err != nil {
		return messagedomain.InboxMessage{}, false, err
	}
	if err := validateCreateInput(input); err != nil {
		return messagedomain.InboxMessage{}, false, err
	}

	nowMS := s.now().UnixMilli()
	message := messagedomain.InboxMessage{
		MessageID:         idgen.New("msg"),
		UserID:            input.TargetUserID,
		MessageType:       input.MessageType,
		Producer:          input.Producer,
		ProducerEventID:   input.ProducerEventID,
		ProducerEventType: input.ProducerEventType,
		AggregateType:     input.AggregateType,
		AggregateID:       input.AggregateID,
		TemplateID:        input.TemplateID,
		TemplateVersion:   input.TemplateVersion,
		Title:             input.Title,
		Summary:           input.Summary,
		Body:              input.Body,
		IconURL:           input.IconURL,
		ImageURL:          input.ImageURL,
		ImageURLs:         append([]string(nil), input.ImageURLs...),
		RewardItems:       append([]messagedomain.RewardItem(nil), input.RewardItems...),
		Actions:           append([]messagedomain.InboxAction(nil), input.Actions...),
		ActionType:        input.ActionType,
		ActionParam:       input.ActionParam,
		Priority:          input.Priority,
		Status:            messagedomain.StatusVisible,
		SentAtMS:          firstPositive(input.SentAtMS, nowMS),
		ExpireAtMS:        input.ExpireAtMS,
		MetadataJSON:      input.MetadataJSON,
		CreatedAtMS:       nowMS,
		UpdatedAtMS:       nowMS,
	}
	created, existing, err := s.repository.SaveInboxMessage(ctx, message)
	if err != nil {
		return messagedomain.InboxMessage{}, false, err
	}
	if !created {
		if !sameInboxPayload(existing, message) {
			return messagedomain.InboxMessage{}, false, xerr.New(xerr.ProducerEventConflict, "producer event payload conflicts with existing inbox message")
		}
		existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
		existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
		existing.Actions = inboxActions(existing.MetadataJSON)
		return existing, false, nil
	}
	return existing, true, nil
}

// CreateFanoutJob records an async fanout job and keeps large broadcasts off the request path.
func (s *Service) CreateFanoutJob(ctx context.Context, input messagedomain.CreateFanoutInput) (messagedomain.FanoutJob, bool, error) {
	if err := s.requireRepository(); err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	input = normalizeFanoutInput(input)
	if err := validateFanoutInput(input); err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	nowMS := s.now().UnixMilli()
	job := messagedomain.FanoutJob{
		JobID:                idgen.New("mfan"),
		CommandID:            input.CommandID,
		MessageType:          input.MessageType,
		TargetScope:          input.TargetScope,
		TargetFilterJSON:     input.TargetFilterJSON,
		TemplateSnapshotJSON: input.TemplateSnapshotJSON,
		Status:               messagedomain.FanoutStatusPending,
		BatchSize:            normalizeBatchSize(input.BatchSize),
		CreatedBy:            input.CreatedBy,
		CreatedAtMS:          nowMS,
		UpdatedAtMS:          nowMS,
	}
	created, existing, err := s.repository.SaveFanoutJob(ctx, job)
	if err != nil {
		return messagedomain.FanoutJob{}, false, err
	}
	if !created {
		if !sameFanoutPayload(existing, job) {
			return messagedomain.FanoutJob{}, false, xerr.New(xerr.RequestConflict, "fanout command payload conflicts with existing job")
		}
		return existing, false, nil
	}
	return existing, true, nil
}

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

func (s *Service) fillTemplateSnapshot(ctx context.Context, input *messagedomain.CreateInput) error {
	if input.Title != "" || input.Summary != "" {
		return nil
	}
	if input.TemplateID == "" || input.TemplateVersion == "" {
		return nil
	}
	template, exists, err := s.repository.GetActiveMessageTemplate(ctx, input.TemplateID, input.TemplateVersion, input.MessageType)
	if err != nil {
		return err
	}
	if !exists {
		return xerr.New(xerr.NotFound, "message template not found")
	}
	input.Title = template.Title
	input.Summary = template.Summary
	input.Body = template.Body
	input.IconURL = template.IconURL
	input.ImageURL = template.ImageURL
	input.ActionType = template.ActionType
	input.ActionParam = template.ActionParam
	return nil
}

type pageCursor struct {
	Section   string `json:"section"`
	SentAtMS  int64  `json:"sent_at_ms"`
	MessageID string `json:"message_id"`
}

func encodeCursor(section string, message messagedomain.InboxMessage) string {
	payload, err := json.Marshal(pageCursor{Section: section, SentAtMS: message.SentAtMS, MessageID: message.MessageID})
	if err != nil {
		return ""
	}
	return base64.RawURLEncoding.EncodeToString(payload)
}

func decodeCursor(section string, encoded string) (pageCursor, error) {
	encoded = strings.TrimSpace(encoded)
	if encoded == "" {
		return pageCursor{Section: section}, nil
	}
	raw, err := base64.RawURLEncoding.DecodeString(encoded)
	if err != nil {
		return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
	}
	var cursor pageCursor
	if err := json.Unmarshal(raw, &cursor); err != nil {
		return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
	}
	if cursor.Section != section || cursor.SentAtMS <= 0 || strings.TrimSpace(cursor.MessageID) == "" {
		return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
	}
	return cursor, nil
}

func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateInput {
	input.Producer = strings.TrimSpace(input.Producer)
	input.ProducerEventID = strings.TrimSpace(input.ProducerEventID)
	input.ProducerEventType = strings.TrimSpace(input.ProducerEventType)
	input.MessageType = normalizeSection(input.MessageType)
	input.AggregateType = strings.TrimSpace(input.AggregateType)
	input.AggregateID = strings.TrimSpace(input.AggregateID)
	input.TemplateID = strings.TrimSpace(input.TemplateID)
	input.TemplateVersion = strings.TrimSpace(input.TemplateVersion)
	input.Title = strings.TrimSpace(input.Title)
	input.Summary = strings.TrimSpace(input.Summary)
	input.Body = strings.TrimSpace(input.Body)
	input.IconURL = strings.TrimSpace(input.IconURL)
	input.ImageURL = strings.TrimSpace(input.ImageURL)
	input.ImageURLs = normalizeInboxImageURLs(input.ImageURLs, input.ImageURL)
	input.RewardItems = normalizeInboxRewardItems(input.RewardItems)
	input.Actions = normalizeInboxActions(input.Actions)
	if input.ImageURL == "" && len(input.ImageURLs) > 0 {
		input.ImageURL = input.ImageURLs[0]
	}
	// 图片、奖励和操作按钮都写入 inbox owner 的受控 metadata 快照，避免扩表，同时输出强类型字段。
	input.MetadataJSON = inboxMetadataWithPresentation(input.MetadataJSON, input.ImageURLs, input.RewardItems, input.Actions)
	input.ActionType = strings.TrimSpace(input.ActionType)
	input.ActionParam = strings.TrimSpace(input.ActionParam)
	input.MetadataJSON = strings.TrimSpace(input.MetadataJSON)
	return input
}

func validateCreateInput(input messagedomain.CreateInput) error {
	if input.TargetUserID <= 0 || input.Producer == "" || input.ProducerEventID == "" {
		return xerr.New(xerr.InvalidArgument, "target_user_id, producer and producer_event_id are required")
	}
	if !backendSection(input.MessageType) {
		return xerr.New(xerr.InvalidSection, "message_type is invalid")
	}
	for _, field := range []struct {
		name  string
		value string
		max   int
	}{
		{"producer", input.Producer, 64},
		{"producer_event_id", input.ProducerEventID, 128},
		{"producer_event_type", input.ProducerEventType, 96},
		{"aggregate_type", input.AggregateType, 64},
		{"aggregate_id", input.AggregateID, 128},
		{"template_id", input.TemplateID, 96},
		{"template_version", input.TemplateVersion, 64},
		{"title", input.Title, 160},
		{"summary", input.Summary, 512},
		{"body", input.Body, 2048},
		{"icon_url", input.IconURL, 512},
		{"image_url", input.ImageURL, 512},
		{"action_type", input.ActionType, 64},
		{"action_param", input.ActionParam, 1024},
	} {
		if utf8.RuneCountInString(field.value) > field.max {
			return xerr.New(xerr.InvalidArgument, field.name+" is too long")
		}
	}
	if len(input.ImageURLs) > 100 {
		return xerr.New(xerr.InvalidArgument, "image_urls has too many items")
	}
	for _, imageURL := range input.ImageURLs {
		if utf8.RuneCountInString(imageURL) > 512 {
			return xerr.New(xerr.InvalidArgument, "image_urls item is too long")
		}
	}
	if len(input.RewardItems) > 100 {
		return xerr.New(xerr.InvalidArgument, "reward_items has too many items")
	}
	for _, item := range input.RewardItems {
		if item.RewardType != "resource" && item.RewardType != "coin" {
			return xerr.New(xerr.InvalidArgument, "reward_items reward_type is invalid")
		}
		if utf8.RuneCountInString(item.Name) > 160 || utf8.RuneCountInString(item.ImageURL) > 512 {
			return xerr.New(xerr.InvalidArgument, "reward_items item is too long")
		}
		if item.Quantity < 0 || item.DurationDays < 0 {
			return xerr.New(xerr.InvalidArgument, "reward_items values cannot be negative")
		}
	}
	if err := validateInboxActions(input.Actions); err != nil {
		return err
	}
	if input.Title == "" || input.Summary == "" {
		return xerr.New(xerr.InvalidArgument, "title and summary are required")
	}
	if !allowedActionType(input.ActionType) {
		return xerr.New(xerr.InvalidArgument, "action_type is invalid")
	}
	if input.ExpireAtMS > 0 && input.SentAtMS > 0 && input.ExpireAtMS <= input.SentAtMS {
		return xerr.New(xerr.InvalidArgument, "expire_at_ms must be after sent_at_ms")
	}
	if input.MetadataJSON != "" && !json.Valid([]byte(input.MetadataJSON)) {
		return xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
	}
	if len(input.ImageURLs) > 0 && !slices.Equal(inboxImageURLs(input.MetadataJSON, ""), input.ImageURLs) {
		return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when image_urls is present")
	}
	if len(input.RewardItems) > 0 && !slices.Equal(inboxRewardItems(input.MetadataJSON), input.RewardItems) {
		return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when reward_items is present")
	}
	if len(input.Actions) > 0 && !sameJSONValue(inboxActions(input.MetadataJSON), input.Actions) {
		return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when actions are present")
	}
	return nil
}

func normalizeFanoutInput(input messagedomain.CreateFanoutInput) messagedomain.CreateFanoutInput {
	input.CommandID = strings.TrimSpace(input.CommandID)
	input.MessageType = normalizeSection(input.MessageType)
	input.TargetScope = strings.TrimSpace(input.TargetScope)
	input.TargetFilterJSON = strings.TrimSpace(input.TargetFilterJSON)
	input.TemplateSnapshotJSON = strings.TrimSpace(input.TemplateSnapshotJSON)
	input.CreatedBy = strings.TrimSpace(input.CreatedBy)
	return input
}

func validateFanoutInput(input messagedomain.CreateFanoutInput) error {
	if input.CommandID == "" || input.TargetScope == "" || input.CreatedBy == "" {
		return xerr.New(xerr.InvalidArgument, "command_id, target_scope and created_by are required")
	}
	if !backendSection(input.MessageType) {
		return xerr.New(xerr.InvalidSection, "message_type is invalid")
	}
	if !allowedFanoutScope(input.TargetScope) {
		return xerr.New(xerr.InvalidArgument, "target_scope is invalid")
	}
	if input.TargetFilterJSON == "" || !json.Valid([]byte(input.TargetFilterJSON)) {
		return xerr.New(xerr.InvalidArgument, "target_filter_json is invalid")
	}
	if input.TemplateSnapshotJSON == "" || !json.Valid([]byte(input.TemplateSnapshotJSON)) {
		return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
	}
	var snapshot struct {
		Actions []messagedomain.InboxAction `json:"actions"`
	}
	if err := json.Unmarshal([]byte(input.TemplateSnapshotJSON), &snapshot); err != nil {
		return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
	}
	if err := validateInboxActions(normalizeInboxActions(snapshot.Actions)); err != nil {
		return err
	}
	return nil
}

func normalizeSection(section string) string {
	return strings.ToLower(strings.TrimSpace(section))
}

func backendSection(section string) bool {
	return section == messagedomain.SectionSystem || section == messagedomain.SectionActivity
}

func normalizePageSize(value int32) int {
	if value <= 0 {
		return defaultPageSize
	}
	if value > maxPageSize {
		return maxPageSize
	}
	return int(value)
}

func normalizeBatchSize(value int32) int32 {
	if value <= 0 {
		return defaultBatchSize
	}
	if value > maxBatchSize {
		return maxBatchSize
	}
	return value
}

func allowedActionType(actionType string) bool {
	switch actionType {
	case "", "wallet_withdraw_detail", "host_application_detail", "activity_detail", "room_detail", "user_profile", "app_h5", "coin_seller_transfer_detail":
		return true
	case "vip_coin_rebate_claim":
		// VIP 返现通知只携带 wallet 生成的 rebate_id JSON，客户端仍需调用受鉴权的
		// claim 接口才能入账；它不是任意外链或脚本动作，可以进入 system inbox。
		return true
	default:
		return false
	}
}

func allowedFanoutScope(scope string) bool {
	switch scope {
	case messagedomain.TargetScopeSingleUser, messagedomain.TargetScopeUserIDs, messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers, messagedomain.TargetScopeAllRegistered:
		return true
	default:
		return false
	}
}

func firstPositive(values ...int64) int64 {
	for _, value := range values {
		if value > 0 {
			return value
		}
	}
	return 0
}

func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.InboxMessage) bool {
	existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
	next.ImageURLs = inboxImageURLs(next.MetadataJSON, next.ImageURL)
	existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
	next.RewardItems = inboxRewardItems(next.MetadataJSON)
	existing.Actions = inboxActions(existing.MetadataJSON)
	next.Actions = inboxActions(next.MetadataJSON)
	return existing.UserID == next.UserID &&
		existing.MessageType == next.MessageType &&
		existing.Producer == next.Producer &&
		existing.ProducerEventID == next.ProducerEventID &&
		existing.ProducerEventType == next.ProducerEventType &&
		existing.AggregateType == next.AggregateType &&
		existing.AggregateID == next.AggregateID &&
		existing.TemplateID == next.TemplateID &&
		existing.TemplateVersion == next.TemplateVersion &&
		existing.Title == next.Title &&
		existing.Summary == next.Summary &&
		existing.Body == next.Body &&
		existing.IconURL == next.IconURL &&
		existing.ImageURL == next.ImageURL &&
		slices.Equal(existing.ImageURLs, next.ImageURLs) &&
		slices.Equal(existing.RewardItems, next.RewardItems) &&
		existing.ActionType == next.ActionType &&
		existing.ActionParam == next.ActionParam &&
		existing.Priority == next.Priority &&
		existing.ExpireAtMS == next.ExpireAtMS &&
		sameJSONText(existing.MetadataJSON, next.MetadataJSON)
}

func normalizeInboxImageURLs(values []string, legacyImageURL string) []string {
	result := make([]string, 0, len(values)+1)
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		result = append(result, value)
	}
	legacyImageURL = strings.TrimSpace(legacyImageURL)
	if legacyImageURL != "" && (len(result) == 0 || result[0] != legacyImageURL) {
		result = append([]string{legacyImageURL}, result...)
	}
	return result
}

func inboxMetadataWithPresentation(raw string, imageURLs []string, rewardItems []messagedomain.RewardItem, actions []messagedomain.InboxAction) string {
	raw = strings.TrimSpace(raw)
	metadata := map[string]any{}
	if raw != "" {
		if err := json.Unmarshal([]byte(raw), &metadata); err != nil {
			// validateCreateInput 继续负责返回稳定的 invalid metadata 错误。
			return raw
		}
	}
	if metadata == nil {
		metadata = map[string]any{}
	}
	if len(imageURLs) == 0 {
		delete(metadata, "image_urls")
	} else {
		metadata["image_urls"] = imageURLs
	}
	if len(rewardItems) == 0 {
		delete(metadata, "reward_items")
	} else {
		metadata["reward_items"] = rewardItems
	}
	if len(actions) == 0 {
		delete(metadata, "actions")
	} else {
		metadata["actions"] = actions
	}
	if len(metadata) == 0 {
		return ""
	}
	body, err := json.Marshal(metadata)
	if err != nil {
		return raw
	}
	return string(body)
}

func normalizeInboxRewardItems(values []messagedomain.RewardItem) []messagedomain.RewardItem {
	result := make([]messagedomain.RewardItem, 0, len(values))
	for _, value := range values {
		value.RewardType = strings.ToLower(strings.TrimSpace(value.RewardType))
		value.Name = strings.TrimSpace(value.Name)
		value.ImageURL = strings.TrimSpace(value.ImageURL)
		if value.RewardType == "" {
			continue
		}
		result = append(result, value)
	}
	return result
}

func inboxRewardItems(raw string) []messagedomain.RewardItem {
	var metadata struct {
		RewardItems []messagedomain.RewardItem `json:"reward_items"`
	}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
		return nil
	}
	return normalizeInboxRewardItems(metadata.RewardItems)
}

func inboxImageURLs(raw string, legacyImageURL string) []string {
	var metadata struct {
		ImageURLs []string `json:"image_urls"`
	}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
		return normalizeInboxImageURLs(nil, legacyImageURL)
	}
	return normalizeInboxImageURLs(metadata.ImageURLs, legacyImageURL)
}

func normalizeInboxActions(values []messagedomain.InboxAction) []messagedomain.InboxAction {
	result := make([]messagedomain.InboxAction, 0, len(values))
	for _, value := range values {
		value.Action = strings.ToLower(strings.TrimSpace(value.Action))
		value.Label = strings.TrimSpace(value.Label)
		value.Style = strings.ToLower(strings.TrimSpace(value.Style))
		if value.Style == "" {
			value.Style = "primary"
		}
		value.API.Method = strings.ToUpper(strings.TrimSpace(value.API.Method))
		if value.API.Method == "" {
			value.API.Method = "POST"
		}
		value.API.Path = strings.TrimSpace(value.API.Path)
		result = append(result, value)
	}
	return result
}

func inboxActions(raw string) []messagedomain.InboxAction {
	var metadata struct {
		Actions []messagedomain.InboxAction `json:"actions"`
	}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
		return nil
	}
	return normalizeInboxActions(metadata.Actions)
}

func validateInboxActions(actions []messagedomain.InboxAction) error {
	if len(actions) > 2 {
		return xerr.New(xerr.InvalidArgument, "actions has too many items")
	}
	seen := make(map[string]struct{}, len(actions))
	for _, action := range actions {
		if action.Action == "" || action.Label == "" {
			return xerr.New(xerr.InvalidArgument, "actions action and label are required")
		}
		if utf8.RuneCountInString(action.Action) > 64 || utf8.RuneCountInString(action.Label) > 64 {
			return xerr.New(xerr.InvalidArgument, "actions action or label is too long")
		}
		if _, exists := seen[action.Action]; exists {
			return xerr.New(xerr.InvalidArgument, "actions action must be unique")
		}
		seen[action.Action] = struct{}{}
		if action.Style != "primary" && action.Style != "secondary" {
			return xerr.New(xerr.InvalidArgument, "actions style is invalid")
		}
		switch action.API.Method {
		case "GET", "POST", "PUT", "PATCH", "DELETE":
		default:
			return xerr.New(xerr.InvalidArgument, "actions api method is invalid")
		}
		parsed, err := url.Parse(action.API.Path)
		if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" ||
			!strings.HasPrefix(parsed.Path, "/api/v1/") || strings.Contains(parsed.Path, "..") {
			return xerr.New(xerr.InvalidArgument, "actions api path is invalid")
		}
		if (action.API.Method == "GET" || action.API.Method == "DELETE") && action.API.Body != nil {
			return xerr.New(xerr.InvalidArgument, "actions api body is not supported for method")
		}
		payload, err := json.Marshal(action)
		if err != nil || len(payload) > 8192 {
			return xerr.New(xerr.InvalidArgument, "actions item is too large")
		}
	}
	return nil
}

func sameJSONValue(left any, right any) bool {
	leftBody, leftErr := json.Marshal(left)
	rightBody, rightErr := json.Marshal(right)
	return leftErr == nil && rightErr == nil && sameJSONText(string(leftBody), string(rightBody))
}

func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool {
	return existing.CommandID == next.CommandID &&
		existing.MessageType == next.MessageType &&
		existing.TargetScope == next.TargetScope &&
		sameJSONText(existing.TargetFilterJSON, next.TargetFilterJSON) &&
		sameJSONText(existing.TemplateSnapshotJSON, next.TemplateSnapshotJSON) &&
		existing.BatchSize == next.BatchSize &&
		existing.CreatedBy == next.CreatedBy
}

func sameJSONText(left string, right string) bool {
	left = strings.TrimSpace(left)
	right = strings.TrimSpace(right)
	if left == "" || left == "null" {
		return right == "" || right == "null"
	}
	leftCanonical, leftOK := canonicalJSON(left)
	rightCanonical, rightOK := canonicalJSON(right)
	if leftOK && rightOK {
		return leftCanonical == rightCanonical
	}
	return left == right
}

func canonicalJSON(value string) (string, bool) {
	var parsed any
	if err := json.Unmarshal([]byte(value), &parsed); err != nil {
		return "", false
	}
	encoded, err := json.Marshal(parsed)
	if err != nil {
		return "", false
	}
	return string(encoded), true
}
