package permissions

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"sync"

	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/fieldpermissions"
	"github.com/nexuscrm/shared/pkg/models"
)

// access_input_cache.go
//
// Request-scoped, fail-closed memoization layer for the INPUTS to record-level
// access decisions (issue #1585).
//
// It does NOT make access decisions: checkRecordAccessInternal remains the
// single decision path / source of truth. This layer only batches and caches
// the per-record database lookups that decision path performs — group
// membership, record-owner role, manual record shares, team membership, and
// effective object/field permissions.
// Share/team grants are cached only when a caller primes the cache with the
// current candidate record set; unprimed single-record reads keep the point
// lookup path.
//
// Correctness guarantees:
//   - One decision path: the cached helpers return the SAME primitive inputs the
//     per-record queries would return; checkRecordAccessInternal is unchanged.
//   - Fail-closed: share/team input errors retain their existing point-query
//     fallback. A bounded field-permission projection error is returned to the
//     caller and never expands into per-field queries. A miss never grants
//     access on its own.
//   - Request snapshot: each cache belongs to one request-scoped permission
//     subject and is populated lazily on first use per object. It is strictly fresher
//     than the existing process-wide sharing-rule cache (5-minute TTL), so it
//     introduces no new staleness window for steady-state reads.
//
// The manual-share / team-member cache preserves the exact authored access
// levels for the scoped record IDs. Read/Edit/Delete therefore consume the same
// primitive inputs as the point path without re-querying those inputs once per
// operation.

type accessInputCacheKey struct{}

var errAccessInputCachePermissionIdentityMismatch = errors.New("request permission cache identity mismatch")

// Keep ordinary one- and two-object requests on bounded point lookups. A third
// distinct object indicates a fanout surface such as a metadata catalog, where
// one request-scoped bulk projection is cheaper than continuing point queries.
const objectPermissionBulkLoadThreshold = 2

type accessInputCache struct {
	mu sync.Mutex

	// userID this cache was populated for. userBound is separate because the
	// empty string is still an identity value and must not act as an unbound
	// sentinel. Each cache is scoped to one permission subject. Authorized
	// administrative target reads use a separate child cache; a stray memberID
	// must never read another subject's cached inputs.
	userID    string
	userBound bool

	// Effective system permissions (ModifyAllData/ViewAllData/…) for the session
	// user+profile. Checked twice per CheckRecordAccess (system-level overrides),
	// so memoizing it collapses an O(records) GetEffectiveSystemPermissions blast
	// to one load per request.
	sysPerms           map[string]bool
	sysPermsProfile    string
	sysPermsLoaded     bool
	customPerms        map[string]bool
	customPermsProfile string
	customPermsLoaded  bool
	userGroupIDs       []string
	userGroupLoaded    bool
	userGroupLoading   chan struct{}

	objPerms              map[string]*models.ObjectPermissions
	objPermsProfile       string
	objPermsBound         bool
	objPermPointLoads     int
	objPermsBulkAttempted bool
	objPermsBulkLoaded    bool
	objPermsLoading       map[string]chan struct{}
	objPermsBulkLoading   chan struct{}

	fieldPerms        map[string]*models.FieldPermissions
	fieldPermsProfile string
	fieldPermsBound   bool
	fieldPermsLoaded  map[string]bool          // object cache key -> completed bounded projection
	fieldPermsErrors  map[string]error         // object cache key -> terminal fail-closed result for this request
	fieldPermsLoading map[string]chan struct{} // object cache key -> in-flight bounded projection

	ownerRole       map[string]*string // ownerID -> roleID
	ownerRoleLoaded map[string]bool

	// Point-memoized group membership keyed by (groupID, memberID).
	memberGroup map[memberGroupKey]bool

	manualShareLevels       map[string]map[string][]string // objectAPIName -> recordID -> exact levels
	manualShareLevelsLoaded map[string]map[string]struct{} // objectAPIName -> scoped recordIDs loaded
	teamMemberLevels        map[string]map[string]string   // objectAPIName -> recordID -> exact level
	teamMemberLevelsLoaded  map[string]map[string]struct{} // objectAPIName -> scoped recordIDs loaded
}

// memberGroupKey identifies a single (group, member) membership question for the
// point-memoization path used by non-session members.
type memberGroupKey struct {
	groupID  string
	memberID string
}

