package routes

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/bytedance/sonic"
	"github.com/gofiber/fiber/v2"
	"github.com/redis/go-redis/v9"
	"go.uber.org/zap"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"

	"github.com/break/junhong_cmp_fiber/internal/bootstrap"
	"github.com/break/junhong_cmp_fiber/internal/handler/admin"
	apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
	internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
	"github.com/break/junhong_cmp_fiber/internal/model"
	assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
	packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
	assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
	customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
	deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
	iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
	"github.com/break/junhong_cmp_fiber/internal/store/postgres"
	"github.com/break/junhong_cmp_fiber/pkg/auth"
	"github.com/break/junhong_cmp_fiber/pkg/config"
	"github.com/break/junhong_cmp_fiber/pkg/constants"
	"github.com/break/junhong_cmp_fiber/pkg/database"
	"github.com/break/junhong_cmp_fiber/pkg/errors"
	pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)

const (
	// testListPageSize 是列表批量验收的代表性每页资产数量。
	testListPageSize = 100
	// testHTTPPerformanceLimit 是单次集成请求可验证的项目 API P99 响应耗时上限。
	testHTTPPerformanceLimit = 500 * time.Millisecond
)

// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	visibleShop := env.createShop(t, "UR86 可见店铺", nil, 1)
	hiddenShop := env.createShop(t, "UR86 不可见店铺", nil, 1)
	oldCard := env.createCard(t, 1, &hiddenShop.ID)
	middleCard := env.createCard(t, 2, &visibleShop.ID)
	nextCard := env.createCard(t, 3, &visibleShop.ID)
	env.createOrder(t, "UR86-HTTP-PREV", oldCard, middleCard, "HTTP 历史前代快照", "HTTP 中间快照", time.Now().Add(-time.Minute))
	env.createOrder(t, "UR86-HTTP-NEXT", middleCard, nextCard, "HTTP 中间快照", "HTTP 历史后代快照", time.Now())
	token := env.newAgentToken(t, visibleShop.ID)

	status, body := env.request(t, "/api/admin/assets/resolve/"+middleCard.ICCID, token)
	if status != http.StatusOK {
		t.Fatalf("查询中间资产失败，状态 %d：%s", status, body)
	}
	var success struct {
		Code int `json:"code"`
		Data struct {
			AssetID       uint `json:"asset_id"`
			ExchangeTrace struct {
				PreviousAsset *struct {
					AssetID    *uint  `json:"asset_id"`
					Identifier string `json:"identifier"`
					ExchangeNo string `json:"exchange_no"`
					CanView    bool   `json:"can_view"`
				} `json:"previous_asset"`
				NextAsset *struct {
					AssetID    *uint  `json:"asset_id"`
					Identifier string `json:"identifier"`
					ExchangeNo string `json:"exchange_no"`
					CanView    bool   `json:"can_view"`
				} `json:"next_asset"`
			} `json:"exchange_trace"`
		} `json:"data"`
	}
	if err := sonic.Unmarshal(body, &success); err != nil {
		t.Fatalf("解析资产详情响应失败：%v", err)
	}
	if success.Code != errors.CodeSuccess || success.Data.AssetID != middleCard.ID || success.Data.ExchangeTrace.PreviousAsset == nil || success.Data.ExchangeTrace.NextAsset == nil {
		t.Fatalf("双向换货响应不完整：%s", body)
	}
	previous := success.Data.ExchangeTrace.PreviousAsset
	if previous.CanView || previous.AssetID != nil || previous.Identifier != "HTTP 历史前代快照" || previous.ExchangeNo != "UR86-HTTP-PREV" {
		t.Fatalf("不可见前代未按契约隐藏 ID：%s", body)
	}
	next := success.Data.ExchangeTrace.NextAsset
	if !next.CanView || next.AssetID == nil || *next.AssetID != nextCard.ID || next.Identifier != "HTTP 历史后代快照" || next.ExchangeNo != "UR86-HTTP-NEXT" {
		t.Fatalf("可见后代未按契约返回：%s", body)
	}

	noTraceCard := env.createCard(t, 4, &visibleShop.ID)
	status, body = env.request(t, "/api/admin/assets/resolve/"+noTraceCard.ICCID, token)
	assertHTTPTraceDirections(t, status, body, false, false)

	previousOnlyOld := env.createCard(t, 5, &visibleShop.ID)
	previousOnlyNew := env.createCard(t, 6, &visibleShop.ID)
	env.createOrder(t, "UR86-HTTP-PREV-ONLY", previousOnlyOld, previousOnlyNew, "仅前代旧快照", "仅前代新快照", time.Now())
	status, body = env.request(t, "/api/admin/assets/resolve/"+previousOnlyNew.ICCID, token)
	assertHTTPTraceDirections(t, status, body, true, false)

	nextOnlyOld := env.createCard(t, 7, &visibleShop.ID)
	nextOnlyNew := env.createCard(t, 8, &visibleShop.ID)
	env.createOrder(t, "UR86-HTTP-NEXT-ONLY", nextOnlyOld, nextOnlyNew, "仅后代旧快照", "仅后代新快照", time.Now())
	status, body = env.request(t, "/api/admin/assets/resolve/"+nextOnlyOld.ICCID, token)
	assertHTTPTraceDirections(t, status, body, false, true)

	status, body = env.request(t, "/api/admin/assets/resolve/"+oldCard.ICCID, token)
	if status != http.StatusNotFound {
		t.Fatalf("当前资产无权限时应维持防枚举响应，状态 %d：%s", status, body)
	}
	var denied struct {
		Code int `json:"code"`
		Data any `json:"data"`
	}
	if err := sonic.Unmarshal(body, &denied); err != nil || denied.Code != errors.CodeNotFound || denied.Data != nil {
		t.Fatalf("当前资产无权限响应不符合原契约：%s", body)
	}
}

