package middleware

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

	"github.com/gin-gonic/gin"
	jwtauth "github.com/nexuscrm/kernel/pkg/auth"
	"github.com/nexuscrm/shared/pkg/constants"
)

func TestScopeRegistryPolicyCoversRoute(t *testing.T) {
	reg := testScopePolicyRegistry()
	tests := []struct {
		name     string
		method   string
		fullPath string
		want     bool
	}{
		{
			name:     "versions root",
			method:   http.MethodGet,
			fullPath: "/services/data",
			want:     true,
		},
		{
			name:     "version root",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/",
			want:     true,
		},
		{
			name:     "record read",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/sobjects/:sobject/:id",
			want:     true,
		},
		{
			name:     "query more",
			method:   http.MethodHead,
			fullPath: "/services/data/v63.0/query/:locator",
			want:     true,
		},
		{
			name:     "root identity",
			method:   http.MethodGet,
			fullPath: "/id/:orgId/:userId",
			want:     true,
		},
		{
			name:     "mutating method denied",
			method:   http.MethodPost,
			fullPath: "/services/data/v63.0/sobjects/:sobject",
			want:     false,
		},
		{
			name:     "userinfo excluded",
			method:   http.MethodGet,
			fullPath: "/services/oauth2/userinfo",
			want:     false,
		},
		{
			name:     "connect excluded",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/connect/notifications",
			want:     false,
		},
		{
			name:     "tooling excluded",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/tooling/query",
			want:     false,
		},
		{
			name:     "composite excluded even for GET",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/composite/sobjects",
			want:     false,
		},
		{
			name:     "external id read excluded",
			method:   http.MethodGet,
			fullPath: "/services/data/v63.0/sobjects/:sobject/:id/:fieldValue",
			want:     false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := reg.policyCoversRoute("external.records.read", tt.method, tt.fullPath); got != tt.want {
				t.Fatalf("policyCoversRoute() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestPolicyCoversRouteDoesNotGrantUnknownScopes(t *testing.T) {
	reg := testScopePolicyRegistry()
	if reg.policyCoversRoute("custom.read", http.MethodGet, "/services/data/v63.0/sobjects") {
		t.Fatal("unknown scopes must not gain route access from the public policy registry")
	}
}

func TestResourceDocumentLinkPolicyFromRegistry(t *testing.T) {
	reg := testScopePolicyRegistry()
	links, restricted := ResourceDocumentLinkPolicy("external.records.read", reg)
	if !restricted {
		t.Fatal("metadata scope policy should restrict resource-document links")
	}
	for _, key := range []string{"sobjects", "query", "queryAll", "identity"} {
		if _, ok := links[key]; !ok {
			t.Fatalf("link %s missing from scope policy: %#v", key, links)
		}
	}
	for _, key := range []string{"connect", "tooling", "analytics", "composite", "metadata", "ui-api"} {
		if _, ok := links[key]; ok {
			t.Fatalf("link %s unexpectedly allowed by scope policy", key)
		}
	}

	if _, restricted := ResourceDocumentLinkPolicy("api.read", reg); restricted {
		t.Fatal("legacy api.read should preserve existing unfiltered link behavior")
	}
}

func TestResourceDocumentLinkPolicyRequiresProductRouteCoverageForIdentity(t *testing.T) {
	reg := testScopePolicyRegistry()
	reg.SetResourceDocumentLinkRoutePolicies(ResourceDocumentLinkRoutePolicyFunc{
		Link: "identity",
		Allows: func(ResourceDocumentLinkRouteContext) bool {
			return false
		},
	})

	links, restricted := ResourceDocumentLinkPolicy("external.records.read", reg)
	if !restricted {
		t.Fatal("external.records.read should still restrict resource links")
	}
	if hasLink(links, "identity") {
		t.Fatalf("identity link allowed without product route coverage: %#v", links)
	}
	if !hasLink(links, "sobjects") {
		t.Fatalf("generic sobjects link missing without product identity route policy: %#v", links)
	}
}

func TestScopeEnforcerUsesMetadataPolicyForIdentityRoutes(t *testing.T) {
	gin.SetMode(gin.TestMode)
	tests := []struct {
		name      string
		route     string
		target    string
		scope     string
		wantCode  int
		wantError string
	}{
		{
			name:     "root identity allowed",
			route:    "/id/:orgId/:userId",
			target:   "/id/00D000000000001/005000000000123AAA",
			scope:    "external.records.read",
			wantCode: http.StatusNoContent,
		},
		{
			name:     "versioned identity allowed",
			route:    "/services/data/:version/id/:orgId/:userId",
			target:   "/services/data/v63.0/id/00D000000000001/005000000000123AAA",
			scope:    "external.records.read",
			wantCode: http.StatusNoContent,
		},
		{
			name:      "root identity rejects insufficient scope",
			route:     "/id/:orgId/:userId",
			target:    "/id/00D000000000001/005000000000123AAA",
			scope:     "missing.scope",
			wantCode:  http.StatusForbidden,
			wantError: "INSUFFICIENT_SCOPE",
		},
		{
			name:      "versioned identity rejects insufficient scope",
			route:     "/services/data/:version/id/:orgId/:userId",
			target:    "/services/data/v63.0/id/00D000000000001/005000000000123AAA",
			scope:     "missing.scope",
			wantCode:  http.StatusForbidden,
			wantError: "INSUFFICIENT_SCOPE",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			router := gin.New()
			router.Use(func(c *gin.Context) {
				c.Set(constants.ContextKeyUser, jwtauth.UserSession{ID: "005000000000123AAA", Scope: tt.scope})
				c.Next()
			}, ScopeEnforcer(testScopePolicyRegistry()))
			router.GET(tt.route, func(c *gin.Context) {
				c.Status(http.StatusNoContent)
			})

			rec := httptest.NewRecorder()
			router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.target, nil))
			if rec.Code != tt.wantCode {
				t.Fatalf("status=%d want=%d body=%s", rec.Code, tt.wantCode, rec.Body.String())
			}
			if tt.wantError != "" && !regexp.MustCompile(tt.wantError).MatchString(rec.Body.String()) {
				t.Fatalf("body=%s want error %s", rec.Body.String(), tt.wantError)
			}
		})
	}
}

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

	cases := []struct {
		name       string
		method     string
		route      string
		target     string
		scope      string
		policies   []OAuthScopeRoutePolicy
		wantStatus int
	}{
		{
			name:       "openid neutral userinfo stays in L0",
			route:      "/services/oauth2/userinfo",
			target:     "/services/oauth2/userinfo",
			scope:      "openid",
			wantStatus: http.StatusNoContent,
		},
		{
			name:       "openid root identity without product policy denied",
			route:      "/id/:orgId/:userId",
			target:     "/id/00D000000000001/005000000000123AAA",
			scope:      "openid",
			wantStatus: http.StatusForbidden,
		},
		{
			name:   "api read cannot be broadened by product policy",
			method: http.MethodPost,
			route:  "/product/notifications",
			target: "/product/notifications",
			scope:  "api.read",
			policies: []OAuthScopeRoutePolicy{OAuthScopeRoutePolicyFunc(func(ctx OAuthScopeRouteContext) bool {
				return ctx.Scope == "api.read" && ctx.FullPath == "/product/notifications"
			})},
			wantStatus: http.StatusForbidden,
		},
		{
			name:   "openid root identity with product policy allowed",
			route:  "/id/:orgId/:userId",
			target: "/id/00D000000000001/005000000000123AAA",
			scope:  "openid",
			policies: []OAuthScopeRoutePolicy{OAuthScopeRoutePolicyFunc(func(ctx OAuthScopeRouteContext) bool {
				return ctx.Scope == "openid" && ctx.Method == http.MethodGet && ctx.FullPath == "/id/:orgId/:userId"
			})},
			wantStatus: http.StatusNoContent,
		},
		{
			name:       "product scope without product policy denied",
			route:      "/product/notifications",
			target:     "/product/notifications",
			scope:      "product.notifications",
			wantStatus: http.StatusForbidden,
		},
		{
			name:   "product scope with composed product policy allowed",
			route:  "/product/notifications",
			target: "/product/notifications",
			scope:  "product.notifications",
			policies: []OAuthScopeRoutePolicy{OAuthScopeRoutePolicyFunc(func(ctx OAuthScopeRouteContext) bool {
				return ctx.Scope == "product.notifications" && ctx.FullPath == "/product/notifications"
			})},
			wantStatus: http.StatusNoContent,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			reg := newScopeRegistryForTest()
			reg.SetOAuthScopeRoutePolicies(tc.policies...)
			router := gin.New()
			router.Use(func(c *gin.Context) {
				c.Set(constants.ContextKeyUser, jwtauth.UserSession{ID: "005000000000123AAA", Scope: tc.scope})
				c.Next()
			}, ScopeEnforcer(reg))
			handler := func(c *gin.Context) {
				c.Status(http.StatusNoContent)
			}
			router.GET(tc.route, handler)
			router.POST(tc.route, handler)

			rec := httptest.NewRecorder()
			method := tc.method
			if method == "" {
				method = http.MethodGet
			}
			router.ServeHTTP(rec, httptest.NewRequest(method, tc.target, nil))
			if rec.Code != tc.wantStatus {
				t.Fatalf("status=%d want=%d body=%s", rec.Code, tc.wantStatus, rec.Body.String())
			}
		})
	}
}

