// Package main runs a local end-to-end CP flow against real Docker services.
package main

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

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

const (
	appCode   = "lalu"
	jwtSecret = "dev-shared-secret"

	userA = int64(910001)
	userB = int64(910002)
	userC = int64(910003)

	giftCP      = "local_cp_gift_cp"
	giftBrother = "local_cp_gift_brother"
	giftSister  = "local_cp_gift_sister"
)

type smoke struct {
	db      *sql.DB
	http    *http.Client
	gateway string
	startMS int64
	roomID  string
	region  int64
}

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

type cpApplication struct {
	ApplicationID string `json:"application_id"`
	RelationType  string `json:"relation_type"`
	Status        string `json:"status"`
	RoomID        string `json:"room_id"`
	ExpiresAtMS   int64  `json:"expires_at_ms"`
}

type cpRelationship struct {
	RelationshipID        string `json:"relationship_id"`
	RelationType          string `json:"relation_type"`
	Status                string `json:"status"`
	IntimacyValue         int64  `json:"intimacy_value"`
	Level                 int32  `json:"level"`
	CurrentLevelThreshold int64  `json:"current_level_threshold"`
	NextLevelThreshold    int64  `json:"next_level_threshold"`
	NeededForNextLevel    int64  `json:"needed_for_next_level"`
	LevelProgressPercent  int32  `json:"level_progress_percent"`
	MaxLevel              bool   `json:"max_level"`
}

type cpRelationshipListData struct {
	Relationships []cpRelationship `json:"relationships"`
	Total         int64            `json:"total"`
}

type cpLevelRuleSnapshot struct {
	LevelNo               int32
	IntimacyThreshold     int64
	RewardResourceGroupID int64
	LevelIconURL          string
	Status                string
	CreatedAtMS           int64
	UpdatedAtMS           int64
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), envDuration("CP_SMOKE_TIMEOUT", 4*time.Minute))
	defer cancel()

	db, err := sql.Open("mysql", env("CP_SMOKE_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"))
	if err != nil {
		fatal(err)
	}
	defer db.Close()
	if err := db.PingContext(ctx); err != nil {
		fatal(fmt.Errorf("mysql ping failed: %w", err))
	}

	s := &smoke{
		db:      db,
		http:    &http.Client{Timeout: 8 * time.Second},
		gateway: strings.TrimRight(env("CP_SMOKE_GATEWAY", "http://127.0.0.1:13000"), "/"),
		startMS: time.Now().UTC().UnixMilli(),
	}

	if env("CP_SMOKE_FOCUS", "") == "relationships_progress" {
		fmt.Println("==> focused relationship progress HTTP chain")
		if err := s.runRelationshipProgressFocus(ctx); err != nil {
			fatal(err)
		}
		fmt.Println("ok  focused relationship progress HTTP chain")
		fmt.Println("CP relationship progress smoke passed")
		return
	}

	steps := []struct {
		name string
		fn   func(context.Context) error
	}{
		{"seed users, wallet, gifts", s.seed},
		{"import A/B/C Tencent IM accounts", s.importIMAccounts},
		{"create room and join A/B/C", s.createRoomAndJoin},
		{"T01 create CP application from gift", s.testCreateCPApplication},
		{"T02 accept application and deliver C2C/room/region IM", s.testAcceptApplication},
		{"T03 existing relationship gift increments intimacy", s.testIntimacyIncrement},
		{"T04 same pair can accept only one relation type", s.testPairSingleRelation},
		{"T05 one user can have only one active relation", s.testUserSingleRelation},
		{"T06 expired application cannot be accepted", s.testExpiredApplication},
		{"T07 rejected application delivers C2C and room IM", s.testRejectApplication},
	}
	for _, step := range steps {
		fmt.Printf("==> %s\n", step.name)
		if err := step.fn(ctx); err != nil {
			fatal(fmt.Errorf("%s: %w", step.name, err))
		}
		fmt.Printf("ok  %s\n", step.name)
	}
	fmt.Println("CP smoke flow passed")
}

func (s *smoke) seed(ctx context.Context) error {
	regionID, err := s.lookupUSRegion(ctx)
	if err != nil {
		return err
	}
	s.region = regionID
	if err := s.cleanupCPFacts(ctx); err != nil {
		return err
	}
	if err := s.cleanupRoomFacts(ctx); err != nil {
		return err
	}
	if err := s.seedUsers(ctx, regionID); err != nil {
		return err
	}
	if err := s.seedWallet(ctx); err != nil {
		return err
	}
	return s.seedGifts(ctx)
}

