package restapi

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"galaxy/gateway/internal/config"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) {
	t.Parallel()

	oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}`
	oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`

	tests := []struct {
		name      string
		method    string
		target    string
		body      string
		wantClass PublicRouteClass
	}{
		{
			name:      "send email",
			method:    http.MethodPost,
			target:    "/api/v1/public/auth/send-email-code",
			body:      oversizedJSONBody,
			wantClass: PublicRouteClassPublicAuth,
		},
		{
			name:      "confirm email",
			method:    http.MethodPost,
			target:    "/api/v1/public/auth/confirm-email-code",
			body:      oversizedConfirmJSONBody,
			wantClass: PublicRouteClassPublicAuth,
		},
		{
			name:      "healthz body",
			method:    http.MethodGet,
			target:    "/healthz",
			body:      `x`,
			wantClass: PublicRouteClassPublicMisc,
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			observer := &recordingPublicRequestObserver{}
			authService := &recordingAuthServiceClient{
				sendEmailCodeResult: SendEmailCodeResult{ChallengeID: "challenge-123"},
				confirmEmailCodeResult: ConfirmEmailCodeResult{
					DeviceSessionID: "device-session-123",
				},
			}
			handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
				AuthService: authService,
				Observer:    observer,
			})

			req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
			req.Header.Set("Content-Type", "application/json")
			recorder := httptest.NewRecorder()

			handler.ServeHTTP(recorder, req)

			assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
			assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
			assert.Equal(t, `{"error":{"code":"request_too_large","message":"request body exceeds the configured limit"}}`, recorder.Body.String())
			assert.Equal(t, 0, authService.sendEmailCodeCalls)
			assert.Equal(t, 0, authService.confirmEmailCodeCalls)
			assert.Equal(t, []malformedObservation{{
				class:  tt.wantClass,
				reason: PublicMalformedRequestReasonOversizedBody,
			}}, observer.snapshot())
		})
	}
}

func TestPublicAntiAbuseRejectsInvalidMethodsForBrowserShapes(t *testing.T) {
	t.Parallel()

	handler := newPublicHandler(ServerDependencies{})

	tests := []struct {
		name      string
		method    string
		target    string
		accept    string
		wantAllow string
	}{
		{
			name:      "asset path",
			method:    http.MethodPost,
			target:    "/assets/app.js",
			wantAllow: "GET, HEAD",
		},
		{
			name:      "bootstrap request",
			method:    http.MethodPost,
			target:    "/",
			accept:    "text/html",
			wantAllow: "GET, HEAD",
		},
		{
			name:      "head probe rejected",
			method:    http.MethodHead,
			target:    "/healthz",
			wantAllow: http.MethodGet,
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			req := httptest.NewRequest(tt.method, tt.target, nil)
			if tt.accept != "" {
				req.Header.Set("Accept", tt.accept)
			}
			recorder := httptest.NewRecorder()

			handler.ServeHTTP(recorder, req)

			assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
			assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
			assert.Equal(t, tt.wantAllow, recorder.Header().Get("Allow"))
			assert.Equal(t, `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`, recorder.Body.String())
		})
	}
}

func TestPublicAntiAbuseBrowserClassBucketsStayIsolatedFromPublicAuth(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name         string
		burstRequest *http.Request
	}{
		{
			name:         "browser asset",
			burstRequest: httptest.NewRequest(http.MethodGet, "/assets/app.js", nil),
		},
		{
			name: "browser bootstrap",
			burstRequest: func() *http.Request {
				req := httptest.NewRequest(http.MethodGet, "/", nil)
				req.Header.Set("Accept", "text/html")
				return req
			}(),
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			cfg := config.DefaultPublicHTTPConfig()
			cfg.AntiAbuse.BrowserAsset.RateLimit = config.PublicRateLimitConfig{
				Requests: 1,
				Window:   time.Hour,
				Burst:    1,
			}
			cfg.AntiAbuse.BrowserBootstrap.RateLimit = config.PublicRateLimitConfig{
				Requests: 1,
				Window:   time.Hour,
				Burst:    1,
			}
			cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
				Requests: 100,
				Window:   time.Hour,
				Burst:    100,
			}
			authService := &recordingAuthServiceClient{
				sendEmailCodeResult: SendEmailCodeResult{
					ChallengeID: "challenge-123",
				},
			}
			handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})

			tt.burstRequest.RemoteAddr = "192.0.2.10:1234"

			firstBurst := httptest.NewRecorder()
			handler.ServeHTTP(firstBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))

			secondBurst := httptest.NewRecorder()
			handler.ServeHTTP(secondBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))

			authReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":"pilot@example.com"}`))
			authReq.Header.Set("Content-Type", "application/json")
			authReq.RemoteAddr = "192.0.2.10:1234"
			authResp := httptest.NewRecorder()

			handler.ServeHTTP(authResp, authReq)

			assert.Equal(t, http.StatusNotFound, firstBurst.Code)
			assert.Equal(t, http.StatusTooManyRequests, secondBurst.Code)
			assert.Equal(t, http.StatusOK, authResp.Code)
			assert.Equal(t, `{"challenge_id":"challenge-123"}`, authResp.Body.String())
			assert.Equal(t, 1, authService.sendEmailCodeCalls)
		})
	}
}

