// Package config 定义 room-service 的启动配置和 YAML 加载入口。
package config

import (
	"fmt"
	"net"
	"net/http"
	"strings"
	"time"

	"hyapp/pkg/configx"
	"hyapp/pkg/logx"
	"hyapp/pkg/roommq"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/tencentrtc"
)

const (
	OutboxPublishModeDirect = "direct"
	OutboxPublishModeMQ     = "mq"
	OutboxPublishModeDual   = "dual"
)

// Config 描述 room-service 进程启动所需的最小配置。
type Config struct {
	// ServiceName 是日志和部署识别使用的稳定服务名。
	ServiceName string `yaml:"service_name"`
	// Environment 是 local/dev/staging/prod 等运行环境标签。
	Environment string `yaml:"environment"`
	// NodeID 是当前 room-service 实例标识，Redis lease owner 使用它。
	NodeID string `yaml:"node_id"`
	// GRPCAddr 是内部 gRPC 监听地址，gateway-service 通过它访问 room-service。
	GRPCAddr string `yaml:"grpc_addr"`
	// AdvertiseAddr 是其他 room-service 实例转发到本节点时使用的私网地址，不能写负载均衡地址。
	AdvertiseAddr string `yaml:"advertise_addr"`
	// HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址，不承载业务流量。
	HealthHTTPAddr string `yaml:"health_http_addr"`
	// NodeRegistryTTL 是 node_id -> advertise_addr 注册的有效期。
	NodeRegistryTTL time.Duration `yaml:"node_registry_ttl"`
	// NodeRegistryHeartbeatInterval 是刷新节点注册的周期。
	NodeRegistryHeartbeatInterval time.Duration `yaml:"node_registry_heartbeat_interval"`
	// OwnerForwardTimeout 是非 owner 节点转发到当前 owner 的单次 RPC 超时。
	OwnerForwardTimeout time.Duration `yaml:"owner_forward_timeout"`
	// LeaseTTL 是 room owner 租约有效期，节点故障后必须等它过期才能接管。
	LeaseTTL time.Duration `yaml:"lease_ttl"`
	// LuckyGiftSendLockTTL 是同一用户幸运礼物扣费、抽奖、同步返奖链路的 Redis 短锁 TTL。
	LuckyGiftSendLockTTL time.Duration `yaml:"lucky_gift_send_lock_ttl"`
	// RankLimit 是房间本地礼物榜 top N。
	RankLimit int `yaml:"rank_limit"`
	// SnapshotEveryN 控制按房间版本保存快照的频率。
	SnapshotEveryN int64 `yaml:"snapshot_every_n"`
	// WalletServiceAddr 是 SendGift 同步扣费 gRPC 地址。
	WalletServiceAddr string `yaml:"wallet_service_addr"`
	// ActivityServiceAddr 是房间事实事件投给 activity-service 的内部 gRPC 地址。
	ActivityServiceAddr string `yaml:"activity_service_addr"`
	// LuckyGiftServiceAddr 是幸运礼物 owner service 的内部 gRPC 地址；不能复用 activity-service。
	LuckyGiftServiceAddr string `yaml:"lucky_gift_service_addr"`
	// RobotServiceAddr 是真人房间机器人运行时读取全站机器人池的内部 gRPC 地址。
	RobotServiceAddr string `yaml:"robot_service_addr"`
	// UserServiceAddr 是真人房间机器人运行时按用户资料分国家的内部 gRPC 地址。
	UserServiceAddr string `yaml:"user_service_addr"`
	// TencentIM 是腾讯云 IM 群组和房间系统消息的外部集成配置。
	TencentIM TencentIMConfig `yaml:"tencent_im"`
	// TencentRTC 是平台级封禁后移出实时音频房的外部集成配置。
	TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
	// MySQLDSN 是 rooms、snapshot、command log 和 outbox 的持久化连接串。
	MySQLDSN string `yaml:"mysql_dsn"`
	// MySQLMaxOpenConns 控制 MySQL 最大打开连接数。
	MySQLMaxOpenConns int `yaml:"mysql_max_open_conns"`
	// MySQLMaxIdleConns 控制 MySQL 空闲连接数。
	MySQLMaxIdleConns int `yaml:"mysql_max_idle_conns"`
	// MySQLAutoMigrate 控制启动时是否自动建表。
	MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
	// RedisAddr 是 room owner lease 目录地址。
	RedisAddr string `yaml:"redis_addr"`
	// RedisPassword 是 Redis 鉴权密码，本地默认留空。
	RedisPassword string `yaml:"redis_password"`
	// RedisDB 是 Redis logical DB。
	RedisDB int `yaml:"redis_db"`
	// PresenceStaleAfter 是业务 presence 超过多久未刷新后被视为断线离房。
	PresenceStaleAfter time.Duration `yaml:"presence_stale_after"`
	// PresenceStaleScanInterval 是本节点扫描已装载 Room Cell 的周期。
	PresenceStaleScanInterval time.Duration `yaml:"presence_stale_scan_interval"`
	// MicPublishTimeout 是 MicUp 后等待客户端/RTC 发流确认的最长时间。
	MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"`
	// MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。
	MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
	// RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。
	RoomRocketLaunchScanInterval time.Duration `yaml:"room_rocket_launch_scan_interval"`
	// RoomListCacheRefreshInterval 是用 MySQL owner 表重建 Redis 发现页展示读模型的周期。
	RoomListCacheRefreshInterval time.Duration `yaml:"room_list_cache_refresh_interval"`
	// RocketMQ 承载 room_outbox 事件分发和语音房火箭延迟发射唤醒。
	RocketMQ RocketMQConfig `yaml:"rocketmq"`
	// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
	OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
	// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
	Log logx.Config `yaml:"log"`
}

