package middleware

import (
	"context"
	"database/sql"
	"net/http"
	"strings"
	"sync"

	"github.com/gin-gonic/gin"
	"github.com/nexuscrm/kernel/pkg/oauthroutes"
	apihostmiddleware "github.com/pingcap-inc/nexuscrm-api-host/middleware"
)

// ScopeRegistry loads custom scope definitions for the ScopeEnforcer.
// DB-backed registries refresh on every scoped decision because control-plane
// setup/apply can update _System_OAuthCustomScope from another process.
type ScopeRegistry struct {
	mu             sync.RWMutex
	reloadMu       sync.Mutex
	db             *sql.DB
	dirty          bool
	epoch          uint64
	reloadOnAccess bool
	scopes         map[string]*CustomScope // name -> scope definition
	appOwners      map[string]string       // connected app id -> source pack

	// routeFamilyPrefix is the app-blind route-family prefix supplied by the
	// active product/dialect (see SetRouteFamilyPrefix). Every reader uses this
	// one value to canonicalize and validate stored route patterns; empty means
	// no product route family is configured.
	routeFamilyPrefix string

	oauthScopeRoutePolicies           []OAuthScopeRoutePolicy
	resourceDocumentLinkRoutePolicies []ResourceDocumentLinkRoutePolicy
	// sourceScopeGatedFamilyScopes are scopes whose product route family requires
	// the source-scope active+owner gate (fail-closed) before a family match is
	// honored — set from product composition.
	sourceScopeGatedFamilyScopes map[string]struct{}
}

// CustomScope holds a custom OAuth scope definition loaded from _System_OAuthCustomScope.
type CustomScope struct {
	Name               string
	CustomPermissionId string
	OwnerPack          string
	IsActive           bool
	allowedClientTypes map[string]struct{}
	routePolicy        routeScopePolicy
}

// ContextKeyConnectedAppID is the request-scoped sentinel consumed by app-rate
// limiting, audit, and dialect projection. It is set only when scope enforcement
// has accepted a scoped Connected App request; legacy no-scope Connected App
// sessions intentionally do not get this marker.
const ContextKeyConnectedAppID = "connected_app_id"

type routeScopePolicy struct {
	methods       map[string]struct{}
	routes        map[string]struct{}
	resourceLinks map[string]struct{}
	// routeFamilyPrefix is the prefix used to canonicalize the stored routes at
	// load time; coversRoute canonicalizes the live request path with the SAME
	// prefix so the two representations cannot drift.
	routeFamilyPrefix string
}

// OAuthScopeRouteContext is the route fact used to decide whether a composed
// product route family is covered by a product-owned OAuth scope.
type OAuthScopeRouteContext struct {
	Scope    string
	Method   string
	FullPath string
}

// OAuthScopeRoutePolicy lets product distributions contribute route coverage
// without hardcoding product protocol roots in L0. Product vocabulary is
// validated by serverruntime composition before policies are installed here.
type OAuthScopeRoutePolicy interface {
	AllowsOAuthScopeRoute(OAuthScopeRouteContext) bool
}

// OAuthScopeRoutePolicyFunc adapts a function into OAuthScopeRoutePolicy.
type OAuthScopeRoutePolicyFunc func(OAuthScopeRouteContext) bool

func (f OAuthScopeRoutePolicyFunc) AllowsOAuthScopeRoute(ctx OAuthScopeRouteContext) bool {
	if f == nil {
		return false
	}
	return f(ctx)
}

// ResourceDocumentLinkRouteContext lets a product-owned resource document link
// prove that the current custom scope route policy actually covers its route.
type ResourceDocumentLinkRouteContext struct {
	Link        string
	CoversRoute func(method, fullPath string) bool
}

// ResourceDocumentLinkRoutePolicy declares a product-owned resource document
// link and the route coverage required before L0 may advertise that link.
type ResourceDocumentLinkRoutePolicy interface {
	ResourceDocumentLinkName() string
	AllowsResourceDocumentLinkRoute(ResourceDocumentLinkRouteContext) bool
}