func (s *smoke) runRelationshipProgressFocus(ctx context.Context) (err error) {
	originalRules, err := s.snapshotCPLevelRules(ctx, "cp")
	if err != nil {
		return err
	}
	// 聚焦烟测会临时写入确定性的等级规则；退出前恢复原规则，避免影响本地后台正在调的 CP 配置。
	defer func() {
		if restoreErr := s.restoreCPLevelRules(ctx, "cp", originalRules); err == nil && restoreErr != nil {
			err = restoreErr
		}
	}()
	// 关系和申请只使用固定 smoke 用户，退出时清掉这些事实，避免下一次烟测被旧 active 关系挡住。
	defer func() {
		if cleanupErr := s.cleanupCPFacts(ctx); err == nil && cleanupErr != nil {
			err = cleanupErr
		}
	}()
	if err := s.seed(ctx); err != nil {
		return err
	}
	if err := s.seedProgressLevelRules(ctx, "cp"); err != nil {
		return err
	}
	if err := s.seedProgressRelationship(ctx, 180, 2); err != nil {
		return err
	}
	if err := s.assertRelationshipProgressHTTP(ctx, cpRelationship{
		RelationshipID:        relationshipID(userA, userB),
		RelationType:          "cp",
		Status:                "active",
		IntimacyValue:         180,
		Level:                 2,
		CurrentLevelThreshold: 100,
		NextLevelThreshold:    300,
		NeededForNextLevel:    120,
		LevelProgressPercent:  40,
		MaxLevel:              false,
	}); err != nil {
		return err
	}
	if err := s.seedProgressRelationship(ctx, 1200, 5); err != nil {
		return err
	}
	return s.assertRelationshipProgressHTTP(ctx, cpRelationship{
		RelationshipID:        relationshipID(userA, userB),
		RelationType:          "cp",
		Status:                "active",
		IntimacyValue:         1200,
		Level:                 5,
		CurrentLevelThreshold: 1000,
		NextLevelThreshold:    0,
		NeededForNextLevel:    0,
		LevelProgressPercent:  100,
		MaxLevel:              true,
	})
}

