package server

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"math/big"
	"net/http"
	"strings"
	"time"

	"github.com/google/uuid"

	"github.com/mem9-ai/drive9/pkg/logger"
	"github.com/mem9-ai/drive9/pkg/meta"
	"github.com/mem9-ai/drive9/pkg/tenant/token"
)

type fsTokenScopeRequest struct {
	Prefix string   `json:"prefix"`
	Ops    []string `json:"ops"`
}

type issueScopedTokenRequest struct {
	Subject    string                `json:"subject,omitempty"`
	TTLSeconds int64                 `json:"ttl_seconds"`
	Scopes     []fsTokenScopeRequest `json:"scopes"`
}

type scopedTokenResponse struct {
	Token     string                `json:"token"`
	TokenID   string                `json:"token_id,omitempty"`
	Subject   string                `json:"subject,omitempty"`
	ScopeKind string                `json:"scope_kind"`
	ExpiresAt *time.Time            `json:"expires_at,omitempty"`
	Scopes    []fsTokenScopeRequest `json:"scopes"`
}

type revokeScopedTokenByAPIKeyRequest struct {
	APIKey string `json:"api_key"`
}

type refreshTokenRequest struct {
	TTLSeconds *int64 `json:"ttl_seconds"`
}

type refreshTokenResponse struct {
	Token     string     `json:"token"`
	TokenID   string     `json:"token_id"`
	TenantID  string     `json:"tenant_id"`
	ScopeKind string     `json:"scope_kind"`
	ExpiresAt *time.Time `json:"expires_at"`
}

const maxScopedTokenTTLSeconds = int64(1<<63-1) / int64(time.Second)

