// Package cp 持久化 CP/兄弟/姐妹申请、单 active 关系、亲密值和等级默认规则。
package cp

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"strings"
	"time"

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

const formationGiftCoinThreshold = int64(5000)

const relationshipCapacityReachedMessage = "用户关系已经达到上限"

// Repository 是 CP 关系在 user-service MySQL 上的 owner 存储。
type Repository struct {
	db *sql.DB
}

// New 基于共享 MySQL 连接池创建 CP 关系存储。
func New(db *sql.DB) *Repository {
	return &Repository{db: db}
}

// ListApplications 按用户和方向读取申请，同时懒过期已超时的 pending 申请。
func (r *Repository) ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32, nowMs int64) ([]cpdomain.Application, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	// 列表读取前先做一次轻量懒过期，避免客户端看到已经超过 24 小时但仍可操作的 pending 申请。
	if _, err := r.db.ExecContext(ctx, `
		UPDATE user_cp_applications
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ? AND status = ? AND expires_at_ms <= ?`,
		cpdomain.ApplicationStatusExpired, nowMs, appCode, cpdomain.ApplicationStatusPending, nowMs,
	); err != nil {
		return nil, 0, err
	}

	where := []string{"a.app_code = ?"}
	args := []any{appCode}
	// direction 只影响 requester/target 过滤，不把双向申请合并，客户端才能区分我发出的和我收到的申请。
	switch strings.ToLower(strings.TrimSpace(direction)) {
	case "outgoing":
		where = append(where, "a.requester_user_id = ?")
		args = append(args, userID)
	default:
		where = append(where, "a.target_user_id = ?")
		args = append(args, userID)
	}
	if status = strings.ToLower(strings.TrimSpace(status)); status != "" {
		where = append(where, "a.status = ?")
		args = append(args, status)
	}
	total, err := countRows(ctx, r.db, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+strings.Join(where, " AND "), args...)
	if err != nil {
		return nil, 0, err
	}
	args = append(args, clampPageSize(pageSize), offset(page, pageSize))
	rows, err := r.db.QueryContext(ctx, applicationSelectSQL("WHERE "+strings.Join(where, " AND ")+`
		ORDER BY a.created_at_ms DESC, a.application_id DESC
		LIMIT ? OFFSET ?`), args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	applications, err := scanApplications(rows)
	if err != nil {
		return nil, 0, err
	}
	return applications, total, nil
}

// AcceptApplication 处理同意申请，事务内保证同一对用户只能形成一个 active 关系，并按关系类型容量限制个人数量。
func (r *Repository) AcceptApplication(ctx context.Context, userID int64, applicationID string, nowMs int64) (cpdomain.Application, cpdomain.Relationship, error) {
	if r == nil || r.db == nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	// 先锁申请行，再判断状态和过期时间，保证两个端同时点同意/拒绝时只有一个事务能改状态。
	application, err := lockApplicationTx(ctx, tx, appCode, applicationID)
	if err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	if application.Target.UserID != userID {
		return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.PermissionDenied, "only target user can accept cp application")
	}
	if err := assertPendingApplication(application, nowMs); err != nil {
		if isExpiredPendingApplication(application, nowMs) {
			// 过期申请虽然不能继续同意，但 expired 本身是一个需要持久化的业务事实；
			// 这里必须在返回冲突前提交事务，否则 defer Rollback 会把状态收敛一起回滚掉。
			if markErr := markApplicationExpiredTx(ctx, tx, appCode, applicationID, nowMs); markErr != nil {
				return cpdomain.Application{}, cpdomain.Relationship{}, markErr
			}
			if commitErr := tx.Commit(); commitErr != nil {
				return cpdomain.Application{}, cpdomain.Relationship{}, commitErr
			}
		}
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	userAID, userBID := orderedPair(application.Requester.UserID, application.Target.UserID)
	// A/B 关系按无序 pair 加锁；不论 CP、兄弟还是姐妹，同一时间只能存在一种 active 关系。
	existing, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID)
	if err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	if found {
		return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("%s用户和你已经有%s关系", application.Requester.Username, existing.RelationType))
	}
	// 容量校验按关系类型读取后台配置；CP 固定只能有一个，兄弟/姐妹允许按运营配置扩展。
	if err := assertRelationCapacityTx(ctx, tx, appCode, application.RelationType, application.Requester.UserID, application.Target.UserID, userID); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}

	relationshipID := relationshipIDForPair(userAID, userBID)
	if _, err := tx.ExecContext(ctx, `
		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, ended_by_user_id, breakup_cost_coins,
			breakup_command_id, breakup_wallet_transaction_id, breakup_coin_balance_after,
			created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, 0, 0, 0, '', '', 0, ?, ?)
		ON DUPLICATE KEY UPDATE
			relation_type = VALUES(relation_type),
			status = VALUES(status),
			intimacy_value = 0,
			level_no = 1,
			source_application_id = VALUES(source_application_id),
			formed_at_ms = VALUES(formed_at_ms),
			ended_at_ms = 0,
			ended_by_user_id = 0,
			breakup_cost_coins = 0,
			breakup_command_id = '',
			breakup_wallet_transaction_id = '',
			breakup_coin_balance_after = 0,
			updated_at_ms = VALUES(updated_at_ms)`,
		appCode, relationshipID, userAID, userBID, application.RelationType, cpdomain.RelationshipStatusActive,
		application.ApplicationID, nowMs, nowMs, nowMs,
	); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	// 同意一种关系后，同一对用户之间其他类型的 pending 申请不再允许接受，状态置为 blocked 便于客户端提示。
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_applications
		SET status = ?, decided_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`,
		cpdomain.ApplicationStatusAccepted, nowMs, nowMs, appCode, application.ApplicationID,
	); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_applications
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ?
		  AND status = ?
		  AND requester_user_id IN (?, ?)
		  AND target_user_id IN (?, ?)
		  AND application_id <> ?`,
		cpdomain.ApplicationStatusBlocked, nowMs, appCode, cpdomain.ApplicationStatusPending,
		application.Requester.UserID, application.Target.UserID, application.Requester.UserID, application.Target.UserID, application.ApplicationID,
	); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	application.Status = cpdomain.ApplicationStatusAccepted
	application.UpdatedAtMS = nowMs
	application.DecidedAtMS = nowMs
	relationship, err := queryRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
	if err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	// user_outbox 只记录事实；IM、区域广播和后续 push 由下游 notice/统计等消费者按事件类型各自投递。
	if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationAccepted, "cp_application", application.Target.UserID, application, nowMs); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	relationshipPayload := map[string]any{"application": application, "relationship": relationship}
	if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipCreated, "cp_relationship", application.Target.UserID, relationshipPayload, nowMs); err != nil {
		return cpdomain.Application{}, cpdomain.Relationship{}, err
	}
	return application, relationship, tx.Commit()
}

