package cp

import (
	"context"
	"database/sql"
	"fmt"
	"testing"

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	cpdomain "hyapp/services/user-service/internal/domain/cp"
	"hyapp/services/user-service/internal/testutil/mysqlschema"
)

func TestExpiredApplicationOperationsPersistExpiredStatus(t *testing.T) {
	tests := []struct {
		name          string
		applicationID string
		run           func(context.Context, *Repository, string, int64) error
	}{
		{
			name:          "accept expired",
			applicationID: "cp_expired_accept",
			run: func(ctx context.Context, repo *Repository, applicationID string, nowMS int64) error {
				_, _, err := repo.AcceptApplication(ctx, 1002, applicationID, nowMS)
				return err
			},
		},
		{
			name:          "reject expired",
			applicationID: "cp_expired_reject",
			run: func(ctx context.Context, repo *Repository, applicationID string, nowMS int64) error {
				_, err := repo.RejectApplication(ctx, 1002, applicationID, "too late", nowMS)
				return err
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			schema := mysqlschema.New(t)
			repo := New(schema.DB)
			ctx := appcode.WithContext(context.Background(), appcode.Default)
			nowMS := int64(1700000000000)

			seedCPTestUser(t, schema.DB, 1001, "CP Alice", nowMS)
			seedCPTestUser(t, schema.DB, 1002, "CP Bob", nowMS)
			seedExpiredCPApplication(t, schema.DB, test.applicationID, 1001, 1002, nowMS)

			err := test.run(ctx, repo, test.applicationID, nowMS)
			if xerr.CodeOf(err) != xerr.Conflict {
				t.Fatalf("expired operation must return conflict, got %v", err)
			}

			var status string
			if err := schema.DB.QueryRowContext(ctx, `
				SELECT status
				FROM user_cp_applications
				WHERE app_code = ? AND application_id = ?`,
				appcode.Default, test.applicationID,
			).Scan(&status); err != nil {
				t.Fatalf("read application status failed: %v", err)
			}
			if status != cpdomain.ApplicationStatusExpired {
				t.Fatalf("expired operation must persist status=%s, got %s", cpdomain.ApplicationStatusExpired, status)
			}
		})
	}
}

func TestAcceptApplicationUsesSiblingCapacityConfig(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	for _, userID := range []int64{2001, 2002, 2003, 2004} {
		seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("Brother %d", userID), nowMS)
	}
	seedRelationConfig(t, schema.DB, cpdomain.RelationTypeBrother, 2, nowMS)
	seedPendingCPApplication(t, schema.DB, "brother_1", cpdomain.RelationTypeBrother, 2001, 2002, nowMS)
	seedPendingCPApplication(t, schema.DB, "brother_2", cpdomain.RelationTypeBrother, 2001, 2003, nowMS)
	seedPendingCPApplication(t, schema.DB, "brother_3", cpdomain.RelationTypeBrother, 2001, 2004, nowMS)

	if _, _, err := repo.AcceptApplication(ctx, 2002, "brother_1", nowMS); err != nil {
		t.Fatalf("accept first brother relationship failed: %v", err)
	}
	if _, _, err := repo.AcceptApplication(ctx, 2003, "brother_2", nowMS+1); err != nil {
		t.Fatalf("accept second brother relationship failed: %v", err)
	}
	if _, _, err := repo.AcceptApplication(ctx, 2004, "brother_3", nowMS+2); xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("third brother relationship must hit configured limit, got %v", err)
	}
}

func TestAcceptApplicationKeepsCPHardLimitAndIdentifiesOccupiedParty(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	for _, userID := range []int64{3001, 3002, 3003, 3004} {
		seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("CP %d", userID), nowMS)
	}
	seedRelationConfig(t, schema.DB, cpdomain.RelationTypeCP, 9, nowMS)
	seedPendingCPApplication(t, schema.DB, "cp_1", cpdomain.RelationTypeCP, 3001, 3002, nowMS)
	seedPendingCPApplication(t, schema.DB, "cp_other_occupied", cpdomain.RelationTypeCP, 3001, 3003, nowMS)
	seedPendingCPApplication(t, schema.DB, "cp_self_occupied", cpdomain.RelationTypeCP, 3004, 3002, nowMS)

	if _, _, err := repo.AcceptApplication(ctx, 3002, "cp_1", nowMS); err != nil {
		t.Fatalf("accept first cp relationship failed: %v", err)
	}
	if _, _, err := repo.AcceptApplication(ctx, 3003, "cp_other_occupied", nowMS+1); xerr.CodeOf(err) != xerr.CPAlreadyExistsOther {
		t.Fatalf("occupied requester must return other-party cp error, got %v", err)
	}
	if _, _, err := repo.AcceptApplication(ctx, 3002, "cp_self_occupied", nowMS+2); xerr.CodeOf(err) != xerr.CPAlreadyExistsSelf {
		t.Fatalf("occupied actor must return self cp error, got %v", err)
	}
}

