package cp

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/redis/go-redis/v9"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	cpdomain "hyapp/services/user-service/internal/domain/cp"
)

const (
	defaultIntimacyLeaderboardKeyPrefix = "user:cp_intimacy_leaderboard"
	intimacyLeaderboardRelationAll      = "all"
)

// RedisIntimacyLeaderboardStore 使用 zset 保存 CP 亲密值排行榜；score 以 intimacy_value 为主排序并写入批次内 tie-break，member 是双方展示资料 JSON。
type RedisIntimacyLeaderboardStore struct {
	client    *redis.Client
	keyPrefix string
}

// NewRedisIntimacyLeaderboardClient 创建 Redis 客户端并探测连通性，避免 cron 首次执行才发现配置错误。
func NewRedisIntimacyLeaderboardClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
	client := redis.NewClient(&redis.Options{
		Addr:     strings.TrimSpace(addr),
		Password: password,
		DB:       db,
	})
	// 榜单 Redis 是 App 查询链路的唯一读模型，不按登录风控那样 fail-open；启动期探测失败直接暴露配置问题。
	if err := client.Ping(ctx).Err(); err != nil {
		_ = client.Close()
		return nil, err
	}
	return client, nil
}

// NewRedisIntimacyLeaderboardStore 基于共享 Redis client 创建亲密榜读模型。
func NewRedisIntimacyLeaderboardStore(client *redis.Client, keyPrefix string) *RedisIntimacyLeaderboardStore {
	keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":")
	if keyPrefix == "" {
		// keyPrefix 统一去掉首尾冒号，最终 key 固定为 prefix:app:relation，避免配置里多写冒号产生双冒号 key。
		keyPrefix = defaultIntimacyLeaderboardKeyPrefix
	}
	return &RedisIntimacyLeaderboardStore{client: client, keyPrefix: keyPrefix}
}

// ReplaceIntimacyLeaderboard 每轮全量替换 all/cp/brother/sister 四个 zset，保证资料快照和关系状态可被重建覆盖。
func (s *RedisIntimacyLeaderboardStore) ReplaceIntimacyLeaderboard(ctx context.Context, app string, entries []cpdomain.IntimacyLeaderboardEntry) error {
	if s == nil || s.client == nil {
		return xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured")
	}
	grouped := map[string][]redis.Z{
		intimacyLeaderboardRelationAll: {},
		cpdomain.RelationTypeCP:        {},
		cpdomain.RelationTypeBrother:   {},
		cpdomain.RelationTypeSister:    {},
	}
	for index, entry := range entries {
		relationType := normalizeRelationType(entry.RelationType)
		if relationType == "" || entry.RelationshipID == "" {
			// 异常关系类型或空 relationship_id 不写入榜单，避免 Redis member 变成无法回溯的脏读模型。
			continue
		}
		member, err := encodeIntimacyLeaderboardMember(entry)
		if err != nil {
			return err
		}
		// Redis zset 在同 score 时会按 member 字符串排序；MySQL 已经按更新时间和关系 ID 给出稳定顺序，
		// 这里把批次内顺序编码进小偏移，保证亲密值相同的关系不会因为 JSON 字符串变化而跳名次。
		z := redis.Z{Score: intimacyLeaderboardScore(entry.IntimacyValue, index, len(entries)), Member: member}
		// all 榜给 App 默认入口使用，按关系类型拆出的榜给筛选页使用；四个 key 同轮替换，保证视图时间一致。
		grouped[intimacyLeaderboardRelationAll] = append(grouped[intimacyLeaderboardRelationAll], z)
		grouped[relationType] = append(grouped[relationType], z)
	}
	for relationType, members := range grouped {
		// 即使某个关系类型本轮没有数据也要执行 replaceKey，让已结束关系从旧榜单里被清掉。
		if err := s.replaceKey(ctx, s.key(app, relationType), members); err != nil {
			return err
		}
	}
	return nil
}