// RejectApplication 处理拒绝申请；拒绝只改变该申请，不影响同一对用户其他 pending 类型。
func (r *Repository) RejectApplication(ctx context.Context, userID int64, applicationID string, reason string, nowMs int64) (cpdomain.Application, error) {
	if r == nil || r.db == nil {
		return cpdomain.Application{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.Application{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	application, err := lockApplicationTx(ctx, tx, appCode, applicationID)
	if err != nil {
		return cpdomain.Application{}, err
	}
	if application.Target.UserID != userID {
		return cpdomain.Application{}, xerr.New(xerr.PermissionDenied, "only target user can reject cp application")
	}
	if err := assertPendingApplication(application, nowMs); err != nil {
		if isExpiredPendingApplication(application, nowMs) {
			// 拒绝接口同样承担惰性过期收敛；过期事实要先提交，再把“不能操作”的冲突返回给客户端。
			if markErr := markApplicationExpiredTx(ctx, tx, appCode, applicationID, nowMs); markErr != nil {
				return cpdomain.Application{}, markErr
			}
			if commitErr := tx.Commit(); commitErr != nil {
				return cpdomain.Application{}, commitErr
			}
		}
		return cpdomain.Application{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_applications
		SET status = ?, decided_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ?`,
		cpdomain.ApplicationStatusRejected, nowMs, nowMs, appCode, application.ApplicationID,
	); err != nil {
		return cpdomain.Application{}, err
	}
	application.Status = cpdomain.ApplicationStatusRejected
	application.UpdatedAtMS = nowMs
	application.DecidedAtMS = nowMs
	payload := map[string]any{"application": application, "reason": strings.TrimSpace(reason)}
	if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationRejected, "cp_application", application.Target.UserID, payload, nowMs); err != nil {
		return cpdomain.Application{}, err
	}
	return application, tx.Commit()
}

// ListRelationships 读取用户 active 关系列表；CP 最多一条，兄弟/姐妹可能多条，因此分页必须保留。
func (r *Repository) ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	where := []string{"r.app_code = ?", "r.status = ?", "(r.user_a_id = ? OR r.user_b_id = ?)"}
	args := []any{appCode, cpdomain.RelationshipStatusActive, userID, userID}
	if relationType = normalizeRelationType(relationType); relationType != "" {
		where = append(where, "r.relation_type = ?")
		args = append(args, relationType)
	}
	// total 只统计 active 关系；历史关系不会占用当前关系数量，也不会在 App 资料卡展示。
	total, err := countRows(ctx, r.db, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+strings.Join(where, " AND "), args...)
	if err != nil {
		return nil, 0, err
	}
	// relationshipSelectSQL 的前两个占位是 viewer/me 和 partner 选择，suffix 的过滤参数必须跟在它们后面。
	queryArgs := []any{userID, userID}
	queryArgs = append(queryArgs, args...)
	queryArgs = append(queryArgs, clampPageSize(pageSize), offset(page, pageSize))
	rows, err := r.db.QueryContext(ctx, relationshipSelectSQL("WHERE "+strings.Join(where, " AND ")+`
		ORDER BY r.formed_at_ms DESC, r.relationship_id DESC
		LIMIT ? OFFSET ?`), queryArgs...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	relationships, err := scanRelationships(rows)
	if err != nil {
		return nil, 0, err
	}
	return relationships, total, nil
}

// ListActiveIntimacyLeaderboardEntries 读取当前 active 关系亲密值榜单快照；cron 会把结果全量覆盖到 Redis zset。
func (r *Repository) ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	if limit <= 0 {
		limit = 1000
	}
	// 榜单只读取 active 关系，ended 关系依赖下一轮 Redis 全量替换自然消失；这里不读取历史关系，避免旧亲密值继续上榜。
	// 用户基础资料在同一个 SQL 内 join 出来，保证关系快照和双方短号/昵称/头像来自同一时刻的 MySQL 读视图。
	rows, err := r.db.QueryContext(ctx, `
		SELECT
			r.relationship_id, r.relation_type, r.intimacy_value, r.level_no,
			r.formed_at_ms, r.updated_at_ms,
			user_a.user_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
			user_b.user_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, '')
		FROM user_cp_relationships r
		JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id
		JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id
		WHERE r.app_code = ? AND r.status = ?
		-- 亲密值是主排序；同分时用 updated_at_ms 和 relationship_id 稳定顺序，Redis 写入会保留这个 tie-break。
		ORDER BY r.intimacy_value DESC, r.updated_at_ms DESC, r.relationship_id ASC
		LIMIT ?`,
		appCode, cpdomain.RelationshipStatusActive, limit,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	entries := make([]cpdomain.IntimacyLeaderboardEntry, 0)
	for rows.Next() {
		// scan 层只做字段搬运，不补头像框；头像框来自 wallet-service，避免 user-service 复制资源归属事实。
		entry, err := scanIntimacyLeaderboardEntry(rows)
		if err != nil {
			return nil, err
		}
		entries = append(entries, entry)
	}
	return entries, rows.Err()
}

// ListFormationGiftFeed 读取“组 CP 时礼物金币价值严格超过 5000”的已接受成立事件，和累计亲密榜保持完全独立。
func (r *Repository) ListFormationGiftFeed(ctx context.Context, limit int) ([]cpdomain.FormationGiftFeedItem, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}
	appCode := appcode.FromContext(ctx)
	// accepted 申请从 formation_feed_eligible=1 的等值索引范围按决定时间倒序扫描；历史行资格默认 0，
	// 无命中时不会退化为扫描全部 accepted CP，查询也不做 COUNT 或 OFFSET。解除关系不会抹掉已发生的成立事件。
	rows, err := r.db.QueryContext(ctx, `
		SELECT
			source.application_id, source.gift_coin_value,
			requester.user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
			target.user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''),
			source.decided_at_ms
		FROM user_cp_applications source
		JOIN users requester
		  ON requester.app_code = source.app_code AND requester.user_id = source.requester_user_id
		JOIN users target
		  ON target.app_code = source.app_code AND target.user_id = source.target_user_id
		WHERE source.app_code = ?
		  AND source.status = ?
		  AND source.relation_type = ?
		  AND source.formation_feed_eligible = 1
		  AND source.gift_coin_value > ?
		ORDER BY source.decided_at_ms DESC, source.application_id DESC
		LIMIT ?`,
		appCode,
		cpdomain.ApplicationStatusAccepted,
		cpdomain.RelationTypeCP,
		formationGiftCoinThreshold,
		limit,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]cpdomain.FormationGiftFeedItem, 0, limit)
	for rows.Next() {
		var item cpdomain.FormationGiftFeedItem
		if err := rows.Scan(
			&item.FormationID,
			&item.GiftCoinValue,
			&item.Requester.UserID,
			&item.Requester.DisplayUserID,
			&item.Requester.Username,
			&item.Requester.Avatar,
			&item.Target.UserID,
			&item.Target.DisplayUserID,
			&item.Target.Username,
			&item.Target.Avatar,
			&item.FormedAtMS,
		); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

// ListWeeklyRankEntries 按消费事实聚合一个 UTC 周期内的 CP 关系新增亲密值。
// score 只等于窗口内 relationship_intimacy_added gift_value 之和；关系表 intimacy_value 只服务普通亲密榜和等级累计态，不参与活动周榜排名。
func (r *Repository) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	if limit <= 0 {
		limit = 3
	}
	where := []string{
		"c.app_code = ?",
		"c.status = 'relationship_intimacy_added'",
		"c.relationship_id <> ''",
		"c.occurred_at_ms >= ?",
		"c.occurred_at_ms < ?",
		"c.gift_value > 0",
	}
	args := []any{appCode, startMS, endMS}
	if relationType = normalizeRelationType(relationType); relationType != "" {
		where = append(where, "r.relation_type = ?")
		args = append(args, relationType)
	}
	args = append(args, limit, appCode, appCode)
	// 周榜以礼物消费位点为事实源，relationship 表只提供双方用户、关系类型和展示时间；即使累计亲密值更高，也不能覆盖本周期新增分数排序。
	// 同分时用首次得分时间和 relationship_id 稳定排序，保证 cron 重试、后台查询和 H5 展示看到同一批排名。
	rows, err := r.db.QueryContext(ctx, `
		SELECT
			ranked.rank_no, ranked.relationship_id, ranked.relation_type, ranked.score,
			ranked.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''),
			ranked.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''),
			ranked.formed_at_ms, ranked.updated_at_ms, ranked.first_scored_at_ms, ranked.last_scored_at_ms
		FROM (
			SELECT
				ROW_NUMBER() OVER (ORDER BY SUM(c.gift_value) DESC, MIN(c.occurred_at_ms) ASC, c.relationship_id ASC) AS rank_no,
				c.relationship_id,
				r.relation_type,
				SUM(c.gift_value) AS score,
				r.user_a_id,
				r.user_b_id,
				r.formed_at_ms,
				r.updated_at_ms,
				MIN(c.occurred_at_ms) AS first_scored_at_ms,
				MAX(c.occurred_at_ms) AS last_scored_at_ms
			FROM user_cp_gift_event_consumption c
			JOIN user_cp_relationships r ON r.app_code = c.app_code AND r.relationship_id = c.relationship_id
			WHERE `+strings.Join(where, " AND ")+`
			GROUP BY c.relationship_id, r.relation_type, r.user_a_id, r.user_b_id, r.formed_at_ms, r.updated_at_ms
			ORDER BY score DESC, first_scored_at_ms ASC, c.relationship_id ASC
			LIMIT ?
		) ranked
		JOIN users user_a ON user_a.app_code = ? AND user_a.user_id = ranked.user_a_id
		JOIN users user_b ON user_b.app_code = ? AND user_b.user_id = ranked.user_b_id
		ORDER BY ranked.rank_no ASC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	entries := make([]cpdomain.WeeklyRankEntry, 0)
	for rows.Next() {
		var entry cpdomain.WeeklyRankEntry
		if err := rows.Scan(
			&entry.Rank, &entry.RelationshipID, &entry.RelationType, &entry.Score,
			&entry.UserA.UserID, &entry.UserA.DisplayUserID, &entry.UserA.Username, &entry.UserA.Avatar,
			&entry.UserB.UserID, &entry.UserB.DisplayUserID, &entry.UserB.Username, &entry.UserB.Avatar,
			&entry.FormedAtMS, &entry.UpdatedAtMS, &entry.FirstScoredAtMS, &entry.LastScoredAtMS,
		); err != nil {
			return nil, err
		}
		entries = append(entries, entry)
	}
	return entries, rows.Err()
}

// PrepareBreakRelationship 为解除关系创建 pending 占位；占位持有 command 幂等事实，但不提前结束关系。
func (r *Repository) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
	if r == nil || r.db == nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	// 所有解除分支先锁关系行，再锁 breakup 行；prepare/confirm/cancel 共享这个顺序以降低死锁概率。
	relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if err := assertRelationshipParticipant(relationship, userID); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if existing, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	} else if found {
		if err := assertBreakupMatches(existing, userID, relationshipID); err != nil {
			return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
		}
		if existing.Status == cpdomain.BreakupStatusCanceled {
			return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled")
		}
		return existing, relationship, tx.Commit()
	}
	if relationship.Status != cpdomain.RelationshipStatusActive {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active")
	}
	if pending, found, err := lockPendingBreakupForRelationshipTx(ctx, tx, appCode, relationshipID); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	} else if found {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("cp relationship breakup is pending: %s", pending.CommandID))
	}
	cost := relationship.BreakupCostCoins
	if cost < 0 {
		cost = 0
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_cp_relationship_breakups (
			app_code, command_id, relationship_id, requester_user_id, relation_type,
			breakup_cost_coins, status, wallet_transaction_id, coin_balance_after,
			reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, '', 0, '', ?, ?, 0, 0)`,
		appCode, commandID, relationship.RelationshipID, userID, relationship.RelationType,
		cost, cpdomain.BreakupStatusPending, nowMs, nowMs,
	); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	return breakup, relationship, tx.Commit()
}

// ConfirmBreakRelationship 在钱包扣费成功后真正结束关系；重复确认同一 command 只返回已确认结果。
func (r *Repository) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
	if r == nil || r.db == nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if err := assertRelationshipParticipant(relationship, userID); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if breakup.Status == cpdomain.BreakupStatusConfirmed {
		return breakup, relationship, tx.Commit()
	}
	if breakup.Status == cpdomain.BreakupStatusCanceled {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled")
	}
	if relationship.Status != cpdomain.RelationshipStatusActive {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active")
	}
	if paidCoinAmount != breakup.BreakupCostCoins {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp breakup paid amount mismatch")
	}
	if breakup.BreakupCostCoins > 0 && walletTransactionID == "" {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required")
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_relationships
		SET status = ?, ended_at_ms = ?, ended_by_user_id = ?,
		    breakup_cost_coins = ?, breakup_command_id = ?, breakup_wallet_transaction_id = ?,
		    breakup_coin_balance_after = ?, updated_at_ms = ?
		WHERE app_code = ? AND relationship_id = ? AND status = ?`,
		cpdomain.RelationshipStatusEnded, nowMs, userID,
		breakup.BreakupCostCoins, commandID, walletTransactionID,
		coinBalanceAfter, nowMs,
		appCode, relationshipID, cpdomain.RelationshipStatusActive,
	); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_relationship_breakups
		SET status = ?, wallet_transaction_id = ?, coin_balance_after = ?,
		    updated_at_ms = ?, confirmed_at_ms = ?
		WHERE app_code = ? AND command_id = ? AND status = ?`,
		cpdomain.BreakupStatusConfirmed, walletTransactionID, coinBalanceAfter,
		nowMs, nowMs, appCode, commandID, cpdomain.BreakupStatusPending,
	); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	relationship, err = queryRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	payload := map[string]any{"relationship": relationship, "breakup": breakup}
	if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipEnded, "cp_relationship", userID, payload, nowMs); err != nil {
		return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
	}
	return breakup, relationship, tx.Commit()
}

// CancelBreakRelationship 释放 pending 占位；钱包扣费失败后 gateway 调用它，关系仍保持 active。
func (r *Repository) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error) {
	if r == nil || r.db == nil {
		return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	if err := assertRelationshipParticipant(relationship, userID); err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	if breakup.Status == cpdomain.BreakupStatusConfirmed {
		return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Conflict, "cp relationship breakup is confirmed")
	}
	if breakup.Status == cpdomain.BreakupStatusPending {
		if _, err := tx.ExecContext(ctx, `
			UPDATE user_cp_relationship_breakups
			SET status = ?, reason = ?, updated_at_ms = ?, canceled_at_ms = ?
			WHERE app_code = ? AND command_id = ? AND status = ?`,
			cpdomain.BreakupStatusCanceled, strings.TrimSpace(reason), nowMs, nowMs,
			appCode, commandID, cpdomain.BreakupStatusPending,
		); err != nil {
			return cpdomain.RelationshipBreakup{}, err
		}
		breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
		if err != nil {
			return cpdomain.RelationshipBreakup{}, err
		}
	}
	return breakup, tx.Commit()
}

// ConsumeGiftEvent 把房间送礼事实转换为 CP 申请或亲密值增长。
func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error) {
	if r == nil || r.db == nil {
		return cpdomain.ConsumeResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	relationType := normalizeRelationType(event.CPRelationType)
	if event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.SenderUserID == event.TargetUserID {
		return cpdomain.ConsumeResult{Consumed: false}, nil
	}
	if strings.TrimSpace(event.EventID) == "" {
		return cpdomain.ConsumeResult{}, xerr.New(xerr.InvalidArgument, "room event id is required")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	if event.AppCode != "" {
		appCode = appcode.Normalize(event.AppCode)
	}
	if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	// room outbox event_id 是消费幂等键；重复投递时直接提交空事务，避免重复创建申请或重复加亲密值。
	if exists, err := lockConsumedEventTx(ctx, tx, appCode, event.EventID); err != nil {
		return cpdomain.ConsumeResult{}, err
	} else if exists {
		return cpdomain.ConsumeResult{Consumed: false}, tx.Commit()
	}
	userAID, userBID := orderedPair(event.SenderUserID, event.TargetUserID)
	// 已有 active 关系时，任意礼物都只增加这对关系的亲密值；GiftValue 已由 wallet-service 按普通/幸运/超级幸运比例结算。
	if relationship, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID); err != nil {
		return cpdomain.ConsumeResult{}, err
	} else if found {
		updated, err := addRelationshipIntimacyTx(ctx, tx, appCode, relationship, event.GiftValue, event.TargetUserID, nowMs)
		if err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "relationship_intimacy_added", "", updated.RelationshipID, "", event.GiftValue, event.OccurredAtMS, nowMs); err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipIntimacyChanged, "cp_relationship", event.TargetUserID, map[string]any{"relationship": updated, "gift_event": event}, nowMs); err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		return cpdomain.ConsumeResult{Consumed: true, Relationship: updated}, tx.Commit()
	}
	if relationType == "" {
		// 普通礼物在双方还没有 active 关系时不能创建申请；这类事件不写消费位点，后续若 MQ 重投仍然是无副作用 no-op。
		return cpdomain.ConsumeResult{Consumed: false}, tx.Commit()
	}
	// 没有 active 关系时，CP/兄弟/姐妹礼物只生成 pending 申请；真正建立关系必须由 B 显式同意。
	if err := ensurePairHasNoActiveRelationshipTx(ctx, tx, appCode, userAID, userBID, event.TargetUserID); err != nil {
		if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "skipped", "", "", "existing_relationship", event.GiftValue, event.OccurredAtMS, nowMs); err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		return cpdomain.ConsumeResult{Consumed: true}, tx.Commit()
	}
	// 关系礼物扣费和房间表现已经在上游完成，这里只能根据 user-service 的关系事实判断申请是否仍有意义。
	// 任一方该类型名额已满时不再制造一条注定无法同意的 pending 申请，而是同事务记录消费位点和系统通知 outbox；
	// notice-service 最终只通知送礼人，避免目标用户收到不可操作的申请卡片。
	capacity, err := relationCapacityTx(ctx, tx, appCode, relationType, event.SenderUserID, event.TargetUserID)
	if err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	if capacity.LimitedUserID > 0 {
		if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "skipped", "", "", "relationship_capacity_reached", event.GiftValue, event.OccurredAtMS, nowMs); err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		notice := cpdomain.RelationshipCapacityReachedNotice{
			RelationType:      relationType,
			SenderUserID:      event.SenderUserID,
			TargetUserID:      event.TargetUserID,
			LimitedUserID:     capacity.LimitedUserID,
			CurrentCount:      capacity.CurrentCount,
			MaxCount:          capacity.MaxCount,
			RoomID:            event.RoomID,
			SourceRoomEventID: event.EventID,
			MessageText:       relationshipCapacityReachedMessage,
		}
		if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipCapacityReached, "cp_relationship_capacity", event.SenderUserID, notice, nowMs); err != nil {
			return cpdomain.ConsumeResult{}, err
		}
		return cpdomain.ConsumeResult{Consumed: true}, tx.Commit()
	}
	application, err := upsertApplicationFromGiftTx(ctx, tx, appCode, event, relationType, nowMs)
	if err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "application_created", application.ApplicationID, "", "", event.GiftValue, event.OccurredAtMS, nowMs); err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationCreated, "cp_application", application.Target.UserID, application, nowMs); err != nil {
		return cpdomain.ConsumeResult{}, err
	}
	return cpdomain.ConsumeResult{Consumed: true, Application: application}, tx.Commit()
}

func ensureDefaultConfigTx(ctx context.Context, tx *sql.Tx, appCode string, nowMs int64) error {
	// 默认值只兜底本地和新 App 启动；CP 固定 1 个，兄弟/姐妹默认 5 个并允许后台覆盖。
	for _, relationType := range []string{cpdomain.RelationTypeCP, cpdomain.RelationTypeBrother, cpdomain.RelationTypeSister} {
		maxCount := int32(1)
		if relationType == cpdomain.RelationTypeBrother || relationType == cpdomain.RelationTypeSister {
			maxCount = 5
		}
		if _, err := tx.ExecContext(ctx, `
			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, 0, 'active', ?, ?)
			ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`,
			appCode, relationType, maxCount, nowMs, nowMs,
		); err != nil {
			return err
		}
		for level := int32(1); level <= 5; level++ {
			threshold := int64(level-1) * 10000
			if _, err := tx.ExecContext(ctx, `
				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 level_no = VALUES(level_no)`,
				appCode, relationType, level, threshold, nowMs, nowMs,
			); err != nil {
				return err
			}
		}
	}
	return nil
}

func applicationSelectSQL(suffix string) string {
	// 申请查询统一 join 双方 users 快照，避免 service 层为每条申请再发起批量用户查询。
	return `
		SELECT
			a.application_id, a.relation_type, a.status,
			a.room_id, a.room_region_id, a.room_locked,
			a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url,
			a.gift_count, a.gift_value, a.gift_coin_value, a.billing_receipt_id,
			a.source_room_event_id, a.source_command_id,
			a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms,
			requester.user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''),
			target.user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, '')
		FROM user_cp_applications a
		JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id
		JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id
		` + suffix
}

func scanApplications(rows *sql.Rows) ([]cpdomain.Application, error) {
	applications := make([]cpdomain.Application, 0)
	for rows.Next() {
		application, err := scanApplication(rows)
		if err != nil {
			return nil, err
		}
		applications = append(applications, application)
	}
	return applications, rows.Err()
}

func scanApplication(scanner interface{ Scan(dest ...any) error }) (cpdomain.Application, error) {
	var app cpdomain.Application
	err := scanner.Scan(
		&app.ApplicationID, &app.RelationType, &app.Status,
		&app.RoomID, &app.RoomRegionID, &app.RoomLocked,
		&app.Gift.GiftID, &app.Gift.GiftName, &app.Gift.GiftIconURL, &app.Gift.GiftAnimationURL,
		&app.Gift.GiftCount, &app.Gift.GiftValue, &app.Gift.CoinSpent, &app.Gift.BillingReceiptID,
		&app.SourceEventID, &app.SourceCommandID,
		&app.CreatedAtMS, &app.UpdatedAtMS, &app.ExpiresAtMS, &app.DecidedAtMS,
		&app.Requester.UserID, &app.Requester.DisplayUserID, &app.Requester.Username, &app.Requester.Avatar,
		&app.Target.UserID, &app.Target.DisplayUserID, &app.Target.Username, &app.Target.Avatar,
	)
	return app, err
}

func lockApplicationTx(ctx context.Context, tx *sql.Tx, appCode string, applicationID string) (cpdomain.Application, error) {
	row := tx.QueryRowContext(ctx, applicationSelectSQL(`
		WHERE a.app_code = ? AND a.application_id = ?
		FOR UPDATE`), appCode, strings.TrimSpace(applicationID))
	application, err := scanApplication(row)
	if err == sql.ErrNoRows {
		return cpdomain.Application{}, xerr.New(xerr.NotFound, "cp application not found")
	}
	return application, err
}

func markApplicationExpiredTx(ctx context.Context, tx *sql.Tx, appCode string, applicationID string, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_cp_applications
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ? AND application_id = ? AND status = ?`,
		cpdomain.ApplicationStatusExpired, nowMs, appCode, applicationID, cpdomain.ApplicationStatusPending,
	)
	return err
}