// WithAccessInputCache installs an empty request-scoped access-input cache on
// the context if one is not already present. Safe to call more than once. Wire
// this once per request (e.g. in the permission-loader middleware) so all
// downstream record-access checks within the request share one snapshot.
func WithAccessInputCache(ctx context.Context) context.Context {
	if ctx.Value(accessInputCacheKey{}) != nil {
		return ctx
	}
	return withFreshAccessInputCache(ctx)
}

// withFreshAccessInputCache starts a separate permission subject scope without
// changing the caller's cache or dropping request cancellation and metadata
// authority. Only an authorized subject transition should replace an existing
// cache; ordinary reads must retain WithAccessInputCache's shared snapshot.
func withFreshAccessInputCache(ctx context.Context) context.Context {
	return context.WithValue(ctx, accessInputCacheKey{}, &accessInputCache{
		objPerms:                map[string]*models.ObjectPermissions{},
		objPermsLoading:         map[string]chan struct{}{},
		fieldPerms:              map[string]*models.FieldPermissions{},
		fieldPermsLoaded:        map[string]bool{},
		fieldPermsErrors:        map[string]error{},
		fieldPermsLoading:       map[string]chan struct{}{},
		ownerRole:               map[string]*string{},
		ownerRoleLoaded:         map[string]bool{},
		memberGroup:             map[memberGroupKey]bool{},
		manualShareLevels:       map[string]map[string][]string{},
		manualShareLevelsLoaded: map[string]map[string]struct{}{},
		teamMemberLevels:        map[string]map[string]string{},
		teamMemberLevelsLoaded:  map[string]map[string]struct{}{},
	})
}

// WithEffectiveRecordReadProjection binds one complete, revision-checked
// effective-permission projection for objectAPIName to the current request.
// The caller remains responsible for proving the projection's Catalog and
// Permission State authority before calling this helper. Record-level sharing,
// approval locks, and lock rules are intentionally not projected here and keep
// using their existing authoritative decision paths.
//
// This is request-local input reuse, not a permission decision cache: the
// existing PermissionService remains the sole CRUD/FLS decision owner.
func WithEffectiveRecordReadProjection(
	ctx context.Context,
	userID, profileID, objectAPIName string,
	systemPerms, customPerms map[string]bool,
	objectPerm models.ObjectPermissions,
	fieldPerms []models.FieldPermissions,
) (context.Context, error) {
	userID = strings.TrimSpace(userID)
	profileID = strings.TrimSpace(profileID)
	objectAPIName = strings.TrimSpace(objectAPIName)
	if userID == "" || profileID == "" || objectAPIName == "" ||
		!strings.EqualFold(objectPerm.SobjectType, objectAPIName) {
		return ctx, errAccessInputCachePermissionIdentityMismatch
	}

	ctx = WithAccessInputCache(ctx)
	ctx = WithEffectivePermissions(ctx, userID, profileID, systemPerms, customPerms)
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return ctx, errAccessInputCachePermissionIdentityMismatch
	}

	objectKey := effectiveFieldPermissionObjectCacheKey(objectAPIName)
	loadedFields := make(map[string]*models.FieldPermissions, len(fieldPerms))
	for i := range fieldPerms {
		permission := fieldPerms[i]
		if !strings.EqualFold(permission.SobjectType, objectAPIName) {
			return ctx, errAccessInputCachePermissionIdentityMismatch
		}
		key := effectiveFieldPermissionCacheKey(objectAPIName, permission.Field)
		if key == "" {
			return ctx, errAccessInputCachePermissionIdentityMismatch
		}
		copy := permission
		loadedFields[key] = &copy
	}

	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) ||
		(c.objPermsBound && c.objPermsProfile != profileID) ||
		(c.fieldPermsBound && c.fieldPermsProfile != profileID) {
		return ctx, errAccessInputCachePermissionIdentityMismatch
	}
	c.objPermsBound = true
	c.objPermsProfile = profileID
	if c.objPerms == nil {
		c.objPerms = map[string]*models.ObjectPermissions{}
	}
	objectCopy := objectPerm
	c.objPerms[objectKey] = &objectCopy

	c.fieldPermsBound = true
	c.fieldPermsProfile = profileID
	if c.fieldPerms == nil {
		c.fieldPerms = map[string]*models.FieldPermissions{}
	}
	if c.fieldPermsLoaded == nil {
		c.fieldPermsLoaded = map[string]bool{}
	}
	if c.fieldPermsErrors == nil {
		c.fieldPermsErrors = map[string]error{}
	}
	delete(c.fieldPermsErrors, objectKey)
	for key, permission := range loadedFields {
		c.fieldPerms[key] = permission
	}
	c.fieldPermsLoaded[objectKey] = true
	return ctx, nil
}