func (s *smoke) snapshotCPLevelRules(ctx context.Context, relationType string) ([]cpLevelRuleSnapshot, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms
		FROM hyapp_user.user_cp_level_rules
		WHERE app_code = ? AND relation_type = ?
		ORDER BY level_no ASC`, appCode, relationType)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	rules := make([]cpLevelRuleSnapshot, 0)
	for rows.Next() {
		var rule cpLevelRuleSnapshot
		if err := rows.Scan(&rule.LevelNo, &rule.IntimacyThreshold, &rule.RewardResourceGroupID, &rule.LevelIconURL, &rule.Status, &rule.CreatedAtMS, &rule.UpdatedAtMS); err != nil {
			return nil, err
		}
		rules = append(rules, rule)
	}
	return rules, rows.Err()
}

func (s *smoke) restoreCPLevelRules(ctx context.Context, relationType string, rules []cpLevelRuleSnapshot) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	// 先删除当前 relation_type 的临时规则，再按快照恢复；这样新增、删除和禁用状态都能回到烟测前。
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM hyapp_user.user_cp_level_rules
		WHERE app_code = ? AND relation_type = ?`, appCode, relationType); err != nil {
		return err
	}
	for _, rule := range rules {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO hyapp_user.user_cp_level_rules (
				app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id,
				level_icon_url, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appCode, relationType, rule.LevelNo, rule.IntimacyThreshold, rule.RewardResourceGroupID,
			rule.LevelIconURL, rule.Status, rule.CreatedAtMS, rule.UpdatedAtMS,
		); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (s *smoke) seedProgressLevelRules(ctx context.Context, relationType string) error {
	nowMS := time.Now().UTC().UnixMilli()
	rules := []struct {
		level     int32
		threshold int64
	}{
		{1, 0},
		{2, 100},
		{3, 300},
		{4, 600},
		{5, 1000},
	}
	for _, rule := range rules {
		if _, err := s.db.ExecContext(ctx, `
			INSERT INTO hyapp_user.user_cp_level_rules (
				app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id,
				level_icon_url, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, 0, '', 'active', ?, ?)
			ON DUPLICATE KEY UPDATE
				intimacy_threshold = VALUES(intimacy_threshold),
				reward_resource_group_id = 0,
				level_icon_url = '',
				status = 'active',
				updated_at_ms = VALUES(updated_at_ms)`,
			appCode, relationType, rule.level, rule.threshold, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (s *smoke) seedProgressRelationship(ctx context.Context, intimacyValue int64, level int32) error {
	nowMS := time.Now().UTC().UnixMilli()
	a, b := orderedPair(userA, userB)
	// 直接写真实关系表，只绕过“送礼/申请/同意”前置链路；本用例验证的是已形成关系的服务端读模型和 gateway 输出。
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO hyapp_user.user_cp_relationships (
			app_code, relationship_id, user_a_id, user_b_id, relation_type, status,
			intimacy_value, level_no, source_application_id, formed_at_ms, ended_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 'cp', 'active', ?, ?, 'cp-smoke-progress', ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			relation_type = 'cp',
			status = 'active',
			intimacy_value = VALUES(intimacy_value),
			level_no = VALUES(level_no),
			updated_at_ms = VALUES(updated_at_ms)`,
		appCode, relationshipID(userA, userB), a, b, intimacyValue, level, nowMS, nowMS, nowMS,
	)
	return err
}

func (s *smoke) assertRelationshipProgressHTTP(ctx context.Context, want cpRelationship) error {
	got, err := s.getHTTPRelationship(ctx, userA, want.RelationType)
	if err != nil {
		return err
	}
	// 新增字段必须由 HTTP 响应直接给出；这里逐项比对，防止客户端继续用等级和亲密值自行推算进度。
	checks := []struct {
		name string
		got  any
		want any
	}{
		{"relationship_id", got.RelationshipID, want.RelationshipID},
		{"relation_type", got.RelationType, want.RelationType},
		{"status", got.Status, want.Status},
		{"intimacy_value", got.IntimacyValue, want.IntimacyValue},
		{"level", got.Level, want.Level},
		{"current_level_threshold", got.CurrentLevelThreshold, want.CurrentLevelThreshold},
		{"next_level_threshold", got.NextLevelThreshold, want.NextLevelThreshold},
		{"needed_for_next_level", got.NeededForNextLevel, want.NeededForNextLevel},
		{"level_progress_percent", got.LevelProgressPercent, want.LevelProgressPercent},
		{"max_level", got.MaxLevel, want.MaxLevel},
	}
	for _, check := range checks {
		if check.got != check.want {
			return fmt.Errorf("relationship %s mismatch: got=%v want=%v full=%+v", check.name, check.got, check.want, got)
		}
	}
	return nil
}

func (s *smoke) getHTTPRelationship(ctx context.Context, userID int64, relationType string) (cpRelationship, error) {
	resp, err := s.request(ctx, http.MethodGet, "/api/v1/cp/relationships?relation_type="+relationType+"&page=1&page_size=20", userID, nil)
	if err != nil {
		return cpRelationship{}, err
	}
	var data cpRelationshipListData
	if err := json.Unmarshal(resp.Data, &data); err != nil {
		return cpRelationship{}, err
	}
	if data.Total != 1 || len(data.Relationships) != 1 {
		return cpRelationship{}, fmt.Errorf("relationship list size mismatch total=%d len=%d data=%s", data.Total, len(data.Relationships), string(resp.Data))
	}
	return data.Relationships[0], nil
}

func (s *smoke) lookupUSRegion(ctx context.Context) (int64, error) {
	var regionID int64
	err := s.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`, appCode).Scan(&regionID)
	if err != nil {
		return 0, fmt.Errorf("lookup US region: %w", err)
	}
	return regionID, nil
}

func (s *smoke) cleanupCPFacts(ctx context.Context) error {
	ids := []int64{userA, userB, userC}
	relationshipIDAB := relationshipID(userA, userB)
	relationshipIDAC := relationshipID(userA, userC)
	statements := []string{
		`DELETE FROM hyapp_user.user_cp_level_reward_grants WHERE app_code = ? AND relationship_id IN (?, ?)`,
		`DELETE FROM hyapp_user.user_cp_relationships WHERE app_code = ? AND (user_a_id IN (?, ?, ?) OR user_b_id IN (?, ?, ?))`,
		`DELETE FROM hyapp_user.user_cp_applications WHERE app_code = ? AND (requester_user_id IN (?, ?, ?) OR target_user_id IN (?, ?, ?))`,
		`DELETE FROM hyapp_user.user_outbox WHERE app_code = ? AND event_type LIKE 'UserCP%' AND aggregate_id IN (?, ?, ?)`,
		`DELETE FROM hyapp_notice.notice_delivery_events
		 WHERE source_name = 'user_outbox' AND app_code = ? AND notice_type LIKE 'cp_%'
		   AND (JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL
		     OR JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL
		     OR JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL)`,
	}
	if _, err := s.db.ExecContext(ctx, statements[0], appCode, relationshipIDAB, relationshipIDAC); err != nil && !isMissingTable(err) {
		return err
	}
	if _, err := s.db.ExecContext(ctx, statements[1], appCode, ids[0], ids[1], ids[2], ids[0], ids[1], ids[2]); err != nil {
		return err
	}
	if _, err := s.db.ExecContext(ctx, statements[2], appCode, ids[0], ids[1], ids[2], ids[0], ids[1], ids[2]); err != nil {
		return err
	}
	if _, err := s.db.ExecContext(ctx, statements[3], appCode, ids[0], ids[1], ids[2]); err != nil {
		return err
	}
	_, err := s.db.ExecContext(ctx, statements[4], appCode, fmt.Sprint(userA), fmt.Sprint(userB), fmt.Sprint(userC))
	return err
}

func (s *smoke) cleanupRoomFacts(ctx context.Context) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	// smoke 使用固定用户反复造房；room-service 对 owner_user_id 有唯一约束，所以每次运行前必须清掉这些测试用户拥有的旧房间事实。
	// 所有删除都先通过 rooms.owner_user_id 限定范围，最后才删 rooms 本表，避免误清理其他本地调试房间。
	roomSubquery := `SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?, ?)`
	deleteByRoomStatements := []string{
		`DELETE FROM hyapp_room.room_background_images WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_snapshots WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_command_log WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_outbox WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_user_gift_stats WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_user_feed_entries WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_follows WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_region_pins WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
		`DELETE FROM hyapp_room.room_list_entries WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`,
	}
	for _, statement := range deleteByRoomStatements {
		if _, err := tx.ExecContext(ctx, statement, appCode, appCode, userA, userB, userC); err != nil {
			return err
		}
	}
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM hyapp_room.room_user_presence
		WHERE app_code = ?
		  AND (user_id IN (?, ?, ?) OR room_id IN (`+roomSubquery+`))`,
		appCode, userA, userB, userC, appCode, userA, userB, userC,
	); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM hyapp_room.rooms
		WHERE app_code = ? AND owner_user_id IN (?, ?, ?)`,
		appCode, userA, userB, userC,
	); err != nil {
		return err
	}
	return tx.Commit()
}

func (s *smoke) seedUsers(ctx context.Context, regionID int64) error {
	if _, err := s.db.ExecContext(ctx, `
		DELETE FROM hyapp_user.user_display_user_ids
		WHERE app_code = ? AND (user_id IN (?, ?, ?) OR display_user_id IN ('910001', '910002', '910003'))`,
		appCode, userA, userB, userC,
	); err != nil {
		return err
	}
	users := []struct {
		id     int64
		name   string
		avatar string
	}{
		{userA, "CP Smoke Alice", "https://cdn.example.com/cp-smoke-a.png"},
		{userB, "CP Smoke Bob", "https://cdn.example.com/cp-smoke-b.png"},
		{userC, "CP Smoke Cara", "https://cdn.example.com/cp-smoke-c.png"},
	}
	nowMS := time.Now().UTC().UnixMilli()
	for _, user := range users {
		displayID := strconv.FormatInt(user.id, 10)
		if _, err := s.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)`,
			appCode, user.id, displayID, displayID, user.name, regionID, user.avatar, nowMS, nowMS, nowMS,
		); err != nil {
			return err
		}
		if _, err := s.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', ?, ?, ?, ?)`,
			appCode, displayID, user.id, nowMS, nowMS, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (s *smoke) seedWallet(ctx context.Context) error {
	nowMS := time.Now().UTC().UnixMilli()
	for _, userID := range []int64{userA, userC} {
		if _, err := s.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', 1000000, 0, 1, ?, ?)
			ON DUPLICATE KEY UPDATE available_amount = 1000000, frozen_amount = 0, version = version + 1, updated_at_ms = VALUES(updated_at_ms)`,
			appCode, userID, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (s *smoke) seedGifts(ctx context.Context) error {
	gifts := []struct {
		id           string
		resourceCode string
		name         string
		relationType string
		icon         string
	}{
		{giftCP, "local_cp_gift_cp_resource", "Local CP Gift", "cp", "https://cdn.example.com/local-cp.png"},
		{giftBrother, "local_cp_gift_brother_resource", "Local Brother Gift", "brother", "https://cdn.example.com/local-brother.png"},
		{giftSister, "local_cp_gift_sister_resource", "Local Sister Gift", "sister", "https://cdn.example.com/local-sister.png"},
	}
	nowMS := time.Now().UTC().UnixMilli()
	for index, gift := range gifts {
		resourceID, err := s.upsertGiftResource(ctx, gift.resourceCode, gift.name, gift.icon, int32(index+1)*10, nowMS)
		if err != nil {
			return err
		}
		presentation := fmt.Sprintf(`{"cp_relation_type":"%s","cp_relation_label":"%s"}`, gift.relationType, gift.name)
		if _, err := s.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', ?, ?, CAST(? AS JSON), 'cp', 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 = 'cp',
				effective_from_ms = 0,
				effective_to_ms = 0,
				effect_types_json = JSON_ARRAY(),
				updated_at_ms = VALUES(updated_at_ms)`,
			appCode, gift.id, resourceID, gift.name, int32(index+1)*10, presentation, nowMS, nowMS,
		); err != nil {
			return err
		}
		if _, err := s.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 (?, ?, 'smoke-v1', 'active', 'COIN', 10, 0, 10, 0, ?, ?)
			ON DUPLICATE KEY UPDATE
				status = 'active',
				charge_asset_type = 'COIN',
				coin_price = 10,
				gift_point_amount = 0,
				heat_value = 10,
				effective_at_ms = 0,
				updated_at_ms = VALUES(updated_at_ms)`,
			appCode, gift.id, nowMS, nowMS,
		); err != nil {
			return err
		}
		if _, err := s.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)`,
			appCode, gift.id, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (s *smoke) upsertGiftResource(ctx context.Context, code string, name string, icon string, sortOrder int32, nowMS int64) (int64, error) {
	if _, err := s.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', ?, 'active', TRUE, TRUE, 'permanent', '', 0, 'coin', 10, 0,
			JSON_ARRAY('gift_panel'), ?, ?, '', JSON_OBJECT(), ?, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			resource_type = 'gift',
			name = VALUES(name),
			status = 'active',
			asset_url = VALUES(asset_url),
			preview_url = VALUES(preview_url),
			sort_order = VALUES(sort_order),
			updated_at_ms = VALUES(updated_at_ms)`,
		appCode, code, name, icon, icon, sortOrder, nowMS, nowMS,
	); err != nil {
		return 0, err
	}
	var resourceID int64
	err := s.db.QueryRowContext(ctx, `SELECT resource_id FROM hyapp_wallet.resources WHERE app_code = ? AND resource_code = ?`, appCode, code).Scan(&resourceID)
	return resourceID, err
}

func (s *smoke) importIMAccounts(ctx context.Context) error {
	for _, userID := range []int64{userA, userB, userC} {
		if _, err := s.request(ctx, http.MethodGet, "/api/v1/im/usersig", userID, nil); err != nil {
			return fmt.Errorf("import im account %d: %w", userID, err)
		}
	}
	return nil
}

func (s *smoke) createRoomAndJoin(ctx context.Context) error {
	// CreateRoom 走真实 gateway->room-service 校验，seat_count 必须使用后台允许的麦位数，
	// 这里固定 10 麦避免测试数据绕过当前房间配置边界。
	resp, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/create", userA, map[string]any{
		"command_id":       commandID("create_room"),
		"seat_count":       10,
		"mode":             "voice",
		"room_name":        "CP Smoke Room",
		"room_avatar":      "https://cdn.example.com/cp-smoke-room.png",
		"room_description": "cp smoke",
	})
	if err != nil {
		return err
	}
	roomID := jsonString(resp.Data, "room", "room_id")
	if roomID == "" {
		return fmt.Errorf("create room response missing room.room_id: %s", string(resp.Data))
	}
	s.roomID = roomID
	for _, userID := range []int64{userA, userB, userC} {
		if _, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/join", userID, map[string]any{
			"room_id":    roomID,
			"command_id": commandID(fmt.Sprintf("join_%d", userID)),
			"role":       "audience",
		}); err != nil {
			return fmt.Errorf("join user %d: %w", userID, err)
		}
	}
	panel, err := s.request(ctx, http.MethodGet, "/api/v1/rooms/"+roomID+"/gift-panel", userA, nil)
	if err != nil {
		return err
	}
	if !jsonArrayContainsGift(panel.Data, giftCP) {
		return fmt.Errorf("gift panel does not contain %s", giftCP)
	}
	return nil
}

func (s *smoke) testCreateCPApplication(ctx context.Context) error {
	if err := s.cleanupCPFacts(ctx); err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftCP, "t01_cp"); err != nil {
		return err
	}
	app, err := s.waitApplication(ctx, userA, userB, "cp", "pending")
	if err != nil {
		return err
	}
	if app.ExpiresAtMS <= time.Now().UTC().Add(23*time.Hour).UnixMilli() {
		return fmt.Errorf("application expires_at_ms is not about 24h later: %+v", app)
	}
	if err := s.waitNotice(ctx, "UserCPApplicationCreated", "tencent_im_c2c", "cp_application_created", userB, "delivered"); err != nil {
		return err
	}
	return s.waitNotice(ctx, "UserCPApplicationCreated", "tencent_im_room", "cp_application_created", userB, "delivered")
}

func (s *smoke) testAcceptApplication(ctx context.Context) error {
	app, err := s.waitApplication(ctx, userA, userB, "cp", "pending")
	if err != nil {
		return err
	}
	resp, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userB, nil)
	if err != nil {
		return err
	}
	if jsonString(resp.Data, "relationship", "relationship_id") != relationshipID(userA, userB) {
		return fmt.Errorf("accept response relationship mismatch: %s", string(resp.Data))
	}
	if _, err := s.waitRelationship(ctx, userA, userB, "cp", 0); err != nil {
		return err
	}
	if err := s.waitApplicationStatus(ctx, app.ApplicationID, "accepted"); err != nil {
		return err
	}
	if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_c2c", "cp_application_accepted", userA, "delivered"); err != nil {
		return err
	}
	if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_c2c_acceptor", "cp_application_accepted", userB, "delivered"); err != nil {
		return err
	}
	if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_room", "cp_application_accepted", userA, "delivered"); err != nil {
		return err
	}
	return s.waitNotice(ctx, "UserCPRelationshipCreated", "tencent_im_region", "cp_relationship_created", 0, "delivered")
}

func (s *smoke) testIntimacyIncrement(ctx context.Context) error {
	before, err := s.waitRelationship(ctx, userA, userB, "cp", 0)
	if err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftCP, "t03_cp_again"); err != nil {
		return err
	}
	after, err := s.waitRelationship(ctx, userA, userB, "cp", before.IntimacyValue+10)
	if err != nil {
		return err
	}
	if after.IntimacyValue != before.IntimacyValue+10 {
		return fmt.Errorf("intimacy mismatch before=%d after=%d", before.IntimacyValue, after.IntimacyValue)
	}
	return s.waitUserOutbox(ctx, "UserCPRelationshipIntimacyChanged")
}

func (s *smoke) testPairSingleRelation(ctx context.Context) error {
	if err := s.cleanupCPFacts(ctx); err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftBrother, "t04_brother"); err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftSister, "t04_sister"); err != nil {
		return err
	}
	brother, err := s.waitApplication(ctx, userA, userB, "brother", "pending")
	if err != nil {
		return err
	}
	sister, err := s.waitApplication(ctx, userA, userB, "sister", "pending")
	if err != nil {
		return err
	}
	if _, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(brother.ApplicationID)+"/accept", userB, nil); err != nil {
		return err
	}
	if err := s.waitApplicationStatus(ctx, sister.ApplicationID, "blocked"); err != nil {
		return err
	}
	err = s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(sister.ApplicationID)+"/accept", userB, nil, "CONFLICT")
	if err != nil {
		return err
	}
	_, err = s.waitRelationship(ctx, userA, userB, "brother", 0)
	return err
}

func (s *smoke) testUserSingleRelation(ctx context.Context) error {
	if err := s.sendGift(ctx, userC, userA, giftCP, "t05_c_to_a"); err != nil {
		return err
	}
	app, err := s.waitApplication(ctx, userC, userA, "cp", "pending")
	if err != nil {
		return err
	}
	if err := s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userA, nil, "CONFLICT"); err != nil {
		return err
	}
	if _, err := s.waitRelationship(ctx, userA, userB, "brother", 0); err != nil {
		return err
	}
	return s.assertNoRelationship(ctx, userA, userC)
}

func (s *smoke) testExpiredApplication(ctx context.Context) error {
	if err := s.cleanupCPFacts(ctx); err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftCP, "t06_expire"); err != nil {
		return err
	}
	app, err := s.waitApplication(ctx, userA, userB, "cp", "pending")
	if err != nil {
		return err
	}
	if _, err := s.db.ExecContext(ctx, `
		UPDATE hyapp_user.user_cp_applications SET expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`, time.Now().UTC().Add(-time.Minute).UnixMilli(), time.Now().UTC().UnixMilli(), appCode, app.ApplicationID,
	); err != nil {
		return err
	}
	if err := s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userB, nil, "CONFLICT"); err != nil {
		return err
	}
	return s.waitApplicationStatus(ctx, app.ApplicationID, "expired")
}

func (s *smoke) testRejectApplication(ctx context.Context) error {
	if err := s.cleanupCPFacts(ctx); err != nil {
		return err
	}
	if err := s.sendGift(ctx, userA, userB, giftCP, "t07_reject"); err != nil {
		return err
	}
	app, err := s.waitApplication(ctx, userA, userB, "cp", "pending")
	if err != nil {
		return err
	}
	if _, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/reject", userB, map[string]any{"reason": "smoke"}); err != nil {
		return err
	}
	if err := s.waitApplicationStatus(ctx, app.ApplicationID, "rejected"); err != nil {
		return err
	}
	if err := s.assertNoRelationship(ctx, userA, userB); err != nil {
		return err
	}
	if err := s.waitNotice(ctx, "UserCPApplicationRejected", "tencent_im_c2c", "cp_application_rejected", userA, "delivered"); err != nil {
		return err
	}
	return s.waitNotice(ctx, "UserCPApplicationRejected", "tencent_im_room", "cp_application_rejected", userA, "delivered")
}

func (s *smoke) sendGift(ctx context.Context, senderID int64, targetID int64, giftID string, key string) error {
	_, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/gift/send", senderID, map[string]any{
		"room_id":         s.roomID,
		"command_id":      commandID(key),
		"target_type":     "user",
		"target_user_ids": []int64{targetID},
		"gift_id":         giftID,
		"gift_count":      1,
	})
	return err
}

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

func (s *smoke) expectCode(ctx context.Context, method string, path string, userID int64, body any, expected string) error {
	out, err := s.request(ctx, method, path, userID, body)
	if err == nil {
		return fmt.Errorf("expected code %s, got OK data=%s", expected, string(out.Data))
	}
	if out.Code != expected {
		return fmt.Errorf("expected code %s, got %s: %v", expected, out.Code, err)
	}
	return nil
}

func (s *smoke) waitApplication(ctx context.Context, requester int64, target int64, relationType string, status string) (cpApplication, error) {
	var app cpApplication
	err := wait(ctx, 25*time.Second, func() error {
		row := s.db.QueryRowContext(ctx, `
			SELECT application_id, relation_type, status, room_id, expires_at_ms
			FROM hyapp_user.user_cp_applications
			WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ? AND relation_type = ? AND status = ?
			ORDER BY updated_at_ms DESC LIMIT 1`,
			appCode, requester, target, relationType, status,
		)
		return row.Scan(&app.ApplicationID, &app.RelationType, &app.Status, &app.RoomID, &app.ExpiresAtMS)
	})
	return app, err
}

func (s *smoke) waitApplicationStatus(ctx context.Context, applicationID string, status string) error {
	return wait(ctx, 20*time.Second, func() error {
		var got string
		err := s.db.QueryRowContext(ctx, `
			SELECT status FROM hyapp_user.user_cp_applications WHERE app_code = ? AND application_id = ?`,
			appCode, applicationID,
		).Scan(&got)
		if err != nil {
			return err
		}
		if got != status {
			return fmt.Errorf("application %s status=%s, want %s", applicationID, got, status)
		}
		return nil
	})
}

func (s *smoke) waitRelationship(ctx context.Context, user1 int64, user2 int64, relationType string, minIntimacy int64) (cpRelationship, error) {
	var rel cpRelationship
	a, b := orderedPair(user1, user2)
	err := wait(ctx, 25*time.Second, func() error {
		row := s.db.QueryRowContext(ctx, `
			SELECT relationship_id, relation_type, status, intimacy_value, level_no
			FROM hyapp_user.user_cp_relationships
			WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND relation_type = ? AND status = 'active'
			LIMIT 1`,
			appCode, a, b, relationType,
		)
		if err := row.Scan(&rel.RelationshipID, &rel.RelationType, &rel.Status, &rel.IntimacyValue, &rel.Level); err != nil {
			return err
		}
		if rel.IntimacyValue < minIntimacy {
			return fmt.Errorf("relationship intimacy=%d, want >=%d", rel.IntimacyValue, minIntimacy)
		}
		return nil
	})
	return rel, err
}

func (s *smoke) assertNoRelationship(ctx context.Context, user1 int64, user2 int64) error {
	a, b := orderedPair(user1, user2)
	var count int64
	if err := s.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM hyapp_user.user_cp_relationships
		WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND status = 'active'`,
		appCode, a, b,
	).Scan(&count); err != nil {
		return err
	}
	if count != 0 {
		return fmt.Errorf("unexpected active relationship for %d/%d", user1, user2)
	}
	return nil
}

func (s *smoke) waitUserOutbox(ctx context.Context, eventType string) error {
	return wait(ctx, 20*time.Second, func() error {
		var count int64
		err := s.db.QueryRowContext(ctx, `
			SELECT COUNT(*) FROM hyapp_user.user_outbox
			WHERE app_code = ? AND event_type = ? AND created_at_ms >= ?`,
			appCode, eventType, s.startMS,
		).Scan(&count)
		if err != nil {
			return err
		}
		if count == 0 {
			return fmt.Errorf("missing user_outbox %s", eventType)
		}
		return nil
	})
}

func (s *smoke) waitNotice(ctx context.Context, sourceEventType string, channel string, noticeType string, targetUserID int64, wantStatus string) error {
	return wait(ctx, 35*time.Second, func() error {
		query := `
			SELECT nde.status, COALESCE(nde.last_error, '')
			FROM hyapp_notice.notice_delivery_events nde
			JOIN hyapp_user.user_outbox uo
			  ON uo.app_code = nde.app_code AND uo.event_id = nde.source_event_id
			WHERE nde.source_name = 'user_outbox'
			  AND nde.app_code = ?
			  AND uo.event_type = ?
			  AND nde.channel = ?
			  AND nde.notice_type = ?
			  AND nde.created_at_ms >= ?`
		args := []any{appCode, sourceEventType, channel, noticeType, s.startMS}
		if targetUserID > 0 {
			query += ` AND nde.target_user_id = ?`
			args = append(args, targetUserID)
		}
		query += ` ORDER BY nde.updated_at_ms DESC LIMIT 1`
		var status, lastErr string
		err := s.db.QueryRowContext(ctx, query, args...).Scan(&status, &lastErr)
		if err != nil {
			return err
		}
		if status != wantStatus {
			return fmt.Errorf("notice %s/%s/%s status=%s last_error=%s", sourceEventType, channel, noticeType, status, lastErr)
		}
		return nil
	})
}

func wait(ctx context.Context, timeout time.Duration, fn func() error) error {
	deadline := time.Now().Add(timeout)
	var last error
	for time.Now().Before(deadline) {
		if err := fn(); err != nil {
			last = err
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(500 * time.Millisecond):
				continue
			}
		}
		return nil
	}
	if last == nil {
		last = errors.New("condition not satisfied")
	}
	return last
}

func tokenForUser(userID int64) string {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id":           strconv.FormatInt(userID, 10),
		"app_code":          appCode,
		"sid":               "cp-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(jwtSecret))
	if err != nil {
		panic(err)
	}
	return signed
}

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

func jsonString(raw json.RawMessage, path ...string) string {
	var cur any
	if err := json.Unmarshal(raw, &cur); err != nil {
		return ""
	}
	for _, key := range path {
		obj, ok := cur.(map[string]any)
		if !ok {
			return ""
		}
		cur = obj[key]
	}
	switch value := cur.(type) {
	case string:
		return value
	case float64:
		if value == float64(int64(value)) {
			return strconv.FormatInt(int64(value), 10)
		}
	}
	return ""
}

func jsonArrayContainsGift(raw json.RawMessage, giftID string) bool {
	var obj map[string]any
	if err := json.Unmarshal(raw, &obj); err != nil {
		return false
	}
	items, _ := obj["gifts"].([]any)
	for _, item := range items {
		gift, _ := item.(map[string]any)
		if strings.TrimSpace(fmt.Sprint(gift["gift_id"])) == giftID {
			return true
		}
	}
	return false
}

func orderedPair(a int64, b int64) (int64, int64) {
	if a < b {
		return a, b
	}
	return b, a
}

func relationshipID(a int64, b int64) string {
	x, y := orderedPair(a, b)
	return fmt.Sprintf("cp_rel:%d:%d", x, y)
}

func urlPath(value string) string {
	return strings.ReplaceAll(value, "/", "%2F")
}

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

func envDuration(key string, fallback time.Duration) time.Duration {
	if value := strings.TrimSpace(os.Getenv(key)); value != "" {
		if parsed, err := time.ParseDuration(value); err == nil {
			return parsed
		}
	}
	return fallback
}

func isMissingTable(err error) bool {
	return strings.Contains(err.Error(), "doesn't exist")
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "cp smoke failed:", err)
	os.Exit(1)
}
