package luckygift

import (
	"context"
	"encoding/json"
	"fmt"
	"math"
	"strings"
	"time"

	"hyapp-admin-server/internal/integration/luckygiftadmin"
	"hyapp-admin-server/internal/integration/userclient"
	"hyapp-admin-server/internal/middleware"
	"hyapp-admin-server/internal/modules/shared"
	"hyapp-admin-server/internal/response"
	luckygiftv1 "hyapp.local/api/proto/luckygift/v1"

	"github.com/gin-gonic/gin"
	"google.golang.org/grpc/codes"
	grpcstatus "google.golang.org/grpc/status"
)

type Handler struct {
	luckyGift      luckygiftadmin.Client
	users          luckyGiftUserClient
	requestTimeout time.Duration
	audit          shared.OperationLogger
}

// luckyGiftUserClient 只暴露画像页面需要的 owner 能力。后台搜索必须走专用精确解析 RPC，
// 用户资料仍批量读取，避免给全局 userclient.Client 增加画像模块专属依赖。
type luckyGiftUserClient interface {
	BatchGetUsers(ctx context.Context, req userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error)
	ResolveAdminUserIdentifier(ctx context.Context, req userclient.ResolveAdminUserIdentifierRequest) (*userclient.UserIdentity, error)
}

func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
	if requestTimeout <= 0 {
		requestTimeout = 3 * time.Second
	}
	return &Handler{luckyGift: luckyGift, requestTimeout: requestTimeout, audit: audit}
}

// BindUserClient 注入 user-service 主数据读取能力。幸运礼物 owner 只保存不可变的内部 user_id，
// 短号、默认号、靓号和用户资料必须在 admin-server 展示边界实时解析，不能复制进幸运礼物画像表。
func (h *Handler) BindUserClient(users luckyGiftUserClient) *Handler {
	h.users = users
	return h
}

type configRequest struct {
	AppCode                            string    `json:"app_code"`
	PoolID                             string    `json:"pool_id"`
	StrategyVersion                    string    `json:"strategy_version"`
	Enabled                            bool      `json:"enabled"`
	TargetRTPPercent                   float64   `json:"target_rtp_percent"`
	PoolRatePercent                    float64   `json:"pool_rate_percent"`
	ProfitRatePercent                  float64   `json:"profit_rate_percent"`
	AnchorRatePercent                  float64   `json:"anchor_rate_percent"`
	SettlementWindowWager              int64     `json:"settlement_window_wager"`
	ControlBandPercent                 float64   `json:"control_band_percent"`
	GiftPriceReference                 int64     `json:"gift_price_reference"`
	NoviceMaxEquivalentDraws           int64     `json:"novice_max_equivalent_draws"`
	NormalMaxEquivalentDraws           int64     `json:"normal_max_equivalent_draws"`
	EffectiveFromMS                    int64     `json:"effective_from_ms"`
	InitialPoolCoins                   int64     `json:"initial_pool_coins"`
	LossStreakGuarantee                int64     `json:"loss_streak_guarantee"`
	LowWatermarkCoins                  int64     `json:"low_watermark_coins"`
	LowWaterNonzeroFactorPercent       float64   `json:"low_water_nonzero_factor_percent"`
	HighWatermarkCoins                 int64     `json:"high_watermark_coins"`
	HighWaterNonzeroFactorPercent      float64   `json:"high_water_nonzero_factor_percent"`
	V2HighWaterBoostThresholdCoins     int64     `json:"v2_high_water_boost_threshold_coins"`
	V2HighWaterBoostFactorPercent      float64   `json:"v2_high_water_boost_factor_percent"`
	V2HighWaterBoostRecoverCoins       int64     `json:"v2_high_water_boost_recover_coins"`
	RechargeBoostWindowMS              int64     `json:"recharge_boost_window_ms"`
	RechargeBoostFactorPercent         float64   `json:"recharge_boost_factor_percent"`
	User24HourRTPThresholdPercent      float64   `json:"user_24h_rtp_threshold_percent"`
	User24HourOrdinaryWinFactorPercent float64   `json:"user_24h_ordinary_win_factor_percent"`
	JackpotMultipliers                 []float64 `json:"jackpot_multipliers"`
	JackpotGlobalRTPMaxPercent         float64   `json:"jackpot_global_rtp_max_percent"`
	JackpotUserRoundRTPMaxPercent      float64   `json:"jackpot_user_round_rtp_max_percent"`
	JackpotUser48hRTPMaxPercent        float64   `json:"jackpot_user_48h_rtp_max_percent"`
	// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
	// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
	JackpotUserDayRTPMaxPercent   float64    `json:"-"`
	JackpotUser72hRTPMaxPercent   float64    `json:"-"`
	JackpotSpendThresholdCoins    int64      `json:"jackpot_spend_threshold_coins"`
	MaxJackpotHitsPerUserDay      int64      `json:"max_jackpot_hits_per_user_day"`
	JackpotRateLimitWindowMinutes int64      `json:"jackpot_rate_limit_window_minutes"`
	MaxJackpotHitsPerWindow       int64      `json:"max_jackpot_hits_per_window"`
	MaxSinglePayout               int64      `json:"max_single_payout"`
	UserHourlyPayoutCap           int64      `json:"user_hourly_payout_cap"`
	UserDailyPayoutCap            int64      `json:"user_daily_payout_cap"`
	DeviceDailyPayoutCap          int64      `json:"device_daily_payout_cap"`
	RoomHourlyPayoutCap           int64      `json:"room_hourly_payout_cap"`
	AnchorDailyPayoutCap          int64      `json:"anchor_daily_payout_cap"`
	Stages                        []stageDTO `json:"stages"`
}

