package main

import (
	"context"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"log"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"hyapp-admin-server/internal/cache"
	"hyapp-admin-server/internal/config"
	"hyapp-admin-server/internal/integration/activityclient"
	"hyapp-admin-server/internal/integration/gameclient"
	"hyapp-admin-server/internal/integration/googleorders"
	"hyapp-admin-server/internal/integration/luckygiftadmin"
	"hyapp-admin-server/internal/integration/robotclient"
	"hyapp-admin-server/internal/integration/roomclient"
	"hyapp-admin-server/internal/integration/statisticsclient"
	"hyapp-admin-server/internal/integration/userclient"
	"hyapp-admin-server/internal/integration/walletclient"
	"hyapp-admin-server/internal/integration/withdrawalsource"
	jobrunner "hyapp-admin-server/internal/job"
	"hyapp-admin-server/internal/migration"
	achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig"
	activitytemplatemodule "hyapp-admin-server/internal/modules/activitytemplate"
	adminusermodule "hyapp-admin-server/internal/modules/adminuser"
	agencyopeningmodule "hyapp-admin-server/internal/modules/agencyopening"
	appconfigmodule "hyapp-admin-server/internal/modules/appconfig"
	appregistrymodule "hyapp-admin-server/internal/modules/appregistry"
	appusermodule "hyapp-admin-server/internal/modules/appuser"
	auditmodule "hyapp-admin-server/internal/modules/audit"
	authmodule "hyapp-admin-server/internal/modules/auth"
	coinledgermodule "hyapp-admin-server/internal/modules/coinledger"
	countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
	cprelationmodule "hyapp-admin-server/internal/modules/cprelation"
	cpweeklyrankmodule "hyapp-admin-server/internal/modules/cpweeklyrank"
	cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
	dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
	dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
	databimodule "hyapp-admin-server/internal/modules/databi"
	externaladminmodule "hyapp-admin-server/internal/modules/externaladmin"
	financeordermodule "hyapp-admin-server/internal/modules/financeorder"
	financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
	firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
	fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
	gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
	giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
	giftrecordmodule "hyapp-admin-server/internal/modules/giftrecord"
	healthmodule "hyapp-admin-server/internal/modules/health"
	hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
	hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
	hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
	hostwithdrawalmodule "hyapp-admin-server/internal/modules/hostwithdrawal"
	inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward"
	jobmodule "hyapp-admin-server/internal/modules/job"
	levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
	luckygiftmodule "hyapp-admin-server/internal/modules/luckygift"
	menumodule "hyapp-admin-server/internal/modules/menu"
	opscentermodule "hyapp-admin-server/internal/modules/opscenter"
	paymentmodule "hyapp-admin-server/internal/modules/payment"
	pointwithdrawalconfigmodule "hyapp-admin-server/internal/modules/pointwithdrawalconfig"
	policyconfigmodule "hyapp-admin-server/internal/modules/policyconfig"
	prettyidmodule "hyapp-admin-server/internal/modules/prettyid"
	rbacmodule "hyapp-admin-server/internal/modules/rbac"
	redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
	regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
	registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
	reportmodule "hyapp-admin-server/internal/modules/report"
	resourcemodule "hyapp-admin-server/internal/modules/resource"
	riskconfigmodule "hyapp-admin-server/internal/modules/riskconfig"
	roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
	roomrocketmodule "hyapp-admin-server/internal/modules/roomrocket"
	roomturnoverrewardmodule "hyapp-admin-server/internal/modules/roomturnoverreward"
	searchmodule "hyapp-admin-server/internal/modules/search"
	sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
	teammodule "hyapp-admin-server/internal/modules/team"
	teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
	teamsalarysettlementmodule "hyapp-admin-server/internal/modules/teamsalarysettlement"
	uploadmodule "hyapp-admin-server/internal/modules/upload"
	userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
	vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
	weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar"
	wheelmodule "hyapp-admin-server/internal/modules/wheel"
	"hyapp-admin-server/internal/platform/activitylanding"
	"hyapp-admin-server/internal/platform/dingtalkrobot"
	"hyapp-admin-server/internal/platform/logging"
	"hyapp-admin-server/internal/platform/tencentcos"
	"hyapp-admin-server/internal/repository"
	"hyapp-admin-server/internal/router"
	"hyapp-admin-server/internal/service"

	mysqlDriver "github.com/go-sql-driver/mysql"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
	"go.mongodb.org/mongo-driver/v2/mongo/readpref"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/keepalive"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
)

