package walletapi

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"log/slog"
	"math/big"
	"net/http"
	"strconv"
	"strings"
	"time"

	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/services/gateway-service/internal/auth"
	"hyapp/services/gateway-service/internal/financewithdrawal"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
)

const (
	pointWalletAssetType                    = "POINT"
	pointWalletTargetAssetType              = "COIN"
	pointWithdrawalRollbackAttempts         = 3
	pointWithdrawalRollbackAttemptTimeout   = 1500 * time.Millisecond
	pointWithdrawalRollbackBackoffBaseDelay = 100 * time.Millisecond
)

type pointWithdrawalGatewayClient interface {
	FreezePointWithdrawal(ctx context.Context, req *walletv1.FreezePointWithdrawalRequest) (*walletv1.FreezePointWithdrawalResponse, error)
	ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
	ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error)
	TransferPointToCoinSeller(ctx context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error)
	ExchangePointToCoin(ctx context.Context, req *walletv1.ExchangePointToCoinRequest) (*walletv1.ExchangePointToCoinResponse, error)
	GetPointWithdrawalConfig(ctx context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error)
}

type pointWalletOverviewData struct {
	AssetType       string                          `json:"asset_type"`
	Balance         pointWalletBalanceData          `json:"balance"`
	WithdrawAddress salaryWalletWithdrawAddressData `json:"withdraw_address"`
	PointsPerUSD    int64                           `json:"points_per_usd"`
	CoinsPerUSD     int64                           `json:"coins_per_usd"`
	FeeBPS          int32                           `json:"fee_bps"`
	MinimumPoints   int64                           `json:"minimum_points"`
	CoinSellers     []pointWalletCoinSellerData     `json:"coin_sellers"`
	PolicyInstance  string                          `json:"policy_instance_code"`
}

type pointWalletCoinSellerData struct {
	UserID              string   `json:"user_id"`
	DisplayUserID       string   `json:"display_user_id"`
	Nickname            string   `json:"nickname"`
	Avatar              string   `json:"avatar"`
	SortOrder           int32    `json:"sort_order"`
	PointAmount         int64    `json:"point_amount"`
	SellerCoinAmount    int64    `json:"seller_coin_amount"`
	ServiceRegionIDs    []int64  `json:"service_region_ids"`
	ServiceCountryCodes []string `json:"service_country_codes"`
}

type pointWalletBalanceData struct {
	AssetType       string `json:"asset_type"`
	AvailableAmount int64  `json:"available_amount"`
	FrozenAmount    int64  `json:"frozen_amount"`
	Version         int64  `json:"version"`
	DisplayUSD      string `json:"display_usd"`
}

type pointWalletExchangeConfigData struct {
	SourceAssetType    string `json:"source_asset_type"`
	TargetAssetType    string `json:"target_asset_type"`
	PointsPerUSD       int64  `json:"points_per_usd"`
	CoinsPerUSD        int64  `json:"coins_per_usd"`
	RatioPointAmount   int64  `json:"ratio_point_amount"`
	RatioCoinAmount    int64  `json:"ratio_coin_amount"`
	RoundingMode       string `json:"rounding_mode"`
	PolicyInstanceCode string `json:"policy_instance_code"`
}

type pointWalletWithdrawBody struct {
	CommandID             string `json:"command_id"`
	CommandIDCamel        string `json:"commandId"`
	GrossPointAmount      int64  `json:"gross_point_amount"`
	GrossPointAmountCamel int64  `json:"grossPointAmount"`
	PointAmount           int64  `json:"point_amount"`
	PointAmountCamel      int64  `json:"pointAmount"`
	AmountPoints          int64  `json:"amount_points"`
	AmountPointsCamel     int64  `json:"amountPoints"`

	WithdrawUSDTTRC20Address  string `json:"withdraw_usdt_trc20_address"`
	WithdrawUSDTTRC20AddressC string `json:"withdrawUsdtTrc20Address"`
	USDTTRC20Address          string `json:"usdt_trc20_address"`
	USDTTRC20AddressC         string `json:"usdtTrc20Address"`
	PaymentAddress            string `json:"payment_address"`
	PaymentAddressC           string `json:"paymentAddress"`
	Address                   string `json:"address"`
}

type pointWalletCoinSellerTransferBody struct {
	CommandID         string            `json:"command_id"`
	CommandIDCamel    string            `json:"commandId"`
	SellerUserID      flexibleJSONInt64 `json:"seller_user_id"`
	SellerUserIDCamel flexibleJSONInt64 `json:"sellerUserId"`
	PointAmount       int64             `json:"point_amount"`
	PointAmountCamel  int64             `json:"pointAmount"`
}

