package integration

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/imgroup"
	"hyapp/pkg/tencentim"
)

// TencentIMPublisher 是 room-service 到腾讯云 IM 的生产路径发布器。
type TencentIMPublisher struct {
	// client 持有腾讯云 IM REST API、UserSig 和管理员账号配置。
	client *tencentim.RESTClient
}

// NewTencentIMPublisher 创建腾讯云 IM 房间消息发布器。
func NewTencentIMPublisher(client *tencentim.RESTClient) *TencentIMPublisher {
	return &TencentIMPublisher{client: client}
}

// EnsureRoomGroup 确保每个 Room Cell 都有对应腾讯云 IM 群组。
func (p *TencentIMPublisher) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error {
	if p == nil || p.client == nil {
		// 未启用腾讯云 IM 时保持无副作用，便于本地测试只跑 room-service。
		return nil
	}

	groupID, err := imgroup.RoomGroupID(roomID)
	if err != nil {
		return err
	}
	return p.client.EnsureRoomGroup(ctx, groupID, ownerUserID)
}

// PublishRoomEvent 把房间系统事件发送到腾讯云 IM 群。
func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error {
	if p == nil || p.client == nil {
		// 未启用腾讯云 IM 时不阻断房间状态提交。
		return nil
	}

	groupID, err := imgroup.RoomGroupID(event.RoomID)
	if err != nil {
		return err
	}
	event.GroupID = groupID
	if err := p.client.PublishRoomEvent(ctx, event); err != nil {
		if !tencentim.IsRESTErrorCode(err, 10010, 10015) {
			return err
		}
		// 历史房间可能先产生 outbox 事件、但腾讯云群没有成功创建；保持原 GroupID，补建后只重发一次。
		if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil {
			return fmt.Errorf("ensure room im group after publish failure: %w", ensureErr)
		}
		return p.client.PublishRoomEvent(ctx, event)
	}
	return nil
}

// SetRoomGroupMemberMuted 把 Room Cell 禁言状态投影到腾讯 IM 原生群成员禁言。
// 群缺失时沿用房间消息补偿策略先补建再重试；其它错误返回 outbox worker 继续退避重试。
func (p *TencentIMPublisher) SetRoomGroupMemberMuted(ctx context.Context, roomID string, userID int64, muted bool) error {
	if p == nil || p.client == nil {
		return nil
	}
	groupID, err := imgroup.RoomGroupID(roomID)
	if err != nil {
		return err
	}
	if err := p.client.SetGroupMemberMuted(ctx, groupID, userID, muted); err != nil {
		if !tencentim.IsRESTErrorCode(err, 10010, 10015) {
			return err
		}
		if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil {
			return fmt.Errorf("ensure room im group after mute failure: %w", ensureErr)
		}
		return p.client.SetGroupMemberMuted(ctx, groupID, userID, muted)
	}
	return nil
}

// PublishOutboxEvent 补偿投递 room_outbox 中可转成房间系统消息的事件。
func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
	if p == nil || p.client == nil || envelope == nil {
		return nil
	}
	if envelope.GetEventType() == "RoomCreated" {
		var body roomeventsv1.RoomCreated
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return err
		}
		// 建群是腾讯 IM 的外部副作用，必须留在 outbox worker 内，不能阻塞 CreateRoom 返回。
		return p.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId())
	}

	event, publish, err := roomEventFromEnvelope(envelope)
	if err != nil || !publish {
		return err
	}
	if envelope.GetEventType() == "RoomUserKicked" {
		// KickUser 的安全边界除了系统消息，还必须补偿移除腾讯云 IM 群成员。
		groupID, err := imgroup.RoomGroupID(envelope.GetRoomId())
		if err != nil {
			return err
		}
		targetUserID, parseErr := strconv.ParseInt(event.TargetUserID, 10, 64)
		if parseErr != nil || targetUserID <= 0 {
			return fmt.Errorf("invalid room kick target_user_id %q", event.TargetUserID)
		}
		if err := p.client.DeleteGroupMember(ctx, groupID, targetUserID); err != nil {
			if !tencentim.IsRESTErrorCode(err, 10010, 10015) {
				return err
			}
			// 群缺失时没有成员可删，先补建群，再继续投递 kick 系统消息以推进 outbox。
			if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil {
				return fmt.Errorf("ensure room im group after member removal failure: %w", ensureErr)
			}
		}
	}

	return p.PublishRoomEvent(ctx, event)
}

