package main

import (
	"bytes"
	"context"
	"crypto/rand"
	_ "embed"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
)

const (
	demoGiftID       = "local_lucky_meteor_100"
	demoResourceCode = "local_lucky_meteor_resource"
	demoPoolID       = "local_dynamic_v3"
	demoGiftPrice    = int64(100)
)

//go:embed index.html
var indexHTML []byte

type demoConfig struct {
	ListenAddr     string
	GatewayBaseURL string
	AdminBaseURL   string
	AdminUsername  string
	AdminPassword  string
	WalletAddr     string
	LuckyGiftAddr  string
	AppCode        string
	InitialCoins   int64
}

type demoServer struct {
	config demoConfig
	http   *http.Client
	proxy  *httputil.ReverseProxy

	walletConn *grpc.ClientConn
	luckyConn  *grpc.ClientConn
	wallet     walletv1.WalletServiceClient
	luckyAdmin luckygiftv1.AdminLuckyGiftServiceClient

	// setupMu serializes fixture creation because quick accounts, an enabled rule and a room are one local-test unit.
	// A failed setup never publishes a half-filled in-memory fixture to the page.
	setupMu sync.Mutex
	fixture *demoFixture
}

type demoFixture struct {
	AppCode      string      `json:"app_code"`
	RoomID       string      `json:"room_id"`
	RoomName     string      `json:"room_name"`
	RegionID     int64       `json:"region_id"`
	GiftID       string      `json:"gift_id"`
	GiftName     string      `json:"gift_name"`
	GiftPrice    int64       `json:"gift_price"`
	PoolID       string      `json:"pool_id"`
	RuleVersion  int64       `json:"rule_version"`
	TargetRTPPPM int64       `json:"target_rtp_ppm"`
	InitialCoins int64       `json:"initial_coins"`
	SenderToken  string      `json:"sender_token"`
	Sender       fixtureUser `json:"sender"`
	Host         fixtureUser `json:"host"`
	CreatedAtMS  int64       `json:"created_at_ms"`
	hostToken    string
}

type fixtureUser struct {
	UserID        string `json:"user_id"`
	DisplayUserID string `json:"display_user_id"`
	Name          string `json:"name"`
	DeviceID      string `json:"device_id"`
}

type quickCreateResponse struct {
	UID         string `json:"uid"`
	Account     string `json:"account"`
	AccessToken string `json:"access_token"`
	Token       struct {
		UserID        string `json:"user_id"`
		DisplayUserID string `json:"display_user_id"`
		AccessToken   string `json:"access_token"`
	} `json:"token"`
}

type gatewayEnvelope struct {
	Code      string          `json:"code"`
	Message   string          `json:"message"`
	RequestID string          `json:"request_id"`
	Data      json.RawMessage `json:"data"`
}

type adminEnvelope struct {
	Code    any             `json:"code"`
	Message string          `json:"message"`
	Data    json.RawMessage `json:"data"`
}

type giftRatioItem struct {
	GiftTypeCode           string `json:"giftTypeCode"`
	RatioPercent           string `json:"ratioPercent"`
	ReturnCoinRatioPercent string `json:"returnCoinRatioPercent"`
}

type giftRatioList struct {
	RegionID int64           `json:"regionId"`
	Items    []giftRatioItem `json:"items"`
}