// TestAssetResolveHTTPReturnsPackageExpiryEstimate 验证后台详情通过真实 JWT、Redis、Fiber 与 PostgreSQL 返回预计最终到期字段。
func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	shop := env.createShop(t, "UR46详情店铺", nil, 1)
	card := env.createCard(t, 46, &shop.ID)
	now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
	expiresAt := now.AddDate(0, 0, 5)
	usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-HTTP", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
	if err := createExpiryHTTPUsage(env.db, usage); err != nil {
		t.Fatalf("创建详情套餐使用记录失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, usage.ID, constants.PackageUsageStatusActive)
	status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
	if status != http.StatusOK {
		t.Fatalf("查询资产详情失败：%d %s", status, body)
	}
	var response struct {
		Data struct {
			ExpiresAt *time.Time `json:"estimated_final_expires_at"`
			Days      *int       `json:"days_until_final_expiry"`
			Status    string     `json:"expiry_estimate_status"`
			Name      string     `json:"expiry_estimate_status_name"`
			Expiring  bool       `json:"is_expiring"`
		} `json:"data"`
	}
	if err := sonic.Unmarshal(body, &response); err != nil {
		t.Fatalf("解析预计到期响应失败：%v", err)
	}
	if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
		t.Fatalf("预计到期字段不完整：%s", body)
	}

	device := env.createDevice(t, 46, &shop.ID)
	deviceUsage := newExpiryHTTPUsage(device.ID, constants.AssetTypeDevice, constants.PackageUsageStatusActive, &expiresAt, false, now)
	if err := createExpiryHTTPUsage(env.db, deviceUsage); err != nil {
		t.Fatalf("创建设备详情套餐使用记录失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, deviceUsage.ID, constants.PackageUsageStatusActive)
	status, body = env.request(t, "/api/admin/assets/resolve/"+device.VirtualNo, env.newAgentToken(t, shop.ID))
	assertExpiryHTTPStatus(t, status, body, constants.PackageExpiryEstimateStatusExact, true)
}

// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义。
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	shop := env.createShop(t, "UR46四状态店铺", nil, 1)
	token := env.newAgentToken(t, shop.ID)
	now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
	for typeIndex, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
		for statusIndex, estimateStatus := range []string{constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData} {
			name := assetType + "/" + estimateStatus
			t.Run(name, func(t *testing.T) {
				asset := createAdminExpiryTestAsset(t, env, assetType, 60+typeIndex*10+statusIndex, &shop.ID)
				if estimateStatus != constants.PackageExpiryEstimateStatusNone {
					usageStatus, waiting := constants.PackageUsageStatusActive, false
					if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
						usageStatus, waiting = constants.PackageUsageStatusPending, true
					}
					usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, nil, waiting, now)
					if err := createExpiryHTTPUsage(env.db, usage); err != nil {
						t.Fatalf("创建状态套餐失败：%v", err)
					}
					assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
				}
				status, body := env.request(t, "/api/admin/assets/resolve/"+asset.identifier, token)
				assertExpiryHTTPStatus(t, status, body, estimateStatus, false)
			})
		}
	}
}

func createAdminExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, shopID *uint) clientExpiryTestAsset {
	t.Helper()
	if assetType == constants.AssetTypeDevice {
		device := env.createDevice(t, suffix, shopID)
		return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
	}
	card := env.createCard(t, suffix, shopID)
	return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}

// TestAssetResolveHTTPReflectsQueueChangesImmediately 验证新增、退款和失效队列后无需刷新资产快照。
func TestAssetResolveHTTPReflectsQueueChangesImmediately(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	shop := env.createShop(t, "UR46实时变化店铺", nil, 1)
	for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
		t.Run(assetType, func(t *testing.T) {
			asset := createAdminExpiryTestAsset(t, env, assetType, 63+index, &shop.ID)
			verifyAdminQueueChanges(t, env, asset, shop.ID)
		})
	}
}