type configRollbackRequest struct {
	TargetRuleVersion int64 `json:"target_rule_version"`
}

// configUpsertRequest 只服务 HTTP 写入口。滚动发布期间旧前端仍可能发送 day/72h 字段，
// 因此输入 DTO 明确接收两代名字；configRequest 继续只暴露 canonical JSON，保证 GET 和写入响应不会双写字段。
type configUpsertRequest struct {
	configRequest
	JackpotUserRoundRTPMaxPercent *float64 `json:"jackpot_user_round_rtp_max_percent"`
	JackpotUser48hRTPMaxPercent   *float64 `json:"jackpot_user_48h_rtp_max_percent"`
	LegacyUserDayRTPMaxPercent    *float64 `json:"jackpot_user_day_rtp_max_percent"`
	LegacyUser72hRTPMaxPercent    *float64 `json:"jackpot_user_72h_rtp_max_percent"`
}

func (req configUpsertRequest) canonicalConfig() configRequest {
	config := req.configRequest
	// canonical 字段优先；指针用于区分“未发送”和显式发送 0，避免新旧字段同时存在时旧值覆盖新值。
	if req.JackpotUserRoundRTPMaxPercent != nil {
		config.JackpotUserRoundRTPMaxPercent = *req.JackpotUserRoundRTPMaxPercent
	} else if req.LegacyUserDayRTPMaxPercent != nil {
		config.JackpotUserRoundRTPMaxPercent = *req.LegacyUserDayRTPMaxPercent
	}
	if req.JackpotUser48hRTPMaxPercent != nil {
		config.JackpotUser48hRTPMaxPercent = *req.JackpotUser48hRTPMaxPercent
	} else if req.LegacyUser72hRTPMaxPercent != nil {
		config.JackpotUser48hRTPMaxPercent = *req.LegacyUser72hRTPMaxPercent
	}
	return config
}

type configDTO struct {
	configRequest
	RuleVersion      int64 `json:"rule_version"`
	CreatedByAdminID int64 `json:"created_by_admin_id"`
	CreatedAtMS      int64 `json:"created_at_ms"`
}

type stageDTO struct {
	Stage               string    `json:"stage"`
	MinRecharge7DCoins  int64     `json:"min_recharge_7d_coins"`
	MinRecharge30DCoins int64     `json:"min_recharge_30d_coins"`
	Tiers               []tierDTO `json:"tiers"`
}

type tierDTO struct {
	TierID             string  `json:"tier_id"`
	Multiplier         float64 `json:"multiplier"`
	ProbabilityPercent float64 `json:"probability_percent"`
	HighWaterOnly      bool    `json:"high_water_only"`
	Enabled            bool    `json:"enabled"`
}

type drawDTO struct {
	DrawID               string `json:"draw_id"`
	CommandID            string `json:"command_id"`
	PoolID               string `json:"pool_id"`
	GiftID               string `json:"gift_id"`
	RuleVersion          int64  `json:"rule_version"`
	ExperiencePool       string `json:"experience_pool"`
	SelectedTierID       string `json:"selected_tier_id"`
	MultiplierPPM        int64  `json:"multiplier_ppm"`
	BaseRewardCoins      int64  `json:"base_reward_coins"`
	EffectiveRewardCoins int64  `json:"effective_reward_coins"`
	RewardStatus         string `json:"reward_status"`
	RTPWindowIndex       int64  `json:"rtp_window_index"`
	GiftRTPWindowIndex   int64  `json:"gift_rtp_window_index"`
	GlobalBaseRTPPPM     int64  `json:"global_base_rtp_ppm"`
	GiftBaseRTPPPM       int64  `json:"gift_base_rtp_ppm"`
	StageFeedback        bool   `json:"stage_feedback"`
	HighMultiplier       bool   `json:"high_multiplier"`
	CreatedAtMS          int64  `json:"created_at_ms"`
	AppCode              string `json:"app_code"`
	UserID               int64  `json:"user_id"`
	ExternalUserID       string `json:"external_user_id"`
}

type drawSummaryDTO struct {
	PoolID           string `json:"pool_id"`
	TotalDraws       int64  `json:"total_draws"`
	UniqueUsers      int64  `json:"unique_users"`
	UniqueRooms      int64  `json:"unique_rooms"`
	TotalSpentCoins  int64  `json:"total_spent_coins"`
	TotalRewardCoins int64  `json:"total_reward_coins"`
	BaseRewardCoins  int64  `json:"base_reward_coins"`
	ActualRTPPPM     int64  `json:"actual_rtp_ppm"`
	PendingDraws     int64  `json:"pending_draws"`
	GrantedDraws     int64  `json:"granted_draws"`
	FailedDraws      int64  `json:"failed_draws"`
}