func newDemoServer(config demoConfig) (*demoServer, error) {
	if strings.TrimSpace(config.AppCode) == "" || config.InitialCoins <= 0 {
		return nil, errors.New("app and initial-coins must be configured")
	}
	gatewayURL, err := url.Parse(strings.TrimRight(config.GatewayBaseURL, "/"))
	if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" {
		return nil, fmt.Errorf("invalid gateway URL %q", config.GatewayBaseURL)
	}

	walletConn, err := grpc.NewClient(config.WalletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return nil, fmt.Errorf("connect wallet-service: %w", err)
	}
	luckyConn, err := grpc.NewClient(config.LuckyGiftAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		_ = walletConn.Close()
		return nil, fmt.Errorf("connect lucky-gift-service: %w", err)
	}

	proxy := httputil.NewSingleHostReverseProxy(gatewayURL)
	originalDirector := proxy.Director
	proxy.Director = func(request *http.Request) {
		originalDirector(request)
		request.Host = gatewayURL.Host
	}
	proxy.ModifyResponse = func(response *http.Response) error {
		response.Header.Set("Cache-Control", "no-store")
		return nil
	}

	server := &demoServer{
		config:     config,
		http:       &http.Client{Timeout: 15 * time.Second},
		proxy:      proxy,
		walletConn: walletConn,
		luckyConn:  luckyConn,
		wallet:     walletv1.NewWalletServiceClient(walletConn),
		luckyAdmin: luckygiftv1.NewAdminLuckyGiftServiceClient(luckyConn),
	}
	proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, proxyErr error) {
		server.writeError(writer, http.StatusBadGateway, "gateway unavailable: "+proxyErr.Error())
	}
	return server, nil
}

func (s *demoServer) Close() {
	_ = s.walletConn.Close()
	_ = s.luckyConn.Close()
}

func (s *demoServer) Routes() http.Handler {
	mux := http.NewServeMux()
	// 根页面必须使用精确模式；若写成 GET /，Go 1.22+ 会把它视为路径前缀，
	// 与允许多种 HTTP 方法的 /api/ 反向代理产生歧义并在启动时直接 panic。
	mux.HandleFunc("GET /{$}", s.handleIndex)
	mux.HandleFunc("GET /index.html", s.handleIndex)
	mux.HandleFunc("POST /__local/setup", s.handleSetup)
	mux.HandleFunc("POST /__local/heartbeat", s.handleHeartbeat)
	mux.HandleFunc("GET /__local/state", s.handleState)
	mux.Handle("/api/", s.proxy)
	return mux
}

func (s *demoServer) handleIndex(writer http.ResponseWriter, _ *http.Request) {
	writer.Header().Set("Content-Type", "text/html; charset=utf-8")
	writer.Header().Set("Cache-Control", "no-store")
	_, _ = writer.Write(indexHTML)
}

func (s *demoServer) handleSetup(writer http.ResponseWriter, request *http.Request) {
	s.setupMu.Lock()
	defer s.setupMu.Unlock()

	ctx, cancel := context.WithTimeout(request.Context(), 35*time.Second)
	defer cancel()

	fixture := s.fixture
	var err error
	if fixture == nil {
		fixture, err = s.createFixture(ctx)
		if err == nil {
			s.fixture = fixture
		}
	} else {
		// "准备测试数据" is intentionally a reset boundary: the sender starts at exactly one million coins,
		// while the same authenticated users and room remain real owner-service rows for repeatable UI work.
		err = s.setSenderBalance(ctx, fixture, s.config.InitialCoins)
		if err == nil {
			err = s.heartbeatFixture(ctx, fixture)
		}
	}
	if err != nil {
		s.writeError(writer, http.StatusBadGateway, err.Error())
		return
	}
	s.writeOK(writer, fixture)
}