func accessInputCacheFromCtx(ctx context.Context) *accessInputCache {
	c, _ := ctx.Value(accessInputCacheKey{}).(*accessInputCache)
	return c
}

// bindUserLocked returns false if the cache is already bound to a different
// session user (caller must bypass the cache and use the per-record path).
// Must be called with c.mu held.
func (c *accessInputCache) bindUserLocked(userID string) bool {
	if !c.userBound {
		c.userID = userID
		c.userBound = true
		return true
	}
	return c.userID == userID
}

// isUserInGroupForSession answers group membership through point memoization.
// Keeping this keyed by the exact (groupID, memberID) question prevents a
// single-record read from expanding into a full user-group preload.
func (ps *PermissionService) isUserInGroupForSession(ctx context.Context, groupID, memberID, sessionUserID string) bool {
	if groupID == "" || memberID == "" {
		return false
	}
	if memberID == sessionUserID {
		if in, loaded := preloadedSessionGroupMembership(ctx, groupID, memberID); loaded {
			return in
		}
	}
	return ps.cachedIsMemberOfGroup(ctx, groupID, memberID)
}

// preloadedSessionGroupMembership answers only from an already-completed
// request-scoped GetUserGroupIDs projection. It never initiates I/O: ordinary
// point reads retain their one-question path, while aggregate access callers
// can explicitly choose one bounded projection before evaluating many sharing
// rules.
func preloadedSessionGroupMembership(ctx context.Context, groupID, memberID string) (bool, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil || groupID == "" || memberID == "" {
		return false, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(memberID) || !c.userGroupLoaded {
		return false, false
	}
	for _, candidate := range c.userGroupIDs {
		if candidate == groupID {
			return true, true
		}
	}
	return false, true
}

// cachedIsMemberOfGroup point-memoizes a single (groupID, memberID) membership
// result within the request, covering members other than the session user — e.g.
// the record owner during sharing-rule source evaluation, which is checked once
// per record across a list/hydration page. With no cache installed, or on a load
// error, it falls back to the per-call repository query (fail-closed: a miss or
// error never grants access). Transient errors are not cached.
func (ps *PermissionService) cachedIsMemberOfGroup(ctx context.Context, groupID, memberID string) bool {
	if groupID == "" || memberID == "" {
		return false
	}
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		in, err := ps.repo.IsUserInGroup(ctx, groupID, memberID)
		return err == nil && in
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	key := memberGroupKey{groupID: groupID, memberID: memberID}
	if in, ok := c.memberGroup[key]; ok {
		return in
	}
	in, err := ps.repo.IsUserInGroup(ctx, groupID, memberID)
	if err != nil {
		return false
	}
	c.memberGroup[key] = in
	return in
}

type manualShareLevelBatchLoader interface {
	GetManualShareAccessLevelsByRecordIDs(ctx context.Context, objectAPIName, userID string, recordIDs []string) (map[string][]string, error)
}

type teamMemberLevelBatchLoader interface {
	GetTeamMemberAccessLevelsByRecordIDs(ctx context.Context, objectAPIName, userID string, recordIDs []string) (map[string]string, error)
}

// PrimeRecordAccessInputCache preloads exact manual-share and team-member access
// levels for the current candidate records. It deliberately scopes database
// work to recordIDs from the supplied records; it never loads every shared
// record for an object. Missing rows are cached as deny for this candidate set
// so the downstream Read/Edit/Delete decisions can avoid point lookups without
// changing checkRecordAccessInternal's authority.
func (ps *PermissionService) PrimeRecordAccessInputCache(ctx context.Context, objectAPIName string, records []models.SObject, user *models.UserSession) {
	if ps == nil || ps.repo == nil || user == nil || objectAPIName == "" || len(records) == 0 {
		return
	}
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return
	}

	recordIDs := uniqueRecordIDs(records)
	if len(recordIDs) == 0 {
		return
	}

	manualMissing, teamMissing := c.missingGrantLevelRecordIDs(objectAPIName, user.ID, recordIDs)
	if len(manualMissing) > 0 {
		if loader, ok := ps.repo.(manualShareLevelBatchLoader); ok {
			if levelsByID, err := loader.GetManualShareAccessLevelsByRecordIDs(ctx, objectAPIName, user.ID, manualMissing); err == nil {
				c.storeManualShareLevels(objectAPIName, user.ID, manualMissing, levelsByID)
			}
		}
	}
	if len(teamMissing) > 0 {
		if loader, ok := ps.repo.(teamMemberLevelBatchLoader); ok {
			if levelsByID, err := loader.GetTeamMemberAccessLevelsByRecordIDs(ctx, objectAPIName, user.ID, teamMissing); err == nil {
				c.storeTeamMemberLevels(objectAPIName, user.ID, teamMissing, levelsByID)
			}
		}
	}
}

