package smoke_test

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"testing"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/golang-jwt/jwt/v5"
)

const (
	bagGiftAppCode   = "lalu"
	bagGiftJWTSecret = "dev-shared-secret"

	bagGiftSenderID = int64(920001)
	bagGiftTargetID = int64(920002)

	bagGiftID           = "bag_smoke_gift"
	bagGiftResourceCode = "bag_smoke_gift_resource"
	bagGiftEntitlement  = "bag-smoke-entitlement"
	bagGiftPrice        = int64(10)
	bagGiftHeatValue    = int64(10)
	bagGiftSendCount    = int64(2)
)

func TestBagGiftLocalHTTPFlow(t *testing.T) {
	if os.Getenv("BAG_GIFT_REAL_HTTP_FLOW_TEST") != "1" {
		t.Skip("set BAG_GIFT_REAL_HTTP_FLOW_TEST=1 to run the local bag gift HTTP flow")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
	defer cancel()

	db, err := sql.Open("mysql", bagGiftEnvOr("BAG_GIFT_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"))
	if err != nil {
		t.Fatalf("open mysql failed: %v", err)
	}
	defer db.Close()
	if err := db.PingContext(ctx); err != nil {
		t.Fatalf("mysql ping failed: %v", err)
	}

	flow := &bagGiftHTTPFlow{
		db:      db,
		http:    &http.Client{Timeout: 15 * time.Second},
		gateway: strings.TrimRight(bagGiftEnvOr("BAG_GIFT_GATEWAY", "http://127.0.0.1:13000"), "/"),
		startMS: time.Now().UTC().UnixMilli(),
	}
	regionID, err := flow.lookupUSRegion(ctx)
	if err != nil {
		t.Fatalf("lookup region failed: %v", err)
	}
	if err := flow.cleanup(ctx); err != nil {
		t.Fatalf("cleanup failed: %v", err)
	}
	if err := flow.seedUsers(ctx, regionID); err != nil {
		t.Fatalf("seed users failed: %v", err)
	}
	if err := flow.seedBagGift(ctx); err != nil {
		t.Fatalf("seed bag gift failed: %v", err)
	}
	if err := flow.assertMyGiftResourceVisible(ctx); err != nil {
		t.Fatalf("my gift resource assertion failed: %v", err)
	}
	roomID, err := flow.createRoomAndJoin(ctx)
	if err != nil {
		t.Fatalf("create room and join failed: %v", err)
	}
	if err := flow.assertBagPanelVisible(ctx, roomID); err != nil {
		t.Fatalf("bag gift panel assertion failed: %v", err)
	}

	commandID := bagGiftCommandID("send")
	sendResp, err := flow.sendBagGift(ctx, roomID, commandID)
	if err != nil {
		t.Fatalf("send bag gift failed: %v", err)
	}
	if strings.TrimSpace(sendResp.BillingReceiptID) == "" {
		t.Fatalf("send response must include billing_receipt_id: %+v", sendResp)
	}
	if sendResp.RoomHeat < bagGiftPrice*bagGiftSendCount {
		t.Fatalf("room_heat must include bag gift value, got=%d want>=%d resp=%+v", sendResp.RoomHeat, bagGiftPrice*bagGiftSendCount, sendResp)
	}
	if err := flow.assertWalletFacts(ctx, commandID, sendResp.BillingReceiptID); err != nil {
		t.Fatalf("wallet facts mismatch: %v", err)
	}
	if err := flow.assertRoomFacts(ctx, roomID, commandID); err != nil {
		t.Fatalf("room facts mismatch: %v", err)
	}

	t.Logf("bag gift HTTP smoke passed: gateway=%s room_id=%s command_id=%s receipt=%s", flow.gateway, roomID, commandID, sendResp.BillingReceiptID)
}

type bagGiftHTTPFlow struct {
	db      *sql.DB
	http    *http.Client
	gateway string
	startMS int64
}

type bagGiftEnvelope struct {
	Code      string          `json:"code"`
	Message   string          `json:"message"`
	RequestID string          `json:"request_id"`
	Data      json.RawMessage `json:"data"`
}

type bagGiftCreateRoomData struct {
	Room struct {
		RoomID string `json:"room_id"`
	} `json:"room"`
}

type bagGiftSendData struct {
	BillingReceiptID string `json:"billing_receipt_id"`
	RoomHeat         int64  `json:"room_heat"`
}

func (f *bagGiftHTTPFlow) lookupUSRegion(ctx context.Context) (int64, error) {
	var regionID int64
	err := f.db.QueryRowContext(ctx, `
		SELECT r.region_id
		FROM hyapp_user.regions r
		JOIN hyapp_user.region_countries rc ON rc.app_code = r.app_code AND rc.region_id = r.region_id
		WHERE r.app_code = ? AND rc.country_code = 'US' AND r.status = 'active' AND rc.status = 'active'
		ORDER BY r.region_id
		LIMIT 1`, bagGiftAppCode).Scan(&regionID)
	if err != nil {
		return 0, fmt.Errorf("lookup US region: %w", err)
	}
	return regionID, nil
}

func (f *bagGiftHTTPFlow) cleanup(ctx context.Context) error {
	// 这个 smoke 使用固定用户和固定资源，运行前先把本用例自己产生的房间、钱包流水和权益恢复到确定状态。
	// 业务行为仍然只通过 gateway HTTP 发起；这里的 SQL 只负责隔离测试数据，避免旧房间 owner 唯一约束和旧权益库存影响结果。
	queries := []struct {
		sql  string
		args []any
	}{
		{`DELETE FROM hyapp_room.room_background_images WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.room_snapshots WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.room_command_log WHERE app_code = ? AND (command_id LIKE 'bag-gift-smoke:%' OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.room_outbox WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.room_user_gift_stats WHERE app_code = ? AND room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?))`, []any{bagGiftAppCode, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.room_user_presence WHERE app_code = ? AND (user_id IN (?, ?) OR room_id IN (SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)))`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID, bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?)`, []any{bagGiftAppCode, bagGiftSenderID, bagGiftTargetID}},
		{`DELETE FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id IN (SELECT transaction_id FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%')`, []any{bagGiftAppCode, bagGiftAppCode}},
		{`DELETE FROM hyapp_wallet.wallet_outbox WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
		{`DELETE FROM hyapp_wallet.wallet_transactions WHERE app_code = ? AND command_id LIKE 'bag-gift-smoke:%'`, []any{bagGiftAppCode}},
	}
	for _, query := range queries {
		if _, err := f.db.ExecContext(ctx, query.sql, query.args...); err != nil {
			return err
		}
	}
	return nil
}

func (f *bagGiftHTTPFlow) seedUsers(ctx context.Context, regionID int64) error {
	if _, err := f.db.ExecContext(ctx, `
		DELETE FROM hyapp_user.user_display_user_ids
		WHERE app_code = ? AND (user_id IN (?, ?) OR display_user_id IN ('920001', '920002'))`,
		bagGiftAppCode, bagGiftSenderID, bagGiftTargetID,
	); err != nil {
		return err
	}
	nowMS := time.Now().UTC().UnixMilli()
	users := []struct {
		id     int64
		name   string
		avatar string
	}{
		{bagGiftSenderID, "Bag Smoke Sender", "https://cdn.example.com/bag-smoke-sender.png"},
		{bagGiftTargetID, "Bag Smoke Target", "https://cdn.example.com/bag-smoke-target.png"},
	}
	for _, user := range users {
		displayID := strconv.FormatInt(user.id, 10)
		if _, err := f.db.ExecContext(ctx, `
			INSERT INTO hyapp_user.users (
				app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
				username, gender, country, region_id, avatar, profile_completed, profile_completed_at_ms,
				onboarding_status, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'US', ?, ?, 1, ?, 'completed', 'active', ?, ?)
			ON DUPLICATE KEY UPDATE
				current_display_user_id = VALUES(current_display_user_id),
				current_display_user_id_kind = VALUES(current_display_user_id_kind),
				username = VALUES(username),
				country = VALUES(country),
				region_id = VALUES(region_id),
				avatar = VALUES(avatar),
				profile_completed = VALUES(profile_completed),
				profile_completed_at_ms = VALUES(profile_completed_at_ms),
				onboarding_status = VALUES(onboarding_status),
				status = VALUES(status),
				updated_at_ms = VALUES(updated_at_ms)`,
			bagGiftAppCode, user.id, displayID, displayID, user.name, regionID, user.avatar, nowMS, nowMS, nowMS,
		); err != nil {
			return err
		}
		if _, err := f.db.ExecContext(ctx, `
			INSERT INTO hyapp_user.user_display_user_ids (
				app_code, display_user_id, user_id, display_user_id_kind, status,
				assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, 'default', 'active', ?, ?, ?, ?)`,
			bagGiftAppCode, displayID, user.id, nowMS, nowMS, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (f *bagGiftHTTPFlow) seedBagGift(ctx context.Context) error {
	nowMS := time.Now().UTC().UnixMilli()
	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.resources (
			app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
			grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
			usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
			created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, 'gift', 'Bag Smoke Gift', 'active', TRUE, TRUE,
			'increase_quantity', '', 0, 'coin', ?, 0, JSON_ARRAY('gift_panel'),
			'https://cdn.example.com/bag-smoke-gift.png', 'https://cdn.example.com/bag-smoke-gift-preview.png',
			'https://cdn.example.com/bag-smoke-gift-animation.svga', JSON_OBJECT(), 0, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			resource_type = 'gift',
			name = VALUES(name),
			status = 'active',
			grantable = TRUE,
			manager_grant_enabled = TRUE,
			grant_strategy = 'increase_quantity',
			price_type = 'coin',
			coin_price = VALUES(coin_price),
			usage_scope_json = VALUES(usage_scope_json),
			asset_url = VALUES(asset_url),
			preview_url = VALUES(preview_url),
			animation_url = VALUES(animation_url),
			updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftResourceCode, bagGiftPrice, nowMS, nowMS,
	); err != nil {
		return err
	}

	var resourceID int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT resource_id FROM hyapp_wallet.resources WHERE app_code = ? AND resource_code = ?`,
		bagGiftAppCode, bagGiftResourceCode,
	).Scan(&resourceID); err != nil {
		return err
	}

	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.gift_configs (
			app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
			gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
			created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 'active', 'Bag Smoke Gift', 1, CAST(? AS JSON), 'normal', 0, 0, JSON_ARRAY(), 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			resource_id = VALUES(resource_id),
			status = 'active',
			name = VALUES(name),
			sort_order = VALUES(sort_order),
			presentation_json = VALUES(presentation_json),
			gift_type_code = 'normal',
			effective_from_ms = 0,
			effective_to_ms = 0,
			effect_types_json = JSON_ARRAY(),
			updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftID, resourceID, `{"preview_url":"https://cdn.example.com/bag-smoke-gift-preview.png","animation_url":"https://cdn.example.com/bag-smoke-gift-animation.svga"}`, nowMS, nowMS,
	); err != nil {
		return err
	}
	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.gift_config_regions (app_code, gift_id, region_id, created_at_ms, updated_at_ms)
		VALUES (?, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftID, nowMS, nowMS,
	); err != nil {
		return err
	}
	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.wallet_gift_prices (
			app_code, gift_id, price_version, status, charge_asset_type, coin_price,
			gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, 'bag-smoke-v1', 'active', 'COIN', ?, 0, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			status = 'active',
			charge_asset_type = 'COIN',
			coin_price = VALUES(coin_price),
			gift_point_amount = 0,
			heat_value = VALUES(heat_value),
			effective_at_ms = 0,
			updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftID, bagGiftPrice, bagGiftHeatValue, nowMS, nowMS,
	); err != nil {
		return err
	}
	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.wallet_accounts (
			app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
		) VALUES (?, ?, 'COIN', 0, 0, 1, ?, ?)
		ON DUPLICATE KEY UPDATE available_amount = 0, frozen_amount = 0, version = version + 1, updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftSenderID, nowMS, nowMS,
	); err != nil {
		return err
	}
	if _, err := f.db.ExecContext(ctx, `
		INSERT INTO hyapp_wallet.user_resource_entitlements (
			app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
			effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 'active', 3, 3, 0, 0, 'bag-smoke-grant', ?, ?)
		ON DUPLICATE KEY UPDATE
			user_id = VALUES(user_id),
			resource_id = VALUES(resource_id),
			status = 'active',
			quantity = 3,
			remaining_quantity = 3,
			effective_at_ms = 0,
			expires_at_ms = 0,
			source_grant_id = VALUES(source_grant_id),
			updated_at_ms = VALUES(updated_at_ms)`,
		bagGiftAppCode, bagGiftEntitlement, bagGiftSenderID, resourceID, nowMS, nowMS,
	); err != nil {
		return err
	}
	return nil
}

func (f *bagGiftHTTPFlow) createRoomAndJoin(ctx context.Context) (string, error) {
	var createData bagGiftCreateRoomData
	if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/create", bagGiftSenderID, map[string]any{
		"command_id":       bagGiftCommandID("create-room"),
		"seat_count":       10,
		"mode":             "voice",
		"room_name":        "Bag Gift Smoke Room",
		"room_avatar":      "https://cdn.example.com/bag-gift-smoke-room.png",
		"room_description": "bag gift smoke",
	}, &createData); err != nil {
		return "", err
	}
	if strings.TrimSpace(createData.Room.RoomID) == "" {
		return "", fmt.Errorf("create room response missing room.room_id")
	}
	for _, userID := range []int64{bagGiftSenderID, bagGiftTargetID} {
		if err := f.request(ctx, http.MethodPost, "/api/v1/rooms/join", userID, map[string]any{
			"room_id":    createData.Room.RoomID,
			"command_id": bagGiftCommandID(fmt.Sprintf("join-%d", userID)),
			"role":       "audience",
		}, nil); err != nil {
			return "", fmt.Errorf("join user %d: %w", userID, err)
		}
	}
	return createData.Room.RoomID, nil
}