func (s *demoServer) createFixture(ctx context.Context) (*demoFixture, error) {
	stamp := time.Now().UTC().UnixMilli()
	host, hostToken, err := s.quickCreate(ctx, "星河主播", fmt.Sprintf("lucky-demo-host-%d", stamp))
	if err != nil {
		return nil, fmt.Errorf("create host: %w", err)
	}
	sender, senderToken, err := s.quickCreate(ctx, "幸运玩家", fmt.Sprintf("lucky-demo-sender-%d", stamp))
	if err != nil {
		return nil, fmt.Errorf("create sender: %w", err)
	}
	operatorID, err := parsePositiveInt64(host.UserID, "host user_id")
	if err != nil {
		return nil, err
	}

	if err := s.ensureGiftCatalog(ctx, operatorID); err != nil {
		return nil, fmt.Errorf("ensure lucky gift: %w", err)
	}

	roomID, regionID, err := s.createRoom(ctx, hostToken)
	if err != nil {
		return nil, fmt.Errorf("create voice room: %w", err)
	}
	if err := s.ensureGiftReturnPolicy(ctx, regionID); err != nil {
		return nil, fmt.Errorf("ensure regional lucky gift return: %w", err)
	}
	rule, err := s.ensureDynamicRule(ctx, operatorID)
	if err != nil {
		return nil, fmt.Errorf("ensure dynamic_v3 rule: %w", err)
	}
	// CreateRoom already establishes owner presence, but an explicit JoinRoom keeps the fixture aligned with the
	// public client lifecycle and refreshes the same lease before the sender starts interacting with the room.
	if err := s.joinRoom(ctx, hostToken, roomID, "host"); err != nil {
		return nil, fmt.Errorf("join host: %w", err)
	}
	if err := s.joinRoom(ctx, senderToken, roomID, "sender"); err != nil {
		return nil, fmt.Errorf("join sender: %w", err)
	}

	fixture := &demoFixture{
		AppCode:      s.config.AppCode,
		RoomID:       roomID,
		RoomName:     "星河幸运房",
		RegionID:     regionID,
		GiftID:       demoGiftID,
		GiftName:     "幸运流星",
		GiftPrice:    demoGiftPrice,
		PoolID:       demoPoolID,
		RuleVersion:  rule.GetRuleVersion(),
		TargetRTPPPM: rule.GetTargetRtpPpm(),
		InitialCoins: s.config.InitialCoins,
		SenderToken:  senderToken,
		Sender:       sender,
		Host:         host,
		CreatedAtMS:  time.Now().UTC().UnixMilli(),
		hostToken:    hostToken,
	}
	if err := s.setSenderBalance(ctx, fixture, s.config.InitialCoins); err != nil {
		return nil, err
	}
	if err := s.assertGiftPanel(ctx, fixture); err != nil {
		return nil, err
	}
	return fixture, nil
}

func (s *demoServer) quickCreate(ctx context.Context, name, deviceID string) (fixtureUser, string, error) {
	body := map[string]any{
		"password":  "LocalLucky#2026",
		"username":  name,
		"country":   "SG",
		"gender":    "unknown",
		"device_id": deviceID,
		// 账号注册协议当前只接受 android/ios；页面仍通过本地 Web 调用 gateway，
		// 这里的 android 仅是落库的真实测试账号平台快照，不伪造第三方凭证。
		"platform":        "android",
		"language":        "zh-CN",
		"timezone":        "Asia/Shanghai",
		"source":          "quick_account",
		"install_channel": "local_lucky_room_demo",
	}
	var data quickCreateResponse
	if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/auth/account/quick-create", "", body, &data); err != nil {
		return fixtureUser{}, "", err
	}
	token := data.AccessToken
	if token == "" {
		token = data.Token.AccessToken
	}
	userID := strings.TrimSpace(data.Token.UserID)
	displayID := strings.TrimSpace(data.UID)
	if displayID == "" {
		displayID = strings.TrimSpace(data.Token.DisplayUserID)
	}
	if userID == "" || displayID == "" || token == "" {
		return fixtureUser{}, "", fmt.Errorf("quick-create response is incomplete")
	}
	if _, err := strconv.ParseInt(userID, 10, 64); err != nil {
		return fixtureUser{}, "", fmt.Errorf("quick-create user_id is invalid: %w", err)
	}
	return fixtureUser{UserID: userID, DisplayUserID: displayID, Name: name, DeviceID: deviceID}, token, nil
}