type poolBalanceDTO struct {
	AppCode           string `json:"app_code"`
	PoolID            string `json:"pool_id"`
	StrategyVersion   string `json:"strategy_version"`
	Balance           int64  `json:"balance"`
	ReserveFloor      int64  `json:"reserve_floor"`
	AvailableBalance  int64  `json:"available_balance"`
	TotalIn           int64  `json:"total_in"`
	TotalOut          int64  `json:"total_out"`
	ManualCreditTotal int64  `json:"manual_credit_total"`
	ManualDebitTotal  int64  `json:"manual_debit_total"`
	Materialized      bool   `json:"materialized"`
	UpdatedAtMS       int64  `json:"updated_at_ms"`
}

type poolAdjustmentRequest struct {
	AdjustmentID string `json:"adjustment_id"`
	AmountCoins  int64  `json:"amount_coins"`
	Reason       string `json:"reason"`
}

type poolAdjustmentDTO struct {
	AdjustmentID    string `json:"adjustment_id"`
	AppCode         string `json:"app_code"`
	PoolID          string `json:"pool_id"`
	StrategyVersion string `json:"strategy_version"`
	Direction       string `json:"direction"`
	AmountCoins     int64  `json:"amount_coins"`
	Reason          string `json:"reason"`
	OperatorAdminID int64  `json:"operator_admin_id"`
	BalanceBefore   int64  `json:"balance_before"`
	BalanceAfter    int64  `json:"balance_after"`
	CreatedAtMS     int64  `json:"created_at_ms"`
}

type poolAdjustmentResponseDTO struct {
	Adjustment       poolAdjustmentDTO `json:"adjustment"`
	Pool             poolBalanceDTO    `json:"pool"`
	IdempotentReplay bool              `json:"idempotent_replay"`
}

func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
	if strings.TrimSpace(c.Query("app_code")) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.GetLuckyGiftConfig(ctx, &luckygiftv1.GetLuckyGiftConfigRequest{
		Meta:   h.meta(c),
		PoolId: strings.TrimSpace(c.Query("pool_id")),
	})
	if err != nil {
		response.ServerError(c, "获取幸运礼物配置失败")
		return
	}
	response.OK(c, configFromProto(resp.GetConfig()))
}

func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
	var input configUpsertRequest
	decoder := json.NewDecoder(c.Request.Body)
	// 兼容字段必须进入显式 DTO，不能退回 map 或宽松二次反序列化，否则拼错的配置字段会被静默忽略。
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&input); err != nil {
		response.BadRequest(c, "幸运礼物配置参数不正确")
		return
	}
	req := input.canonicalConfig()
	if req.PoolID == "" {
		req.PoolID = strings.TrimSpace(c.Query("pool_id"))
	}
	if req.AppCode == "" {
		req.AppCode = strings.TrimSpace(c.Query("app_code"))
	}
	if strings.TrimSpace(req.AppCode) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.UpsertLuckyGiftConfig(ctx, &luckygiftv1.UpsertLuckyGiftConfigRequest{
		Meta:            h.metaForApp(c, req.AppCode),
		Config:          configToProto(req),
		OperatorAdminId: int64(middleware.CurrentUserID(c)),
	})
	if err != nil {
		response.BadRequest(c, err.Error())
		return
	}
	item := configFromProto(resp.GetConfig())
	shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rule_versions", item.PoolID, "success", "")
	response.OK(c, item)
}

func (h *Handler) ListLuckyGiftConfigs(c *gin.Context) {
	if strings.TrimSpace(c.Query("app_code")) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.ListLuckyGiftConfigs(ctx, &luckygiftv1.ListLuckyGiftConfigsRequest{
		Meta: h.meta(c),
	})
	if err != nil {
		response.ServerError(c, "获取幸运礼物奖池列表失败")
		return
	}
	items := make([]configDTO, 0, len(resp.GetConfigs()))
	for _, config := range resp.GetConfigs() {
		items = append(items, configFromProto(config))
	}
	response.OK(c, items)
}

func (h *Handler) ListLuckyGiftConfigVersions(c *gin.Context) {
	appCode := strings.TrimSpace(c.Query("app_code"))
	poolID := strings.TrimSpace(c.Query("pool_id"))
	if appCode == "" || poolID == "" {
		response.BadRequest(c, "app_code and pool_id are required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.ListLuckyGiftConfigVersions(ctx, &luckygiftv1.ListLuckyGiftConfigVersionsRequest{
		Meta:   h.metaForApp(c, appCode),
		PoolId: poolID,
	})
	if err != nil {
		h.writeConfigRollbackError(c, err, "获取幸运礼物历史版本失败")
		return
	}
	items := make([]configDTO, 0, len(resp.GetConfigs()))
	for _, config := range resp.GetConfigs() {
		items = append(items, configFromProto(config))
	}
	response.OK(c, items)
}

