package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"encoding/pem"
	"math/big"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"google.golang.org/grpc/codes"

	"github.com/tidbcloud/cluster-service-ng/internal/cluster-agent/service"
)

func generateSelfSignedCert(t *testing.T, dir string) (certPath, keyPath string) {
	t.Helper()

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("ecdsa.GenerateKey() error = %v", err)
	}

	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(time.Hour),
	}
	certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		t.Fatalf("x509.CreateCertificate() error = %v", err)
	}

	certPath = filepath.Join(dir, "tls.crt")
	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
	if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
		t.Fatalf("os.WriteFile(certPath) error = %v", err)
	}

	keyDER, err := x509.MarshalECPrivateKey(key)
	if err != nil {
		t.Fatalf("x509.MarshalECPrivateKey() error = %v", err)
	}
	keyPath = filepath.Join(dir, "tls.key")
	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
	if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
		t.Fatalf("os.WriteFile(keyPath) error = %v", err)
	}

	return certPath, keyPath
}

func TestValidateTLSFiles(t *testing.T) {
	dir := t.TempDir()

	certFile, keyFile := generateSelfSignedCert(t, dir)

	invalidCert := filepath.Join(dir, "bad.crt")
	if err := os.WriteFile(invalidCert, []byte("not-a-pem"), 0o600); err != nil {
		t.Fatalf("os.WriteFile(invalidCert) error = %v", err)
	}
	invalidKey := filepath.Join(dir, "bad.key")
	if err := os.WriteFile(invalidKey, []byte("not-a-pem"), 0o600); err != nil {
		t.Fatalf("os.WriteFile(invalidKey) error = %v", err)
	}

	certDir := filepath.Join(dir, "certdir")
	if err := os.MkdirAll(certDir, 0o700); err != nil {
		t.Fatalf("os.MkdirAll(certDir) error = %v", err)
	}

	tests := []struct {
		name        string
		certFile    string
		keyFile     string
		wantEnabled bool
		wantErr     bool
	}{
		{
			name:        "both empty disables tls",
			wantEnabled: false,
			wantErr:     false,
		},
		{
			name:        "cert empty key set",
			certFile:    "",
			keyFile:     keyFile,
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "key empty cert set",
			certFile:    certFile,
			keyFile:     "",
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "cert missing",
			certFile:    filepath.Join(dir, "missing.crt"),
			keyFile:     keyFile,
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "key missing",
			certFile:    certFile,
			keyFile:     filepath.Join(dir, "missing.key"),
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "cert is directory",
			certFile:    certDir,
			keyFile:     keyFile,
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "invalid PEM content",
			certFile:    invalidCert,
			keyFile:     invalidKey,
			wantEnabled: false,
			wantErr:     true,
		},
		{
			name:        "valid cert and key",
			certFile:    certFile,
			keyFile:     keyFile,
			wantEnabled: true,
			wantErr:     false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			enabled, err := validateTLSFiles(tt.certFile, tt.keyFile)
			if (err != nil) != tt.wantErr {
				t.Fatalf("validateTLSFiles() err = %v, wantErr %v", err, tt.wantErr)
			}
			if enabled != tt.wantEnabled {
				t.Fatalf("validateTLSFiles() enabled = %v, want %v", enabled, tt.wantEnabled)
			}
		})
	}
}

func TestGatewayTLSConfigMinVersion(t *testing.T) {
	srv := newGatewayHTTPServer(":0", http.NewServeMux())
	if srv.TLSConfig == nil {
		t.Fatal("TLSConfig is nil")
	}
	if srv.TLSConfig.MinVersion != tls.VersionTLS12 {
		t.Fatalf("TLSConfig.MinVersion = %v, want %v", srv.TLSConfig.MinVersion, tls.VersionTLS12)
	}
}

