// Package userleaderboard owns the Redis read model for App user gift rankings.
package userleaderboard

import (
	"context"
	"errors"
	"fmt"
	"math"
	"strconv"
	"strings"
	"time"

	"github.com/redis/go-redis/v9"
	"hyapp/pkg/appcode"
)

const (
	BoardSent     = "sent"
	BoardReceived = "received"

	PeriodToday = "today"
	PeriodWeek  = "week"
	PeriodMonth = "month"

	defaultKeyPrefix       = "activity:user_leaderboard"
	defaultPageSize        = 20
	maxPageSize            = 100
	defaultEventDedupeTTL  = 90 * 24 * time.Hour
	dailyLeaderboardKeep   = 7 * 24 * time.Hour
	weeklyLeaderboardKeep  = 21 * 24 * time.Hour
	monthlyLeaderboardKeep = 35 * 24 * time.Hour
)

var ErrNotConfigured = errors.New("user leaderboard redis is not configured")

// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only.
type Store struct {
	client    *redis.Client
	keyPrefix string
}

// GiftEvent is the minimal WalletGiftDebited fact needed to update the user leaderboard.
type GiftEvent struct {
	AppCode      string
	EventID      string
	SenderUserID int64
	TargetUserID int64
	GiftValue    int64
	GiftCount    int64
	OccurredAtMS int64
	DirectGift   bool
}

// Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
type Query struct {
	AppCode   string
	BoardType string
	Period    string
	Page      int
	PageSize  int
	Now       time.Time
}

// Page is the Redis aggregate result returned to gateway before profile enrichment.
type Page struct {
	Items        []Entry
	Total        int64
	BoardType    string
	Period       string
	StartAtMS    int64
	EndAtMS      int64
	ServerTimeMS int64
}

// Entry contains only aggregate facts. gateway enriches user profile data separately.
type Entry struct {
	Rank             int64
	UserID           string
	GiftValue        int64
	GiftCount        int64
	TransactionCount int64
	LastGiftAtMS     int64
}

// NewRedisClient creates and verifies the Redis dependency used by the leaderboard read model.
func NewRedisClient(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 是 gateway 查询的唯一读模型；启动时探测失败比运行中慢查钱包库更容易定位。
	if err := client.Ping(ctx).Err(); err != nil {
		_ = client.Close()
		return nil, err
	}
	return client, nil
}

// NewStore binds a verified Redis client to a stable key namespace.
func NewStore(client *redis.Client, keyPrefix string) *Store {
	keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":")
	if keyPrefix == "" {
		keyPrefix = defaultKeyPrefix
	}
	return &Store{client: client, keyPrefix: keyPrefix}
}

// ApplyGiftEvent increments sent and received buckets for day/week/month.
// It returns false when the fact is outside the old SQL leaderboard scope, such as direct gifts.
func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, error) {
	if s == nil || s.client == nil {
		return false, ErrNotConfigured
	}
	event = normalizeGiftEvent(event)
	if event.AppCode == "" || event.EventID == "" || event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.GiftValue <= 0 || event.DirectGift {
		return false, nil
	}

	occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
	keys := []string{s.dedupeKey(event.AppCode, event.EventID)}
	args := []any{
		strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10),
		strconv.FormatInt(event.GiftValue, 10),
		strconv.FormatInt(event.GiftCount, 10),
		strconv.FormatInt(event.OccurredAtMS, 10),
	}

	updates := make([]leaderboardUpdate, 0, 6)
	for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
		updates = append(updates,
			s.updateFor(event.AppCode, BoardSent, period, event.SenderUserID, occurredAt),
			s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
		)
	}
	args = append(args, strconv.Itoa(len(updates)))
	for _, update := range updates {
		keys = append(keys, update.scoreKey, update.itemKey)
		args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10))
	}

	result, err := applyGiftEventScript.Run(ctx, s.client, keys, args...).Int64()
	if err != nil {
		return false, err
	}
	return result == 1, nil
}

