package auth

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

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/dataflow-service-ng/app/container"
	grpcliMock "github.com/tidbcloud/dataflow-service-ng/pkg/grpc/grpcli/mock"
	mgmt "github.com/tidbcloud/tidb-management-service/api/spec/tidb_mgmt_service/v1"
)

// ---------------------------------------------------------------------------
// Pure-function unit tests (table-driven)
// ---------------------------------------------------------------------------

func TestAllResourcesAreProjectScoped(t *testing.T) {
	t.Parallel()
	for _, resource := range urlResourceMap {
		require.Contains(t, resource, "orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/",
			"resource %q is not project scoped", resource)
	}
}

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

	tests := []struct {
		name   string
		method string
		path   string
		want   string
	}{
		{"endpoint service availability zones", "GET", "/api/v1beta2/tidbs/10452928564191866258/sinkEndpoints/endpointServiceAvailabilityZones", "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/sinkEndpoints/availabilityZones"},
		{"sink endpoint list", "GET", "/api/v1beta2/tidbs/10452928564191866258/sinkEndpoints", "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/sinkEndpoints/list"},
		{"unknown sink endpoint route", "GET", "/api/v1beta2/tidbs/10452928564191866258/sinkEndpoints/unknown", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, getRBACResource(tt.path, tt.method))
		})
	}
}

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

	const resourcePrefix = "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/"
	const (
		view   = resourcePrefix + "data_pipeline/view"
		modify = resourcePrefix + "data_pipeline/modify"
	)
	tests := []struct {
		name   string
		method string
		path   string
		want   string
	}{
		{"list", "GET", "/api/v1beta2/tidbs/101/data-pipelines", view},
		{"get", "GET", "/api/v1beta2/tidbs/101/data-pipelines/202", view},
		{"staging trust", "GET", "/api/v1beta2/tidbs/101/data-pipelines:stagingTrust", view},
		{"create", "POST", "/api/v1beta2/tidbs/101/data-pipelines", modify},
		{"pause", "POST", "/api/v1beta2/tidbs/101/data-pipelines/202:pause", modify},
		{"edit", "POST", "/api/v1beta2/tidbs/101/data-pipelines/202:edit", modify},
		{"resume", "POST", "/api/v1beta2/tidbs/101/data-pipelines/202:resume", modify},
		{"scale", "POST", "/api/v1beta2/tidbs/101/data-pipelines/202:scale", modify},
		{"delete", "DELETE", "/api/v1beta2/tidbs/101/data-pipelines/202", modify},
		// The validate calls reach out to the source, the staging bucket and
		// the destination with credentials the caller supplied. They read
		// rather than write, but they are steps in authoring a pipeline, and
		// someone who may only view one has no use for them.
		{"validate source", "POST", "/api/v1beta2/tidbs/101/data-pipelines:validateSourceConnection", modify},
		{"validate staging", "POST", "/api/v1beta2/tidbs/101/data-pipelines:validateStaging", modify},
		{"validate destination", "POST", "/api/v1beta2/tidbs/101/data-pipelines:validateDestination", modify},
		// A sub-route nobody has mapped yet is covered by the method it is
		// reached with rather than rejected, so adding a route does not mean
		// remembering to add a privilege for it. Gin still 404s if no handler
		// is registered.
		{"unmapped action", "POST", "/api/v1beta2/tidbs/101/data-pipelines/202:unknown", modify},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, test.want, getRBACResource(test.path, test.method))
		})
	}
}

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

	tests := []struct {
		name             string
		email            string
		bypassUserEmails []string
		want             bool
	}{
		{"exact match", "user@example.com", []string{"user@example.com"}, true},
		{"case and space insensitive", " User@Example.com ", []string{"user@example.com"}, true},
		{"no match", "user@example.com", []string{"other@example.com"}, false},
		{"empty email never matches", "", []string{""}, false},
		{"empty list", "user@example.com", nil, false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, isRBACBypassUser(tt.email, tt.bypassUserEmails))
		})
	}
}

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

	const (
		viewResource   = "nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/import_into/view"
		modifyResource = "nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/import_into/modify"
	)

	tests := []struct {
		name   string
		method string
		path   string
		want   string
	}{
		{"trimmed list route uses view resource", "GET", "/v1beta2/tidbs/101/imports", viewResource},
		{"api list route is not supported", "GET", "/api/v1beta2/tidbs/101/imports", ""},
		{"trimmed get route uses view resource", "GET", "/v1beta2/tidbs/101/imports/202", viewResource},
		{"trimmed async check status route uses view resource", "GET", "/v1beta2/tidbs/101/imports/checkSourceAsync/303", viewResource},
		{"api async check status route is not supported", "GET", "/api/v1beta2/tidbs/101/imports/checkSourceAsync/303", ""},
		{"trimmed create route uses modify resource", "POST", "/v1beta2/tidbs/101/imports", modifyResource},
		{"trimmed cancel route uses modify resource", "POST", "/v1beta2/tidbs/101/imports/202:cancel", modifyResource},
		{"api cancel route is not supported", "POST", "/api/v1beta2/tidbs/101/imports/202:cancel", ""},
		{"trimmed check source route uses modify resource", "POST", "/v1beta2/tidbs/101/imports:checkSource", modifyResource},
		{"trimmed check database route uses modify resource", "POST", "/v1beta2/tidbs/101/imports:checkDatabaseConnectivity", modifyResource},
		{"api check database route is not supported", "POST", "/api/v1beta2/tidbs/101/imports:checkDatabaseConnectivity", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, getRBACResource(tt.path, tt.method))
		})
	}
}

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

	tests := []struct {
		name        string
		pathPattern string
		want        string
	}{
		{
			name:        "list",
			pathPattern: `get:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeeds/list",
		},
		{
			name:        "create",
			pathPattern: `post:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeed/create",
		},
		{
			name:        "get",
			pathPattern: `get:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeeds/get",
		},
		{
			name:        "delete",
			pathPattern: `delete:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeed/delete",
		},
		{
			name:        "pause",
			pathPattern: `post:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+:pause$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeeds/pause",
		},
		{
			name:        "resume",
			pathPattern: `post:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+:resume$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeeds/resume",
		},
		{
			name:        "edit downstream",
			pathPattern: `post:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+:editDownstreamConfig$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeed/edit",
		},
		{
			name:        "scale",
			pathPattern: `post:/orgs/(\d+)/projects/(\d+)/tidbs/(\d+)/v1beta2/changefeeds/\d+:scale$`,
			want:        "/nextgen/orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/changefeed/scale",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tt.want, openAPIURLResourceMap[tt.pathPattern])
		})
	}
}

// ---------------------------------------------------------------------------
// openAPIURLResourceMap tests
// ---------------------------------------------------------------------------

func TestOpenAPIURLResourceMap_AllProjectScoped(t *testing.T) {
	t.Parallel()
	for _, resource := range openAPIURLResourceMap {
		require.Contains(t, resource, "orgs/{org_id}/projects/{project_id}/tidbs/{cluster_id}/",
			"resource %q is not project scoped", resource)
	}
}

// ---------------------------------------------------------------------------
// GenResourceParam4GinCtx unit tests (table-driven)
// ---------------------------------------------------------------------------

func TestGenResourceParam4GinCtx(t *testing.T) {
	origClient := container.Global().MgmtSvcGrpcClient
	defer func() { container.Global().MgmtSvcGrpcClient = origClient }()

	tests := []struct {
		name       string
		params     gin.Params
		path       string
		mockSetup  func(m *grpcliMock.MgmtSvcGrpcClient)
		wantParams map[string]string
		wantErr    error
	}{
		{
			name:   "with cluster_id resolves org and project",
			params: gin.Params{{Key: "cluster_id", Value: "12345"}},
			path:   "/api/v1beta2/tidbs/12345/changefeeds",
			mockSetup: func(m *grpcliMock.MgmtSvcGrpcClient) {
				m.On("GetClusterByID", mock.Anything, uint64(12345)).Return(
					&mgmt.Cluster{OrganizationId: 100, ProjectId: 200}, nil)
			},
			wantParams: map[string]string{
				RBACClusterIdKey: "12345",
				RBACOrgIdKey:     "100",
				RBACProjectIdKey: "200",
			},
		},
		{
			name:   "without cluster_id falls back to org_id param",
			params: gin.Params{{Key: "org_id", Value: "42"}},
			path:   "/orgs/42/projects/99/v1beta2/tidbs/101/imports",
			wantParams: map[string]string{
				RBACOrgIdKey: "42",
			},
		},
		{
			name:    "invalid cluster_id returns ResourceNotFoundError",
			params:  gin.Params{{Key: "cluster_id", Value: "abc"}},
			path:    "/api/v1beta2/tidbs/abc/changefeeds",
			wantErr: ResourceNotFoundError,
		},
		{
			name:       "no params returns empty map",
			params:     nil,
			path:       "/v1beta2/some/path",
			wantParams: map[string]string{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.mockSetup != nil {
				mockClient := grpcliMock.NewMgmtSvcGrpcClient(t)
				tt.mockSetup(mockClient)
				container.Global().MgmtSvcGrpcClient = mockClient
			}

			gin.SetMode(gin.TestMode)
			w := httptest.NewRecorder()
			ctx, _ := gin.CreateTestContext(w)
			ctx.Request = httptest.NewRequest("GET", tt.path, nil)
			ctx.Params = tt.params

			params, err := GenResourceParam4GinCtx(ctx)
			if tt.wantErr != nil {
				require.ErrorIs(t, err, tt.wantErr)
				require.Nil(t, params)
			} else {
				require.NoError(t, err)
				for k, v := range tt.wantParams {
					require.Equal(t, v, params[k], "key=%s", k)
				}
			}
		})
	}
}

func TestSetAuthOrgIdAndPrjIdWithTiDBId(t *testing.T) {
	origClient := container.Global().MgmtSvcGrpcClient
	defer func() { container.Global().MgmtSvcGrpcClient = origClient }()

	tests := []struct {
		name      string
		path      string
		mockSetup func(m *grpcliMock.MgmtSvcGrpcClient)
		wantErr   string
		wantOrg   uint64
		wantPrj   uint64
		wantQuery string
	}{
		{
			name: "resolves org and project from tidb id",
			path: "/v1beta2/tidbs/12345/imports",
			mockSetup: func(m *grpcliMock.MgmtSvcGrpcClient) {
				m.On("ListClusters", mock.Anything, mock.Anything).Return(
					&mgmt.ListClustersResponse{Clusters: []*mgmt.Cluster{
						{OrganizationId: 100, ProjectId: 200},
					}}, nil)
			},
			wantOrg:   100,
			wantPrj:   200,
			wantQuery: "100",
		},
		{
			name: "no tidbs in path is a no-op",
			path: "/v1beta2/some/other/path",
		},
		{
			name: "cluster not found returns error",
			path: "/v1beta2/tidbs/99999/imports",
			mockSetup: func(m *grpcliMock.MgmtSvcGrpcClient) {
				m.On("ListClusters", mock.Anything, mock.Anything).Return(
					&mgmt.ListClustersResponse{Clusters: nil}, nil)
			},
			wantErr: "no cluster",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.mockSetup != nil {
				mockClient := grpcliMock.NewMgmtSvcGrpcClient(t)
				tt.mockSetup(mockClient)
				container.Global().MgmtSvcGrpcClient = mockClient
			}

			gin.SetMode(gin.TestMode)
			w := httptest.NewRecorder()
			ctx, _ := gin.CreateTestContext(w)
			ctx.Request = httptest.NewRequest("GET", tt.path, nil)

			err := SetAuthIDsWithTiDBId(ctx)
			if tt.wantErr != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.wantErr)
				return
			}
			require.NoError(t, err)

			if tt.wantOrg != 0 {
				orgID, exists := ctx.Get(CTX_KEY_RESOURCE_ORG_ID)
				require.True(t, exists)
				require.Equal(t, tt.wantOrg, orgID)
				prjID, exists := ctx.Get(CTX_KEY_RESOURCE_PRJ_ID)
				require.True(t, exists)
				require.Equal(t, tt.wantPrj, prjID)
				require.Equal(t, tt.wantQuery, ctx.Request.URL.Query().Get("accessOrgId"))
			} else {
				_, exists := ctx.Get(CTX_KEY_RESOURCE_ORG_ID)
				require.False(t, exists)
			}
		})
	}
}