func TestShouldSkipHTTPHealthCheckLog(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		logHealthCheck bool
		path           string
		statusCode     int
		wantSkip       bool
	}{
		{
			name:           "skip successful healthz when health logging is disabled",
			logHealthCheck: false,
			path:           healthzEndpoint,
			statusCode:     http.StatusOK,
			wantSkip:       true,
		},
		{
			name:           "keep failed healthz when health logging is disabled",
			logHealthCheck: false,
			path:           healthzEndpoint,
			statusCode:     http.StatusServiceUnavailable,
			wantSkip:       false,
		},
		{
			name:           "keep non-health endpoint when health logging is disabled",
			logHealthCheck: false,
			path:           "/api/v1/clusters/test:executeCommand",
			statusCode:     http.StatusOK,
			wantSkip:       false,
		},
		{
			name:           "keep healthz when health logging is enabled",
			logHealthCheck: true,
			path:           healthzEndpoint,
			statusCode:     http.StatusOK,
			wantSkip:       false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := shouldSkipHTTPHealthCheckLog(tt.logHealthCheck, tt.path, tt.statusCode)
			if got != tt.wantSkip {
				t.Fatalf("shouldSkipHTTPHealthCheckLog() = %v, want %v", got, tt.wantSkip)
			}
		})
	}
}

func TestHTTPHealthCheckPath(t *testing.T) {
	tests := []struct {
		name     string
		path     string
		params   gin.Params
		wantPath string
	}{
		{
			name:     "use request path for direct healthz endpoint",
			path:     healthzEndpoint,
			wantPath: healthzEndpoint,
		},
		{
			name: "use rewritten any path for prefixed healthz endpoint",
			path: "/cluster-agent/tidb123/api/v1/healthz",
			params: gin.Params{
				{Key: "tcns", Value: "tidb123"},
				{Key: "any", Value: healthzEndpoint},
			},
			wantPath: healthzEndpoint,
		},
		{
			name:     "rewrite prefixed healthz endpoint during no route fallback",
			path:     "/cluster-agent/tidb123/api/v1/healthz",
			wantPath: healthzEndpoint,
		},
		{
			name: "keep non-health business path",
			path: "/cluster-agent/tidb123/api/v1/clusters/test:executeCommand",
			params: gin.Params{
				{Key: "tcns", Value: "tidb123"},
				{Key: "any", Value: "/clusters/test:executeCommand"},
			},
			wantPath: "/clusters/test:executeCommand",
		},
		{
			name:     "keep original path when request is not a gateway route",
			path:     "/favicon.ico",
			wantPath: "/favicon.ico",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			recorder := httptest.NewRecorder()
			ctx, _ := gin.CreateTestContext(recorder)
			ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, http.NoBody)
			ctx.Params = tt.params

			got := httpHealthCheckPath(ctx)
			if got != tt.wantPath {
				t.Fatalf("httpHealthCheckPath() = %q, want %q", got, tt.wantPath)
			}
		})
	}
}

func TestShouldSkipGRPCHealthAccessLog(t *testing.T) {
	tests := []struct {
		name           string
		logHealthCheck bool
		msg            string
		fields         []any
		wantSkip       bool
	}{
		{
			name:           "skip started health call when health logging is disabled",
			logHealthCheck: false,
			msg:            "started call",
			fields: []any{
				"grpc.service", grpcHealthServiceName,
				"grpc.method", grpcHealthMethodName,
			},
			wantSkip: true,
		},
		{
			name:           "skip finished health call with OK code when health logging is disabled",
			logHealthCheck: false,
			msg:            "finished call",
			fields: []any{
				"grpc.service", grpcHealthServiceName,
				"grpc.method", grpcHealthMethodName,
				"grpc.code", codes.OK.String(),
			},
			wantSkip: true,
		},
		{
			name:           "keep finished health call with non-OK code when health logging is disabled",
			logHealthCheck: false,
			msg:            "finished call",
			fields: []any{
				"grpc.service", grpcHealthServiceName,
				"grpc.method", grpcHealthMethodName,
				"grpc.code", codes.Unavailable.String(),
			},
			wantSkip: false,
		},
		{
			name:           "keep health logs when health logging is enabled",
			logHealthCheck: true,
			msg:            "started call",
			fields: []any{
				"grpc.service", grpcHealthServiceName,
				"grpc.method", grpcHealthMethodName,
			},
			wantSkip: false,
		},
		{
			name:           "keep non-health logs when health logging is disabled",
			logHealthCheck: false,
			msg:            "started call",
			fields: []any{
				"grpc.service", "tidbcloud.cluster.agent.v1.ClusterAgentService",
				"grpc.method", "ExecuteCommand",
			},
			wantSkip: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := shouldSkipGRPCHealthAccessLog(tt.logHealthCheck, tt.msg, tt.fields...)
			if got != tt.wantSkip {
				t.Fatalf("shouldSkipGRPCHealthAccessLog() = %v, want %v", got, tt.wantSkip)
			}
		})
	}
}