func main() {
	configPath := flag.String("config", "configs/config.yaml", "path to admin-server config")
	runBootstrap := flag.Bool("bootstrap", false, "run configured bootstrap seed before starting")
	flag.Parse()

	cfg, err := config.Load(*configPath)
	if err != nil {
		log.Fatal(err)
	}
	if err := logging.Init(cfg.ServiceName, cfg.Environment, cfg.NodeID, cfg.Log); err != nil {
		log.Fatal(err)
	}

	if err := ensureDatabase(cfg.MySQLDSN); err != nil {
		fatalRuntime("ensure_mysql_database_failed", err)
	}

	dbLogger := logger.New(log.New(logging.StdWriter(), "", 0), logger.Config{
		LogLevel:                  logger.Warn,
		IgnoreRecordNotFoundError: true,
	})
	db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{
		Logger: dbLogger,
		NowFunc: func() time.Time {
			return time.Now().UTC()
		},
	})
	if err != nil {
		fatalRuntime("connect_mysql_failed", err)
	}
	sqlDB, err := db.DB()
	if err != nil {
		fatalRuntime("mysql_db_handle_failed", err)
	}
	if cfg.Migrations.Enabled {
		if err := migration.ApplyWithOptions(context.Background(), sqlDB, cfg.Migrations.Dir, migration.Options{
			AllowChecksumRepair: cfg.Migrations.AllowChecksumRepair,
		}); err != nil {
			fatalRuntime("apply_migrations_failed", err)
		}
	}
	redisClient, err := cache.NewRedis(context.Background(), cfg.Redis)
	if err != nil {
		fatalRuntime("connect_redis_failed", err)
	}
	if redisClient != nil {
		defer redisClient.Close()
	}

	userDB, err := sql.Open("mysql", cfg.UserMySQLDSN)
	if err != nil {
		fatalRuntime("connect_user_mysql_failed", err)
	}
	if err := userDB.PingContext(context.Background()); err != nil {
		fatalRuntime("ping_user_mysql_failed", err)
	}
	defer userDB.Close()

	walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN)
	if err != nil {
		fatalRuntime("connect_wallet_mysql_failed", err)
	}
	if err := walletDB.PingContext(context.Background()); err != nil {
		fatalRuntime("ping_wallet_mysql_failed", err)
	}
	defer walletDB.Close()

	var robotProfileSource gamemanagementmodule.RobotProfileSource
	if strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) != "" {
		robotProfileDB, err := sql.Open("mysql", cfg.RobotProfileSource.MySQLDSN)
		if err != nil {
			fatalRuntime("connect_robot_profile_mysql_failed", err)
		}
		robotProfileCtx, robotProfileCancel := context.WithTimeout(context.Background(), cfg.RobotProfileSource.RequestTimeout)
		if err := robotProfileDB.PingContext(robotProfileCtx); err != nil {
			robotProfileCancel()
			fatalRuntime("ping_robot_profile_mysql_failed", err)
		}
		robotProfileCancel()
		defer robotProfileDB.Close()
		robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB)
	}
	moneyRegionSources, closeMoneyRegionSources, err := connectMoneyRegionSources(context.Background(), cfg.MoneyRegionSources)
	if err != nil {
		fatalRuntime("connect_money_region_sources_failed", err)
	}
	defer closeMoneyRegionSources(context.Background())
	dashboardExternalSources, closeDashboardExternalSources, err := connectDashboardExternalSources(context.Background(), cfg.DashboardExternalSources, dashboardRegionResolvers(moneyRegionSources)...)
	if err != nil {
		fatalRuntime("connect_dashboard_external_sources_failed", err)
	}
	defer closeDashboardExternalSources()
	financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources, dashboardExternalSourceIndex(dashboardExternalSources))
	if err != nil {
		closeDashboardExternalSources()
		fatalRuntime("connect_finance_bill_sources_failed", err)
	}
	defer closeFinanceBillSources(context.Background())
	legacyCoinSellerRechargeWriters, closeLegacyCoinSellerRechargeWriters, err := connectLegacyCoinSellerRechargeWriters(context.Background(), cfg.LegacyCoinSellerRechargeSources)
	if err != nil {
		fatalRuntime("connect_legacy_coin_seller_recharge_sources_failed", err)
	}
	defer closeLegacyCoinSellerRechargeWriters(context.Background())

	store := repository.New(db)
	if cfg.MySQLAutoMigrate {
		if err := store.AutoMigrate(); err != nil {
			fatalRuntime("auto_migrate_failed", err)
		}
	}
	// legacy 账单源的谷歌实付同步依赖 admin 库缓存表，store 建好后再注入；凭证异常只降级该 App，不阻断启动。
	wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store)
	if cfg.FinanceGooglePaidSync.Enabled {
		// Polling is process-owned rather than HTTP-owned: startup immediately drains one batch, then
		// every 15 seconds prioritizes newest orders while gradually repairing the complete backlog.
		googlePaidSyncWorker := paymentmodule.NewLegacyGooglePaidSyncWorker(financeBillSources, store, cfg.FinanceGooglePaidSync)
		googlePaidSyncWorker.Start()
		defer googlePaidSyncWorker.Close()
	}
	if *runBootstrap || cfg.Bootstrap.Enabled {
		if err := store.Seed(cfg); err != nil {
			fatalRuntime("seed_database_failed", err)
		}
	}

	userConn, err := dialBackendGRPC(cfg.UserService.Addr)
	if err != nil {
		fatalRuntime("connect_user_service_failed", err)
	}
	defer userConn.Close()

	walletConn, err := dialBackendGRPC(cfg.WalletService.Addr)
	if err != nil {
		fatalRuntime("connect_wallet_service_failed", err)
	}
	defer walletConn.Close()

	roomConn, err := dialBackendGRPC(cfg.RoomService.Addr)
	if err != nil {
		fatalRuntime("connect_room_service_failed", err)
	}
	defer roomConn.Close()
	roomClient := roomclient.NewGRPC(roomConn)

	robotConn, err := dialBackendGRPC(cfg.RobotService.Addr)
	if err != nil {
		fatalRuntime("connect_robot_service_failed", err)
	}
	defer robotConn.Close()
	robotClient := robotclient.NewGRPC(robotConn)

	activityConn, err := dialBackendGRPC(cfg.ActivityService.Addr)
	if err != nil {
		fatalRuntime("connect_activity_service_failed", err)
	}
	defer activityConn.Close()
	activityAdminClient := activityclient.NewGRPC(activityConn)
	activityLandingBuilder, err := activitylanding.New(cfg.ActivityTemplateH5.BaseURL)
	if err != nil {
		fatalRuntime("create_activity_template_landing_url_builder_failed", err)
	}

	luckyGiftConn, err := dialBackendGRPC(cfg.LuckyGiftService.Addr)
	if err != nil {
		fatalRuntime("connect_lucky_gift_service_failed", err)
	}
	defer luckyGiftConn.Close()

	gameConn, err := dialBackendGRPC(cfg.GameService.Addr)
	if err != nil {
		fatalRuntime("connect_game_service_failed", err)
	}
	defer gameConn.Close()

	auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
	auditHandler := auditmodule.New(store)
	countryRegionHandler := countryregionmodule.New(userclient.NewGRPC(userConn), userDB, store, roomClient, cfg, auditHandler)
	appUserHandler := appusermodule.New(
		userclient.NewGRPC(userConn),
		activityclient.NewGRPC(activityConn),
		sqlDB,
		userDB,
		walletDB,
		cfg,
		auditHandler,
		appusermodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
		appusermodule.WithExportJobs(store, cfg),
	)
	var runner *jobrunner.Runner
	var jobStatus healthmodule.JobStatusProvider
	if cfg.Jobs.Enabled {
		runner = jobrunner.NewRunner(store, redisClient, cfg,
			jobrunner.WithHandler(countryregionmodule.CountryCodeRenameJobType, countryRegionHandler.Service().HandleCountryCodeRenameJob),
			jobrunner.WithArtifactHandler(appusermodule.AppUserExportJobType, appUserHandler.HandleExportJob),
		)
		runner.Start()
		defer runner.Close()
		jobStatus = runner
	}

	teamSalarySettlementHandler := teamsalarysettlementmodule.New(sqlDB, walletDB, userDB, auditHandler)
	if cfg.Jobs.Enabled {
		teamSalaryRunner := teamsalarysettlementmodule.NewAutoRunner(teamSalarySettlementHandler.Service(), "lalu", cfg.NodeID)
		teamSalaryRunner.Start()
		defer teamSalaryRunner.Close()
	}
	var objectUploader uploadmodule.ObjectUploader
	if cfg.TencentCOS.Enabled {
		uploader, err := tencentcos.NewUploader(tencentcos.Config{
			SecretID:  cfg.TencentCOS.SecretID,
			SecretKey: cfg.TencentCOS.SecretKey,
			Bucket:    cfg.TencentCOS.BucketName,
			Region:    cfg.TencentCOS.Region,
			AccessURL: cfg.TencentCOS.AccessURL,
		}, nil)
		if err != nil {
			fatalRuntime("create_tencent_cos_uploader_failed", err)
		}
		objectUploader = uploader
	}
	// 社交 BI 与大屏共享同一个 dashboard 服务实例，保证外部源路由和统计口径一致。
	dashboardService := dashboardmodule.NewService(
		store,
		cfg,
		userclient.NewGRPC(userConn),
		// 用户画像从 userDB 读取已完成真实用户的总量、性别和最近一次成功登录版本；
		// 当天新增与日活继续走 statistics-service，避免重算事件去重口径。
		dashboardmodule.WithUserDB(userDB),
		dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...),
	)
	appRegistryService := appregistrymodule.NewService(userDB)
	// Finance 与 Social BI 必须复用同一 Handler 实例，确保普通充值、已核验币商订单和 H5 币商充值完全同口径。
	paymentHandler := paymentmodule.New(
		walletclient.NewGRPC(walletConn),
		userDB,
		walletDB,
		store,
		auditHandler,
		paymentmodule.WithMoneyRegionSources(moneyRegionSources...),
		paymentmodule.WithRechargeBillSources(financeBillSources...),
	)
	databiService := databimodule.NewService(
		store,
		dashboardService,
		appRegistryService,
		userclient.NewGRPC(userConn),
		databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
		databimodule.WithLuckyGiftDailyStats(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout),
		databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
		databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
	)
	luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler).
		BindUserClient(userclient.NewGRPC(userConn))
	withdrawalReviewNotifier, err := newWithdrawalReviewNotifier(cfg.FinanceNotifications.DingTalk)
	if err != nil {
		fatalRuntime("create_finance_dingtalk_notifier_failed", err)
	}
	activityTemplateHandler := activitytemplatemodule.New(
		activityAdminClient, auditHandler,
		statisticsclient.NewHTTP(cfg.StatisticsService.BaseURL, cfg.StatisticsService.RequestTimeout),
	).BindUserBatchGetter(userclient.NewGRPC(userConn)).
		BindLandingPageURLBuilder(activityLandingBuilder).
		BindBannerTargetDisabler(store)
	appConfigHandler := appconfigmodule.New(store, auditHandler).
		BindActivityTemplateTarget(activityAdminClient, activityLandingBuilder).
		BindRoleScopePolicyClient(userclient.NewGRPC(userConn))
	handlers := router.Handlers{
		Audit:               auditHandler,
		Auth:                authmodule.New(store, auth, auditHandler, cfg),
		AdminUser:           adminusermodule.New(store, cfg, auditHandler),
		AgencyOpening:       agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		ActivityTemplate:    activityTemplateHandler,
		AchievementConfig:   achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		AppConfig:           appConfigHandler,
		AppRegistry:         appregistrymodule.New(userDB, store),
		AppUser:             appUserHandler,
		CoinLedger:          coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
		CountryRegion:       countryRegionHandler,
		CPRelation:          cprelationmodule.NewWithActivity(userDB, activityclient.NewGRPC(activityConn), auditHandler),
		CPWeeklyRank:        cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		CumulativeRecharge:  cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		DailyTask:           dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		Dashboard:           dashboardmodule.NewWithService(dashboardService),
		Databi:              databimodule.New(databiService, store, auditHandler),
		FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler,
			financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService),
			financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...),
			financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
				return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
			})),
		FinanceWithdrawal: financewithdrawalmodule.NewWithSources(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler, withdrawalsource.New(cfg.WithdrawalSources)).
			BindReviewPendingNotifier(withdrawalReviewNotifier),
		ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{
			SessionTTL:       cfg.ExternalAdmin.SessionTTL,
			MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,
			LockDuration:     cfg.ExternalAdmin.LockDuration,
			CookieSecure:     cfg.RefreshCookieSecure,
			CookieSameSite:   cfg.RefreshCookieSameSite,
			LoginRateLimit: externaladminmodule.LoginRateLimitConfig{
				Window:        cfg.ExternalAdmin.LoginRateLimit.Window,
				SystemLimit:   cfg.ExternalAdmin.LoginRateLimit.SystemLimit,
				AppLimit:      cfg.ExternalAdmin.LoginRateLimit.AppLimit,
				IPLimit:       cfg.ExternalAdmin.LoginRateLimit.IPLimit,
				IdentityLimit: cfg.ExternalAdmin.LoginRateLimit.IdentityLimit,
			},
		}, auditHandler,
			externaladminmodule.WithUserHostClient(userv1.NewUserHostServiceClient(userConn)),
			externaladminmodule.WithLoginRateLimiter(redisClient),
		),
		FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		Game: gamemanagementmodule.New(
			gameclient.NewGRPC(gameConn),
			userclient.NewGRPC(userConn),
			auditHandler,
			gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
			gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
		),
		GiftDiamond:           giftdiamondmodule.New(walletDB, auditHandler),
		GiftRecord:            giftrecordmodule.New(userDB, walletDB),
		Health:                healthmodule.New(store, redisClient, jobStatus),
		HostAgencyPolicy:      hostagencypolicymodule.New(store, walletDB, auditHandler),
		HostOrg:               hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
		HostSalarySettlement:  hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
		HostWithdrawal:        hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
		InviteActivityReward:  inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		Job:                   jobmodule.New(store, cfg, auditHandler),
		LevelConfig:           levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
		Menu:                  menumodule.New(store, auditHandler),
		OpsCenter:             opscentermodule.New(appRegistryService, luckyGiftHandler),
		Payment:               paymentHandler,
		PolicyConfig:          policyconfigmodule.New(sqlDB, walletDB, userDB, auditHandler),
		PointWithdrawalConfig: pointwithdrawalconfigmodule.New(walletDB, userDB, auditHandler),
		PrettyID:              prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
		RBAC:                  rbacmodule.New(store, auditHandler),
		RedPacket:             redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
		Report:                reportmodule.New(userDB, roomClient),
		RegistrationReward:    registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		RegionBlock:           regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
		Resource:              resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler, activityclient.NewGRPC(activityConn)),
		RiskConfig:            riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
		RoomAdmin:             roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
		RoomRocket:            roomrocketmodule.New(roomClient, auditHandler),
		RoomTurnoverReward:    roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		Search:                searchmodule.New(store),
		SevenDayCheckIn:       sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
		Team:                  teammodule.New(store, auditHandler),
		TeamSalaryPolicy:      teamsalarypolicymodule.New(store, auditHandler),
		TeamSalarySettlement:  teamSalarySettlementHandler,
		Upload:                uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
		UserLeaderboard:       userleaderboardmodule.New(walletDB, userDB, roomClient),
		VIPConfig:             vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler, activityclient.NewGRPC(activityConn)),
		WeeklyStar:            weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
		Wheel:                 wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
	}
	engine := router.New(cfg, auth, store, handlers)

	server := &http.Server{
		Addr:              cfg.HTTPAddr,
		Handler:           engine,
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		slog.Info("service_listening", "addr", cfg.HTTPAddr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			fatalRuntime("listen_failed", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {
		slog.Error("shutdown_failed", "error", err.Error())
	}
}

func newWithdrawalReviewNotifier(cfg config.DingTalkRobotConfig) (financewithdrawalmodule.ReviewPendingNotifier, error) {
	if !cfg.Enabled || strings.TrimSpace(cfg.WebhookURL) == "" {
		return nil, nil
	}
	client, err := dingtalkrobot.New(dingtalkrobot.Config{
		WebhookURL:     cfg.WebhookURL,
		Secret:         cfg.Secret,
		AtMobiles:      cfg.AtMobiles,
		AtAll:          cfg.AtAll,
		RequestTimeout: cfg.RequestTimeout,
	})
	if err != nil {
		return nil, err
	}
	return financewithdrawalmodule.NewDingTalkReviewPendingNotifier(client), nil
}

func fatalRuntime(msg string, err error) {
	slog.Error(msg, "error", err.Error())
	os.Exit(1)
}

func dialBackendGRPC(addr string) (*grpc.ClientConn, error) {
	// admin-server 在广州跨区访问 Saudi 内网 CLB；gRPC 长连接如果长时间空闲，
	// TCP 层可能留下半开连接。客户端 keepalive 主动探活并触发重连，避免请求卡到 HTTP 超时。
	return grpc.Dial(addr,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                30 * time.Second,
			Timeout:             10 * time.Second,
			PermitWithoutStream: true,
		}),
	)
}