// OutboxWorkerConfig 控制 room_outbox pending 事件的补偿投递循环。
type OutboxWorkerConfig struct {
	// Enabled 为 false 时不启动补偿 worker，测试或临时止血可用。
	Enabled bool `yaml:"enabled"`
	// PublishMode 控制 room_outbox 发往 direct、mq 或 dual，避免 MQ 消费者与直接投递重复发同一条 IM。
	PublishMode string `yaml:"publish_mode"`
	// PollInterval 是两轮 pending outbox 扫描之间的固定间隔。
	PollInterval time.Duration `yaml:"poll_interval"`
	// BatchSize 是单轮最多处理的 pending outbox 数，防止全表扫描。
	BatchSize int `yaml:"batch_size"`
	// Concurrency 是本进程内并发抢占 outbox 的 worker 数；多机多 worker 依赖 MySQL 原子 claim 防重复。
	Concurrency int `yaml:"concurrency"`
	// PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。
	PublishTimeout time.Duration `yaml:"publish_timeout"`
	// RetryStrategy 当前只接受 exponential_backoff，避免配置错误静默改变投递语义。
	RetryStrategy string `yaml:"retry_strategy"`
	// MaxRetryCount 是转入 failed 前允许的最大失败次数。
	MaxRetryCount int `yaml:"max_retry_count"`
	// InitialBackoff 是首次失败后的等待时间。
	InitialBackoff time.Duration `yaml:"initial_backoff"`
	// MaxBackoff 是指数退避的最大等待时间。
	MaxBackoff time.Duration `yaml:"max_backoff"`
	// Retention 控制 delivered 历史事件的定期清理；它独立于 Enabled，临时关闭补偿投递不会停掉清理。
	Retention OutboxRetentionConfig `yaml:"retention"`
}

// OutboxRetentionConfig 控制 room_outbox 中 delivered 历史事件的小批量定期删除。
// 只清理 delivered：pending/retryable/delivering 仍在投递生命周期内，failed 留给人工补偿。
type OutboxRetentionConfig struct {
	// Enabled 默认 false。statistics-service 的 replay-stat-tz/databi-real-flow-audit 和
	// notice-service 离线修复工具都按时间窗直读 room_outbox 历史，开启前必须和数据侧确认
	// MaxAge 覆盖所有重放/补数窗口（或先归档再删）。
	Enabled bool `yaml:"enabled"`
	// MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长，早于 now-MaxAge 的才会删除。
	MaxAge time.Duration `yaml:"max_age"`
	// ScanInterval 是清空一轮可删事件后到下一轮扫描的间隔。
	ScanInterval time.Duration `yaml:"scan_interval"`
	// BatchSize 是单条 DELETE 的行数上限，小批量避免大事务长时间持有行锁。
	BatchSize int `yaml:"batch_size"`
}

// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。
type RocketMQConfig struct {
	Enabled          bool          `yaml:"enabled"`
	NameServers      []string      `yaml:"name_servers"`
	NameServerDomain string        `yaml:"name_server_domain"`
	AccessKey        string        `yaml:"access_key"`
	SecretKey        string        `yaml:"secret_key"`
	SecurityToken    string        `yaml:"security_token"`
	Namespace        string        `yaml:"namespace"`
	VIPChannel       bool          `yaml:"vip_channel"`
	SendTimeout      time.Duration `yaml:"send_timeout"`
	Retry            int           `yaml:"retry"`
	// RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧
	// consumer 可见；event_type 只能在全部 consumer 完成兼容订阅滚动后启用。
	RoomEventTagMode     roommq.EventTagMode     `yaml:"room_event_tag_mode"`
	RoomOutbox           RoomOutboxMQConfig      `yaml:"room_outbox"`
	RocketLaunch         RocketLaunchMQConfig    `yaml:"rocket_launch"`
	UserOutbox           UserOutboxMQConfig      `yaml:"user_outbox"`
	WalletOutbox         WalletOutboxMQConfig    `yaml:"wallet_outbox"`
	WalletRealtimeOutbox WalletOutboxMQConfig    `yaml:"wallet_realtime_outbox"`
	LuckyGiftOutbox      LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
}

// RoomOutboxMQConfig 控制 room_outbox fanout topic。
type RoomOutboxMQConfig struct {
	Enabled       bool   `yaml:"enabled"`
	Topic         string `yaml:"topic"`
	ProducerGroup string `yaml:"producer_group"`
	// TencentIMConsumerEnabled 已废弃：房间 UI 消息由 Room Cell 提交后直发腾讯 IM，room_outbox 只保留业务事实 fanout。
	TencentIMConsumerEnabled  bool   `yaml:"tencent_im_consumer_enabled"`
	TencentIMConsumerGroup    string `yaml:"tencent_im_consumer_group"`
	ConsumerMaxReconsumeTimes int32  `yaml:"consumer_max_reconsume_times"`
	// ConsumerGoroutines 控制 room_outbox RocketMQ push consumer 的并发回调数，消费方仍必须自行做业务幂等。
	ConsumerGoroutines int `yaml:"consumer_goroutines"`
	// IMPublishConcurrency 已废弃，仅兼容旧配置文件；房间 IM 不再由 MQ consumer 桥接。
	IMPublishConcurrency int `yaml:"im_publish_concurrency"`
}

// RocketLaunchMQConfig 控制火箭延迟发射唤醒 topic。
type RocketLaunchMQConfig struct {
	Enabled                   bool   `yaml:"enabled"`
	Topic                     string `yaml:"topic"`
	ProducerGroup             string `yaml:"producer_group"`
	ConsumerGroup             string `yaml:"consumer_group"`
	ConsumerMaxReconsumeTimes int32  `yaml:"consumer_max_reconsume_times"`
}

// UserOutboxMQConfig 控制用户区域变更事实消费。
type UserOutboxMQConfig struct {
	Enabled                   bool   `yaml:"enabled"`
	Topic                     string `yaml:"topic"`
	ConsumerGroup             string `yaml:"consumer_group"`
	ConsumerMaxReconsumeTimes int32  `yaml:"consumer_max_reconsume_times"`
}

// WalletOutboxMQConfig 控制 wallet owner 的 VIP/资源变化事实消费。
// room-service 使用独立 group 更新 Room Cell 装扮，不能复用 notice/statistics 的消费位点。
type WalletOutboxMQConfig struct {
	Enabled                   bool   `yaml:"enabled"`
	Topic                     string `yaml:"topic"`
	ConsumerGroup             string `yaml:"consumer_group"`
	ConsumerMaxReconsumeTimes int32  `yaml:"consumer_max_reconsume_times"`
}

// LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。
// 该 consumer 只派生房间展示 outbox，不读取幸运礼物库，也不修改 Room Cell 状态。
type LuckyGiftOutboxMQConfig struct {
	Enabled                   bool   `yaml:"enabled"`
	Topic                     string `yaml:"topic"`
	ConsumerGroup             string `yaml:"consumer_group"`
	ConsumerMaxReconsumeTimes int32  `yaml:"consumer_max_reconsume_times"`
}

// TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct {
	// Enabled 控制是否启用腾讯云 IM REST 桥接；关闭时只保留 Room Cell 内部提交。
	Enabled bool `yaml:"enabled"`
	// SDKAppID 是腾讯云 IM 控制台分配的应用 ID。
	SDKAppID int64 `yaml:"sdk_app_id"`
	// SecretKey 用于生成 App 管理员 UserSig，不能写入公开配置。
	SecretKey string `yaml:"secret_key"`
	// AdminIdentifier 是腾讯云 IM App 管理员账号。
	AdminIdentifier string `yaml:"admin_identifier"`
	// AdminUserSigTTL 是 REST API 管理员票据有效期。
	AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
	// Endpoint 是腾讯云 IM REST API 区域域名。
	Endpoint string `yaml:"endpoint"`
	// GroupType 是语音房对应的腾讯云 IM 群组类型。
	GroupType string `yaml:"group_type"`
	// RequestTimeout 是单次 REST API 调用超时。
	RequestTimeout time.Duration `yaml:"request_timeout"`
}

