package http

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

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

func newVIPTestRouter(walletClient *fakeWalletClient) http.Handler {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	return handler.Routes(auth.NewVerifier("secret"))
}

func TestTriggerVIPOnlineNoticeChecksBenefitAndPublishesTrustedGlobalPayload(t *testing.T) {
	walletClient := &fakeWalletClient{
		benefitCheckResp: &walletv1.CheckVipBenefitResponse{
			Allowed: true,
			Benefit: &walletv1.VipBenefit{
				BenefitCode:  "online_global_notice",
				MetadataJson: `{"message":"欢迎进入Fami，祝你有美好的一天"}`,
			},
			State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{
				UserId: 42, Level: 9, Name: "VIP9", Active: true, ProgramType: "tiered_privilege_v1",
			}},
			EvaluatedAtMs: 1_800_000_000_000,
		},
		batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{
			{UserId: 99, Resources: []*walletv1.UserResourceEntitlement{{Resource: &walletv1.Resource{
				ResourceId: 9000, ResourceCode: "other_user_frame", ResourceType: "avatar_frame", AssetUrl: "https://cdn.example/wrong-user.png",
			}}}},
			{UserId: 42, Resources: []*walletv1.UserResourceEntitlement{
				{Resource: &walletv1.Resource{ResourceId: 7000, ResourceCode: "not_a_frame", ResourceType: "vehicle", AssetUrl: "https://cdn.example/car.svga"}},
				{Resource: &walletv1.Resource{
					ResourceId: 7001, ResourceCode: "vip9_gold_frame", ResourceType: "avatar_frame",
					AssetUrl: "https://cdn.example/frame.svga", PreviewUrl: "https://cdn.example/frame.png", AnimationUrl: "https://cdn.example/frame.json",
				}},
			}},
		}},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
		UserId:              42,
		Username:            "I am a Rich",
		Avatar:              "https://cdn.example/vip9.png",
		DisplayUserId:       "100042",
		PrettyDisplayUserId: "251145",
	}}}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-1"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if req := walletClient.lastBenefitCheck; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetBenefitCode() != "online_global_notice" || req.GetRequestId() != recorder.Header().Get("X-Request-ID") {
		t.Fatalf("CheckVipBenefit request mismatch: %+v", req)
	}
	if req := walletClient.lastBatchEquipped; req == nil || req.GetAppCode() != "fami" || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || len(req.GetUserIds()) != 1 || req.GetUserIds()[0] != 42 || len(req.GetResourceTypes()) != 1 || req.GetResourceTypes()[0] != "avatar_frame" {
		t.Fatalf("BatchGetUserEquippedResources request mismatch: %+v", req)
	}
	if req := profileClient.lastGet; req == nil || req.GetUserId() != 42 || req.GetMeta().GetAppCode() != "fami" {
		t.Fatalf("GetUser request mismatch: %+v", req)
	}
	publish := broadcastClient.lastGlobal
	if publish == nil || publish.GetMeta().GetAppCode() != "fami" || publish.GetBroadcastType() != "vip_online_notice" || !strings.HasPrefix(publish.GetEventId(), "vip_online_notice:42:") {
		t.Fatalf("PublishGlobalBroadcast request mismatch: %+v", publish)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(publish.GetPayloadJson()), &payload); err != nil {
		t.Fatalf("decode broadcast payload: %v", err)
	}
	if payload["user_id"] != "42" || payload["sender_user_id"] != "42" || payload["nickname"] != "I am a Rich" || payload["avatar"] != "https://cdn.example/vip9.png" || payload["display_user_id"] != "251145" || payload["vip_level"] != float64(9) || payload["vip_name"] != "VIP9" || payload["message"] != "欢迎进入Fami，祝你有美好的一天" {
		t.Fatalf("broadcast payload mismatch: %+v", payload)
	}
	if payload["avatar_frame_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_resource_id"] != float64(7001) || payload["avatar_frame_code"] != "vip9_gold_frame" || payload["avatar_frame_asset_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_preview_url"] != "https://cdn.example/frame.png" || payload["avatar_frame_animation_url"] != "https://cdn.example/frame.json" {
		t.Fatalf("avatar frame payload mismatch: %+v", payload)
	}
	if _, leaked := payload["command_id"]; leaked || strings.Contains(publish.GetPayloadJson(), "process-launch-1") {
		t.Fatalf("client command must not be broadcast: %s", publish.GetPayloadJson())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			EventID       string         `json:"event_id"`
			GroupID       string         `json:"group_id"`
			Status        string         `json:"status"`
			Created       bool           `json:"created"`
			EvaluatedAtMS int64          `json:"evaluated_at_ms"`
			Notice        map[string]any `json:"notice"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.EventID != publish.GetEventId() || envelope.Data.GroupID != "hy_fami_bc_g_v2" || envelope.Data.Status != "pending" || !envelope.Data.Created || envelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
		t.Fatalf("response mismatch: %+v", envelope)
	}
	if envelope.Data.Notice["event_id"] != publish.GetEventId() || envelope.Data.Notice["broadcast_type"] != "vip_online_notice" || envelope.Data.Notice["app_code"] != "fami" || envelope.Data.Notice["scope"] != "global" || envelope.Data.Notice["user_id"] != "42" || envelope.Data.Notice["vip_level"] != float64(9) {
		t.Fatalf("local notice snapshot mismatch: %+v", envelope.Data.Notice)
	}
}

func TestTriggerVIPOnlineNoticeWithoutEquippedFramePublishesExplicitEmptyFields(t *testing.T) {
	walletClient := &fakeWalletClient{
		benefitCheckResp: &walletv1.CheckVipBenefitResponse{
			Allowed: true,
			Benefit: &walletv1.VipBenefit{BenefitCode: "online_global_notice"},
			State:   &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 9, Name: "VIP9", Active: true}},
		},
		batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {UserId: 42, Username: "No Frame"}}}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-no-frame"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK || broadcastClient.lastGlobal == nil {
		t.Fatalf("no-frame request must still broadcast: status=%d body=%s", recorder.Code, recorder.Body.String())
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(broadcastClient.lastGlobal.GetPayloadJson()), &payload); err != nil {
		t.Fatalf("decode broadcast payload: %v", err)
	}
	if payload["avatar_frame_resource_id"] != float64(0) || payload["avatar_frame_url"] != "" || payload["avatar_frame_code"] != "" || payload["avatar_frame_asset_url"] != "" || payload["avatar_frame_preview_url"] != "" || payload["avatar_frame_animation_url"] != "" {
		t.Fatalf("empty avatar frame fields mismatch: %+v", payload)
	}
}

func TestTriggerVIPOnlineNoticeEquippedResourceFailureDoesNotBroadcast(t *testing.T) {
	walletClient := &fakeWalletClient{
		benefitCheckResp: &walletv1.CheckVipBenefitResponse{Allowed: true},
		err:              errors.New("wallet equipped resources unavailable"),
	}
	profileClient := &fakeUserProfileClient{}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-frame-rpc-error"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusBadGateway || walletClient.lastBatchEquipped == nil || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
		t.Fatalf("equipped-resource failure must stop before profile/broadcast: status=%d equipped=%+v profile=%+v broadcast=%+v body=%s", recorder.Code, walletClient.lastBatchEquipped, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
	}
}

func TestTriggerVIPOnlineNoticeDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
	walletClient := &fakeWalletClient{benefitCheckResp: &walletv1.CheckVipBenefitResponse{Allowed: false, DenialReason: "benefit_not_enabled"}}
	profileClient := &fakeUserProfileClient{}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-denied"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
		t.Fatalf("denied request leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
	}
	if !strings.Contains(recorder.Body.String(), `"code":"VIP_BENEFIT_REQUIRED"`) {
		t.Fatalf("denied notice must expose stable VIP code: %s", recorder.Body.String())
	}
}

func TestTriggerVIPOnlineNoticeUserSettingDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
	walletClient := &fakeWalletClient{benefitCheckResp: &walletv1.CheckVipBenefitResponse{
		Allowed: false, DenialReason: "user_setting_disabled",
	}}
	profileClient := &fakeUserProfileClient{}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"setting-disabled"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
		t.Fatalf("user setting denial leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
	}
}

func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T) {
	walletClient := &fakeWalletClient{
		myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{
			EvaluatedAtMs: 1_800_000_000_000,
			UserSettings: &walletv1.VipUserSettings{
				AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true,
				OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_799_999_999_000,
				HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: false,
				LeaderboardInvisibleEnabled: true,
			},
		}},
		updateVIPSettingsResp: &walletv1.UpdateMyVipSettingsResponse{
			Settings: &walletv1.VipUserSettings{
				AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: false,
				OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_800_000_000_100,
				HideProfileDataEnabled: false, AnonymousProfileVisitEnabled: false,
				LeaderboardInvisibleEnabled: true,
			},
			ServerTimeMs: 1_800_000_000_200,
		},
	}
	router := newVIPTestRouter(walletClient)
	token := "Bearer " + signGatewayTokenWithAppCode(t, "secret", 42, "fami")

	getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/settings", nil)
	getRequest.Header.Set("Authorization", token)
	getRecorder := httptest.NewRecorder()
	router.ServeHTTP(getRecorder, getRequest)
	if getRecorder.Code != http.StatusOK {
		t.Fatalf("GET settings status mismatch: got=%d body=%s", getRecorder.Code, getRecorder.Body.String())
	}
	if req := walletClient.lastMyVip; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != getRecorder.Header().Get("X-Request-ID") {
		t.Fatalf("GET settings must use authenticated scope: %+v", req)
	}
	var getEnvelope struct {
		Data struct {
			Settings struct {
				AppCode                      string `json:"app_code"`
				UserID                       int64  `json:"user_id"`
				RoomEntryNoticeEnabled       bool   `json:"room_entry_notice_enabled"`
				OnlineGlobalNoticeEnabled    bool   `json:"online_global_notice_enabled"`
				HideProfileDataEnabled       bool   `json:"hide_profile_data_enabled"`
				AnonymousProfileVisitEnabled bool   `json:"anonymous_profile_visit_enabled"`
				LeaderboardInvisibleEnabled  bool   `json:"leaderboard_invisible_enabled"`
			} `json:"settings"`
			EvaluatedAtMS int64 `json:"evaluated_at_ms"`
		} `json:"data"`
	}
	if err := json.NewDecoder(getRecorder.Body).Decode(&getEnvelope); err != nil {
		t.Fatalf("decode GET settings response failed: %v", err)
	}
	if getEnvelope.Data.Settings.AppCode != "fami" || getEnvelope.Data.Settings.UserID != 42 || !getEnvelope.Data.Settings.RoomEntryNoticeEnabled || getEnvelope.Data.Settings.OnlineGlobalNoticeEnabled || !getEnvelope.Data.Settings.HideProfileDataEnabled || getEnvelope.Data.Settings.AnonymousProfileVisitEnabled || !getEnvelope.Data.Settings.LeaderboardInvisibleEnabled || getEnvelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
		t.Fatalf("GET settings response mismatch: %+v", getEnvelope.Data)
	}

	patchRequest := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{"room_entry_notice_enabled":false,"hide_profile_data_enabled":false}`)))
	patchRequest.Header.Set("Authorization", token)
	patchRecorder := httptest.NewRecorder()
	router.ServeHTTP(patchRecorder, patchRequest)
	if patchRecorder.Code != http.StatusOK {
		t.Fatalf("PATCH settings status mismatch: got=%d body=%s", patchRecorder.Code, patchRecorder.Body.String())
	}
	patch := walletClient.lastUpdateVIPSettings
	if patch == nil || patch.GetAppCode() != "fami" || patch.GetUserId() != 42 || patch.RoomEntryNoticeEnabled == nil || patch.GetRoomEntryNoticeEnabled() || patch.HideProfileDataEnabled == nil || patch.GetHideProfileDataEnabled() || patch.OnlineGlobalNoticeEnabled != nil || patch.AnonymousProfileVisitEnabled != nil || patch.LeaderboardInvisibleEnabled != nil || patch.GetRequestId() != patchRecorder.Header().Get("X-Request-ID") {
		t.Fatalf("PATCH settings lost explicit false or auth scope: %+v", patch)
	}

	// /vip/me 复用同一 DTO 转换，必须把 state.user_settings 一并透出。
	meRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/me", nil)
	meRequest.Header.Set("Authorization", token)
	meRecorder := httptest.NewRecorder()
	router.ServeHTTP(meRecorder, meRequest)
	if meRecorder.Code != http.StatusOK || !strings.Contains(meRecorder.Body.String(), `"user_settings"`) || !strings.Contains(meRecorder.Body.String(), `"online_global_notice_enabled":false`) {
		t.Fatalf("/vip/me missing user settings: status=%d body=%s", meRecorder.Code, meRecorder.Body.String())
	}
}