// List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings.
func (s *Store) List(ctx context.Context, query Query) (Page, error) {
	if s == nil || s.client == nil {
		return Page{}, ErrNotConfigured
	}
	query = normalizeQuery(query)
	start, end := QueryWindow(query.Period, query.Now)
	key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
	offset := int64((query.Page - 1) * query.PageSize)
	stop := offset + int64(query.PageSize) - 1

	total, err := s.client.ZCard(ctx, key).Result()
	if err != nil {
		return Page{}, err
	}
	rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
	if err != nil {
		return Page{}, err
	}

	items := make([]Entry, 0, len(rows))
	for index, row := range rows {
		userID, ok := row.Member.(string)
		if !ok || strings.TrimSpace(userID) == "" {
			continue
		}
		entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, userID, int64(math.Round(row.Score)))
		if err != nil {
			return Page{}, err
		}
		entry.Rank = offset + int64(index) + 1
		items = append(items, entry)
	}
	return Page{
		Items:        items,
		Total:        total,
		BoardType:    query.BoardType,
		Period:       query.Period,
		StartAtMS:    start.UnixMilli(),
		EndAtMS:      end.UnixMilli(),
		ServerTimeMS: query.Now.UTC().UnixMilli(),
	}, nil
}

// RankForUser returns the caller's current aggregate rank for the same bucket used by List.
func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Entry, bool, error) {
	if s == nil || s.client == nil {
		return Entry{}, false, ErrNotConfigured
	}
	if userID <= 0 {
		return Entry{}, false, nil
	}
	query = normalizeQuery(query)
	start, _ := QueryWindow(query.Period, query.Now)
	key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
	member := strconv.FormatInt(userID, 10)
	rank, err := s.client.ZRevRank(ctx, key, member).Result()
	if errors.Is(err, redis.Nil) {
		return Entry{}, false, nil
	}
	if err != nil {
		return Entry{}, false, err
	}
	entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, member, 0)
	if err != nil {
		return Entry{}, false, err
	}
	entry.Rank = rank + 1
	return entry, true, nil
}

// NormalizeBoardType keeps gateway query aliases out of the Redis key layer.
func NormalizeBoardType(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "", BoardSent, "send", "sender", "gift_sent", "user_sent":
		return BoardSent
	case BoardReceived, "receive", "receiver", "gift_received", "user_received":
		return BoardReceived
	default:
		return ""
	}
}

// NormalizePeriod maps public aliases to the fixed UTC buckets used by activity and gateway.
func NormalizePeriod(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "", PeriodToday, "day", "daily":
		return PeriodToday
	case PeriodWeek, "weekly":
		return PeriodWeek
	case PeriodMonth, "monthly":
		return PeriodMonth
	default:
		return ""
	}
}

// QueryWindow preserves the existing gateway response contract: start is the UTC bucket boundary, end is request time.
func QueryWindow(period string, now time.Time) (time.Time, time.Time) {
	now = normalizedNow(now)
	start, _ := periodBounds(period, now)
	return start, now
}

type leaderboardUpdate struct {
	scoreKey string
	itemKey  string
	member   string
	ttl      time.Duration
}

func (s *Store) updateFor(app string, board string, period string, userID int64, occurredAt time.Time) leaderboardUpdate {
	start, periodEnd := periodBounds(period, occurredAt)
	member := strconv.FormatInt(userID, 10)
	return leaderboardUpdate{
		scoreKey: s.scoreKey(app, board, period, start),
		itemKey:  s.itemKey(app, board, period, start, member),
		member:   member,
		ttl:      retentionTTL(period, start, periodEnd),
	}
}

func (s *Store) entryFromHash(ctx context.Context, app string, board string, period string, start time.Time, userID string, scoreFallback int64) (Entry, error) {
	values, err := s.client.HGetAll(ctx, s.itemKey(app, board, period, start, userID)).Result()
	if err != nil {
		return Entry{}, err
	}
	entry := Entry{
		UserID:    userID,
		GiftValue: scoreFallback,
	}
	if value := int64FromHash(values, "gift_value"); value > 0 {
		entry.GiftValue = value
	}
	entry.GiftCount = int64FromHash(values, "gift_count")
	entry.TransactionCount = int64FromHash(values, "transaction_count")
	entry.LastGiftAtMS = int64FromHash(values, "last_gift_at_ms")
	return entry, nil
}