// noopRoomEventPublisher 是本地和测试路径的房间消息发布占位实现。
type noopRoomEventPublisher struct{}

// NewNoopRoomEventPublisher 返回无外部副作用的房间消息发布器。
func NewNoopRoomEventPublisher() RoomEventPublisher {
	return noopRoomEventPublisher{}
}

// EnsureRoomGroup 在 noop 实现中不做外部调用。
func (noopRoomEventPublisher) EnsureRoomGroup(context.Context, string, int64) error {
	return nil
}

// PublishRoomEvent 在 noop 实现中不做外部调用。
func (noopRoomEventPublisher) PublishRoomEvent(context.Context, tencentim.RoomEvent) error {
	return nil
}

// noopOutboxPublisher 是首版异步 outbox 消费占位实现。
type noopOutboxPublisher struct{}

// NewNoopOutboxPublisher 返回一个占位 outbox 发布器。
// 当前版本保留 outbox 表和 worker 语义，activity/audit 投递后续替换该实现。
func NewNoopOutboxPublisher() OutboxPublisher {
	return noopOutboxPublisher{}
}

// PublishOutboxEvent 直接吞掉 outbox 事件。
func (noopOutboxPublisher) PublishOutboxEvent(context.Context, *roomeventsv1.EventEnvelope) error {
	// room_outbox worker 会把同一事件源异步投给腾讯云 IM、activity 或后续审计消费者。
	return nil
}

