package api

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

	"dataapps/consts"

	"github.com/gin-gonic/gin"
	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/account/idl/pbgen/proto/account"
	"github.com/tidbcloud/tiinf/sdk/rbac/common"
	"github.com/tidbcloud/tiinf/sdk/rbac/helperimpl"
	googlegrpc "google.golang.org/grpc"
)

const testSQLClusterStatusRoute = "/api/v1/dataapps/sqleditor/orgs/:org_id/projects/:project_id/clusters/status"

func TestSQLEditorRBACMappings(t *testing.T) {
	resourceHelper := helperimpl.NewMapStoreResourceHelper(rbacPathPattern2ResourceMap())

	clusterRoutes := []struct {
		name     string
		method   string
		path     string
		resource string
	}{
		{
			name:     "put settings",
			method:   http.MethodPut,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/settings",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/put_api",
		},
		{
			name:     "get settings",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/settings",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "post statements",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/statements",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/post_api",
		},
		{
			name:     "get table meta",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/dbs/db_1/table_1/meta",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "get table existence",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/dbs/db_1/table_1/exist",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "post sqlfiles",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/post_api",
		},
		{
			name:     "get sqlfile",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles/44",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "put sqlfile",
			method:   http.MethodPut,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles/44",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/put_api",
		},
		{
			name:     "delete sqlfile",
			method:   http.MethodDelete,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles/44",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/delete_api",
		},
		{
			name:     "list sqlfiles",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "post session",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/session",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/post_api",
		},
		{
			name:     "delete session",
			method:   http.MethodDelete,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/session/55",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/delete_api",
		},
		{
			name:     "get cluster meta",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/meta",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "list databases",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/schemas/databases",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "list tables",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/schemas/databases/db_1/tables",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "list columns",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/schemas/databases/db_1/tables/table_1/columns",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "search schemas",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/schemas/search",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "initialize sample",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sample/initialize",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/post_api",
		},
		{
			name:     "post oplog",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/oplog",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/post_api",
		},
		{
			name:     "get data API sample",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/dataapi/sample",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "get enter audit",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/enter/audit",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
		{
			name:     "get download audit",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/download/audit",
			resource: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		},
	}
	for _, tt := range clusterRoutes {
		t.Run("cluster_"+tt.name, func(t *testing.T) {
			resource, err := resourceHelper.GetResource(context.Background(), rbacAccessParam(tt.method, tt.path))
			require.NoError(t, err)
			require.Equal(t, tt.resource, resource)
			require.Equal(t, 1, countMatchingRBACPatterns(t, rbacPathPattern2ResourceMap(), tt.method, tt.path), "current cluster route must match exactly one RBAC pattern")
		})
	}

	projectRoutes := []struct {
		name     string
		method   string
		path     string
		resource string
	}{
		{
			name:     "settings",
			method:   http.MethodPut,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/settings",
			resource: "orgs/11/projects/22/sqleditor/put_api",
		},
		{
			name:     "bot",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/bot",
			resource: "orgs/11/projects/22/sqleditor/post_api",
		},
		{
			name:     "permission_entry",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/permission/entry",
			resource: "orgs/11/projects/22/sqleditor/get_api",
		},
		{
			name:     "streaming",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/streaming",
			resource: "orgs/11/projects/22/sqleditor/get_api",
		},
	}
	for _, tt := range projectRoutes {
		t.Run("project_"+tt.name, func(t *testing.T) {
			resource, err := resourceHelper.GetResource(context.Background(), rbacAccessParam(tt.method, tt.path))
			require.NoError(t, err)
			require.Equal(t, tt.resource, resource)
		})
	}

	for _, tt := range []struct {
		name   string
		method string
		path   string
	}{
		{
			name:   "users",
			method: http.MethodGet,
			path:   "/api/v1/dataapps/sqleditor/orgs/11/projects/22/users",
		},
		{
			name:   "cluster_status",
			method: http.MethodGet,
			path:   "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/status",
		},
		{
			name:   "unknown_cluster_route",
			method: http.MethodGet,
			path:   "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/future",
		},
	} {
		t.Run("dedicated_"+tt.name, func(t *testing.T) {
			resource, err := resourceHelper.GetResource(context.Background(), rbacAccessParam(tt.method, tt.path))
			require.NoError(t, err)
			require.Empty(t, resource, "dedicated routes must not be swallowed by a broad SQL Editor wildcard")
			require.Zero(t, countMatchingRBACPatterns(t, rbacPathPattern2ResourceMap(), tt.method, tt.path))
		})
	}
}

func TestSQLEditorRBACPatternsDoNotOverlap(t *testing.T) {
	resourceMap := rbacPathPattern2ResourceMap()
	for pattern := range resourceMap {
		if strings.Contains(pattern, "/dataapps/sqleditor/") {
			require.NotContains(t, pattern, "/projects/(\\d+)/.*", "a broad project wildcard would overlap cluster routes")
		}
	}

	for _, tt := range []struct {
		method string
		path   string
	}{
		{method: http.MethodGet, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/settings"},
		{method: http.MethodPost, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/session"},
		{method: http.MethodPut, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles/44"},
		{method: http.MethodDelete, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/33/sqlfiles/44"},
		{method: http.MethodPut, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/settings"},
		{method: http.MethodPost, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/bot"},
		{method: http.MethodGet, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/permission/entry"},
		{method: http.MethodGet, path: "/api/v1/dataapps/sqleditor/orgs/11/projects/22/streaming"},
	} {
		require.Equal(t, 1, countMatchingRBACPatterns(t, resourceMap, tt.method, tt.path), "%s %s must match exactly one RBAC pattern", tt.method, tt.path)
	}
}

func TestSQLEditorRBACWhitelist(t *testing.T) {
	requestHelper := helperimpl.NewWhitelistRequestHelper(rbacPathWhitelist()...)

	tests := []struct {
		name        string
		method      string
		path        string
		fullPath    string
		whitelisted bool
		special     bool
	}{
		{
			name:     "get users",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/users",
			fullPath: sqlEditorUsersRoutePattern,
			special:  true,
		},
		{
			name:     "put users",
			method:   http.MethodPut,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/users",
			fullPath: sqlEditorUsersRoutePattern,
			special:  true,
		},
		{
			name:     "post users",
			method:   http.MethodPost,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/users",
			fullPath: sqlEditorUsersRoutePattern,
		},
		{
			name:     "get cluster status",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/status",
			fullPath: sqlEditorClusterStatusRoutePattern,
			special:  true,
		},
		{
			name:     "put cluster status",
			method:   http.MethodPut,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/status",
			fullPath: sqlEditorClusterStatusRoutePattern,
		},
		{
			name:     "unrelated SQL Editor route",
			method:   http.MethodGet,
			path:     "/api/v1/dataapps/sqleditor/orgs/11/projects/22/unknown",
			fullPath: "/api/v1/dataapps/sqleditor/orgs/:org_id/projects/:project_id/unknown",
		},
		{
			name:        "health check remains whitelisted",
			method:      http.MethodGet,
			path:        "/healthz",
			fullPath:    "/healthz",
			whitelisted: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			released, err := requestHelper.ReleaseRequest(context.Background(), rbacAccessParam(tt.method, tt.path))
			require.NoError(t, err)
			require.Equal(t, tt.whitelisted, released)
			require.Equal(t, tt.special, isSQLEditorSpecialRBACRoute(tt.method, tt.fullPath))
		})
	}
}

func TestCheckRBACPermissionRunsDedicatedStatusCheckBeforeHandler(t *testing.T) {
	for _, tt := range []struct {
		name           string
		authorized     bool
		expectedStatus int
		handled        bool
	}{
		{name: "authorized", authorized: true, expectedStatus: http.StatusNoContent, handled: true},
		{name: "denied", authorized: false, expectedStatus: http.StatusForbidden, handled: false},
	} {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			accountClient := account.NewMockAccountAPIServiceClient(ctrl)
			accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
				UserId:      7,
				ResourceUri: "orgs/11/projects/22/tidbs/31/sqleditor/get_api",
			}).Return(&account.RbacPermissionResponse{AuthResult: tt.authorized}, nil)

			status, handled := serveClusterStatusRequestWithMiddleware(checkRBACPermission(accountClient), "31")
			require.Equal(t, tt.expectedStatus, status)
			require.Equal(t, tt.handled, handled)
		})
	}
}

func TestCheckSQLClusterStatusPermissionAllowsEveryCluster(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)

	gomock.InOrder(
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
			UserId:      7,
			ResourceUri: "orgs/11/projects/22/tidbs/31/sqleditor/get_api",
		}).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
			UserId:      7,
			ResourceUri: "orgs/11/projects/22/tidbs/32/sqleditor/get_api",
		}).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
	)

	status, handled := serveClusterStatusRequest(accountClient, "31,32")
	require.Equal(t, http.StatusNoContent, status)
	require.True(t, handled)
}