func TestVIPSettingsEmptyPatchRejectedBeforeWallet(t *testing.T) {
	walletClient := &fakeWalletClient{}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusBadRequest || walletClient.lastUpdateVIPSettings != nil {
		t.Fatalf("empty settings PATCH must fail before wallet: status=%d req=%+v body=%s", recorder.Code, walletClient.lastUpdateVIPSettings, recorder.Body.String())
	}
}

func TestTriggerVIPOnlineNoticeValidatesCommandID(t *testing.T) {
	walletClient := &fakeWalletClient{}
	profileClient := &fakeUserProfileClient{}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	for name, body := range map[string]string{
		"empty":    `{"command_id":""}`,
		"too_long": `{"command_id":"` + strings.Repeat("x", 129) + `"}`,
	} {
		t.Run(name, func(t *testing.T) {
			request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
			recorder := httptest.NewRecorder()
			router.ServeHTTP(recorder, request)
			if recorder.Code != http.StatusBadRequest {
				t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
			}
		})
	}
	if walletClient.lastBenefitCheck != nil || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
		t.Fatal("invalid command must be rejected before downstream calls")
	}
}

func TestVIPCoinRebateHTTPContractUsesAuthenticatedUser(t *testing.T) {
	rebate := &walletv1.VipDailyCoinRebate{
		RebateId: "vip_rebate_01", UserId: 42, TaskDay: "2027-01-15", VipLevel: 9, VipName: "VIP9",
		CoinAmount: 100000, Status: "claimable", AvailableAtMs: 1800000000000, ExpiresAtMs: 1800086400000,
		ConfigVersion: 7, CreatedAtMs: 1800000000100, UpdatedAtMs: 1800000000100,
	}
	walletClient := &fakeWalletClient{
		currentVIPRebateResp:  &walletv1.GetMyCurrentVipDailyCoinRebateResponse{Found: true, Rebate: rebate, ServerTimeMs: 1800000000200},
		vipRebateStatusesResp: &walletv1.ListMyVipDailyCoinRebateStatusesResponse{Rebates: []*walletv1.VipDailyCoinRebate{rebate}, Total: 1, ServerTimeMs: 1800000000300},
		claimVIPRebateResp: &walletv1.ClaimVipDailyCoinRebateResponse{
			Rebate: &walletv1.VipDailyCoinRebate{
				RebateId: rebate.GetRebateId(), UserId: 42, TaskDay: rebate.GetTaskDay(), VipLevel: 9, VipName: "VIP9",
				CoinAmount: 100000, Status: "claimed", AvailableAtMs: rebate.GetAvailableAtMs(), ExpiresAtMs: rebate.GetExpiresAtMs(),
				ConfigVersion: 7, ClaimedAtMs: 1800000000400, WalletTransactionId: "wtx-rebate-1",
			},
			TransactionId: "wtx-rebate-1",
			CoinBalance:   &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 500000, Version: 3},
			ServerTimeMs:  1800000000400,
		},
	}
	router := newVIPTestRouter(walletClient)
	token := "Bearer " + signGatewayTokenWithAppCode(t, "secret", 42, "fami")

	currentRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/coin-rebates/current", nil)
	currentRequest.Header.Set("Authorization", token)
	currentRecorder := httptest.NewRecorder()
	router.ServeHTTP(currentRecorder, currentRequest)
	if currentRecorder.Code != http.StatusOK {
		t.Fatalf("current status mismatch: got %d body=%s", currentRecorder.Code, currentRecorder.Body.String())
	}
	if req := walletClient.lastCurrentVIPRebate; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != currentRecorder.Header().Get("X-Request-ID") {
		t.Fatalf("current rebate request mismatch: %+v", req)
	}
	var currentEnvelope struct {
		Data struct {
			Found  bool `json:"found"`
			Rebate struct {
				RebateID   string `json:"rebate_id"`
				UserID     string `json:"user_id"`
				CoinAmount int64  `json:"coin_amount"`
				Status     string `json:"status"`
			} `json:"rebate"`
		} `json:"data"`
	}
	if err := json.NewDecoder(currentRecorder.Body).Decode(&currentEnvelope); err != nil || !currentEnvelope.Data.Found || currentEnvelope.Data.Rebate.RebateID != rebate.GetRebateId() || currentEnvelope.Data.Rebate.UserID != "42" || currentEnvelope.Data.Rebate.CoinAmount != 100000 || currentEnvelope.Data.Rebate.Status != "claimable" {
		t.Fatalf("current rebate response mismatch: envelope=%+v err=%v", currentEnvelope, err)
	}

	statusesRequest := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/statuses", bytes.NewReader([]byte(`{"rebate_ids":["vip_rebate_01"]}`)))
	statusesRequest.Header.Set("Authorization", token)
	statusesRecorder := httptest.NewRecorder()
	router.ServeHTTP(statusesRecorder, statusesRequest)
	if statusesRecorder.Code != http.StatusOK {
		t.Fatalf("statuses mismatch: got %d body=%s", statusesRecorder.Code, statusesRecorder.Body.String())
	}
	if req := walletClient.lastVIPRebateStatuses; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || len(req.GetRebateIds()) != 1 || req.GetRebateIds()[0] != "vip_rebate_01" {
		t.Fatalf("rebate statuses request mismatch: %+v", req)
	}

	claimRequest := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/vip_rebate_01/claim", bytes.NewReader([]byte(`{"command_id":"claim-rebate-01"}`)))
	claimRequest.Header.Set("Authorization", token)
	claimRecorder := httptest.NewRecorder()
	router.ServeHTTP(claimRecorder, claimRequest)
	if claimRecorder.Code != http.StatusOK {
		t.Fatalf("claim mismatch: got %d body=%s", claimRecorder.Code, claimRecorder.Body.String())
	}
	if req := walletClient.lastClaimVIPRebate; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRebateId() != "vip_rebate_01" || req.GetCommandId() != "claim-rebate-01" {
		t.Fatalf("claim rebate request mismatch: %+v", req)
	}
	var claimEnvelope struct {
		Data struct {
			TransactionID string `json:"transaction_id"`
			Rebate        struct {
				Status string `json:"status"`
			} `json:"rebate"`
			CoinBalance struct {
				AvailableAmount int64 `json:"available_amount"`
			} `json:"coin_balance"`
		} `json:"data"`
	}
	if err := json.NewDecoder(claimRecorder.Body).Decode(&claimEnvelope); err != nil || claimEnvelope.Data.TransactionID != "wtx-rebate-1" || claimEnvelope.Data.Rebate.Status != "claimed" || claimEnvelope.Data.CoinBalance.AvailableAmount != 500000 {
		t.Fatalf("claim response mismatch: envelope=%+v err=%v", claimEnvelope, err)
	}
}

