// Package app 负责把 room-service 的领域服务、存储、路由目录和 gRPC 入口装配成进程。
package app

import (
	"context"
	"errors"
	"log/slog"
	"net"
	"sync"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	activityv1 "hyapp.local/api/proto/activity/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/healthhttp"
	"hyapp/pkg/logx"
	"hyapp/pkg/luckygiftmq"
	"hyapp/pkg/rocketmqx"
	"hyapp/pkg/roommq"
	serviceapp "hyapp/pkg/servicekit/app"
	servicegrpc "hyapp/pkg/servicekit/grpcserver"
	servicehealth "hyapp/pkg/servicekit/health"
	servicemq "hyapp/pkg/servicekit/mq"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/tencentrtc"
	"hyapp/pkg/usermq"
	"hyapp/pkg/walletmq"
	"hyapp/services/room-service/internal/config"
	"hyapp/services/room-service/internal/healthcheck"
	"hyapp/services/room-service/internal/integration"
	roomservice "hyapp/services/room-service/internal/room/service"
	"hyapp/services/room-service/internal/router"
	mysqlstorage "hyapp/services/room-service/internal/storage/mysql"
	grpcserver "hyapp/services/room-service/internal/transport/grpc"
)

// App 负责装配 room-service 运行时依赖。
type App struct {
	// cfg 保留启动配置，便于后续扩展后台 worker 时复用。
	cfg config.Config
	// grpcServer 承载 RoomCommandService、RoomGuardService 和标准 gRPC health。
	grpcServer *grpc.Server
	// service 是 Room Cell 领域入口，后台 presence worker 复用它执行清理命令。
	service *roomservice.Service
	// listener 是 room-service 的唯一网络入口。
	listener net.Listener
	// walletConn 是 SendGift 同步扣费依赖。
	walletConn *grpc.ClientConn
	// activityConn 是 room outbox 事实投递到 activity-service 的 best-effort 通道。
	activityConn *grpc.ClientConn
	// luckyGiftConn 是 SendGift 同步抽奖依赖，独立于 activity-service 的 outbox fanout 通道。
	luckyGiftConn *grpc.ClientConn
	// robotConn 是真人房间机器人运行时读取全站机器人池的依赖。
	robotConn *grpc.ClientConn
	// userConn 是真人房间机器人运行时读取机器人国家资料的依赖。
	userConn *grpc.ClientConn
	// repository 是 MySQL 持久化实现，保存 meta、command log、snapshot 和 outbox。
	repository *mysqlstorage.Repository
	// redisClose 关闭 Redis client，Redis directory 本身不拥有额外生命周期。
	redisClose func() error
	// nodeRegistry 保存当前节点可被 owner 转发直连的实例地址。
	nodeRegistry router.NodeRegistry
	// health 汇总 gRPC、runtime、MySQL 和 Redis lease 探测状态。
	health *healthcheck.State
	// healthHTTP 给 CLB 和发布脚本提供 HTTP readiness，不承载业务请求。
	healthHTTP *healthhttp.Server
	// mqProducers 在 outbox worker 启动前打开，承载 fanout 和延迟发射唤醒。
	mqProducers []*rocketmqx.Producer
	// mqConsumers 在 gRPC 服务启动前订阅，承接火箭唤醒、用户区域、wallet 装扮失效和幸运礼物 owner 事实。
	mqConsumers []*rocketmqx.Consumer
	// workers 统一控制本节点后台 worker，关闭时必须先停止 worker 再释放 MySQL/Redis。
	workers *serviceapp.BackgroundGroup
	// closeOnce 防止信号退出和 Serve 错误同时触发重复关闭。
	closeOnce sync.Once
}