func TestPublicAntiAbuseUsesRemoteAddrInsteadOfForwardedHeaders(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		headerKey      string
		firstHeader    string
		secondHeader   string
		firstRemote    string
		secondRemote   string
		wantSecondCode int
	}{
		{
			name:           "same remote addr ignores x-forwarded-for changes",
			headerKey:      "X-Forwarded-For",
			firstHeader:    "198.51.100.10",
			secondHeader:   "198.51.100.11",
			firstRemote:    "192.0.2.10:1234",
			secondRemote:   "192.0.2.10:1234",
			wantSecondCode: http.StatusTooManyRequests,
		},
		{
			name:           "different remote addr wins over shared forwarded header",
			headerKey:      "Forwarded",
			firstHeader:    "for=198.51.100.10",
			secondHeader:   "for=198.51.100.10",
			firstRemote:    "192.0.2.10:1234",
			secondRemote:   "192.0.2.11:1234",
			wantSecondCode: http.StatusOK,
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			cfg := config.DefaultPublicHTTPConfig()
			cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
				Requests: 1,
				Window:   time.Hour,
				Burst:    1,
			}
			cfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
				Requests: 100,
				Window:   time.Hour,
				Burst:    100,
			}

			authService := &recordingAuthServiceClient{
				sendEmailCodeResult: SendEmailCodeResult{
					ChallengeID: "challenge-123",
				},
			}
			handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})

			first := sendEmailCodeRequest(`{"email":"pilot-one@example.com"}`)
			first.RemoteAddr = tt.firstRemote
			first.Header.Set(tt.headerKey, tt.firstHeader)

			second := sendEmailCodeRequest(`{"email":"pilot-two@example.com"}`)
			second.RemoteAddr = tt.secondRemote
			second.Header.Set(tt.headerKey, tt.secondHeader)

			firstResp := httptest.NewRecorder()
			handler.ServeHTTP(firstResp, first)

			secondResp := httptest.NewRecorder()
			handler.ServeHTTP(secondResp, second)

			assert.Equal(t, http.StatusOK, firstResp.Code)
			assert.Equal(t, tt.wantSecondCode, secondResp.Code)
		})
	}
}

func TestPublicAntiAbuseSendEmailIdentityThrottle(t *testing.T) {
	t.Parallel()

	cfg := config.DefaultPublicHTTPConfig()
	cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
		Requests: 100,
		Window:   time.Hour,
		Burst:    100,
	}
	cfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
		Requests: 1,
		Window:   time.Hour,
		Burst:    1,
	}

	authService := &recordingAuthServiceClient{
		sendEmailCodeResult: SendEmailCodeResult{
			ChallengeID: "challenge-123",
		},
	}
	handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})

	first := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
	second := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
	third := sendEmailCodeRequest(`{"email":"other@example.com"}`)

	firstResp := httptest.NewRecorder()
	handler.ServeHTTP(firstResp, first)

	secondResp := httptest.NewRecorder()
	handler.ServeHTTP(secondResp, second)

	thirdResp := httptest.NewRecorder()
	handler.ServeHTTP(thirdResp, third)

	assert.Equal(t, http.StatusOK, firstResp.Code)
	assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
	assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
	assert.Equal(t, http.StatusOK, thirdResp.Code)
	assert.Equal(t, 2, authService.sendEmailCodeCalls)
	thirdInput := authService.sendEmailCodeInput
	assert.Equal(t, "other@example.com", thirdInput.Email)
}