func TestVIPCoinRebateClaimRejectsClientControlledInvalidKeys(t *testing.T) {
	walletClient := &fakeWalletClient{}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/rebate-1/claim", bytes.NewReader([]byte(`{"command_id":""}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusBadRequest || walletClient.lastClaimVIPRebate != nil {
		t.Fatalf("invalid claim must stop before wallet: status=%d req=%+v body=%s", recorder.Code, walletClient.lastClaimVIPRebate, recorder.Body.String())
	}
}

func TestGetMyVIPKeepsLegacyFieldsAndAddsEffectiveState(t *testing.T) {
	config := &walletv1.VipProgramConfig{
		AppCode:                  "fami",
		ProgramType:              "tiered_privilege_v1",
		LevelCount:               9,
		SameLevelExpiryPolicy:    "extend_remaining",
		UpgradeExpiryPolicy:      "replace_from_now",
		DowngradePurchasePolicy:  "reject",
		BenefitInheritancePolicy: "target_only",
		GrantMode:                "trial_card",
		TrialCardEnabled:         true,
		Status:                   "active",
		ConfigVersion:            3,
	}
	state := &walletv1.VipState{
		PaidVip: &walletv1.UserVip{UserId: 42, Level: 3, Name: "VIP3", Active: true, ExpiresAtMs: 2_000, ProgramType: config.GetProgramType(), ConfigVersion: 3},
		EquippedTrialCard: &walletv1.VipTrialCard{
			TrialCardId: "card-4", EntitlementId: "ent-4", UserId: 42, Level: 4, Name: "VIP4体验卡", Equipped: true, ExpiresAtMs: 3_000,
		},
		EffectiveVip:      &walletv1.UserVip{UserId: 42, Level: 4, Name: "VIP4", Active: true, ExpiresAtMs: 3_000, ProgramType: config.GetProgramType(), ConfigVersion: 3},
		EffectiveSource:   "trial",
		EffectiveBenefits: []*walletv1.VipBenefit{{BenefitCode: "room_entry_notice", Name: "进房通知", TrialEnabled: true, Status: "active"}},
		EvaluatedAtMs:     1_500,
		ProgramConfig:     config,
	}
	walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{Vip: state.GetEffectiveVip(), State: state}}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodGet, "/api/v1/vip/me", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	request.Header.Set("X-Request-ID", "req-vip-me")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	generatedRequestID := recorder.Header().Get("X-Request-ID")
	if req := walletClient.lastMyVip; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != generatedRequestID {
		t.Fatalf("GetMyVip request mismatch: %+v", req)
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Level         int32 `json:"level"`
			ConfigVersion int64 `json:"config_version"`
			ProgramConfig struct {
				ProgramType      string `json:"program_type"`
				TrialCardEnabled bool   `json:"trial_card_enabled"`
			} `json:"program_config"`
			State struct {
				EffectiveSource string `json:"effective_source"`
				PaidVIP         struct {
					Level int32 `json:"level"`
				} `json:"paid_vip"`
				EffectiveVIP struct {
					Level int32 `json:"level"`
				} `json:"effective_vip"`
				EquippedTrialCard struct {
					EntitlementID string `json:"entitlement_id"`
				} `json:"equipped_trial_card"`
				EffectiveBenefits []struct {
					BenefitCode string `json:"benefit_code"`
				} `json:"effective_benefits"`
				UserSettings struct {
					RoomEntryNoticeEnabled       bool `json:"room_entry_notice_enabled"`
					OnlineGlobalNoticeEnabled    bool `json:"online_global_notice_enabled"`
					HideProfileDataEnabled       bool `json:"hide_profile_data_enabled"`
					AnonymousProfileVisitEnabled bool `json:"anonymous_profile_visit_enabled"`
					LeaderboardInvisibleEnabled  bool `json:"leaderboard_invisible_enabled"`
				} `json:"user_settings"`
			} `json:"state"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.Level != 4 || envelope.Data.ConfigVersion != 3 {
		t.Fatalf("legacy VIP fields mismatch: %+v", envelope.Data)
	}
	if envelope.Data.ProgramConfig.ProgramType != "tiered_privilege_v1" || !envelope.Data.ProgramConfig.TrialCardEnabled {
		t.Fatalf("program config mismatch: %+v", envelope.Data.ProgramConfig)
	}
	if envelope.Data.State.PaidVIP.Level != 3 || envelope.Data.State.EffectiveVIP.Level != 4 || envelope.Data.State.EffectiveSource != "trial" || envelope.Data.State.EquippedTrialCard.EntitlementID != "ent-4" {
		t.Fatalf("effective state mismatch: %+v", envelope.Data.State)
	}
	if len(envelope.Data.State.EffectiveBenefits) != 1 || envelope.Data.State.EffectiveBenefits[0].BenefitCode != "room_entry_notice" {
		t.Fatalf("effective benefits mismatch: %+v", envelope.Data.State.EffectiveBenefits)
	}
	settings := envelope.Data.State.UserSettings
	if !settings.RoomEntryNoticeEnabled || !settings.OnlineGlobalNoticeEnabled || settings.HideProfileDataEnabled || settings.AnonymousProfileVisitEnabled || settings.LeaderboardInvisibleEnabled {
		t.Fatalf("legacy wallet fallback must keep notice-on privacy-off defaults: %+v", settings)
	}
}

func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
	config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9, ConfigVersion: 7, TrialCardEnabled: true}
	walletClient := &fakeWalletClient{vipPackagesResp: &walletv1.ListVipPackagesResponse{
		CurrentVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true},
		Packages: []*walletv1.VipLevel{{
			Level: 3, Name: "VIP3", PriceCoin: 300, DurationMs: 2_592_000_000, CanPurchase: true, ConfigVersion: 7,
			Benefits: []*walletv1.VipBenefit{{
				BenefitCode: "voice_wave", Name: "语音波纹", ExecutionScope: "room", TrialEnabled: true,
				Presentation: &walletv1.VipBenefitPresentation{
					Description:  "动态声波纹预览",
					PreviewItems: []*walletv1.VipBenefitPreviewItem{{PreviewId: "wave-main", MediaType: "animation", AnimationUrl: "https://cdn.example/wave.svga", CompositionType: "user_avatar_waveform"}},
				},
			}},
		}},
		State:         &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
		ProgramConfig: config,
	}}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodGet, "/api/v1/vip/packages", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if req := walletClient.lastVipPackages; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != recorder.Header().Get("X-Request-ID") {
		t.Fatalf("ListVipPackages request mismatch: %+v", req)
	}
	var envelope struct {
		Data struct {
			CurrentVIP struct {
				Level int32 `json:"level"`
			} `json:"current_vip"`
			ProgramConfig struct {
				LevelCount int32 `json:"level_count"`
			} `json:"program_config"`
			State struct {
				EffectiveSource string `json:"effective_source"`
			} `json:"state"`
			Packages []struct {
				ConfigVersion int64 `json:"config_version"`
				Benefits      []struct {
					BenefitCode  string `json:"benefit_code"`
					Presentation struct {
						Description  string `json:"description"`
						PreviewItems []struct {
							CompositionType string `json:"composition_type"`
						} `json:"preview_items"`
					} `json:"presentation"`
				} `json:"benefits"`
			} `json:"packages"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if envelope.Data.CurrentVIP.Level != 2 || envelope.Data.ProgramConfig.LevelCount != 9 || envelope.Data.State.EffectiveSource != "paid" || len(envelope.Data.Packages) != 1 {
		t.Fatalf("packages response mismatch: %+v", envelope.Data)
	}
	if envelope.Data.Packages[0].ConfigVersion != 7 || len(envelope.Data.Packages[0].Benefits) != 1 || envelope.Data.Packages[0].Benefits[0].BenefitCode != "voice_wave" {
		t.Fatalf("package benefits mismatch: %+v", envelope.Data.Packages)
	}
	if envelope.Data.Packages[0].Benefits[0].Presentation.Description != "动态声波纹预览" || len(envelope.Data.Packages[0].Benefits[0].Presentation.PreviewItems) != 1 || envelope.Data.Packages[0].Benefits[0].Presentation.PreviewItems[0].CompositionType != "user_avatar_waveform" {
		t.Fatalf("structured presentation mismatch: %+v", envelope.Data.Packages[0].Benefits[0].Presentation)
	}
}