func TestCheckSQLClusterStatusPermissionDeduplicatesClustersInOriginalOrder(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)

	gomock.InOrder(
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
			UserId:      7,
			ResourceUri: "orgs/11/projects/22/tidbs/32/sqleditor/get_api",
		}).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
			UserId:      7,
			ResourceUri: "orgs/11/projects/22/tidbs/31/sqleditor/get_api",
		}).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), &account.RbacPermissionRequest{
			UserId:      7,
			ResourceUri: "orgs/11/projects/22/tidbs/33/sqleditor/get_api",
		}).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
	)

	status, handled := serveClusterStatusRequest(accountClient, "32,31,32,33,31")
	require.Equal(t, http.StatusNoContent, status)
	require.True(t, handled)
}

func TestCheckSQLClusterStatusPermissionRejectsMoreThanOneHundredRawIDs(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)
	tooManyDuplicateIDs := strings.TrimSuffix(strings.Repeat("31,", 101), ",")

	status, handled := serveClusterStatusRequest(accountClient, tooManyDuplicateIDs)
	require.Equal(t, http.StatusBadRequest, status)
	require.False(t, handled)
}

func TestCheckSQLClusterStatusPermissionDeniesWholeBatch(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)

	gomock.InOrder(
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), gomock.Any()).Return(&account.RbacPermissionResponse{AuthResult: true}, nil),
		accountClient.EXPECT().HasPermissionForUser(gomock.Any(), gomock.Any()).Return(&account.RbacPermissionResponse{AuthResult: false}, nil),
	)

	status, handled := serveClusterStatusRequest(accountClient, "31,32,33")
	require.Equal(t, http.StatusForbidden, status)
	require.False(t, handled)
}

