package walletapi

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"math"
	"net/http"
	"net/http/httptest"
	"testing"

	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/client"
	"hyapp/services/gateway-service/internal/financewithdrawal"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
)

func TestCalculatePointFeeUsesOverflowSafeFixedPoint(t *testing.T) {
	for _, test := range []struct {
		name   string
		points int64
		feeBPS int32
		want   int64
	}{
		{name: "normal", points: 1_000_000, feeBPS: 500, want: 50_000},
		{name: "maximum-half-tenth", points: math.MaxInt64, feeBPS: 500, want: math.MaxInt64 / 20},
		{name: "maximum-full-fee", points: math.MaxInt64, feeBPS: 10_000, want: math.MaxInt64},
	} {
		t.Run(test.name, func(t *testing.T) {
			got, ok := calculatePointFee(test.points, test.feeBPS)
			if !ok || got != test.want {
				t.Fatalf("calculatePointFee(%d, %d) = (%d, %t), want (%d, true)", test.points, test.feeBPS, got, ok, test.want)
			}
		})
	}
	for _, invalidBPS := range []int32{-1, 10_001} {
		if _, ok := calculatePointFee(math.MaxInt64, invalidBPS); ok {
			t.Fatalf("invalid fee bps %d must be rejected", invalidBPS)
		}
	}
}

func TestFormatPointUSDUsesOverflowSafeFixedPoint(t *testing.T) {
	for _, test := range []struct {
		name         string
		points       int64
		pointsPerUSD int64
		want         string
	}{
		{name: "normal", points: 950_000, pointsPerUSD: 100_000, want: "9.50"},
		{name: "fraction-floor", points: 1, pointsPerUSD: 3, want: "0.33"},
		{name: "maximum-unit-ratio", points: math.MaxInt64, pointsPerUSD: 1, want: "9223372036854775807.00"},
		{name: "maximum-production-scale", points: math.MaxInt64, pointsPerUSD: 100_000, want: "92233720368547.75"},
	} {
		t.Run(test.name, func(t *testing.T) {
			if got := formatPointUSD(test.points, test.pointsPerUSD); got != test.want {
				t.Fatalf("formatPointUSD(%d, %d) = %q, want %q", test.points, test.pointsPerUSD, got, test.want)
			}
		})
	}
}