// verifyAdminQueueChanges 验证后台详情实时反映指定资产的套餐队列变化。
func verifyAdminQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset, shopID uint) {
	t.Helper()
	now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
	currentExpiry := now.AddDate(0, 0, 5)
	current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
	if err := createExpiryHTTPUsage(env.db, current); err != nil {
		t.Fatalf("创建当前套餐失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
	token := env.newAgentToken(t, shopID)
	initial := requestExpiryEstimate(t, env, asset.identifier, token)
	queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
	queued.Priority = 2
	queued.PackageID = asset.assetID + 1
	queued.OrderNo = fmt.Sprintf("UR46-Q-%s-%d", asset.assetType, asset.assetID)
	if err := createExpiryHTTPUsage(env.db, queued); err != nil {
		t.Fatalf("创建排队套餐失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
	extended := requestExpiryEstimate(t, env, asset.identifier, token)
	if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
		t.Fatalf("新增排队套餐后预计到期未延长：initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
	}
	refundID := uint(990001)
	if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
		t.Fatalf("设置排队套餐退款状态失败：%v", err)
	}
	assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
	refunded := requestExpiryEstimate(t, env, asset.identifier, token)
	if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
		t.Fatalf("退款后预计到期未实时恢复：initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
	}
	if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
		t.Fatalf("设置排队套餐失效状态失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
	invalidated := requestExpiryEstimate(t, env, asset.identifier, token)
	if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
		t.Fatalf("失效后预计到期未实时恢复：initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
	}
}

type expiryHTTPResult struct {
	ExpiresAt *time.Time `json:"estimated_final_expires_at"`
	Days      *int       `json:"days_until_final_expiry"`
	Status    string     `json:"expiry_estimate_status"`
	Name      string     `json:"expiry_estimate_status_name"`
	Expiring  bool       `json:"is_expiring"`
}

// requestExpiryEstimate 请求后台统一资产详情并验证预计到期字段的 JSON 契约。
func requestExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, identifier, token string) expiryHTTPResult {
	t.Helper()
	status, body := env.request(t, "/api/admin/assets/resolve/"+identifier, token)
	if status != http.StatusOK {
		t.Fatalf("查询预计到期失败：%d %s", status, body)
	}
	var response struct {
		Data expiryHTTPResult `json:"data"`
	}
	if err := sonic.Unmarshal(body, &response); err != nil {
		t.Fatalf("解析预计到期响应失败：%v", err)
	}
	assertExpiryJSONNullable(t, body, response.Data.Status == constants.PackageExpiryEstimateStatusExact)
	return response.Data
}

func newExpiryHTTPUsage(assetID uint, assetType string, status int, expiresAt *time.Time, waiting bool, now time.Time) *model.PackageUsage {
	usage := &model.PackageUsage{OrderID: assetID, OrderNo: fmt.Sprintf("UR46-STATUS-%s-%d", assetType, assetID), PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: 1, ActivatedAt: &now, ExpiresAt: expiresAt, PendingRealnameActivation: waiting, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
	if assetType == constants.AssetTypeDevice {
		usage.DeviceID = assetID
	} else {
		usage.IotCardID = assetID
	}
	return usage
}

func createExpiryHTTPUsage(db *gorm.DB, usage *model.PackageUsage) error {
	desiredStatus := usage.Status
	pendingRealname := usage.PendingRealnameActivation
	if err := db.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
		return err
	}
	usage.Status = desiredStatus
	usage.PendingRealnameActivation = pendingRealname
	return db.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error
}

// assertExpiryHTTPStatus 验证详情状态、中文名称、临期标记及 nullable 语义。
func assertExpiryHTTPStatus(t *testing.T, status int, body []byte, expected string, exact bool) {
	t.Helper()
	if status != http.StatusOK {
		t.Fatalf("预计到期接口失败：%d %s", status, body)
	}
	var response struct {
		Data struct {
			ExpiresAt *time.Time `json:"estimated_final_expires_at"`
			Days      *int       `json:"days_until_final_expiry"`
			Status    string     `json:"expiry_estimate_status"`
			Name      string     `json:"expiry_estimate_status_name"`
			Expiring  bool       `json:"is_expiring"`
		} `json:"data"`
	}
	if err := sonic.Unmarshal(body, &response); err != nil {
		t.Fatalf("解析预计到期响应失败：%v", err)
	}
	if response.Data.Status != expected || response.Data.Name == "" {
		t.Fatalf("预计到期状态错误：want=%s body=%s", expected, body)
	}
	if exact != (response.Data.ExpiresAt != nil && response.Data.Days != nil) {
		t.Fatalf("预计到期 nullable 语义错误：body=%s", body)
	}
	if response.Data.Expiring != exact {
		t.Fatalf("预计到期临期标记错误：exact=%v body=%s", exact, body)
	}
	assertExpiryJSONNullable(t, body, exact)
}

// assertExpiryJSONNullable 验证日期和天数字段必须存在，并按状态显式返回值或 null。
func assertExpiryJSONNullable(t *testing.T, body []byte, exact bool) {
	t.Helper()
	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := sonic.Unmarshal(body, &payload); err != nil {
		t.Fatalf("解析预计到期原始字段失败：%v", err)
	}
	expiresAt, expiresAtExists := payload.Data["estimated_final_expires_at"]
	days, daysExists := payload.Data["days_until_final_expiry"]
	if !expiresAtExists || !daysExists || exact != (expiresAt != nil && days != nil) {
		t.Fatalf("预计到期字段必须存在并显式返回值或 null：exact=%v body=%s", exact, body)
	}
}

func assertPackageUsagePersisted(t *testing.T, db *gorm.DB, usageID uint, expectedStatus int) {
	t.Helper()
	var usage model.PackageUsage
	if err := db.First(&usage, usageID).Error; err != nil || usage.Status != expectedStatus {
		t.Fatalf("套餐使用记录数据库状态错误：id=%d status=%d err=%v", usageID, usage.Status, err)
	}
}

func assertPackageUsageRefunded(t *testing.T, db *gorm.DB, usageID, expectedRefundID uint) {
	t.Helper()
	var usage model.PackageUsage
	if err := db.First(&usage, usageID).Error; err != nil || usage.RefundID == nil || *usage.RefundID != expectedRefundID {
		t.Fatalf("套餐退款数据库状态错误：id=%d refund_id=%v err=%v", usageID, usage.RefundID, err)
	}
}

// TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses 验证 C 端卡、设备四状态与后台口径一致。
func TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
	assets := append(createClientExpiryStatusAssets(t, env, constants.AssetTypeIotCard, 70, now), createClientExpiryStatusAssets(t, env, constants.AssetTypeDevice, 80, now)...)
	adminToken := env.newAgentToken(t, 0)
	for _, asset := range assets {
		client := requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
		admin := requestExpiryEstimate(t, env, asset.identifier, adminToken)
		if client.Status != asset.status || client.Status != admin.Status || client.Name == "" || client.Expiring != admin.Expiring || !timePointersEqualRoute(client.ExpiresAt, admin.ExpiresAt) || !intPointersEqualRoute(client.Days, admin.Days) {
			t.Fatalf("C 端与后台预计到期不一致：asset=%s client=%+v admin=%+v", asset.identifier, client, admin)
		}
		exact := asset.status == constants.PackageExpiryEstimateStatusExact
		if exact != (client.ExpiresAt != nil && client.Days != nil) || exact != client.Expiring {
			t.Fatalf("C 端 nullable 或临期标记错误：asset=%s result=%+v", asset.identifier, client)
		}
	}
}

type clientExpiryTestAsset struct {
	assetType  string
	assetID    uint
	virtualNo  string
	identifier string
	status     string
}

// createClientExpiryStatusAssets 为指定资产类型创建预计到期四状态夹具。
func createClientExpiryStatusAssets(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, now time.Time) []clientExpiryTestAsset {
	t.Helper()
	statuses := []string{constants.PackageExpiryEstimateStatusExact, constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData}
	assets := make([]clientExpiryTestAsset, 0, len(statuses))
	for index, estimateStatus := range statuses {
		asset := createClientExpiryTestAsset(t, env, assetType, suffix+index)
		asset.status = estimateStatus
		if estimateStatus != constants.PackageExpiryEstimateStatusNone {
			usageStatus, waiting := constants.PackageUsageStatusActive, false
			var expiresAt *time.Time
			if estimateStatus == constants.PackageExpiryEstimateStatusExact {
				value := now.AddDate(0, 0, 5)
				expiresAt = &value
			} else if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
				usageStatus, waiting = constants.PackageUsageStatusPending, true
			}
			usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, expiresAt, waiting, now)
			if err := createExpiryHTTPUsage(env.db, usage); err != nil {
				t.Fatalf("创建 C 端状态套餐失败：%v", err)
			}
			assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
		}
		assets = append(assets, asset)
	}
	return assets
}

func createClientExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int) clientExpiryTestAsset {
	t.Helper()
	if assetType == constants.AssetTypeDevice {
		device := env.createDevice(t, suffix, nil)
		return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
	}
	card := env.createCard(t, suffix, nil)
	return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}

// TestClientAssetInfoHTTPReflectsQueueChangesImmediately 验证 C 端实时反映套餐新增、退款和失效。
func TestClientAssetInfoHTTPReflectsQueueChangesImmediately(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
		t.Run(assetType, func(t *testing.T) {
			asset := createClientExpiryTestAsset(t, env, assetType, 90+index)
			verifyClientQueueChanges(t, env, asset)
		})
	}
}

// verifyClientQueueChanges 验证 C 端详情实时反映指定资产的套餐队列变化。
func verifyClientQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset) {
	t.Helper()
	now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
	currentExpiry := now.AddDate(0, 0, 5)
	current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
	if err := createExpiryHTTPUsage(env.db, current); err != nil {
		t.Fatalf("创建 C 端当前套餐失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
	request := func() expiryHTTPResult {
		return requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
	}
	initial := request()
	queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
	queued.PackageID = asset.assetID + 1
	queued.OrderNo = fmt.Sprintf("UR46-CQ-%s-%d", asset.assetType, asset.assetID)
	queued.Priority = 2
	if err := createExpiryHTTPUsage(env.db, queued); err != nil {
		t.Fatalf("创建 C 端排队套餐失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
	extended := request()
	if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
		t.Fatalf("C 端新增套餐后预计到期未延长：initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
	}
	refundID := uint(990002)
	if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
		t.Fatalf("设置 C 端排队套餐退款失败：%v", err)
	}
	assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
	refunded := request()
	if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
		t.Fatalf("C 端退款后预计到期未实时恢复：initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
	}
	if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
		t.Fatalf("设置 C 端排队套餐失效失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
	invalidated := request()
	if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
		t.Fatalf("C 端失效后预计到期未实时恢复：initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
	}
}

// requestClientExpiryEstimate 创建真实客户认证和绑定后请求 C 端资产信息。
func requestClientExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, virtualNo, identifier string) expiryHTTPResult {
	t.Helper()
	now := time.Now()
	customer := &model.PersonalCustomer{WxOpenID: fmt.Sprintf("ur46-client-%d-%d", assetID, time.Now().UnixNano()), WxUnionID: fmt.Sprintf("ur46-union-%d", assetID), Status: constants.StatusEnabled}
	if err := env.db.Create(customer).Error; err != nil {
		t.Fatalf("创建 C 端客户失败：%v", err)
	}
	binding := &model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: virtualNo, BindAt: now, Status: constants.StatusEnabled}
	if err := env.db.Create(binding).Error; err != nil {
		t.Fatalf("创建 C 端资产绑定失败：%v", err)
	}
	jwtManager := auth.NewJWTManager("ur46-client-status-secret", time.Hour)
	token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", assetType, assetID)
	if err != nil {
		t.Fatalf("生成 C 端 JWT 失败：%v", err)
	}
	if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
		t.Fatalf("写入 C 端 Token 失败：%v", err)
	}
	t.Cleanup(func() {
		_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
	})
	cardStore := postgres.NewIotCardStore(env.db, env.redis)
	deviceStore := postgres.NewDeviceStore(env.db, env.redis)
	handler := apphandler.NewClientAssetHandler(env.assetService, customerBinding.New(env.db, cardStore, deviceStore), postgres.NewAssetWalletStore(env.db, env.redis), postgres.NewPackageStore(env.db), postgres.NewShopPackageAllocationStore(env.db), cardStore, deviceStore, env.db, zap.NewNop())
	app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
	app.Get("/api/c/v1/asset/info", internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()).Authenticate(), handler.GetAssetInfo)
	request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+identifier, nil)
	if err != nil {
		t.Fatalf("创建 C 端请求失败：%v", err)
	}
	request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
	response, err := app.Test(request, -1)
	if err != nil {
		t.Fatalf("执行 C 端请求失败：%v", err)
	}
	defer response.Body.Close()
	body, _ := io.ReadAll(response.Body)
	if response.StatusCode != http.StatusOK {
		t.Fatalf("C 端预计到期响应失败：%d %s", response.StatusCode, body)
	}
	var payload struct {
		Data expiryHTTPResult `json:"data"`
	}
	if err := sonic.Unmarshal(body, &payload); err != nil {
		t.Fatalf("解析 C 端预计到期响应失败：%v", err)
	}
	assertExpiryJSONNullable(t, body, payload.Data.Status == constants.PackageExpiryEstimateStatusExact)
	return payload.Data
}