func (s *Store) scoreKey(app string, board string, period string, start time.Time) string {
	return fmt.Sprintf("%s:%s:%s:%s:%s:scores", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start))
}

func (s *Store) itemKey(app string, board string, period string, start time.Time, userID string) string {
	return fmt.Sprintf("%s:%s:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start), userID)
}

func (s *Store) dedupeKey(app string, eventID string) string {
	return fmt.Sprintf("%s:%s:dedupe:%s", s.keyPrefix, appcode.Normalize(app), strings.TrimSpace(eventID))
}

func normalizeGiftEvent(event GiftEvent) GiftEvent {
	event.AppCode = appcode.Normalize(event.AppCode)
	event.EventID = strings.TrimSpace(event.EventID)
	if event.OccurredAtMS <= 0 {
		event.OccurredAtMS = time.Now().UTC().UnixMilli()
	}
	return event
}

func normalizeQuery(query Query) Query {
	query.AppCode = appcode.Normalize(query.AppCode)
	query.BoardType = NormalizeBoardType(query.BoardType)
	if query.BoardType == "" {
		query.BoardType = BoardSent
	}
	query.Period = NormalizePeriod(query.Period)
	if query.Period == "" {
		query.Period = PeriodToday
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = defaultPageSize
	}
	if query.PageSize > maxPageSize {
		query.PageSize = maxPageSize
	}
	query.Now = normalizedNow(query.Now)
	return query
}

func normalizedNow(now time.Time) time.Time {
	if now.IsZero() {
		return time.Now().UTC()
	}
	return now.UTC()
}

func periodBounds(period string, at time.Time) (time.Time, time.Time) {
	at = normalizedNow(at)
	dayStart := time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC)
	switch NormalizePeriod(period) {
	case PeriodWeek:
		weekday := int(dayStart.Weekday())
		if weekday == 0 {
			weekday = 7
		}
		start := dayStart.AddDate(0, 0, 1-weekday)
		return start, start.AddDate(0, 0, 7)
	case PeriodMonth:
		start := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, time.UTC)
		return start, start.AddDate(0, 1, 0)
	default:
		return dayStart, dayStart.AddDate(0, 0, 1)
	}
}

func retentionTTL(period string, start time.Time, periodEnd time.Time) time.Duration {
	switch NormalizePeriod(period) {
	case PeriodMonth:
		return periodEnd.Sub(start) + monthlyLeaderboardKeep
	case PeriodWeek:
		return periodEnd.Sub(start) + weeklyLeaderboardKeep
	default:
		return periodEnd.Sub(start) + dailyLeaderboardKeep
	}
}

func bucketID(period string, start time.Time) string {
	switch NormalizePeriod(period) {
	case PeriodWeek:
		year, week := start.ISOWeek()
		return fmt.Sprintf("%04d%02d", year, week)
	case PeriodMonth:
		return fmt.Sprintf("%04d%02d", start.Year(), int(start.Month()))
	default:
		return start.Format("20060102")
	}
}

func int64FromHash(values map[string]string, key string) int64 {
	value, _ := strconv.ParseInt(values[key], 10, 64)
	return value
}

var applyGiftEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then
	return 0
end
redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do
	local score_key = KEYS[2 + i * 2]
	local item_key = KEYS[3 + i * 2]
	local member = ARGV[6 + i * 2]
	local ttl_seconds = tonumber(ARGV[7 + i * 2])
	local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value)
	redis.call("HINCRBY", item_key, "gift_count", gift_count)
	redis.call("HINCRBY", item_key, "transaction_count", 1)
	local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
	local last_gift_at_ms = previous_last
	if occurred_at_ms > previous_last then
		redis.call("HSET", item_key, "last_gift_at_ms", occurred_at_ms)
		last_gift_at_ms = occurred_at_ms
	end
	redis.call("ZADD", score_key, tonumber(current_value) + last_gift_at_ms / 10000000000000000, member)
	redis.call("EXPIRE", score_key, ttl_seconds)
	redis.call("EXPIRE", item_key, ttl_seconds)
end
return 1
`)