func isExpiredPendingApplication(application cpdomain.Application, nowMs int64) bool {
	// 只有 pending 且超过过期时间的申请才做惰性状态收敛；accepted/rejected/blocked 等终态不能被误改成 expired。
	return application.Status == cpdomain.ApplicationStatusPending && application.ExpiresAtMS <= nowMs
}

func assertPendingApplication(application cpdomain.Application, nowMs int64) error {
	if application.Status != cpdomain.ApplicationStatusPending {
		return xerr.New(xerr.Conflict, "cp application is not pending")
	}
	if application.ExpiresAtMS <= nowMs {
		return xerr.New(xerr.Conflict, "cp application expired")
	}
	return nil
}

func relationshipSelectSQL(suffix string) string {
	// viewerUserID 连续出现两次：me 投影和 partner 选择都必须以当前查看用户为准。
	// 等级阈值也在查询时投影出来，客户端资料卡只能使用服务端计算后的进度，不能读取后台配置或硬编码等级表。
	return `
		SELECT
			r.relationship_id, r.relation_type, r.status, r.user_a_id, r.user_b_id,
			r.intimacy_value, r.level_no, r.source_application_id, r.formed_at_ms, r.updated_at_ms,
			r.ended_at_ms,
			CASE WHEN r.status = '` + cpdomain.RelationshipStatusEnded + `'
				THEN r.breakup_cost_coins
				ELSE COALESCE(config.breakup_cost_coins, 0)
			END AS breakup_cost_coins,
			COALESCE((
				SELECT current_rule.intimacy_threshold
				FROM user_cp_level_rules current_rule
				WHERE current_rule.app_code = r.app_code
				  AND current_rule.relation_type = r.relation_type
				  AND current_rule.level_no = r.level_no
				  AND current_rule.status = 'active'
				LIMIT 1
			), 0) AS current_level_threshold,
			COALESCE((
				SELECT next_rule.intimacy_threshold
				FROM user_cp_level_rules next_rule
				WHERE next_rule.app_code = r.app_code
				  AND next_rule.relation_type = r.relation_type
				  AND next_rule.level_no > r.level_no
				  AND next_rule.status = 'active'
				ORDER BY next_rule.level_no ASC
				LIMIT 1
			), 0) AS next_level_threshold,
			me.user_id, COALESCE(me.current_display_user_id, ''), COALESCE(me.username, ''), COALESCE(me.avatar, ''),
			partner.user_id, COALESCE(partner.current_display_user_id, ''), COALESCE(partner.username, ''), COALESCE(partner.avatar, '')
		FROM user_cp_relationships r
		JOIN users me ON me.app_code = r.app_code AND me.user_id = ?
		JOIN users partner ON partner.app_code = r.app_code
		 AND partner.user_id = CASE WHEN r.user_a_id = ? THEN r.user_b_id ELSE r.user_a_id END
		LEFT JOIN user_cp_relation_configs config
		  ON config.app_code = r.app_code AND config.relation_type = r.relation_type
		` + suffix
}