// TencentRTCConfig 描述 room-service 调用腾讯云 TRTC 房间管理 API 的配置。
type TencentRTCConfig struct {
	// Enabled 控制是否启用服务端 RTC 踢人；关闭时仍会提交 Room Cell 驱逐。
	Enabled bool `yaml:"enabled"`
	// SDKAppID 是 TRTC 控制台分配的应用 ID，必须和 gateway 签发 RTC UserSig 的应用一致。
	SDKAppID int64 `yaml:"sdk_app_id"`
	// SecretID/SecretKey 是腾讯云 API 3.0 访问密钥，只能写在线上密钥系统或私有配置中。
	SecretID  string `yaml:"secret_id"`
	SecretKey string `yaml:"secret_key"`
	// Region 是腾讯云 API 公共参数；TRTC 房间管理接口当前使用 ap-guangzhou/ap-beijing。
	Region string `yaml:"region"`
	// Endpoint 是腾讯云 API 域名，默认 trtc.tencentcloudapi.com。
	Endpoint string `yaml:"endpoint"`
	// RequestTimeout 是单次公网调用超时。
	RequestTimeout time.Duration `yaml:"request_timeout"`
}

// RESTConfig 把 YAML 配置转换成 pkg/tencentrtc 的 REST 客户端配置。
func (c TencentRTCConfig) RESTConfig() tencentrtc.RESTConfig {
	timeout := c.RequestTimeout
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	return tencentrtc.RESTConfig{
		Enabled:    c.Enabled,
		SDKAppID:   c.SDKAppID,
		SecretID:   c.SecretID,
		SecretKey:  c.SecretKey,
		Region:     c.Region,
		Endpoint:   c.Endpoint,
		HTTPClient: &http.Client{Timeout: timeout},
	}
}

// RESTConfig 把 YAML 配置转换成 pkg/tencentim 的 REST 客户端配置。
func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
	timeout := c.RequestTimeout
	if timeout <= 0 {
		// 腾讯云 REST 后台超时约 3 秒，本地客户端默认给 5 秒预算。
		timeout = 5 * time.Second
	}

	return tencentim.RESTConfig{
		SDKAppID:        c.SDKAppID,
		SecretKey:       c.SecretKey,
		AdminIdentifier: c.AdminIdentifier,
		AdminUserSigTTL: c.AdminUserSigTTL,
		Endpoint:        c.Endpoint,
		GroupType:       c.GroupType,
		HTTPClient:      &http.Client{Timeout: timeout},
	}
}

// Default 返回没有外部私有密钥时可启动的基础配置。
func Default() Config {
	// 默认端口遵守项目 13xxx 约束，MySQL/Redis 指向本地 Docker 暴露端口。
	return Config{
		ServiceName:          "room-service",
		Environment:          "local",
		NodeID:               "room-node-local",
		GRPCAddr:             ":13001",
		AdvertiseAddr:        "127.0.0.1:13001",
		HealthHTTPAddr:       ":13101",
		NodeRegistryTTL:      30 * time.Second,
		OwnerForwardTimeout:  2 * time.Second,
		LeaseTTL:             10 * time.Second,
		LuckyGiftSendLockTTL: 5 * time.Second,
		RankLimit:            20,
		SnapshotEveryN:       10,
		WalletServiceAddr:    "127.0.0.1:13004",
		ActivityServiceAddr:  "127.0.0.1:13006",
		LuckyGiftServiceAddr: "127.0.0.1:13013",
		RobotServiceAddr:     "127.0.0.1:13011",
		UserServiceAddr:      "127.0.0.1:13005",
		TencentIM: TencentIMConfig{
			Enabled:         false,
			AdminIdentifier: "administrator",
			AdminUserSigTTL: 24 * time.Hour,
			Endpoint:        tencentim.DefaultEndpoint,
			GroupType:       tencentim.DefaultGroupType,
			RequestTimeout:  5 * time.Second,
		},
		TencentRTC: TencentRTCConfig{
			Enabled:        false,
			Region:         tencentrtc.DefaultRESTRegion,
			Endpoint:       tencentrtc.DefaultRESTEndpoint,
			RequestTimeout: 5 * time.Second,
		},
		MySQLDSN:                     "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
		MySQLMaxOpenConns:            20,
		MySQLMaxIdleConns:            10,
		MySQLAutoMigrate:             true,
		RedisAddr:                    "127.0.0.1:13379",
		RedisDB:                      0,
		PresenceStaleAfter:           2 * time.Minute,
		PresenceStaleScanInterval:    30 * time.Second,
		MicPublishTimeout:            15 * time.Second,
		MicPublishScanInterval:       time.Second,
		RoomRocketLaunchScanInterval: time.Second,
		RoomListCacheRefreshInterval: 5 * time.Minute,
		RocketMQ:                     defaultRocketMQConfig(),
		OutboxWorker:                 defaultOutboxWorkerConfig(),
		Log: logx.Config{
			Level:           "info",
			Format:          "json",
			MaxPayloadBytes: 2048,
		},
	}
}