// PrimeReadAccessInputCache is retained as the compatibility spelling used by
// existing list/read callers. The cached primitive is now the exact grant
// level, so later complete access projections in the same request can reuse it.
func (ps *PermissionService) PrimeReadAccessInputCache(ctx context.Context, objectAPIName string, records []models.SObject, user *models.UserSession) {
	ps.PrimeRecordAccessInputCache(ctx, objectAPIName, records, user)
}

func uniqueRecordIDs(records []models.SObject) []string {
	seen := make(map[string]struct{}, len(records))
	ids := make([]string, 0, len(records))
	for _, record := range records {
		id := recordStringValue(record[constants.FieldID])
		if id == "" {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		ids = append(ids, id)
	}
	return ids
}

func (c *accessInputCache) missingGrantLevelRecordIDs(objectAPIName, userID string, recordIDs []string) ([]string, []string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return nil, nil
	}

	manualLoaded := c.manualShareLevelsLoaded[objectAPIName]
	teamLoaded := c.teamMemberLevelsLoaded[objectAPIName]
	manualMissing := make([]string, 0, len(recordIDs))
	teamMissing := make([]string, 0, len(recordIDs))
	for _, recordID := range recordIDs {
		if _, ok := manualLoaded[recordID]; !ok {
			manualMissing = append(manualMissing, recordID)
		}
		if _, ok := teamLoaded[recordID]; !ok {
			teamMissing = append(teamMissing, recordID)
		}
	}
	return manualMissing, teamMissing
}

func (c *accessInputCache) storeManualShareLevels(objectAPIName, userID string, recordIDs []string, levelsByID map[string][]string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return
	}
	if c.manualShareLevels == nil {
		c.manualShareLevels = map[string]map[string][]string{}
	}
	if c.manualShareLevelsLoaded == nil {
		c.manualShareLevelsLoaded = map[string]map[string]struct{}{}
	}
	if c.manualShareLevels[objectAPIName] == nil {
		c.manualShareLevels[objectAPIName] = map[string][]string{}
	}
	if c.manualShareLevelsLoaded[objectAPIName] == nil {
		c.manualShareLevelsLoaded[objectAPIName] = map[string]struct{}{}
	}
	for _, recordID := range recordIDs {
		c.manualShareLevels[objectAPIName][recordID] = append([]string(nil), levelsByID[recordID]...)
		c.manualShareLevelsLoaded[objectAPIName][recordID] = struct{}{}
	}
}

func (c *accessInputCache) storeTeamMemberLevels(objectAPIName, userID string, recordIDs []string, levelsByID map[string]string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return
	}
	if c.teamMemberLevels == nil {
		c.teamMemberLevels = map[string]map[string]string{}
	}
	if c.teamMemberLevelsLoaded == nil {
		c.teamMemberLevelsLoaded = map[string]map[string]struct{}{}
	}
	if c.teamMemberLevels[objectAPIName] == nil {
		c.teamMemberLevels[objectAPIName] = map[string]string{}
	}
	if c.teamMemberLevelsLoaded[objectAPIName] == nil {
		c.teamMemberLevelsLoaded[objectAPIName] = map[string]struct{}{}
	}
	for _, recordID := range recordIDs {
		c.teamMemberLevels[objectAPIName][recordID] = levelsByID[recordID]
		c.teamMemberLevelsLoaded[objectAPIName][recordID] = struct{}{}
	}
}