func TestListRelationshipsIncludesServerLevelProgress(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	for _, userID := range []int64{4001, 4002, 4003} {
		seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("Progress %d", userID), nowMS)
	}
	seedCPLevelRules(t, schema.DB, cpdomain.RelationTypeBrother, []int64{0, 100, 300, 600, 1000}, nowMS)
	seedActiveCPRelationship(t, schema.DB, "progress_mid", cpdomain.RelationTypeBrother, 4001, 4002, 180, 2, nowMS)
	seedActiveCPRelationship(t, schema.DB, "progress_max", cpdomain.RelationTypeBrother, 4001, 4003, 1200, 5, nowMS+1)

	relationships, total, err := repo.ListRelationships(ctx, 4001, cpdomain.RelationTypeBrother, 1, 20)
	if err != nil {
		t.Fatalf("list relationships failed: %v", err)
	}
	if total != 2 || len(relationships) != 2 {
		t.Fatalf("expected two relationships, total=%d len=%d", total, len(relationships))
	}

	byID := map[string]cpdomain.Relationship{}
	for _, relationship := range relationships {
		byID[relationship.RelationshipID] = relationship
	}
	mid := byID["progress_mid"]
	if mid.CurrentLevelThreshold != 100 || mid.NextLevelThreshold != 300 || mid.NeededForNextLevel != 120 || mid.LevelProgressPercent != 40 || mid.MaxLevel {
		t.Fatalf("mid-level progress mismatch: %+v", mid)
	}
	max := byID["progress_max"]
	if max.CurrentLevelThreshold != 1000 || max.NextLevelThreshold != 0 || max.NeededForNextLevel != 0 || max.LevelProgressPercent != 100 || !max.MaxLevel {
		t.Fatalf("max-level progress mismatch: %+v", max)
	}
}

func TestListActiveIntimacyLeaderboardEntriesOrdersAndIncludesBothUsers(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	for _, userID := range []int64{4101, 4102, 4103, 4104} {
		seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("Rank %d", userID), nowMS)
	}
	seedActiveCPRelationship(t, schema.DB, "rank_low", cpdomain.RelationTypeCP, 4101, 4102, 90, 1, nowMS)
	seedActiveCPRelationship(t, schema.DB, "rank_high", cpdomain.RelationTypeBrother, 4103, 4104, 220, 2, nowMS+1)

	entries, err := repo.ListActiveIntimacyLeaderboardEntries(ctx, 10)
	if err != nil {
		t.Fatalf("list intimacy leaderboard entries failed: %v", err)
	}
	if len(entries) != 2 {
		t.Fatalf("entries len=%d, want 2: %+v", len(entries), entries)
	}
	if entries[0].RelationshipID != "rank_high" || entries[0].IntimacyValue != 220 {
		t.Fatalf("highest intimacy relationship should rank first: %+v", entries[0])
	}
	if entries[0].UserA.DisplayUserID != "4103" || entries[0].UserB.DisplayUserID != "4104" || entries[0].UserA.Avatar == "" || entries[0].UserB.Username == "" {
		t.Fatalf("entry must include both user snapshots: %+v", entries[0])
	}
}