func (h *Handler) RollbackLuckyGiftConfig(c *gin.Context) {
	appCode := strings.TrimSpace(c.Query("app_code"))
	poolID := strings.TrimSpace(c.Query("pool_id"))
	if appCode == "" || poolID == "" {
		response.BadRequest(c, "app_code and pool_id are required")
		return
	}
	var input configRollbackRequest
	decoder := json.NewDecoder(c.Request.Body)
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&input); err != nil || input.TargetRuleVersion <= 0 {
		response.BadRequest(c, "回滚目标版本不正确")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.RollbackLuckyGiftConfig(ctx, &luckygiftv1.RollbackLuckyGiftConfigRequest{
		Meta:              h.metaForApp(c, appCode),
		PoolId:            poolID,
		TargetRuleVersion: input.TargetRuleVersion,
		OperatorAdminId:   int64(middleware.CurrentUserID(c)),
	})
	if err != nil {
		h.writeConfigRollbackError(c, err, "回滚幸运礼物版本失败")
		return
	}
	item := configFromProto(resp.GetConfig())
	shared.OperationLogWithResourceID(
		c, h.audit, "rollback-lucky-gift-config", "lucky_gift_rule_versions", fmt.Sprintf("%s/%s/v%d", appCode, poolID, item.RuleVersion), "success",
		fmt.Sprintf("source_rule_version=v%d new_rule_version=v%d", resp.GetSourceRuleVersion(), item.RuleVersion),
	)
	response.OK(c, gin.H{
		"source_rule_version": resp.GetSourceRuleVersion(),
		"config":              item,
	})
}

func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
	if strings.TrimSpace(c.Query("app_code")) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	options := shared.ListOptions(c)
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.ListLuckyGiftDraws(ctx, &luckygiftv1.ListLuckyGiftDrawsRequest{
		Meta:           h.meta(c),
		PoolId:         strings.TrimSpace(c.Query("pool_id")),
		GiftId:         strings.TrimSpace(c.Query("gift_id")),
		UserId:         parseOptionalInt64(c.Query("user_id")),
		RoomId:         strings.TrimSpace(c.Query("room_id")),
		Status:         strings.TrimSpace(options.Status),
		Page:           int32(options.Page),
		PageSize:       int32(options.PageSize),
		ExternalUserId: strings.TrimSpace(c.Query("external_user_id")),
		ExternalOnly:   parseOptionalBool(c.Query("external_only")),
	})
	if err != nil {
		response.ServerError(c, "获取幸运礼物抽奖记录失败")
		return
	}
	items := make([]drawDTO, 0, len(resp.GetDraws()))
	for _, draw := range resp.GetDraws() {
		items = append(items, drawFromProto(draw))
	}
	response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

func (h *Handler) GetLuckyGiftDrawSummary(c *gin.Context) {
	if strings.TrimSpace(c.Query("app_code")) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.GetLuckyGiftDrawSummary(ctx, &luckygiftv1.GetLuckyGiftDrawSummaryRequest{
		Meta:           h.meta(c),
		PoolId:         strings.TrimSpace(c.Query("pool_id")),
		GiftId:         strings.TrimSpace(c.Query("gift_id")),
		UserId:         parseOptionalInt64(c.Query("user_id")),
		RoomId:         strings.TrimSpace(c.Query("room_id")),
		Status:         strings.TrimSpace(c.Query("status")),
		ExternalUserId: strings.TrimSpace(c.Query("external_user_id")),
		ExternalOnly:   parseOptionalBool(c.Query("external_only")),
	})
	if err != nil {
		response.ServerError(c, "获取幸运礼物抽奖汇总失败")
		return
	}
	response.OK(c, drawSummaryFromProto(resp.GetSummary()))
}

func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
	if strings.TrimSpace(c.Query("app_code")) == "" {
		response.BadRequest(c, "app_code is required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{
		Meta:            h.meta(c),
		PoolId:          strings.TrimSpace(c.Query("pool_id")),
		StrategyVersion: strings.ToLower(strings.TrimSpace(c.Query("strategy_version"))),
	})
	if err != nil {
		response.ServerError(c, "获取幸运礼物奖池余额失败")
		return
	}
	items := make([]poolBalanceDTO, 0, len(resp.GetPools()))
	for _, pool := range resp.GetPools() {
		items = append(items, poolBalanceFromProto(pool))
	}
	response.OK(c, gin.H{"items": items, "total": len(items)})
}

func (h *Handler) CreditLuckyGiftPoolBalance(c *gin.Context) {
	h.adjustLuckyGiftPoolBalance(c, "in")
}

func (h *Handler) DebitLuckyGiftPoolBalance(c *gin.Context) {
	h.adjustLuckyGiftPoolBalance(c, "out")
}