func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
	config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", UpgradeExpiryPolicy: "replace_from_now", ConfigVersion: 8}
	walletClient := &fakeWalletClient{purchaseVipResp: &walletv1.PurchaseVipResponse{
		OrderId: "vip-order-1", TransactionId: "vip-tx-1", Vip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true, ExpiresAtMs: 4_000}, CoinSpent: 400, CoinBalanceAfter: 600,
		CoinBalance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 600, FrozenAmount: 0, Version: 18},
		State:       &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveSource: "paid", ProgramConfig: config, UserSettings: &walletv1.VipUserSettings{AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true, OnlineGlobalNoticeEnabled: true, HideProfileDataEnabled: true, AnonymousProfileVisitEnabled: true, LeaderboardInvisibleEnabled: true}},
	}}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/purchase", bytes.NewReader([]byte(`{"command_id":"vip-buy-4","level":4}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
	if req := walletClient.lastPurchaseVip; req == nil || req.GetCommandId() != "vip-buy-4" || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetLevel() != 4 {
		t.Fatalf("PurchaseVip request mismatch: %+v", req)
	}
	var envelope struct {
		Data struct {
			OrderID       string `json:"order_id"`
			TransactionID string `json:"transaction_id"`
			VIP           struct {
				Level int32 `json:"level"`
			} `json:"vip"`
			ProgramConfig struct {
				UpgradeExpiryPolicy string `json:"upgrade_expiry_policy"`
			} `json:"program_config"`
			State struct {
				EffectiveSource string `json:"effective_source"`
			} `json:"state"`
			CoinBalance struct {
				AvailableAmount int64 `json:"available_amount"`
				Version         int64 `json:"version"`
			} `json:"coin_balance"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if envelope.Data.OrderID != "vip-order-1" || envelope.Data.TransactionID != "vip-tx-1" || envelope.Data.VIP.Level != 4 || envelope.Data.ProgramConfig.UpgradeExpiryPolicy != "replace_from_now" || envelope.Data.State.EffectiveSource != "paid" || envelope.Data.CoinBalance.AvailableAmount != 600 || envelope.Data.CoinBalance.Version != 18 {
		t.Fatalf("purchase response mismatch: %+v", envelope.Data)
	}
}

func TestEquipVIPTrialCardUsesAuthenticatedEntitlement(t *testing.T) {
	config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", TrialCardEnabled: true}
	walletClient := &fakeWalletClient{trialEquipResp: &walletv1.EquipVipTrialCardResponse{
		TrialCard:    &walletv1.VipTrialCard{TrialCardId: "card-9", EntitlementId: "ent-card-9", UserId: 42, Level: 9, Equipped: true, ExpiresAtMs: 9_000},
		State:        &walletv1.VipState{EquippedTrialCard: &walletv1.VipTrialCard{EntitlementId: "ent-card-9", Level: 9, Equipped: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 9, Active: true}, EffectiveSource: "trial", ProgramConfig: config},
		ServerTimeMs: 1_800,
	}}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/trial-cards/ent-card-9/equip", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
	if req := walletClient.lastTrialEquip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetEntitlementId() != "ent-card-9" {
		t.Fatalf("EquipVipTrialCard request mismatch: %+v", req)
	}
	var envelope struct {
		Data struct {
			TrialCard struct {
				EntitlementID string `json:"entitlement_id"`
			} `json:"trial_card"`
			State struct {
				EffectiveSource string `json:"effective_source"`
			} `json:"state"`
			ServerTimeMS int64 `json:"server_time_ms"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if envelope.Data.TrialCard.EntitlementID != "ent-card-9" || envelope.Data.State.EffectiveSource != "trial" || envelope.Data.ServerTimeMS != 1_800 {
		t.Fatalf("equip response mismatch: %+v", envelope.Data)
	}
}

func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
	config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", TrialCardEnabled: true}
	walletClient := &fakeWalletClient{trialUnequipResp: &walletv1.UnequipVipTrialCardResponse{
		Unequipped:   true,
		State:        &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 3, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 3, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
		ServerTimeMs: 1_900,
	}}
	router := newVIPTestRouter(walletClient)
	request := httptest.NewRequest(http.MethodDelete, "/api/v1/vip/trial-cards/equipped", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	assertCompleteVIPStateJSON(t, recorder.Body.Bytes())
	if req := walletClient.lastTrialUnequip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 {
		t.Fatalf("UnequipVipTrialCard request mismatch: %+v", req)
	}
	var envelope struct {
		Data struct {
			Unequipped bool `json:"unequipped"`
			State      struct {
				EffectiveSource string `json:"effective_source"`
				EffectiveVIP    struct {
					Level int32 `json:"level"`
				} `json:"effective_vip"`
			} `json:"state"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if !envelope.Data.Unequipped || envelope.Data.State.EffectiveSource != "paid" || envelope.Data.State.EffectiveVIP.Level != 3 {
		t.Fatalf("unequip response mismatch: %+v", envelope.Data)
	}
}

// assertCompleteVIPStateJSON 锁定所有 VIP 写接口的 state 与 /vip/me.state 同构；即使值为空，
// 字段也必须存在，Flutter 才能用响应原子替换本地状态而不是做不安全的局部合并。
func assertCompleteVIPStateJSON(t *testing.T, body []byte) {
	t.Helper()
	var envelope struct {
		Data struct {
			State map[string]json.RawMessage `json:"state"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &envelope); err != nil {
		t.Fatalf("decode complete state envelope: %v", err)
	}
	for _, field := range []string{"paid_vip", "equipped_trial_card", "effective_vip", "effective_source", "effective_benefits", "evaluated_at_ms", "program_config", "user_settings"} {
		if _, exists := envelope.Data.State[field]; !exists {
			t.Fatalf("VIP state missing %s: %s", field, body)
		}
	}
}