func TestListWeeklyRankEntriesUsesWeeklyGiftValueNotCumulativeIntimacy(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	startMS := int64(1780876800000)
	endMS := startMS + 7*24*60*60*1000
	nowMS := startMS + 1234

	for _, userID := range []int64{7001, 7002, 7003, 7004, 7005, 7006} {
		seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("Weekly %d", userID), nowMS)
	}
	seedActiveCPRelationship(t, schema.DB, "weekly_total_high", cpdomain.RelationTypeCP, 7001, 7002, 9000, 9, nowMS)
	seedActiveCPRelationship(t, schema.DB, "weekly_delta_high", cpdomain.RelationTypeCP, 7003, 7004, 100, 1, nowMS+1)
	seedActiveCPRelationship(t, schema.DB, "weekly_brother", cpdomain.RelationTypeBrother, 7005, 7006, 1, 1, nowMS+2)

	seedCPGiftConsumption(t, schema.DB, "evt_total_high_start", "relationship_intimacy_added", "weekly_total_high", 10, startMS, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_delta_high_start", "relationship_intimacy_added", "weekly_delta_high", 70, startMS, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_delta_high_end_minus_one", "relationship_intimacy_added", "weekly_delta_high", 30, endMS-1, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_delta_high_end_excluded", "relationship_intimacy_added", "weekly_delta_high", 999, endMS, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_delta_high_zero_ignored", "relationship_intimacy_added", "weekly_delta_high", 0, startMS+1, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_delta_high_status_ignored", "skipped", "weekly_delta_high", 500, startMS+2, nowMS)
	seedCPGiftConsumption(t, schema.DB, "evt_brother_relation_ignored", "relationship_intimacy_added", "weekly_brother", 1000, startMS+3, nowMS)

	entries, err := repo.ListWeeklyRankEntries(ctx, cpdomain.RelationTypeCP, startMS, endMS, 10)
	if err != nil {
		t.Fatalf("list weekly rank entries failed: %v", err)
	}
	if len(entries) != 2 {
		t.Fatalf("weekly entries len=%d, want 2: %+v", len(entries), entries)
	}
	if entries[0].RelationshipID != "weekly_delta_high" || entries[0].Score != 100 || entries[0].Rank != 1 {
		t.Fatalf("weekly rank must order by in-window gift_value delta, got first=%+v", entries[0])
	}
	if entries[0].FirstScoredAtMS != startMS || entries[0].LastScoredAtMS != endMS-1 {
		t.Fatalf("weekly rank must use [start,end) boundary times, got first=%d last=%d", entries[0].FirstScoredAtMS, entries[0].LastScoredAtMS)
	}
	if entries[1].RelationshipID != "weekly_total_high" || entries[1].Score != 10 || entries[1].Rank != 2 {
		t.Fatalf("cumulative intimacy must not outrank weekly delta score, got second=%+v", entries[1])
	}
}

func TestConsumeGiftEventAddsIntimacyForNormalGiftWhenRelationshipExists(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 4201, "Normal Gift Alice", nowMS)
	seedCPTestUser(t, schema.DB, 4202, "Normal Gift Bob", nowMS)
	seedCPLevelRules(t, schema.DB, cpdomain.RelationTypeCP, []int64{0, 100, 300}, nowMS)
	seedActiveCPRelationship(t, schema.DB, "normal_gift_cp", cpdomain.RelationTypeCP, 4201, 4202, 90, 1, nowMS)

	result, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_normal_gift_cp",
		RoomID:           "room-normal",
		RoomVersion:      3,
		RoomLocked:       true,
		OccurredAtMS:     nowMS + 1,
		SenderUserID:     4201,
		TargetUserID:     4202,
		GiftID:           "rose",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-normal-gift-cp",
		CommandID:        "cmd-normal-gift-cp",
		GiftTypeCode:     "normal",
	}, nowMS+2)
	if err != nil {
		t.Fatalf("consume normal gift for active cp failed: %v", err)
	}
	if !result.Consumed || result.Relationship.RelationshipID != "normal_gift_cp" {
		t.Fatalf("normal gift must update existing relationship: %+v", result)
	}
	if result.Relationship.IntimacyValue != 127 || result.Relationship.Level != 2 {
		t.Fatalf("relationship intimacy/level mismatch: %+v", result.Relationship)
	}

	var status string
	if err := schema.DB.QueryRowContext(ctx, `
		SELECT status
		FROM user_cp_gift_event_consumption
		WHERE app_code = ? AND room_event_id = ?`,
		appcode.Default, "room_evt_normal_gift_cp",
	).Scan(&status); err != nil {
		t.Fatalf("read gift consumption failed: %v", err)
	}
	if status != "relationship_intimacy_added" {
		t.Fatalf("normal gift consumption status mismatch: %s", status)
	}
}

