package giftlimit

import (
	"context"
	"errors"
	"reflect"
	"strings"
	"testing"
	"time"
)

type redisScriptCall struct {
	script string
	keys   []string
	args   []any
}

type fakeRedisScriptRunner struct {
	result  []any
	results [][]any
	errors  []error
	calls   []redisScriptCall
}

func (f *fakeRedisScriptRunner) evalSlice(_ context.Context, script string, keys []string, args ...any) ([]any, error) {
	f.calls = append(f.calls, redisScriptCall{
		script: script,
		keys:   append([]string(nil), keys...),
		args:   append([]any(nil), args...),
	})
	if len(f.errors) > 0 {
		err := f.errors[0]
		f.errors = f.errors[1:]
		if err != nil {
			return nil, err
		}
	}
	if len(f.results) > 0 {
		result := f.results[0]
		f.results = f.results[1:]
		return result, nil
	}
	return f.result, nil
}

type blockingRedisScriptRunner struct{}

func (blockingRedisScriptRunner) evalSlice(ctx context.Context, _ string, _ []string, _ ...any) ([]any, error) {
	<-ctx.Done()
	return nil, ctx.Err()
}

func TestRedisClientOptionsEnforceBoundedContextAwareIO(t *testing.T) {
	options := redisClientOptions(" 127.0.0.1:6379 ", "secret", 3)
	if options.Addr != "127.0.0.1:6379" || options.Password != "secret" || options.DB != 3 {
		t.Fatalf("Redis identity options mismatch: %+v", options)
	}
	if !options.ContextTimeoutEnabled || options.MaxRetries != 1 || options.DialerRetries != 1 {
		t.Fatalf("Redis timeout/retry guard mismatch: %+v", options)
	}
	if options.DialTimeout != redisDialTimeout || options.ReadTimeout != redisReadTimeout || options.WriteTimeout != redisWriteTimeout || options.PoolTimeout != redisPoolTimeout {
		t.Fatalf("Redis socket/pool timeouts mismatch: %+v", options)
	}
}

func TestRedisLimiterAcquireUsesThreeWeightedClusterSafeLayers(t *testing.T) {
	runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
	limiter := newRedisLimiter(runner, Config{
		KeyPrefix:                 "test:gift:",
		UserBucketCapacity:        40,
		UserRefillTokensPerSecond: 15,
		RoomBucketCapacity:        120,
		RoomRefillTokensPerSecond: 40,
		AppBucketCapacity:         1000,
		AppRefillTokensPerSecond:  500,
		MaxInFlight:               2,
		InFlightLease:             10 * time.Second,
		RedisOperationTimeout:     time.Second,
	})

	decision, err := limiter.Acquire(context.Background(), Input{
		AppCode:   "LALU",
		UserID:    42,
		RoomID:    "room:unsafe/value",
		RequestID: "req-1",
		Weight:    13,
	})
	if err != nil {
		t.Fatalf("Acquire failed: %v", err)
	}
	if !decision.Allowed || decision.Lease.inFlightKey == "" || decision.Lease.member == "" {
		t.Fatalf("allowed decision must include release lease: %+v", decision)
	}
	if len(runner.calls) != 3 {
		t.Fatalf("Acquire must execute app, room and user layers: calls=%d", len(runner.calls))
	}
	if runner.calls[0].script != redisUserAcquireScript || runner.calls[1].script != redisTokenBucketScript || runner.calls[2].script != redisTokenBucketScript {
		t.Fatalf("layer script order mismatch: %+v", runner.calls)
	}
	if len(runner.calls[0].keys) != 2 || len(runner.calls[1].keys) != 1 || len(runner.calls[2].keys) != 1 {
		t.Fatalf("each Lua must stay inside one aggregation slot: %+v", runner.calls)
	}
	appTag := redisHashTag(runner.calls[2].keys[0])
	roomTag := redisHashTag(runner.calls[1].keys[0])
	userTag := redisHashTag(runner.calls[0].keys[0])
	if appTag == "" || roomTag == "" || userTag == "" || appTag == roomTag || appTag == userTag || roomTag == userTag {
		t.Fatalf("aggregation layers must use distinct hash slots: app=%q room=%q user=%q", appTag, roomTag, userTag)
	}
	if redisHashTag(runner.calls[0].keys[1]) != userTag {
		t.Fatalf("user bucket and in-flight lease must share one hash slot: %v", runner.calls[0].keys)
	}
	for _, call := range runner.calls {
		for _, key := range call.keys {
			if strings.Contains(key, "room:unsafe") || strings.Contains(key, "LALU") {
				t.Fatalf("untrusted scope leaked into Redis key: %q", key)
			}
		}
		if got := call.args[2]; got != 13 {
			t.Fatalf("target_count must weight every layer: got=%v call=%+v", got, call)
		}
	}
	if runner.calls[0].args[3] != 2 || runner.calls[0].args[4] != int64(10_000) {
		t.Fatalf("user concurrency/lease args mismatch: %v", runner.calls[0].args)
	}
}