func (f *bagGiftHTTPFlow) assertMyGiftResourceVisible(ctx context.Context) error {
	var raw json.RawMessage
	if err := f.request(ctx, http.MethodGet, "/api/v1/users/me/resources?resource_type=gift", bagGiftSenderID, nil, &raw); err != nil {
		return err
	}
	body := string(raw)
	if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, bagGiftResourceCode) {
		return fmt.Errorf("my resources does not contain bag gift entitlement/resource data=%s", body)
	}
	return nil
}

func (f *bagGiftHTTPFlow) assertBagPanelVisible(ctx context.Context, roomID string) error {
	var raw json.RawMessage
	if err := f.request(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(roomID)+"/gift-panel", bagGiftSenderID, nil, &raw); err != nil {
		return err
	}
	body := string(raw)
	if !strings.Contains(body, bagGiftID) {
		return fmt.Errorf("gift panel does not contain gift_id=%s data=%s", bagGiftID, body)
	}
	if !strings.Contains(body, bagGiftEntitlement) || !strings.Contains(body, `"source":"bag"`) {
		return fmt.Errorf("gift panel does not contain bag entitlement/source data=%s", body)
	}
	return nil
}

func (f *bagGiftHTTPFlow) sendBagGift(ctx context.Context, roomID string, commandID string) (bagGiftSendData, error) {
	var data bagGiftSendData
	err := f.request(ctx, http.MethodPost, "/api/v1/rooms/gift/send", bagGiftSenderID, map[string]any{
		"room_id":         roomID,
		"command_id":      commandID,
		"target_type":     "user",
		"target_user_ids": []int64{bagGiftTargetID},
		"gift_id":         bagGiftID,
		"gift_count":      bagGiftSendCount,
		"source":          "bag",
		"entitlement_id":  bagGiftEntitlement,
	}, &data)
	return data, err
}