func (ps *PermissionService) cachedManualShareAccess(ctx context.Context, objectAPIName, userID, recordID, operation string) (bool, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return false, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return false, false
	}
	if _, ok := c.manualShareLevelsLoaded[objectAPIName][recordID]; !ok {
		return false, false
	}
	for _, level := range c.manualShareLevels[objectAPIName][recordID] {
		if ps.accessLevelAllowsOperation(level, operation) {
			return true, true
		}
	}
	return false, true
}

func (ps *PermissionService) cachedTeamMemberAccess(ctx context.Context, objectAPIName, userID, recordID, operation string) (bool, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return false, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return false, false
	}
	if _, ok := c.teamMemberLevelsLoaded[objectAPIName][recordID]; !ok {
		return false, false
	}
	return ps.accessLevelAllowsOperation(c.teamMemberLevels[objectAPIName][recordID], operation), true
}

// cachedOwnerRoleID memoizes ownerID -> roleID within the request. Returns
// (roleID, true) on a cache hit; (nil, false) ⇒ caller queries and may store.
func (ps *PermissionService) cachedOwnerRoleID(ctx context.Context, ownerID string) (*string, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return nil, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.ownerRoleLoaded[ownerID] {
		return c.ownerRole[ownerID], true
	}
	return nil, false
}

// cachedSystemPermissions returns (effectiveSystemPerms, true) for the session
// user+profile, loading it once via GetEffectiveSystemPermissions. (nil, false)
// ⇒ no request cache / different user / load error → caller queries directly.
func (ps *PermissionService) cachedSystemPermissions(ctx context.Context, userID, profileID string) (map[string]bool, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil || ps.repo == nil {
		return nil, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return nil, false
	}
	if c.sysPermsLoaded {
		if c.sysPermsProfile != profileID {
			return nil, false
		}
		return c.sysPerms, true
	}
	perms, err := ps.repo.GetEffectiveSystemPermissions(ctx, userID, profileID)
	if err != nil {
		return nil, false
	}
	c.sysPerms = perms
	c.sysPermsProfile = profileID
	c.sysPermsLoaded = true
	return perms, true
}

func (ps *PermissionService) cachedEffectiveObjectPermission(ctx context.Context, user *models.UserSession, objectAPIName string) (*models.ObjectPermissions, bool, error) {
	if user == nil {
		return nil, false, nil
	}
	c := accessInputCacheFromCtx(ctx)
	if c == nil || ps.repo == nil {
		return nil, false, nil
	}
	key := strings.ToLower(objectAPIName)
	for {
		c.mu.Lock()
		if !c.bindUserLocked(user.ID) || (c.objPermsBound && c.objPermsProfile != user.ProfileID) {
			c.mu.Unlock()
			return nil, true, errAccessInputCachePermissionIdentityMismatch
		}
		if !c.objPermsBound {
			c.objPermsProfile = user.ProfileID
			c.objPermsBound = true
		}
		if c.objPerms == nil {
			c.objPerms = map[string]*models.ObjectPermissions{}
		}
		if c.objPermsLoading == nil {
			c.objPermsLoading = map[string]chan struct{}{}
		}
		if perm, ok := c.objPerms[key]; ok {
			c.mu.Unlock()
			return perm, true, nil
		}
		if c.objPermsBulkLoaded {
			c.mu.Unlock()
			return nil, true, nil
		}
		if ready := c.objPermsBulkLoading; ready != nil {
			c.mu.Unlock()
			select {
			case <-ready:
				continue
			case <-ctx.Done():
				return nil, true, ctx.Err()
			}
		}
		if ready := c.objPermsLoading[key]; ready != nil {
			c.mu.Unlock()
			select {
			case <-ready:
				continue
			case <-ctx.Done():
				return nil, true, ctx.Err()
			}
		}

		doBulk := !c.objPermsBulkAttempted && c.objPermPointLoads >= objectPermissionBulkLoadThreshold
		ready := make(chan struct{})
		if doBulk {
			c.objPermsBulkAttempted = true
			c.objPermsBulkLoading = ready
		} else {
			c.objPermsLoading[key] = ready
		}
		c.mu.Unlock()

		if doBulk {
			perms, err := ps.repo.ListEffectiveObjectPermissionsForUser(ctx, user.ID, user.ProfileID)
			c.mu.Lock()
			if err == nil {
				for i := range perms {
					rowKey := strings.ToLower(perms[i].SobjectType)
					if _, loaded := c.objPerms[rowKey]; loaded {
						continue
					}
					perm := perms[i]
					c.objPerms[rowKey] = &perm
				}
				c.objPermsBulkLoaded = true
			}
			c.objPermsBulkLoading = nil
			close(ready)
			c.mu.Unlock()
			if err != nil {
				return nil, true, err
			}
			continue
		}

		perm, err := ps.repo.LoadEffectiveObjectPermission(ctx, user, objectAPIName)
		c.mu.Lock()
		if err == nil {
			c.objPermPointLoads++
			c.objPerms[key] = perm
		}
		delete(c.objPermsLoading, key)
		close(ready)
		c.mu.Unlock()
		return perm, true, err
	}
}