func TestRedisLimiterStopsAtRejectedLayerWithoutUnsafeRollback(t *testing.T) {
	tests := []struct {
		name        string
		results     [][]any
		wantCalls   int
		wantRetryMS int64
	}{
		{name: "user", results: [][]any{{int64(0), int64(101)}}, wantCalls: 1, wantRetryMS: 101},
		{name: "room", results: [][]any{{int64(1), int64(0)}, {int64(0), int64(202)}, {int64(1)}}, wantCalls: 3, wantRetryMS: 202},
		{name: "app", results: [][]any{{int64(1), int64(0)}, {int64(1), int64(0)}, {int64(0), int64(303)}, {int64(1)}}, wantCalls: 4, wantRetryMS: 303},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			runner := &fakeRedisScriptRunner{results: test.results}
			limiter := newRedisLimiter(runner, Config{})
			decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-deny", Weight: 1})
			if err != nil {
				t.Fatalf("Acquire failed: %v", err)
			}
			if decision.Allowed || decision.RetryAfter != time.Duration(test.wantRetryMS)*time.Millisecond || len(runner.calls) != test.wantCalls {
				t.Fatalf("denied layer mismatch: decision=%+v calls=%d", decision, len(runner.calls))
			}
			if test.name != "user" && runner.calls[len(runner.calls)-1].script != redisReleaseScript {
				t.Fatalf("shared-layer rejection must immediately release user lease: calls=%+v", runner.calls)
			}
		})
	}
}

func TestRedisLimiterInFlightDenialDoesNotConsumeRoomOrApp(t *testing.T) {
	runner := &fakeRedisScriptRunner{result: []any{int64(0), int64(250)}}
	limiter := newRedisLimiter(runner, Config{})
	decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-concurrent", Weight: 13})
	if err != nil || decision.Allowed || decision.RetryAfter != 250*time.Millisecond {
		t.Fatalf("user in-flight denial mismatch: decision=%+v err=%v", decision, err)
	}
	if len(runner.calls) != 1 || runner.calls[0].script != redisUserAcquireScript || len(runner.calls[0].keys) != 2 {
		t.Fatalf("in-flight denial must stop before room/app: calls=%+v", runner.calls)
	}
}

func TestRedisScopeKeysAggregateDifferentUsersInSameRoom(t *testing.T) {
	left := buildRedisScopeKeys("test:", "lalu", Input{UserID: 42, RoomID: "room-1", RequestID: "req-left"})
	right := buildRedisScopeKeys("test:", "lalu", Input{UserID: 43, RoomID: "room-1", RequestID: "req-right"})
	if left.appBucket != right.appBucket || left.roomBucket != right.roomBucket {
		t.Fatalf("different users in one room must share app and room buckets: left=%+v right=%+v", left, right)
	}
	if left.userBucket == right.userBucket || left.inFlight == right.inFlight {
		t.Fatalf("different users must keep independent user buckets and leases: left=%+v right=%+v", left, right)
	}
}

func TestRedisLimiterReleaseUsesLeaseIdentity(t *testing.T) {
	runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
	limiter := newRedisLimiter(runner, Config{})
	decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-release", Weight: 1})
	if err != nil {
		t.Fatalf("Acquire failed: %v", err)
	}
	wantKey := decision.Lease.inFlightKey
	wantMember := decision.Lease.member
	if err := limiter.Release(context.Background(), decision.Lease); err != nil {
		t.Fatalf("Release failed: %v", err)
	}
	lastCall := runner.calls[len(runner.calls)-1]
	if !reflect.DeepEqual(lastCall.keys, []string{wantKey}) || !reflect.DeepEqual(lastCall.args, []any{wantMember}) || lastCall.script != redisReleaseScript {
		t.Fatalf("release identity mismatch: %+v", lastCall)
	}
}

func TestRedisLimiterAcquireDeadlineFailsClosedQuickly(t *testing.T) {
	limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{RedisOperationTimeout: 30 * time.Millisecond})
	startedAt := time.Now()
	_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-slow", Weight: 1})
	elapsed := time.Since(startedAt)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("blocked Redis must fail closed with deadline: %v", err)
	}
	if elapsed < 20*time.Millisecond || elapsed > 200*time.Millisecond {
		t.Fatalf("blocked Redis escaped short operation budget: elapsed=%s", elapsed)
	}
}

func TestRedisLimiterReleaseDeadlineIsBounded(t *testing.T) {
	limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{})
	startedAt := time.Now()
	err := limiter.Release(context.Background(), Lease{inFlightKey: "test:{user}:inflight", member: "req"})
	elapsed := time.Since(startedAt)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("blocked release must honor deadline: %v", err)
	}
	if elapsed < 900*time.Millisecond || elapsed > 1500*time.Millisecond {
		t.Fatalf("release deadline is not the configured 1s bound: elapsed=%s", elapsed)
	}
}

func TestRedisLimiterPropagatesLayerAndProtocolErrors(t *testing.T) {
	wantErr := errors.New("redis unavailable")
	runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}, errors: []error{nil, wantErr, nil}}
	limiter := newRedisLimiter(runner, Config{})
	input := Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-error", Weight: 1}
	if _, err := limiter.Acquire(context.Background(), input); !errors.Is(err, wantErr) || len(runner.calls) != 3 || runner.calls[2].script != redisReleaseScript {
		t.Fatalf("room-layer backend error mismatch: err=%v calls=%d", err, len(runner.calls))
	}

	limiter = newRedisLimiter(&fakeRedisScriptRunner{result: []any{int64(1)}}, Config{})
	if _, err := limiter.Acquire(context.Background(), input); err == nil {
		t.Fatal("invalid Lua response must fail closed")
	}
}

func TestRedisLimiterRejectsWeightAboveConfiguredCapacity(t *testing.T) {
	runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
	limiter := newRedisLimiter(runner, Config{UserBucketCapacity: 2})
	_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-heavy", Weight: 3})
	if err == nil || len(runner.calls) != 0 {
		t.Fatalf("impossible weight must fail before Redis: err=%v calls=%d", err, len(runner.calls))
	}
}

func redisHashTag(key string) string {
	start := strings.IndexByte(key, '{')
	end := strings.IndexByte(key, '}')
	if start < 0 || end <= start {
		return ""
	}
	return key[start+1 : end]
}