func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegionSourceConfig) ([]paymentmodule.MoneyRegionSource, func(context.Context), error) {
	sources := []paymentmodule.MoneyRegionSource{}
	clients := []*mongo.Client{}
	cleanup := func(closeCtx context.Context) {
		for _, client := range clients {
			if client != nil {
				_ = client.Disconnect(closeCtx)
			}
		}
	}
	for _, sourceConfig := range configs {
		if !sourceConfig.Enabled {
			continue
		}
		connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
		// 财务范围页面依赖 legacy likei 区域目录；启用后启动阶段先 ping，避免运行时静默漏掉 Yumi/Aslan 区域。
		client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
		if err != nil {
			cancel()
			cleanup(ctx)
			return nil, nil, err
		}
		if err := client.Ping(connectCtx, readpref.Primary()); err != nil {
			cancel()
			_ = client.Disconnect(ctx)
			cleanup(ctx)
			return nil, nil, err
		}
		cancel()
		clients = append(clients, client)
		source := paymentmodule.NewMongoMoneyRegionSource(client.Database(sourceConfig.MongoDatabase), sourceConfig)
		if source != nil {
			sources = append(sources, source)
		}
	}
	return sources, cleanup, nil
}

// connectFinanceBillSources 连接 legacy App 的账单 Mongo；启动阶段 ping 失败直接退出，避免财务明细静默漏掉 Yumi 账单。
func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource, dashboardSources map[string]dashboardmodule.ExternalDashboardSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) {
	sources := []paymentmodule.RechargeBillSource{}
	clients := []*mongo.Client{}
	cleanup := func(closeCtx context.Context) {
		for _, client := range clients {
			if client != nil {
				_ = client.Disconnect(closeCtx)
			}
		}
	}
	for _, sourceConfig := range configs {
		if !sourceConfig.Enabled {
			continue
		}
		connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
		client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
		if err != nil {
			cancel()
			cleanup(ctx)
			return nil, nil, err
		}
		if err := client.Ping(connectCtx, readpref.Primary()); err != nil {
			cancel()
			_ = client.Disconnect(ctx)
			cleanup(ctx)
			return nil, nil, err
		}
		cancel()
		clients = append(clients, client)
		source := paymentmodule.NewMongoRechargeBillSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionSources...)
		if source != nil {
			dashboardSource := dashboardSources[source.AppCode()]
			if dashboardSource == nil {
				cleanup(ctx)
				return nil, nil, fmt.Errorf("finance bill source %s requires matching dashboard_external_sources entry for coin seller stats", source.AppCode())
			}
			source.SetDashboardCoinSellerSource(dashboardSource)
			sources = append(sources, source)
		}
	}
	return sources, cleanup, nil
}