func TestConsumeGiftEventIgnoresNormalGiftWithoutRelationship(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 4301, "No CP Alice", nowMS)
	seedCPTestUser(t, schema.DB, 4302, "No CP Bob", nowMS)

	result, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_normal_no_cp",
		RoomID:           "room-normal",
		RoomVersion:      3,
		OccurredAtMS:     nowMS + 1,
		SenderUserID:     4301,
		TargetUserID:     4302,
		GiftID:           "rose",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-normal-no-cp",
		CommandID:        "cmd-normal-no-cp",
		GiftTypeCode:     "normal",
	}, nowMS+2)
	if err != nil {
		t.Fatalf("consume normal gift without cp failed: %v", err)
	}
	if result.Consumed {
		t.Fatalf("normal gift without active relationship must be ignored: %+v", result)
	}

	var applicationCount int64
	if err := schema.DB.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM user_cp_applications
		WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ?`,
		appcode.Default, 4301, 4302,
	).Scan(&applicationCount); err != nil {
		t.Fatalf("count cp applications failed: %v", err)
	}
	if applicationCount != 0 {
		t.Fatalf("normal gift without relationship must not create application, got %d", applicationCount)
	}
}

func TestConsumeGiftEventCreatesNewApplicationAfterRejected(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 4401, "Invite Alice", nowMS)
	seedCPTestUser(t, schema.DB, 4402, "Invite Bob", nowMS)

	// 第一次 CP 礼物只生成 pending 申请；目标用户拒绝后，这条申请必须保持终态，不允许后续礼物回写成 pending。
	first, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_cp_invite_1",
		RoomID:           "room-cp",
		RoomVersion:      3,
		OccurredAtMS:     nowMS + 1,
		SenderUserID:     4401,
		TargetUserID:     4402,
		GiftID:           "cp_gift",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-cp-invite-1",
		CommandID:        "cmd-cp-invite-1",
		CPRelationType:   cpdomain.RelationTypeCP,
		GiftName:         "CP Gift",
	}, nowMS+2)
	if err != nil {
		t.Fatalf("consume first cp gift failed: %v", err)
	}
	if !first.Consumed || first.Application.Status != cpdomain.ApplicationStatusPending {
		t.Fatalf("first cp gift must create pending application: %+v", first)
	}
	if _, err := repo.RejectApplication(ctx, 4402, first.Application.ApplicationID, "declined", nowMS+3); err != nil {
		t.Fatalf("reject first cp application failed: %v", err)
	}

	// 第二次送礼来自新的 room outbox event，应创建新的 pending 申请，不能复用或覆盖旧 rejected 申请。
	second, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_cp_invite_2",
		RoomID:           "room-cp",
		RoomVersion:      4,
		OccurredAtMS:     nowMS + 4,
		SenderUserID:     4401,
		TargetUserID:     4402,
		GiftID:           "cp_gift",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-cp-invite-2",
		CommandID:        "cmd-cp-invite-2",
		CPRelationType:   cpdomain.RelationTypeCP,
		GiftName:         "CP Gift",
	}, nowMS+5)
	if err != nil {
		t.Fatalf("consume second cp gift failed: %v", err)
	}
	if !second.Consumed || second.Application.Status != cpdomain.ApplicationStatusPending {
		t.Fatalf("second cp gift must create pending application: %+v", second)
	}
	if second.Application.ApplicationID == first.Application.ApplicationID {
		t.Fatalf("rejected application must not be reused: first=%s second=%s", first.Application.ApplicationID, second.Application.ApplicationID)
	}

	statuses := map[string]string{}
	rows, err := schema.DB.QueryContext(ctx, `
		SELECT application_id, status
		FROM user_cp_applications
		WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ?
		ORDER BY created_at_ms ASC`,
		appcode.Default, 4401, 4402,
	)
	if err != nil {
		t.Fatalf("list cp applications failed: %v", err)
	}
	defer rows.Close()
	for rows.Next() {
		var applicationID string
		var status string
		if err := rows.Scan(&applicationID, &status); err != nil {
			t.Fatalf("scan cp application status failed: %v", err)
		}
		statuses[applicationID] = status
	}
	if err := rows.Err(); err != nil {
		t.Fatalf("iterate cp application statuses failed: %v", err)
	}
	if statuses[first.Application.ApplicationID] != cpdomain.ApplicationStatusRejected {
		t.Fatalf("first application must remain rejected: %+v", statuses)
	}
	if statuses[second.Application.ApplicationID] != cpdomain.ApplicationStatusPending {
		t.Fatalf("second application must remain pending: %+v", statuses)
	}
}

func TestConsumeGiftEventWritesDistinctOutboxEventsInSameMillisecond(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 4501, "Outbox Alice", nowMS)
	seedCPTestUser(t, schema.DB, 4502, "Outbox Bob", nowMS)

	// 同一 pending 申请被后续礼物刷新时仍会产生新的通知事实；event_id 不能只按目标用户和毫秒拼接，否则 IM 会被主键碰撞吞掉。
	first, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_cp_outbox_1",
		RoomID:           "room-cp-outbox",
		RoomVersion:      3,
		RoomLocked:       true,
		OccurredAtMS:     nowMS + 1,
		SenderUserID:     4501,
		TargetUserID:     4502,
		GiftID:           "cp_gift",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-cp-outbox-1",
		CommandID:        "cmd-cp-outbox-1",
		CPRelationType:   cpdomain.RelationTypeCP,
		GiftName:         "CP Gift",
	}, nowMS+2)
	if err != nil {
		t.Fatalf("consume first cp outbox gift failed: %v", err)
	}
	second, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
		AppCode:          appcode.Default,
		EventID:          "room_evt_cp_outbox_2",
		RoomID:           "room-cp-outbox",
		RoomVersion:      4,
		OccurredAtMS:     nowMS + 2,
		SenderUserID:     4501,
		TargetUserID:     4502,
		GiftID:           "cp_gift",
		GiftCount:        1,
		GiftValue:        37,
		BillingReceiptID: "receipt-cp-outbox-2",
		CommandID:        "cmd-cp-outbox-2",
		CPRelationType:   cpdomain.RelationTypeCP,
		GiftName:         "CP Gift",
	}, nowMS+2)
	if err != nil {
		t.Fatalf("consume second cp outbox gift failed: %v", err)
	}
	if first.Application.ApplicationID != second.Application.ApplicationID {
		t.Fatalf("same pending application should be refreshed before a decision: first=%s second=%s", first.Application.ApplicationID, second.Application.ApplicationID)
	}
	if !first.Application.RoomLocked {
		t.Fatalf("first CP application must persist room lock snapshot: %+v", first.Application)
	}

	var outboxCount int64
	if err := schema.DB.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM user_outbox
		WHERE app_code = ?
		  AND event_type = ?
		  AND aggregate_type = 'cp_application'
		  AND aggregate_id = ?`,
		appcode.Default, cpdomain.EventTypeApplicationCreated, 4502,
	).Scan(&outboxCount); err != nil {
		t.Fatalf("count cp outbox events failed: %v", err)
	}
	if outboxCount != 2 {
		t.Fatalf("same-millisecond cp notifications must keep two outbox rows, got %d", outboxCount)
	}
}