func timePointersEqualRoute(left, right *time.Time) bool {
	return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
}

func intPointersEqualRoute(left, right *int) bool {
	return left == nil && right == nil || left != nil && right != nil && *left == *right
}

type routeSQLCounterLogger struct {
	logger.Interface
	queries []string
}

func (l *routeSQLCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
	sql, rows := fc()
	l.queries = append(l.queries, sql)
	l.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
}

func (l *routeSQLCounterLogger) Reset() {
	l.queries = nil
}

func (l *routeSQLCounterLogger) Total() int {
	return len(l.queries)
}

func (l *routeSQLCounterLogger) CountContaining(fragment string) int {
	count := 0
	for _, query := range l.queries {
		if strings.Contains(query, fragment) {
			count++
		}
	}
	return count
}

// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
	env := newAssetTraceHTTPEnv(t)
	counter := &routeSQLCounterLogger{Interface: env.db.Logger}
	db := env.db.Session(&gorm.Session{Logger: counter})
	registerPackageExpiryListRoutes(env, db)
	fixture := createPackageExpiryListFixtures(t, env)
	token := env.newAgentToken(t, 0)
	for _, testCase := range []packageExpiryListCase{
		{path: "/api/admin/iot-cards/standalone", assetType: constants.AssetTypeIotCard, identifier: fixture.lastCardIdentifier, orderedIDs: fixture.cardIDs, packageQueries: 1},
		{path: "/api/admin/devices", assetType: constants.AssetTypeDevice, identifier: fixture.lastDeviceIdentifier, orderedIDs: fixture.deviceIDs, packageQueries: 2},
	} {
		t.Run(testCase.assetType, func(t *testing.T) {
			verifyPackageExpiryList(t, env, counter, fixture, testCase, token)
		})
	}
}

type packageExpiryListFixture struct {
	batchNo              string
	cardIDs              []uint
	deviceIDs            []uint
	statusIDs            map[string]map[string]uint
	lastCardIdentifier   string
	lastDeviceIdentifier string
}

type packageExpiryListCase struct {
	path           string
	assetType      string
	identifier     string
	orderedIDs     []uint
	packageQueries int
}

type packageExpiryListItem struct {
	ID          uint       `json:"id"`
	ExpiresAt   *time.Time `json:"estimated_final_expires_at"`
	Days        *int       `json:"days_until_final_expiry"`
	Status      string     `json:"expiry_estimate_status"`
	StatusName  string     `json:"expiry_estimate_status_name"`
	IsExpiring  bool       `json:"is_expiring"`
	fieldsExist bool
	fieldsNull  bool
}