func (ps *PermissionService) cachedEffectiveFieldPermission(ctx context.Context, user *models.UserSession, objectAPIName, fieldAPIName string) (*models.FieldPermissions, bool, error) {
	if user == nil {
		return nil, false, nil
	}
	c := accessInputCacheFromCtx(ctx)
	if c == nil || ps == nil || ps.repo == nil {
		return nil, false, nil
	}
	key := effectiveFieldPermissionCacheKey(objectAPIName, fieldAPIName)
	if key == "" {
		return nil, false, nil
	}
	objectKey := effectiveFieldPermissionObjectCacheKey(objectAPIName)
	if objectKey == "" {
		return nil, false, nil
	}

	for {
		c.mu.Lock()
		if !c.bindUserLocked(user.ID) {
			c.mu.Unlock()
			return nil, true, errAccessInputCachePermissionIdentityMismatch
		}
		if c.fieldPermsBound && c.fieldPermsProfile != user.ProfileID {
			c.mu.Unlock()
			return nil, true, errAccessInputCachePermissionIdentityMismatch
		}
		if c.fieldPermsLoaded == nil {
			c.fieldPermsLoaded = map[string]bool{}
		}
		if c.fieldPermsLoading == nil {
			c.fieldPermsLoading = map[string]chan struct{}{}
		}
		if terminalErr, failed := c.fieldPermsErrors[objectKey]; failed {
			c.mu.Unlock()
			return nil, true, terminalErr
		}
		if c.fieldPermsLoaded[objectKey] {
			perm := cloneFieldPermission(c.fieldPerms[key])
			c.mu.Unlock()
			return perm, true, nil
		}
		if ready, loading := c.fieldPermsLoading[objectKey]; loading {
			c.mu.Unlock()
			select {
			case <-ready:
				continue
			case <-ctx.Done():
				return nil, true, ctx.Err()
			}
		}

		ready := make(chan struct{})
		if !c.fieldPermsBound {
			c.fieldPermsProfile = user.ProfileID
			c.fieldPermsBound = true
		}
		c.fieldPermsLoading[objectKey] = ready
		c.mu.Unlock()

		perms, err := ps.loadEffectiveFieldPermissionsForRequestObject(ctx, user.ID, user.ProfileID, objectAPIName)
		loaded := make(map[string]*models.FieldPermissions, len(perms))
		if err == nil {
			for i := range perms {
				perm := perms[i]
				if effectiveFieldPermissionObjectCacheKey(perm.SobjectType) != objectKey {
					continue
				}
				rowKey := effectiveFieldPermissionCacheKey(perm.SobjectType, perm.Field)
				if rowKey == "" {
					continue
				}
				cp := perm
				loaded[rowKey] = &cp
			}
		}

		c.mu.Lock()
		identityMatches := c.bindUserLocked(user.ID) && c.fieldPermsBound && c.fieldPermsProfile == user.ProfileID
		if identityMatches && err != nil {
			c.fieldPermsErrors[objectKey] = err
		}
		if identityMatches && err == nil {
			c.fieldPermsLoaded[objectKey] = true
			if c.fieldPerms == nil {
				c.fieldPerms = map[string]*models.FieldPermissions{}
			}
			for rowKey, perm := range loaded {
				c.fieldPerms[rowKey] = perm
			}
		}
		delete(c.fieldPermsLoading, objectKey)
		close(ready)
		if !identityMatches {
			c.mu.Unlock()
			return nil, true, errAccessInputCachePermissionIdentityMismatch
		}
		if err != nil {
			c.mu.Unlock()
			return nil, true, err
		}
		perm := cloneFieldPermission(c.fieldPerms[key])
		c.mu.Unlock()
		return perm, true, nil
	}
}