func (f *bagGiftHTTPFlow) assertWalletFacts(ctx context.Context, commandID string, billingReceiptID string) error {
	var remaining int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT remaining_quantity
		FROM hyapp_wallet.user_resource_entitlements
		WHERE app_code = ? AND entitlement_id = ?`,
		bagGiftAppCode, bagGiftEntitlement,
	).Scan(&remaining); err != nil {
		return err
	}
	if remaining != 1 {
		return fmt.Errorf("remaining_quantity=%d, want 1", remaining)
	}

	var transactionID, bizType, status, chargeSource, chargeAssetType, gotBillingReceiptID string
	var coinSpent, heatValue int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT transaction_id, biz_type, status,
			COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_source')), ''),
			COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_asset_type')), ''),
			COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.billing_receipt_id')), ''),
			COALESCE(JSON_EXTRACT(metadata_json, '$.coin_spent'), 0),
			COALESCE(JSON_EXTRACT(metadata_json, '$.heat_value'), 0)
		FROM hyapp_wallet.wallet_transactions
		WHERE app_code = ? AND command_id = ?`,
		bagGiftAppCode, commandID,
	).Scan(&transactionID, &bizType, &status, &chargeSource, &chargeAssetType, &gotBillingReceiptID, &coinSpent, &heatValue); err != nil {
		return err
	}
	// Bag 送礼只是不扣 COIN 账户库存，交易快照里的 coin_spent/heat_value 仍表示礼物原价总值；
	// 房间贡献、麦位热度和主播积分继续复用这份价值口径，不能因为来源是背包就归零。
	if bizType != "gift_debit" || status != "succeeded" || chargeSource != "bag" || chargeAssetType != "BAG" || coinSpent != bagGiftPrice*bagGiftSendCount || heatValue != bagGiftPrice*bagGiftSendCount {
		return fmt.Errorf("wallet transaction mismatch biz_type=%s status=%s source=%s asset=%s coin_spent=%d heat=%d", bizType, status, chargeSource, chargeAssetType, coinSpent, heatValue)
	}
	if gotBillingReceiptID != billingReceiptID {
		return fmt.Errorf("billing receipt mismatch metadata=%s response=%s transaction_id=%s", gotBillingReceiptID, billingReceiptID, transactionID)
	}
	var entryCount int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM hyapp_wallet.wallet_entries WHERE app_code = ? AND transaction_id = ?`,
		bagGiftAppCode, transactionID,
	).Scan(&entryCount); err != nil {
		return err
	}
	if entryCount != 0 {
		return fmt.Errorf("bag send must not create coin wallet entries, got=%d", entryCount)
	}
	return nil
}

func (f *bagGiftHTTPFlow) assertRoomFacts(ctx context.Context, roomID string, commandID string) error {
	var commandCount int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM hyapp_room.room_command_log
		WHERE app_code = ? AND room_id = ? AND command_id = ? AND command_type = 'send_gift'`,
		bagGiftAppCode, roomID, commandID,
	).Scan(&commandCount); err != nil {
		return err
	}
	if commandCount != 1 {
		return fmt.Errorf("room_command_log count=%d, want 1", commandCount)
	}

	var outboxCount int64
	if err := f.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM hyapp_room.room_outbox
		WHERE app_code = ? AND room_id = ? AND event_type = 'RoomGiftSent' AND created_at_ms >= ?`,
		bagGiftAppCode, roomID, f.startMS,
	).Scan(&outboxCount); err != nil {
		return err
	}
	if outboxCount == 0 {
		return fmt.Errorf("missing RoomGiftSent outbox event")
	}
	return nil
}

func (f *bagGiftHTTPFlow) request(ctx context.Context, method string, path string, userID int64, body any, out any) error {
	var reader io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return err
		}
		reader = bytes.NewReader(raw)
	}
	req, err := http.NewRequestWithContext(ctx, method, f.gateway+path, reader)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+bagGiftTokenForUser(userID))
	req.Header.Set("X-App-Code", bagGiftAppCode)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := f.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	var envelope bagGiftEnvelope
	if err := json.Unmarshal(raw, &envelope); err != nil {
		return fmt.Errorf("%s %s returned non-envelope status=%d body=%s", method, path, resp.StatusCode, string(raw))
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != "OK" {
		return fmt.Errorf("%s %s failed status=%d code=%s message=%s body=%s", method, path, resp.StatusCode, envelope.Code, envelope.Message, string(raw))
	}
	if out == nil {
		return nil
	}
	if rawOut, ok := out.(*json.RawMessage); ok {
		*rawOut = append((*rawOut)[:0], envelope.Data...)
		return nil
	}
	if err := json.Unmarshal(envelope.Data, out); err != nil {
		return fmt.Errorf("decode data for %s %s failed: %w data=%s", method, path, err, string(envelope.Data))
	}
	return nil
}

func bagGiftTokenForUser(userID int64) string {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id":           strconv.FormatInt(userID, 10),
		"app_code":          bagGiftAppCode,
		"sid":               "bag-gift-smoke-" + strconv.FormatInt(userID, 10),
		"profile_completed": true,
		"onboarding_status": "completed",
		"exp":               time.Now().Add(2 * time.Hour).Unix(),
		"iat":               time.Now().Unix(),
	})
	signed, err := token.SignedString([]byte(bagGiftJWTSecret))
	if err != nil {
		panic(err)
	}
	return signed
}

func bagGiftCommandID(key string) string {
	return fmt.Sprintf("bag-gift-smoke:%s:%d", key, time.Now().UTC().UnixNano())
}

func bagGiftEnvOr(key string, fallback string) string {
	if value := strings.TrimSpace(os.Getenv(key)); value != "" {
		return value
	}
	return fallback
}