// connectLegacyCoinSellerRechargeWriters 只连接显式启用的 legacy 可写账套；
// dashboard/账单源是只读报表连接，不能复用来写 Aslan/Yumi 钱包流水。
func connectLegacyCoinSellerRechargeWriters(ctx context.Context, configs []config.LegacyCoinSellerRechargeSourceConfig) ([]financeordermodule.LegacyCoinSellerRechargeWriter, func(context.Context), error) {
	writers := []financeordermodule.LegacyCoinSellerRechargeWriter{}
	cleanup := func(context.Context) {
		for _, writer := range writers {
			if writer != nil {
				_ = writer.Close()
			}
		}
	}
	for _, sourceConfig := range configs {
		if !sourceConfig.Enabled {
			continue
		}
		appDB, err := sql.Open("mysql", sourceConfig.AppMySQLDSN)
		if err != nil {
			cleanup(ctx)
			return nil, nil, err
		}
		walletDB, err := sql.Open("mysql", sourceConfig.WalletMySQLDSN)
		if err != nil {
			_ = appDB.Close()
			cleanup(ctx)
			return nil, nil, err
		}
		timeout := sourceConfig.RequestTimeout
		if timeout <= 0 {
			timeout = 5 * time.Second
		}
		connectCtx, cancel := context.WithTimeout(ctx, timeout)
		if err := appDB.PingContext(connectCtx); err != nil {
			cancel()
			_ = appDB.Close()
			_ = walletDB.Close()
			cleanup(ctx)
			return nil, nil, err
		}
		if err := walletDB.PingContext(connectCtx); err != nil {
			cancel()
			_ = appDB.Close()
			_ = walletDB.Close()
			cleanup(ctx)
			return nil, nil, err
		}
		cancel()
		writers = append(writers, financeordermodule.NewMySQLLegacyCoinSellerRechargeWriter(sourceConfig, appDB, walletDB))
	}
	return writers, cleanup, nil
}