func TestBreakRelationshipPrepareConfirmIsIdempotent(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 5001, "Break Alice", nowMS)
	seedCPTestUser(t, schema.DB, 5002, "Break Bob", nowMS)
	seedRelationConfigWithBreakupCost(t, schema.DB, cpdomain.RelationTypeCP, 1, 88, nowMS)
	seedActiveCPRelationship(t, schema.DB, "break_confirm", cpdomain.RelationTypeCP, 5001, 5002, 20, 1, nowMS)

	breakup, relationship, err := repo.PrepareBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", nowMS+1)
	if err != nil {
		t.Fatalf("prepare breakup failed: %v", err)
	}
	if breakup.Status != cpdomain.BreakupStatusPending || breakup.BreakupCostCoins != 88 {
		t.Fatalf("prepare breakup mismatch: %+v", breakup)
	}
	if relationship.BreakupCostCoins != 88 || relationship.Status != cpdomain.RelationshipStatusActive {
		t.Fatalf("relationship cost/status mismatch: %+v", relationship)
	}

	replayed, _, err := repo.PrepareBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", nowMS+2)
	if err != nil {
		t.Fatalf("prepare replay failed: %v", err)
	}
	if replayed.CommandID != breakup.CommandID || replayed.Status != cpdomain.BreakupStatusPending {
		t.Fatalf("prepare replay mismatch: %+v", replayed)
	}
	if _, _, err := repo.PrepareBreakRelationship(ctx, 5002, "break_confirm", "break_cmd_2", nowMS+3); xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("concurrent breakup must be rejected, got %v", err)
	}
	if _, _, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_bad", 87, 913, nowMS+4); xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("paid amount mismatch must be rejected, got %v", err)
	}

	confirmed, ended, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_1", 88, 912, nowMS+5)
	if err != nil {
		t.Fatalf("confirm breakup failed: %v", err)
	}
	if confirmed.Status != cpdomain.BreakupStatusConfirmed || confirmed.WalletTransactionID != "wallet_tx_1" {
		t.Fatalf("confirmed breakup mismatch: %+v", confirmed)
	}
	if ended.Status != cpdomain.RelationshipStatusEnded || ended.EndedAtMS != nowMS+5 || ended.BreakupCostCoins != 88 {
		t.Fatalf("ended relationship mismatch: %+v", ended)
	}
	replayedConfirm, replayedEnded, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_1", 88, 912, nowMS+6)
	if err != nil {
		t.Fatalf("confirm replay failed: %v", err)
	}
	if replayedConfirm.Status != cpdomain.BreakupStatusConfirmed || replayedEnded.Status != cpdomain.RelationshipStatusEnded {
		t.Fatalf("confirm replay mismatch: breakup=%+v relationship=%+v", replayedConfirm, replayedEnded)
	}
}

