package externaladmin

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"hyapp-admin-server/internal/cache"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

type allowFixedWindowLimiter struct{}

func (allowFixedWindowLimiter) ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
	return cache.FixedWindowDecision{Allowed: true}, nil
}

type recordingFixedWindowLimiter struct {
	decision    cache.FixedWindowDecision
	err         error
	calls       int
	window      time.Duration
	rules       []cache.FixedWindowRule
	batches     [][]cache.FixedWindowRule
	deadlineSet bool
	deadlineIn  time.Duration
}

func (limiter *recordingFixedWindowLimiter) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
	limiter.calls++
	limiter.window = window
	limiter.rules = append([]cache.FixedWindowRule(nil), rules...)
	limiter.batches = append(limiter.batches, append([]cache.FixedWindowRule(nil), rules...))
	deadline, ok := ctx.Deadline()
	limiter.deadlineSet = ok
	if ok {
		limiter.deadlineIn = time.Until(deadline)
	}
	return limiter.decision, limiter.err
}

func TestLoginRateLimiterUsesTrustedHashedLayersBeforeAccountResolution(t *testing.T) {
	limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
	service := NewService(&gorm.DB{}, nil, Config{LoginRateLimit: LoginRateLimitConfig{
		Window: time.Minute, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10,
	}})
	service.limiter = limiter

	// Empty password is invalid, but this syntactically valid attempt must consume
	// distributed counters before dummy bcrypt/input rejection.
	_, err := service.Login(t.Context(), LoginInput{Username: "Operator", IP: "203.0.113.44"})
	if !errors.Is(err, ErrInvalidCredentials) {
		t.Fatalf("login error = %v", err)
	}
	if limiter.calls != 1 || limiter.window != time.Minute || len(limiter.rules) != 3 {
		t.Fatalf("limiter call=%d window=%s rules=%+v", limiter.calls, limiter.window, limiter.rules)
	}
	if !limiter.deadlineSet || limiter.deadlineIn <= 0 || limiter.deadlineIn > loginLimiterTimeout+25*time.Millisecond {
		t.Fatalf("independent limiter deadline = %s set=%t", limiter.deadlineIn, limiter.deadlineSet)
	}
	wantLimits := []uint64{300, 30, 10}
	for index, rule := range limiter.rules {
		if rule.Limit != wantLimits[index] {
			t.Fatalf("rule %d limit=%d", index, rule.Limit)
		}
		if !strings.Contains(rule.Key, loginLimiterClusterTag) {
			t.Fatalf("rule %d is not Redis Cluster-safe: %s", index, rule.Key)
		}
		for _, secret := range []string{"operator", "203.0.113.44"} {
			if strings.Contains(rule.Key, secret) {
				t.Fatalf("rule %d leaks %q: %s", index, secret, rule.Key)
			}
		}
	}
	if limiter.rules[0].Key != "rate:{external-login}:system" {
		t.Fatalf("system-global key must not vary by App: %s", limiter.rules[0].Key)
	}
	if len(loginRateLimitDigest("fami")) != 32 || loginRateLimitDigest("fami") == loginRateLimitDigest("lalu") {
		t.Fatal("rate-limit identifiers must use a truncated SHA-256 digest")
	}
}

func TestLoginRateLimitDenialAndRedisFailurePrecedeAllDatabaseWork(t *testing.T) {
	t.Run("limited", func(t *testing.T) {
		limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}}
		service := NewService(nil, nil, Config{})
		service.limiter = limiter
		_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
		var rateErr *LoginRateLimitError
		if !errors.As(err, &rateErr) || rateErr.RetryAfter != 1500*time.Millisecond {
			t.Fatalf("rate error = %#v", err)
		}
	})

	t.Run("redis unavailable", func(t *testing.T) {
		limiter := &recordingFixedWindowLimiter{err: errors.New("redis down")}
		service := NewService(nil, nil, Config{})
		service.limiter = limiter
		_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
		if !errors.Is(err, ErrLoginLimiterFailed) {
			t.Fatalf("login error = %v", err)
		}
	})

	t.Run("missing redis", func(t *testing.T) {
		service := NewService(nil, nil, Config{})
		_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
		if !errors.Is(err, ErrLoginLimiterFailed) {
			t.Fatalf("login error = %v", err)
		}
	})
}

func TestLoginHandlerReturns429RetryAfterAnd503OnLimiterFailure(t *testing.T) {
	for _, testCase := range []struct {
		name       string
		limiter    *recordingFixedWindowLimiter
		wantStatus int
		wantRetry  string
		body       string
	}{
		{
			name: "limited", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}},
			wantStatus: http.StatusTooManyRequests, wantRetry: "2", body: `{"account":"operator","password":"password"}`,
		},
		{
			name: "valid empty json still counted", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: time.Second}},
			wantStatus: http.StatusTooManyRequests, wantRetry: "1", body: `{}`,
		},
		{
			name: "redis unavailable", limiter: &recordingFixedWindowLimiter{err: errors.New("redis down")},
			wantStatus: http.StatusServiceUnavailable, body: `{"account":"operator","password":"password"}`,
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			gin.SetMode(gin.TestMode)
			engine := gin.New()
			handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(testCase.limiter))
			RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
			request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(testCase.body))
			request.Header.Set("Content-Type", "application/json")
			responseRecorder := httptest.NewRecorder()
			engine.ServeHTTP(responseRecorder, request)
			if responseRecorder.Code != testCase.wantStatus {
				t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
			}
			if got := responseRecorder.Header().Get("Retry-After"); got != testCase.wantRetry {
				t.Fatalf("Retry-After=%q want=%q", got, testCase.wantRetry)
			}
			if responseRecorder.Header().Get("Cache-Control") != "no-store" {
				t.Fatal("rate-limit auth responses must remain non-cacheable")
			}
		})
	}
}

func TestLoginHandlerCapsBodyBeforeDistributedLimiter(t *testing.T) {
	limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
	gin.SetMode(gin.TestMode)
	engine := gin.New()
	handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(limiter))
	RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
	body := `{"account":"operator","password":"` + strings.Repeat("x", int(maxLoginBodyBytes)) + `"}`
	request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(body))
	request.Header.Set("Content-Type", "application/json")
	responseRecorder := httptest.NewRecorder()
	engine.ServeHTTP(responseRecorder, request)
	if responseRecorder.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if limiter.calls != 0 {
		t.Fatalf("oversized, unparseable body reached limiter %d times", limiter.calls)
	}
}