func (ps *PermissionService) loadEffectiveFieldPermissionsForRequestObject(ctx context.Context, userID, profileID, objectAPIName string) (perms []models.FieldPermissions, err error) {
	defer func() {
		if recovered := recover(); recovered != nil {
			perms = nil
			err = fmt.Errorf("bounded effective field permission projection panicked: %v", recovered)
		}
	}()
	return ps.loadFieldPermissionInputs(ctx, userID, profileID, []string{objectAPIName})
}

func effectiveFieldPermissionObjectCacheKey(objectAPIName string) string {
	return strings.ToLower(strings.TrimSpace(objectAPIName))
}

func effectiveFieldPermissionCacheKey(defaultObjectAPIName, rawField string) string {
	objectAPIName, fieldAPIName := fieldpermissions.SplitFieldReference(defaultObjectAPIName, rawField)
	objectAPIName = strings.ToLower(strings.TrimSpace(objectAPIName))
	fieldAPIName = strings.ToLower(strings.TrimSpace(fieldAPIName))
	if objectAPIName == "" || fieldAPIName == "" {
		return ""
	}
	return objectAPIName + "\x00" + fieldAPIName
}

func cloneFieldPermission(perm *models.FieldPermissions) *models.FieldPermissions {
	if perm == nil {
		return nil
	}
	cp := *perm
	return &cp
}

// cachedCustomPermissions mirrors cachedSystemPermissions for policy-level
// custom permissions used by standard action guards and $Permission formulas.
func (ps *PermissionService) cachedCustomPermissions(ctx context.Context, userID, profileID string) (map[string]bool, bool) {
	if perms, ok := customPermissionsFromContext(ctx, userID, profileID); ok {
		return perms, true
	}
	c := accessInputCacheFromCtx(ctx)
	if c == nil || ps.repo == nil {
		return nil, false
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.bindUserLocked(userID) {
		return nil, false
	}
	if c.customPermsLoaded {
		if c.customPermsProfile != profileID {
			return nil, false
		}
		return c.customPerms, true
	}
	perms, err := ps.repo.GetEffectiveCustomPermissions(ctx, userID, profileID)
	if err != nil {
		return nil, false
	}
	c.customPerms = perms
	c.customPermsProfile = profileID
	c.customPermsLoaded = true
	return perms, true
}

// cachedUserGroupIDs returns the session user's direct and transitive group IDs,
// loading them at most once per request. It is scoped to the session user only:
// a different userID on the same request bypasses the cache fail-closed.
func (ps *PermissionService) cachedUserGroupIDs(ctx context.Context, userID string) ([]string, bool) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil || ps.repo == nil || userID == "" {
		return nil, false
	}
	for {
		c.mu.Lock()
		if !c.bindUserLocked(userID) {
			c.mu.Unlock()
			return nil, false
		}
		if c.userGroupLoaded {
			groupIDs := append([]string(nil), c.userGroupIDs...)
			c.mu.Unlock()
			return groupIDs, true
		}
		if ready := c.userGroupLoading; ready != nil {
			c.mu.Unlock()
			select {
			case <-ready:
				continue
			case <-ctx.Done():
				return nil, false
			}
		}
		ready := make(chan struct{})
		c.userGroupLoading = ready
		c.mu.Unlock()

		groupIDs, err := ps.repo.GetUserGroupIDs(ctx, userID)
		c.mu.Lock()
		if err == nil {
			c.userGroupIDs = append([]string(nil), groupIDs...)
			c.userGroupLoaded = true
		}
		c.userGroupLoading = nil
		close(ready)
		c.mu.Unlock()
		if err != nil {
			return nil, false
		}
		return append([]string(nil), groupIDs...), true
	}
}

func (ps *PermissionService) storeOwnerRoleID(ctx context.Context, ownerID string, roleID *string) {
	c := accessInputCacheFromCtx(ctx)
	if c == nil {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.ownerRole[ownerID] = roleID
	c.ownerRoleLoaded[ownerID] = true
}