func TestBreakRelationshipCancelKeepsRelationshipActive(t *testing.T) {
	schema := mysqlschema.New(t)
	repo := New(schema.DB)
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	nowMS := int64(1700000000000)

	seedCPTestUser(t, schema.DB, 6001, "Cancel Alice", nowMS)
	seedCPTestUser(t, schema.DB, 6002, "Cancel Bob", nowMS)
	seedRelationConfigWithBreakupCost(t, schema.DB, cpdomain.RelationTypeSister, 5, 66, nowMS)
	seedActiveCPRelationship(t, schema.DB, "break_cancel", cpdomain.RelationTypeSister, 6001, 6002, 20, 1, nowMS)

	if _, _, err := repo.PrepareBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", nowMS+1); err != nil {
		t.Fatalf("prepare cancel breakup failed: %v", err)
	}
	canceled, err := repo.CancelBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", "wallet_debit_failed", nowMS+2)
	if err != nil {
		t.Fatalf("cancel breakup failed: %v", err)
	}
	if canceled.Status != cpdomain.BreakupStatusCanceled {
		t.Fatalf("cancel status mismatch: %+v", canceled)
	}
	relationships, total, err := repo.ListRelationships(ctx, 6002, cpdomain.RelationTypeSister, 1, 20)
	if err != nil {
		t.Fatalf("list relationships after cancel failed: %v", err)
	}
	if total != 1 || len(relationships) != 1 || relationships[0].Status != cpdomain.RelationshipStatusActive {
		t.Fatalf("relationship must stay active after cancel, total=%d relationships=%+v", total, relationships)
	}
	if _, _, err := repo.ConfirmBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", "wallet_tx_after_cancel", 66, 934, nowMS+3); xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("canceled breakup must not be confirmed, got %v", err)
	}
}

func seedCPTestUser(t testing.TB, db *sql.DB, userID int64, username string, nowMS int64) {
	t.Helper()

	displayID := fmt.Sprintf("%d", userID)
	// CP 申请查询会 join requester/target 用户快照；测试只准备这些不可空主数据字段。
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO 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, ?, 1, ?, 'completed', 'active', ?, ?)`,
		appcode.Default, userID, displayID, displayID, username, "https://cdn.example.com/cp-test.png", nowMS, nowMS, nowMS,
	); err != nil {
		t.Fatalf("seed cp test user %d failed: %v", userID, err)
	}
}

func seedRelationConfig(t testing.TB, db *sql.DB, relationType string, maxCount int32, nowMS int64) {
	seedRelationConfigWithBreakupCost(t, db, relationType, maxCount, 0, nowMS)
}

func seedRelationConfigWithBreakupCost(t testing.TB, db *sql.DB, relationType string, maxCount int32, breakupCost int64, nowMS int64) {
	t.Helper()

	// 容量测试直接写配置表，AcceptApplication 中的默认配置 upsert 不会覆盖已有业务配置。
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO user_cp_relation_configs (
			app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
			status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 24, ?, 'active', ?, ?)
		ON DUPLICATE KEY UPDATE
			max_count_per_user = VALUES(max_count_per_user),
			breakup_cost_coins = VALUES(breakup_cost_coins),
			updated_at_ms = VALUES(updated_at_ms)`,
		appcode.Default, relationType, maxCount, breakupCost, nowMS, nowMS,
	); err != nil {
		t.Fatalf("seed relation config %s failed: %v", relationType, err)
	}
}