func (h *Handler) adjustLuckyGiftPoolBalance(c *gin.Context, direction string) {
	appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
	poolID := strings.TrimSpace(c.Query("pool_id"))
	strategyVersion := strings.ToLower(strings.TrimSpace(c.Query("strategy_version")))
	if appCode == "" || poolID == "" || (strategyVersion != "fixed_v2" && strategyVersion != "dynamic_v3") {
		response.BadRequest(c, "app_code, pool_id and strategy_version are required")
		return
	}
	var req poolAdjustmentRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.BadRequest(c, "幸运礼物奖池调整参数不正确")
		return
	}
	req.AdjustmentID = strings.TrimSpace(req.AdjustmentID)
	req.Reason = strings.TrimSpace(req.Reason)
	if req.AdjustmentID == "" || req.AmountCoins <= 0 || req.Reason == "" {
		response.BadRequest(c, "adjustment_id, positive amount_coins and reason are required")
		return
	}
	ctx, cancel := h.luckyGiftContext(c)
	defer cancel()
	resp, err := h.luckyGift.AdjustLuckyGiftPoolBalance(ctx, &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{
		Meta:            h.metaForApp(c, appCode),
		PoolId:          poolID,
		StrategyVersion: strategyVersion,
		AdjustmentId:    req.AdjustmentID,
		Direction:       direction,
		AmountCoins:     req.AmountCoins,
		Reason:          req.Reason,
		OperatorAdminId: int64(middleware.CurrentUserID(c)),
	})
	if err != nil {
		h.writePoolAdjustmentError(c, err)
		return
	}
	item := poolAdjustmentResponseDTO{
		Adjustment:       poolAdjustmentFromProto(resp.GetAdjustment()),
		Pool:             poolBalanceFromProto(resp.GetPool()),
		IdempotentReplay: resp.GetIdempotentReplay(),
	}
	shared.OperationLogWithResourceID(
		c, h.audit, "adjust-lucky-gift-pool-"+direction, "lucky_pool_adjustments", item.Adjustment.AdjustmentID, "success",
		fmt.Sprintf("app_code=%s pool_id=%s strategy_version=%s direction=%s amount_coins=%d reason=%s", appCode, poolID, strategyVersion, direction, req.AmountCoins, req.Reason),
	)
	response.OK(c, item)
}

func (h *Handler) writePoolAdjustmentError(c *gin.Context, err error) {
	status := grpcstatus.Convert(err)
	switch status.Code() {
	case codes.InvalidArgument:
		response.BadRequest(c, status.Message())
	case codes.NotFound:
		response.NotFound(c, status.Message())
	case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
		response.Conflict(c, status.Message())
	default:
		response.ServerError(c, "调整幸运礼物奖池余额失败")
	}
}

func (h *Handler) writeConfigRollbackError(c *gin.Context, err error, fallback string) {
	status := grpcstatus.Convert(err)
	switch status.Code() {
	case codes.InvalidArgument:
		response.BadRequest(c, status.Message())
	case codes.NotFound:
		response.NotFound(c, status.Message())
	case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
		response.Conflict(c, status.Message())
	default:
		response.ServerError(c, fallback)
	}
}

func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta {
	return h.metaForApp(c, "")
}

func (h *Handler) metaForApp(c *gin.Context, explicitAppCode string) *luckygiftv1.RequestMeta {
	appCode := strings.ToLower(strings.TrimSpace(explicitAppCode))
	if appCode == "" {
		appCode = strings.ToLower(strings.TrimSpace(c.Query("app_code")))
	}
	return &luckygiftv1.RequestMeta{
		RequestId: middleware.CurrentRequestID(c),
		Caller:    "admin-server",
		// ops-center 是 admin 的衍生平台，筛选 app_code 通过 query/body 传入；
		// 这里把它放进 Admin RPC meta，让 lucky-gift-service 继续作为 app 维度过滤和配置发布的唯一 owner。
		AppCode:  appCode,
		SentAtMs: time.Now().UTC().UnixMilli(),
	}
}

func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.CancelFunc) {
	return context.WithTimeout(c.Request.Context(), h.requestTimeout)
}

