package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"

	_ "github.com/go-sql-driver/mysql"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

const (
	defaultAppCode = "lalu"
	countryCode    = "US"
	regionID       = int64(3)
	gameID         = "dice"
)

type config struct {
	appCode     string
	runID       string
	userDSN     string
	walletDSN   string
	userAddr    string
	walletAddr  string
	roomAddr    string
	gameAddr    string
	countryID   int64
	roomID      string
	admin       account
	sender      account
	host        account
	seller      account
	gamePlayer  account
	gameRival   account
	normalGift  string
	luckyGift   string
	superGift   string
	luckyPool   string
	superPool   string
	normalPrice int64
	luckyPrice  int64
	superPrice  int64
}

type account struct {
	userID        int64
	displayUserID string
	username      string
}

type runner struct {
	cfg          config
	userDB       *sql.DB
	walletDB     *sql.DB
	authClient   userv1.AuthServiceClient
	hostClient   userv1.UserHostAdminServiceClient
	walletClient walletv1.WalletServiceClient
	cronClient   walletv1.WalletCronServiceClient
	roomClient   roomv1.RoomCommandServiceClient
	gameAdmin    gamev1.GameAdminServiceClient
	gameApp      gamev1.GameAppServiceClient
}

func main() {
	var cfg config
	flag.StringVar(&cfg.appCode, "app", envDefault("APP_CODE", defaultAppCode), "app code")
	flag.StringVar(&cfg.runID, "run-id", envDefault("RUN_ID", ""), "stable run id")
	flag.StringVar(&cfg.userDSN, "user-dsn", envDefault("USER_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"), "user mysql dsn")
	flag.StringVar(&cfg.walletDSN, "wallet-dsn", envDefault("WALLET_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"), "wallet mysql dsn")
	flag.StringVar(&cfg.userAddr, "user-addr", envDefault("USER_GRPC_ADDR", "127.0.0.1:13005"), "user-service grpc address")
	flag.StringVar(&cfg.walletAddr, "wallet-addr", envDefault("WALLET_GRPC_ADDR", "127.0.0.1:13004"), "wallet-service grpc address")
	flag.StringVar(&cfg.roomAddr, "room-addr", envDefault("ROOM_GRPC_ADDR", "127.0.0.1:13001"), "room-service grpc address")
	flag.StringVar(&cfg.gameAddr, "game-addr", envDefault("GAME_GRPC_ADDR", "127.0.0.1:13008"), "game-service grpc address")
	flag.Parse()

	if strings.TrimSpace(cfg.runID) == "" {
		cfg.runID = fmt.Sprintf("%d", time.Now().UnixNano())
	}
	cfg.runID = sanitizeID(cfg.runID)
	cfg.roomID = shortID("dbroom", cfg.runID, 44)
	cfg.normalGift = shortID("dbg_normal", cfg.runID, 60)
	cfg.luckyGift = shortID("dbg_lucky", cfg.runID, 60)
	cfg.superGift = shortID("dbg_super", cfg.runID, 60)
	cfg.luckyPool = "lucky"
	cfg.superPool = "super_lucky"
	cfg.normalPrice = 200
	cfg.luckyPrice = 120
	cfg.superPrice = 160

	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
	defer cancel()

	userDB, err := openDB(ctx, cfg.userDSN)
	if err != nil {
		log.Fatalf("open user db: %v", err)
	}
	defer userDB.Close()
	walletDB, err := openDB(ctx, cfg.walletDSN)
	if err != nil {
		log.Fatalf("open wallet db: %v", err)
	}
	defer walletDB.Close()

	userConn := mustDial(ctx, cfg.userAddr)
	defer userConn.Close()
	walletConn := mustDial(ctx, cfg.walletAddr)
	defer walletConn.Close()
	roomConn := mustDial(ctx, cfg.roomAddr)
	defer roomConn.Close()
	gameConn := mustDial(ctx, cfg.gameAddr)
	defer gameConn.Close()

	r := &runner{
		cfg:          cfg,
		userDB:       userDB,
		walletDB:     walletDB,
		authClient:   userv1.NewAuthServiceClient(userConn),
		hostClient:   userv1.NewUserHostAdminServiceClient(userConn),
		walletClient: walletv1.NewWalletServiceClient(walletConn),
		cronClient:   walletv1.NewWalletCronServiceClient(walletConn),
		roomClient:   roomv1.NewRoomCommandServiceClient(roomConn),
		gameAdmin:    gamev1.NewGameAdminServiceClient(gameConn),
		gameApp:      gamev1.NewGameAppServiceClient(gameConn),
	}
	if err := r.run(ctx); err != nil {
		log.Fatalf("databi real flow failed: %v", err)
	}
}