func scanRelationships(rows *sql.Rows) ([]cpdomain.Relationship, error) {
	relationships := make([]cpdomain.Relationship, 0)
	for rows.Next() {
		relationship, err := scanRelationship(rows)
		if err != nil {
			return nil, err
		}
		relationships = append(relationships, relationship)
	}
	return relationships, rows.Err()
}

func scanRelationship(scanner interface{ Scan(dest ...any) error }) (cpdomain.Relationship, error) {
	var relationship cpdomain.Relationship
	err := scanner.Scan(
		&relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID,
		&relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.UpdatedAtMS,
		&relationship.EndedAtMS, &relationship.BreakupCostCoins,
		&relationship.CurrentLevelThreshold, &relationship.NextLevelThreshold,
		&relationship.Me.UserID, &relationship.Me.DisplayUserID, &relationship.Me.Username, &relationship.Me.Avatar,
		&relationship.Partner.UserID, &relationship.Partner.DisplayUserID, &relationship.Partner.Username, &relationship.Partner.Avatar,
	)
	if err == nil {
		applyRelationshipLevelProgress(&relationship)
	}
	return relationship, err
}

func scanIntimacyLeaderboardEntry(scanner interface{ Scan(dest ...any) error }) (cpdomain.IntimacyLeaderboardEntry, error) {
	var entry cpdomain.IntimacyLeaderboardEntry
	err := scanner.Scan(
		&entry.RelationshipID, &entry.RelationType, &entry.IntimacyValue, &entry.Level,
		&entry.FormedAtMS, &entry.UpdatedAtMS,
		&entry.UserA.UserID, &entry.UserA.DisplayUserID, &entry.UserA.Username, &entry.UserA.Avatar,
		&entry.UserB.UserID, &entry.UserB.DisplayUserID, &entry.UserB.Username, &entry.UserB.Avatar,
	)
	return entry, err
}