type pointWalletExchangeBody struct {
	CommandID        string `json:"command_id"`
	CommandIDCamel   string `json:"commandId"`
	PointAmount      int64  `json:"point_amount"`
	PointAmountCamel int64  `json:"pointAmount"`
}

func (b pointWalletExchangeBody) commandID() string {
	if value := strings.TrimSpace(b.CommandID); value != "" {
		return value
	}
	return strings.TrimSpace(b.CommandIDCamel)
}

func (b pointWalletExchangeBody) amount() int64 {
	if b.PointAmount > 0 {
		return b.PointAmount
	}
	return b.PointAmountCamel
}

func (b pointWalletCoinSellerTransferBody) commandID() string {
	if value := strings.TrimSpace(b.CommandID); value != "" {
		return value
	}
	return strings.TrimSpace(b.CommandIDCamel)
}

func (b pointWalletCoinSellerTransferBody) sellerUserID() int64 {
	if b.SellerUserID.Int64() > 0 {
		return b.SellerUserID.Int64()
	}
	return b.SellerUserIDCamel.Int64()
}

func (b pointWalletCoinSellerTransferBody) amount() int64 {
	if b.PointAmount > 0 {
		return b.PointAmount
	}
	return b.PointAmountCamel
}

func (b pointWalletWithdrawBody) commandID() string {
	if value := strings.TrimSpace(b.CommandID); value != "" {
		return value
	}
	return strings.TrimSpace(b.CommandIDCamel)
}

func (b pointWalletWithdrawBody) grossPointAmount() int64 {
	for _, value := range []int64{
		b.GrossPointAmount,
		b.GrossPointAmountCamel,
		b.PointAmount,
		b.PointAmountCamel,
		b.AmountPoints,
		b.AmountPointsCamel,
	} {
		if value > 0 {
			return value
		}
	}
	return 0
}

func (b pointWalletWithdrawBody) usdtTRC20Address() string {
	return salaryWalletWithdrawAddressBody{
		WithdrawUSDTTRC20Address:  b.WithdrawUSDTTRC20Address,
		WithdrawUSDTTRC20AddressC: b.WithdrawUSDTTRC20AddressC,
		USDTTRC20Address:          b.USDTTRC20Address,
		USDTTRC20AddressC:         b.USDTTRC20AddressC,
		PaymentAddress:            b.PaymentAddress,
		PaymentAddressC:           b.PaymentAddressC,
		Address:                   b.Address,
	}.usdtTRC20Address()
}

func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *http.Request) {
	if h.walletClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta: httpkit.UserMeta(request, ""), UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	profile := profileResp.GetUser()
	if profile == nil {
		httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
		return
	}
	config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
		RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if !config.GetFound() {
		// 没有已发布 wallet 政策就关闭资金入口；不能回退到编译期常量。
		httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point wallet policy is not configured")
		return
	}
	balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
		RequestId:  httpkit.RequestIDFromContext(request.Context()),
		UserId:     userID,
		AssetTypes: []string{pointWalletAssetType},
		AppCode:    appcode.FromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
		RegionId: profile.GetRegionId(), CountryCode: pointWalletCountryCode(profile),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	sellers, err := h.pointWalletCoinSellerData(request, sellerResp.GetSellers())
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, pointWalletOverviewData{
		AssetType:       pointWalletAssetType,
		Balance:         pointBalanceFromProto(firstBalance(balanceResp.GetBalances()), config.GetPointsPerUsd()),
		WithdrawAddress: salaryWithdrawAddressFromProto(profileResp.GetUser()),
		PointsPerUSD:    config.GetPointsPerUsd(),
		CoinsPerUSD:     config.GetCoinsPerUsd(),
		FeeBPS:          config.GetFeeBps(),
		MinimumPoints:   config.GetMinimumPoints(),
		CoinSellers:     sellers,
		PolicyInstance:  config.GetPolicyInstanceCode(),
	})
}