func TestCheckSQLClusterStatusPermissionDeniesRPCError(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)
	accountClient.EXPECT().HasPermissionForUser(gomock.Any(), gomock.Any()).Return(nil, errors.New("account unavailable"))

	status, handled := serveClusterStatusRequest(accountClient, "31")
	require.Equal(t, http.StatusForbidden, status)
	require.False(t, handled)
}

func TestCheckSQLClusterStatusPermissionTimesOutWholeBatch(t *testing.T) {
	ctrl := gomock.NewController(t)
	accountClient := account.NewMockAccountAPIServiceClient(ctrl)
	accountClient.EXPECT().HasPermissionForUser(gomock.Any(), gomock.Any()).DoAndReturn(
		func(ctx context.Context, _ *account.RbacPermissionRequest, _ ...googlegrpc.CallOption) (*account.RbacPermissionResponse, error) {
			<-ctx.Done()
			return nil, ctx.Err()
		},
	)

	startedAt := time.Now()
	status, handled := serveClusterStatusRequestWithTimeout(accountClient, "31,32", 10*time.Millisecond)
	require.Equal(t, http.StatusForbidden, status)
	require.False(t, handled)
	require.Less(t, time.Since(startedAt), time.Second)
}

func TestCheckSQLClusterStatusPermissionRejectsInvalidIDs(t *testing.T) {
	for _, clusterIDs := range []string{"", "0", "abc", "31,,32", "-1"} {
		t.Run(clusterIDs, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			accountClient := account.NewMockAccountAPIServiceClient(ctrl)

			status, handled := serveClusterStatusRequest(accountClient, clusterIDs)
			require.Equal(t, http.StatusBadRequest, status)
			require.False(t, handled)
		})
	}
}

func rbacAccessParam(method, path string) *common.AccessParam {
	return &common.AccessParam{Metadata: map[string]interface{}{
		common.METADATA_METHOD: method,
		common.METADATA_PATH:   path,
	}}
}

func countMatchingRBACPatterns(t *testing.T, resourceMap map[string]string, method, path string) int {
	t.Helper()
	matched := 0
	for pattern := range resourceMap {
		pathPattern, patternMethod, err := common.ParsePathPattern(pattern)
		require.NoError(t, err)
		if patternMethod != "" && !strings.EqualFold(patternMethod, method) {
			continue
		}
		if regexp.MustCompile("^" + pathPattern + "$").MatchString(path) {
			matched++
		}
	}
	return matched
}

func serveClusterStatusRequest(accountClient account.AccountAPIServiceClient, clusterIDs string) (int, bool) {
	return serveClusterStatusRequestWithMiddleware(CheckSQLClusterStatusPermission(accountClient), clusterIDs)
}

func serveClusterStatusRequestWithTimeout(accountClient account.AccountAPIServiceClient, clusterIDs string, timeout time.Duration) (int, bool) {
	return serveClusterStatusRequestWithMiddleware(checkSQLClusterStatusPermissionWithTimeout(accountClient, timeout), clusterIDs)
}

func serveClusterStatusRequestWithMiddleware(permissionMiddleware gin.HandlerFunc, clusterIDs string) (int, bool) {
	gin.SetMode(gin.TestMode)
	router := gin.New()
	handled := false
	router.Use(func(c *gin.Context) {
		c.Set(consts.CURRENT_USER, &account.User{Id: 7})
		c.Next()
	})
	router.Use(permissionMiddleware)
	router.GET(testSQLClusterStatusRoute, func(c *gin.Context) {
		handled = true
		c.Status(http.StatusNoContent)
	})

	request := httptest.NewRequest(http.MethodGet, "/api/v1/dataapps/sqleditor/orgs/11/projects/22/clusters/status?cluster_ids="+clusterIDs, nil)
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)
	return response.Code, handled
}