func (s *demoServer) createRoom(ctx context.Context, token string) (string, int64, error) {
	body := map[string]any{
		"command_id":       commandID("room-create"),
		"seat_count":       10,
		"mode":             "voice",
		"room_name":        "星河幸运房",
		"room_avatar":      "https://cdn.example.com/room/lucky-gift-demo.png",
		"room_description": "真实用户、真实房间、真实钱包的 dynamic_v3 幸运礼物联调房",
	}
	var data struct {
		Room struct {
			RoomID          string `json:"room_id"`
			VisibleRegionID int64  `json:"visible_region_id"`
		} `json:"room"`
	}
	if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/create", token, body, &data); err != nil {
		return "", 0, err
	}
	if strings.TrimSpace(data.Room.RoomID) == "" {
		return "", 0, errors.New("create room response is missing room_id")
	}
	if data.Room.VisibleRegionID <= 0 {
		return "", 0, errors.New("create room response is missing visible_region_id")
	}
	return data.Room.RoomID, data.Room.VisibleRegionID, nil
}

func (s *demoServer) joinRoom(ctx context.Context, token, roomID, role string) error {
	return s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/join", token, map[string]any{
		"room_id":       roomID,
		"command_id":    commandID("room-join-" + role),
		"role":          "audience",
		"response_mode": "lite",
	}, nil)
}

func (s *demoServer) heartbeatFixture(ctx context.Context, fixture *demoFixture) error {
	for role, token := range map[string]string{"host": fixture.hostToken, "sender": fixture.SenderToken} {
		if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/heartbeat", token, map[string]any{
			"room_id":    fixture.RoomID,
			"command_id": commandID("room-heartbeat-" + role),
		}, nil); err != nil {
			return fmt.Errorf("heartbeat %s: %w", role, err)
		}
	}
	return nil
}

func (s *demoServer) handleHeartbeat(writer http.ResponseWriter, request *http.Request) {
	s.setupMu.Lock()
	defer s.setupMu.Unlock()
	if s.fixture == nil {
		s.writeError(writer, http.StatusConflict, "fixture is not ready")
		return
	}
	ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
	defer cancel()
	if err := s.heartbeatFixture(ctx, s.fixture); err != nil {
		s.writeError(writer, http.StatusBadGateway, err.Error())
		return
	}
	s.writeOK(writer, map[string]any{"room_id": s.fixture.RoomID, "heartbeat_at_ms": time.Now().UTC().UnixMilli()})
}

func (s *demoServer) ensureGiftCatalog(ctx context.Context, operatorID int64) error {
	resources, err := s.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
		RequestId: commandID("resource-list"), AppCode: s.config.AppCode, ResourceType: "gift", Keyword: demoResourceCode, Page: 1, PageSize: 100,
	})
	if err != nil {
		return err
	}
	var resourceID int64
	for _, resource := range resources.GetResources() {
		if resource.GetResourceCode() == demoResourceCode {
			resourceID = resource.GetResourceId()
			break
		}
	}
	if resourceID == 0 {
		created, createErr := s.wallet.CreateResource(ctx, &walletv1.CreateResourceRequest{
			RequestId: commandID("resource-create"), AppCode: s.config.AppCode,
			ResourceCode: demoResourceCode, ResourceType: "gift", Name: "幸运流星", Status: "active",
			// 礼物资源若被运营发放，会累加背包数量；grant_strategy 使用钱包域的明确枚举，
			// 不能把展示层的“永久”概念写进资源 owner 的协议。
			Grantable: true, GrantStrategy: "increase_quantity", UsageScopes: []string{"gift_panel"},
			MetadataJson: `{"local_demo":true,"visual":"meteor"}`, SortOrder: 10, OperatorUserId: operatorID,
			PriceType: "coin", CoinPrice: demoGiftPrice,
		})
		if createErr != nil {
			return createErr
		}
		resourceID = created.GetResource().GetResourceId()
	}

	gifts, err := s.wallet.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
		RequestId: commandID("gift-list"), AppCode: s.config.AppCode, Keyword: demoGiftID, Page: 1, PageSize: 100,
	})
	if err != nil {
		return err
	}
	for _, gift := range gifts.GetGifts() {
		if gift.GetGiftId() != demoGiftID {
			continue
		}
		_, err = s.wallet.UpdateGiftConfig(ctx, &walletv1.UpdateGiftConfigRequest{
			RequestId: commandID("gift-update"), AppCode: s.config.AppCode, GiftId: demoGiftID,
			ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
			PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
			PriceVersion:     "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
			OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
		})
		return err
	}
	_, err = s.wallet.CreateGiftConfig(ctx, &walletv1.CreateGiftConfigRequest{
		RequestId: commandID("gift-create"), AppCode: s.config.AppCode, GiftId: demoGiftID,
		ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
		PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
		PriceVersion:     "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
		OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
	})
	return err
}