func (r *runner) run(ctx context.Context) error {
	if err := r.ensureUserMasterData(ctx); err != nil {
		return err
	}
	if err := r.ensureSalaryConfig(ctx); err != nil {
		return err
	}
	if err := r.ensureGameConfig(ctx); err != nil {
		return err
	}
	if err := r.createAccounts(ctx); err != nil {
		return err
	}
	if err := r.createBusinessRoles(ctx); err != nil {
		return err
	}
	if err := r.prepareWalletAndGifts(ctx); err != nil {
		return err
	}
	if err := r.simulateRechargeEvents(ctx); err != nil {
		return err
	}
	if err := r.runRoomFlow(ctx); err != nil {
		return err
	}
	if err := r.runSalaryFlow(ctx); err != nil {
		return err
	}
	if err := r.runGameFlow(ctx); err != nil {
		return err
	}
	if err := r.waitForAsyncSources(ctx); err != nil {
		return err
	}
	fmt.Printf("databi real business flow completed run_id=%s app=%s country_id=%d room_id=%s\n", r.cfg.runID, r.cfg.appCode, r.cfg.countryID, r.cfg.roomID)
	return nil
}

func (r *runner) ensureUserMasterData(ctx context.Context) error {
	now := time.Now().UnixMilli()
	_, err := r.userDB.ExecContext(ctx, `
		INSERT INTO countries (
			app_code, country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
			phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, 'United States', ?, 'USA', '840', 'United States', '+1', 'US', 1, 900, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			country_name = VALUES(country_name),
			country_display_name = VALUES(country_display_name),
			enabled = 1,
			updated_at_ms = VALUES(updated_at_ms)`,
		r.cfg.appCode, countryCode, now, now)
	if err != nil {
		return fmt.Errorf("seed country: %w", err)
	}
	if err := r.userDB.QueryRowContext(ctx,
		`SELECT country_id FROM countries WHERE app_code = ? AND country_code = ?`,
		r.cfg.appCode, countryCode,
	).Scan(&r.cfg.countryID); err != nil {
		return fmt.Errorf("read country id: %w", err)
	}
	_, err = r.userDB.ExecContext(ctx, `
		INSERT INTO region_countries (
			app_code, region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 'active', 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			region_id = VALUES(region_id),
			status = 'active',
			updated_at_ms = VALUES(updated_at_ms)`,
		r.cfg.appCode, regionID, countryCode, now, now)
	if err != nil {
		return fmt.Errorf("seed region country: %w", err)
	}
	fmt.Printf("seeded user master data country=%s country_id=%d region_id=%d\n", countryCode, r.cfg.countryID, regionID)
	return nil
}

func (r *runner) ensureSalaryConfig(ctx context.Context) error {
	nowUTC := time.Now().UTC()
	now := nowUTC.UnixMilli()
	cycleKey := nowUTC.Format("2006-01")
	policyID := uint64(990000000000 + nowUTC.Year()*100 + int(nowUTC.Month()))
	_, err := r.walletDB.ExecContext(ctx, `
		INSERT INTO host_agency_salary_policies (
			app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
			gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 'Databi Local Daily Policy', ?, 'active', 'daily', 'automatic', 1.000000, 0.000000000000, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			status = 'active',
			settlement_mode = 'daily',
			settlement_trigger_mode = 'automatic',
			gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio),
			updated_at_ms = VALUES(updated_at_ms)`,
		r.cfg.appCode, policyID, cycleKey, policyID, regionID, now, now)
	if err != nil {
		return fmt.Errorf("seed salary policy: %w", err)
	}
	_, err = r.walletDB.ExecContext(ctx, `
		INSERT INTO host_agency_salary_policy_levels (
			app_code, policy_id, policy_version, level_no, required_diamonds, host_salary_usd_minor, host_coin_reward,
			agency_salary_usd_minor, status, sort_order, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 1, 100, 150, 0, 50, 'active', 1, ?, ?)
		ON DUPLICATE KEY UPDATE
			required_diamonds = VALUES(required_diamonds),
			host_salary_usd_minor = VALUES(host_salary_usd_minor),
			agency_salary_usd_minor = VALUES(agency_salary_usd_minor),
			status = 'active',
			updated_at_ms = VALUES(updated_at_ms)`,
		r.cfg.appCode, policyID, policyID, now, now)
	if err != nil {
		return fmt.Errorf("seed salary policy level: %w", err)
	}
	_, err = r.walletDB.ExecContext(ctx, `
		INSERT INTO host_salary_policy_cycle_bindings (
			app_code, region_id, cycle_key, policy_id, policy_version, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id), policy_version = VALUES(policy_version)`,
		r.cfg.appCode, regionID, cycleKey, policyID, policyID, now)
	if err != nil {
		return fmt.Errorf("seed salary policy cycle binding: %w", err)
	}
	_, err = r.walletDB.ExecContext(ctx,
		`DELETE FROM coin_seller_salary_exchange_rate_tiers WHERE app_code = ? AND region_id = ? AND sort_order = 990001`,
		r.cfg.appCode, regionID)
	if err != nil {
		return fmt.Errorf("clear salary exchange tier: %w", err)
	}
	_, err = r.walletDB.ExecContext(ctx, `
		INSERT INTO coin_seller_salary_exchange_rate_tiers (
			app_code, region_id, min_usd_minor, max_usd_minor, coin_per_usd, status,
			sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, 1, 99999999, 1000, 'active', 990001, 0, 0, ?, ?)`,
		r.cfg.appCode, regionID, now, now)
	if err != nil {
		return fmt.Errorf("seed salary exchange tier: %w", err)
	}
	fmt.Println("seeded wallet salary policy and salary-to-seller exchange tier")
	return nil
}

