package service

import (
	"context"
	"strconv"
	"time"

	"github.com/redis/go-redis/v9"
)

// ConcurrencyService is a Redis-backed, self-healing concurrency limiter shared
// by the per-user gate (画图台 + API key) and the per-account upstream gate.
//
// Each slot is a member of a sorted set keyed by the subject (user/account),
// scored with its expiry time. Acquire prunes expired members first, so a slot
// whose Release was lost (crash / missed defer) auto-frees after the TTL — the
// count can never leak forever. It's intentionally lossy-tolerant: if Redis is
// unavailable it FAILS OPEN (allows the work) rather than blocking generation.
type ConcurrencyService struct {
	redis *redis.Client
	// ttl is the max lifetime of a slot — the longest a generation can run
	// (video ~3min) plus head-room, after which a stuck slot self-heals.
	ttl int
}

func NewConcurrencyService(rdb *redis.Client) *ConcurrencyService {
	return &ConcurrencyService{redis: rdb, ttl: 900} // 15 min
}

// acquireScript: KEYS[1]=set, ARGV[1]=max (0=unlimited), ARGV[2]=ttl secs,
// ARGV[3]=token. Prunes expired members, then admits the token iff under max.
// Returns 1 on success, 0 when full.
var acquireScript = redis.NewScript(`
local t = redis.call('TIME')
local now = tonumber(t[1])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
local n = redis.call('ZCARD', KEYS[1])
local max = tonumber(ARGV[1])
if max > 0 and n >= max then return 0 end
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[2]), ARGV[3])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
return 1
`)

// Acquire takes one slot under `key` (capped at max; 0 = unlimited), tagged with
// `token`. Returns true if admitted. Fail-open when Redis is down/unset.
func (c *ConcurrencyService) Acquire(ctx context.Context, key string, max int, token string) bool {
	if c == nil || c.redis == nil {
		return true
	}
	res, err := acquireScript.Run(ctx, c.redis, []string{key}, max, c.ttl, token).Int()
	if err != nil {
		return true // fail open — never block a generation on Redis trouble
	}
	return res == 1
}

// Release frees the slot held by `token` under `key`. Safe to call even if the
// slot already expired.
func (c *ConcurrencyService) Release(ctx context.Context, key, token string) {
	if c == nil || c.redis == nil {
		return
	}
	_ = c.redis.ZRem(ctx, key, token).Err()
}

// Count returns the live (non-expired) slot count under `key` — for display.
func (c *ConcurrencyService) Count(ctx context.Context, key string) int {
	if c == nil || c.redis == nil {
		return 0
	}
	now := time.Now().Unix()
	_ = c.redis.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(now, 10)).Err()
	n, err := c.redis.ZCard(ctx, key).Result()
	if err != nil {
		return 0
	}
	return int(n)
}

// CountUsers returns live concurrency for many users in one round-trip
// (group_id display etc. don't need this, but the user list does). Keyed by the
// raw subject id passed in.
func (c *ConcurrencyService) CountMany(ctx context.Context, prefix string, ids []string) map[string]int {
	out := make(map[string]int, len(ids))
	if c == nil || c.redis == nil || len(ids) == 0 {
		return out
	}
	pipe := c.redis.Pipeline()
	cmds := make(map[string]*redis.IntCmd, len(ids))
	for _, id := range ids {
		cmds[id] = pipe.ZCard(ctx, prefix+id)
	}
	if _, err := pipe.Exec(ctx); err != nil {
		return out
	}
	for id, cmd := range cmds {
		if n, err := cmd.Result(); err == nil && n > 0 {
			out[id] = int(n)
		}
	}
	return out
}