func defaultOutboxWorkerConfig() OutboxWorkerConfig {
	// 默认启动补偿 worker，使用退避和死信避免外部故障刷屏。
	return OutboxWorkerConfig{
		Enabled:        true,
		PublishMode:    OutboxPublishModeDirect,
		PollInterval:   time.Second,
		BatchSize:      100,
		Concurrency:    1,
		PublishTimeout: 3 * time.Second,
		RetryStrategy:  "exponential_backoff",
		MaxRetryCount:  10,
		InitialBackoff: 5 * time.Second,
		MaxBackoff:     5 * time.Minute,
		// 清理默认关闭：保留窗口必须先和数据侧确认，见 OutboxRetentionConfig.Enabled。
		Retention: OutboxRetentionConfig{
			Enabled:      false,
			MaxAge:       30 * 24 * time.Hour,
			ScanInterval: 5 * time.Minute,
			BatchSize:    500,
		},
	}
}

func defaultRocketMQConfig() RocketMQConfig {
	return RocketMQConfig{
		Enabled:          false,
		SendTimeout:      3 * time.Second,
		Retry:            2,
		RoomEventTagMode: roommq.EventTagModeLegacy,
		RoomOutbox: RoomOutboxMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_room_outbox",
			ProducerGroup:             "hyapp-room-outbox-producer",
			TencentIMConsumerEnabled:  false,
			TencentIMConsumerGroup:    "",
			ConsumerMaxReconsumeTimes: 16,
			IMPublishConcurrency:      1,
		},
		RocketLaunch: RocketLaunchMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_room_rocket_launch",
			ProducerGroup:             "hyapp-room-rocket-launch-producer",
			ConsumerGroup:             "hyapp-room-rocket-launch",
			ConsumerMaxReconsumeTimes: 16,
		},
		UserOutbox: UserOutboxMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_user_outbox",
			ConsumerGroup:             "hyapp-room-user-region-sync",
			ConsumerMaxReconsumeTimes: 16,
		},
		WalletOutbox: WalletOutboxMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_wallet_outbox",
			ConsumerGroup:             "hyapp-room-wallet-decoration-sync",
			ConsumerMaxReconsumeTimes: 16,
		},
		WalletRealtimeOutbox: WalletOutboxMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_wallet_realtime_outbox",
			ConsumerGroup:             "hyapp-room-wallet-red-packet-projection",
			ConsumerMaxReconsumeTimes: 16,
		},
		LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
			Enabled:                   false,
			Topic:                     "hyapp_lucky_gift_outbox",
			ConsumerGroup:             "hyapp-room-lucky-gift-outbox",
			ConsumerMaxReconsumeTimes: 16,
		},
	}
}