func (r *runner) ensureGameConfig(ctx context.Context) error {
	meta := r.gameMeta("game-admin")
	if _, err := r.gameAdmin.UpsertPlatform(ctx, &gamev1.UpsertPlatformRequest{
		Meta: meta,
		Platform: &gamev1.GamePlatform{
			AppCode:             r.cfg.appCode,
			PlatformCode:        "dice",
			PlatformName:        "Databi Dice",
			Status:              "active",
			ApiBaseUrl:          "https://local.hyapp.invalid/dice",
			SortOrder:           1,
			AdapterType:         "yomi_v4",
			CallbackSecret:      "local-databi-secret",
			CallbackIpWhitelist: []string{},
			AdapterConfigJson:   "{}",
		},
	}); err != nil {
		return fmt.Errorf("upsert game platform: %w", err)
	}
	if _, err := r.gameAdmin.UpsertCatalog(ctx, &gamev1.UpsertCatalogRequest{
		Meta: meta,
		Game: &gamev1.GameCatalogItem{
			AppCode:        r.cfg.appCode,
			GameId:         gameID,
			PlatformCode:   "dice",
			ProviderGameId: "dice",
			GameName:       "Databi Dice",
			Category:       "self",
			IconUrl:        "https://local.hyapp.invalid/dice.png",
			CoverUrl:       "https://local.hyapp.invalid/dice-cover.png",
			LaunchMode:     "h5_popup",
			Orientation:    "portrait",
			MinCoin:        100,
			Status:         "active",
			SortOrder:      1,
			Tags:           []string{"self"},
		},
	}); err != nil {
		return fmt.Errorf("upsert game catalog: %w", err)
	}
	if _, err := r.gameAdmin.UpdateDiceConfig(ctx, &gamev1.UpdateDiceConfigRequest{
		Meta: meta,
		Config: &gamev1.DiceConfig{
			AppCode: r.cfg.appCode,
			GameId:  gameID,
			Status:  "active",
			StakeOptions: []*gamev1.DiceStakeOption{{
				StakeCoin: 100,
				Enabled:   true,
				SortOrder: 1,
			}},
			FeeBps:           500,
			PoolBps:          100,
			MinPlayers:       2,
			MaxPlayers:       2,
			RobotEnabled:     false,
			RobotMatchWaitMs: 3000,
		},
	}); err != nil {
		return fmt.Errorf("upsert dice config: %w", err)
	}
	fmt.Println("seeded game platform/catalog/dice config through game-service RPC")
	return nil
}

func (r *runner) createAccounts(ctx context.Context) error {
	admin, err := r.quickCreate(ctx, "admin")
	if err != nil {
		return err
	}
	r.cfg.admin = admin
	fmt.Printf("created admin user_id=%d display=%s\n", admin.userID, admin.displayUserID)

	for _, spec := range []struct {
		key    string
		target *account
	}{
		{key: "sender", target: &r.cfg.sender},
		{key: "host", target: &r.cfg.host},
		{key: "seller", target: &r.cfg.seller},
		{key: "game_player", target: &r.cfg.gamePlayer},
		{key: "game_rival", target: &r.cfg.gameRival},
	} {
		acc, err := r.registerAppAccount(ctx, spec.key)
		if err != nil {
			return err
		}
		*spec.target = acc
		fmt.Printf("registered app user %-11s user_id=%d display=%s\n", spec.key, acc.userID, acc.displayUserID)
	}
	return nil
}

func (r *runner) quickCreate(ctx context.Context, role string) (account, error) {
	username := shortID("databi_"+role, r.cfg.runID, 28)
	deviceID := shortID("device_"+role, r.cfg.runID, 60)
	resp, err := r.authClient.QuickCreateAccount(ctx, &userv1.QuickCreateAccountRequest{
		Meta: &userv1.RequestMeta{
			RequestId:   r.commandID("quick_create_" + role),
			Caller:      "databi-real-flow",
			SentAtMs:    time.Now().UnixMilli(),
			DeviceId:    deviceID,
			ClientIp:    "127.0.0.1",
			UserAgent:   "databi-real-flow",
			CountryByIp: countryCode,
			AppCode:     r.cfg.appCode,
			Platform:    "ios",
			Language:    "en",
			Timezone:    "Asia/Shanghai",
		},
		Password:       "Databi@123456",
		Username:       username,
		Avatar:         "https://local.hyapp.invalid/avatar.png",
		Gender:         "female",
		Country:        countryCode,
		DeviceId:       deviceID,
		Device:         "local",
		OsVersion:      "17.0",
		Birth:          "1995-01-01",
		AppVersion:     "1.0.0",
		BuildNumber:    "1",
		Source:         "databi_real_flow",
		InstallChannel: "local",
		Platform:       "ios",
		Language:       "en",
		Timezone:       "Asia/Shanghai",
	})
	if err != nil {
		return account{}, fmt.Errorf("quick create %s: %w", role, err)
	}
	token := resp.GetToken()
	if token.GetUserId() <= 0 {
		return account{}, fmt.Errorf("quick create %s returned empty user id", role)
	}
	return account{userID: token.GetUserId(), displayUserID: token.GetDisplayUserId(), username: username}, nil
}