func TestScopeRegistryAllowsPublicOAuthScopeRequiresMetadataPolicy(t *testing.T) {
	reg := testScopePolicyRegistry()
	if !reg.AllowsPublicOAuthScope("external.records.read") {
		t.Fatal("metadata-backed public scope should be allowed")
	}
	if reg.AllowsPublicOAuthScope("missing.scope") {
		t.Fatal("missing metadata scope should not be allowed")
	}
}

func TestScopeRegistryAllowsPublicOAuthScopeForPackRequiresOwner(t *testing.T) {
	reg := testScopePolicyRegistry()
	reg.scopes["external.records.read"].OwnerPack = "crm-pack"
	if reg.AllowsPublicOAuthScope("external.records.read") {
		t.Fatal("ownerless public scope lookup accepted a pack-owned scope")
	}
	if !reg.AllowsPublicOAuthScopeForPack("external.records.read", "crm-pack") {
		t.Fatal("metadata-backed public scope should allow owning pack")
	}
	if reg.AllowsPublicOAuthScopeForPack("external.records.read", "other-pack") {
		t.Fatal("metadata-backed public scope allowed non-owning pack")
	}
}

func TestScopeRegistryRoutePolicyRequiresConnectedAppScopeOwner(t *testing.T) {
	reg := testScopePolicyRegistry()
	reg.scopes["external.records.read"].OwnerPack = "crm-pack"
	reg.appOwners["crm-public-client"] = "crm-pack"
	reg.appOwners["other-public-client"] = "other-pack"

	if !reg.policyCoversRouteForConnectedApp("external.records.read", "crm-public-client", http.MethodGet, "/services/data/v63.0/sobjects") {
		t.Fatal("owning source-backed Connected App should use its pack-owned route policy")
	}
	if reg.policyCoversRouteForConnectedApp("external.records.read", "other-public-client", http.MethodGet, "/services/data/v63.0/sobjects") {
		t.Fatal("Connected App from another pack used a pack-owned route policy")
	}
	if reg.policyCoversRoute("external.records.read", http.MethodGet, "/services/data/v63.0/sobjects") {
		t.Fatal("ownerless route lookup used a pack-owned route policy")
	}
}