func (s *demoServer) ensureGiftReturnPolicy(ctx context.Context, regionID int64) error {
	if regionID <= 0 {
		return errors.New("room region must be positive")
	}
	adminToken, err := s.adminLogin(ctx)
	if err != nil {
		return err
	}
	var current giftRatioList
	path := "/api/v1/admin/operations/gift-diamond-ratios?region_id=" + strconv.FormatInt(regionID, 10)
	if err := s.adminJSON(ctx, http.MethodGet, path, adminToken, nil, &current); err != nil {
		return err
	}
	ratios := make(map[string]any, len(current.Items))
	returns := make(map[string]any, len(current.Items))
	foundSuperLucky := false
	needsUpdate := false
	for _, item := range current.Items {
		giftType := strings.TrimSpace(item.GiftTypeCode)
		if giftType == "" || strings.TrimSpace(item.RatioPercent) == "" || strings.TrimSpace(item.ReturnCoinRatioPercent) == "" {
			return errors.New("gift ratio owner returned an incomplete item")
		}
		ratios[giftType] = item.RatioPercent
		returns[giftType] = item.ReturnCoinRatioPercent
		if giftType == "super_lucky" {
			foundSuperLucky = true
			needsUpdate = item.ReturnCoinRatioPercent != "1.00"
			returns[giftType] = "1.00"
		}
	}
	if !foundSuperLucky || len(ratios) != 3 {
		return errors.New("gift ratio owner did not return all three gift types")
	}
	if !needsUpdate {
		return nil
	}
	// 98/1/1 的 dynamic_v3 只能消费 wallet 固化的真实 1% 收礼返币。这里通过后台 owner API
	// 仅修正当前真实房间区域的 super_lucky 返币，并把 GET 到的其他比例原样回写；不能直改钱包表、
	// 不能篡改已扣费回执，也不能让 lucky service 在金额不一致时继续开奖。
	var updated giftRatioList
	if err := s.adminJSON(ctx, http.MethodPut, "/api/v1/admin/operations/gift-diamond-ratios", adminToken, map[string]any{
		"regionId": regionID, "ratios": ratios, "returnCoinRatios": returns,
	}, &updated); err != nil {
		return err
	}
	for _, item := range updated.Items {
		if item.GiftTypeCode == "super_lucky" && item.ReturnCoinRatioPercent == "1.00" {
			return nil
		}
	}
	return errors.New("super_lucky return ratio was not persisted as 1.00%")
}