func (r *runner) registerAppAccount(ctx context.Context, role string) (account, error) {
	username := shortID("databi_"+role, r.cfg.runID, 28)
	deviceID := shortID("device_"+role, r.cfg.runID, 60)
	resp, err := r.authClient.RegisterThirdParty(ctx, &userv1.RegisterThirdPartyRequest{
		Meta: &userv1.RequestMeta{
			RequestId:   r.commandID("register_third_party_" + role),
			Caller:      "databi-real-flow",
			SentAtMs:    time.Now().UnixMilli(),
			DeviceId:    deviceID,
			ClientIp:    "127.0.0.1",
			UserAgent:   "databi-real-flow",
			CountryByIp: countryCode,
			AppCode:     r.cfg.appCode,
			Platform:    "ios",
			Language:    "en",
			Timezone:    "Asia/Shanghai",
		},
		Provider:       "wechat",
		Credential:     "openid_" + role + "_" + r.cfg.runID,
		DeviceId:       deviceID,
		Username:       username,
		Avatar:         "https://local.hyapp.invalid/avatar.png",
		Gender:         "female",
		Country:        countryCode,
		Device:         "local",
		OsVersion:      "17.0",
		Birth:          "1995-01-01",
		AppVersion:     "1.0.0",
		BuildNumber:    "1",
		Source:         "databi_app_registration",
		InstallChannel: "local",
		Platform:       "ios",
		Language:       "en",
		Timezone:       "Asia/Shanghai",
	})
	if err != nil {
		return account{}, fmt.Errorf("register app user %s: %w", role, err)
	}
	token := resp.GetToken()
	if token.GetUserId() <= 0 {
		return account{}, fmt.Errorf("register app user %s returned empty user id", role)
	}
	if !resp.GetIsNewUser() {
		return account{}, fmt.Errorf("register app user %s reused an existing third-party credential", role)
	}
	return account{userID: token.GetUserId(), displayUserID: token.GetDisplayUserId(), username: username}, nil
}

func (r *runner) createBusinessRoles(ctx context.Context) error {
	if _, err := r.hostClient.CreateCoinSeller(ctx, &userv1.CreateCoinSellerRequest{
		Meta:         r.userMeta("create_coin_seller"),
		CommandId:    r.commandID("create_coin_seller"),
		AdminUserId:  r.cfg.admin.userID,
		TargetUserId: r.cfg.seller.userID,
		Reason:       "databi real flow",
	}); err != nil {
		return fmt.Errorf("create coin seller: %w", err)
	}
	if _, err := r.hostClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
		Meta:        r.userMeta("create_agency"),
		CommandId:   r.commandID("create_agency"),
		AdminUserId: r.cfg.admin.userID,
		OwnerUserId: r.cfg.host.userID,
		Name:        shortID("Databi Agency", r.cfg.runID, 80),
		JoinEnabled: true,
		Reason:      "databi real flow",
	}); err != nil {
		return fmt.Errorf("create agency/host: %w", err)
	}
	fmt.Printf("created roles seller_user_id=%d host_agency_owner_user_id=%d\n", r.cfg.seller.userID, r.cfg.host.userID)
	return nil
}