func TestPointDiamondWalletHistoryExposesMergedHostAgencyComponentsAsStrings(t *testing.T) {
	wallet := &fakePointWalletClient{transactions: []*walletv1.WalletTransaction{{
		EntryId: 91, TransactionId: "gift-tx", BizType: "gift_debit", AssetType: "POINT_DIAMOND",
		AvailableDelta: 120, AvailableAfter: 120, EntryRole: "host_gift_income_and_agency_share",
		HostBaseAmount: 100, AgencyAmount: 20, CreatedAtMs: 1_784_635_000_000,
	}}}
	handler := New(Config{WalletClient: wallet})
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/history?page=1&page_size=20", nil)
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
	httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletHistory)).ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("POINT_DIAMOND history status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response struct {
		Code string `json:"code"`
		Data struct {
			Items []struct {
				EntryRole      string              `json:"entry_role"`
				AvailableDelta string              `json:"available_delta"`
				HostBaseAmount string              `json:"host_base_amount"`
				AgencyAmount   string              `json:"agency_amount"`
				Components     []map[string]string `json:"components"`
			} `json:"items"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode POINT_DIAMOND history: %v", err)
	}
	if response.Code != httpkit.CodeOK || len(response.Data.Items) != 1 {
		t.Fatalf("POINT_DIAMOND history response mismatch: %+v", response)
	}
	item := response.Data.Items[0]
	if item.EntryRole != "host_gift_income_and_agency_share" || item.AvailableDelta != "120" || item.HostBaseAmount != "100" || item.AgencyAmount != "20" || len(item.Components) != 2 || item.Components[0]["amount"] != "100" || item.Components[1]["amount"] != "20" {
		t.Fatalf("merged Host/Agency history components mismatch: %+v", item)
	}
}

func TestPointDiamondWalletOverviewReturnsEvaluatedActionLimits(t *testing.T) {
	wallet := &fakePointWalletClient{
		balances: []*walletv1.AssetBalance{{AssetType: "POINT_DIAMOND", AvailableAmount: 3_000_000}},
		pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
			Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
			CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: false, LimitPeriod: "month", LimitCount: 2, UsedCount: 2, RemainingCount: 0, BlockReason: "limit_reached"},
			PlatformAvailability:   &walletv1.PointWithdrawalActionAvailability{Allowed: true, LimitPeriod: "month", LimitCount: 0, RemainingCount: -1, AllowedDays: ""},
		}},
	}
	handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 25}})
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
	httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastPointConfig == nil || wallet.lastPointConfig.GetUserId() != 42001 || wallet.lastPointConfig.GetAssetType() != "POINT_DIAMOND" {
		t.Fatalf("overview must request user-scoped availability: %+v", wallet.lastPointConfig)
	}
	var response struct {
		Data struct {
			Actions struct {
				Exchange bool `json:"exchange_to_coins"`
				Withdraw bool `json:"withdraw"`
				Seller   bool `json:"transfer_to_coin_seller"`
			} `json:"actions"`
			Channels     []string `json:"channels"`
			ActionLimits map[string]struct {
				Remaining string `json:"remaining_count"`
				Reason    string `json:"block_reason"`
			} `json:"action_limits"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode overview: %v", err)
	}
	if !response.Data.Actions.Exchange || !response.Data.Actions.Withdraw || response.Data.Actions.Seller || len(response.Data.Channels) != 2 || response.Data.ActionLimits["transfer_to_coin_seller"].Remaining != "0" || response.Data.ActionLimits["transfer_to_coin_seller"].Reason != "limit_reached" {
		t.Fatalf("overview capability mismatch: %+v", response.Data)
	}
}

func TestPointDiamondWalletOverviewUsesWalletCoinSellerWhitelist(t *testing.T) {
	wallet := &fakePointWalletClient{
		balances: []*walletv1.AssetBalance{{AssetType: "POINT_DIAMOND", AvailableAmount: 3_000_000}},
		pointSellers: []*walletv1.PointWithdrawalCoinSellerConfig{{
			SellerUserId: 73001, SortOrder: 2, ServiceCountryCodes: []string{"SA"}, Status: "active",
		}},
		pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
			Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
			CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
			PlatformAvailability:   &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
		}},
	}
	profiles := &fakePointUserProfileClient{
		country: "sa", regionID: 25,
		users: map[int64]*userv1.User{
			73001: {UserId: 73001, DisplayUserId: "seller-73001", Username: "Trusted Seller", Status: userv1.UserStatus_USER_STATUS_ACTIVE},
		},
	}
	handler := New(Config{WalletClient: wallet, UserProfileClient: profiles})
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
	httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastSellerList == nil || wallet.lastSellerList.GetCountryCode() != "SA" || wallet.lastSellerList.GetAppCode() != "fami" {
		t.Fatalf("wallet seller whitelist request mismatch: %+v", wallet.lastSellerList)
	}
	var response struct {
		Data struct {
			Actions struct {
				Seller bool `json:"transfer_to_coin_seller"`
			} `json:"actions"`
			Sellers []struct {
				UserID        string `json:"user_id"`
				DisplayUserID string `json:"display_user_id"`
			} `json:"coin_sellers"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode overview: %v", err)
	}
	if !response.Data.Actions.Seller || len(response.Data.Sellers) != 1 || response.Data.Sellers[0].UserID != "73001" || response.Data.Sellers[0].DisplayUserID != "seller-73001" {
		t.Fatalf("POINT_DIAMOND seller whitelist mismatch: %+v", response.Data)
	}
}

func TestPointDiamondWalletOverviewDisablesSellerActionWithoutEligibleSeller(t *testing.T) {
	wallet := &fakePointWalletClient{
		pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
			Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
			CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
		}},
	}
	handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{country: "SA", regionID: 25}})
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
	httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response struct {
		Data struct {
			Actions struct {
				Seller bool `json:"transfer_to_coin_seller"`
			} `json:"actions"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode overview: %v", err)
	}
	if response.Data.Actions.Seller {
		t.Fatalf("seller action must be disabled when the wallet whitelist has no eligible seller: %+v", response.Data)
	}
}