// New 初始化 room-service 应用。
func New(cfg config.Config) (*App, error) {
	normalized, err := config.Normalize(cfg)
	if err != nil {
		return nil, err
	}
	cfg = normalized

	// wallet-service 是 SendGift 主链路依赖，连接对象创建失败时不能启动。
	walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return nil, err
	}
	var activityConn *grpc.ClientConn
	if cfg.ActivityServiceAddr != "" {
		activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
		if err != nil {
			_ = walletConn.Close()
			return nil, err
		}
	}
	var luckyGiftConn *grpc.ClientConn
	if cfg.LuckyGiftServiceAddr != "" {
		luckyGiftConn, err = grpc.Dial(cfg.LuckyGiftServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
		if err != nil {
			closeClientConn(activityConn)
			_ = walletConn.Close()
			return nil, err
		}
	}
	robotConn, err := grpc.Dial(cfg.RobotServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		closeClientConn(luckyGiftConn)
		closeClientConn(activityConn)
		_ = walletConn.Close()
		return nil, err
	}
	userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		closeClientConn(robotConn)
		closeClientConn(luckyGiftConn)
		closeClientConn(activityConn)
		_ = walletConn.Close()
		return nil, err
	}
	closeRobotDepsOnError := true
	defer func() {
		if closeRobotDepsOnError {
			closeClientConn(userConn)
			closeClientConn(robotConn)
			closeClientConn(luckyGiftConn)
			closeClientConn(activityConn)
			_ = walletConn.Close()
		}
	}()

	startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	// MySQL 是房间恢复的持久来源，启动阶段必须确认连接池可用。
	repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns)
	if err != nil {
		closeClientConn(activityConn)
		_ = walletConn.Close()
		return nil, err
	}

	if cfg.MySQLAutoMigrate {
		// 本地和测试环境可自动建表，线上应通过显式迁移控制结构变更。
		if err := repository.Migrate(startupCtx); err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			return nil, err
		}
	}

	// Redis directory 保存 room owner lease，是单房间单活的运行时仲裁来源。
	redisClient, err := router.NewRedisClient(startupCtx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
	if err != nil {
		_ = repository.Close()
		closeClientConn(activityConn)
		_ = walletConn.Close()
		return nil, err
	}

	directory := router.NewRedisDirectory(redisClient)
	roomPublisher := integration.NewNoopRoomEventPublisher()
	outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
	var rtcUserRemover integration.RTCUserRemover
	var rtcUserAudioBlocker integration.RTCUserAudioBlocker
	var imUserMuter integration.IMUserMuter
	var tencentPublisher integration.OutboxPublisher
	if cfg.TencentIM.Enabled {
		// 腾讯云 IM 替代自研 IM；room-service 只负责服务端 REST 群消息桥接。
		tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
		tencentPublisher = concreteTencentPublisher
		imUserMuter = concreteTencentPublisher
		roomPublisher = concreteTencentPublisher
		// 图片消息和 VIP 房间装扮都是用户可见的持久事实，必须由 durable room_outbox 补偿腾讯 IM；
		// 只过滤这两类事件，避免把现有 direct-IM 展示事件整体接入后制造双消息。
		outboxPublishers = append(outboxPublishers, integration.NewEventTypeOutboxPublisher(
			concreteTencentPublisher,
			"RoomImageMessageSent",
			"RoomDecorationChanged",
		))
	}
	if cfg.TencentRTC.Enabled {
		rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		rtcUserRemover = rtcClient
		rtcUserAudioBlocker = rtcClient
	}

	mqProducers := make([]*rocketmqx.Producer, 0, 2)
	mqConsumers := make([]*rocketmqx.Consumer, 0, 5)
	var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
	if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
		if activityConn != nil {
			activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
			outboxPublishers = append(outboxPublishers, activityPublisher)
		}
	}
	if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
		outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqProducers = append(mqProducers, outboxProducer)
		outboxPublishers = append(outboxPublishers, integration.NewRocketMQOutboxPublisher(outboxProducer, cfg.RocketMQ.RoomOutbox.Topic, cfg.RocketMQ.RoomEventTagMode))
	}
	if cfg.RocketMQ.RocketLaunch.Enabled {
		rocketProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RocketLaunch.ProducerGroup))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqProducers = append(mqProducers, rocketProducer)
		rocketLaunchScheduler = integration.NewRocketMQRocketLaunchScheduler(rocketProducer, cfg.RocketMQ.RocketLaunch.Topic)
	}
	outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
	var luckyGiftClient integration.LuckyGiftClient
	if luckyGiftConn != nil {
		// 幸运礼物已经是独立 owner，room-service 只把扣费成功后的服务端事实同步交给 lucky-gift-service。
		luckyGiftClient = integration.NewGRPCLuckyGiftClient(luckyGiftConn)
	}

	// 领域服务只依赖接口，App 层负责选择 MySQL/Redis/gRPC 具体实现。
	svc := roomservice.New(roomservice.Config{
		NodeID:                    cfg.NodeID,
		LeaseTTL:                  cfg.LeaseTTL,
		RankLimit:                 cfg.RankLimit,
		SnapshotEveryN:            cfg.SnapshotEveryN,
		PresenceStaleAfter:        cfg.PresenceStaleAfter,
		MicPublishTimeout:         cfg.MicPublishTimeout,
		RTCUserRemover:            rtcUserRemover,
		RTCUserAudioBlocker:       rtcUserAudioBlocker,
		IMUserMuter:               imUserMuter,
		RoomRocketLaunchScheduler: rocketLaunchScheduler,
		RobotDisplayPublisher:     tencentPublisher,
		RoomDisplayPublisher:      tencentPublisher,
		HumanRobotPoolProvider:    integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
		RoomGiftLeaderboard:       roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
		RoomListCache:             roomservice.NewRedisRoomListCacheStore(redisClient),
		LuckyGiftSendLocker:       roomservice.NewRedisLuckyGiftSendLocker(redisClient),
		LuckyGiftSendLockTTL:      cfg.LuckyGiftSendLockTTL,
	}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
	if cfg.RocketMQ.RocketLaunch.Enabled {
		rocketConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RocketLaunch.ConsumerGroup, cfg.RocketMQ.RocketLaunch.ConsumerMaxReconsumeTimes, 0))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		if err := rocketConsumer.Subscribe(cfg.RocketMQ.RocketLaunch.Topic, roommq.TagRoomRocketLaunchDue, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			due, err := roommq.DecodeRoomRocketLaunchDueMessage(message.Body)
			if err != nil {
				return err
			}
			return svc.HandleRoomRocketLaunchDue(ctx, due)
		}); err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqConsumers = append(mqConsumers, rocketConsumer)
	}
	if cfg.RocketMQ.UserOutbox.Enabled {
		userConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.UserOutbox.ConsumerGroup, cfg.RocketMQ.UserOutbox.ConsumerMaxReconsumeTimes, 0))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		if err := userConsumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			outboxMessage, err := usermq.DecodeUserOutboxMessage(message.Body)
			if err != nil {
				return err
			}
			return svc.HandleUserOutboxMessage(ctx, outboxMessage)
		}); err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqConsumers = append(mqConsumers, userConsumer)
	}
	if cfg.RocketMQ.WalletOutbox.Enabled {
		// 兼容 legacy Tag 与 typed Tag 的滚动窗口；独立 group 只消费会改变当前房间装扮的 owner 事实。
		walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
			walletmq.EventTypeVIPActivated,
			"ResourceChanged",
			"ResourceGrantRevoked",
			"UserResourceChanged",
			"VipEffectiveChanged",
			"VipProgramChanged",
		)
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		walletConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes, 0))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		if err := walletConsumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
			if err != nil {
				return err
			}
			return svc.HandleWalletDecorationEvent(ctx, walletMessage)
		}); err != nil {
			_ = walletConsumer.Shutdown()
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqConsumers = append(mqConsumers, walletConsumer)
	}
	if cfg.RocketMQ.WalletRealtimeOutbox.Enabled {
		// 红包 projection 是可重建读模型；独立 group 从最早保留位点回放，覆盖发布前已经存在且仍未过期的红包。
		redPacketTags, err := walletmq.LegacyCompatibleTagExpression(
			walletmq.EventTypeWalletRedPacketCreated,
			walletmq.EventTypeWalletRedPacketClaimed,
			walletmq.EventTypeWalletRedPacketRefunded,
		)
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		redPacketConsumer, err := rocketmqx.NewConsumer(walletRedPacketProjectionConsumerConfig(cfg.RocketMQ))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		if err := redPacketConsumer.Subscribe(cfg.RocketMQ.WalletRealtimeOutbox.Topic, redPacketTags, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
			if err != nil {
				return err
			}
			return svc.HandleWalletRedPacketProjectionEvent(ctx, walletMessage)
		}); err != nil {
			_ = redPacketConsumer.Shutdown()
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqConsumers = append(mqConsumers, redPacketConsumer)
	}
	if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
		luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
		if err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		if err := luckyGiftConsumer.Subscribe(cfg.RocketMQ.LuckyGiftOutbox.Topic, luckygiftmq.TagLuckyGiftDrawn, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			envelope, _, err := luckygiftmq.DecodeLuckyGiftOutboxMessage(message.Body)
			if err != nil {
				return err
			}
			// 消费回调只在本地 room_outbox 持久化成功后确认；腾讯 IM 失败由本服务 outbox worker 独立补偿。
			return svc.HandleLuckyGiftOutboxEvent(ctx, envelope)
		}); err != nil {
			_ = repository.Close()
			closeClientConn(activityConn)
			_ = walletConn.Close()
			_ = redisClient.Close()
			return nil, err
		}
		mqConsumers = append(mqConsumers, luckyGiftConsumer)
	}

	server := servicegrpc.New("room-service")
	roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
	// 命令服务处理房间状态变更，守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
	roomv1.RegisterRoomCommandServiceServer(server, roomServer)
	roomv1.RegisterRoomGuardServiceServer(server, roomServer)
	roomv1.RegisterRoomQueryServiceServer(server, roomServer)
	healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory)
	healthHTTP, err := servicehealth.Register(server, healthState, cfg.HealthHTTPAddr, cfg.NodeID)
	if err != nil {
		closeClientConn(activityConn)
		_ = walletConn.Close()
		_ = repository.Close()
		_ = redisClient.Close()
		return nil, err
	}

	// listener 在 New 阶段创建，可以把端口占用作为启动失败暴露。
	listener, err := net.Listen("tcp", cfg.GRPCAddr)
	if err != nil {
		_ = healthHTTP.Close(context.Background())
		closeClientConn(activityConn)
		_ = walletConn.Close()
		_ = repository.Close()
		_ = redisClient.Close()
		return nil, err
	}
	closeRobotDepsOnError = false

	return &App{
		cfg:           cfg,
		grpcServer:    server,
		service:       svc,
		listener:      listener,
		walletConn:    walletConn,
		activityConn:  activityConn,
		luckyGiftConn: luckyGiftConn,
		robotConn:     robotConn,
		userConn:      userConn,
		repository:    repository,
		redisClose:    redisClient.Close,
		nodeRegistry:  directory,
		health:        healthState,
		healthHTTP:    healthHTTP,
		mqProducers:   mqProducers,
		mqConsumers:   mqConsumers,
		workers:       serviceapp.NewBackground(context.Background()),
	}, nil
}