func buildTestRouter(svc *service.AgentService) *gin.Engine {
	gin.SetMode(gin.TestMode)
	return buildTestRouterWithGatewayRecorder(svc).router
}

type gatewayHandlerRecorder struct {
	lastPath string
}

type testRouter struct {
	router          *gin.Engine
	gatewayRecorder *gatewayHandlerRecorder
}

func buildTestRouterWithGatewayRecorder(svc *service.AgentService) testRouter {
	return buildTestRouterWithGatewayHandler(svc, func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("grpc-gateway"))
	})
}

func buildTestRouterWithGatewayHandler(svc *service.AgentService, gatewayHandler http.HandlerFunc) testRouter {
	recorder := &gatewayHandlerRecorder{}
	router := newGatewayRouter(&Config{}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		recorder.lastPath = r.URL.Path
		gatewayHandler(w, r)
	}), svc, nil)

	return testRouter{
		router:          router,
		gatewayRecorder: recorder,
	}
}

func TestNewGatewayRouter_DoesNotPanicWithDirectMetricsRoutes(t *testing.T) {
	svc := &service.AgentService{}

	assert.NotPanics(t, func() {
		_ = buildTestRouter(svc)
	})
}

func TestNewGatewayRouter_DoesNotRegisterConflictingAPIWildcards(t *testing.T) {
	svc := &service.AgentService{}
	router := buildTestRouter(svc)

	for _, route := range router.Routes() {
		assert.NotEqual(t, baseAPIPath+"/*any", route.Path,
			"gateway router must not register a wildcard under /api/v1")
		assert.NotEqual(t, baseAPIPathWithPrefix+"/*any", route.Path,
			"gateway router must not register a wildcard under the Kong-prefixed /api/v1 path")
	}
}

func TestRouterMetricsRoute_DirectPath(t *testing.T) {
	svc := &service.AgentService{}
	router := buildTestRouter(svc)

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet,
		"/api/v1/clusters/123/components/pd/db-pd-0/metrics", http.NoBody)
	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusNotImplemented, w.Code)
	assert.Contains(t, w.Body.String(), "metrics proxy not implemented for component: pd")
}

func TestRouterMetricsRoute_KongPrefixedPath(t *testing.T) {
	svc := &service.AgentService{}
	router := buildTestRouter(svc)

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet,
		"/cluster-agent/tidb123/api/v1/clusters/123/components/pd/db-pd-0/metrics", http.NoBody)
	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusNotImplemented, w.Code)
	assert.Contains(t, w.Body.String(), "metrics proxy not implemented for component: pd")
}