func (r *runner) prepareWalletAndGifts(ctx context.Context) error {
	credits := []struct {
		userID int64
		amount int64
		label  string
	}{
		{userID: r.cfg.sender.userID, amount: 1_000_000, label: "sender"},
		{userID: r.cfg.gamePlayer.userID, amount: 10_000, label: "game_player"},
		{userID: r.cfg.gameRival.userID, amount: 10_000, label: "game_rival"},
	}
	for _, credit := range credits {
		if _, err := r.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
			CommandId:      r.commandID("manual_credit_" + credit.label),
			TargetUserId:   credit.userID,
			AssetType:      "COIN",
			Amount:         credit.amount,
			OperatorUserId: r.cfg.admin.userID,
			Reason:         "databi real flow manual grant",
			EvidenceRef:    "local-real-flow",
			AppCode:        r.cfg.appCode,
		}); err != nil {
			return fmt.Errorf("admin credit %s: %w", credit.label, err)
		}
	}
	if _, err := r.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
		CommandId:        r.commandID("seller_stock"),
		SellerUserId:     r.cfg.seller.userID,
		StockType:        "usdt_purchase",
		CoinAmount:       500_000,
		PaidCurrencyCode: "USDT",
		PaidAmountMicro:  10_000_000,
		PaymentRef:       r.commandID("seller_stock_payment"),
		EvidenceRef:      "local-real-flow",
		OperatorUserId:   r.cfg.admin.userID,
		Reason:           "databi real flow seller stock",
		AppCode:          r.cfg.appCode,
		SellerCountryId:  r.cfg.countryID,
		SellerRegionId:   regionID,
	}); err != nil {
		return fmt.Errorf("credit seller stock: %w", err)
	}
	if _, err := r.walletClient.TransferCoinFromSeller(ctx, &walletv1.TransferCoinFromSellerRequest{
		CommandId:       r.commandID("seller_transfer"),
		SellerUserId:    r.cfg.seller.userID,
		TargetUserId:    r.cfg.sender.userID,
		Amount:          50_000,
		Reason:          "databi real flow seller transfer",
		AppCode:         r.cfg.appCode,
		SellerRegionId:  regionID,
		TargetRegionId:  regionID,
		TargetCountryId: r.cfg.countryID,
	}); err != nil {
		return fmt.Errorf("seller transfer coin: %w", err)
	}
	if _, err := r.walletClient.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
		CommandId:    r.commandID("task_reward"),
		AppCode:      r.cfg.appCode,
		TargetUserId: r.cfg.sender.userID,
		Amount:       1_000,
		TaskType:     "daily",
		TaskId:       "databi_daily",
		CycleKey:     time.Now().UTC().Format("2006-01-02"),
		Reason:       "databi real flow platform reward",
	}); err != nil {
		return fmt.Errorf("task reward: %w", err)
	}
	if _, err := r.walletClient.CreditLuckyGiftReward(ctx, &walletv1.CreditLuckyGiftRewardRequest{
		CommandId:       r.commandID("super_lucky_reward"),
		AppCode:         r.cfg.appCode,
		TargetUserId:    r.cfg.sender.userID,
		Amount:          2_000,
		DrawId:          r.commandID("super_lucky_draw"),
		RoomId:          r.cfg.roomID,
		GiftId:          r.cfg.superGift,
		PoolId:          r.cfg.superPool,
		Reason:          "databi real flow super lucky payout",
		VisibleRegionId: regionID,
		CountryId:       r.cfg.countryID,
	}); err != nil {
		return fmt.Errorf("lucky gift reward: %w", err)
	}
	if err := r.createGift(ctx, r.cfg.normalGift, "normal", r.cfg.normalPrice); err != nil {
		return err
	}
	if err := r.createGift(ctx, r.cfg.luckyGift, "lucky", r.cfg.luckyPrice); err != nil {
		return err
	}
	if err := r.createGift(ctx, r.cfg.superGift, "super_lucky", r.cfg.superPrice); err != nil {
		return err
	}
	fmt.Println("prepared wallet balances, seller stock/transfer, platform reward and gift configs")
	return nil
}

func (r *runner) createGift(ctx context.Context, giftID string, giftType string, price int64) error {
	if err := r.ensureGiftType(ctx, giftType); err != nil {
		return err
	}
	mgrGrant := true
	resourceResp, err := r.walletClient.CreateResource(ctx, &walletv1.CreateResourceRequest{
		RequestId:           r.commandID("resource_" + giftID),
		AppCode:             r.cfg.appCode,
		ResourceCode:        "res_" + giftID,
		ResourceType:        "gift",
		Name:                "Databi " + giftType,
		Status:              "active",
		Grantable:           true,
		GrantStrategy:       "increase_quantity",
		UsageScopes:         []string{"gift"},
		AssetUrl:            "https://local.hyapp.invalid/" + giftID + ".png",
		PreviewUrl:          "https://local.hyapp.invalid/" + giftID + "-preview.png",
		AnimationUrl:        "https://local.hyapp.invalid/" + giftID + ".svga",
		MetadataJson:        "{}",
		SortOrder:           900,
		OperatorUserId:      r.cfg.admin.userID,
		ManagerGrantEnabled: &mgrGrant,
		PriceType:           "coin",
		CoinPrice:           price,
	})
	if err != nil {
		return fmt.Errorf("create resource %s: %w", giftID, err)
	}
	if _, err := r.walletClient.CreateGiftConfig(ctx, &walletv1.CreateGiftConfigRequest{
		RequestId:        r.commandID("gift_config_" + giftID),
		AppCode:          r.cfg.appCode,
		GiftId:           giftID,
		ResourceId:       resourceResp.GetResource().GetResourceId(),
		Status:           "active",
		Name:             "Databi " + giftType,
		SortOrder:        900,
		PresentationJson: "{}",
		PriceVersion:     "v1",
		CoinPrice:        price,
		HeatValue:        price,
		EffectiveAtMs:    time.Now().UnixMilli(),
		OperatorUserId:   r.cfg.admin.userID,
		RegionIds:        []int64{regionID},
		GiftTypeCode:     giftType,
		ChargeAssetType:  "COIN",
		EffectiveFromMs:  0,
		EffectiveToMs:    0,
	}); err != nil {
		return fmt.Errorf("create gift config %s: %w", giftID, err)
	}
	return nil
}