func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*luckygiftv1.LuckyGiftRuleConfig, error) {
	meta := s.luckyMeta("rule-get")
	current, err := s.luckyAdmin.GetLuckyGiftConfig(ctx, &luckygiftv1.GetLuckyGiftConfigRequest{Meta: meta, PoolId: demoPoolID})
	if err == nil {
		config := current.GetConfig()
		if config.GetEnabled() && config.GetStrategyVersion() == "dynamic_v3" && config.GetPoolRatePpm() == 980_000 && config.GetProfitRatePpm() == 10_000 && config.GetAnchorRatePpm() == 10_000 {
			return config, nil
		}
	}

	stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, 3)
	for index, name := range []string{"novice", "normal", "advanced"} {
		min7, min30 := int64(0), int64(0)
		if index == 1 {
			min30 = 1
		}
		if index == 2 {
			min7, min30 = 1, 1
		}
		stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
			Stage: name, MinRecharge_7DCoins: min7, MinRecharge_30DCoins: min30,
			Tiers: []*luckygiftv1.LuckyGiftRuleTier{
				{Stage: name, TierId: name + "_none", MultiplierPpm: 0, BaseWeightPpm: 50_000, Enabled: true},
				{Stage: name, TierId: name + "_0_5x", MultiplierPpm: 500_000, BaseWeightPpm: 40_000, Enabled: true},
				{Stage: name, TierId: name + "_1x", MultiplierPpm: 1_000_000, BaseWeightPpm: 860_000, Enabled: true},
				{Stage: name, TierId: name + "_2x", MultiplierPpm: 2_000_000, BaseWeightPpm: 50_000, Enabled: true},
			},
		})
	}
	upserted, err := s.luckyAdmin.UpsertLuckyGiftConfig(ctx, &luckygiftv1.UpsertLuckyGiftConfigRequest{
		Meta: s.luckyMeta("rule-upsert"), OperatorAdminId: operatorID,
		Config: &luckygiftv1.LuckyGiftRuleConfig{
			AppCode: s.config.AppCode, PoolId: demoPoolID, Enabled: true, StrategyVersion: "dynamic_v3",
			TargetRtpPpm: 980_000, PoolRatePpm: 980_000, ProfitRatePpm: 10_000, AnchorRatePpm: 10_000,
			SettlementWindowWager: 1_000_000, ControlBandPpm: 30_000, GiftPriceReference: demoGiftPrice,
			NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000,
			EffectiveFromMs: time.Now().UTC().Add(-time.Second).UnixMilli(), CreatedByAdminId: operatorID,
			InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5,
			LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPpm: 700_000,
			HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
			RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
			JackpotMultiplierPpms:  []int64{200_000_000, 500_000_000, 1_000_000_000},
			JackpotGlobalRtpMaxPpm: 980_000, JackpotUserRoundRtpMaxPpm: 960_000, JackpotUser_48HRtpMaxPpm: 960_000,
			JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
			MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
			DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,
			Stages: stages,
		},
	})
	if err != nil {
		return nil, err
	}
	return upserted.GetConfig(), nil
}

func (s *demoServer) setSenderBalance(ctx context.Context, fixture *demoFixture, wanted int64) error {
	current, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
	if err != nil {
		return fmt.Errorf("read sender balance: %w", err)
	}
	delta := wanted - current
	if delta == 0 {
		return nil
	}
	adminToken, err := s.adminLogin(ctx)
	if err != nil {
		return fmt.Errorf("admin login: %w", err)
	}
	body := map[string]any{
		"commandId":    commandID("coin-adjust"),
		"targetUserId": fixture.Sender.UserID,
		"amount":       delta,
		"reason":       "local_lucky_gift_demo",
		"remark":       fmt.Sprintf("set sender initial COIN balance to %d", wanted),
	}
	if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/admin/operations/coin-adjustments", adminToken, body, nil); err != nil {
		return fmt.Errorf("adjust sender balance by %d: %w", delta, err)
	}
	after, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
	if err != nil {
		return err
	}
	if after != wanted {
		return fmt.Errorf("sender balance mismatch after adjustment: got %d want %d", after, wanted)
	}
	return nil
}

func (s *demoServer) adminLogin(ctx context.Context) (string, error) {
	var data struct {
		AccessToken string `json:"accessToken"`
	}
	if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
		"username": s.config.AdminUsername,
		"password": s.config.AdminPassword,
	}, &data); err != nil {
		return "", err
	}
	if data.AccessToken == "" {
		return "", errors.New("admin login response is missing accessToken")
	}
	return data.AccessToken, nil
}