func TestResourceDocumentLinkPolicyRequiresConnectedAppScopeOwner(t *testing.T) {
	reg := testScopePolicyRegistry()
	reg.scopes["external.records.read"].OwnerPack = "crm-pack"
	reg.appOwners["crm-public-client"] = "crm-pack"
	reg.appOwners["other-public-client"] = "other-pack"

	links, restricted := ResourceDocumentLinkPolicyForConnectedApp("external.records.read", "crm-public-client", reg)
	if !restricted || !hasLink(links, "sobjects") {
		t.Fatalf("owning Connected App resource links = %#v restricted=%v, want scoped links", links, restricted)
	}
	if links, restricted := ResourceDocumentLinkPolicyForConnectedApp("external.records.read", "other-public-client", reg); restricted || len(links) != 0 {
		t.Fatalf("other pack resource links = %#v restricted=%v, want fail closed", links, restricted)
	}
	if links, restricted := ResourceDocumentLinkPolicy("external.records.read", reg); restricted || len(links) != 0 {
		t.Fatalf("ownerless resource links = %#v restricted=%v, want fail closed", links, restricted)
	}
}

func hasLink(links map[string]struct{}, key string) bool {
	_, ok := links[key]
	return ok
}

// testScopeRouteFamilyPrefix is the route-family prefix the host installs at
// runtime for the Salesforce data API. These middleware tests exercise that
// family, so they declare the same prefix the CRM distribution supplies.
const testScopeRouteFamilyPrefix = "/services/data/"