type packageExpiryListPayload struct {
	Data struct {
		Items []packageExpiryListItem `json:"items"`
		Total int64                   `json:"total"`
		Page  int                     `json:"page"`
		Size  int                     `json:"size"`
	} `json:"data"`
}

// registerPackageExpiryListRoutes 使用计数数据库会话注册卡和设备列表路由。
func registerPackageExpiryListRoutes(env *assetTraceHTTPEnv, db *gorm.DB) {
	shopStore := postgres.NewShopStore(db, env.redis)
	cardStore := postgres.NewIotCardStore(db, nil)
	deviceStore := postgres.NewDeviceStore(db, nil)
	seriesStore := postgres.NewPackageSeriesStore(db)
	allocationStore := postgres.NewAssetAllocationRecordStore(db, env.redis)
	cardSvc := iotCardService.New(db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, zap.NewNop(), nil)
	deviceSvc := deviceService.New(db, nil, deviceStore, postgres.NewDeviceSimBindingStore(db, nil), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, postgres.NewAssetIdentifierStore(db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(db, nil), postgres.NewEnterpriseStore(db, nil))
	packageExpiry := packageExpiryQuery.NewQuery(db)
	cardSvc.SetPackageExpiryQuery(packageExpiry)
	deviceSvc.SetPackageExpiryQuery(packageExpiry)
	authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
		info, err := env.tokenManager.ValidateAccessToken(context.Background(), token)
		if err != nil {
			return nil, err
		}
		return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, ShopID: info.ShopID}, nil
	}, ShopStore: shopStore})
	env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
	env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
}

// createPackageExpiryListFixtures 创建卡和设备各 100 条的四状态列表数据。
func createPackageExpiryListFixtures(t *testing.T, env *assetTraceHTTPEnv) packageExpiryListFixture {
	t.Helper()
	fixture := packageExpiryListFixture{batchNo: "UR46-LIST-" + strconv.FormatInt(time.Now().UnixNano(), 10), cardIDs: make([]uint, 0, 100), deviceIDs: make([]uint, 0, 100), statusIDs: map[string]map[string]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}}
	now := time.Now()
	expiresAt := now.AddDate(0, 0, 5)
	for index := 0; index < 100; index++ {
		card := env.createCard(t, 1000+index, nil)
		device := env.createDevice(t, 1000+index, nil)
		if err := env.db.Model(card).Updates(map[string]any{"is_standalone": true, "batch_no": fixture.batchNo}).Error; err != nil {
			t.Fatalf("设置卡列表 fixture 失败：%v", err)
		}
		if err := env.db.Model(device).Update("batch_no", fixture.batchNo).Error; err != nil {
			t.Fatalf("设置设备列表 fixture 失败：%v", err)
		}
		fixture.cardIDs = append(fixture.cardIDs, card.ID)
		fixture.deviceIDs = append(fixture.deviceIDs, device.ID)
		fixture.lastCardIdentifier, fixture.lastDeviceIdentifier = card.ICCID, device.VirtualNo
		createPackageExpiryListUsage(t, env, constants.AssetTypeIotCard, card.ID, index, now, expiresAt, fixture.statusIDs)
		createPackageExpiryListUsage(t, env, constants.AssetTypeDevice, device.ID, index, now, expiresAt, fixture.statusIDs)
	}
	return fixture
}

// createPackageExpiryListUsage 按资产序号分配精确、无套餐、待激活和异常状态。
func createPackageExpiryListUsage(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, index int, now, expiresAt time.Time, statusIDs map[string]map[string]uint) {
	t.Helper()
	estimateStatus := constants.PackageExpiryEstimateStatusExact
	usageStatus := constants.PackageUsageStatusActive
	usageExpiry := &expiresAt
	waiting := false
	switch index {
	case 1:
		estimateStatus = constants.PackageExpiryEstimateStatusNone
		statusIDs[assetType][estimateStatus] = assetID
		return
	case 2:
		estimateStatus = constants.PackageExpiryEstimateStatusWaitingActivation
		usageStatus, usageExpiry, waiting = constants.PackageUsageStatusPending, nil, true
	case 3:
		estimateStatus = constants.PackageExpiryEstimateStatusInvalidData
		usageExpiry = nil
	}
	statusIDs[assetType][estimateStatus] = assetID
	usage := newExpiryHTTPUsage(assetID, assetType, usageStatus, usageExpiry, waiting, now)
	if err := createExpiryHTTPUsage(env.db, usage); err != nil {
		t.Fatalf("创建列表套餐失败：%v", err)
	}
	assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}