func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
	if s.meta == nil || s.pool == nil || len(s.tokenSecret) == 0 {
		errJSON(w, http.StatusNotFound, "token management not enabled")
		return
	}
	cps := ControlPlaneScopeFromContext(r.Context())
	scope := ScopeFromContext(r.Context())

	// Branch 0: refresh — the only entry open to scoped tokens (Bearer only).
	if r.URL.Path == "/v1/tokens/refresh" {
		if cps != nil {
			errJSON(w, http.StatusForbidden, "control-plane credentials cannot refresh tokens")
			return
		}
		if r.Method != http.MethodPost {
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		if len(r.URL.Query()) != 0 {
			errJSON(w, http.StatusBadRequest, "refresh does not accept query parameters")
			return
		}
		if scope == nil {
			errJSON(w, http.StatusUnauthorized, "missing tenant scope")
			return
		}
		s.handleTokenRefresh(w, r, scope)
		return
	}

	// Branch 1: control-plane identity.
	if cps != nil {
		switch {
		case r.URL.Path == "/v1/tokens/generate":
			if r.Method != http.MethodPost {
				errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			s.handleTokenGenerate(w, r, cps)
		case r.URL.Path == "/v1/tokens/revoke":
			if r.Method != http.MethodPost {
				errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			s.handleTokenRevokeByAPIKey(w, r, cps)
		case r.URL.Path == "/v1/tokens":
			if r.Method == http.MethodGet {
				s.handleTokenList(w, r)
				return
			}
			// POST issue and other methods are data-plane only for this path.
			errJSON(w, http.StatusForbidden, "control-plane credentials cannot issue scoped tokens")
		case strings.HasPrefix(r.URL.Path, "/v1/tokens/") && strings.HasSuffix(r.URL.Path, "/activate"):
			if r.Method != http.MethodPost {
				errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			tokenID := tokenIDFromStatusPath(r.URL.Path, "/activate")
			if tokenID == "" {
				errJSON(w, http.StatusNotFound, "token not found")
				return
			}
			s.handleTokenStatusTransition(w, r, tokenID, true)
		case strings.HasPrefix(r.URL.Path, "/v1/tokens/") && strings.HasSuffix(r.URL.Path, "/deactivate"):
			if r.Method != http.MethodPost {
				errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			tokenID := tokenIDFromStatusPath(r.URL.Path, "/deactivate")
			if tokenID == "" {
				errJSON(w, http.StatusNotFound, "token not found")
				return
			}
			s.handleTokenStatusTransition(w, r, tokenID, false)
		case strings.HasPrefix(r.URL.Path, "/v1/tokens/"):
			// /v1/tokens/{id} — delete only. (refresh is handled in branch 0.)
			tokenID := tokenIDFromPath(r.URL.Path)
			if tokenID == "" {
				errJSON(w, http.StatusNotFound, "token not found or already revoked")
				return
			}
			if r.Method != http.MethodDelete {
				errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
				return
			}
			s.handleTokenDelete(w, r, cps, tokenID)
		default:
			errJSON(w, http.StatusForbidden, "control-plane credentials cannot issue scoped tokens")
		}
		return
	}

	// Branch 2: data-plane identity.
	if scope == nil {
		errJSON(w, http.StatusUnauthorized, "missing tenant scope")
		return
	}
	if scope.IsScoped || scope.ScopeKind == meta.APIKeyScopeKindFS {
		errJSON(w, http.StatusForbidden, "scoped token cannot manage tokens")
		return
	}

	// Owner below. Legacy three endpoints preserve method/response semantics.
	switch {
	case r.URL.Path == "/v1/tokens":
		switch r.Method {
		case http.MethodPost:
			s.handleScopedTokenIssue(w, r, scope)
		case http.MethodGet:
			s.handleTokenList(w, r)
		default:
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
		}
	case r.URL.Path == "/v1/tokens/revoke":
		if r.Method != http.MethodPost {
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		s.handleScopedTokenRevokeByAPIKey(w, r, scope)
	case r.URL.Path == "/v1/tokens/generate":
		errJSON(w, http.StatusForbidden, "owner key cannot generate owner keys")
	case strings.HasPrefix(r.URL.Path, "/v1/tokens/") && strings.HasSuffix(r.URL.Path, "/activate"):
		if r.Method != http.MethodPost {
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		tokenID := tokenIDFromStatusPath(r.URL.Path, "/activate")
		if tokenID == "" {
			errJSON(w, http.StatusNotFound, "token not found")
			return
		}
		s.handleTokenStatusTransition(w, r, tokenID, true)
	case strings.HasPrefix(r.URL.Path, "/v1/tokens/") && strings.HasSuffix(r.URL.Path, "/deactivate"):
		if r.Method != http.MethodPost {
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		tokenID := tokenIDFromStatusPath(r.URL.Path, "/deactivate")
		if tokenID == "" {
			errJSON(w, http.StatusNotFound, "token not found")
			return
		}
		s.handleTokenStatusTransition(w, r, tokenID, false)
	case strings.HasPrefix(r.URL.Path, "/v1/tokens/"):
		tokenID := tokenIDFromPath(r.URL.Path)
		if tokenID == "" {
			errJSON(w, http.StatusNotFound, "token not found or already revoked")
			return
		}
		if r.Method != http.MethodDelete {
			errJSON(w, http.StatusMethodNotAllowed, "method not allowed")
			return
		}
		s.handleScopedTokenRevoke(w, r, scope, tokenID)
	default:
		errJSON(w, http.StatusNotFound, "token not found or already revoked")
	}
}

// tokenIDFromPath extracts /v1/tokens/{id} and requires a UUID form (token.NewID).
func tokenIDFromPath(path string) string {
	id := strings.TrimPrefix(path, "/v1/tokens/")
	if id == "" || strings.Contains(id, "/") {
		return ""
	}
	if _, err := uuid.Parse(id); err != nil {
		return ""
	}
	return id
}

func tokenIDFromStatusPath(path, suffix string) string {
	// path is /v1/tokens/{id}/activate or /deactivate
	rest := strings.TrimPrefix(path, "/v1/tokens/")
	if !strings.HasSuffix(rest, suffix) {
		return ""
	}
	id := strings.TrimSuffix(rest, suffix)
	id = strings.TrimSuffix(id, "/")
	if id == "" || strings.Contains(id, "/") {
		return ""
	}
	// Defense in depth: IDs are uuid.NewString() (token.NewID). Reject path
	// traversal junk before it reaches parameterized DB queries.
	if _, err := uuid.Parse(id); err != nil {
		return ""
	}
	return id
}

func ownerScopeFromRequest(w http.ResponseWriter, r *http.Request, action string) (*TenantScope, bool) {
	scope := ScopeFromContext(r.Context())
	if scope == nil {
		errJSON(w, http.StatusUnauthorized, "missing tenant scope")
		return nil, false
	}
	if scope.IsScoped || scope.ScopeKind == meta.APIKeyScopeKindFS {
		if strings.TrimSpace(action) == "" {
			action = "perform this operation"
		}
		errJSON(w, http.StatusForbidden, "scoped token cannot "+action)
		return nil, false
	}
	return scope, true
}

func (s *Server) handleScopedTokenIssue(w http.ResponseWriter, r *http.Request, scope *TenantScope) {
	var req issueScopedTokenRequest
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
		errJSON(w, http.StatusBadRequest, "invalid JSON")
		return
	}
	subject := strings.TrimSpace(req.Subject)
	if len(subject) > 64 {
		errJSON(w, http.StatusBadRequest, "subject must be at most 64 bytes")
		return
	}
	if req.TTLSeconds <= 0 {
		errJSON(w, http.StatusBadRequest, "ttl_seconds must be positive")
		return
	}
	if req.TTLSeconds > maxScopedTokenTTLSeconds {
		errJSON(w, http.StatusBadRequest, "ttl_seconds is too large")
		return
	}
	if len(req.Scopes) == 0 {
		errJSON(w, http.StatusBadRequest, "scopes are required")
		return
	}
	validatedScopes, err := validateScopedTokenScopes(req.Scopes)
	if err != nil {
		errJSON(w, http.StatusBadRequest, err.Error())
		return
	}

	tokenVersion, err := newScopedTokenVersion()
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to issue token")
		return
	}
	// Second-level alignment: truncate now and expires_at so JWT exp and the
	// column agree exactly (§5.3).
	nowSec := time.Now().UTC().Truncate(time.Second)
	expiresAt := nowSec.Add(time.Duration(req.TTLSeconds) * time.Second)
	rawToken, err := token.IssueTokenWithExpiry(s.tokenSecret, scope.TenantID, tokenVersion, expiresAt)
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to issue token")
		return
	}
	cipherToken, err := s.pool.Encrypt(r.Context(), []byte(rawToken))
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to encrypt token")
		return
	}

	apiKeyID := token.NewID()
	if err := s.meta.InsertAPIKeyWithLimit(r.Context(), &meta.APIKey{
		ID:            apiKeyID,
		TenantID:      scope.TenantID,
		KeyName:       subject,
		JWTCiphertext: cipherToken,
		JWTHash:       token.HashToken(rawToken),
		TokenVersion:  tokenVersion,
		Status:        meta.APIKeyActive,
		ScopeKind:     meta.APIKeyScopeKindFS,
		IssuedAt:      nowSec,
		ExpiresAt:     &expiresAt,
		CreatedAt:     nowSec,
		UpdatedAt:     nowSec,
	}, meta.DefaultMaxActiveAPIKeys); err != nil {
		if errors.Is(err, meta.ErrAPIKeyLimitExceeded) {
			errJSON(w, http.StatusConflict, "api key limit exceeded")
			return
		}
		if errors.Is(err, meta.ErrDuplicate) {
			errJSON(w, http.StatusConflict, "token already exists")
			return
		}
		if errors.Is(err, meta.ErrTenantNotActive) || errors.Is(err, meta.ErrNotFound) {
			errJSON(w, http.StatusConflict, "tenant is not ready")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to persist token")
		return
	}

	for _, scopeReq := range validatedScopes {
		if err := s.meta.InsertAPIKeyFSScope(r.Context(), &meta.APIKeyFSScope{
			TenantID:  scope.TenantID,
			APIKeyID:  apiKeyID,
			Prefix:    scopeReq.Prefix,
			Ops:       scopeReq.Ops,
			CreatedAt: nowSec,
			UpdatedAt: nowSec,
		}); err != nil {
			_ = s.meta.RevokeAPIKey(context.Background(), scope.TenantID, apiKeyID)
			if errors.Is(err, meta.ErrDuplicate) {
				errJSON(w, http.StatusConflict, "duplicate fs scope")
				return
			}
			errJSON(w, http.StatusBadRequest, err.Error())
			return
		}
	}

	rows, err := s.meta.ListAPIKeyFSScopes(r.Context(), scope.TenantID, apiKeyID)
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to load token scopes")
		return
	}
	resp := scopedTokenResponse{
		Token:     rawToken,
		TokenID:   apiKeyID,
		Subject:   subject,
		ScopeKind: string(meta.APIKeyScopeKindFS),
		ExpiresAt: &expiresAt,
		Scopes:    fsScopeResponses(rows),
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(resp)
}

func (s *Server) handleScopedTokenRevoke(w http.ResponseWriter, r *http.Request, scope *TenantScope, tokenID string) {
	if err := s.meta.RevokeAPIKey(r.Context(), scope.TenantID, tokenID); err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			errJSON(w, http.StatusNotFound, "token not found or already revoked")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to revoke token")
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func (s *Server) handleScopedTokenRevokeByAPIKey(w http.ResponseWriter, r *http.Request, scope *TenantScope) {
	var req revokeScopedTokenByAPIKeyRequest
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
		errJSON(w, http.StatusBadRequest, "invalid JSON")
		return
	}
	apiKey := strings.TrimSpace(req.APIKey)
	if apiKey == "" {
		errJSON(w, http.StatusBadRequest, "api_key is required")
		return
	}
	resolved, err := s.meta.ResolveByAPIKeyHash(r.Context(), token.HashToken(apiKey))
	if err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			errJSON(w, http.StatusNotFound, "token not found or already revoked")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to revoke token")
		return
	}
	if resolved.APIKey.TenantID != scope.TenantID || resolved.APIKey.ScopeKind != meta.APIKeyScopeKindFS {
		errJSON(w, http.StatusNotFound, "token not found or already revoked")
		return
	}
	if err := s.meta.RevokeAPIKey(r.Context(), scope.TenantID, resolved.APIKey.ID); err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			errJSON(w, http.StatusNotFound, "token not found or already revoked")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to revoke token")
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func (s *Server) handleTokenRefresh(w http.ResponseWriter, r *http.Request, scope *TenantScope) {
	start := time.Now()
	var req refreshTokenRequest
	if err := decodeJSONBody(w, r, &req, false); err != nil {
		errJSON(w, http.StatusBadRequest, err.Error())
		return
	}
	if req.TTLSeconds != nil {
		if *req.TTLSeconds == 0 {
			errJSON(w, http.StatusBadRequest, "ttl_seconds must be positive when provided")
			return
		}
		if *req.TTLSeconds < 0 {
			errJSON(w, http.StatusBadRequest, "ttl_seconds must be positive when provided")
			return
		}
		// Compare in seconds to avoid int64 Duration overflow (~292y).
		if *req.TTLSeconds > maxAPIKeyTTLSeconds {
			errJSON(w, http.StatusBadRequest, "ttl_seconds exceeds maximum allowed")
			return
		}
	}

	// Re-parse claims from the Bearer token (middleware already verified signature).
	rawBearer := bearerToken(r)
	claims, err := token.ParseAndVerifyToken(s.tokenSecret, rawBearer)
	if err != nil {
		errJSON(w, http.StatusUnauthorized, "invalid API key")
		return
	}

	// Uncached direct read — do not use ResolveByAPIKeyHash cache.
	row, err := s.meta.GetAPIKey(r.Context(), scope.TenantID, scope.APIKeyID)
	if err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			errJSON(w, http.StatusUnauthorized, "invalid API key")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to load token")
		return
	}
	if row.Status != meta.APIKeyActive {
		errJSON(w, http.StatusUnauthorized, "invalid API key")
		return
	}

	// Optimistic lock must use the caller's proven version, not the current DB
	// row. Within the auth-cache residual window a stale Bearer may still
	// authenticate while GetAPIKey already sees a newer generation; locking on
	// the DB version would let that stale caller clobber the successor.
	callerVersion := claims.TokenVersion
	if scope.TokenVersion > 0 {
		callerVersion = scope.TokenVersion
	}
	if callerVersion != row.TokenVersion {
		logger.Info(r.Context(), "server_event", eventFields(r.Context(), "token_refresh",
			"tenant_id", scope.TenantID, "token_id", scope.APIKeyID,
			"scope_kind", string(row.ScopeKind), "result", "conflict",
			"duration_ms", time.Since(start).Milliseconds())...)
		metricEvent(r.Context(), "token_refresh", "scope_kind", string(row.ScopeKind), "result", "conflict")
		errJSON(w, http.StatusConflict, "token refresh conflict")
		return
	}

	// Resolve effective expiry: expires_at column → JWT exp → none.
	// Versions match, so the row and claims describe the same generation.
	var effectiveExpiry *time.Time
	var effectiveIssuedAt time.Time
	if row.ExpiresAt != nil {
		t := row.ExpiresAt.UTC()
		effectiveExpiry = &t
		effectiveIssuedAt = row.IssuedAt.UTC()
	} else if claims.ExpiresAt > 0 {
		t := time.Unix(claims.ExpiresAt, 0).UTC()
		effectiveExpiry = &t
		if claims.IssuedAt > 0 {
			effectiveIssuedAt = time.Unix(claims.IssuedAt, 0).UTC()
		} else {
			effectiveIssuedAt = row.IssuedAt.UTC()
		}
	}

	isScoped := row.ScopeKind == meta.APIKeyScopeKindFS || scope.IsScoped || scope.ScopeKind == meta.APIKeyScopeKindFS
	if isScoped && effectiveExpiry == nil {
		errJSON(w, http.StatusBadRequest, "scoped token has no resolvable expiry; re-issue via owner")
		return
	}

	nowSec := time.Now().UTC().Truncate(time.Second)
	var period time.Duration
	var newExpiresAt *time.Time
	if req.TTLSeconds != nil {
		period = time.Duration(*req.TTLSeconds) * time.Second
	} else if effectiveExpiry != nil {
		// Default period = previous period. Do not clamp: scoped issue still
		// allows longer TTLs, and clamping then enforcing no-shortening would
		// permanently lock those keys out of refresh.
		period = effectiveExpiry.Sub(effectiveIssuedAt)
		if period <= 0 {
			errJSON(w, http.StatusBadRequest, "cannot derive refresh period from existing expiry")
			return
		}
	}
	// period == 0 and no effective expiry → owner no-expiry rotation.

	if period > 0 {
		exp := nowSec.Add(period)
		// No shortening: new expiry must not be earlier than the original
		// effective expiry. Equality is allowed so a same-second default
		// refresh (period unchanged, issued_at advanced) is not rejected.
		if effectiveExpiry != nil && exp.Before(*effectiveExpiry) {
			errJSON(w, http.StatusBadRequest, "refresh must not shorten expiry")
			return
		}
		newExpiresAt = &exp
	} else if isScoped {
		// Defensive: scoped must always get an exp.
		errJSON(w, http.StatusBadRequest, "scoped token requires an expiry")
		return
	}

	// Rotation always advances token_version monotonically (caller+1); the row
	// can never return to a historical version, so a residual-window stale
	// Bearer can never re-pass the version check.
	tokenVersion, err := nextTokenVersion(callerVersion)
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to refresh token")
		return
	}

	// Preserve journal permissions from the current claims.
	var journalPerms []string
	if len(claims.JournalPermissions) > 0 {
		journalPerms = append(journalPerms, claims.JournalPermissions...)
	}

	var jwtExpiresAt time.Time
	if newExpiresAt != nil {
		jwtExpiresAt = *newExpiresAt
	}
	rawToken, err := token.IssueTokenWithJournalPermissions(s.tokenSecret, scope.TenantID, tokenVersion, jwtExpiresAt, journalPerms)
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to issue token")
		return
	}
	cipherToken, err := s.pool.Encrypt(r.Context(), []byte(rawToken))
	if err != nil {
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to encrypt token")
		return
	}

	next := &meta.APIKey{
		JWTHash:       token.HashToken(rawToken),
		JWTCiphertext: cipherToken,
		TokenVersion:  tokenVersion,
		IssuedAt:      nowSec,
		ExpiresAt:     newExpiresAt,
		ScopeKind:     row.ScopeKind,
	}
	if err := s.meta.RotateAPIKey(r.Context(), scope.TenantID, scope.APIKeyID, callerVersion, next); err != nil {
		if errors.Is(err, meta.ErrNotFound) || errors.Is(err, meta.ErrAPIKeyVersionUnchanged) {
			logger.Info(r.Context(), "server_event", eventFields(r.Context(), "token_refresh",
				"tenant_id", scope.TenantID, "token_id", scope.APIKeyID,
				"scope_kind", string(row.ScopeKind), "result", "conflict",
				"duration_ms", time.Since(start).Milliseconds())...)
			metricEvent(r.Context(), "token_refresh", "scope_kind", string(row.ScopeKind), "result", "conflict")
			errJSON(w, http.StatusConflict, "token refresh conflict")
			return
		}
		errJSON(w, backendErrorStatus(r.Context(), err), "failed to refresh token")
		return
	}

	scopeKindLabel := string(row.ScopeKind)
	logger.Info(r.Context(), "server_event", eventFields(r.Context(), "token_refresh",
		"tenant_id", scope.TenantID, "token_id", scope.APIKeyID,
		"scope_kind", scopeKindLabel, "result", "ok",
		"duration_ms", time.Since(start).Milliseconds())...)
	metricEvent(r.Context(), "token_refresh", "scope_kind", scopeKindLabel, "result", "ok")

	writeJSON(w, http.StatusOK, refreshTokenResponse{
		Token:     rawToken,
		TokenID:   scope.APIKeyID,
		TenantID:  scope.TenantID,
		ScopeKind: scopeKindLabel,
		ExpiresAt: newExpiresAt,
	})
}

func validateScopedTokenScopes(reqScopes []fsTokenScopeRequest) ([]meta.APIKeyFSScope, error) {
	validated := make([]meta.APIKeyFSScope, 0, len(reqScopes))
	seenPrefix := make(map[string]bool, len(reqScopes))
	for _, scopeReq := range reqScopes {
		prefix, err := canonicalScopePrefix(scopeReq.Prefix)
		if err != nil {
			return nil, err
		}
		if seenPrefix[prefix] {
			return nil, fmt.Errorf("duplicate fs scope prefix %q", prefix)
		}
		seenPrefix[prefix] = true
		ops, err := canonicalScopeTerms(scopeReq.Ops)
		if err != nil {
			return nil, err
		}
		validated = append(validated, meta.APIKeyFSScope{Prefix: prefix, Ops: ops})
	}
	if _, err := fsPolicyFromMeta(validated); err != nil {
		return nil, err
	}
	return validated, nil
}

func canonicalScopePrefix(raw string) (string, error) {
	if strings.TrimSpace(raw) == "" {
		return "", fmt.Errorf("fs scope prefix is required")
	}
	if strings.TrimSpace(raw) == ":" {
		return "", fmt.Errorf("fs scope prefix is required")
	}
	prefix, err := normalizeFSAuthorizationPath(raw)
	if err != nil {
		return "", fmt.Errorf("invalid fs scope prefix: %w", err)
	}
	return prefix, nil
}

func canonicalScopeTerms(raw []string) (string, error) {
	seen := make(map[FSOp]bool)
	pseudoRoot := false
	for _, part := range raw {
		term := strings.TrimSpace(part)
		if term == fsScopeDirectivePseudoRoot {
			pseudoRoot = true
			continue
		}
		op := FSOp(term)
		if op == "" {
			return "", fmt.Errorf("empty fs scope op")
		}
		if !isKnownFSOp(op) {
			return "", fmt.Errorf("unknown fs scope op %q", op)
		}
		seen[op] = true
	}
	if len(seen) == 0 && !pseudoRoot {
		return "", fmt.Errorf("empty fs scope ops")
	}
	if seen[FSOpSearch] && !seen[FSOpRead] {
		return "", fmt.Errorf("search fs scope requires read")
	}
	ordered := make([]string, 0, len(seen))
	if pseudoRoot {
		ordered = append(ordered, fsScopeDirectivePseudoRoot)
	}
	for _, op := range []FSOp{FSOpRead, FSOpList, FSOpSearch, FSOpWrite, FSOpDelete} {
		if seen[op] {
			ordered = append(ordered, string(op))
		}
	}
	return strings.Join(ordered, ","), nil
}

func fsScopeResponses(rows []meta.APIKeyFSScope) []fsTokenScopeRequest {
	out := make([]fsTokenScopeRequest, 0, len(rows))
	for _, row := range rows {
		ops := make([]string, 0)
		for _, op := range strings.Split(row.Ops, ",") {
			op = strings.TrimSpace(op)
			if op != "" {
				ops = append(ops, op)
			}
		}
		out = append(out, fsTokenScopeRequest{Prefix: row.Prefix, Ops: ops})
	}
	return out
}

func newScopedTokenVersion() (int, error) {
	n, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt32))
	if err != nil {
		return 0, err
	}
	return int(n.Int64()) + 1, nil
}

// nextTokenVersion returns oldVersion + 1. Rotation is strictly monotonic per
// key row: a historical token_version must never reappear on the live row, or
// a stale Bearer minted for that historical version (still signature-valid
// until its own exp) would re-pass the caller-version conflict check and
// clobber the successor. Overflow at MaxInt32 fails closed: refusing the
// rotation is safe; wrapping would reintroduce reuse.
func nextTokenVersion(oldVersion int) (int, error) {
	if oldVersion >= math.MaxInt32 {
		return 0, fmt.Errorf("token_version overflow at %d", oldVersion)
	}
	return oldVersion + 1, nil
}