func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource {
	index := map[string]dashboardmodule.ExternalDashboardSource{}
	for _, source := range sources {
		if source == nil {
			continue
		}
		appCode := strings.ToLower(strings.TrimSpace(source.AppCode()))
		if appCode == "" {
			continue
		}
		index[appCode] = source
	}
	return index
}

// wireLegacyGooglePaidSync 为配置了 Play Console 包名与服务账号的 legacy 账单源开启谷歌实付同步。
func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, configs []config.FinanceBillSourceConfig, store *repository.Store) {
	configsByApp := map[string]config.FinanceBillSourceConfig{}
	for _, sourceConfig := range configs {
		configsByApp[strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))] = sourceConfig
	}
	for _, source := range sources {
		mongoSource, ok := source.(*paymentmodule.MongoRechargeBillSource)
		if !ok {
			continue
		}
		sourceConfig := configsByApp[source.AppCode()]
		if sourceConfig.GooglePackageName == "" ||
			(sourceConfig.GoogleServiceAccountJSON == "" && sourceConfig.GoogleServiceAccountFile == "") {
			continue
		}
		client, err := googleorders.New(googleorders.Config{
			PackageName:        sourceConfig.GooglePackageName,
			ServiceAccountJSON: sourceConfig.GoogleServiceAccountJSON,
			ServiceAccountFile: sourceConfig.GoogleServiceAccountFile,
		})
		if err != nil {
			slog.Error("legacy_google_paid_sync_disabled", "app_code", source.AppCode(), "error", err.Error())
			continue
		}
		mongoSource.SetGooglePaidSync(store, client)
		slog.Info("legacy_google_paid_sync_enabled", "app_code", source.AppCode(), "package_name", sourceConfig.GooglePackageName)
	}
}

// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录；
// Logo 复用财务范围的 legacy App 目录，避免 dashboard、账单和 Social BI 各自维护同一份品牌元数据。
func databiLegacyApps(configs []config.DashboardExternalSourceConfig, regionConfigs []config.MoneyRegionSourceConfig) []databimodule.LegacyAppDescriptor {
	out := []databimodule.LegacyAppDescriptor{}
	seen := map[string]struct{}{}
	logoByAppCode := map[string]string{}
	for _, regionConfig := range regionConfigs {
		appCode := strings.ToLower(strings.TrimSpace(regionConfig.AppCode))
		if appCode != "" {
			logoByAppCode[appCode] = strings.TrimSpace(regionConfig.LogoURL)
		}
	}
	for _, sourceConfig := range configs {
		if !sourceConfig.Enabled {
			continue
		}
		appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
		if appCode == "" {
			continue
		}
		if _, ok := seen[appCode]; ok {
			continue
		}
		seen[appCode] = struct{}{}
		out = append(out, databimodule.LegacyAppDescriptor{
			AppCode: appCode,
			AppName: strings.TrimSpace(sourceConfig.AppName),
			LogoURL: logoByAppCode[appCode],
		})
	}
	return out
}

func databiLegacyRegionCatalogs(sources []paymentmodule.MoneyRegionSource) []databimodule.LegacyRegionCatalogSource {
	out := []databimodule.LegacyRegionCatalogSource{}
	for _, source := range sources {
		if catalog, ok := source.(databimodule.LegacyRegionCatalogSource); ok && catalog != nil {
			out = append(out, catalog)
		}
	}
	return out
}