func TestRouterMetricsRoute_NotSwallowedByGatewayFallback(t *testing.T) {
	svc := &service.AgentService{}
	testRouter := buildTestRouterWithGatewayRecorder(svc)
	router := testRouter.router

	metricsW := httptest.NewRecorder()
	metricsReq := httptest.NewRequest(http.MethodGet,
		"/api/v1/clusters/123/components/tikv/db-tikv-0/metrics", http.NoBody)
	router.ServeHTTP(metricsW, metricsReq)
	assert.NotEqual(t, "grpc-gateway", metricsW.Body.String(),
		"metrics route must not be handled by grpc-gateway fallback")
	assert.Empty(t, testRouter.gatewayRecorder.lastPath,
		"metrics route must not be forwarded to grpc-gateway")

	otherW := httptest.NewRecorder()
	otherReq := httptest.NewRequest(http.MethodGet,
		"/api/v1/public_key", http.NoBody)
	router.ServeHTTP(otherW, otherReq)
	assert.Equal(t, "grpc-gateway", otherW.Body.String(),
		"non-metrics routes must still reach grpc-gateway")
	assert.Equal(t, "/public_key", testRouter.gatewayRecorder.lastPath)
}

func TestRouterFallbackRewritesKongPrefixedPath(t *testing.T) {
	svc := &service.AgentService{}
	testRouter := buildTestRouterWithGatewayRecorder(svc)
	router := testRouter.router

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet,
		"/cluster-agent/tidb123/api/v1/public_key",
		http.NoBody)
	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "grpc-gateway", w.Body.String())
	assert.Equal(t, "/public_key", testRouter.gatewayRecorder.lastPath)
}

func TestRouterFallbackSuccessStatus_NotStuckAtNotFound(t *testing.T) {
	svc := &service.AgentService{}
	testRouter := buildTestRouterWithGatewayHandler(svc, func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("grpc-gateway"))
	})

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/public_key", http.NoBody)
	testRouter.router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "grpc-gateway", w.Body.String())
	assert.Equal(t, "/public_key", testRouter.gatewayRecorder.lastPath)
}

func TestRouterFallbackPreservesGatewayErrorStatus(t *testing.T) {
	svc := &service.AgentService{}
	testRouter := buildTestRouterWithGatewayHandler(svc, func(w http.ResponseWriter, _ *http.Request) {
		http.Error(w, "gateway failure", http.StatusBadGateway)
	})

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/public_key", http.NoBody)
	testRouter.router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusBadGateway, w.Code)
	assert.Contains(t, w.Body.String(), "gateway failure")
	assert.Equal(t, "/public_key", testRouter.gatewayRecorder.lastPath)
}

func TestRewriteGatewayPath(t *testing.T) {
	tests := []struct {
		name        string
		requestPath string
		wantPath    string
		wantOK      bool
	}{
		{
			name:        "direct api path",
			requestPath: "/api/v1/clusters/123",
			wantPath:    "/clusters/123",
			wantOK:      true,
		},
		{
			name:        "kong prefixed api path",
			requestPath: "/cluster-agent/tidb123/api/v1/clusters/123",
			wantPath:    "/clusters/123",
			wantOK:      true,
		},
		{
			name:        "health endpoint",
			requestPath: "/healthz",
			wantPath:    "/healthz",
			wantOK:      true,
		},
		{
			name:        "direct public key path",
			requestPath: "/api/v1/public_key",
			wantPath:    "/public_key",
			wantOK:      true,
		},
		{
			name:        "kong prefixed public key path",
			requestPath: "/cluster-agent/tidb123/api/v1/public_key",
			wantPath:    "/public_key",
			wantOK:      true,
		},
		{
			name:        "bare api prefix does not match gateway route",
			requestPath: "/api/v1",
			wantPath:    "",
			wantOK:      false,
		},
		{
			name:        "missing tcns segment",
			requestPath: "/cluster-agent//api/v1/clusters/123",
			wantPath:    "",
			wantOK:      false,
		},
		{
			name:        "non gateway path",
			requestPath: "/favicon.ico",
			wantPath:    "",
			wantOK:      false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gotPath, gotOK := rewriteGatewayPath(tt.requestPath)
			assert.Equal(t, tt.wantOK, gotOK)
			assert.Equal(t, tt.wantPath, gotPath)
		})
	}
}