// Normalize 补齐动态配置默认值，并拒绝会改变补偿语义的未知策略。
func Normalize(cfg Config) (Config, error) {
	// 配置归一化只处理需要跨环境保持语义的字段，其他默认值由 Default 提供。
	cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
	if cfg.ServiceName == "" {
		cfg.ServiceName = "room-service"
	}
	cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
	if cfg.Environment == "" {
		cfg.Environment = "local"
	}
	cfg.NodeID = strings.TrimSpace(cfg.NodeID)
	if cfg.NodeID == "" {
		cfg.NodeID = cfg.ServiceName
	}
	cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr)
	if cfg.GRPCAddr == "" {
		cfg.GRPCAddr = ":13001"
	}
	cfg.AdvertiseAddr = strings.TrimSpace(cfg.AdvertiseAddr)
	if cfg.AdvertiseAddr == "" {
		cfg.AdvertiseAddr = defaultAdvertiseAddr(cfg.GRPCAddr)
	}
	cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr)
	if cfg.HealthHTTPAddr == "" {
		cfg.HealthHTTPAddr = ":13101"
	}
	if cfg.NodeRegistryTTL <= 0 {
		cfg.NodeRegistryTTL = 30 * time.Second
	}
	if cfg.NodeRegistryHeartbeatInterval <= 0 {
		cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3
	}
	if cfg.NodeRegistryHeartbeatInterval <= 0 {
		cfg.NodeRegistryHeartbeatInterval = 10 * time.Second
	}
	if cfg.NodeRegistryHeartbeatInterval >= cfg.NodeRegistryTTL {
		cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3
	}
	if cfg.OwnerForwardTimeout <= 0 {
		cfg.OwnerForwardTimeout = 2 * time.Second
	}
	if cfg.LuckyGiftSendLockTTL <= 0 {
		cfg.LuckyGiftSendLockTTL = 5 * time.Second
	}
	if cfg.RoomListCacheRefreshInterval <= 0 {
		cfg.RoomListCacheRefreshInterval = 5 * time.Minute
	}
	cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
	if cfg.WalletServiceAddr == "" {
		cfg.WalletServiceAddr = "127.0.0.1:13004"
	}
	cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr)
	if cfg.ActivityServiceAddr == "" {
		cfg.ActivityServiceAddr = "127.0.0.1:13006"
	}
	cfg.LuckyGiftServiceAddr = strings.TrimSpace(cfg.LuckyGiftServiceAddr)
	if cfg.LuckyGiftServiceAddr == "" {
		cfg.LuckyGiftServiceAddr = "127.0.0.1:13013"
	}
	cfg.RobotServiceAddr = strings.TrimSpace(cfg.RobotServiceAddr)
	if cfg.RobotServiceAddr == "" {
		cfg.RobotServiceAddr = "127.0.0.1:13011"
	}
	cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
	if cfg.UserServiceAddr == "" {
		cfg.UserServiceAddr = "127.0.0.1:13005"
	}
	cfg.TencentRTC.SecretID = strings.TrimSpace(cfg.TencentRTC.SecretID)
	cfg.TencentRTC.SecretKey = strings.TrimSpace(cfg.TencentRTC.SecretKey)
	cfg.TencentRTC.Region = strings.TrimSpace(cfg.TencentRTC.Region)
	if cfg.TencentRTC.Region == "" {
		cfg.TencentRTC.Region = tencentrtc.DefaultRESTRegion
	}
	cfg.TencentRTC.Endpoint = strings.TrimSpace(cfg.TencentRTC.Endpoint)
	if cfg.TencentRTC.Endpoint == "" {
		cfg.TencentRTC.Endpoint = tencentrtc.DefaultRESTEndpoint
	}
	if cfg.TencentRTC.RequestTimeout <= 0 {
		cfg.TencentRTC.RequestTimeout = 5 * time.Second
	}
	outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker)
	if err != nil {
		return Config{}, err
	}
	cfg.OutboxWorker = outboxWorker
	rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ, cfg.OutboxWorker.PublishMode)
	if err != nil {
		return Config{}, err
	}
	cfg.RocketMQ = rocketMQ
	if cfg.RocketMQ.LuckyGiftOutbox.Enabled && !cfg.TencentIM.Enabled {
		// 开启 owner 事实消费却没有 IM publisher 会让派生 outbox 永久 retry；启动时拒绝比运行后静默积压更安全。
		return Config{}, fmt.Errorf("rocketmq.lucky_gift_outbox.enabled requires tencent_im.enabled")
	}

	return cfg, nil
}

func defaultAdvertiseAddr(grpcAddr string) string {
	host, port, err := net.SplitHostPort(grpcAddr)
	if err != nil {
		// 非 host:port 形态保持原值，由启动拨测暴露错误。
		return grpcAddr
	}
	if host == "" || host == "0.0.0.0" || host == "::" {
		// 本地默认值只能服务单机开发；多机部署必须显式配置真实私网 IP 或主机名。
		host = "127.0.0.1"
	}
	return net.JoinHostPort(host, port)
}