func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
	resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
	for _, source := range sources {
		resolver, ok := source.(dashboardmodule.ExternalDashboardRegionResolver)
		if ok && resolver != nil {
			resolvers = append(resolvers, resolver)
		}
	}
	return resolvers
}

func connectDashboardExternalSources(ctx context.Context, configs []config.DashboardExternalSourceConfig, regionResolvers ...dashboardmodule.ExternalDashboardRegionResolver) ([]dashboardmodule.ExternalDashboardSource, func(), error) {
	sources := []dashboardmodule.ExternalDashboardSource{}
	dbs := []*sql.DB{}
	mongoClients := []*mongo.Client{}
	cleanup := func() {
		for _, db := range dbs {
			if db != nil {
				_ = db.Close()
			}
		}
		closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		for _, client := range mongoClients {
			if client != nil {
				_ = client.Disconnect(closeCtx)
			}
		}
	}
	for _, sourceConfig := range configs {
		if !sourceConfig.Enabled {
			continue
		}
		switch strings.ToLower(strings.TrimSpace(sourceConfig.SourceType)) {
		case "aslan_http":
			// Aslan 大屏数据由对方 console 免鉴权接口返回；这里不建立数据库连接，避免 admin 绕过 Aslan 服务端口径。
			source, err := dashboardmodule.NewAslanHTTPDashboardSource(sourceConfig, regionResolvers...)
			if err != nil {
				cleanup()
				return nil, nil, err
			}
			if source != nil {
				sources = append(sources, source)
			}
			continue
		case "aslan_mongo":
			// Aslan 当前要求 admin-server 直接读外接项目 Mongo 聚合事实；启动期先 ping，
			// 避免前端选择 Aslan 后才暴露连接或凭证错误。
			client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
			if err != nil {
				cleanup()
				return nil, nil, err
			}
			pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
			if err := client.Ping(pingCtx, readpref.PrimaryPreferred()); err != nil {
				cancel()
				_ = client.Disconnect(ctx)
				cleanup()
				return nil, nil, err
			}
			cancel()
			var legacyDB *sql.DB
			if strings.TrimSpace(sourceConfig.LegacyMySQLDSN) != "" {
				// 金币代理发货充值金额只存在 legacy RDS；它是 Aslan Mongo 大屏的补充事实源，失败应在启动期暴露。
				legacyDB, err = sql.Open("mysql", sourceConfig.LegacyMySQLDSN)
				if err != nil {
					_ = client.Disconnect(ctx)
					cleanup()
					return nil, nil, fmt.Errorf("open dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err)
				}
				legacyCtx, legacyCancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
				if err := legacyDB.PingContext(legacyCtx); err != nil {
					legacyCancel()
					_ = legacyDB.Close()
					_ = client.Disconnect(ctx)
					cleanup()
					return nil, nil, fmt.Errorf("ping dashboard legacy mysql for app %s: %w", sourceConfig.AppCode, err)
				}
				legacyCancel()
			}
			source, err := dashboardmodule.NewAslanMongoDashboardSourceWithLegacyDB(client.Database(sourceConfig.MongoDatabase), legacyDB, sourceConfig, regionResolvers...)
			if err != nil {
				if legacyDB != nil {
					_ = legacyDB.Close()
				}
				_ = client.Disconnect(ctx)
				cleanup()
				return nil, nil, err
			}
			if source == nil {
				if legacyDB != nil {
					_ = legacyDB.Close()
				}
				_ = client.Disconnect(ctx)
				continue
			}
			if legacyDB != nil {
				dbs = append(dbs, legacyDB)
			}
			mongoClients = append(mongoClients, client)
			sources = append(sources, source)
			continue
		case "", "mysql":
		default:
			cleanup()
			return nil, nil, errors.New("unsupported dashboard external source type: " + sourceConfig.SourceType)
		}
		db, err := sql.Open("mysql", sourceConfig.MySQLDSN)
		if err != nil {
			cleanup()
			return nil, nil, err
		}
		pingCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
		// Yumi 大屏使用外接项目 dashboard-cdc-worker 的只读聚合库；启动期先 ping，避免 admin 正常启动但 Yumi 查询静默回退到错误事实域。
		if err := db.PingContext(pingCtx); err != nil {
			cancel()
			_ = db.Close()
			cleanup()
			return nil, nil, err
		}
		cancel()
		source := dashboardmodule.NewMySQLExternalDashboardSource(db, sourceConfig, regionResolvers...)
		if source == nil {
			_ = db.Close()
			continue
		}
		dbs = append(dbs, db)
		sources = append(sources, source)
	}
	return sources, cleanup, nil
}

func ensureDatabase(dsn string) error {
	parsed, err := mysqlDriver.ParseDSN(dsn)
	if err != nil {
		return err
	}
	if parsed.DBName == "" {
		return nil
	}

	dbName := parsed.DBName
	parsed.DBName = ""

	db, err := sql.Open("mysql", parsed.FormatDSN())
	if err != nil {
		return err
	}
	defer db.Close()

	escaped := strings.ReplaceAll(dbName, "`", "``")
	_, err = db.Exec("CREATE DATABASE IF NOT EXISTS `" + escaped + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
	return err
}