func applyRelationshipLevelProgress(relationship *cpdomain.Relationship) {
	if relationship == nil {
		return
	}
	if relationship.CurrentLevelThreshold < 0 {
		relationship.CurrentLevelThreshold = 0
	}
	if relationship.NextLevelThreshold <= relationship.CurrentLevelThreshold {
		// 没有下一等级规则时按满级处理；客户端可以直接隐藏“距离下一级”和进度条。
		relationship.NextLevelThreshold = 0
		relationship.NeededForNextLevel = 0
		relationship.LevelProgressPercent = 100
		relationship.MaxLevel = true
		return
	}
	remaining := relationship.NextLevelThreshold - relationship.IntimacyValue
	if remaining < 0 {
		remaining = 0
	}
	relationship.NeededForNextLevel = remaining
	span := relationship.NextLevelThreshold - relationship.CurrentLevelThreshold
	filled := relationship.IntimacyValue - relationship.CurrentLevelThreshold
	if filled < 0 {
		filled = 0
	}
	progress := int32((filled * 100) / span)
	if progress < 0 {
		progress = 0
	}
	if progress > 100 {
		progress = 100
	}
	relationship.LevelProgressPercent = progress
	relationship.MaxLevel = false
}

func queryRelationshipByIDTx(ctx context.Context, tx *sql.Tx, appCode string, viewerUserID int64, relationshipID string) (cpdomain.Relationship, error) {
	row := tx.QueryRowContext(ctx, relationshipSelectSQL(`
		WHERE r.app_code = ? AND r.relationship_id = ?`),
		viewerUserID, viewerUserID, appCode, strings.TrimSpace(relationshipID),
	)
	relationship, err := scanRelationship(row)
	if err == sql.ErrNoRows {
		return cpdomain.Relationship{}, xerr.New(xerr.NotFound, "cp relationship not found")
	}
	return relationship, err
}