func TestWithdrawPointWalletRequiresClientCommandID(t *testing.T) {
	wallet := &fakePointWalletClient{}
	writer := &fakePointWithdrawalWriter{}
	handler := New(Config{
		WalletClient:      wallet,
		UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
		WithdrawalWriter:  writer,
	})
	missingCommandRecorder := httptest.NewRecorder()
	missingCommandRequest := pointWalletRequest(`{"gross_point_amount":1000000}`, "huwaa")
	httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(missingCommandRecorder, missingCommandRequest)
	if missingCommandRecorder.Code != http.StatusBadRequest {
		t.Fatalf("missing command_id status mismatch: got %d body=%s", missingCommandRecorder.Code, missingCommandRecorder.Body.String())
	}
	if wallet.lastFreeze != nil || writer.calls != 0 {
		t.Fatalf("missing command_id must not freeze or create application: freeze=%+v writer_calls=%d", wallet.lastFreeze, writer.calls)
	}
}

func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *testing.T) {
	wallet := &fakePointWalletClient{
		// Gateway 不读取该余额快照；真正的 available 校验必须和冻结写入一起由 wallet 事务完成。
		balances: []*walletv1.AssetBalance{{AssetType: "POINT", AvailableAmount: 0, FrozenAmount: 0, Version: 3}},
		pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{
			{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"},
			{Found: true, PointsPerUsd: 200_000, CoinsPerUsd: 160_000, FeeBps: 1_000, MinimumPoints: 1_000_000, PolicyInstanceCode: "changed-policy"},
		},
	}
	writer := &fakePointWithdrawalWriter{}
	handler := New(Config{
		WalletClient:      wallet,
		UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
		WithdrawalWriter:  writer,
	})
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(`{"command_id":"client-point-withdraw-1","gross_point_amount":1000000}`, "huwaa")

	httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("POINT withdrawal status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastBalance != nil {
		t.Fatalf("withdraw handler must leave atomic balance validation to wallet freeze, got preflight request %+v", wallet.lastBalance)
	}
	if wallet.lastFreeze == nil ||
		wallet.lastFreeze.GetCommandId() != "client-point-withdraw-1" ||
		wallet.lastFreeze.GetAppCode() != "huwaa" ||
		wallet.lastFreeze.GetUserId() != 42001 ||
		wallet.lastFreeze.GetGrossPointAmount() != 1_000_000 ||
		wallet.lastFreeze.GetFeePointAmount() != 50_000 ||
		wallet.lastFreeze.GetNetPointAmount() != 950_000 ||
		wallet.lastFreeze.GetRegionId() != 25 ||
		wallet.lastFreeze.GetWithdrawalRef() == "" {
		t.Fatalf("FreezePointWithdrawal payload mismatch: %+v", wallet.lastFreeze)
	}
	if writer.calls != 1 ||
		writer.last.AppCode != "huwaa" ||
		writer.last.UserID != 42001 ||
		writer.last.SalaryAssetType != "POINT" ||
		writer.last.WithdrawAmount != "9.50" ||
		writer.last.WithdrawAmountMinor != 1_000_000 ||
		writer.last.PointFeeAmount != 50_000 ||
		writer.last.PointNetAmount != 950_000 ||
		writer.last.PointsPerUSD != 100_000 ||
		writer.last.PointFeeBPS != 500 ||
		writer.last.PointPolicyInstance != "test-policy" ||
		writer.last.FreezeCommandID != "client-point-withdraw-1" ||
		writer.last.FreezeTransactionID != "point-freeze-tx" ||
		writer.last.WithdrawAddress != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" {
		t.Fatalf("withdrawal application command mismatch: %+v calls=%d", writer.last, writer.calls)
	}
	var response struct {
		Code string         `json:"code"`
		Data map[string]any `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if response.Code != httpkit.CodeOK ||
		response.Data["application_id"] != "77" ||
		response.Data["status"] != financewithdrawal.StatusPending ||
		response.Data["operations_status"] != financewithdrawal.OperationsStatusPending ||
		response.Data["gross_point_amount"] != float64(1_000_000) ||
		response.Data["fee_point_amount"] != float64(50_000) ||
		response.Data["policy_instance_code"] != "test-policy" ||
		response.Data["net_usd"] != "9.50" {
		t.Fatalf("POINT withdrawal response mismatch: %+v", response)
	}

	firstRequestID := recorder.Header().Get("X-Request-ID")
	firstWithdrawalRef := wallet.lastFreeze.GetWithdrawalRef()
	if firstRequestID == "" || firstWithdrawalRef == "" || len(firstWithdrawalRef) > 128 || firstWithdrawalRef == firstRequestID {
		t.Fatalf("withdrawal ref must be a bounded business id, request_id=%q withdrawal_ref=%q", firstRequestID, firstWithdrawalRef)
	}
	retryRecorder := httptest.NewRecorder()
	retryRequest := pointWalletRequest(`{"command_id":"client-point-withdraw-1","gross_point_amount":1000000}`, "huwaa")
	httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(retryRecorder, retryRequest)
	if retryRecorder.Code != http.StatusOK {
		t.Fatalf("same-command retry status mismatch: got %d body=%s", retryRecorder.Code, retryRecorder.Body.String())
	}
	secondRequestID := retryRecorder.Header().Get("X-Request-ID")
	if secondRequestID == "" || secondRequestID == firstRequestID {
		t.Fatalf("test did not establish distinct tracing IDs: first=%q second=%q", firstRequestID, secondRequestID)
	}
	if wallet.lastFreeze.GetCommandId() != "client-point-withdraw-1" ||
		wallet.lastFreeze.GetWithdrawalRef() != firstWithdrawalRef ||
		wallet.lastFreeze.GetFeePointAmount() != 100_000 ||
		wallet.lastFreeze.GetPointsPerUsd() != 200_000 ||
		writer.calls != 2 {
		t.Fatalf("same command must retain freeze identity across request IDs: freeze=%+v first_ref=%q writer_calls=%d", wallet.lastFreeze, firstWithdrawalRef, writer.calls)
	}
	if writer.last.WithdrawAmountMinor != 1_000_000 ||
		writer.last.PointFeeAmount != 50_000 ||
		writer.last.PointNetAmount != 950_000 ||
		writer.last.PointsPerUSD != 100_000 ||
		writer.last.PointFeeBPS != 500 ||
		writer.last.PointPolicyInstance != "test-policy" ||
		writer.last.WithdrawAmount != "9.50" {
		t.Fatalf("same-command retry must create application from first wallet receipt, got %+v", writer.last)
	}
	var retryResponse struct {
		Code string         `json:"code"`
		Data map[string]any `json:"data"`
	}
	if err := json.NewDecoder(retryRecorder.Body).Decode(&retryResponse); err != nil {
		t.Fatalf("decode retry response failed: %v", err)
	}
	if retryResponse.Code != httpkit.CodeOK || retryResponse.Data["fee_point_amount"] != float64(50_000) || retryResponse.Data["points_per_usd"] != float64(100_000) || retryResponse.Data["policy_instance_code"] != "test-policy" || retryResponse.Data["net_usd"] != "9.50" {
		t.Fatalf("retry response must expose first wallet receipt: %+v", retryResponse)
	}
}

func TestWithdrawPointWalletApplicationFailureRollsBackSameLimitReservation(t *testing.T) {
	wallet := &fakePointWalletClient{
		releaseFailures: 2,
		freezeReceipts: map[string]*walletv1.FreezePointWithdrawalResponse{
			"client-point-withdraw-rollback": {
				TransactionId: "point-freeze-existing", GrossPointAmount: 1_000_000, FeePointAmount: 75_000,
				NetPointAmount: 925_000, PointsPerUsd: 125_000, FeeBps: 750,
			},
		},
	}
	handler := New(Config{
		WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
		WithdrawalWriter: &fakePointWithdrawalWriter{err: errors.New("admin insert failed")},
	})
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(`{"command_id":"client-point-withdraw-rollback","gross_point_amount":1000000}`, "huwaa")
	httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusInternalServerError {
		t.Fatalf("application failure status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastFreeze == nil || wallet.lastRelease == nil ||
		wallet.lastRelease.GetCommandId() != wallet.lastFreeze.GetWithdrawalRef()+":rollback" ||
		wallet.lastRelease.GetWithdrawalRef() == "" ||
		wallet.lastRelease.GetWithdrawalRef() != wallet.lastFreeze.GetWithdrawalRef() ||
		len(wallet.lastRelease.GetWithdrawalRef()) > 128 ||
		len(wallet.releaseRequests) != pointWithdrawalRollbackAttempts {
		t.Fatalf("rollback must target the freeze reservation: freeze=%+v release=%+v", wallet.lastFreeze, wallet.lastRelease)
	}
	if wallet.lastRelease.GetGrossPointAmount() != 1_000_000 || wallet.lastRelease.GetFeePointAmount() != 75_000 || wallet.lastRelease.GetNetPointAmount() != 925_000 || wallet.lastRelease.GetPointsPerUsd() != 125_000 || wallet.lastRelease.GetFeeBps() != 750 {
		t.Fatalf("rollback must release the first wallet receipt snapshot: %+v", wallet.lastRelease)
	}
	for index, release := range wallet.releaseRequests {
		if release.GetCommandId() != wallet.lastRelease.GetCommandId() || release.GetWithdrawalRef() != wallet.lastFreeze.GetWithdrawalRef() {
			t.Fatalf("rollback retry %d changed idempotency identity: %+v", index+1, release)
		}
	}
}

func TestWithdrawPointWalletUnknownApplicationOutcomeKeepsFreeze(t *testing.T) {
	wallet := &fakePointWalletClient{}
	handler := New(Config{
		WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
		WithdrawalWriter: &fakePointWithdrawalWriter{err: financewithdrawal.ErrApplicationOutcomeUnknown},
	})
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(`{"command_id":"client-point-withdraw-unknown","gross_point_amount":1000000}`, "huwaa")
	httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusInternalServerError {
		t.Fatalf("unknown application outcome status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastFreeze == nil || wallet.lastRelease != nil || len(wallet.releaseRequests) != 0 {
		t.Fatalf("outcome-unknown application must retain freeze for same-command reconciliation: freeze=%+v releases=%+v", wallet.lastFreeze, wallet.releaseRequests)
	}
}

func TestTransferPointWalletToCoinSellerUsesStringIDAndTrustedCountry(t *testing.T) {
	wallet := &fakePointWalletClient{}
	handler := New(Config{
		WalletClient:      wallet,
		UserProfileClient: &fakePointUserProfileClient{country: "SA", regionID: 26},
		UserHostClient:    &fakePointUserHostClient{},
	})
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(`{"command_id":"point-seller-1","seller_user_id":"9007199254740001","point_amount":1000000}`, "fami")

	httpkit.WithRequestID(http.HandlerFunc(handler.transferPointWalletToCoinSeller)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("point seller transfer status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9007199254740001 || wallet.lastPointTransfer.GetSourceUserId() != 42001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetAppCode() != "fami" || wallet.lastPointTransfer.GetRegionId() != 26 {
		t.Fatalf("point seller transfer request mismatch: %+v", wallet.lastPointTransfer)
	}
}

func TestExchangePointWalletToCoinsUsesAuthenticatedUserAndClientCommandID(t *testing.T) {
	wallet := &fakePointWalletClient{}
	handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 24}})
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(`{"command_id":"point-exchange-1","point_amount":1000000}`, "fami")

	httpkit.WithRequestID(http.HandlerFunc(handler.exchangePointWalletToCoins)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("point exchange status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastPointExchange == nil || wallet.lastPointExchange.GetCommandId() != "point-exchange-1" || wallet.lastPointExchange.GetAppCode() != "fami" || wallet.lastPointExchange.GetUserId() != 42001 || wallet.lastPointExchange.GetPointAmount() != 1_000_000 || wallet.lastPointExchange.GetRegionId() != 24 || wallet.lastPointExchange.GetNowMs() <= 0 {
		t.Fatalf("point exchange request mismatch: %+v", wallet.lastPointExchange)
	}
}

func TestTransferPointDiamondToCoinSellerLetsWalletOwnReplayValidation(t *testing.T) {
	wallet := &fakePointWalletClient{}
	profile := &fakePointUserProfileClient{country: "SA", regionID: 26}
	handler := New(Config{
		WalletClient:       wallet,
		UserProfileClient:  profile,
		UserIdentityClient: &fakePointUserIdentityClient{userID: 9001},
	})
	requestBody := `{"command_id":"pd-seller-1","target_display_user_id":"700001","gross_usd_minor":10000}`
	recorder := httptest.NewRecorder()
	request := pointWalletRequest(requestBody, "fami")
	httpkit.WithRequestID(http.HandlerFunc(handler.transferPointDiamondWalletToCoinSeller)).ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("POINT_DIAMOND seller transfer status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetRegionId() != 26 || wallet.lastPointTransfer.GetSourceAssetType() != "POINT_DIAMOND" || wallet.lastPointTransfer.GetGrossUsdMinor() != 10000 {
		t.Fatalf("POINT_DIAMOND seller transfer request mismatch: %+v", wallet.lastPointTransfer)
	}

	// Gateway 不再用当前区域的 active 币商列表拦截重放；变化后的派生资料仍送到 wallet，
	// wallet 会在事务开头按稳定 command_id 返回首次回执。
	profile.country, profile.regionID = "AE", 99
	retryRecorder := httptest.NewRecorder()
	retryRequest := pointWalletRequest(requestBody, "fami")
	httpkit.WithRequestID(http.HandlerFunc(handler.transferPointDiamondWalletToCoinSeller)).ServeHTTP(retryRecorder, retryRequest)
	if retryRecorder.Code != http.StatusOK || wallet.lastPointTransfer.GetSourceCountryCode() != "AE" || wallet.lastPointTransfer.GetRegionId() != 99 {
		t.Fatalf("profile drift must still reach wallet replay: status=%d request=%+v body=%s", retryRecorder.Code, wallet.lastPointTransfer, retryRecorder.Body.String())
	}
}

func TestGetPointWalletExchangeConfigUsesTrustedRegionAndPublishedPolicy(t *testing.T) {
	wallet := &fakePointWalletClient{}
	handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 24}})
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodGet, "/api/v1/point-wallet/exchange-config", nil)
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))

	httpkit.WithRequestID(http.HandlerFunc(handler.getPointWalletExchangeConfig)).ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("point exchange config status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if wallet.lastPointConfig == nil || wallet.lastPointConfig.GetAppCode() != "fami" || wallet.lastPointConfig.GetRegionId() != 24 || wallet.lastPointConfig.GetNowMs() <= 0 {
		t.Fatalf("point exchange config request mismatch: %+v", wallet.lastPointConfig)
	}
	var response struct {
		Code string `json:"code"`
		Data 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"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode point exchange config failed: %v", err)
	}
	if response.Code != "OK" || response.Data.SourceAssetType != "POINT" || response.Data.TargetAssetType != "COIN" || response.Data.PointsPerUSD != 100_000 || response.Data.CoinsPerUSD != 80_000 || response.Data.RatioPointAmount != 100_000 || response.Data.RatioCoinAmount != 80_000 || response.Data.RoundingMode != "floor" || response.Data.PolicyInstanceCode != "test-policy" {
		t.Fatalf("point exchange config response mismatch: %+v", response)
	}
}

type fakePointWalletClient struct {
	client.WalletClient
	balances            []*walletv1.AssetBalance
	lastBalance         *walletv1.GetBalancesRequest
	lastFreeze          *walletv1.FreezePointWithdrawalRequest
	lastRelease         *walletv1.ReleasePointWithdrawalRequest
	releaseRequests     []*walletv1.ReleasePointWithdrawalRequest
	lastPointTransfer   *walletv1.TransferPointToCoinSellerRequest
	lastPointExchange   *walletv1.ExchangePointToCoinRequest
	lastPointConfig     *walletv1.GetPointWithdrawalConfigRequest
	freezeErr           error
	releaseFailures     int
	freezeReceipts      map[string]*walletv1.FreezePointWithdrawalResponse
	pointConfigs        []*walletv1.GetPointWithdrawalConfigResponse
	pointConfigCalls    int
	lastPointConfigCode string
	transactions        []*walletv1.WalletTransaction
	pointSellers        []*walletv1.PointWithdrawalCoinSellerConfig
	lastSellerList      *walletv1.ListPointWithdrawalCoinSellersRequest
}

func (f *fakePointWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	f.lastBalance = req
	balances := f.balances
	if len(balances) == 0 {
		balances = []*walletv1.AssetBalance{{AssetType: "POINT", AvailableAmount: 2_000_000}}
	}
	return &walletv1.GetBalancesResponse{Balances: balances}, nil
}

func (f *fakePointWalletClient) ListWalletTransactions(_ context.Context, _ *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
	return &walletv1.ListWalletTransactionsResponse{Transactions: f.transactions, Total: int64(len(f.transactions))}, nil
}

func (f *fakePointWalletClient) FreezePointWithdrawal(_ context.Context, req *walletv1.FreezePointWithdrawalRequest) (*walletv1.FreezePointWithdrawalResponse, error) {
	f.lastFreeze = req
	if f.freezeErr != nil {
		return nil, f.freezeErr
	}
	if existing := f.freezeReceipts[req.GetCommandId()]; existing != nil {
		return existing, nil
	}
	receipt := &walletv1.FreezePointWithdrawalResponse{
		TransactionId:      "point-freeze-tx",
		GrossPointAmount:   req.GetGrossPointAmount(),
		FeePointAmount:     req.GetFeePointAmount(),
		NetPointAmount:     req.GetNetPointAmount(),
		PointsPerUsd:       req.GetPointsPerUsd(),
		FeeBps:             req.GetFeeBps(),
		PolicyInstanceCode: f.lastPointConfigCode,
		Balance:            &walletv1.AssetBalance{AssetType: "POINT", AvailableAmount: 1_000_000, FrozenAmount: req.GetGrossPointAmount(), Version: 4},
	}
	if f.freezeReceipts == nil {
		f.freezeReceipts = make(map[string]*walletv1.FreezePointWithdrawalResponse)
	}
	f.freezeReceipts[req.GetCommandId()] = receipt
	return receipt, nil
}

func (f *fakePointWalletClient) ReleasePointWithdrawal(_ context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) {
	f.lastRelease = req
	f.releaseRequests = append(f.releaseRequests, req)
	if len(f.releaseRequests) <= f.releaseFailures {
		return nil, errors.New("temporary point release failure")
	}
	return &walletv1.ReleasePointWithdrawalResponse{TransactionId: "point-release-tx"}, nil
}

func (f *fakePointWalletClient) GetPointWithdrawalConfig(_ context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) {
	f.lastPointConfig = req
	if len(f.pointConfigs) > 0 {
		index := f.pointConfigCalls
		if index >= len(f.pointConfigs) {
			index = len(f.pointConfigs) - 1
		}
		f.pointConfigCalls++
		config := f.pointConfigs[index]
		f.lastPointConfigCode = config.GetPolicyInstanceCode()
		return config, nil
	}
	config := &walletv1.GetPointWithdrawalConfigResponse{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"}
	f.lastPointConfigCode = config.GetPolicyInstanceCode()
	return config, nil
}

func (f *fakePointWalletClient) ListPointWithdrawalCoinSellers(_ context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
	f.lastSellerList = req
	return &walletv1.ListPointWithdrawalCoinSellersResponse{Sellers: f.pointSellers}, nil
}

func (f *fakePointWalletClient) TransferPointToCoinSeller(_ context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error) {
	f.lastPointTransfer = req
	return &walletv1.TransferPointToCoinSellerResponse{TransactionId: "point-seller-tx", PointAmount: req.GetPointAmount(), SellerCoinAmount: 920000}, nil
}

func (f *fakePointWalletClient) ExchangePointToCoin(_ context.Context, req *walletv1.ExchangePointToCoinRequest) (*walletv1.ExchangePointToCoinResponse, error) {
	f.lastPointExchange = req
	return &walletv1.ExchangePointToCoinResponse{
		TransactionId: "point-exchange-tx", PointAmount: req.GetPointAmount(), CoinAmount: 80_000,
		PointBalanceAfter: 1_000_000, CoinBalanceAfter: 180_000,
		RatioPointAmount: 1_000_000, RatioCoinAmount: 80_000,
	}, nil
}

type fakePointUserProfileClient struct {
	client.UserProfileClient
	address  string
	country  string
	regionID int64
	err      error
	users    map[int64]*userv1.User
}

func (f *fakePointUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
	if f.err != nil {
		return nil, f.err
	}
	return &userv1.GetUserResponse{User: &userv1.User{UserId: req.GetUserId(), Country: f.country, RegionId: f.regionID, WithdrawUsdtTrc20Address: f.address}}, nil
}

func (f *fakePointUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
	users := make(map[int64]*userv1.User, len(req.GetUserIds()))
	for _, userID := range req.GetUserIds() {
		if user := f.users[userID]; user != nil {
			users[userID] = user
		}
	}
	return &userv1.BatchGetUsersResponse{Users: users}, nil
}

type fakePointUserHostClient struct {
	client.UserHostClient
}

func (f *fakePointUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) {
	return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: &userv1.CoinSellerProfile{UserId: req.GetUserId(), Status: "active", MerchantAssetType: "COIN_SELLER_COIN"}}, nil
}

type fakePointUserIdentityClient struct {
	client.UserIdentityClient
	userID int64
}

func (f *fakePointUserIdentityClient) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) {
	return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{UserId: f.userID, DisplayUserId: req.GetDisplayUserId()}}, nil
}

func (f *fakePointUserProfileClient) UpdateUserWithdrawAddress(_ context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) {
	if f.err != nil {
		return nil, f.err
	}
	return &userv1.UpdateUserWithdrawAddressResponse{User: &userv1.User{UserId: req.GetUserId(), WithdrawUsdtTrc20Address: req.GetUsdtTrc20Address()}}, nil
}

type fakePointWithdrawalWriter struct {
	calls int
	last  financewithdrawal.CreateApplicationCommand
	err   error
}

func (f *fakePointWithdrawalWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
	f.calls++
	f.last = command
	if f.err != nil {
		return financewithdrawal.Application{}, f.err
	}
	return financewithdrawal.Application{
		ID:                  77,
		AppCode:             command.AppCode,
		UserID:              command.UserID,
		SalaryAssetType:     command.SalaryAssetType,
		WithdrawAmount:      command.WithdrawAmount,
		WithdrawAmountMinor: command.WithdrawAmountMinor,
		PointFeeAmount:      command.PointFeeAmount,
		PointNetAmount:      command.PointNetAmount,
		PointsPerUSD:        command.PointsPerUSD,
		PointFeeBPS:         command.PointFeeBPS,
		PointPolicyInstance: command.PointPolicyInstance,
		WithdrawMethod:      command.WithdrawMethod,
		WithdrawAddress:     command.WithdrawAddress,
		FreezeCommandID:     command.FreezeCommandID,
		FreezeTransactionID: command.FreezeTransactionID,
		OperationsStatus:    financewithdrawal.OperationsStatusPending,
		Status:              financewithdrawal.StatusPending,
		CreatedAtMS:         command.CreatedAtMS,
		UpdatedAtMS:         command.CreatedAtMS,
	}, nil
}

func pointWalletRequest(body string, app string) *http.Request {
	request := httptest.NewRequest(http.MethodPost, "/api/v1/point-wallet/withdraw", bytes.NewReader([]byte(body)))
	request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), app), 42001))
	return request
}