func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, error) {
	// Outbox worker 的所有时间和批量参数都必须有安全下限，避免配置缺省导致 busy loop。
	defaults := defaultOutboxWorkerConfig()
	if cfg.PollInterval <= 0 {
		cfg.PollInterval = defaults.PollInterval
	}
	if cfg.BatchSize <= 0 {
		cfg.BatchSize = defaults.BatchSize
	}
	if cfg.Concurrency <= 0 {
		cfg.Concurrency = defaults.Concurrency
	}
	if cfg.PublishTimeout <= 0 {
		cfg.PublishTimeout = defaults.PublishTimeout
	}
	if cfg.MaxRetryCount <= 0 {
		cfg.MaxRetryCount = defaults.MaxRetryCount
	}
	if cfg.InitialBackoff <= 0 {
		cfg.InitialBackoff = defaults.InitialBackoff
	}
	if cfg.MaxBackoff <= 0 {
		cfg.MaxBackoff = defaults.MaxBackoff
	}
	if cfg.MaxBackoff < cfg.InitialBackoff {
		cfg.MaxBackoff = cfg.InitialBackoff
	}
	cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy)
	if cfg.RetryStrategy == "" {
		cfg.RetryStrategy = defaults.RetryStrategy
	}
	if cfg.RetryStrategy != defaults.RetryStrategy {
		// 当前只实现指数退避；未知策略直接启动失败，避免误以为退避或死信已生效。
		return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy)
	}
	cfg.PublishMode = strings.ToLower(strings.TrimSpace(cfg.PublishMode))
	if cfg.PublishMode == "" {
		cfg.PublishMode = defaults.PublishMode
	}
	switch cfg.PublishMode {
	case OutboxPublishModeDirect, OutboxPublishModeMQ, OutboxPublishModeDual:
	default:
		return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker publish_mode %q", cfg.PublishMode)
	}
	if cfg.Retention.MaxAge <= 0 {
		cfg.Retention.MaxAge = defaults.Retention.MaxAge
	}
	if cfg.Retention.ScanInterval <= 0 {
		cfg.Retention.ScanInterval = defaults.Retention.ScanInterval
	}
	if cfg.Retention.BatchSize <= 0 {
		cfg.Retention.BatchSize = defaults.Retention.BatchSize
	}
	if cfg.Retention.Enabled && cfg.Retention.MaxAge < 24*time.Hour {
		// 保留窗口小于一天大概率是单位写错（如 720 被当秒），直接拒绝启动，避免静默清掉重放依赖的历史。
		return OutboxWorkerConfig{}, fmt.Errorf("outbox_worker retention max_age %s is below the 24h safety floor", cfg.Retention.MaxAge)
	}

	return cfg, nil
}