func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.RoomEvent, bool, error) {
	// EventEnvelope 是 room_outbox 的稳定信封，补偿投递时再按 event_type 解出客户端字段。
	base := tencentim.RoomEvent{
		EventID:     envelope.GetEventId(),
		RoomID:      envelope.GetRoomId(),
		EventType:   eventTypeForClient(envelope.GetEventType()),
		RoomVersion: envelope.GetRoomVersion(),
	}

	switch envelope.GetEventType() {
	case "RoomUserJoined":
		// 进房事件需要带 role，客户端可用于展示进房身份，但权限仍以后续 snapshot 为准。
		var body roomeventsv1.RoomUserJoined
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		// RoomUserJoined 的座驾必须从 outbox body 取快照，不能在补偿投递时按当前佩戴重算。
		base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle())
		base.Attributes = roomUserJoinedAttributesFromEvent(&body)
		return base, true, nil
	case "RoomUserLeft":
		// 离房事件只表达业务 presence 移除，不声明腾讯云 IM 连接是否已断开。
		var body roomeventsv1.RoomUserLeft
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		return base, true, nil
	case "RoomClosed":
		// 关房事件提示仍在群内的客户端刷新房间终态；私有通知由 RoomUserKicked 事实另行投递。
		var body roomeventsv1.RoomClosed
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.Attributes = map[string]string{"reason": body.GetReason()}
		return base, true, nil
	case "RoomMicChanged":
		// 麦位变更把 from/to 合并成一个客户端事件，避免换麦出现中间空位状态。
		var body roomeventsv1.RoomMicChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		base.SeatNo = body.GetToSeat()
		if base.SeatNo == 0 {
			base.SeatNo = body.GetFromSeat()
		}
		base.Attributes = map[string]string{
			"action":                        body.GetAction(),
			"mic_session_id":                body.GetMicSessionId(),
			"publish_state":                 body.GetPublishState(),
			"reason":                        body.GetReason(),
			"publish_deadline_ms":           fmt.Sprintf("%d", body.GetPublishDeadlineMs()),
			"publish_event_time_ms":         fmt.Sprintf("%d", body.GetPublishEventTimeMs()),
			"mic_muted":                     fmt.Sprintf("%t", body.GetMicMuted()),
			"seat_status":                   body.GetSeatStatus(),
			"target_gift_value":             fmt.Sprintf("%d", body.GetTargetGiftValue()),
			"target_nickname":               body.GetTargetNickname(),
			"target_avatar":                 body.GetTargetAvatar(),
			"target_display_user_id":        body.GetTargetDisplayUserId(),
			"target_pretty_display_user_id": body.GetTargetPrettyDisplayUserId(),
		}
		if body.GetActorUserId() == body.GetTargetUserId() {
			base.Attributes["nickname"] = body.GetTargetNickname()
			base.Attributes["avatar"] = body.GetTargetAvatar()
			base.Attributes["display_user_id"] = body.GetTargetDisplayUserId()
			base.Attributes["pretty_display_user_id"] = body.GetTargetPrettyDisplayUserId()
		}
		return base, true, nil
	case "RoomMicSeatLocked":
		// 锁麦事件只广播锁状态；首版锁麦不会自动下掉已占用用户。
		var body roomeventsv1.RoomMicSeatLocked
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.SeatNo = body.GetSeatNo()
		base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
		return base, true, nil
	case "RoomProfileUpdated":
		// 房间资料变更会影响标题、头像、简介和麦位布局，客户端收到后应以 room_version 更新本地 UI。
		var body roomeventsv1.RoomProfileUpdated
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.Attributes = map[string]string{
			"room_name":           body.GetRoomName(),
			"room_avatar":         body.GetRoomAvatar(),
			"room_description":    body.GetRoomDescription(),
			"seat_count":          fmt.Sprintf("%d", body.GetSeatCount()),
			"room_background_url": body.GetRoomBackgroundUrl(),
		}
		return base, true, nil
	case "RoomBackgroundChanged":
		// 背景切换是独立的房间 UI 状态变更，客户端无需从通用资料更新里反推。
		var body roomeventsv1.RoomBackgroundChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.Attributes = map[string]string{
			"background_id":       fmt.Sprintf("%d", body.GetBackgroundId()),
			"room_background_url": body.GetRoomBackgroundUrl(),
		}
		appendRoomMediaAttributes(base.Attributes, body.GetMedia(), "")
		return base, true, nil
	case "RoomDecorationChanged":
		var body roomeventsv1.RoomDecorationChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		if body.GetExpiresAtMs() > 0 && time.Now().UTC().UnixMilli() >= body.GetExpiresAtMs() {
			// 装扮应用事实继续留在 outbox/MQ，但补偿 IM 在资格到期后不再让客户端恢复失效视觉。
			return tencentim.RoomEvent{}, false, nil
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		colors, err := json.Marshal(body.GetTextColors())
		if err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		stops, err := json.Marshal(body.GetStops())
		if err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.Attributes = map[string]string{
			"decoration_type": body.GetDecorationType(), "resource_id": fmt.Sprintf("%d", body.GetResourceId()),
			"resource_code": body.GetResourceCode(), "resource_type": body.GetResourceType(), "name": body.GetName(),
			"asset_url": body.GetAssetUrl(), "preview_url": body.GetPreviewUrl(), "animation_url": body.GetAnimationUrl(),
			"format": body.GetFormat(), "metadata_json": body.GetMetadataJson(), "text_colors": string(colors),
			"stops": string(stops), "angle_degrees": fmt.Sprintf("%g", body.GetAngleDegrees()),
			"entitlement_id": body.GetEntitlementId(), "expires_at_ms": fmt.Sprintf("%d", body.GetExpiresAtMs()),
		}
		if strings.EqualFold(strings.TrimSpace(body.GetDecorationType()), "room_border") {
			// Flutter 的房间模型使用扁平字符串字段；结构化通用 asset_url 继续保留给旧装扮解析器。
			base.Attributes["room_border_url"] = body.GetAssetUrl()
		}
		return base, true, nil
	case "RoomImageMessageSent":
		var body roomeventsv1.RoomImageMessageSent
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.Attributes = map[string]string{
			"message_id": body.GetMessageId(), "command_id": body.GetCommandId(),
			"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
		}
		appendRoomMediaAttributes(base.Attributes, body.GetImage(), "image_")
		return base, true, nil
	case "RoomChatEnabledChanged":
		// 公屏开关事件只提示客户端更新输入态；单用户服务端禁言由 forbid_send_msg 的独立 outbox 投影负责。
		var body roomeventsv1.RoomChatEnabledChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
		return base, true, nil
	case "RoomPasswordChanged":
		// 锁房事件改由 activity-service 按 visible_region_id 生成区域通知；房间群不再重复投递。
		var body roomeventsv1.RoomPasswordChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		return tencentim.RoomEvent{}, false, nil
	case "RoomAdminChanged":
		// 管理员变更进入系统消息，客户端收到后仍应以最新 snapshot 修正本地权限 UI。
		var body roomeventsv1.RoomAdminChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
		return base, true, nil
	case "RoomUserMuted":
		// 禁言事件只影响公屏发言权限，不改变 presence、麦位或收消息资格。
		var body roomeventsv1.RoomUserMuted
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		base.Attributes = map[string]string{"muted": fmt.Sprintf("%t", body.GetMuted())}
		return base, true, nil
	case "RoomUserKicked":
		// KickUser 的即时系统消息和补偿踢群共用同一个 target_user_id。
		var body roomeventsv1.RoomUserKicked
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		return base, true, nil
	case "RoomUserUnbanned":
		// 解封只恢复再次 JoinRoom 的资格，不代表目标已经回到房间 presence。
		var body roomeventsv1.RoomUserUnbanned
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		return base, true, nil
	case "RoomGiftSent":
		// 送礼客户端消息只用 GiftSent 主事件承载，Heat/Rank 变化作为字段随同一条消息展示。
		var body roomeventsv1.RoomGiftSent
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		if strings.EqualFold(strings.TrimSpace(body.GetDisplayMode()), "batch") {
			// batch-send 仍写逐目标 RoomGiftSent 作为 durable 业务事实，但房间展示只发 RoomGiftBatchSent；否则新 Flutter 会同时收到 batch 和 N 条旧单目标 IM。
			return tencentim.RoomEvent{}, false, nil
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		base.GiftValue = body.GetGiftValue()
		// 房间内 IM 也透传 effect_types，客户端可以在房间流和区域广播流中使用同一套礼物展示判断。
		effectTypesJSON, err := json.Marshal(body.GetGiftEffectTypes())
		if err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.Attributes = map[string]string{
			"gift_id":                             body.GetGiftId(),
			"room_locked":                         fmt.Sprintf("%t", body.GetRoomLocked()),
			"gift_count":                          fmt.Sprintf("%d", body.GetGiftCount()),
			"coin_spent":                          fmt.Sprintf("%d", body.GetCoinSpent()),
			"billing_receipt_id":                  body.GetBillingReceiptId(),
			"gift_type_code":                      body.GetGiftTypeCode(),
			"cp_relation_type":                    body.GetCpRelationType(),
			"gift_name":                           body.GetGiftName(),
			"gift_icon_url":                       body.GetGiftIconUrl(),
			"gift_animation_url":                  body.GetGiftAnimationUrl(),
			"gift_effect_types":                   string(effectTypesJSON),
			"sender_name":                         body.GetSenderName(),
			"sender_avatar":                       body.GetSenderAvatar(),
			"sender_display_user_id":              body.GetSenderDisplayUserId(),
			"sender_pretty_display_user_id":       body.GetSenderPrettyDisplayUserId(),
			"sender_vip_level":                    fmt.Sprintf("%d", body.GetSenderVipLevel()),
			"sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(),
			"receiver_nickname":                   body.GetReceiverNickname(),
			"receiver_avatar":                     body.GetReceiverAvatar(),
			"receiver_display_user_id":            body.GetReceiverDisplayUserId(),
			"receiver_pretty_display_user_id":     body.GetReceiverPrettyDisplayUserId(),
		}
		if body.GetIsRobotGift() {
			// 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献，
			// 也会用 target_gift_value 覆盖麦位值；两者都必须从机器人协议中移除，避免假贡献和乱序回退。
			base.GiftValue = 0
			base.Attributes["is_robot_gift"] = "true"
		} else {
			base.Attributes["target_gift_value"] = fmt.Sprintf("%d", body.GetTargetGiftValue())
		}
		return base, true, nil
	case "RoomGiftBatchSent":
		// 批量送礼展示事件只面向新客户端；逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
		var body roomeventsv1.RoomGiftBatchSent
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		// 数组元素同样属于用户标识，必须保持字符串；仅把外层 attributes 设为字符串并不能避免客户端二次 jsonDecode 丢精度。
		targetUserIDsJSON := tencentim.FormatUserIDsJSON(body.GetTargetUserIds())
		targetsJSON, err := roomGiftBatchIMTargetsJSON(body.GetTargets())
		if err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(firstInt64(body.GetTargetUserIds()))
		base.GiftValue = body.GetTotalGiftValue()
		base.RoomHeat = body.GetRoomHeat()
		// batch 必须携带逐目标 wallet 收益快照，客户端不能把根节点 total/aggregate 平均分到不同 Host。
		// lucky 明细仍由独立协议承载，targets 只包含展示和账务快照，避免重复嵌套中奖对象。
		base.Attributes = map[string]string{
			"gift_id":                             body.GetGiftId(),
			"gift_count":                          fmt.Sprintf("%d", body.GetGiftCount()),
			"total_gift_value":                    fmt.Sprintf("%d", body.GetTotalGiftValue()),
			"billing_receipt_id":                  body.GetBillingReceiptId(),
			"pool_id":                             body.GetPoolId(),
			"gift_type_code":                      body.GetGiftTypeCode(),
			"cp_relation_type":                    body.GetCpRelationType(),
			"gift_name":                           body.GetGiftName(),
			"gift_icon_url":                       body.GetGiftIconUrl(),
			"target_count":                        fmt.Sprintf("%d", len(body.GetTargetUserIds())),
			"target_user_ids":                     targetUserIDsJSON,
			"targets":                             targetsJSON,
			"command_id":                          body.GetCommandId(),
			"visible_region_id":                   fmt.Sprintf("%d", body.GetVisibleRegionId()),
			"sender_name":                         body.GetSenderName(),
			"sender_avatar":                       body.GetSenderAvatar(),
			"sender_display_user_id":              body.GetSenderDisplayUserId(),
			"sender_pretty_display_user_id":       body.GetSenderPrettyDisplayUserId(),
			"sender_vip_level":                    fmt.Sprintf("%d", body.GetSenderVipLevel()),
			"sender_gift_broadcast_animation_url": body.GetSenderGiftBroadcastAnimationUrl(),
		}
		return base, true, nil
	case "RoomHeatChanged":
		// 机器人通道也会投递热度展示事件，客户端可独立刷新房间热度，不依赖主 outbox。
		var body roomeventsv1.RoomHeatChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.RoomHeat = body.GetCurrentHeat()
		base.Attributes = map[string]string{
			"delta":     fmt.Sprintf("%d", body.GetDelta()),
			"room_heat": fmt.Sprintf("%d", body.GetCurrentHeat()),
		}
		return base, true, nil
	case "RoomRankChanged":
		// 麦位收礼热度属于房间内展示数据；机器人事件只在 robot outbox 内广播。
		var body roomeventsv1.RoomRankChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
		base.GiftValue = body.GetGiftValue()
		base.Attributes = map[string]string{
			"user_id":    fmt.Sprintf("%d", body.GetUserId()),
			"score":      fmt.Sprintf("%d", body.GetScore()),
			"gift_value": fmt.Sprintf("%d", body.GetGiftValue()),
		}
		return base, true, nil
	case "RoomLuckyGiftDrawn":
		// 真人中奖由 lucky-gift owner 事实派生；room-service 只把稳定快照翻译为现有 Flutter lucky_gift_drawn 协议。
		var body roomeventsv1.RoomLuckyGiftDrawn
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		base.GiftValue = body.GetEffectiveRewardCoins()
		base.Attributes = map[string]string{
			"event_type":                    "lucky_gift_drawn",
			"draw_id":                       body.GetDrawId(),
			"command_id":                    body.GetCommandId(),
			"sender_user_id":                fmt.Sprintf("%d", body.GetSenderUserId()),
			"target_user_id":                fmt.Sprintf("%d", body.GetTargetUserId()),
			"sender_name":                   body.GetSenderName(),
			"sender_avatar":                 body.GetSenderAvatar(),
			"sender_display_user_id":        body.GetSenderDisplayUserId(),
			"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
			"gift_id":                       body.GetGiftId(),
			"gift_count":                    fmt.Sprintf("%d", body.GetGiftCount()),
			"pool_id":                       body.GetPoolId(),
			"visible_region_id":             fmt.Sprintf("%d", body.GetVisibleRegionId()),
			"room_locked":                   fmt.Sprintf("%t", body.GetRoomLocked()),
			"country_id":                    fmt.Sprintf("%d", body.GetCountryId()),
			"coin_spent":                    fmt.Sprintf("%d", body.GetCoinSpent()),
			"rule_version":                  fmt.Sprintf("%d", body.GetRuleVersion()),
			"experience_pool":               body.GetExperiencePool(),
			"selected_tier_id":              body.GetSelectedTierId(),
			"multiplier_ppm":                fmt.Sprintf("%d", body.GetMultiplierPpm()),
			"base_reward_coins":             fmt.Sprintf("%d", body.GetBaseRewardCoins()),
			"effective_reward_coins":        fmt.Sprintf("%d", body.GetEffectiveRewardCoins()),
			"stage_feedback":                fmt.Sprintf("%t", body.GetStageFeedback()),
			"high_multiplier":               fmt.Sprintf("%t", body.GetHighMultiplier()),
			"reward_status":                 body.GetRewardStatus(),
			"wallet_transaction_id":         body.GetWalletTransactionId(),
			"created_at_ms":                 fmt.Sprintf("%d", body.GetDrawCreatedAtMs()),
			"reward_granted_at_ms":          fmt.Sprintf("%d", body.GetRewardGrantedAtMs()),
		}
		return base, true, nil
	case "RoomRobotLuckyGiftDrawn":
		// 机器人幸运礼物只发房间展示协议，不进入 activity-service 真实抽奖和奖池链路。
		var body roomeventsv1.RoomRobotLuckyGiftDrawn
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
		// 中奖金额保留在 effective_reward_coins 供动画展示；顶层 gift_value 必须为 0，
		// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
		base.GiftValue = 0
		base.Attributes = map[string]string{
			"event_type": "lucky_gift_drawn",
			// RoomEvent.GiftValue=0 会被 omitempty 省略；显式扁平 gift_value=0，防止 Flutter 回退读取中奖额并累计成员贡献。
			"gift_value":             "0",
			"draw_id":                body.GetDrawId(),
			"command_id":             body.GetCommandId(),
			"sender_user_id":         fmt.Sprintf("%d", body.GetSenderUserId()),
			"target_user_id":         fmt.Sprintf("%d", body.GetTargetUserId()),
			"gift_id":                body.GetGiftId(),
			"gift_count":             fmt.Sprintf("%d", body.GetGiftCount()),
			"pool_id":                body.GetPoolId(),
			"multiplier_ppm":         fmt.Sprintf("%d", body.GetMultiplierPpm()),
			"base_reward_coins":      fmt.Sprintf("%d", body.GetBaseRewardCoins()),
			"effective_reward_coins": fmt.Sprintf("%d", body.GetEffectiveRewardCoins()),
			"created_at_ms":          fmt.Sprintf("%d", body.GetCreatedAtMs()),
			"visible_region_id":      fmt.Sprintf("%d", body.GetVisibleRegionId()),
			"is_robot":               fmt.Sprintf("%t", body.GetIsRobot()),
			"synthetic":              fmt.Sprintf("%t", body.GetSynthetic()),
			"reward_status":          "granted",
		}
		return base, true, nil
	case "RoomRocketFuelChanged":
		var body roomeventsv1.RoomRocketFuelChanged
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
		base.Attributes = map[string]string{
			"rocket_id":            body.GetRocketId(),
			"level":                fmt.Sprintf("%d", body.GetLevel()),
			"added_fuel":           fmt.Sprintf("%d", body.GetAddedFuel()),
			"effective_added_fuel": fmt.Sprintf("%d", body.GetEffectiveAddedFuel()),
			"overflow_fuel":        fmt.Sprintf("%d", body.GetOverflowFuel()),
			"current_fuel":         fmt.Sprintf("%d", body.GetCurrentFuel()),
			"fuel_threshold":       fmt.Sprintf("%d", body.GetFuelThreshold()),
			"status":               body.GetStatus(),
			"reset_at_ms":          fmt.Sprintf("%d", body.GetResetAtMs()),
			"gift_id":              body.GetGiftId(),
			"gift_count":           fmt.Sprintf("%d", body.GetGiftCount()),
			"command_id":           body.GetCommandId(),
		}
		return base, true, nil
	case "RoomRocketIgnited":
		var body roomeventsv1.RoomRocketIgnited
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetIgniterUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTop1UserId())
		base.Attributes = map[string]string{
			"rocket_id":         body.GetRocketId(),
			"level":             fmt.Sprintf("%d", body.GetLevel()),
			"rocket_level":      fmt.Sprintf("%d", body.GetLevel()),
			"room_short_id":     body.GetRoomShortId(),
			"rocket_cover_url":  body.GetRocketCoverUrl(),
			"current_fuel":      fmt.Sprintf("%d", body.GetCurrentFuel()),
			"fuel_threshold":    fmt.Sprintf("%d", body.GetFuelThreshold()),
			"ignited_at_ms":     fmt.Sprintf("%d", body.GetIgnitedAtMs()),
			"launch_at_ms":      fmt.Sprintf("%d", body.GetLaunchAtMs()),
			"reset_at_ms":       fmt.Sprintf("%d", body.GetResetAtMs()),
			"broadcast_scope":   body.GetBroadcastScope(),
			"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
			"top1_user_id":      fmt.Sprintf("%d", body.GetTop1UserId()),
			"igniter_user_id":   fmt.Sprintf("%d", body.GetIgniterUserId()),
			"command_id":        body.GetCommandId(),
			"room_locked":       fmt.Sprintf("%t", body.GetRoomLocked()),
		}
		return base, true, nil
	case "RoomRocketLaunched":
		var body roomeventsv1.RoomRocketLaunched
		if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		rewardsJSON, err := json.Marshal(body.GetRewards())
		if err != nil {
			return tencentim.RoomEvent{}, false, err
		}
		base.ActorUserID = tencentim.FormatOptionalUserID(body.GetIgniterUserId())
		base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTop1UserId())
		base.Attributes = map[string]string{
			"rocket_id":       body.GetRocketId(),
			"level":           fmt.Sprintf("%d", body.GetLevel()),
			"next_level":      fmt.Sprintf("%d", body.GetNextLevel()),
			"launched_at_ms":  fmt.Sprintf("%d", body.GetLaunchedAtMs()),
			"reset_at_ms":     fmt.Sprintf("%d", body.GetResetAtMs()),
			"top1_user_id":    fmt.Sprintf("%d", body.GetTop1UserId()),
			"igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()),
			"rewards_json":    string(rewardsJSON),
		}
		return base, true, nil
	case "RoomRocketRewardGranted":
		// 奖励 IM 需要头像、昵称和短 ID，room-service 不反查 user-service；
		// activity-service 消费同一事实后做资料 enrichment 并投递房间播报。
		return tencentim.RoomEvent{}, false, nil
	default:
		// RoomCreated/HeatChanged/RankChanged 不单独推客户端，避免一条命令产生多条系统消息。
		return tencentim.RoomEvent{}, false, nil
	}
}

