package connectsrv_test

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

	"connectrpc.com/connect"

	"scrabble/gateway/internal/backendclient"
	"scrabble/gateway/internal/config"
	"scrabble/gateway/internal/connectsrv"
	"scrabble/gateway/internal/push"
	"scrabble/gateway/internal/ratelimit"
	"scrabble/gateway/internal/session"
	"scrabble/gateway/internal/transcode"
	edgev1 "scrabble/gateway/proto/edge/v1"
	"scrabble/gateway/proto/edge/v1/edgev1connect"
	fb "scrabble/pkg/fbs/scrabblefb"
)

// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func. The client-version gate is off.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
	return newEdgeMin(t, "", backendHandler)
}

// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
	t.Helper()
	backendSrv := httptest.NewServer(backendHandler)
	backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
	if err != nil {
		t.Fatalf("backendclient: %v", err)
	}
	edge := connectsrv.NewServer(connectsrv.Deps{
		Registry:         transcode.NewRegistry(backend, nil),
		Sessions:         session.NewCache(backend, time.Minute, 100),
		Limiter:          ratelimit.New(),
		Hub:              push.NewHub(0),
		RateLimit:        config.DefaultRateLimit(),
		Heartbeat:        15 * time.Second,
		MinClientVersion: minVersion,
	})
	edgeSrv := httptest.NewServer(edge.HTTPHandler())
	client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
	return client, func() {
		edgeSrv.Close()
		_ = backend.Close()
		backendSrv.Close()
	}
}

// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
	t.Helper()
	backendSrv := httptest.NewServer(backendHandler)
	backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
	if err != nil {
		t.Fatalf("backendclient: %v", err)
	}
	edge := connectsrv.NewServer(connectsrv.Deps{
		Registry:                 transcode.NewRegistry(backend, nil),
		Sessions:                 session.NewCache(backend, time.Minute, 100),
		Limiter:                  ratelimit.New(),
		Hub:                      push.NewHub(0),
		RateLimit:                config.DefaultRateLimit(),
		Heartbeat:                15 * time.Second,
		MinClientVersion:         minVersion,
		RecommendedClientVersion: recommendedVersion,
	})
	edgeSrv := httptest.NewServer(edge.HTTPHandler())
	client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
	return client, func() {
		edgeSrv.Close()
		_ = backend.Close()
		backendSrv.Close()
	}
}

func TestExecuteGuestAuthOK(t *testing.T) {
	client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
	})
	defer cleanup()

	resp, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: transcode.MsgAuthGuest,
		RequestId:   "req-1",
	}))
	if err != nil {
		t.Fatalf("execute: %v", err)
	}
	if resp.Msg.GetResultCode() != "ok" || resp.Msg.GetRequestId() != "req-1" {
		t.Fatalf("result = %q req_id = %q", resp.Msg.GetResultCode(), resp.Msg.GetRequestId())
	}
	sess := fb.GetRootAsSession(resp.Msg.GetPayload(), 0)
	if string(sess.Token()) != "tok" || !sess.IsGuest() {
		t.Fatalf("session decoded wrong: %q guest=%v", sess.Token(), sess.IsGuest())
	}
}

// TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the
// guest_forbidden result code before any backend call, while a guest may still run
// an authenticated op that is not guest-gated.
func TestExecuteGuestGate(t *testing.T) {
	client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/v1/internal/sessions/resolve":
			_, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`))
		case "/api/v1/user/feedback/unread":
			_, _ = w.Write([]byte(`{"reply_unread":false}`))
		case "/api/v1/user/feedback":
			t.Errorf("guest feedback.submit must not reach the backend")
		default:
			t.Errorf("unexpected backend path %s", r.URL.Path)
		}
	})
	defer cleanup()

	submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"})
	submit.Header().Set("Authorization", "Bearer tok")
	resp, err := client.Execute(context.Background(), submit)
	if err != nil {
		t.Fatalf("execute submit: %v", err)
	}
	if resp.Msg.GetResultCode() != "guest_forbidden" {
		t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode())
	}

	unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread})
	unread.Header().Set("Authorization", "Bearer tok")
	resp, err = client.Execute(context.Background(), unread)
	if err != nil {
		t.Fatalf("execute unread: %v", err)
	}
	if resp.Msg.GetResultCode() != "ok" {
		t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode())
	}
}

// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
// than the configured minimum is turned away with the update_required result code before the
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
func TestExecuteVersionGate(t *testing.T) {
	// The backend answers auth.guest with a session; a gated call never reaches it.
	backend := func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
	}
	tests := []struct {
		name    string
		min     string
		header  string
		msgType string
		want    string
	}{
		{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
		{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
		{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
		{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
		{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
		{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			client, cleanup := newEdgeMin(t, tc.min, backend)
			defer cleanup()
			req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
			if tc.header != "" {
				req.Header().Set("X-Client-Version", tc.header)
			}
			resp, err := client.Execute(context.Background(), req)
			if err != nil {
				t.Fatalf("execute: %v", err)
			}
			if got := resp.Msg.GetResultCode(); got != tc.want {
				t.Fatalf("result_code = %q, want %q", got, tc.want)
			}
		})
	}
}

// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
	backend := func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
	}
	tests := []struct {
		name       string
		min        string
		rec        string
		header     string
		wantResult string
		wantHeader bool
	}{
		{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
		{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
		{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
		{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
		{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
		{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
		{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
		{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
		{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
			defer cleanup()
			req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
			if tc.header != "" {
				req.Header().Set("X-Client-Version", tc.header)
			}
			resp, err := client.Execute(context.Background(), req)
			if err != nil {
				t.Fatalf("execute: %v", err)
			}
			if got := resp.Msg.GetResultCode(); got != tc.wantResult {
				t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
			}
			if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
				t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
			}
		})
	}
}

// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
func TestSubscribeVersionGate(t *testing.T) {
	noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
	tests := []struct {
		name   string
		min    string
		header string
		want   connect.Code
	}{
		{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
		{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
		{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			client, cleanup := newEdgeMin(t, tc.min, noBackend)
			defer cleanup()
			req := connect.NewRequest(&edgev1.SubscribeRequest{})
			if tc.header != "" {
				req.Header().Set("X-Client-Version", tc.header)
			}
			stream, err := client.Subscribe(context.Background(), req)
			if err == nil {
				for stream.Receive() {
				}
				err = stream.Err()
			}
			if got := connect.CodeOf(err); got != tc.want {
				t.Fatalf("code = %v, want %v", got, tc.want)
			}
		})
	}
}

func TestExecuteAuthedRequiresSession(t *testing.T) {
	client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
		t.Error("backend must not be called without a session")
	})
	defer cleanup()

	_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: transcode.MsgProfileGet,
	}))
	if connect.CodeOf(err) != connect.CodeUnauthenticated {
		t.Fatalf("code = %v, want Unauthenticated", connect.CodeOf(err))
	}
}

// TestExecuteRateLimitedTracked verifies a limiter rejection returns
// ResourceExhausted and lands in the rejection tracker under the public class,
// keyed by the client IP.
func TestExecuteRateLimitedTracked(t *testing.T) {
	backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
	}))
	defer backendSrv.Close()
	backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
	if err != nil {
		t.Fatalf("backendclient: %v", err)
	}
	defer func() { _ = backend.Close() }()

	limits := config.DefaultRateLimit()
	limits.PublicPerMinute, limits.PublicBurst = 1, 1
	tracker := ratelimit.NewTracker()
	edge := connectsrv.NewServer(connectsrv.Deps{
		Registry:  transcode.NewRegistry(backend, nil),
		Sessions:  session.NewCache(backend, time.Minute, 100),
		Limiter:   ratelimit.New(),
		Tracker:   tracker,
		Hub:       push.NewHub(0),
		RateLimit: limits,
		Heartbeat: 15 * time.Second,
	})
	edgeSrv := httptest.NewServer(edge.HTTPHandler())
	defer edgeSrv.Close()
	client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)

	if _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: transcode.MsgAuthGuest,
	})); err != nil {
		t.Fatalf("first execute: %v", err)
	}
	_, err = client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: transcode.MsgAuthGuest,
	}))
	if connect.CodeOf(err) != connect.CodeResourceExhausted {
		t.Fatalf("code = %v, want ResourceExhausted", connect.CodeOf(err))
	}

	entries := tracker.Drain()
	if len(entries) != 1 {
		t.Fatalf("tracker drained %d entries, want 1", len(entries))
	}
	if e := entries[0]; e.Class != "public" || e.Key != "127.0.0.1" || e.Rejected != 1 {
		t.Fatalf("tracked %+v, want public/127.0.0.1 rejected=1", e)
	}
}

// TestAdminMountRateLimited verifies the /_gm mount is guarded by the per-IP
// admin limiter class ahead of the proxy's Basic-Auth.
func TestAdminMountRateLimited(t *testing.T) {
	backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
	defer backendSrv.Close()
	backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
	if err != nil {
		t.Fatalf("backendclient: %v", err)
	}
	defer func() { _ = backend.Close() }()

	limits := config.DefaultRateLimit()
	limits.AdminPerMinute, limits.AdminBurst = 1, 1
	edge := connectsrv.NewServer(connectsrv.Deps{
		Registry:  transcode.NewRegistry(backend, nil),
		Sessions:  session.NewCache(backend, time.Minute, 100),
		Limiter:   ratelimit.New(),
		Hub:       push.NewHub(0),
		RateLimit: limits,
		Heartbeat: 15 * time.Second,
		AdminProxy: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(http.StatusOK)
		}),
	})
	edgeSrv := httptest.NewServer(edge.HTTPHandler())
	defer edgeSrv.Close()

	first, err := http.Get(edgeSrv.URL + "/_gm/")
	if err != nil {
		t.Fatalf("first /_gm: %v", err)
	}
	_ = first.Body.Close()
	if first.StatusCode != http.StatusOK {
		t.Fatalf("first /_gm = %d, want 200", first.StatusCode)
	}
	second, err := http.Get(edgeSrv.URL + "/_gm/")
	if err != nil {
		t.Fatalf("second /_gm: %v", err)
	}
	_ = second.Body.Close()
	if second.StatusCode != http.StatusTooManyRequests {
		t.Fatalf("second /_gm = %d, want 429", second.StatusCode)
	}
}

// TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute
// message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching
// the backend.
func TestExecuteOversizedPayloadRejected(t *testing.T) {
	client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
		t.Error("backend must not be called for an oversized payload")
	})
	defer cleanup()

	_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: transcode.MsgAuthGuest,
		Payload:     make([]byte, config.DefaultMaxBodyBytes+1),
	}))
	if connect.CodeOf(err) != connect.CodeResourceExhausted {
		t.Fatalf("code = %v, want ResourceExhausted", connect.CodeOf(err))
	}
}

// TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/"
// (it lives in the landing container): a stray root hit is redirected
// to the app shell.
func TestRootRedirectsToApp(t *testing.T) {
	front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler())
	defer front.Close()

	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	resp, err := client.Get(front.URL + "/")
	if err != nil {
		t.Fatalf("get /: %v", err)
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != http.StatusPermanentRedirect || resp.Header.Get("Location") != "/app/" {
		t.Fatalf("GET / = %d -> %q, want 308 -> /app/", resp.StatusCode, resp.Header.Get("Location"))
	}
}

func TestExecuteUnknownMessageType(t *testing.T) {
	client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {})
	defer cleanup()

	_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
		MessageType: "does.not.exist",
	}))
	if connect.CodeOf(err) != connect.CodeNotFound {
		t.Fatalf("code = %v, want NotFound", connect.CodeOf(err))
	}
}