func seedCPLevelRules(t testing.TB, db *sql.DB, relationType string, thresholds []int64, nowMS int64) {
	t.Helper()

	for index, threshold := range thresholds {
		level := int32(index + 1)
		// 测试直接写等级阈值表，验证关系查询返回的是服务端配置结果，而不是客户端或测试本地推导结果。
		if _, err := db.ExecContext(context.Background(), `
			INSERT INTO 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),
				status = VALUES(status),
				updated_at_ms = VALUES(updated_at_ms)`,
			appcode.Default, relationType, level, threshold, nowMS, nowMS,
		); err != nil {
			t.Fatalf("seed cp level rule %s/%d failed: %v", relationType, level, err)
		}
	}
}

func seedActiveCPRelationship(t testing.TB, db *sql.DB, relationshipID string, relationType string, userAID int64, userBID int64, intimacy int64, level int32, nowMS int64) {
	t.Helper()

	firstUserID, secondUserID := orderedPair(userAID, userBID)
	// active 关系事实只保存当前等级和亲密值；当前/下一阈值必须由查询时读取等级规则计算。
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO 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 (?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`,
		appcode.Default, relationshipID, firstUserID, secondUserID, relationType, cpdomain.RelationshipStatusActive,
		intimacy, level, nowMS, nowMS, nowMS,
	); err != nil {
		t.Fatalf("seed active cp relationship %s failed: %v", relationshipID, err)
	}
}

func seedCPGiftConsumption(t testing.TB, db *sql.DB, eventID string, status string, relationshipID string, giftValue int64, occurredAtMS int64, nowMS int64) {
	t.Helper()

	// CP 周榜只信任消费位点里的新增 gift_value；测试显式写多种状态和边界时间，防止查询退回关系累计值或闭区间统计。
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO user_cp_gift_event_consumption (
			app_code, room_event_id, status, relationship_id, gift_value,
			occurred_at_ms, consumed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.Default, eventID, status, relationshipID, giftValue, occurredAtMS, nowMS, nowMS, nowMS,
	); err != nil {
		t.Fatalf("seed cp gift consumption %s failed: %v", eventID, err)
	}
}

func seedPendingCPApplication(t testing.TB, db *sql.DB, applicationID string, relationType string, requesterUserID int64, targetUserID int64, nowMS int64) {
	t.Helper()

	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO user_cp_applications (
			app_code, application_id, requester_user_id, target_user_id, relation_type, status,
			room_id, room_region_id, gift_id, gift_name, gift_icon_url, gift_animation_url,
			gift_count, gift_value, billing_receipt_id, source_room_event_id, source_command_id,
			created_at_ms, updated_at_ms, expires_at_ms, decided_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 'cp_test_room', 1, 'cp_test_gift', 'CP Test Gift', '', '',
			1, 10, 'cp_test_receipt', ?, 'cp_test_command', ?, ?, ?, 0)`,
		appcode.Default, applicationID, requesterUserID, targetUserID, relationType,
		cpdomain.ApplicationStatusPending, "event_"+applicationID, nowMS, nowMS, nowMS+24*60*60*1000,
	); err != nil {
		t.Fatalf("seed pending cp application %s failed: %v", applicationID, err)
	}
}

func seedExpiredCPApplication(t testing.TB, db *sql.DB, applicationID string, requesterUserID int64, targetUserID int64, nowMS int64) {
	t.Helper()

	// expires_at_ms 放在 nowMS 前一毫秒，确保仓储操作走“不能操作但要落 expired”的惰性过期分支。
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO user_cp_applications (
			app_code, application_id, requester_user_id, target_user_id, relation_type, status,
			room_id, room_region_id, gift_id, gift_name, gift_icon_url, gift_animation_url,
			gift_count, gift_value, billing_receipt_id, source_room_event_id, source_command_id,
			created_at_ms, updated_at_ms, expires_at_ms, decided_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 'cp_test_room', 1, 'cp_test_gift', 'CP Test Gift', '', '',
			1, 10, 'cp_test_receipt', ?, 'cp_test_command', ?, ?, ?, 0)`,
		appcode.Default, applicationID, requesterUserID, targetUserID, cpdomain.RelationTypeCP,
		cpdomain.ApplicationStatusPending, "event_"+applicationID, nowMS-1000, nowMS-1000, nowMS-1,
	); err != nil {
		t.Fatalf("seed expired cp application %s failed: %v", applicationID, err)
	}
}