func (s *demoServer) gatewayCoinBalance(ctx context.Context, token string) (int64, error) {
	var data struct {
		Balances []struct {
			AssetType       string `json:"asset_type"`
			AvailableAmount int64  `json:"available_amount"`
		} `json:"balances"`
	}
	if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/wallet/me/balances?asset_type=COIN", token, nil, &data); err != nil {
		return 0, err
	}
	for _, balance := range data.Balances {
		if strings.EqualFold(balance.AssetType, "COIN") {
			return balance.AvailableAmount, nil
		}
	}
	return 0, nil
}

func (s *demoServer) assertGiftPanel(ctx context.Context, fixture *demoFixture) error {
	var data struct {
		Gifts []struct {
			GiftID string `json:"gift_id"`
		} `json:"gifts"`
	}
	if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(fixture.RoomID)+"/gift-panel", fixture.SenderToken, nil, &data); err != nil {
		return fmt.Errorf("load gift panel: %w", err)
	}
	for _, gift := range data.Gifts {
		if gift.GiftID == fixture.GiftID {
			return nil
		}
	}
	return fmt.Errorf("real gift panel does not contain %s", fixture.GiftID)
}

func (s *demoServer) handleState(writer http.ResponseWriter, request *http.Request) {
	s.setupMu.Lock()
	fixture := s.fixture
	s.setupMu.Unlock()
	if fixture == nil {
		s.writeError(writer, http.StatusConflict, "fixture is not ready")
		return
	}
	ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
	defer cancel()

	senderBalance, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
	if err != nil {
		s.writeError(writer, http.StatusBadGateway, err.Error())
		return
	}
	userID, err := parsePositiveInt64(fixture.Sender.UserID, "sender user_id")
	if err != nil {
		s.writeError(writer, http.StatusInternalServerError, err.Error())
		return
	}
	draws, err := s.luckyAdmin.ListLuckyGiftDraws(ctx, &luckygiftv1.ListLuckyGiftDrawsRequest{
		Meta: s.luckyMeta("draw-list"), UserId: userID, RoomId: fixture.RoomID, PoolId: fixture.PoolID, Page: 1, PageSize: 20,
	})
	if err != nil {
		s.writeError(writer, http.StatusBadGateway, "list lucky draws: "+err.Error())
		return
	}
	recent := make([]map[string]any, 0, len(draws.GetDraws()))
	for _, draw := range draws.GetDraws() {
		recent = append(recent, map[string]any{
			"draw_id": draw.GetDrawId(), "command_id": draw.GetCommandId(), "tier_id": draw.GetSelectedTierId(),
			"multiplier_ppm": draw.GetMultiplierPpm(), "reward_coins": draw.GetEffectiveRewardCoins(),
			"reward_status": draw.GetRewardStatus(), "created_at_ms": draw.GetCreatedAtMs(),
		})
	}
	s.writeOK(writer, map[string]any{
		"sender_balance": senderBalance,
		"draw_total":     draws.GetTotal(),
		"recent_draws":   recent,
		"server_time_ms": time.Now().UTC().UnixMilli(),
	})
}

func (s *demoServer) gatewayJSON(ctx context.Context, method, path, token string, body any, out any) error {
	headers := map[string]string{"X-App-Code": s.config.AppCode, "X-App-Platform": "web", "X-App-Language": "zh-CN"}
	if token != "" {
		headers["Authorization"] = "Bearer " + token
	}
	raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.GatewayBaseURL, "/")+path, method, headers, body)
	if err != nil {
		return err
	}
	var envelope gatewayEnvelope
	if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
		return fmt.Errorf("gateway returned invalid JSON (status %d): %w", status, decodeErr)
	}
	if status < 200 || status >= 300 || envelope.Code != "OK" {
		return fmt.Errorf("gateway request failed status=%d code=%s message=%s request_id=%s", status, envelope.Code, envelope.Message, envelope.RequestID)
	}
	if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
		if err := json.Unmarshal(envelope.Data, out); err != nil {
			return fmt.Errorf("decode gateway data: %w", err)
		}
	}
	return nil
}