func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
	strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion))
	user24HourOrdinaryWinFactorPercent := req.User24HourOrdinaryWinFactorPercent
	if req.User24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
		// 旧 ops-center 不认识这两个字段，proto3 又会把缺失标量解成 0；0/0 统一解释为关闭并保留 100% 权重。
		user24HourOrdinaryWinFactorPercent = 100
	}
	initialPoolCoins := req.InitialPoolCoins
	if strategyVersion == "dynamic_v3" {
		// V3 启动资金已经迁移为独立人工 credit；旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
		initialPoolCoins = 0
	}
	stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages))
	for _, stage := range req.Stages {
		stageName := strings.TrimSpace(stage.Stage)
		tierIDCounts := make(map[string]int, len(stage.Tiers))
		tiers := make([]*luckygiftv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
		for _, tier := range stage.Tiers {
			multiplierPPM := multiplierToPPM(tier.Multiplier)
			tiers = append(tiers, &luckygiftv1.LuckyGiftRuleTier{
				Stage: stageName,
				// 奖档 ID 是 lucky-gift-service 持久化主键的一部分，不能让运营手工维护；admin-server 按阶段和倍率生成稳定 ID，
				// 同一阶段同倍率出现多行时追加序号，保证进入 lucky-gift-service 前已经满足唯一性。
				TierId:        generatedLuckyGiftTierID(stageName, multiplierPPM, tierIDCounts),
				MultiplierPpm: multiplierPPM,
				BaseWeightPpm: percentToPPM(tier.ProbabilityPercent),
				HighWaterOnly: tier.HighWaterOnly,
				Enabled:       tier.Enabled,
			})
		}
		stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
			Stage:                stageName,
			MinRecharge_7DCoins:  stage.MinRecharge7DCoins,
			MinRecharge_30DCoins: stage.MinRecharge30DCoins,
			Tiers:                tiers,
		})
	}
	roundRTPMaxPercent := req.JackpotUserRoundRTPMaxPercent
	if roundRTPMaxPercent == 0 {
		roundRTPMaxPercent = req.JackpotUserDayRTPMaxPercent
	}
	rolling48hRTPMaxPercent := req.JackpotUser48hRTPMaxPercent
	if rolling48hRTPMaxPercent == 0 {
		rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
	}
	return &luckygiftv1.LuckyGiftRuleConfig{
		AppCode:                        strings.ToLower(strings.TrimSpace(req.AppCode)),
		PoolId:                         strings.TrimSpace(req.PoolID),
		StrategyVersion:                strategyVersion,
		Enabled:                        req.Enabled,
		TargetRtpPpm:                   percentToPPM(req.TargetRTPPercent),
		PoolRatePpm:                    percentToPPM(req.PoolRatePercent),
		ProfitRatePpm:                  percentToPPM(req.ProfitRatePercent),
		AnchorRatePpm:                  percentToPPM(req.AnchorRatePercent),
		SettlementWindowWager:          req.SettlementWindowWager,
		ControlBandPpm:                 percentToPPM(req.ControlBandPercent),
		GiftPriceReference:             req.GiftPriceReference,
		NoviceMaxEquivalentDraws:       req.NoviceMaxEquivalentDraws,
		NormalMaxEquivalentDraws:       req.NormalMaxEquivalentDraws,
		EffectiveFromMs:                req.EffectiveFromMS,
		InitialPoolCoins:               initialPoolCoins,
		LossStreakGuarantee:            req.LossStreakGuarantee,
		LowWatermarkCoins:              req.LowWatermarkCoins,
		LowWaterNonzeroFactorPpm:       percentToPPM(req.LowWaterNonzeroFactorPercent),
		HighWatermarkCoins:             req.HighWatermarkCoins,
		HighWaterNonzeroFactorPpm:      percentToPPM(req.HighWaterNonzeroFactorPercent),
		V2HighWaterBoostThresholdCoins: req.V2HighWaterBoostThresholdCoins,
		V2HighWaterBoostFactorPpm:      percentToPPM(req.V2HighWaterBoostFactorPercent),
		V2HighWaterBoostRecoverCoins:   req.V2HighWaterBoostRecoverCoins,
		RechargeBoostWindowMs:          req.RechargeBoostWindowMS,
		RechargeBoostFactorPpm:         percentToPPM(req.RechargeBoostFactorPercent),
		User_24HRtpThresholdPpm:        percentToPPM(req.User24HourRTPThresholdPercent),
		User_24HOrdinaryWinFactorPpm:   percentToPPM(user24HourOrdinaryWinFactorPercent),
		JackpotMultiplierPpms:          multipliersToPPM(req.JackpotMultipliers),
		JackpotGlobalRtpMaxPpm:         percentToPPM(req.JackpotGlobalRTPMaxPercent),
		JackpotUserRoundRtpMaxPpm:      percentToPPM(roundRTPMaxPercent),
		JackpotUser_48HRtpMaxPpm:       percentToPPM(rolling48hRTPMaxPercent),
		JackpotSpendThresholdCoins:     req.JackpotSpendThresholdCoins,
		MaxJackpotHitsPerUserDay:       req.MaxJackpotHitsPerUserDay,
		JackpotRateLimitWindowMinutes:  req.JackpotRateLimitWindowMinutes,
		MaxJackpotHitsPerWindow:        req.MaxJackpotHitsPerWindow,
		MaxSinglePayout:                req.MaxSinglePayout,
		UserHourlyPayoutCap:            req.UserHourlyPayoutCap,
		UserDailyPayoutCap:             req.UserDailyPayoutCap,
		DeviceDailyPayoutCap:           req.DeviceDailyPayoutCap,
		RoomHourlyPayoutCap:            req.RoomHourlyPayoutCap,
		AnchorDailyPayoutCap:           req.AnchorDailyPayoutCap,
		Stages:                         stages,
	}
}