// newScopeRegistryForTest builds a registry with the Salesforce route-family
// prefix already set by direct field assignment (so it does NOT mark the cache
// dirty — hand-seeded registries keep their scopes).
func newScopeRegistryForTest() *ScopeRegistry {
	reg := NewScopeRegistry()
	reg.routeFamilyPrefix = testScopeRouteFamilyPrefix
	return reg
}

func testScopePolicyRegistry() *ScopeRegistry {
	reg := newScopeRegistryForTest()
	reg.SetResourceDocumentLinkRoutePolicies(testIdentityResourceDocumentLinkRoutePolicy())
	reg.scopes["external.records.read"] = &CustomScope{
		Name:               "external.records.read",
		IsActive:           true,
		allowedClientTypes: map[string]struct{}{"public": {}},
		routePolicy: routeScopePolicy{
			methods: map[string]struct{}{
				http.MethodGet:  {},
				http.MethodHead: {},
			},
			routes: map[string]struct{}{
				"/services/data":                                     {},
				"/services/data/:version":                            {},
				"/services/data/:version/":                           {},
				"/services/data/:version/sobjects":                   {},
				"/services/data/:version/sobjects/:sobject/describe": {},
				"/services/data/:version/sobjects/:sobject/:id":      {},
				"/services/data/:version/query":                      {},
				"/services/data/:version/queryAll":                   {},
				"/services/data/:version/query/:locator":             {},
				"/services/data/:version/id/:orgId/:userId":          {},
				"/id/:orgId/:userId":                                 {},
			},
			resourceLinks: map[string]struct{}{
				"identity": {},
				"query":    {},
				"queryAll": {},
				"sobjects": {},
			},
			routeFamilyPrefix: testScopeRouteFamilyPrefix,
		},
	}
	return reg
}

func testIdentityResourceDocumentLinkRoutePolicy() ResourceDocumentLinkRoutePolicy {
	return ResourceDocumentLinkRoutePolicyFunc{
		Link: "identity",
		Allows: func(ctx ResourceDocumentLinkRouteContext) bool {
			return ctx.CoversRoute != nil &&
				(ctx.CoversRoute(http.MethodGet, "/id/:orgId/:userId") ||
					ctx.CoversRoute(http.MethodGet, "/services/data/:version/id/:orgId/:userId"))
		},
	}
}