func lockRelationshipByIDTx(ctx context.Context, tx *sql.Tx, appCode string, viewerUserID int64, relationshipID string) (cpdomain.Relationship, error) {
	row := tx.QueryRowContext(ctx, relationshipSelectSQL(`
		WHERE r.app_code = ? AND r.relationship_id = ?
		FOR UPDATE`),
		viewerUserID, viewerUserID, appCode, strings.TrimSpace(relationshipID),
	)
	relationship, err := scanRelationship(row)
	if err == sql.ErrNoRows {
		return cpdomain.Relationship{}, xerr.New(xerr.NotFound, "cp relationship not found")
	}
	return relationship, err
}

func assertRelationshipParticipant(relationship cpdomain.Relationship, userID int64) error {
	if relationship.UserAID == userID || relationship.UserBID == userID {
		return nil
	}
	return xerr.New(xerr.PermissionDenied, "only relationship user can operate cp relationship")
}

func lockBreakupByCommandRequiredTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, error) {
	breakup, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID)
	if err != nil {
		return cpdomain.RelationshipBreakup{}, err
	}
	if !found {
		return cpdomain.RelationshipBreakup{}, xerr.New(xerr.NotFound, "cp relationship breakup not found")
	}
	return breakup, nil
}

func lockBreakupByCommandTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, bool, error) {
	row := tx.QueryRowContext(ctx, breakupSelectSQL(`
		WHERE app_code = ? AND command_id = ?
		FOR UPDATE`), appCode, strings.TrimSpace(commandID))
	breakup, err := scanBreakup(row)
	if err == sql.ErrNoRows {
		return cpdomain.RelationshipBreakup{}, false, nil
	}
	return breakup, err == nil, err
}

func lockPendingBreakupForRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, relationshipID string) (cpdomain.RelationshipBreakup, bool, error) {
	row := tx.QueryRowContext(ctx, breakupSelectSQL(`
		WHERE app_code = ? AND relationship_id = ? AND status = ?
		ORDER BY created_at_ms ASC
		LIMIT 1
		FOR UPDATE`), appCode, strings.TrimSpace(relationshipID), cpdomain.BreakupStatusPending)
	breakup, err := scanBreakup(row)
	if err == sql.ErrNoRows {
		return cpdomain.RelationshipBreakup{}, false, nil
	}
	return breakup, err == nil, err
}

func breakupSelectSQL(suffix string) string {
	return `
		SELECT command_id, relationship_id, requester_user_id, relation_type,
		       breakup_cost_coins, status, wallet_transaction_id, coin_balance_after,
		       reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms
		FROM user_cp_relationship_breakups
		` + suffix
}

func scanBreakup(scanner interface{ Scan(dest ...any) error }) (cpdomain.RelationshipBreakup, error) {
	var breakup cpdomain.RelationshipBreakup
	err := scanner.Scan(
		&breakup.CommandID, &breakup.RelationshipID, &breakup.RequesterUserID, &breakup.RelationType,
		&breakup.BreakupCostCoins, &breakup.Status, &breakup.WalletTransactionID, &breakup.CoinBalanceAfter,
		&breakup.Reason, &breakup.CreatedAtMS, &breakup.UpdatedAtMS, &breakup.ConfirmedAtMS, &breakup.CanceledAtMS,
	)
	return breakup, err
}

func assertBreakupMatches(breakup cpdomain.RelationshipBreakup, userID int64, relationshipID string) error {
	if breakup.RequesterUserID != userID || strings.TrimSpace(breakup.RelationshipID) != strings.TrimSpace(relationshipID) {
		// command_id 是解除动作幂等键，不能被另一个用户或另一个关系复用。
		return xerr.New(xerr.Conflict, "cp relationship breakup command conflict")
	}
	return nil
}

func lockActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, userAID int64, userBID int64) (cpdomain.Relationship, bool, error) {
	// active 关系行用 FOR UPDATE 锁住 pair，关系创建和送礼加亲密值都走这把锁。
	row := tx.QueryRowContext(ctx, `
		SELECT relationship_id, relation_type, status, user_a_id, user_b_id,
		       intimacy_value, level_no, source_application_id, formed_at_ms, ended_at_ms, updated_at_ms
		FROM user_cp_relationships
		WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND status = ?
		FOR UPDATE`,
		appCode, userAID, userBID, cpdomain.RelationshipStatusActive,
	)
	var relationship cpdomain.Relationship
	err := row.Scan(
		&relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID,
		&relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.EndedAtMS, &relationship.UpdatedAtMS,
	)
	if err == sql.ErrNoRows {
		return cpdomain.Relationship{}, false, nil
	}
	return relationship, true, err
}

func ensurePairHasNoActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, userAID int64, userBID int64, viewerUserID int64) error {
	if relationship, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID); err != nil {
		return err
	} else if found {
		partnerID := userAID
		if partnerID == viewerUserID {
			partnerID = userBID
		}
		return xerr.New(xerr.Conflict, fmt.Sprintf("%d用户和你已经有%s关系", partnerID, relationship.RelationType))
	}
	return nil
}

type relationCapacity struct {
	LimitedUserID int64
	CurrentCount  int64
	MaxCount      int32
}

func relationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64) (relationCapacity, error) {
	if normalizeRelationType(relationType) == "" {
		return relationCapacity{}, xerr.New(xerr.Conflict, "cp relation type is disabled")
	}
	maxCount, err := relationMaxCountTx(ctx, tx, appCode, relationType)
	if err != nil {
		return relationCapacity{}, err
	}
	for _, userID := range []int64{userAID, userBID} {
		// 容量按用户和关系类型计算；同一对用户互斥由 pair 锁保证，不同对象可以同时拥有兄弟/姐妹关系。
		// user_a/user_b 分支分别命中 idx_user_cp_rel_user_a / idx_user_cp_rel_user_b，避免 OR 条件退化为大范围扫描。
		total, err := countRows(ctx, tx, `
			SELECT COUNT(*)
			FROM (
				SELECT relationship_id
				FROM user_cp_relationships FORCE INDEX (idx_user_cp_rel_user_a)
				WHERE app_code = ? AND user_a_id = ? AND status = ? AND relation_type = ?
				UNION ALL
				SELECT relationship_id
				FROM user_cp_relationships FORCE INDEX (idx_user_cp_rel_user_b)
				WHERE app_code = ? AND user_b_id = ? AND status = ? AND relation_type = ?
			) active_relationships`,
			appCode, userID, cpdomain.RelationshipStatusActive, relationType,
			appCode, userID, cpdomain.RelationshipStatusActive, relationType,
		)
		if err != nil {
			return relationCapacity{}, err
		}
		if total >= int64(maxCount) {
			return relationCapacity{LimitedUserID: userID, CurrentCount: total, MaxCount: maxCount}, nil
		}
	}
	return relationCapacity{MaxCount: maxCount}, nil
}

func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64, actorUserID int64) error {
	capacity, err := relationCapacityTx(ctx, tx, appCode, relationType, userAID, userBID)
	if err != nil {
		return err
	}
	if capacity.LimitedUserID <= 0 {
		return nil
	}
	// CP 固定一人一个；同意按钮的操作者需要知道是自己还是申请另一方已占用名额。
	// 兄弟/姐妹由后台配置多名额，继续使用通用容量错误，避免把非 CP 关系误称为 CP。
	if normalizeRelationType(relationType) == cpdomain.RelationTypeCP {
		if capacity.LimitedUserID == actorUserID {
			return xerr.New(xerr.CPAlreadyExistsSelf, "current user already has an active cp relationship")
		}
		return xerr.New(xerr.CPAlreadyExistsOther, "other party already has an active cp relationship")
	}
	return xerr.New(xerr.Conflict, "user cp relation count reaches limit")
}

func relationMaxCountTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string) (int32, error) {
	// CP 的“一人只能有一个”是产品硬约束，不能被后台或脏数据配置放宽。
	if normalizeRelationType(relationType) == cpdomain.RelationTypeCP {
		return 1, nil
	}
	var maxCount int32
	err := tx.QueryRowContext(ctx, `
		SELECT max_count_per_user
		FROM user_cp_relation_configs
		WHERE app_code = ? AND relation_type = ? AND status = 'active'
		FOR UPDATE`, appCode, relationType).Scan(&maxCount)
	if err == sql.ErrNoRows {
		return 0, xerr.New(xerr.Conflict, "cp relation type is disabled")
	}
	if maxCount <= 0 {
		maxCount = 1
	}
	return maxCount, err
}