func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
	if config == nil {
		return configDTO{}
	}
	stages := make([]stageDTO, 0, len(config.GetStages()))
	for _, stage := range config.GetStages() {
		tiers := make([]tierDTO, 0, len(stage.GetTiers()))
		for _, tier := range stage.GetTiers() {
			tiers = append(tiers, tierDTO{
				TierID:             tier.GetTierId(),
				Multiplier:         ppmToMultiplier(tier.GetMultiplierPpm()),
				ProbabilityPercent: ppmToPercent(tier.GetBaseWeightPpm()),
				HighWaterOnly:      tier.GetHighWaterOnly(),
				Enabled:            tier.GetEnabled(),
			})
		}
		stages = append(stages, stageDTO{
			Stage:               stage.GetStage(),
			MinRecharge7DCoins:  stage.GetMinRecharge_7DCoins(),
			MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
			Tiers:               tiers,
		})
	}
	poolID := strings.TrimSpace(config.GetPoolId())
	strategyVersion := strings.ToLower(strings.TrimSpace(config.GetStrategyVersion()))
	initialPoolCoins := config.GetInitialPoolCoins()
	if strategyVersion == "dynamic_v3" {
		// 兼容读取迁移前 V3 快照时也展示 0，避免运营误以为规则字段仍会自动形成账本资金。
		initialPoolCoins = 0
	}
	user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm())
	user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm())
	if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
		// 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态，前端无需猜测 0 因子是否表示全禁中。
		user24HourOrdinaryWinFactorPercent = 100
	}
	return configDTO{
		RuleVersion:      config.GetRuleVersion(),
		CreatedByAdminID: config.GetCreatedByAdminId(),
		CreatedAtMS:      config.GetCreatedAtMs(),
		configRequest: configRequest{
			AppCode:                            config.GetAppCode(),
			PoolID:                             poolID,
			StrategyVersion:                    strategyVersion,
			Enabled:                            config.GetEnabled(),
			TargetRTPPercent:                   ppmToPercent(config.GetTargetRtpPpm()),
			PoolRatePercent:                    ppmToPercent(config.GetPoolRatePpm()),
			ProfitRatePercent:                  ppmToPercent(config.GetProfitRatePpm()),
			AnchorRatePercent:                  ppmToPercent(config.GetAnchorRatePpm()),
			SettlementWindowWager:              config.GetSettlementWindowWager(),
			ControlBandPercent:                 ppmToPercent(config.GetControlBandPpm()),
			GiftPriceReference:                 config.GetGiftPriceReference(),
			NoviceMaxEquivalentDraws:           config.GetNoviceMaxEquivalentDraws(),
			NormalMaxEquivalentDraws:           config.GetNormalMaxEquivalentDraws(),
			EffectiveFromMS:                    config.GetEffectiveFromMs(),
			InitialPoolCoins:                   initialPoolCoins,
			LossStreakGuarantee:                config.GetLossStreakGuarantee(),
			LowWatermarkCoins:                  config.GetLowWatermarkCoins(),
			LowWaterNonzeroFactorPercent:       ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
			HighWatermarkCoins:                 config.GetHighWatermarkCoins(),
			HighWaterNonzeroFactorPercent:      ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
			V2HighWaterBoostThresholdCoins:     config.GetV2HighWaterBoostThresholdCoins(),
			V2HighWaterBoostFactorPercent:      ppmToPercent(config.GetV2HighWaterBoostFactorPpm()),
			V2HighWaterBoostRecoverCoins:       config.GetV2HighWaterBoostRecoverCoins(),
			RechargeBoostWindowMS:              config.GetRechargeBoostWindowMs(),
			RechargeBoostFactorPercent:         ppmToPercent(config.GetRechargeBoostFactorPpm()),
			User24HourRTPThresholdPercent:      user24HourRTPThresholdPercent,
			User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent,
			JackpotMultipliers:                 ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
			JackpotGlobalRTPMaxPercent:         ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
			JackpotUserRoundRTPMaxPercent:      ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
			JackpotUser48hRTPMaxPercent:        ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
			JackpotUserDayRTPMaxPercent:        ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
			JackpotUser72hRTPMaxPercent:        ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
			JackpotSpendThresholdCoins:         config.GetJackpotSpendThresholdCoins(),
			MaxJackpotHitsPerUserDay:           config.GetMaxJackpotHitsPerUserDay(),
			JackpotRateLimitWindowMinutes:      config.GetJackpotRateLimitWindowMinutes(),
			MaxJackpotHitsPerWindow:            config.GetMaxJackpotHitsPerWindow(),
			MaxSinglePayout:                    config.GetMaxSinglePayout(),
			UserHourlyPayoutCap:                config.GetUserHourlyPayoutCap(),
			UserDailyPayoutCap:                 config.GetUserDailyPayoutCap(),
			DeviceDailyPayoutCap:               config.GetDeviceDailyPayoutCap(),
			RoomHourlyPayoutCap:                config.GetRoomHourlyPayoutCap(),
			AnchorDailyPayoutCap:               config.GetAnchorDailyPayoutCap(),
			Stages:                             stages,
		},
	}
}

func generatedLuckyGiftTierID(stage string, multiplierPPM int64, counts map[string]int) string {
	baseID := luckyGiftTierIDBase(stage, multiplierPPM)
	counts[baseID]++
	if counts[baseID] == 1 {
		return baseID
	}
	return fmt.Sprintf("%s_%d", baseID, counts[baseID])
}

func luckyGiftTierIDBase(stage string, multiplierPPM int64) string {
	stage = strings.TrimSpace(stage)
	if multiplierPPM <= 0 {
		return stage + "_none"
	}
	whole := multiplierPPM / 1_000_000
	fraction := multiplierPPM % 1_000_000
	if fraction == 0 {
		return fmt.Sprintf("%s_%dx", stage, whole)
	}
	fractionText := strings.TrimRight(fmt.Sprintf("%06d", fraction), "0")
	return fmt.Sprintf("%s_%d_%sx", stage, whole, fractionText)
}

func percentToPPM(value float64) int64 {
	return int64(math.Round(value * 10_000))
}