// verifyPackageExpiryList 验证列表响应、固定查询数、性能、筛选、排序和详情一致性。
func verifyPackageExpiryList(t *testing.T, env *assetTraceHTTPEnv, counter *routeSQLCounterLogger, fixture packageExpiryListFixture, testCase packageExpiryListCase, token string) {
	t.Helper()
	baseQuery := "?page=1&page_size=100&batch_no=" + fixture.batchNo
	counter.Reset()
	oneStatus, oneBody := env.request(t, testCase.path+strings.Replace(baseQuery, "page_size=100", "page_size=1", 1), token)
	oneAssetQueries := counter.Total()
	if oneStatus != http.StatusOK {
		t.Fatalf("单资产分页请求失败：path=%s status=%d body=%s", testCase.path, oneStatus, oneBody)
	}
	counter.Reset()
	startedAt := time.Now()
	status, body := env.request(t, testCase.path+baseQuery, token)
	requestDuration := time.Since(startedAt)
	hundredAssetQueries := counter.Total()
	if status != http.StatusOK || oneAssetQueries != hundredAssetQueries || counter.CountContaining("tb_package_usage") != testCase.packageQueries || requestDuration >= testHTTPPerformanceLimit {
		t.Fatalf("列表批量查询或性能错误：path=%s status=%d one=%d hundred=%d package_queries=%d duration=%v", testCase.path, status, oneAssetQueries, hundredAssetQueries, counter.CountContaining("tb_package_usage"), requestDuration)
	}
	payload := decodePackageExpiryList(t, body)
	if payload.Data.Total != testListPageSize || payload.Data.Page != 1 || payload.Data.Size != testListPageSize || len(payload.Data.Items) != testListPageSize || payload.Data.Items[0].ID != testCase.orderedIDs[testListPageSize-1] {
		t.Fatalf("列表分页或排序错误：path=%s payload=%+v", testCase.path, payload.Data)
	}
	assertPackageExpiryListStatuses(t, payload.Data.Items, fixture.statusIDs[testCase.assetType])
	detail := requestExpiryEstimate(t, env, testCase.identifier, token)
	first := payload.Data.Items[0]
	if first.Status != detail.Status || !timePointersEqualRoute(first.ExpiresAt, detail.ExpiresAt) || !intPointersEqualRoute(first.Days, detail.Days) {
		t.Fatalf("列表与详情预计到期不一致：list=%+v detail=%+v", first, detail)
	}
	status, pageTwoBody := env.request(t, testCase.path+"?page=2&page_size=50&batch_no="+fixture.batchNo, token)
	pageTwo := decodePackageExpiryList(t, pageTwoBody)
	if status != http.StatusOK || pageTwo.Data.Total != 100 || len(pageTwo.Data.Items) != 50 || pageTwo.Data.Items[0].ID != testCase.orderedIDs[49] {
		t.Fatalf("列表翻页不稳定：path=%s status=%d payload=%+v", testCase.path, status, pageTwo.Data)
	}
}

// decodePackageExpiryList 同时解析类型字段和原始 JSON，以区分字段缺失与显式 null。
func decodePackageExpiryList(t *testing.T, body []byte) packageExpiryListPayload {
	t.Helper()
	var payload packageExpiryListPayload
	if err := sonic.Unmarshal(body, &payload); err != nil {
		t.Fatalf("解析列表分页响应失败：%v", err)
	}
	var raw struct {
		Data struct {
			Items []map[string]any `json:"items"`
		} `json:"data"`
	}
	if err := sonic.Unmarshal(body, &raw); err != nil || len(raw.Data.Items) != len(payload.Data.Items) {
		t.Fatalf("解析列表原始字段失败：%v", err)
	}
	for index, item := range raw.Data.Items {
		expiresAt, expiresAtExists := item["estimated_final_expires_at"]
		days, daysExists := item["days_until_final_expiry"]
		payload.Data.Items[index].fieldsExist = expiresAtExists && daysExists
		payload.Data.Items[index].fieldsNull = expiresAt == nil && days == nil
	}
	return payload
}

// assertPackageExpiryListStatuses 逐项验证列表四状态、字段存在性和 nullable 语义。
func assertPackageExpiryListStatuses(t *testing.T, items []packageExpiryListItem, expectedIDs map[string]uint) {
	t.Helper()
	byID := make(map[uint]packageExpiryListItem, len(items))
	for _, item := range items {
		byID[item.ID] = item
	}
	for status, id := range expectedIDs {
		item, ok := byID[id]
		if !ok || item.Status != status || item.StatusName == "" {
			t.Fatalf("列表缺少状态投影：status=%s id=%d item=%+v", status, id, item)
		}
		exact := status == constants.PackageExpiryEstimateStatusExact
		if !item.fieldsExist || exact == item.fieldsNull || exact != (item.ExpiresAt != nil && item.Days != nil) || exact != item.IsExpiring {
			t.Fatalf("列表 nullable 或临期标记错误：status=%s item=%+v", status, item)
		}
	}
}

func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
	t.Helper()
	if status != http.StatusOK {
		t.Fatalf("资产详情状态错误，实际 %d：%s", status, body)
	}
	var response struct {
		Data struct {
			ExchangeTrace struct {
				PreviousAsset any `json:"previous_asset"`
				NextAsset     any `json:"next_asset"`
			} `json:"exchange_trace"`
		} `json:"data"`
	}
	if err := sonic.Unmarshal(body, &response); err != nil {
		t.Fatalf("解析换货方向响应失败：%v", err)
	}
	if (response.Data.ExchangeTrace.PreviousAsset != nil) != hasPrevious || (response.Data.ExchangeTrace.NextAsset != nil) != hasNext {
		t.Fatalf("换货方向空值语义错误：previous=%v next=%v body=%s", hasPrevious, hasNext, body)
	}
}