// ResourceDocumentLinkRoutePolicyFunc adapts a function into
// ResourceDocumentLinkRoutePolicy.
type ResourceDocumentLinkRoutePolicyFunc struct {
	Link   string
	Allows func(ResourceDocumentLinkRouteContext) bool
}

func (f ResourceDocumentLinkRoutePolicyFunc) ResourceDocumentLinkName() string {
	return strings.ToLower(strings.TrimSpace(f.Link))
}

func (f ResourceDocumentLinkRoutePolicyFunc) AllowsResourceDocumentLinkRoute(ctx ResourceDocumentLinkRouteContext) bool {
	if f.Allows == nil {
		return false
	}
	return f.Allows(ctx)
}

const customScopePolicyQuery = "SELECT Name, CustomPermissionId, COALESCE(OwnerPack, ''), IsActive, AllowedClientTypes, AllowedMethods, RoutePatterns, ResourceLinks FROM `_System_OAuthCustomScope` WHERE IsActive = 1"
const connectedAppSourcePackOwnerQuery = `
	SELECT ConnectedAppId, InstalledPackName AS OwnerPack
	  FROM _System_ConnectedAppDeclaration
	 WHERE COALESCE(IsDeleted, 0) = 0
	UNION ALL
	SELECT ConnectedAppId, ScopeOwnerPack AS OwnerPack
	  FROM _System_OAuthDynamicClientRegistration
	 WHERE COALESCE(IsDeleted, 0) = 0
	   AND ScopeOwnerPack IS NOT NULL AND ScopeOwnerPack <> ''`

type connectedAppIDContextKey struct{}

func ContextWithConnectedAppID(ctx context.Context, connectedAppID string) context.Context {
	if strings.TrimSpace(connectedAppID) == "" {
		return ctx
	}
	return context.WithValue(ctx, connectedAppIDContextKey{}, strings.TrimSpace(connectedAppID))
}

func ConnectedAppIDFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	value, _ := ctx.Value(connectedAppIDContextKey{}).(string)
	return strings.TrimSpace(value)
}

// NewScopeRegistry creates an empty ScopeRegistry.
func NewScopeRegistry() *ScopeRegistry {
	return &ScopeRegistry{
		scopes:    make(map[string]*CustomScope),
		appOwners: make(map[string]string),
	}
}

// LoadFromDB refreshes the custom scope cache from _System_OAuthCustomScope table.
func (r *ScopeRegistry) LoadFromDB(db *sql.DB) error {
	if r == nil {
		return nil
	}
	r.mu.Lock()
	r.db = db
	r.dirty = true
	r.epoch++
	r.reloadOnAccess = db != nil
	r.mu.Unlock()
	return r.reloadIfDirty()
}

// Invalidate marks the registry stale after _System_OAuthCustomScope metadata
// changes. The next custom-scope decision reloads once, then serves cache again.
func (r *ScopeRegistry) Invalidate() {
	if r == nil {
		return
	}
	r.mu.Lock()
	r.dirty = true
	r.epoch++
	r.mu.Unlock()
}

// SetRouteFamilyPrefix installs the app-blind route-family prefix the readers
// use to canonicalize and validate stored route patterns. The host supplies it
// once from the active product/dialect. It marks the cache dirty so the next
// access reloads under the new prefix; callers install it before LoadFromDB so
// the very first load already uses it.
func (r *ScopeRegistry) SetRouteFamilyPrefix(prefix string) {
	if r == nil {
		return
	}
	r.mu.Lock()
	r.routeFamilyPrefix = prefix
	r.dirty = true
	r.epoch++
	r.mu.Unlock()
}

func (r *ScopeRegistry) SetOAuthScopeRoutePolicies(policies ...OAuthScopeRoutePolicy) {
	if r == nil {
		return
	}
	r.mu.Lock()
	r.oauthScopeRoutePolicies = append([]OAuthScopeRoutePolicy(nil), policies...)
	r.mu.Unlock()
}