func roomEntryVehicleFromEvent(item *roomeventsv1.RoomEntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot {
	if item == nil || item.GetResourceId() <= 0 {
		return nil
	}
	return &tencentim.RoomEntryVehicleSnapshot{
		ResourceID:    item.GetResourceId(),
		ResourceCode:  item.GetResourceCode(),
		Name:          item.GetName(),
		AssetURL:      item.GetAssetUrl(),
		PreviewURL:    item.GetPreviewUrl(),
		AnimationURL:  item.GetAnimationUrl(),
		MetadataJSON:  item.GetMetadataJson(),
		EntitlementID: item.GetEntitlementId(),
		ExpiresAtMS:   item.GetExpiresAtMs(),
	}
}

func roomUserJoinedAttributesFromEvent(body *roomeventsv1.RoomUserJoined) map[string]string {
	if body == nil {
		return nil
	}
	values := map[string]string{
		"role":                   strings.TrimSpace(body.GetRole()),
		"nickname":               strings.TrimSpace(body.GetNickname()),
		"avatar":                 strings.TrimSpace(body.GetAvatar()),
		"display_user_id":        strings.TrimSpace(body.GetDisplayUserId()),
		"pretty_display_user_id": strings.TrimSpace(body.GetPrettyDisplayUserId()),
	}
	// RoomEvent.MarshalJSON 会把 attributes 平铺成客户端顶层字段；空字段不发送，避免客户端把空快照当成明确资料值。
	for key, value := range values {
		if value == "" {
			delete(values, key)
		}
	}
	// VIP 入场开关必须显式传 false；否则 Flutter 无法区分“普通进房”与旧消息缺字段。
	// 这些值都来自 RoomUserJoined 固化事实，补偿投递不能按用户当前 VIP 重新计算历史进房表现。
	values["vip_program_type"] = strings.TrimSpace(body.GetVipProgramType())
	values["effective_vip_level"] = fmt.Sprintf("%d", body.GetEffectiveVipLevel())
	values["effective_vip_name"] = strings.TrimSpace(body.GetEffectiveVipName())
	values["room_entry_notice_enabled"] = fmt.Sprintf("%t", body.GetRoomEntryNoticeEnabled())
	return values
}

type roomGiftBatchIMTarget struct {
	TargetUserID                string `json:"target_user_id"`
	ReceiverNickname            string `json:"receiver_nickname,omitempty"`
	ReceiverAvatar              string `json:"receiver_avatar,omitempty"`
	ReceiverDisplayUserID       string `json:"receiver_display_user_id,omitempty"`
	ReceiverPrettyDisplayUserID string `json:"receiver_pretty_display_user_id,omitempty"`
	GiftValue                   string `json:"gift_value"`
	TargetGiftValue             string `json:"target_gift_value"`
	CoinSpent                   string `json:"coin_spent"`
	BillingReceiptID            string `json:"billing_receipt_id,omitempty"`
	CommandID                   string `json:"command_id,omitempty"`
}

func roomGiftBatchIMTargetsJSON(targets []*roomeventsv1.RoomGiftBatchTarget) (string, error) {
	items := make([]roomGiftBatchIMTarget, 0, len(targets))
	for _, target := range targets {
		if target == nil || target.GetTargetUserId() <= 0 {
			continue
		}
		item := roomGiftBatchIMTarget{
			TargetUserID:                strconv.FormatInt(target.GetTargetUserId(), 10),
			ReceiverNickname:            target.GetReceiverNickname(),
			ReceiverAvatar:              target.GetReceiverAvatar(),
			ReceiverDisplayUserID:       target.GetReceiverDisplayUserId(),
			ReceiverPrettyDisplayUserID: target.GetReceiverPrettyDisplayUserId(),
			GiftValue:                   strconv.FormatInt(target.GetGiftValue(), 10),
			TargetGiftValue:             strconv.FormatInt(target.GetTargetGiftValue(), 10),
			CoinSpent:                   strconv.FormatInt(target.GetCoinSpent(), 10),
			BillingReceiptID:            target.GetBillingReceiptId(),
			CommandID:                   target.GetCommandId(),
		}
		items = append(items, item)
	}
	payload, err := json.Marshal(items)
	if err != nil {
		return "", err
	}
	return string(payload), nil
}

func firstInt64(values []int64) int64 {
	for _, value := range values {
		if value > 0 {
			return value
		}
	}
	return 0
}

func eventTypeForClient(eventType string) string {
	// protobuf 事件名使用 PascalCase，客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。
	switch eventType {
	case "RoomUserJoined":
		return "room_user_joined"
	case "RoomUserLeft":
		return "room_user_left"
	case "RoomClosed":
		return "room_closed"
	case "RoomMicChanged":
		return "room_mic_changed"
	case "RoomMicSeatLocked":
		return "room_mic_seat_locked"
	case "RoomProfileUpdated":
		return "room_profile_updated"
	case "RoomBackgroundChanged":
		return "room_background_changed"
	case "RoomDecorationChanged":
		return "room_decoration_changed"
	case "RoomImageMessageSent":
		return "room_image_message"
	case "RoomChatEnabledChanged":
		return "room_chat_enabled_changed"
	case "RoomPasswordChanged":
		return "room_password_changed"
	case "RoomAdminChanged":
		return "room_admin_changed"
	case "RoomUserMuted":
		return "room_user_muted"
	case "RoomUserKicked":
		return "room_user_kicked"
	case "RoomUserUnbanned":
		return "room_user_unbanned"
	case "RoomGiftSent":
		return "room_gift_sent"
	case "RoomGiftBatchSent":
		return "room_gift_batch_sent"
	case "RoomHeatChanged":
		return "room_heat_changed"
	case "RoomRankChanged":
		return "room_rank_changed"
	case "RoomRobotLuckyGiftDrawn":
		return "lucky_gift_drawn"
	case "RoomLuckyGiftDrawn":
		return "lucky_gift_drawn"
	case "RoomRocketFuelChanged":
		return "room_rocket_fuel_changed"
	case "RoomRocketIgnited":
		return "room_rocket_ignited"
	case "RoomRocketLaunched":
		return "room_rocket_launched"
	case "RoomRocketRewardGranted":
		return "room_rocket_reward_granted"
	default:
		return eventType
	}
}

func appendRoomMediaAttributes(attributes map[string]string, media *roomeventsv1.RoomMediaSnapshot, prefix string) {
	if attributes == nil || media == nil {
		return
	}
	attributes[prefix+"url"] = media.GetUrl()
	attributes[prefix+"object_key"] = media.GetObjectKey()
	attributes[prefix+"content_type"] = media.GetContentType()
	attributes[prefix+"format"] = media.GetFormat()
	attributes[prefix+"size_bytes"] = fmt.Sprintf("%d", media.GetSizeBytes())
	attributes[prefix+"width"] = fmt.Sprintf("%d", media.GetWidth())
	attributes[prefix+"height"] = fmt.Sprintf("%d", media.GetHeight())
	attributes[prefix+"animated"] = fmt.Sprintf("%t", media.GetAnimated())
	attributes[prefix+"frame_count"] = fmt.Sprintf("%d", media.GetFrameCount())
	attributes[prefix+"duration_ms"] = fmt.Sprintf("%d", media.GetDurationMs())
	attributes[prefix+"poster_url"] = media.GetPosterUrl()
	attributes[prefix+"thumbnail_url"] = media.GetThumbnailUrl()
	attributes[prefix+"sha256"] = media.GetSha256()
}