type assetTraceHTTPEnv struct {
	app          *fiber.App
	db           *gorm.DB
	redis        *redis.Client
	tokenManager *auth.TokenManager
	assetService *assetService.Service
}

func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
	t.Helper()
	if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
		t.Skip("未加载 .env.local，跳过依赖真实 PostgreSQL 和 Redis 的资产换货链 HTTP 集成测试")
	}
	cfg, err := config.Load()
	if err != nil {
		t.Fatalf("加载测试配置失败：%v", err)
	}
	log := zap.NewNop()
	db, err := database.InitPostgreSQL(&cfg.Database, log)
	if err != nil {
		t.Fatalf("连接 PostgreSQL 失败：%v", err)
	}
	tx := db.Begin()
	if tx.Error != nil {
		t.Fatalf("开启资产换货链测试事务失败：%v", tx.Error)
	}
	redisClient, err := database.NewRedisClient(database.RedisConfig{
		Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, DB: cfg.Redis.DB,
		PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
		DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
	}, log)
	if err != nil {
		t.Fatalf("连接 Redis 失败：%v", err)
	}
	t.Cleanup(func() {
		_ = tx.Rollback().Error
		_ = redisClient.Close()
		if sqlDB, dbErr := db.DB(); dbErr == nil {
			_ = sqlDB.Close()
		}
	})

	shopStore := postgres.NewShopStore(tx, redisClient)
	deviceStore := postgres.NewDeviceStore(tx, redisClient)
	cardStore := postgres.NewIotCardStore(tx, redisClient)
	assetSvc := assetService.New(
		tx, deviceStore, cardStore,
		postgres.NewPackageUsageStore(tx, redisClient), postgres.NewPackageStore(tx), postgres.NewPackageSeriesStore(tx),
		postgres.NewDeviceSimBindingStore(tx, redisClient), shopStore, redisClient, nil, nil,
		postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
		postgres.NewExchangeOrderStore(tx), nil,
	)
	assetSvc.SetPackageExpiryQuery(packageExpiryQuery.NewQuery(tx))
	handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
	tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
	authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
		TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
			info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
			if validateErr != nil {
				return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
			}
			return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, Username: info.Username, ShopID: info.ShopID}, nil
		},
		ShopStore: shopStore,
	})
	app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
	RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
	return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager, assetService: assetSvc}
}

func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
	t.Helper()
	shop := &model.Shop{ShopName: name, ShopCode: fmt.Sprintf("UR86-%d", time.Now().UnixNano()), ParentID: parentID, Level: level, Status: constants.ShopStatusEnabled}
	if err := e.db.Create(shop).Error; err != nil {
		t.Fatalf("创建资产换货链测试店铺失败：%v", err)
	}
	return shop
}

func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *model.IotCard {
	t.Helper()
	iccid := fmt.Sprintf("8986333333333333%04d", suffix)
	card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-HTTP-CARD-%04d", suffix), ShopID: shopID, Status: constants.IotCardStatusInStock, AssetStatus: constants.AssetStatusInStock}
	if err := e.db.Create(card).Error; err != nil {
		t.Fatalf("创建资产换货链 HTTP 测试卡失败：%v", err)
	}
	return card
}

func (e *assetTraceHTTPEnv) createDevice(t *testing.T, suffix int, shopID *uint) *model.Device {
	t.Helper()
	device := &model.Device{VirtualNo: fmt.Sprintf("UR46-HTTP-DEVICE-%04d-%d", suffix, time.Now().UnixNano()), ShopID: shopID, Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock, MaxSimSlots: 4}
	if err := e.db.Create(device).Error; err != nil {
		t.Fatalf("创建 UR46 HTTP 测试设备失败：%v", err)
	}
	return device
}

func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
	t.Helper()
	newID := newCard.ID
	order := &model.ExchangeOrder{
		ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect,
		OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: oldSnapshot,
		NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: newSnapshot,
		ExchangeReason: "UR86 HTTP 测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt,
	}
	if err := e.db.Create(order).Error; err != nil {
		t.Fatalf("创建资产换货链 HTTP 测试单失败：%v", err)
	}
}

func (e *assetTraceHTTPEnv) newAgentToken(t *testing.T, shopID uint) string {
	t.Helper()
	info := &auth.TokenInfo{UserID: uint(time.Now().UnixNano() % 1_000_000_000), UserType: constants.UserTypeAgent, ShopID: shopID, Username: "UR86测试代理"}
	accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), info)
	if err != nil {
		t.Fatalf("创建资产换货链测试令牌失败：%v", err)
	}
	t.Cleanup(func() {
		_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
		_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
		_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
	})
	return accessToken
}

func (e *assetTraceHTTPEnv) request(t *testing.T, path, token string) (int, []byte) {
	t.Helper()
	request, err := http.NewRequest(http.MethodGet, path, nil)
	if err != nil {
		t.Fatalf("创建资产换货链 HTTP 请求失败：%v", err)
	}
	request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
	response, err := e.app.Test(request, -1)
	if err != nil {
		t.Fatalf("执行资产换货链 HTTP 请求失败：%v", err)
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		t.Fatalf("读取资产换货链 HTTP 响应失败：%v", err)
	}
	return response.StatusCode, body
}