// ListIntimacyLeaderboard 读取 zset 分页；rank 由当前 zset 顺序计算，不信任 member 里的旧 rank。
func (s *RedisIntimacyLeaderboardStore) ListIntimacyLeaderboard(ctx context.Context, app string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error) {
	if s == nil || s.client == nil {
		return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard redis is not configured")
	}
	relationType = normalizeRelationType(relationType)
	if relationType == "" {
		relationType = intimacyLeaderboardRelationAll
	}
	page, pageSize = normalizeLeaderboardPage(page, pageSize)
	key := s.key(app, relationType)
	// zset 是降序榜单，offset/stop 按客户端分页直接映射；pageSize 已在 service 内限制最大值。
	offset := int64((page - 1) * pageSize)
	stop := offset + int64(pageSize) - 1

	// total 用 ZCARD 取当前 key 的真实长度；空 key 返回 0，不需要额外区分“还没刷新”和“当前无数据”。
	total, err := s.client.ZCard(ctx, key).Result()
	if err != nil {
		return cpdomain.IntimacyLeaderboardPage{}, err
	}
	rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
	if err != nil {
		return cpdomain.IntimacyLeaderboardPage{}, err
	}
	items := make([]cpdomain.IntimacyLeaderboardEntry, 0, len(rows))
	for index, row := range rows {
		member, ok := row.Member.(string)
		if !ok || strings.TrimSpace(member) == "" {
			continue
		}
		var entry cpdomain.IntimacyLeaderboardEntry
		if err := json.Unmarshal([]byte(member), &entry); err != nil {
			return cpdomain.IntimacyLeaderboardPage{}, err
		}
		// rank 只由当前 Redis 顺序计算，member 内写入时会清零，避免历史 rank 在重新排序后污染返回值。
		entry.Rank = offset + int64(index) + 1
		items = append(items, entry)
	}
	return cpdomain.IntimacyLeaderboardPage{
		Items:        items,
		Total:        total,
		Page:         page,
		PageSize:     pageSize,
		ServerTimeMS: nowMs,
	}, nil
}

func (s *RedisIntimacyLeaderboardStore) replaceKey(ctx context.Context, key string, members []redis.Z) error {
	if len(members) == 0 {
		// 全量重建时空集合代表该榜单当前没有 active 关系，直接删除旧 key，避免 App 看到过期关系。
		return s.client.Del(ctx, key).Err()
	}
	tmpKey := key + ":tmp:" + idgen.New("cprank")
	pipe := s.client.Pipeline()
	// 先写临时 key，再 RENAME 覆盖正式 key；读取方要么看到旧榜，要么看到新榜，不会看到半截榜单。
	pipe.Del(ctx, tmpKey)
	pipe.ZAdd(ctx, tmpKey, members...)
	pipe.Rename(ctx, tmpKey, key)
	_, err := pipe.Exec(ctx)
	return err
}

func (s *RedisIntimacyLeaderboardStore) key(app string, relationType string) string {
	app = appcode.Normalize(app)
	relationType = strings.TrimSpace(relationType)
	if relationType == "" {
		relationType = intimacyLeaderboardRelationAll
	}
	return fmt.Sprintf("%s:%s:%s", s.keyPrefix, app, relationType)
}

func encodeIntimacyLeaderboardMember(entry cpdomain.IntimacyLeaderboardEntry) (string, error) {
	// rank 是读取时按 zset 顺序计算的派生值，写入 member 前清零，避免同一关系在不同分榜里带着错误名次。
	entry.Rank = 0
	payload, err := json.Marshal(entry)
	if err != nil {
		return "", err
	}
	return string(payload), nil
}

func intimacyLeaderboardScore(intimacyValue int64, index int, total int) float64 {
	if total <= 0 {
		total = 1
	}
	// score = 亲密值 * (total+1) + 批次倒序偏移；这样任意 1 点亲密值差距都大于所有 tie-break 偏移。
	tieOffset := total - index
	if tieOffset < 0 {
		tieOffset = 0
	}
	return float64(intimacyValue)*float64(total+1) + float64(tieOffset)
}