// Run 启动 gRPC 服务。
func (a *App) Run() error {
	a.runHealthHTTP()
	// health 先标记 serving，再进入 Serve，ready 才能在监听开始后通过。
	a.health.MarkGRPCServing()
	if err := a.registerRoomNode(context.Background()); err != nil {
		a.health.MarkGRPCStopped()
		return err
	}
	if err := a.startMQ(); err != nil {
		a.health.MarkGRPCStopped()
		return err
	}
	defer func() {
		a.workers.StopAndWait()
		a.shutdownMQ()
	}()
	a.workers.Go(func(ctx context.Context) {
		a.runNodeRegistryHeartbeat(ctx)
	})
	if a.cfg.PresenceStaleScanInterval > 0 {
		// presence worker 只清理本节点已装载 Cell，命令仍走 Room Cell 持久化链路。
		a.workers.Go(func(ctx context.Context) {
			a.service.RunPresenceStaleWorker(ctx, a.cfg.PresenceStaleScanInterval)
		})
	}
	if a.cfg.MicPublishScanInterval > 0 {
		// MicUp 只代表业务占麦；后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。
		a.workers.Go(func(ctx context.Context) {
			a.service.RunMicPublishTimeoutWorker(ctx, a.cfg.MicPublishScanInterval)
		})
	}
	if a.cfg.RoomRocketLaunchScanInterval > 0 {
		// 火箭发射仍通过 Room Cell 命令链路提交，worker 只负责触发到点的系统命令。
		a.workers.Go(func(ctx context.Context) {
			a.service.RunRoomRocketLaunchWorker(ctx, a.cfg.RoomRocketLaunchScanInterval)
		})
	}
	a.workers.Go(func(ctx context.Context) {
		// 火箭燃料进度允许 1 秒展示延迟；普通房写主 outbox，机器人展示到期后 best-effort 直发腾讯 IM。
		a.service.RunRoomRocketProgressFlushWorker(ctx, 100*time.Millisecond)
	})
	a.workers.Go(func(ctx context.Context) {
		// SendGift 跨钱包、幸运礼物和 Room Cell；恢复 worker 用稳定 command_id 主动完成扣款未知/已扣款操作，
		// 客户端断线或不再重试都不能留下永久扣款无房间事实。
		a.service.RunGiftOperationRecoveryWorker(ctx, time.Second)
	})
	if a.cfg.RoomListCacheRefreshInterval > 0 {
		// Redis 发现页缓存是 MySQL owner 表的展示读模型，启动后先 warmup，再周期性修复 best-effort 增量丢失。
		a.workers.Go(func(ctx context.Context) {
			a.service.RunRoomListCacheRefreshWorker(ctx, a.cfg.RoomListCacheRefreshInterval)
		})
	}
	if a.cfg.OutboxWorker.Enabled {
		a.workers.Go(func(ctx context.Context) {
			a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
				PollInterval:   a.cfg.OutboxWorker.PollInterval,
				BatchSize:      a.cfg.OutboxWorker.BatchSize,
				Concurrency:    a.cfg.OutboxWorker.Concurrency,
				PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
				RetryStrategy:  a.cfg.OutboxWorker.RetryStrategy,
				MaxRetryCount:  a.cfg.OutboxWorker.MaxRetryCount,
				InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
				MaxBackoff:     a.cfg.OutboxWorker.MaxBackoff,
			})
		})
	}
	if a.cfg.OutboxWorker.Retention.Enabled {
		// 清理独立于补偿投递开关：outbox_worker.enabled=false 止血时历史 delivered 仍可继续回收。
		a.workers.Go(func(ctx context.Context) {
			a.service.RunOutboxRetentionWorker(ctx, roomservice.OutboxRetentionOptions{
				ScanInterval: a.cfg.OutboxWorker.Retention.ScanInterval,
				MaxAge:       a.cfg.OutboxWorker.Retention.MaxAge,
				BatchSize:    a.cfg.OutboxWorker.Retention.BatchSize,
			})
		})
	}
	a.workers.Go(func(ctx context.Context) {
		a.service.RunRobotRoomRuntimeManager(ctx, 10*time.Second)
	})
	a.workers.Go(func(ctx context.Context) {
		a.service.RunHumanRoomRobotRuntimeManager(ctx, 15*time.Second)
	})

	err := a.grpcServer.Serve(a.listener)
	a.health.MarkGRPCStopped()
	if errors.Is(err, grpc.ErrServerStopped) {
		// GracefulStop 触发的退出不是故障。
		return nil
	}

	return err
}