func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) {
	t.Parallel()

	cfg := config.DefaultPublicHTTPConfig()
	cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
		Requests: 100,
		Window:   time.Hour,
		Burst:    100,
	}
	cfg.AntiAbuse.ConfirmEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
		Requests: 1,
		Window:   time.Hour,
		Burst:    1,
	}

	authService := &recordingAuthServiceClient{
		confirmEmailCodeResult: ConfirmEmailCodeResult{
			DeviceSessionID: "device-session-123",
		},
	}
	handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})

	first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
	second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
	third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)

	firstResp := httptest.NewRecorder()
	handler.ServeHTTP(firstResp, first)

	secondResp := httptest.NewRecorder()
	handler.ServeHTTP(secondResp, second)

	thirdResp := httptest.NewRecorder()
	handler.ServeHTTP(thirdResp, third)

	assert.Equal(t, http.StatusOK, firstResp.Code)
	assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
	assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
	assert.Equal(t, http.StatusOK, thirdResp.Code)
	assert.Equal(t, 2, authService.confirmEmailCodeCalls)
	assert.Equal(t, "challenge-456", authService.confirmEmailCodeInput.ChallengeID)
}

func TestPublicAntiAbuseMalformedTelemetry(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		body        string
		wantReason  PublicMalformedRequestReason
		wantRecords int
	}{
		{
			name:        "empty body",
			body:        ``,
			wantReason:  PublicMalformedRequestReasonEmptyBody,
			wantRecords: 1,
		},
		{
			name:        "malformed json",
			body:        `{"email":`,
			wantReason:  PublicMalformedRequestReasonMalformedJSON,
			wantRecords: 1,
		},
		{
			name:        "invalid json value",
			body:        `{"email":123}`,
			wantReason:  PublicMalformedRequestReasonInvalidJSONValue,
			wantRecords: 1,
		},
		{
			name:        "unknown field",
			body:        `{"email":"pilot@example.com","extra":"x"}`,
			wantReason:  PublicMalformedRequestReasonUnknownField,
			wantRecords: 1,
		},
		{
			name:        "multiple objects",
			body:        `{"email":"pilot@example.com"}{"email":"pilot@example.com"}`,
			wantReason:  PublicMalformedRequestReasonMultipleJSONObjects,
			wantRecords: 1,
		},
		{
			name:        "validation error does not count as malformed",
			body:        `{"email":"not-an-email"}`,
			wantRecords: 0,
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			observer := &recordingPublicRequestObserver{}
			authService := &recordingAuthServiceClient{}
			handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
				AuthService: authService,
				Observer:    observer,
			})

			req := sendEmailCodeRequest(tt.body)
			recorder := httptest.NewRecorder()

			handler.ServeHTTP(recorder, req)

			assert.Equal(t, http.StatusBadRequest, recorder.Code)
			assert.Equal(t, tt.wantRecords, len(observer.snapshot()))
			assert.Equal(t, 0, authService.sendEmailCodeCalls)
			if tt.wantRecords == 1 {
				assert.Equal(t, malformedObservation{
					class:  PublicRouteClassPublicAuth,
					reason: tt.wantReason,
				}, observer.snapshot()[0])
			}
		})
	}
}

func TestInMemoryPublicRequestLimiterCleansExpiredBuckets(t *testing.T) {
	t.Parallel()

	now := time.Unix(1000, 0)
	limiter := newInMemoryPublicRequestLimiter()
	limiter.now = func() time.Time {
		return now
	}
	limiter.cleanupInterval = time.Second

	policy := config.PublicRateLimitConfig{
		Requests: 1,
		Window:   time.Minute,
		Burst:    1,
	}

	firstDecision := limiter.Reserve("bucket-1", policy)
	secondDecision := limiter.Reserve("bucket-2", policy)
	require.True(t, firstDecision.Allowed)
	require.True(t, secondDecision.Allowed)
	require.Len(t, limiter.entries, 2)

	now = now.Add(3 * time.Minute)

	thirdDecision := limiter.Reserve("bucket-3", policy)
	require.True(t, thirdDecision.Allowed)
	assert.Len(t, limiter.entries, 1)
	_, exists := limiter.entries["bucket-3"]
	assert.True(t, exists)
}

func sendEmailCodeRequest(body string) *http.Request {
	req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.RemoteAddr = "192.0.2.10:1234"

	return req
}

func confirmEmailCodeRequest(body string) *http.Request {
	req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.RemoteAddr = "192.0.2.10:1234"

	return req
}

type malformedObservation struct {
	class  PublicRouteClass
	reason PublicMalformedRequestReason
}

type recordingPublicRequestObserver struct {
	mu           sync.Mutex
	observations []malformedObservation
}

func (o *recordingPublicRequestObserver) RecordMalformedRequest(class PublicRouteClass, reason PublicMalformedRequestReason) {
	o.mu.Lock()
	defer o.mu.Unlock()

	o.observations = append(o.observations, malformedObservation{
		class:  class,
		reason: reason,
	})
}

func (o *recordingPublicRequestObserver) snapshot() []malformedObservation {
	o.mu.Lock()
	defer o.mu.Unlock()

	return append([]malformedObservation(nil), o.observations...)
}