// getPointWalletExchangeConfig 只返回 Flutter 预览兑换所需的最小运行合同；
// region 从当前登录用户资料解析，客户端不能通过自报地区选择其他政策。
func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, request *http.Request) {
	pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
	if !ok || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta: httpkit.UserMeta(request, ""), UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if profileResp.GetUser() == nil {
		httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
		return
	}
	config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
		RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if !config.GetFound() || config.GetPointsPerUsd() <= 0 || config.GetCoinsPerUsd() <= 0 {
		// 没有完整已发布政策时不向 App 暴露伪造默认比例，兑换写入口也会在 wallet-service 拒绝。
		httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point wallet policy is not configured")
		return
	}
	httpkit.WriteOK(writer, request, pointWalletExchangeConfigData{
		SourceAssetType: pointWalletAssetType, TargetAssetType: pointWalletTargetAssetType,
		PointsPerUSD: config.GetPointsPerUsd(), CoinsPerUSD: config.GetCoinsPerUsd(),
		RatioPointAmount: config.GetPointsPerUsd(), RatioCoinAmount: config.GetCoinsPerUsd(),
		RoundingMode: "floor", PolicyInstanceCode: config.GetPolicyInstanceCode(),
	})
}

func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.Request) {
	if h.walletClient == nil || h.userProfileClient == nil || h.withdrawalWriter == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	var body pointWalletWithdrawBody
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	submittedCommandID := body.commandID()
	if submittedCommandID == "" {
		// POINT 提现涉及冻结真实积分；客户端必须提供稳定幂等键，网络重试不能靠 request_id/时间重新生成命令。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is required")
		return
	}
	grossPoints := body.grossPointAmount()
	userID := auth.UserIDFromContext(request.Context())
	profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{Meta: httpkit.UserMeta(request, ""), UserId: userID})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	profile := profileResp.GetUser()
	if profile == nil {
		httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
		return
	}
	config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
		RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if !config.GetFound() || grossPoints < config.GetMinimumPoints() {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	feePoints, ok := calculatePointFee(grossPoints, config.GetFeeBps())
	if !ok {
		// 政策参数来自 wallet owner；非法 bps 属于上游配置损坏，不能让 int64 溢出后继续冻结错误金额。
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	netPoints := grossPoints - feePoints
	address, ok := h.resolveSalaryWalletWithdrawAddress(writer, request, userID, body.usdtTRC20Address())
	if !ok {
		return
	}
	nowMS := time.Now().UTC().UnixMilli()
	freezeCommandID := strings.TrimSpace(submittedCommandID)
	withdrawalRef := pointWithdrawalReference(freezeCommandID)
	freezeResp, err := pointWallet.FreezePointWithdrawal(request.Context(), &walletv1.FreezePointWithdrawalRequest{
		CommandId:        freezeCommandID,
		UserId:           userID,
		AssetType:        pointWalletAssetType,
		GrossPointAmount: grossPoints,
		FeePointAmount:   feePoints,
		NetPointAmount:   netPoints,
		PointsPerUsd:     config.GetPointsPerUsd(),
		FeeBps:           config.GetFeeBps(),
		Reason:           "point withdrawal submitted",
		AppCode:          appcode.FromContext(request.Context()),
		WithdrawalRef:    withdrawalRef,
		RegionId:         profile.GetRegionId(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	// 同一客户端 command_id 可能跨月、跨政策重试；wallet 命中首次冻结交易时返回的是首次账变快照。
	// 从这里开始只使用回执金额和比例，避免用本次配置创建不匹配的申请，或在失败补偿时释放错误金额。
	frozenGrossPoints := freezeResp.GetGrossPointAmount()
	frozenFeePoints := freezeResp.GetFeePointAmount()
	frozenNetPoints := freezeResp.GetNetPointAmount()
	frozenPointsPerUSD := freezeResp.GetPointsPerUsd()
	frozenFeeBPS := freezeResp.GetFeeBps()
	frozenPolicyInstanceCode := strings.TrimSpace(freezeResp.GetPolicyInstanceCode())
	if frozenPolicyInstanceCode == "" {
		// 兼容字段上线前已经冻结的幂等回执；新冻结必须始终由 wallet 返回实际政策实例编码。
		frozenPolicyInstanceCode = config.GetPolicyInstanceCode()
	}
	application, err := h.withdrawalWriter.CreateApplication(request.Context(), financewithdrawal.CreateApplicationCommand{
		AppCode:             appcode.FromContext(request.Context()),
		UserID:              userID,
		SalaryAssetType:     pointWalletAssetType,
		WithdrawAmount:      formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
		WithdrawAmountMinor: frozenGrossPoints,
		PointFeeAmount:      frozenFeePoints,
		PointNetAmount:      frozenNetPoints,
		PointsPerUSD:        frozenPointsPerUSD,
		PointFeeBPS:         frozenFeeBPS,
		PointPolicyInstance: frozenPolicyInstanceCode,
		WithdrawMethod:      financewithdrawal.MethodUSDTTRC20,
		WithdrawAddress:     address,
		FreezeCommandID:     freezeCommandID,
		FreezeTransactionID: freezeResp.GetTransactionId(),
		CreatedAtMS:         nowMS,
	})
	if err != nil {
		if financewithdrawal.IsApplicationOutcomeUnknown(err) {
			// 申请可能已经落库，只是 INSERT 响应和事实重查同时失败；保留冻结后，同一客户端
			// command_id 的重试会先命中钱包幂等回执，再由 writer 重查申请并完成状态收敛。
			slog.Error("point_withdrawal_application_outcome_unknown", "user_id", userID, "app_code", appcode.FromContext(request.Context()), "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "error", err)
			httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
			return
		}
		rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, freezeResp.GetTransactionId(), userID, frozenGrossPoints, frozenFeePoints, frozenNetPoints, frozenPointsPerUSD, frozenFeeBPS)
		httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
		return
	}
	if h.withdrawalNotifier != nil {
		// 通知失败不能回滚冻结和申请事实；用户重复提交会造成二次冻结，必须只记录告警。
		if err := h.withdrawalNotifier.NotifyApplicationCreated(request.Context(), application); err != nil {
			slog.Warn("point_withdrawal_notify_failed", "application_id", application.ID, "user_id", userID, "app_code", application.AppCode, "error", err)
		}
	}
	httpkit.WriteOK(writer, request, map[string]any{
		"application_id":        strconv.FormatInt(application.ID, 10),
		"status":                application.Status,
		"operations_status":     application.OperationsStatus,
		"asset_type":            pointWalletAssetType,
		"gross_point_amount":    frozenGrossPoints,
		"fee_point_amount":      frozenFeePoints,
		"net_point_amount":      frozenNetPoints,
		"points_per_usd":        frozenPointsPerUSD,
		"fee_bps":               frozenFeeBPS,
		"policy_instance_code":  frozenPolicyInstanceCode,
		"gross_usd":             formatPointUSD(frozenGrossPoints, frozenPointsPerUSD),
		"fee_usd":               formatPointUSD(frozenFeePoints, frozenPointsPerUSD),
		"net_usd":               formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
		"balance_after":         pointBalanceFromProto(freezeResp.GetBalance(), frozenPointsPerUSD),
		"withdraw_method":       application.WithdrawMethod,
		"withdraw_address":      application.WithdrawAddress,
		"freeze_transaction_id": freezeResp.GetTransactionId(),
		"created_at_ms":         application.CreatedAtMS,
	})
}

func pointBalanceFromProto(balance *walletv1.AssetBalance, pointsPerUSD int64) pointWalletBalanceData {
	if balance == nil {
		return pointWalletBalanceData{AssetType: pointWalletAssetType, DisplayUSD: "0.00"}
	}
	return pointWalletBalanceData{
		AssetType:       balance.GetAssetType(),
		AvailableAmount: balance.GetAvailableAmount(),
		FrozenAmount:    balance.GetFrozenAmount(),
		Version:         balance.GetVersion(),
		DisplayUSD:      formatPointUSD(balance.GetAvailableAmount(), pointsPerUSD),
	}
}

func formatPointUSD(points int64, pointsPerUSD int64) string {
	if points <= 0 || pointsPerUSD <= 0 {
		return "0.00"
	}
	// POINT 余额允许达到 int64 上限；先用任意精度整数完成 *100 和整除，避免展示层溢出成负数。
	usdMinor := new(big.Int).Mul(big.NewInt(points), big.NewInt(100))
	usdMinor.Quo(usdMinor, big.NewInt(pointsPerUSD))
	dollars, cents := new(big.Int), new(big.Int)
	dollars.QuoRem(usdMinor, big.NewInt(100), cents)
	return dollars.String() + "." + leftPadTwoDigits(cents.Int64())
}

func calculatePointFee(points int64, feeBPS int32) (int64, bool) {
	if points <= 0 || feeBPS < 0 || feeBPS > 10_000 {
		return 0, false
	}
	// 把 points 拆成商和余数后再乘 bps；两项都不会超过原 points，且结果与 floor(points*bps/10000) 完全一致。
	fee := points/10_000*int64(feeBPS) + points%10_000*int64(feeBPS)/10_000
	return fee, true
}

func leftPadTwoDigits(value int64) string {
	if value < 10 {
		return "0" + strconv.FormatInt(value, 10)
	}
	return strconv.FormatInt(value, 10)
}

func pointWithdrawalReference(freezeCommandID string) string {
	// request_id 只用于链路追踪，每次 HTTP 重试都会变化。对客户端幂等键做固定摘要，既让
	// freeze/rollback 命中同一 reservation，又把引用稳定限制在 VARCHAR(128) 边界内。
	digest := sha256.Sum256([]byte(strings.TrimSpace(freezeCommandID)))
	return "point-withdrawal:" + hex.EncodeToString(digest[:])
}

func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, freezeTransactionID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
	rollbackPointWithdrawalFreezeForAsset(request, walletClient, pointWalletAssetType, freezeCommandID, freezeTransactionID, userID, grossPoints, feePoints, netPoints, pointsPerUSD, feeBPS)
}

func rollbackPointWithdrawalFreezeForAsset(request *http.Request, walletClient pointWithdrawalGatewayClient, assetType string, freezeCommandID string, freezeTransactionID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
	withdrawalRef := pointWithdrawalReference(freezeCommandID)
	// 回滚事务也必须跨 HTTP 重试保持同一个 command_id；直接由固定长度的 reservation 引用派生，
	// 不会因为原始 command_id 接近 128 字节而退化为不稳定的 request_id。
	rollbackCommandID := withdrawalRef + ":rollback"
	rollbackRequest := &walletv1.ReleasePointWithdrawalRequest{
		CommandId:           rollbackCommandID,
		UserId:              userID,
		AssetType:           assetType,
		GrossPointAmount:    grossPoints,
		FeePointAmount:      feePoints,
		NetPointAmount:      netPoints,
		PointsPerUsd:        pointsPerUSD,
		FeeBps:              feeBPS,
		Reason:              "point withdrawal application create failed",
		AppCode:             appcode.FromContext(request.Context()),
		WithdrawalRef:       withdrawalRef,
		FreezeTransactionId: strings.TrimSpace(freezeTransactionID),
	}
	baseCtx := appcode.WithContext(context.Background(), appcode.FromContext(request.Context()))
	var rollbackErr error
	for attempt := 1; attempt <= pointWithdrawalRollbackAttempts; attempt++ {
		// 每次 RPC 使用独立短超时；前一次连接结果不确定时，后续尝试携带相同幂等键只会确认或返回同一回执。
		rollbackCtx, cancel := context.WithTimeout(baseCtx, pointWithdrawalRollbackAttemptTimeout)
		_, rollbackErr = walletClient.ReleasePointWithdrawal(rollbackCtx, rollbackRequest)
		cancel()
		if rollbackErr == nil {
			return
		}
		if attempt < pointWithdrawalRollbackAttempts {
			time.Sleep(time.Duration(attempt) * pointWithdrawalRollbackBackoffBaseDelay)
		}
	}
	slog.Error("point_withdrawal_freeze_rollback_failed", "user_id", userID, "gross_point_amount", grossPoints, "freeze_command_id", freezeCommandID, "withdrawal_ref", withdrawalRef, "attempts", pointWithdrawalRollbackAttempts, "error", rollbackErr)
}

// transferPointWalletToCoinSeller 只接收目标和 POINT 金额；wallet 事务会重新读取 active 白名单与比例。
func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, request *http.Request) {
	pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
	if !ok || h.userProfileClient == nil || h.userHostClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	var body pointWalletCoinSellerTransferBody
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	commandID := body.commandID()
	sellerUserID := body.sellerUserID()
	pointAmount := body.amount()
	if commandID == "" || sellerUserID <= 0 || pointAmount <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{Meta: httpkit.UserMeta(request, ""), UserId: userID})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if profileResp.GetUser() == nil {
		httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
		return
	}
	runtimeConfig, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
		RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if !runtimeConfig.GetFound() || pointAmount < runtimeConfig.GetMinimumPoints() {
		// 币商和 USDT 两种提现共用已发布 wallet 政策中的最低门槛，客户端不能通过切换页签绕过。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "point withdrawal amount is below minimum")
		return
	}
	sellerProfileResp, err := h.userHostClient.GetCoinSellerProfile(request.Context(), &userv1.GetCoinSellerProfileRequest{
		Meta: httpkit.UserMeta(request, ""), UserId: sellerUserID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	sellerProfile := sellerProfileResp.GetCoinSellerProfile()
	if sellerProfile == nil || sellerProfile.GetStatus() != "active" || sellerProfile.GetMerchantAssetType() != "COIN_SELLER_COIN" {
		// Admin 白名单只证明配置存在；转账时仍要检查实时币商身份，防止币商被停用后继续收款。
		httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "coin seller is not active")
		return
	}
	receipt, err := pointWallet.TransferPointToCoinSeller(request.Context(), &walletv1.TransferPointToCoinSellerRequest{
		CommandId: commandID, AppCode: appcode.FromContext(request.Context()), SourceUserId: userID,
		SellerUserId: sellerUserID, PointAmount: pointAmount,
		SourceCountryCode: pointWalletCountryCode(profileResp.GetUser()), Reason: "point wallet withdrawal to coin seller",
		RegionId: profileResp.GetUser().GetRegionId(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, map[string]any{
		"transaction_id": receipt.GetTransactionId(), "seller_user_id": strconv.FormatInt(sellerUserID, 10),
		"point_amount": receipt.GetPointAmount(), "seller_coin_amount": receipt.GetSellerCoinAmount(),
		"source_point_balance_after": receipt.GetSourcePointBalanceAfter(), "seller_balance_after": receipt.GetSellerBalanceAfter(),
		"ratio_point_amount": receipt.GetRatioPointAmount(), "ratio_seller_coin_amount": receipt.GetRatioSellerCoinAmount(),
	})
}

// exchangePointWalletToCoins 只接受幂等键和 POINT 数量；比例、COIN 数量及余额都由 wallet-service 原子返回。
func (h *Handler) exchangePointWalletToCoins(writer http.ResponseWriter, request *http.Request) {
	pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
	if !ok || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	var body pointWalletExchangeBody
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	commandID, pointAmount := body.commandID(), body.amount()
	if commandID == "" || pointAmount <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta: httpkit.UserMeta(request, ""), UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if profileResp.GetUser() == nil {
		httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
		return
	}
	receipt, err := pointWallet.ExchangePointToCoin(request.Context(), &walletv1.ExchangePointToCoinRequest{
		CommandId: commandID, AppCode: appcode.FromContext(request.Context()),
		UserId: userID, PointAmount: pointAmount, RegionId: profileResp.GetUser().GetRegionId(), NowMs: time.Now().UTC().UnixMilli(),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, map[string]any{
		"transaction_id": receipt.GetTransactionId(), "point_amount": receipt.GetPointAmount(), "coin_amount": receipt.GetCoinAmount(),
		"point_balance_after": receipt.GetPointBalanceAfter(), "coin_balance_after": receipt.GetCoinBalanceAfter(),
		"ratio_point_amount": receipt.GetRatioPointAmount(), "ratio_coin_amount": receipt.GetRatioCoinAmount(),
	})
}

func (h *Handler) pointWalletCoinSellerData(request *http.Request, configs []*walletv1.PointWithdrawalCoinSellerConfig) ([]pointWalletCoinSellerData, error) {
	if len(configs) == 0 {
		return []pointWalletCoinSellerData{}, nil
	}
	userIDs := make([]int64, 0, len(configs))
	for _, config := range configs {
		if config != nil && config.GetSellerUserId() > 0 {
			userIDs = append(userIDs, config.GetSellerUserId())
		}
	}
	profiles, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{Meta: httpkit.UserMeta(request, ""), UserIds: userIDs})
	if err != nil {
		return nil, err
	}
	items := make([]pointWalletCoinSellerData, 0, len(configs))
	for _, config := range configs {
		if config == nil {
			continue
		}
		profile := profiles.GetUsers()[config.GetSellerUserId()]
		// 白名单可在用户删除/禁用后短暂残留；读模型跳过缺失用户，转账入口还会实时校验 coin-seller 身份。
		if profile == nil || profile.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE {
			continue
		}
		items = append(items, pointWalletCoinSellerData{
			UserID: strconv.FormatInt(profile.GetUserId(), 10), DisplayUserID: profile.GetDisplayUserId(),
			Nickname: profile.GetUsername(), Avatar: profile.GetAvatar(), SortOrder: config.GetSortOrder(),
			PointAmount: config.GetPointAmount(), SellerCoinAmount: config.GetSellerCoinAmount(),
			ServiceRegionIDs:    append([]int64(nil), config.GetServiceRegionIds()...),
			ServiceCountryCodes: append([]string(nil), config.GetServiceCountryCodes()...),
		})
	}
	return items, nil
}

func pointWalletCountryCode(profile *userv1.User) string {
	if profile == nil {
		return ""
	}
	return strings.ToUpper(strings.TrimSpace(profile.GetCountry()))
}