// Close 关闭底层 gRPC 服务。
func (a *App) Close() {
	a.closeOnce.Do(func() {
		// 先进入 draining，避免下线中的节点继续接管房间 lease 或接收新命令。
		a.health.MarkDraining()
		if a.service != nil {
			a.service.MarkDraining()
		}
		// 先停后台 worker 并等待当前投递结果落库，再关闭 gRPC/MySQL/Redis。
		a.workers.StopAndWait()
		a.shutdownMQ()
		// GracefulStop 让已进入的 gRPC 请求自然结束。
		servicegrpc.GracefulStop(a.grpcServer, 15*time.Second)
		if a.service != nil {
			// gRPC drain 完成后释放本节点持有的 room route，降低滚动发布时等待 Redis TTL 的窗口。
			releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
			a.service.ReleaseLoadedRoomLeases(releaseCtx)
			releaseCancel()
		}
		a.unregisterRoomNode()
		a.closeHealthHTTP()
		if a.walletConn != nil {
			_ = a.walletConn.Close()
		}
		if a.activityConn != nil {
			_ = a.activityConn.Close()
		}
		if a.luckyGiftConn != nil {
			_ = a.luckyGiftConn.Close()
		}
		if a.robotConn != nil {
			_ = a.robotConn.Close()
		}
		if a.userConn != nil {
			_ = a.userConn.Close()
		}
		if a.repository != nil {
			// MySQL 连接池最后释放，保证 GracefulStop 期间仍可完成持久化。
			_ = a.repository.Close()
		}
		if a.redisClose != nil {
			// Redis client 关闭后本节点不再续约或接管房间。
			_ = a.redisClose()
		}
	})
}