func (r *ScopeRegistry) SetResourceDocumentLinkRoutePolicies(policies ...ResourceDocumentLinkRoutePolicy) {
	if r == nil {
		return
	}
	r.mu.Lock()
	r.resourceDocumentLinkRoutePolicies = append([]ResourceDocumentLinkRoutePolicy(nil), policies...)
	r.mu.Unlock()
}

func (r *ScopeRegistry) reloadIfDirty() error {
	if r == nil {
		return nil
	}
	r.mu.RLock()
	dirty := r.dirty
	r.mu.RUnlock()
	if !dirty {
		return nil
	}

	r.reloadMu.Lock()
	defer r.reloadMu.Unlock()

	for {
		r.mu.RLock()
		dirty := r.dirty
		db := r.db
		epoch := r.epoch
		prefix := r.routeFamilyPrefix
		r.mu.RUnlock()
		if !dirty {
			return nil
		}

		scopes, appOwners, err := loadCustomScopePolicies(db, prefix)
		if err != nil {
			return err
		}
		r.mu.Lock()
		r.scopes = scopes
		r.appOwners = appOwners
		if r.epoch == epoch {
			r.dirty = false
			r.mu.Unlock()
			return nil
		}
		r.dirty = true
		r.mu.Unlock()
	}
}

func loadCustomScopePolicies(db *sql.DB, routeFamilyPrefix string) (map[string]*CustomScope, map[string]string, error) {
	scopes := make(map[string]*CustomScope)
	collidedScopes := make(map[string]struct{})
	appOwners := make(map[string]string)
	if db == nil {
		return scopes, appOwners, nil
	}
	rows, err := db.Query(customScopePolicyQuery)
	if err != nil {
		return nil, nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var s CustomScope
		var customPermissionID, ownerPack, allowedClientTypes, allowedMethods, routePatterns, resourceLinks sql.NullString
		if err := rows.Scan(&s.Name, &customPermissionID, &ownerPack, &s.IsActive, &allowedClientTypes, &allowedMethods, &routePatterns, &resourceLinks); err != nil {
			continue
		}
		s.CustomPermissionId = customPermissionID.String
		s.OwnerPack = strings.TrimSpace(ownerPack.String)
		s.allowedClientTypes = tokenSetLower(allowedClientTypes.String)
		s.routePolicy = routeScopePolicy{
			methods:           httpMethodSet(allowedMethods.String),
			routes:            canonicalRouteSet(routePatterns.String, routeFamilyPrefix),
			resourceLinks:     tokenSet(resourceLinks.String),
			routeFamilyPrefix: routeFamilyPrefix,
		}
		name := canonicalOAuthScopeName(s.Name)
		if name == "" {
			continue
		}
		s.Name = name
		// Fail closed on malformed stored route patterns: do not admit the scope
		// to the active map at all, so issuance, route coverage, CORS, and
		// resource-link readers uniformly reject it rather than granting a scope
		// that covers no real route.
		if !validScopeRoutePatterns(routePatterns.String, routeFamilyPrefix) {
			continue
		}
		if !publicScopeMethodsAreReadOnly(s.allowedClientTypes, s.routePolicy.methods) {
			continue
		}
		if _, collided := collidedScopes[name]; collided {
			continue
		}
		if _, exists := scopes[name]; exists {
			delete(scopes, name)
			collidedScopes[name] = struct{}{}
			continue
		}
		scopes[name] = &s
	}
	if err := rows.Err(); err != nil {
		return nil, nil, err
	}
	appOwners, err = loadConnectedAppSourcePackOwners(db)
	if err != nil {
		return nil, nil, err
	}
	return scopes, appOwners, nil
}