func addRelationshipIntimacyTx(ctx context.Context, tx *sql.Tx, appCode string, relationship cpdomain.Relationship, delta int64, viewerUserID int64, nowMs int64) (cpdomain.Relationship, error) {
	if delta < 0 {
		delta = 0
	}
	nextValue := relationship.IntimacyValue + delta
	// 等级直接由当前亲密值重算，避免漏处理一次大额礼物跨多级升级。
	nextLevel, err := levelForIntimacyTx(ctx, tx, appCode, relationship.RelationType, nextValue)
	if err != nil {
		return cpdomain.Relationship{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_cp_relationships
		SET intimacy_value = ?, level_no = ?, updated_at_ms = ?
		WHERE app_code = ? AND relationship_id = ?`,
		nextValue, nextLevel, nowMs, appCode, relationship.RelationshipID,
	); err != nil {
		return cpdomain.Relationship{}, err
	}
	return queryRelationshipByIDTx(ctx, tx, appCode, viewerUserID, relationship.RelationshipID)
}

func levelForIntimacyTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, intimacy int64) (int32, error) {
	rows, err := tx.QueryContext(ctx, `
		SELECT level_no, intimacy_threshold
		FROM user_cp_level_rules
		WHERE app_code = ? AND relation_type = ? AND status = 'active'
		ORDER BY level_no ASC`, appCode, relationType)
	if err != nil {
		return 1, err
	}
	defer rows.Close()
	level := int32(1)
	for rows.Next() {
		var candidate int32
		var threshold int64
		if err := rows.Scan(&candidate, &threshold); err != nil {
			return 1, err
		}
		if intimacy >= threshold {
			level = candidate
		}
	}
	return level, rows.Err()
}

func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string, event cpdomain.GiftEvent, relationType string, nowMs int64) (cpdomain.Application, error) {
	expireHours, err := applicationExpireHoursTx(ctx, tx, appCode, relationType)
	if err != nil {
		return cpdomain.Application{}, err
	}
	expiresAtMS := nowMs + int64(expireHours)*time.Hour.Milliseconds()
	row := tx.QueryRowContext(ctx, applicationSelectSQL(`
		WHERE a.app_code = ?
		  AND a.requester_user_id = ?
		  AND a.target_user_id = ?
		  AND a.relation_type = ?
		  AND a.status = ?
		  AND a.expires_at_ms > ?
		ORDER BY a.created_at_ms DESC
		LIMIT 1
		FOR UPDATE`),
		appCode, event.SenderUserID, event.TargetUserID, relationType, cpdomain.ApplicationStatusPending, nowMs,
	)
	application, err := scanApplication(row)
	if err != nil && err != sql.ErrNoRows {
		return cpdomain.Application{}, err
	}
	if err == nil {
		// 同一对用户、同一类型已有 pending 申请时刷新礼物快照和过期时间，不生成多条待处理卡片。
		if _, err := tx.ExecContext(ctx, `
			UPDATE user_cp_applications
			SET room_id = ?, room_region_id = ?, room_locked = ?, gift_id = ?, gift_name = ?, gift_icon_url = ?,
			    gift_animation_url = ?, gift_count = ?, gift_value = ?, gift_coin_value = ?, formation_feed_eligible = ?, billing_receipt_id = ?,
			    source_room_event_id = ?, source_command_id = ?, expires_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND application_id = ?`,
			event.RoomID, event.VisibleRegionID, event.RoomLocked, event.GiftID, event.GiftName, event.GiftIconURL,
			event.GiftAnimationURL, event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID,
			event.EventID, event.CommandID, expiresAtMS, nowMs, appCode, application.ApplicationID,
		); err != nil {
			return cpdomain.Application{}, err
		}
		application.RoomID = event.RoomID
		application.RoomRegionID = event.VisibleRegionID
		application.RoomLocked = event.RoomLocked
		application.Gift = giftSnapshotFromEvent(event)
		application.SourceEventID = event.EventID
		application.SourceCommandID = event.CommandID
		application.ExpiresAtMS = expiresAtMS
		application.UpdatedAtMS = nowMs
		return application, nil
	}
	applicationID := fmt.Sprintf("cp_app:%s", strings.TrimSpace(event.EventID))
	// 新申请 ID 派生自 room outbox event_id，结合消费位点可以保证 MQ 重投不会生成第二条申请。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_cp_applications (
			app_code, application_id, requester_user_id, target_user_id, relation_type, status,
			room_id, room_region_id, room_locked, gift_id, gift_name, gift_icon_url, gift_animation_url,
			gift_count, gift_value, gift_coin_value, formation_feed_eligible, billing_receipt_id, source_room_event_id, source_command_id,
			created_at_ms, updated_at_ms, expires_at_ms, decided_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
		appCode, applicationID, event.SenderUserID, event.TargetUserID, relationType, cpdomain.ApplicationStatusPending,
		event.RoomID, event.VisibleRegionID, event.RoomLocked, event.GiftID, event.GiftName, event.GiftIconURL, event.GiftAnimationURL,
		event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID, event.EventID, event.CommandID,
		nowMs, nowMs, expiresAtMS,
	); err != nil {
		return cpdomain.Application{}, err
	}
	row = tx.QueryRowContext(ctx, applicationSelectSQL(`
		WHERE a.app_code = ? AND a.application_id = ?`), appCode, applicationID)
	return scanApplication(row)
}

func applicationExpireHoursTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string) (int32, error) {
	var hours int32
	err := tx.QueryRowContext(ctx, `
		SELECT application_expire_hours
		FROM user_cp_relation_configs
		WHERE app_code = ? AND relation_type = ? AND status = 'active'`, appCode, relationType).Scan(&hours)
	if err == sql.ErrNoRows {
		return 0, xerr.New(xerr.Conflict, "cp relation type is disabled")
	}
	if hours <= 0 {
		hours = 24
	}
	return hours, err
}

func lockConsumedEventTx(ctx context.Context, tx *sql.Tx, appCode string, eventID string) (bool, error) {
	// 消费位点也加锁，防止同一 event_id 在并行 consumer 中同时进入创建/加亲密值分支。
	var found string
	err := tx.QueryRowContext(ctx, `
		SELECT room_event_id
		FROM user_cp_gift_event_consumption
		WHERE app_code = ? AND room_event_id = ?
		FOR UPDATE`, appCode, eventID).Scan(&found)
	if err == sql.ErrNoRows {
		return false, nil
	}
	return err == nil, err
}

func insertConsumptionTx(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, applicationID string, relationshipID string, skipReason string, giftValue int64, occurredAtMS int64, nowMs int64) error {
	if occurredAtMS <= 0 {
		occurredAtMS = nowMs
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_cp_gift_event_consumption (
			app_code, room_event_id, status, application_id, relationship_id,
			skip_reason, gift_value, occurred_at_ms, consumed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode, eventID, status, applicationID, relationshipID, skipReason, giftValue, occurredAtMS, nowMs, nowMs, nowMs,
	)
	return err
}

func insertOutboxTx(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, payload any, nowMs int64) error {
	payloadBytes, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	// event_id 是 user_outbox 的投递事实主键，必须按每条通知唯一生成；同一个目标用户同毫秒内可能收到多条 CP 申请/结果通知。
	eventID := idgen.New("uout")
	// outbox 与业务状态同事务提交；RocketMQ 发布失败只影响异步 IM/统计，不回滚已经同意的关系。
	_, err = tx.ExecContext(ctx, `
		INSERT INTO user_outbox (
			app_code, event_id, event_type, aggregate_type, aggregate_id,
			status, worker_id, lock_until_ms, retry_count, next_retry_at_ms,
			last_error, payload_json, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'pending', '', 0, 0, 0, '', CAST(? AS JSON), ?, ?)
		ON DUPLICATE KEY UPDATE event_id = VALUES(event_id)`,
		appCode, eventID, eventType, aggregateType, aggregateID, string(payloadBytes), nowMs, nowMs,
	)
	return err
}

func countRows(ctx context.Context, q interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
}, query string, args ...any) (int64, error) {
	var total int64
	err := q.QueryRowContext(ctx, query, args...).Scan(&total)
	return total, err
}

func normalizeRelationType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case cpdomain.RelationTypeCP:
		return cpdomain.RelationTypeCP
	case cpdomain.RelationTypeBrother:
		return cpdomain.RelationTypeBrother
	case cpdomain.RelationTypeSister:
		return cpdomain.RelationTypeSister
	default:
		return ""
	}
}

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

func relationshipIDForPair(userAID int64, userBID int64) string {
	// 关系 ID 固定由无序 pair 派生，配合 active 唯一约束保证 A/B 不会同时有多种 active 关系。
	return fmt.Sprintf("cp_rel:%d:%d", userAID, userBID)
}

func giftSnapshotFromEvent(event cpdomain.GiftEvent) cpdomain.GiftSnapshot {
	return cpdomain.GiftSnapshot{
		GiftID:           strings.TrimSpace(event.GiftID),
		GiftName:         strings.TrimSpace(event.GiftName),
		GiftIconURL:      strings.TrimSpace(event.GiftIconURL),
		GiftAnimationURL: strings.TrimSpace(event.GiftAnimationURL),
		GiftCount:        event.GiftCount,
		GiftValue:        event.GiftValue,
		CoinSpent:        event.CoinSpent,
		BillingReceiptID: strings.TrimSpace(event.BillingReceiptID),
	}
}

func formationGiftFeedEligibility(coinSpent int64) int8 {
	// 资格值和查询中的严格门槛共用同一常量；pending 礼物快照被覆盖时该值也随最新 wallet 结算价值重算，不累计旧礼物。
	if coinSpent > formationGiftCoinThreshold {
		return 1
	}
	return 0
}

func clampPageSize(pageSize int32) int32 {
	if pageSize <= 0 {
		return 20
	}
	if pageSize > 100 {
		return 100
	}
	return pageSize
}

func offset(page int32, pageSize int32) int32 {
	if page <= 1 {
		return 0
	}
	return (page - 1) * clampPageSize(pageSize)
}