func (a *App) registerRoomNode(ctx context.Context) error {
	if a.nodeRegistry == nil {
		return nil
	}
	if _, ok := ctx.Deadline(); !ok {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, 2*time.Second)
		defer cancel()
	}
	return a.nodeRegistry.RegisterNode(ctx, router.NodeRegistration{
		NodeID:   a.cfg.NodeID,
		GRPCAddr: a.cfg.AdvertiseAddr,
	}, a.cfg.NodeRegistryTTL)
}

func (a *App) runNodeRegistryHeartbeat(ctx context.Context) {
	if a.nodeRegistry == nil {
		return
	}
	ticker := time.NewTicker(a.cfg.NodeRegistryHeartbeatInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			if err := a.registerRoomNode(ctx); err != nil {
				logx.Warn(ctx, "room_node_register_failed",
					slog.String("node_id", a.cfg.NodeID),
					slog.String("advertise_addr", a.cfg.AdvertiseAddr),
					slog.String("error", err.Error()),
				)
			}
		}
	}
}

func (a *App) unregisterRoomNode() {
	if a.nodeRegistry == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := a.nodeRegistry.UnregisterNode(ctx, a.cfg.NodeID); err != nil {
		logx.Warn(ctx, "room_node_unregister_failed",
			slog.String("node_id", a.cfg.NodeID),
			slog.String("error", err.Error()),
		)
	}
}