func (r *runner) ensureGiftType(ctx context.Context, giftType string) error {
	var count int64
	if err := r.walletDB.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM gift_type_configs WHERE app_code = ? AND type_code = ?`,
		r.cfg.appCode, giftType,
	).Scan(&count); err != nil {
		return fmt.Errorf("read gift type %s: %w", giftType, err)
	}
	if count > 0 {
		return nil
	}
	if _, err := r.walletClient.UpsertGiftTypeConfig(ctx, &walletv1.UpsertGiftTypeConfigRequest{
		RequestId:      r.commandID("gift_type_" + giftType),
		AppCode:        r.cfg.appCode,
		TypeCode:       giftType,
		Name:           giftType,
		TabKey:         shortID("databi_"+giftType, r.cfg.runID, 32),
		Status:         "active",
		SortOrder:      900,
		OperatorUserId: r.cfg.admin.userID,
	}); err != nil {
		return fmt.Errorf("upsert gift type %s: %w", giftType, err)
	}
	return nil
}

func (r *runner) simulateRechargeEvents(ctx context.Context) error {
	if err := r.insertSimulatedRecharge(ctx, "google", 199, 1_990_000); err != nil {
		return err
	}
	if err := r.insertSimulatedRecharge(ctx, "mifapay", 299, 2_990_000); err != nil {
		return err
	}
	fmt.Println("inserted simulated google/mifapay recharge outbox events")
	return nil
}

func (r *runner) insertSimulatedRecharge(ctx context.Context, channel string, usdMinor int64, amountMicro int64) error {
	now := time.Now().UnixMilli()
	eventID := r.commandID("sim_recharge_" + channel)
	payload := map[string]any{
		"app_code":               r.cfg.appCode,
		"provider":               channel,
		"channel":                channel,
		"recharge_type":          channel,
		"target_user_id":         r.cfg.sender.userID,
		"target_country_id":      r.cfg.countryID,
		"country_id":             r.cfg.countryID,
		"target_region_id":       regionID,
		"region_id":              regionID,
		"amount_micro":           amountMicro,
		"recharge_usd_minor":     usdMinor,
		"recharge_currency_code": "USD",
		"recharge_sequence":      1,
		"created_at_ms":          now,
	}
	raw, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	_, err = r.walletDB.ExecContext(ctx, `
		INSERT INTO wallet_outbox (
			app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
			available_delta, frozen_delta, payload, status, worker_id, lock_until_ms, retry_count,
			next_retry_at_ms, last_error, created_at_ms, updated_at_ms
		) VALUES (?, ?, 'WalletRechargeRecorded', ?, ?, ?, 'COIN', 0, 0, CAST(? AS JSON), 'pending', '', NULL, 0, NULL, NULL, ?, ?)
		ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at_ms = VALUES(updated_at_ms)`,
		r.cfg.appCode, eventID, eventID, eventID, r.cfg.sender.userID, string(raw), now, now)
	if err != nil {
		return fmt.Errorf("insert simulated %s recharge: %w", channel, err)
	}
	return nil
}

func (r *runner) runRoomFlow(ctx context.Context) error {
	if _, err := r.roomClient.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:             r.roomMeta("create_room", r.cfg.sender.userID),
		Mode:             "voice",
		VisibleRegionId:  regionID,
		RoomName:         "Databi Real Flow",
		RoomAvatar:       "https://local.hyapp.invalid/room.png",
		RoomDescription:  "databi real flow",
		RoomShortId:      firstNonEmpty(r.cfg.sender.displayUserID, fmt.Sprintf("%d", r.cfg.sender.userID)),
		OwnerCountryCode: countryCode,
	}); err != nil {
		return fmt.Errorf("create room: %w", err)
	}
	for _, acc := range []account{r.cfg.host, r.cfg.sender} {
		if _, err := r.roomClient.JoinRoom(ctx, &roomv1.JoinRoomRequest{
			Meta:           r.roomMeta("join_"+acc.username, acc.userID),
			Role:           "member",
			ActorCountryId: r.cfg.countryID,
			ActorRegionId:  regionID,
			ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{
				UserId:   acc.userID,
				Nickname: acc.username,
				Avatar:   "https://local.hyapp.invalid/avatar.png",
			},
		}); err != nil {
			return fmt.Errorf("join room user %d: %w", acc.userID, err)
		}
	}
	micResp, err := r.roomClient.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   r.roomMeta("mic_up", r.cfg.host.userID),
		SeatNo: 1,
		TargetDisplayProfile: &roomv1.RoomUserDisplayProfile{
			UserId:   r.cfg.host.userID,
			Nickname: r.cfg.host.username,
			Avatar:   "https://local.hyapp.invalid/avatar.png",
		},
	})
	if err != nil {
		return fmt.Errorf("mic up: %w", err)
	}
	if _, err := r.roomClient.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
		Meta:         r.roomMeta("mic_confirm", r.cfg.host.userID),
		TargetUserId: r.cfg.host.userID,
		MicSessionId: micResp.GetMicSessionId(),
		RoomVersion:  micResp.GetResult().GetRoomVersion(),
		EventTimeMs:  time.Now().UnixMilli(),
		Source:       "databi-real-flow",
	}); err != nil {
		return fmt.Errorf("confirm mic publishing: %w", err)
	}
	time.Sleep(2200 * time.Millisecond)
	if _, err := r.roomClient.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
		Meta:         r.roomMeta("mic_heartbeat", r.cfg.host.userID),
		TargetUserId: r.cfg.host.userID,
		MicSessionId: micResp.GetMicSessionId(),
	}); err != nil {
		return fmt.Errorf("mic heartbeat: %w", err)
	}
	if _, err := r.roomClient.MicDown(ctx, &roomv1.MicDownRequest{
		Meta:         r.roomMeta("mic_down", r.cfg.host.userID),
		TargetUserId: r.cfg.host.userID,
		Reason:       "databi-real-flow",
	}); err != nil {
		return fmt.Errorf("mic down: %w", err)
	}
	for _, gift := range []struct {
		id     string
		count  int32
		poolID string
	}{
		{id: r.cfg.normalGift, count: 1},
		{id: r.cfg.luckyGift, count: 1, poolID: r.cfg.luckyPool},
		{id: r.cfg.superGift, count: 1, poolID: r.cfg.superPool},
	} {
		if _, err := r.roomClient.SendGift(ctx, &roomv1.SendGiftRequest{
			Meta:                    r.roomMeta("send_gift_"+gift.id, r.cfg.sender.userID),
			TargetUserId:            r.cfg.host.userID,
			GiftId:                  gift.id,
			GiftCount:               gift.count,
			TargetType:              "user",
			PoolId:                  gift.poolID,
			TargetIsHost:            true,
			TargetHostRegionId:      regionID,
			TargetAgencyOwnerUserId: r.cfg.host.userID,
			SenderRegionId:          regionID,
			SenderCountryId:         r.cfg.countryID,
			SenderDisplayProfile: &roomv1.SendGiftDisplayProfile{
				UserId:   r.cfg.sender.userID,
				Username: r.cfg.sender.username,
				Avatar:   "https://local.hyapp.invalid/avatar.png",
			},
			TargetDisplayProfiles: []*roomv1.SendGiftDisplayProfile{{
				UserId:   r.cfg.host.userID,
				Username: r.cfg.host.username,
				Avatar:   "https://local.hyapp.invalid/avatar.png",
			}},
		}); err != nil {
			return fmt.Errorf("send gift %s: %w", gift.id, err)
		}
	}
	fmt.Println("completed room join, mic, normal/lucky/super-lucky gift flows")
	return nil
}

func (r *runner) runSalaryFlow(ctx context.Context) error {
	cycleKey := time.Now().UTC().Format("2006-01")
	resp, err := r.cronClient.ProcessHostSalaryDailySettlementBatch(ctx, &walletv1.CronBatchRequest{
		RequestId:      r.commandID("salary_cron_request"),
		AppCode:        r.cfg.appCode,
		RunId:          r.commandID("salary_cron"),
		WorkerId:       "databi-real-flow",
		BatchSize:      10,
		LockTtlMs:      30_000,
		TriggerMode:    "automatic",
		CycleKey:       cycleKey,
		SettlementRole: "",
	})
	if err != nil {
		return fmt.Errorf("salary daily settlement cron: %w", err)
	}
	fmt.Printf("salary settlement cron success=%d failure=%d\n", resp.GetSuccessCount(), resp.GetFailureCount())
	if _, err := r.walletClient.TransferSalaryToCoinSeller(ctx, &walletv1.TransferSalaryToCoinSellerRequest{
		CommandId:       r.commandID("salary_to_seller"),
		SourceUserId:    r.cfg.host.userID,
		SellerUserId:    r.cfg.seller.userID,
		SalaryAssetType: "HOST_SALARY_USD",
		SalaryUsdMinor:  100,
		RegionId:        regionID,
		Reason:          "databi real flow salary to seller",
		AppCode:         r.cfg.appCode,
	}); err != nil {
		return fmt.Errorf("transfer salary to seller: %w", err)
	}
	fmt.Println("completed host/agency salary settlement and salary-to-seller transfer")
	return nil
}

func (r *runner) runGameFlow(ctx context.Context) error {
	createResp, err := r.gameApp.CreateDiceMatch(ctx, &gamev1.CreateDiceMatchRequest{
		Meta:       r.gameMeta("dice_create"),
		UserId:     r.cfg.gamePlayer.userID,
		GameId:     gameID,
		RoomId:     r.cfg.roomID,
		RegionId:   regionID,
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
		CountryId:  r.cfg.countryID,
	})
	if err != nil {
		return fmt.Errorf("create dice match: %w", err)
	}
	matchID := createResp.GetMatch().GetMatchId()
	if _, err := r.gameApp.JoinDiceMatch(ctx, &gamev1.JoinDiceMatchRequest{
		Meta:    r.gameMeta("dice_join"),
		UserId:  r.cfg.gameRival.userID,
		MatchId: matchID,
		GameId:  gameID,
	}); err != nil {
		return fmt.Errorf("join dice match: %w", err)
	}
	for attempt := 1; attempt <= 5; attempt++ {
		rollResp, err := r.gameApp.RollDiceMatch(ctx, &gamev1.RollDiceMatchRequest{
			Meta:    r.gameMeta(fmt.Sprintf("dice_roll_%d", attempt)),
			UserId:  r.cfg.gamePlayer.userID,
			MatchId: matchID,
			GameId:  gameID,
		})
		if err != nil {
			return fmt.Errorf("roll dice match attempt %d: %w", attempt, err)
		}
		status := rollResp.GetMatch().GetStatus()
		fmt.Printf("dice roll attempt=%d status=%s result=%s\n", attempt, status, rollResp.GetMatch().GetResult())
		if status == "settled" {
			fmt.Println("completed game dice settlement")
			return nil
		}
		time.Sleep(2300 * time.Millisecond)
	}
	return fmt.Errorf("dice match %s did not settle after retries", matchID)
}

func (r *runner) waitForAsyncSources(ctx context.Context) error {
	checks := []struct {
		name  string
		db    *sql.DB
		query string
		args  []any
	}{
		{
			name:  "mic daily stats",
			db:    r.userDB,
			query: `SELECT COUNT(*) FROM user_outbox WHERE app_code = ? AND event_type = 'UserMicDailyStatsUpdated' AND created_at_ms >= ?`,
			args:  []any{r.cfg.appCode, runStartMS(r.cfg.runID)},
		},
		{
			name:  "game settled outbox",
			db:    nil,
			query: "",
		},
	}
	for _, check := range checks {
		if check.db == nil {
			continue
		}
		if err := waitForCount(ctx, check.db, check.query, check.args...); err != nil {
			return fmt.Errorf("wait %s: %w", check.name, err)
		}
	}
	time.Sleep(1500 * time.Millisecond)
	return nil
}

func (r *runner) userMeta(request string) *userv1.RequestMeta {
	return &userv1.RequestMeta{
		RequestId: r.commandID(request),
		Caller:    "databi-real-flow",
		SentAtMs:  time.Now().UnixMilli(),
		AppCode:   r.cfg.appCode,
		Platform:  "ios",
		Language:  "en",
		Timezone:  "Asia/Shanghai",
	}
}

func (r *runner) roomMeta(action string, actorUserID int64) *roomv1.RequestMeta {
	return &roomv1.RequestMeta{
		RequestId:     r.commandID(action + "_request"),
		CommandId:     r.commandID(action),
		ActorUserId:   actorUserID,
		RoomId:        r.cfg.roomID,
		GatewayNodeId: "databi-real-flow",
		SentAtMs:      time.Now().UnixMilli(),
		AppCode:       r.cfg.appCode,
	}
}

func (r *runner) gameMeta(request string) *gamev1.RequestMeta {
	return &gamev1.RequestMeta{
		RequestId:     r.commandID(request),
		Caller:        "databi-real-flow",
		GatewayNodeId: "databi-real-flow",
		ActorUserId:   r.cfg.admin.userID,
		SentAtMs:      time.Now().UnixMilli(),
		AppCode:       r.cfg.appCode,
	}
}

func (r *runner) commandID(action string) string {
	return shortID("databi_"+action, r.cfg.runID, 120)
}

func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("mysql", strings.TrimSpace(dsn))
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(4)
	db.SetMaxIdleConns(2)
	if err := db.PingContext(ctx); err != nil {
		_ = db.Close()
		return nil, err
	}
	return db, nil
}

func mustDial(ctx context.Context, addr string) *grpc.ClientConn {
	conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
	if err != nil {
		log.Fatalf("dial %s: %v", addr, err)
	}
	return conn
}

func waitForCount(ctx context.Context, db *sql.DB, query string, args ...any) error {
	deadline := time.Now().Add(20 * time.Second)
	for {
		var count int64
		if err := db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
			return err
		}
		if count > 0 {
			return nil
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("timed out waiting for count > 0")
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(500 * time.Millisecond):
		}
	}
}

func runStartMS(runID string) int64 {
	if value, err := time.Parse("20060102150405", runID); err == nil {
		return value.UnixMilli()
	}
	return time.Now().Add(-10 * time.Minute).UnixMilli()
}

func shortID(prefix string, suffix string, limit int) string {
	id := sanitizeID(prefix) + "_" + sanitizeID(suffix)
	if len(id) <= limit {
		return id
	}
	if limit <= 8 {
		return id[:limit]
	}
	tailLen := limit - len(sanitizeID(prefix)) - 1
	if tailLen <= 0 {
		return id[:limit]
	}
	if tailLen > len(sanitizeID(suffix)) {
		tailLen = len(sanitizeID(suffix))
	}
	return sanitizeID(prefix) + "_" + sanitizeID(suffix)[:tailLen]
}

func sanitizeID(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		value = fmt.Sprintf("%d", time.Now().UnixNano())
	}
	var b strings.Builder
	for _, r := range value {
		switch {
		case r >= 'a' && r <= 'z':
			b.WriteRune(r)
		case r >= 'A' && r <= 'Z':
			b.WriteRune(r)
		case r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '_' || r == '-':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
	}
	return b.String()
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

func envDefault(key string, fallback string) string {
	if value := strings.TrimSpace(os.Getenv(key)); value != "" {
		return value
	}
	return fallback
}