func (s *demoServer) adminJSON(ctx context.Context, method, path, token string, body any, out any) error {
	headers := map[string]string{"X-App-Code": s.config.AppCode}
	if token != "" {
		headers["Authorization"] = "Bearer " + token
	}
	raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.AdminBaseURL, "/")+path, method, headers, body)
	if err != nil {
		return err
	}
	var envelope adminEnvelope
	if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
		return fmt.Errorf("admin returned invalid JSON (status %d): %w", status, decodeErr)
	}
	if status < 200 || status >= 300 || !adminCodeOK(envelope.Code) {
		return fmt.Errorf("admin request failed status=%d code=%v message=%s", status, envelope.Code, envelope.Message)
	}
	if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
		if err := json.Unmarshal(envelope.Data, out); err != nil {
			return fmt.Errorf("decode admin data: %w", err)
		}
	}
	return nil
}

func (s *demoServer) doJSON(ctx context.Context, endpoint, method string, headers map[string]string, body any) ([]byte, int, error) {
	var reader io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return nil, 0, err
		}
		reader = bytes.NewReader(raw)
	}
	request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
	if err != nil {
		return nil, 0, err
	}
	request.Header.Set("Accept", "application/json")
	if body != nil {
		request.Header.Set("Content-Type", "application/json")
	}
	for key, value := range headers {
		request.Header.Set(key, value)
	}
	response, err := s.http.Do(request)
	if err != nil {
		return nil, 0, err
	}
	defer response.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
	return raw, response.StatusCode, err
}

func (s *demoServer) luckyMeta(prefix string) *luckygiftv1.RequestMeta {
	return &luckygiftv1.RequestMeta{
		RequestId: commandID(prefix), Caller: "lucky-gift-room-demo", SentAtMs: time.Now().UTC().UnixMilli(), AppCode: s.config.AppCode,
	}
}

func (s *demoServer) writeOK(writer http.ResponseWriter, data any) {
	writeJSON(writer, http.StatusOK, map[string]any{"code": "OK", "message": "ok", "data": data})
}

func (s *demoServer) writeError(writer http.ResponseWriter, status int, message string) {
	writeJSON(writer, status, map[string]any{"code": "LOCAL_DEMO_ERROR", "message": message, "data": nil})
}

func writeJSON(writer http.ResponseWriter, status int, value any) {
	writer.Header().Set("Content-Type", "application/json; charset=utf-8")
	writer.Header().Set("Cache-Control", "no-store")
	writer.WriteHeader(status)
	_ = json.NewEncoder(writer).Encode(value)
}

func adminCodeOK(code any) bool {
	switch value := code.(type) {
	case float64:
		return value == 0 || value == 200
	case string:
		return value == "0" || value == "200" || strings.EqualFold(value, "OK")
	case nil:
		return true
	default:
		return false
	}
}

func parsePositiveInt64(value, label string) (int64, error) {
	parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
	if err != nil || parsed <= 0 {
		return 0, fmt.Errorf("%s must be a positive int64", label)
	}
	return parsed, nil
}

func commandID(prefix string) string {
	randomBytes := make([]byte, 6)
	if _, err := rand.Read(randomBytes); err != nil {
		return fmt.Sprintf("demo-%s-%d", prefix, time.Now().UTC().UnixNano())
	}
	return fmt.Sprintf("demo-%s-%d-%s", prefix, time.Now().UTC().UnixMilli(), hex.EncodeToString(randomBytes))
}