func (a *App) runHealthHTTP() {
	if a.healthHTTP == nil {
		return
	}
	go func() {
		if err := a.healthHTTP.Run(); err != nil {
			logx.Error(context.Background(), "health_http_run_failed", err)
		}
	}()
}

func (a *App) closeHealthHTTP() {
	if a.healthHTTP == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	_ = a.healthHTTP.Close(ctx)
}

func (a *App) startMQ() error {
	if err := servicemq.StartProducers(a.mqProducers); err != nil {
		return err
	}
	if err := servicemq.StartConsumers(a.mqConsumers); err != nil {
		servicemq.ShutdownProducers(a.mqProducers)
		return err
	}
	return nil
}

func (a *App) shutdownMQ() {
	servicemq.ShutdownConsumers(a.mqConsumers)
	servicemq.ShutdownProducers(a.mqProducers)
}

func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {
	return rocketmqx.ProducerConfig{
		EndpointConfig: rocketMQEndpointConfig(cfg),
		GroupName:      groupName,
		SendTimeout:    cfg.SendTimeout,
		Retry:          cfg.Retry,
	}
}

func rocketMQConsumerConfig(cfg config.RocketMQConfig, groupName string, maxReconsumeTimes int32, consumeGoroutines int) rocketmqx.ConsumerConfig {
	return rocketmqx.ConsumerConfig{
		EndpointConfig:     rocketMQEndpointConfig(cfg),
		GroupName:          groupName,
		MaxReconsumeTimes:  maxReconsumeTimes,
		ConsumeRetryDelay:  time.Second,
		ConsumePullBatch:   32,
		ConsumePullTimeout: 15 * time.Minute,
		ConsumeGoroutines:  consumeGoroutines,
	}
}

func luckyGiftRocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
	// 房间飘屏是实时外部副作用，不能在新建/误改 group 时从最早位点重放历史大奖。
	// 保持 SDK 的 latest 默认值，因此首次上线必须先让 room/activity consumer 建组并稳定订阅，再开启 lucky-gift producer。
	return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes, 0)
}

func walletRedPacketProjectionConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
	consumerCfg := rocketMQConsumerConfig(
		cfg,
		cfg.WalletRealtimeOutbox.ConsumerGroup,
		cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes,
		0,
	)
	// 这是无外部副作用的 MySQL/Redis 读模型，新 consumer group 必须回放 retained events 才不会漏掉部署前的活跃红包。
	consumerCfg.ConsumeFromFirst = true
	return consumerCfg
}

func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig {
	return rocketmqx.EndpointConfig{
		NameServers:      cfg.NameServers,
		NameServerDomain: cfg.NameServerDomain,
		AccessKey:        cfg.AccessKey,
		SecretKey:        cfg.SecretKey,
		SecurityToken:    cfg.SecurityToken,
		Namespace:        cfg.Namespace,
		VIPChannel:       cfg.VIPChannel,
	}
}

func closeClientConn(conn *grpc.ClientConn) {
	if conn != nil {
		_ = conn.Close()
	}
}