func loadConnectedAppSourcePackOwners(db *sql.DB) (map[string]string, error) {
	appOwners := make(map[string]string)
	collided := make(map[string]struct{})
	if db == nil {
		return appOwners, nil
	}
	rows, err := db.Query(connectedAppSourcePackOwnerQuery)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var connectedAppID, packName string
		if err := rows.Scan(&connectedAppID, &packName); err != nil {
			continue
		}
		connectedAppID = strings.TrimSpace(connectedAppID)
		packName = strings.TrimSpace(packName)
		if connectedAppID != "" && packName != "" {
			if _, seen := collided[connectedAppID]; seen {
				continue
			}
			if _, exists := appOwners[connectedAppID]; exists {
				delete(appOwners, connectedAppID)
				collided[connectedAppID] = struct{}{}
				continue
			}
			appOwners[connectedAppID] = packName
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return appOwners, nil
}

func (r *ScopeRegistry) ensureFresh() bool {
	if r == nil {
		return false
	}
	r.mu.Lock()
	if r.reloadOnAccess && r.db != nil && !r.dirty {
		// _System_OAuthCustomScope can be updated by control-plane setup/apply in
		// another process, so process-local invalidation is only an optimization.
		// Re-read DB-backed scope policy on each scoped decision and fail closed if
		// the metadata source is temporarily unavailable.
		r.dirty = true
		r.epoch++
	}
	r.mu.Unlock()
	return r.reloadIfDirty() == nil
}

// IsCustomScope checks if the given scope name is a registered custom scope.
func (r *ScopeRegistry) IsCustomScope(name string) bool {
	if !r.ensureFresh() {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(name)]
	return ok && s.IsActive
}

// IsSourceBackedScope reports whether the scope is an active custom scope owned
// by an application pack (OwnerPack set). Such scopes are delegated grants whose
// route coverage requires the caller's Connected App to match the owning pack,
// so a first-party credential with no Connected App (e.g. a personal access
// token) cannot use them — callers use this to refuse minting such tokens.
func (r *ScopeRegistry) IsSourceBackedScope(name string) bool {
	if r == nil {
		return false
	}
	if !r.ensureFresh() {
		// Fail closed: if we cannot confirm the scope is safe for a first-party
		// token, treat it as source-backed (inadmissible).
		return true
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(name)]
	if !ok || !s.IsActive {
		return false
	}
	return strings.TrimSpace(s.OwnerPack) != ""
}

// AllowsPublicOAuthScope reports whether metadata explicitly allows this scope
// to be issued to a public OAuth client. No row means no public token.
func (r *ScopeRegistry) AllowsPublicOAuthScope(name string) bool {
	if r == nil {
		return false
	}
	if !r.ensureFresh() {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(name)]
	if !ok || !s.IsActive {
		return false
	}
	if strings.TrimSpace(s.OwnerPack) != "" {
		return false
	}
	if _, ok := s.allowedClientTypes["public"]; !ok {
		return false
	}
	return s.routePolicy.hasRoutePolicy()
}

func (r *ScopeRegistry) AllowsPublicOAuthScopeForPack(name, packName string) bool {
	if r == nil {
		return false
	}
	if !r.ensureFresh() {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(name)]
	if !ok || !s.IsActive {
		return false
	}
	packName = strings.TrimSpace(packName)
	if packName == "" || strings.TrimSpace(s.OwnerPack) != packName {
		return false
	}
	if _, ok := s.allowedClientTypes["public"]; !ok {
		return false
	}
	return s.routePolicy.hasRoutePolicy()
}

func (r *ScopeRegistry) policyCoversRoute(scope, method, fullPath string) bool {
	return r.policyCoversRouteForConnectedApp(scope, "", method, fullPath)
}

func (r *ScopeRegistry) policyCoversRouteForConnectedApp(scope, connectedAppID, method, fullPath string) bool {
	if r == nil {
		return false
	}
	if !r.ensureFresh() {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(scope)]
	if !ok || !s.IsActive {
		return false
	}
	if !r.scopeOwnerMatchesConnectedAppLocked(s.OwnerPack, connectedAppID) {
		return false
	}
	return s.routePolicy.coversRoute(method, fullPath)
}

func (r *ScopeRegistry) AllowsScopedRoute(req apihostmiddleware.ScopeRouteRequest) bool {
	scope := strings.ToLower(strings.TrimSpace(req.Scope))
	if r.oauthScopeRoutePolicyCovers(scope, req.Method, req.FullPath) {
		// A product route family may cover a cross-family route (e.g. POST /mcp)
		// that a scope's own DB route policy cannot. A family whose scope is a
		// source-backed custom scope MUST still be gated on that scope being live
		// and owned by the caller's Connected App — and it must FAIL CLOSED, so a
		// disabled/removed scope or a registry that cannot be read denies rather
		// than grants. This gate is declared per family (RequireActiveSourceScope),
		// so pure product/platform family scopes (e.g. chatter_api, openid) are
		// unaffected and pass as covered. IsCustomScope is deliberately NOT used
		// here: it returns false on reload failure or after removal, which would
		// fail open for exactly the source-backed scopes this must protect.
		if r.isSourceScopeGatedFamilyScope(scope) {
			return r.customScopeActiveAndOwned(scope, req.ConnectedAppID)
		}
		return true
	}
	return r.policyCoversRouteForConnectedApp(scope, req.ConnectedAppID, req.Method, req.FullPath)
}

// isSourceScopeGatedFamilyScope reports whether a product route family declared
// (via RequireActiveSourceScope) that this scope must pass the source-scope
// active+owner gate before a family match is honored.
func (r *ScopeRegistry) isSourceScopeGatedFamilyScope(scope string) bool {
	if r == nil {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	_, ok := r.sourceScopeGatedFamilyScopes[canonicalOAuthScopeName(scope)]
	return ok
}

// SetSourceScopeGatedFamilyScopes records the scopes whose product route family
// requires the source-scope active+owner gate (fail-closed). Supplied by product
// composition.
func (r *ScopeRegistry) SetSourceScopeGatedFamilyScopes(scopes ...string) {
	if r == nil {
		return
	}
	set := make(map[string]struct{}, len(scopes))
	for _, s := range scopes {
		if canonical := canonicalOAuthScopeName(s); canonical != "" {
			set[canonical] = struct{}{}
		}
	}
	r.mu.Lock()
	r.sourceScopeGatedFamilyScopes = set
	r.mu.Unlock()
}

// customScopeActiveAndOwned reports whether scope is a registered, active custom
// scope owned by the caller's Connected App — the ownership/active gate without
// the route-coverage check, so a product route family cannot resurrect a
// disabled scope or cross Connected App ownership.
func (r *ScopeRegistry) customScopeActiveAndOwned(scope, connectedAppID string) bool {
	if r == nil {
		return false
	}
	if !r.ensureFresh() {
		return false
	}
	r.mu.RLock()
	defer r.mu.RUnlock()
	s, ok := r.scopes[canonicalOAuthScopeName(scope)]
	if !ok || !s.IsActive {
		return false
	}
	return r.scopeOwnerMatchesConnectedAppLocked(s.OwnerPack, connectedAppID)
}

func (r *ScopeRegistry) oauthScopeRoutePolicyCovers(scope, method, fullPath string) bool {
	if r == nil {
		return false
	}
	r.mu.RLock()
	policies := append([]OAuthScopeRoutePolicy(nil), r.oauthScopeRoutePolicies...)
	r.mu.RUnlock()
	ctx := OAuthScopeRouteContext{
		Scope:    scope,
		Method:   method,
		FullPath: fullPath,
	}
	for _, policy := range policies {
		if policy != nil && policy.AllowsOAuthScopeRoute(ctx) {
			return true
		}
	}
	return false
}

func (r *ScopeRegistry) resourceDocumentLinksForConnectedApp(scope, connectedAppID string) (map[string]struct{}, bool) {
	if r == nil {
		return nil, false
	}
	if !r.ensureFresh() {
		return map[string]struct{}{}, true
	}
	r.mu.RLock()
	s, ok := r.scopes[canonicalOAuthScopeName(scope)]
	if !ok || !s.IsActive || len(s.routePolicy.resourceLinks) == 0 {
		r.mu.RUnlock()
		return nil, false
	}
	if !r.scopeOwnerMatchesConnectedAppLocked(s.OwnerPack, connectedAppID) {
		r.mu.RUnlock()
		return nil, false
	}
	routePolicy := s.routePolicy
	resourceLinks := make([]string, 0, len(s.routePolicy.resourceLinks))
	for link := range s.routePolicy.resourceLinks {
		resourceLinks = append(resourceLinks, link)
	}
	linkRoutePolicies := append([]ResourceDocumentLinkRoutePolicy(nil), r.resourceDocumentLinkRoutePolicies...)
	r.mu.RUnlock()
	out := make(map[string]struct{}, len(resourceLinks))
	for _, link := range resourceLinks {
		covered, constrained := resourceDocumentLinkRoutePolicyCovers(link, routePolicy, linkRoutePolicies)
		if constrained && !covered {
			continue
		}
		out[link] = struct{}{}
	}
	return out, true
}

func resourceDocumentLinkRoutePolicyCovers(link string, routePolicy routeScopePolicy, policies []ResourceDocumentLinkRoutePolicy) (bool, bool) {
	link = strings.ToLower(strings.TrimSpace(link))
	if link == "" {
		return false, false
	}
	ctx := ResourceDocumentLinkRouteContext{
		Link:        link,
		CoversRoute: routePolicy.coversRoute,
	}
	constrained := false
	for _, policy := range policies {
		if policy == nil || strings.ToLower(strings.TrimSpace(policy.ResourceDocumentLinkName())) != link {
			continue
		}
		constrained = true
		if policy.AllowsResourceDocumentLinkRoute(ctx) {
			return true, true
		}
	}
	return false, constrained
}

func (r *ScopeRegistry) scopeOwnerMatchesConnectedAppLocked(scopeOwnerPack, connectedAppID string) bool {
	scopeOwnerPack = strings.TrimSpace(scopeOwnerPack)
	connectedAppID = strings.TrimSpace(connectedAppID)
	appOwnerPack := ""
	if connectedAppID != "" {
		appOwnerPack = strings.TrimSpace(r.appOwners[connectedAppID])
	}
	if scopeOwnerPack == "" {
		return appOwnerPack == ""
	}
	return appOwnerPack == scopeOwnerPack
}

// ScopeEnforcer checks if the current session's OAuth scope allows access
// to the requested route. Placed AFTER requireAuth and BEFORE permLoader.
//
// Sessions without scope (direct login, Google SSO, legacy tokens) get full
// access — backward compatible. Only tokens with explicit scope are restricted.
//
// An optional ScopeRegistry enables custom scope lookups after built-in checks.
func ScopeEnforcer(registry ...*ScopeRegistry) gin.HandlerFunc {
	return ScopeEnforcerWithSessionContext(backendSessionContextWriter{}, registry...)
}

func ScopeEnforcerWithSessionContext(sessionContext apihostmiddleware.ScopeSessionContext, registry ...*ScopeRegistry) gin.HandlerFunc {
	var authorizer apihostmiddleware.ScopeRouteAuthorizer
	if len(registry) > 0 {
		authorizer = registry[0]
	}
	return apihostmiddleware.ScopeEnforcerWithSessionContext(sessionContext, authorizer)
}

func (p routeScopePolicy) hasRoutePolicy() bool {
	return len(p.methods) > 0 && len(p.routes) > 0
}

func (p routeScopePolicy) coversRoute(method, fullPath string) bool {
	if !p.hasRoutePolicy() {
		return false
	}
	if _, ok := p.methods[strings.ToUpper(strings.TrimSpace(method))]; !ok {
		return false
	}
	_, ok := p.routes[canonicalOAuthRoutePattern(fullPath, p.routeFamilyPrefix)]
	return ok
}

// ResourceDocumentLinkPolicy returns the resource-document links allowed by
// the current scoped session. The bool is true only when at least one scoped
// policy actively restricts links; legacy broad scopes keep existing behavior.
func ResourceDocumentLinkPolicy(scope string, registry ...*ScopeRegistry) (map[string]struct{}, bool) {
	return ResourceDocumentLinkPolicyForConnectedApp(scope, "", registry...)
}

func ResourceDocumentLinkPolicyForConnectedApp(scope, connectedAppID string, registry ...*ScopeRegistry) (map[string]struct{}, bool) {
	var reg *ScopeRegistry
	if len(registry) > 0 {
		reg = registry[0]
	}
	allowed := map[string]struct{}{}
	restricted := false
	for _, s := range strings.Fields(scope) {
		switch s {
		case "api", "full", "api.read":
			return nil, false
		}
		links, ok := reg.resourceDocumentLinksForConnectedApp(s, connectedAppID)
		if !ok {
			continue
		}
		restricted = true
		for link := range links {
			allowed[link] = struct{}{}
		}
	}
	return allowed, restricted
}

func canonicalOAuthRoutePattern(fullPath, routeFamilyPrefix string) string {
	return oauthroutes.NewFamily(routeFamilyPrefix).CanonicalPattern(fullPath)
}

func canonicalOAuthScopeName(name string) string {
	return strings.ToLower(strings.TrimSpace(name))
}

func tokenSet(raw string) map[string]struct{} {
	out := map[string]struct{}{}
	for _, token := range splitPolicyTokens(raw) {
		out[token] = struct{}{}
	}
	return out
}

// canonicalRouteSet builds the stored route-pattern set used for coverage
// checks, normalizing every pattern through canonicalOAuthRoutePattern under the
// active route-family prefix so the persisted form matches the canonical form
// coversRoute derives from the live request path. Without this, a scope route
// pattern authored with a concrete API version is stored and loaded verbatim and
// never matches at runtime, because coversRoute canonicalizes the incoming
// request path to its ":version" placeholder before lookup — so an
// otherwise-valid public scope installs and verifies green yet fails closed
// (403) on its own routes.
func canonicalRouteSet(raw, routeFamilyPrefix string) map[string]struct{} {
	out := map[string]struct{}{}
	for _, token := range splitPolicyTokens(raw) {
		out[canonicalOAuthRoutePattern(token, routeFamilyPrefix)] = struct{}{}
	}
	return out
}

// validScopeRoutePatterns reports whether every stored route pattern is a valid
// OAuth route pattern under the active route-family prefix. A row with a
// malformed route-family token covers no real route, so it must not be admitted
// to the active scope map: otherwise issuance (AllowsPublicOAuthScope), CORS,
// and resource-link readers would treat it as a grantable public scope while
// route enforcement treats it as dead — two meanings for the same stored bytes.
// Fail closed by dropping such scopes entirely.
func validScopeRoutePatterns(raw, routeFamilyPrefix string) bool {
	return oauthroutes.NewFamily(routeFamilyPrefix).ValidatePatterns(raw) == nil
}

func publicScopeMethodsAreReadOnly(allowedClientTypes, methods map[string]struct{}) bool {
	if _, ok := allowedClientTypes["public"]; !ok {
		return true
	}
	for method := range methods {
		switch method {
		case http.MethodGet, http.MethodHead:
		default:
			return false
		}
	}
	return true
}

func tokenSetLower(raw string) map[string]struct{} {
	out := map[string]struct{}{}
	for _, token := range splitPolicyTokens(raw) {
		out[strings.ToLower(token)] = struct{}{}
	}
	return out
}

func httpMethodSet(raw string) map[string]struct{} {
	out := map[string]struct{}{}
	for _, token := range splitPolicyTokens(raw) {
		out[strings.ToUpper(token)] = struct{}{}
	}
	return out
}

func splitPolicyTokens(raw string) []string {
	parts := strings.FieldsFunc(raw, func(r rune) bool {
		switch r {
		case ',', '\n', '\r', '\t', ' ':
			return true
		default:
			return false
		}
	})
	out := make([]string, 0, len(parts))
	for _, part := range parts {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			out = append(out, trimmed)
		}
	}
	return out
}