func ppmToPercent(value int64) float64 {
	return float64(value) / 10_000
}

func multiplierToPPM(value float64) int64 {
	return int64(math.Round(value * 1_000_000))
}

func multipliersToPPM(values []float64) []int64 {
	out := make([]int64, 0, len(values))
	for _, value := range values {
		out = append(out, multiplierToPPM(value))
	}
	return out
}

func ppmToMultiplier(value int64) float64 {
	return float64(value) / 1_000_000
}

func ppmsToMultipliers(values []int64) []float64 {
	out := make([]float64, 0, len(values))
	for _, value := range values {
		out = append(out, ppmToMultiplier(value))
	}
	return out
}

func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO {
	if draw == nil {
		return drawDTO{}
	}
	return drawDTO{
		DrawID:               draw.GetDrawId(),
		CommandID:            draw.GetCommandId(),
		PoolID:               draw.GetPoolId(),
		GiftID:               draw.GetGiftId(),
		RuleVersion:          draw.GetRuleVersion(),
		ExperiencePool:       draw.GetExperiencePool(),
		SelectedTierID:       draw.GetSelectedTierId(),
		MultiplierPPM:        draw.GetMultiplierPpm(),
		BaseRewardCoins:      draw.GetBaseRewardCoins(),
		EffectiveRewardCoins: draw.GetEffectiveRewardCoins(),
		RewardStatus:         draw.GetRewardStatus(),
		RTPWindowIndex:       draw.GetRtpWindowIndex(),
		GiftRTPWindowIndex:   draw.GetGiftRtpWindowIndex(),
		GlobalBaseRTPPPM:     draw.GetGlobalBaseRtpPpm(),
		GiftBaseRTPPPM:       draw.GetGiftBaseRtpPpm(),
		StageFeedback:        draw.GetStageFeedback(),
		HighMultiplier:       draw.GetHighMultiplier(),
		CreatedAtMS:          draw.GetCreatedAtMs(),
		AppCode:              draw.GetAppCode(),
		UserID:               draw.GetUserId(),
		ExternalUserID:       draw.GetExternalUserId(),
	}
}

func drawSummaryFromProto(summary *luckygiftv1.LuckyGiftDrawSummary) drawSummaryDTO {
	if summary == nil {
		return drawSummaryDTO{}
	}
	return drawSummaryDTO{
		PoolID:           summary.GetPoolId(),
		TotalDraws:       summary.GetTotalDraws(),
		UniqueUsers:      summary.GetUniqueUsers(),
		UniqueRooms:      summary.GetUniqueRooms(),
		TotalSpentCoins:  summary.GetTotalSpentCoins(),
		TotalRewardCoins: summary.GetTotalRewardCoins(),
		BaseRewardCoins:  summary.GetBaseRewardCoins(),
		ActualRTPPPM:     summary.GetActualRtpPpm(),
		PendingDraws:     summary.GetPendingDraws(),
		GrantedDraws:     summary.GetGrantedDraws(),
		FailedDraws:      summary.GetFailedDraws(),
	}
}

func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO {
	if pool == nil {
		return poolBalanceDTO{}
	}
	return poolBalanceDTO{
		AppCode:           pool.GetAppCode(),
		PoolID:            pool.GetPoolId(),
		StrategyVersion:   pool.GetStrategyVersion(),
		Balance:           pool.GetBalance(),
		ReserveFloor:      pool.GetReserveFloor(),
		AvailableBalance:  pool.GetAvailableBalance(),
		TotalIn:           pool.GetTotalIn(),
		TotalOut:          pool.GetTotalOut(),
		ManualCreditTotal: pool.GetManualCreditTotal(),
		ManualDebitTotal:  pool.GetManualDebitTotal(),
		Materialized:      pool.GetMaterialized(),
		UpdatedAtMS:       pool.GetUpdatedAtMs(),
	}
}

func poolAdjustmentFromProto(adjustment *luckygiftv1.LuckyGiftPoolAdjustment) poolAdjustmentDTO {
	if adjustment == nil {
		return poolAdjustmentDTO{}
	}
	return poolAdjustmentDTO{
		AdjustmentID:    adjustment.GetAdjustmentId(),
		AppCode:         adjustment.GetAppCode(),
		PoolID:          adjustment.GetPoolId(),
		StrategyVersion: adjustment.GetStrategyVersion(),
		Direction:       adjustment.GetDirection(),
		AmountCoins:     adjustment.GetAmountCoins(),
		Reason:          adjustment.GetReason(),
		OperatorAdminID: adjustment.GetOperatorAdminId(),
		BalanceBefore:   adjustment.GetBalanceBefore(),
		BalanceAfter:    adjustment.GetBalanceAfter(),
		CreatedAtMS:     adjustment.GetCreatedAtMs(),
	}
}

func parseOptionalInt64(value string) int64 {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0
	}
	var result int64
	for _, char := range value {
		if char < '0' || char > '9' {
			return 0
		}
		result = result*10 + int64(char-'0')
	}
	return result
}

func parseOptionalBool(value string) bool {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "1", "true", "yes", "y":
		return true
	default:
		return false
	}
}