func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQConfig, error) {
	defaults := defaultRocketMQConfig()
	cfg.NameServers = normalizeStringSlice(cfg.NameServers)
	cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain)
	cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
	cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
	cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken)
	cfg.Namespace = strings.TrimSpace(cfg.Namespace)
	if cfg.SendTimeout <= 0 {
		cfg.SendTimeout = defaults.SendTimeout
	}
	if cfg.Retry < 0 {
		cfg.Retry = defaults.Retry
	}
	cfg.RoomEventTagMode = roommq.EventTagMode(strings.ToLower(strings.TrimSpace(string(cfg.RoomEventTagMode))))
	if cfg.RoomEventTagMode == "" {
		// 旧线上 YAML 没有此字段时必须继续发 legacy Tag；切 Phase B 必须是一次
		// 显式配置变更，不能由普通代码滚动发布顺带改变 MQ 可见性。
		cfg.RoomEventTagMode = defaults.RoomEventTagMode
	}
	switch cfg.RoomEventTagMode {
	case roommq.EventTagModeLegacy, roommq.EventTagModeEventType:
	default:
		return RocketMQConfig{}, fmt.Errorf("rocketmq.room_event_tag_mode must be legacy or event_type")
	}
	if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
		cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
	}
	if cfg.RoomOutbox.ProducerGroup = strings.TrimSpace(cfg.RoomOutbox.ProducerGroup); cfg.RoomOutbox.ProducerGroup == "" {
		cfg.RoomOutbox.ProducerGroup = defaults.RoomOutbox.ProducerGroup
	}
	if cfg.RoomOutbox.TencentIMConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.TencentIMConsumerGroup); cfg.RoomOutbox.TencentIMConsumerGroup == "" {
		cfg.RoomOutbox.TencentIMConsumerGroup = defaults.RoomOutbox.TencentIMConsumerGroup
	}
	if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
		cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
	}
	// 旧配置可能仍把 MQ -> Tencent IM bridge 打开；当前设计要求 UI 在 Room Cell 提交后直接 IM，outbox worker 不再承担 IM 投递。
	cfg.RoomOutbox.TencentIMConsumerEnabled = false
	if cfg.RoomOutbox.ConsumerGoroutines < 0 {
		cfg.RoomOutbox.ConsumerGoroutines = 0
	}
	if cfg.RoomOutbox.IMPublishConcurrency <= 0 {
		cfg.RoomOutbox.IMPublishConcurrency = defaults.RoomOutbox.IMPublishConcurrency
	}
	if cfg.RocketLaunch.Topic = strings.TrimSpace(cfg.RocketLaunch.Topic); cfg.RocketLaunch.Topic == "" {
		cfg.RocketLaunch.Topic = defaults.RocketLaunch.Topic
	}
	if cfg.RocketLaunch.ProducerGroup = strings.TrimSpace(cfg.RocketLaunch.ProducerGroup); cfg.RocketLaunch.ProducerGroup == "" {
		cfg.RocketLaunch.ProducerGroup = defaults.RocketLaunch.ProducerGroup
	}
	if cfg.RocketLaunch.ConsumerGroup = strings.TrimSpace(cfg.RocketLaunch.ConsumerGroup); cfg.RocketLaunch.ConsumerGroup == "" {
		cfg.RocketLaunch.ConsumerGroup = defaults.RocketLaunch.ConsumerGroup
	}
	if cfg.RocketLaunch.ConsumerMaxReconsumeTimes <= 0 {
		cfg.RocketLaunch.ConsumerMaxReconsumeTimes = defaults.RocketLaunch.ConsumerMaxReconsumeTimes
	}
	if cfg.UserOutbox.Topic = strings.TrimSpace(cfg.UserOutbox.Topic); cfg.UserOutbox.Topic == "" {
		cfg.UserOutbox.Topic = defaults.UserOutbox.Topic
	}
	if cfg.UserOutbox.ConsumerGroup = strings.TrimSpace(cfg.UserOutbox.ConsumerGroup); cfg.UserOutbox.ConsumerGroup == "" {
		cfg.UserOutbox.ConsumerGroup = defaults.UserOutbox.ConsumerGroup
	}
	if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
		cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
	}
	if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
		cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
	}
	if cfg.WalletOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.ConsumerGroup); cfg.WalletOutbox.ConsumerGroup == "" {
		cfg.WalletOutbox.ConsumerGroup = defaults.WalletOutbox.ConsumerGroup
	}
	if cfg.WalletOutbox.ConsumerMaxReconsumeTimes <= 0 {
		cfg.WalletOutbox.ConsumerMaxReconsumeTimes = defaults.WalletOutbox.ConsumerMaxReconsumeTimes
	}
	if cfg.WalletRealtimeOutbox.Topic = strings.TrimSpace(cfg.WalletRealtimeOutbox.Topic); cfg.WalletRealtimeOutbox.Topic == "" {
		cfg.WalletRealtimeOutbox.Topic = defaults.WalletRealtimeOutbox.Topic
	}
	if cfg.WalletRealtimeOutbox.ConsumerGroup = strings.TrimSpace(cfg.WalletRealtimeOutbox.ConsumerGroup); cfg.WalletRealtimeOutbox.ConsumerGroup == "" {
		cfg.WalletRealtimeOutbox.ConsumerGroup = defaults.WalletRealtimeOutbox.ConsumerGroup
	}
	if cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes <= 0 {
		cfg.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes = defaults.WalletRealtimeOutbox.ConsumerMaxReconsumeTimes
	}
	if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
		cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
	}
	if cfg.LuckyGiftOutbox.ConsumerGroup = strings.TrimSpace(cfg.LuckyGiftOutbox.ConsumerGroup); cfg.LuckyGiftOutbox.ConsumerGroup == "" {
		cfg.LuckyGiftOutbox.ConsumerGroup = defaults.LuckyGiftOutbox.ConsumerGroup
	}
	if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
		cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
	}
	if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.WalletRealtimeOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled {
		cfg.Enabled = true
	}
	if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
		if !cfg.RoomOutbox.Enabled {
			return RocketMQConfig{}, fmt.Errorf("outbox_worker publish_mode %q requires rocketmq.room_outbox.enabled", publishMode)
		}
	}
	if cfg.Enabled {
		if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" {
			return RocketMQConfig{}, fmt.Errorf("rocketmq name_servers or name_server_domain is required")
		}
		if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
			return RocketMQConfig{}, fmt.Errorf("rocketmq access_key and secret_key must be configured together")
		}
	}
	return cfg, nil
}

func normalizeStringSlice(input []string) []string {
	out := make([]string, 0, len(input))
	for _, value := range input {
		if value = strings.TrimSpace(value); value != "" {
			out = append(out, value)
		}
	}
	return out
}

// Load 从独立 config.yaml 读取 room-service 配置。
func Load(path string) (Config, error) {
	cfg := Default()
	if path == "" {
		// 空路径代表显式使用默认配置，便于单元测试和最小启动。
		return Normalize(cfg)
	}

	if err := configx.LoadYAML(path, &cfg); err != nil {
		// YAML 解析失败返回空 Config，避免调用方误用部分默认值启动。
		return Config{}, err
	}

	return Normalize(cfg)
}
