package permissions

import (
	"context"
	stderrors "errors"
	"strings"
	"testing"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// pcMetadataStub implements metadataProvider for the parent-controlled
// delegation tests. Holds multiple schemas keyed by APIName plus the
// sharing-parent relationship and parent-controlled flag overrides for each.
type pcMetadataStub struct {
	schemas       map[string]*models.ObjectMetadata
	sharingParent map[string]*models.Relationship // child API name → relationship
	// parentControlled overrides the schema-derived predicate when non-empty
	// — for tests that need a malformed schema (SharingModel set but no
	// relationship) the entry should be absent here so the helper returns "".
	parentControlled map[string]string
}

func (m *pcMetadataStub) GetSchema(_ context.Context, apiName string) *models.ObjectMetadata {
	if m == nil {
		return nil
	}
	return m.schemas[apiName]
}

func (m *pcMetadataStub) GetSchemas(context.Context) []*models.ObjectMetadata {
	out := make([]*models.ObjectMetadata, 0, len(m.schemas))
	for _, s := range m.schemas {
		out = append(out, s)
	}
	return out
}

func (m *pcMetadataStub) GetRelationships(context.Context, string) []*models.Relationship {
	return nil
}

func (m *pcMetadataStub) GetSharingParentRelationship(_ context.Context, childAPI string) *models.Relationship {
	if m == nil {
		return nil
	}
	return m.sharingParent[childAPI]
}

// GetParentControlledObject mirrors *MetadataService.GetParentControlledObject:
// SharingModel == ControlledByParent AND a sharing-parent relationship row.
func (m *pcMetadataStub) GetParentControlledObject(_ context.Context, schema *models.ObjectMetadata) string {
	if m == nil || schema == nil {
		return ""
	}
	// Allow explicit overrides for tests.
	if name, ok := m.parentControlled[schema.APIName]; ok {
		return name
	}
	if schema.SharingModel != constants.SharingModelControlledByParent {
		return ""
	}
	rel := m.sharingParent[schema.APIName]
	if rel == nil {
		return ""
	}
	return rel.ParentObjectAPIName
}

func (m *pcMetadataStub) GetSharingRules(context.Context, string) []*models.SharingRule {
	return nil
}

func (m *pcMetadataStub) GetRecordTypes(context.Context, string) ([]*models.RecordType, error) {
	return nil, nil
}

// pcRepo extends saveTestRepo with per-object effective object permissions
// and an optional parent record. Spy count records GetRecordByID invocations
// and UpsertObjectPermission invocations.
type pcRepo struct {
	saveTestRepo
	objPerms        map[string]*models.ObjectPermissions // keyed by object APIName
	systemPerms     map[string]bool
	effectiveList   []models.ObjectPermissions
	profileID       string
	records         map[string]models.SObject // keyed by `<APIName>:<ID>`
	manualShares    map[string][]string       // keyed by `<APIName>:<ID>:<UserID>`
	getByIDCalls    int
	upsertCalls     int
	capturedUpserts []models.ObjectPermissions
}

// UpsertObjectPermission records the call for parent-controlled write-guard tests.
func (r *pcRepo) UpsertObjectPermission(_ context.Context, perm models.ObjectPermissions) error {
	if r == nil {
		return nil
	}
	r.upsertCalls++
	r.capturedUpserts = append(r.capturedUpserts, perm)
	return nil
}

func (r *pcRepo) LoadEffectiveObjectPermission(_ context.Context, _ *models.UserSession, apiName string) (*models.ObjectPermissions, error) {
	if r == nil {
		return nil, nil
	}
	if p, ok := r.objPerms[apiName]; ok {
		return p, nil
	}
	lowerAPI := strings.ToLower(apiName)
	for key, p := range r.objPerms {
		if strings.ToLower(key) == lowerAPI {
			return p, nil
		}
	}
	return nil, nil
}

func (r *pcRepo) ListEffectiveObjectPermissionsForUser(context.Context, string, string) ([]models.ObjectPermissions, error) {
	if r == nil {
		return nil, nil
	}
	out := make([]models.ObjectPermissions, len(r.effectiveList))
	copy(out, r.effectiveList)
	return out, nil
}

func (r *pcRepo) GetUserProfileID(context.Context, string) (string, error) {
	if r == nil || r.profileID == "" {
		return "p1", nil
	}
	return r.profileID, nil
}

func (r *pcRepo) GetRecordByID(_ context.Context, table, id string) (models.SObject, error) {
	r.getByIDCalls++
	if r == nil || r.records == nil {
		return nil, nil
	}
	if rec, ok := r.records[table+":"+id]; ok {
		return rec, nil
	}
	return nil, nil
}

func (r *pcRepo) GetManualShareAccessLevels(_ context.Context, objectAPIName, recordID, userID string) ([]string, error) {
	if r == nil || r.manualShares == nil {
		return nil, nil
	}
	levels := r.manualShares[objectAPIName+":"+recordID+":"+userID]
	out := make([]string, len(levels))
	copy(out, levels)
	return out, nil
}

func (r *pcRepo) GetEffectiveSystemPermissions(context.Context, string, string) (map[string]bool, error) {
	if r == nil || r.systemPerms == nil {
		return map[string]bool{}, nil
	}
	out := make(map[string]bool, len(r.systemPerms))
	for key, value := range r.systemPerms {
		out[key] = value
	}
	return out, nil
}

func makePCService(repo ports.PermissionRepository, md metadataProvider) *PermissionService {
	return NewPermissionService(repo, md, nil)
}

func objectPermByType(perms []models.ObjectPermissions, objectAPIName string) (models.ObjectPermissions, bool) {
	for _, perm := range perms {
		if strings.EqualFold(perm.SobjectType, objectAPIName) {
			return perm, true
		}
	}
	return models.ObjectPermissions{}, false
}

// TestRequiresRecordAwareCRUDDeferralNeverDefersPermCreate pins the explicit
// edit/delete-only gate at the top of RequiresRecordAwareCRUDDeferral. This is
// the mitigation called out in the issue-1235 crossfire escalation: the
// preflight predicate must return false for PermCreate regardless of the
// parent-chain walk's answer, because Insert has NO record-aware consumer
// gate downstream — any PermCreate deferral here silently bypasses the
// producer-side object-permission check.
//
// Table covers the two blind-spot shapes the designer kept dismissing:
//   - child has no AccessPolicy of its own, parent has a record-context AP
//     for Create ("only the owner can create children")
//   - child has a record-context AP for Create (rare but legal), parent has
//     no AP
//
// Both must return false for PermCreate. The gate also defends PermRead for
// the same reason (no record-aware consumer gate on read paths).
func TestRequiresRecordAwareCRUDDeferralNeverDefersPermCreate(t *testing.T) {
	ctx := context.Background()

	recordCtxAP := &models.ObjectAccessPolicy{
		Mode:         "derived",
		ObjectCreate: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}

	tests := []struct {
		name         string
		schemas      map[string]*models.ObjectMetadata
		shareParent  map[string]*models.Relationship
		targetSchema string
		operation    string
		want         bool
	}{
		{
			name: "PermCreate child:no-AP parent:record-context-AP-Create returns false",
			schemas: map[string]*models.ObjectMetadata{
				"Child":  {APIName: "Child", SharingModel: constants.SharingModelControlledByParent},
				"Parent": {APIName: "Parent", AccessPolicy: recordCtxAP},
			},
			shareParent: map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
			targetSchema: "Child",
			operation:    constants.PermCreate,
			want:         false,
		},
		{
			name: "PermCreate child:record-context-AP-Create parent:no-AP returns false",
			schemas: map[string]*models.ObjectMetadata{
				"Child": {APIName: "Child", AccessPolicy: recordCtxAP},
			},
			targetSchema: "Child",
			operation:    constants.PermCreate,
			want:         false,
		},
		{
			name: "PermRead child:no-AP parent:record-context-AP-Edit returns false",
			schemas: map[string]*models.ObjectMetadata{
				"Child": {APIName: "Child", SharingModel: constants.SharingModelControlledByParent},
				"Parent": {APIName: "Parent", AccessPolicy: &models.ObjectAccessPolicy{
					Mode:       "derived",
					ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
				}},
			},
			shareParent: map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
			targetSchema: "Child",
			operation:    constants.PermRead,
			want:         false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			md := &pcMetadataStub{schemas: tc.schemas, sharingParent: tc.shareParent}
			svc := makePCService(&pcRepo{}, md)
			schema := tc.schemas[tc.targetSchema]
			got := svc.RequiresRecordAwareCRUDDeferral(ctx, schema, tc.operation)
			if got != tc.want {
				t.Fatalf("RequiresRecordAwareCRUDDeferral(%s, %s) = %v; want %v", tc.targetSchema, tc.operation, got, tc.want)
			}
		})
	}
}

// TestRequiresRecordAwareCRUDDeferralCoversParentControlledChain pins the
// Edit/Delete semantics: the predicate is a strict superset of the legacy
// accessPolicyRequiresRecordContext, augmented with a parent-chain walk for
// parent-controlled children. Cycle detection inside the visited map
// terminates malformed A→B→A pairs without panic.
func TestRequiresRecordAwareCRUDDeferralCoversParentControlledChain(t *testing.T) {
	ctx := context.Background()

	editCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}

	build := func(schemas map[string]*models.ObjectMetadata, rels map[string]*models.Relationship) (*pcMetadataStub, *PermissionService) {
		md := &pcMetadataStub{schemas: schemas, sharingParent: rels}
		return md, makePCService(&pcRepo{}, md)
	}

	t.Run("child:no-AP parent:record-context-AP-Edit returns true", func(t *testing.T) {
		_, svc := build(
			map[string]*models.ObjectMetadata{
				"Child":  {APIName: "Child", SharingModel: constants.SharingModelControlledByParent},
				"Parent": {APIName: "Parent", AccessPolicy: editCtxAP},
			},
			map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
		)
		if !svc.RequiresRecordAwareCRUDDeferral(ctx, &models.ObjectMetadata{APIName: "Child", SharingModel: constants.SharingModelControlledByParent}, constants.PermEdit) {
			t.Fatal("want true for child:no-AP→parent:record-context-AP-Edit, got false")
		}
	})

	t.Run("child:delete parent:record-context-AP-Edit returns true", func(t *testing.T) {
		_, svc := build(
			map[string]*models.ObjectMetadata{
				"Child":  {APIName: "Child", SharingModel: constants.SharingModelControlledByParent},
				"Parent": {APIName: "Parent", AccessPolicy: editCtxAP},
			},
			map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
		)
		if !svc.RequiresRecordAwareCRUDDeferral(ctx, &models.ObjectMetadata{APIName: "Child", SharingModel: constants.SharingModelControlledByParent}, constants.PermDelete) {
			t.Fatal("want true for child Delete because inherited Gate-1 normalizes to parent Edit")
		}
	})

	t.Run("child:no-AP parent:no-AP returns false", func(t *testing.T) {
		_, svc := build(
			map[string]*models.ObjectMetadata{
				"Child":  {APIName: "Child", SharingModel: constants.SharingModelControlledByParent},
				"Parent": {APIName: "Parent"},
			},
			map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
		)
		if svc.RequiresRecordAwareCRUDDeferral(ctx, &models.ObjectMetadata{APIName: "Child", SharingModel: constants.SharingModelControlledByParent}, constants.PermEdit) {
			t.Fatal("want false for child:no-AP→parent:no-AP, got true")
		}
	})

	t.Run("child:record-context-AP-Edit parent:no-AP returns true", func(t *testing.T) {
		schema := &models.ObjectMetadata{APIName: "Child", AccessPolicy: editCtxAP}
		_, svc := build(map[string]*models.ObjectMetadata{"Child": schema}, nil)
		if !svc.RequiresRecordAwareCRUDDeferral(ctx, schema, constants.PermEdit) {
			t.Fatal("want true for child:record-context-AP-Edit (self), got false")
		}
	})

	t.Run("cycle A->B->A both parent-controlled record-context terminates", func(t *testing.T) {
		_, svc := build(
			map[string]*models.ObjectMetadata{
				"A": {APIName: "A", SharingModel: constants.SharingModelControlledByParent, AccessPolicy: editCtxAP},
				"B": {APIName: "B", SharingModel: constants.SharingModelControlledByParent, AccessPolicy: editCtxAP},
			},
			map[string]*models.Relationship{
				"A": {ChildObjectAPIName: "A", ParentObjectAPIName: "B", FieldAPIName: "BId", IsSharingParent: true},
				"B": {ChildObjectAPIName: "B", ParentObjectAPIName: "A", FieldAPIName: "AId", IsSharingParent: true},
			},
		)
		got := svc.RequiresRecordAwareCRUDDeferral(ctx, &models.ObjectMetadata{APIName: "A", SharingModel: constants.SharingModelControlledByParent, AccessPolicy: editCtxAP}, constants.PermEdit)
		// First call sees self has record-context AP → returns true; the
		// parent-walk is short-circuited. This is the expected truth value
		// for the (A self has record-context AP) → defer-to-consumer case.
		if !got {
			t.Fatal("cycle terminated but returned false; expected true via self-AP record context")
		}
	})
}

// TestCheckObjectPermissionResolvesParentControlledGateOne verifies the
// non-record Gate-1 branch in checkObjectPermissionWithState. Parent-controlled
// children either use their own RequiredWritePermission or inherit from the
// parent with child mutations normalized to parent Edit.
func TestCheckObjectPermissionResolvesParentControlledGateOne(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	cases := []struct {
		child, parent string
		op            string
		childSchema   *models.ObjectMetadata
		parentPerm    *models.ObjectPermissions
		systemPerms   map[string]bool
		want          bool
	}{
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermRead, parentPerm: &models.ObjectPermissions{PermissionsRead: true}, want: true},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermCreate, parentPerm: &models.ObjectPermissions{PermissionsCreate: true}, want: false},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermCreate, parentPerm: &models.ObjectPermissions{PermissionsEdit: true}, want: true},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermEdit, parentPerm: &models.ObjectPermissions{PermissionsEdit: true}, want: true},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermDelete, parentPerm: &models.ObjectPermissions{PermissionsDelete: true}, want: false},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermDelete, parentPerm: &models.ObjectPermissions{PermissionsEdit: true}, want: true},
		{child: "OpportunityLineItem", parent: "Opportunity", op: constants.PermEdit, parentPerm: &models.ObjectPermissions{PermissionsEdit: false}, want: false},
		{child: "OpportunityContactRole", parent: "Opportunity", op: constants.PermRead, parentPerm: &models.ObjectPermissions{PermissionsRead: true}, want: true},
		{child: "OpportunityContactRole", parent: "Opportunity", op: constants.PermEdit, parentPerm: nil, want: false}, // no parent OP row either
		{
			child:  "Task",
			parent: "Account",
			op:     constants.PermCreate,
			childSchema: &models.ObjectMetadata{
				APIName:                 "Task",
				SharingModel:            constants.SharingModelControlledByParent,
				RequiredWritePermission: "EditTask",
				Fields: []models.FieldMetadata{{
					APIName:     "AccountId",
					Type:        constants.FieldTypeReference,
					ReferenceTo: []string{"Account"},
				}},
			},
			parentPerm:  &models.ObjectPermissions{PermissionsCreate: false, PermissionsEdit: false},
			systemPerms: map[string]bool{"EditTask": true},
			want:        true,
		},
		{
			child:  "Task",
			parent: "Account",
			op:     constants.PermCreate,
			childSchema: &models.ObjectMetadata{
				APIName:                 "Task",
				SharingModel:            constants.SharingModelControlledByParent,
				RequiredWritePermission: "EditTask",
				Fields: []models.FieldMetadata{{
					APIName:     "AccountId",
					Type:        constants.FieldTypeReference,
					ReferenceTo: []string{"Account"},
				}},
			},
			parentPerm:  &models.ObjectPermissions{PermissionsCreate: true, PermissionsEdit: true},
			systemPerms: map[string]bool{"EditTask": false},
			want:        false,
		},
		{
			child:  "Task",
			parent: "Account",
			op:     "ViewAllRecords",
			childSchema: &models.ObjectMetadata{
				APIName:                 "Task",
				SharingModel:            constants.SharingModelControlledByParent,
				RequiredWritePermission: "EditTask",
				Fields: []models.FieldMetadata{{
					APIName:     "AccountId",
					Type:        constants.FieldTypeReference,
					ReferenceTo: []string{"Account"},
				}},
			},
			parentPerm:  &models.ObjectPermissions{PermissionsViewAllRecords: false},
			systemPerms: map[string]bool{"EditTask": true},
			want:        false,
		},
		{
			child:       "MalformedTask",
			parent:      "Account",
			op:          constants.PermCreate,
			childSchema: &models.ObjectMetadata{APIName: "MalformedTask", SharingModel: constants.SharingModelControlledByParent, RequiredWritePermission: "EditTask"},
			parentPerm:  &models.ObjectPermissions{PermissionsCreate: true, PermissionsEdit: true},
			systemPerms: map[string]bool{"EditTask": true},
			want:        false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.child+"_"+tc.op, func(t *testing.T) {
			childSchema := tc.childSchema
			if childSchema == nil {
				childSchema = &models.ObjectMetadata{
					APIName:      tc.child,
					SharingModel: constants.SharingModelControlledByParent,
					Fields: []models.FieldMetadata{{
						APIName:     tc.parent + "Id",
						Type:        constants.FieldTypeReference,
						Required:    true,
						ReferenceTo: []string{tc.parent},
					}},
				}
			}
			md := &pcMetadataStub{
				schemas: map[string]*models.ObjectMetadata{
					tc.child:  childSchema,
					tc.parent: {APIName: tc.parent},
				},
				sharingParent: map[string]*models.Relationship{
					tc.child: {ChildObjectAPIName: tc.child, ParentObjectAPIName: tc.parent, FieldAPIName: tc.parent + "Id", IsSharingParent: true},
				},
			}
			repo := &pcRepo{
				objPerms:    map[string]*models.ObjectPermissions{tc.parent: tc.parentPerm},
				systemPerms: tc.systemPerms,
			}
			svc := makePCService(repo, md)
			got := svc.CheckObjectPermissionWithUser(ctx, tc.child, tc.op, user)
			if got != tc.want {
				t.Fatalf("CheckObjectPermissionWithUser(%s, %s) = %v; want %v", tc.child, tc.op, got, tc.want)
			}
		})
	}
}

func TestCheckObjectPermissionParentControlledCreateDefersRecordContextParentEdit(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}
	parentEditRecordCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}
	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"LineItem": {
				APIName:      "LineItem",
				SharingModel: constants.SharingModelControlledByParent,
				Fields: []models.FieldMetadata{{
					APIName:     "ParentId",
					Type:        constants.FieldTypeReference,
					Required:    true,
					ReferenceTo: []string{"Parent"},
				}},
			},
			"Parent": {APIName: "Parent", AccessPolicy: parentEditRecordCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"LineItem": {ChildObjectAPIName: "LineItem", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
		},
	}
	svc := makePCService(&pcRepo{
		objPerms: map[string]*models.ObjectPermissions{
			"Parent": {SobjectType: "Parent", PermissionsRead: true},
		},
	}, md)

	if !svc.CheckObjectPermissionWithUser(ctx, "LineItem", constants.PermCreate, user) {
		t.Fatal("parent-controlled create should not evaluate parent record-context Edit AP with a nil record")
	}
}

func TestCheckObjectPermissionParentControlledCreateOptionalEdgeDefersRecordContextParentEdit(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}
	parentEditRecordCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}
	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OptionalLine": {
				APIName:      "OptionalLine",
				SharingModel: constants.SharingModelControlledByParent,
				Fields: []models.FieldMetadata{{
					APIName:     "ParentId",
					Type:        constants.FieldTypeReference,
					ReferenceTo: []string{"Parent"},
				}},
			},
			"Parent": {APIName: "Parent", AccessPolicy: parentEditRecordCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"OptionalLine": {ChildObjectAPIName: "OptionalLine", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
		},
	}
	svc := makePCService(&pcRepo{
		objPerms: map[string]*models.ObjectPermissions{
			"Parent": {SobjectType: "Parent", PermissionsRead: true},
		},
	}, md)

	if !svc.CheckObjectPermissionWithUser(ctx, "OptionalLine", constants.PermCreate, user) {
		t.Fatal("optional parent-controlled create with record-context parent Edit should defer to lookup-validator consumer")
	}
}

func TestListEffectiveObjectPermissionsProjectsParentControlledChildren(t *testing.T) {
	ctx := context.Background()
	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OpportunityLineItem": {
				APIName:      "OpportunityLineItem",
				SharingModel: constants.SharingModelControlledByParent,
				Fields: []models.FieldMetadata{{
					APIName:     "OpportunityId",
					Type:        constants.FieldTypeReference,
					Required:    true,
					ReferenceTo: []string{"Opportunity"},
				}},
			},
			"Opportunity": {APIName: "Opportunity"},
		},
		sharingParent: map[string]*models.Relationship{
			"OpportunityLineItem": {
				ChildObjectAPIName:  "OpportunityLineItem",
				ParentObjectAPIName: "Opportunity",
				FieldAPIName:        "OpportunityId",
				IsSharingParent:     true,
			},
		},
	}
	parentPerm := &models.ObjectPermissions{
		SobjectType:                 "Opportunity",
		PermissionsRead:             true,
		PermissionsCreate:           true,
		PermissionsEdit:             true,
		PermissionsViewAllRecords:   true,
		PermissionsModifyAllRecords: false,
	}

	t.Run("missing child row is synthesized from parent runtime CRUD", func(t *testing.T) {
		repo := &pcRepo{
			objPerms: map[string]*models.ObjectPermissions{
				"Opportunity": parentPerm,
			},
			effectiveList: []models.ObjectPermissions{*parentPerm},
		}
		svc := makePCService(repo, md)

		perms, err := svc.ListEffectiveObjectPermissionsForUser(ctx, "u1", "p1")
		if err != nil {
			t.Fatalf("ListEffectiveObjectPermissionsForUser returned error: %v", err)
		}
		child, ok := objectPermByType(perms, "OpportunityLineItem")
		if !ok {
			t.Fatalf("expected synthetic OpportunityLineItem row, got %#v", perms)
		}
		if !child.PermissionsRead || !child.PermissionsCreate || !child.PermissionsEdit || !child.PermissionsDelete {
			t.Fatalf("synthetic child permissions = %#v; want parent-derived read and mutate via parent edit", child)
		}
		if !child.PermissionsViewAllRecords || child.PermissionsModifyAllRecords {
			t.Fatalf("synthetic child all-record grants = %#v; want parent-derived ViewAll only", child)
		}
	})

	t.Run("stale child row is replaced by runtime SSoT", func(t *testing.T) {
		repo := &pcRepo{
			objPerms: map[string]*models.ObjectPermissions{
				"Opportunity": parentPerm,
			},
			effectiveList: []models.ObjectPermissions{
				*parentPerm,
				{SobjectType: "OpportunityLineItem"},
			},
		}
		svc := makePCService(repo, md)

		perms, err := svc.ListEffectiveObjectPermissionsForUser(ctx, "u1", "p1")
		if err != nil {
			t.Fatalf("ListEffectiveObjectPermissionsForUser returned error: %v", err)
		}
		child, ok := objectPermByType(perms, "OpportunityLineItem")
		if !ok {
			t.Fatal("expected OpportunityLineItem row after projection")
		}
		if !child.PermissionsCreate || !child.PermissionsEdit {
			t.Fatalf("stale child row was not replaced by parent-derived grants: %#v", child)
		}
	})

	t.Run("request cache participates in runtime projection", func(t *testing.T) {
		repo := &pcRepo{
			objPerms: map[string]*models.ObjectPermissions{
				"Opportunity": parentPerm,
			},
			effectiveList: []models.ObjectPermissions{*parentPerm},
		}
		svc := makePCService(repo, md)
		requestCtx := WithAccessInputCache(ctx)
		if _, err := svc.loadEffectiveObjectPermission(requestCtx, &models.UserSession{ID: "u1", ProfileID: "p1"}, "Opportunity"); err != nil {
			t.Fatalf("preload parent permission returned error: %v", err)
		}
		repo.objPerms["Opportunity"] = &models.ObjectPermissions{SobjectType: "Opportunity"}

		perms, err := svc.ListEffectiveObjectPermissionsForUser(requestCtx, "u1", "p1")
		if err != nil {
			t.Fatalf("ListEffectiveObjectPermissionsForUser returned error: %v", err)
		}
		child, ok := objectPermByType(perms, "OpportunityLineItem")
		if !ok {
			t.Fatalf("expected request-cache-backed synthetic OpportunityLineItem row, got %#v", perms)
		}
		if !child.PermissionsCreate || !child.PermissionsEdit {
			t.Fatalf("request-cache-backed synthetic child permissions = %#v; want parent grants", child)
		}
	})

	t.Run("no parent grant removes non-authoritative child row", func(t *testing.T) {
		repo := &pcRepo{
			objPerms: map[string]*models.ObjectPermissions{
				"Opportunity": {SobjectType: "Opportunity"},
			},
			effectiveList: []models.ObjectPermissions{
				{SobjectType: "Opportunity"},
				{SobjectType: "OpportunityLineItem", PermissionsRead: true},
			},
		}
		svc := makePCService(repo, md)

		perms, err := svc.ListEffectiveObjectPermissionsForUser(ctx, "u1", "p1")
		if err != nil {
			t.Fatalf("ListEffectiveObjectPermissionsForUser returned error: %v", err)
		}
		if child, ok := objectPermByType(perms, "OpportunityLineItem"); ok {
			t.Fatalf("non-authoritative child row should be removed when runtime denies all grants, got %#v", child)
		}
	})
}

func TestGetEffectiveObjectPermissionsUsesParentControlledProjection(t *testing.T) {
	ctx := context.Background()
	parentPerm := models.ObjectPermissions{
		SobjectType:       "Opportunity",
		PermissionsRead:   true,
		PermissionsCreate: true,
	}
	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OpportunityLineItem": {
				APIName:      "OpportunityLineItem",
				SharingModel: constants.SharingModelControlledByParent,
				Fields: []models.FieldMetadata{{
					APIName:     "OpportunityId",
					Type:        constants.FieldTypeReference,
					Required:    true,
					ReferenceTo: []string{"Opportunity"},
				}},
			},
			"Opportunity": {APIName: "Opportunity"},
		},
		sharingParent: map[string]*models.Relationship{
			"OpportunityLineItem": {
				ChildObjectAPIName:  "OpportunityLineItem",
				ParentObjectAPIName: "Opportunity",
				FieldAPIName:        "OpportunityId",
				IsSharingParent:     true,
			},
		},
	}
	repo := &pcRepo{
		profileID: "p1",
		objPerms: map[string]*models.ObjectPermissions{
			"Opportunity": &parentPerm,
		},
		effectiveList: []models.ObjectPermissions{parentPerm},
	}
	svc := makePCService(repo, md)

	perms, err := svc.GetEffectiveObjectPermissions(ctx, "u1")
	if err != nil {
		t.Fatalf("GetEffectiveObjectPermissions returned error: %v", err)
	}
	child, ok := objectPermByType(perms, "OpportunityLineItem")
	if !ok {
		t.Fatalf("GetEffectiveObjectPermissions did not include synthesized child row: %#v", perms)
	}
	if !child.PermissionsRead || child.PermissionsCreate {
		t.Fatalf("child projection = %#v; want read inherited and create denied without parent edit", child)
	}
}

// TestCheckObjectPermissionOnRecordLoadsParentRecordWhenParentAPNeedsContext
// verifies the slow-path record-context delegation: child has no AP; parent
// has a record-context AP keyed on OwnerId; the parent record is loaded and
// evaluated against the user.
func TestCheckObjectPermissionOnRecordLoadsParentRecordWhenParentAPNeedsContext(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}
	editCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OLI":         {APIName: "OLI", SharingModel: constants.SharingModelControlledByParent},
			"Opportunity": {APIName: "Opportunity", AccessPolicy: editCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"OLI": {ChildObjectAPIName: "OLI", ParentObjectAPIName: "Opportunity", FieldAPIName: "OpportunityId", IsSharingParent: true},
		},
	}

	t.Run("owner matches grants edit", func(t *testing.T) {
		repo := &pcRepo{records: map[string]models.SObject{
			"Opportunity:p1": {"Id": "p1", "OwnerId": "u1"},
		}}
		svc := makePCService(repo, md)
		child := md.schemas["OLI"]
		if !svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "c1", "OpportunityId": "p1"}, constants.PermEdit, user) {
			t.Fatal("want true (parent owner matches user)")
		}
	})

	t.Run("owner mismatch denies", func(t *testing.T) {
		repo := &pcRepo{records: map[string]models.SObject{
			"Opportunity:p1": {"Id": "p1", "OwnerId": "other"},
		}}
		svc := makePCService(repo, md)
		child := md.schemas["OLI"]
		if svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "c1", "OpportunityId": "p1"}, constants.PermEdit, user) {
			t.Fatal("want false (parent owner != user)")
		}
	})

	t.Run("missing parent FK denies", func(t *testing.T) {
		repo := &pcRepo{records: map[string]models.SObject{}}
		svc := makePCService(repo, md)
		child := md.schemas["OLI"]
		if svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "c1", "OpportunityId": ""}, constants.PermEdit, user) {
			t.Fatal("want false (orphan child)")
		}
	})
}

// TestCheckObjectPermissionOnRecordSkipsParentLoadForSimplePolicy is the spy
// assertion: when the parent has NO record-context AP, the fast path must
// recurse into the non-record entry — no GetRecordByID calls.
func TestCheckObjectPermissionOnRecordSkipsParentLoadForSimplePolicy(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OLI":         {APIName: "OLI", SharingModel: constants.SharingModelControlledByParent},
			"Opportunity": {APIName: "Opportunity"}, // no AccessPolicy
		},
		sharingParent: map[string]*models.Relationship{
			"OLI": {ChildObjectAPIName: "OLI", ParentObjectAPIName: "Opportunity", FieldAPIName: "OpportunityId", IsSharingParent: true},
		},
	}
	repo := &pcRepo{objPerms: map[string]*models.ObjectPermissions{
		"Opportunity": {PermissionsEdit: true},
	}}
	svc := makePCService(repo, md)
	child := md.schemas["OLI"]

	if !svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "c1", "OpportunityId": "p1"}, constants.PermEdit, user) {
		t.Fatal("delegation: want true (parent OP grants Edit)")
	}
	if repo.getByIDCalls != 0 {
		t.Fatalf("fast path must not load parent record; getByIDCalls = %d", repo.getByIDCalls)
	}
}

// TestCheckObjectPermissionParentControlledCycleDeniesSafely verifies the
// non-record cycle path: A→B→A both parent-controlled with no record-context
// AP. The recursed call hits state.objectOps[a:op] on the second entry into
// A and returns false. No stack overflow, no panic.
func TestCheckObjectPermissionParentControlledCycleDeniesSafely(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"A": {APIName: "A", SharingModel: constants.SharingModelControlledByParent},
			"B": {APIName: "B", SharingModel: constants.SharingModelControlledByParent},
		},
		sharingParent: map[string]*models.Relationship{
			"A": {ChildObjectAPIName: "A", ParentObjectAPIName: "B", FieldAPIName: "BId", IsSharingParent: true},
			"B": {ChildObjectAPIName: "B", ParentObjectAPIName: "A", FieldAPIName: "AId", IsSharingParent: true},
		},
	}
	svc := makePCService(&pcRepo{}, md)
	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("cycle caused panic: %v", r)
		}
	}()
	if svc.CheckObjectPermissionWithUser(ctx, "A", constants.PermEdit, user) {
		t.Fatal("A->B->A cycle must deny (no OP rows anywhere)")
	}
}

// TestCheckObjectPermissionOnRecordParentControlledRecordContextCycleDeniesSafely
// is the record-aware cycle twin. The separate `visited` map (NOT
// state.objectOps) is the load-bearing tracker here, because the record-
// context branch evaluates parent AP via evaluateObjectAccessPolicy directly,
// which does not mark state.objectOps for the schema being evaluated.
func TestCheckObjectPermissionOnRecordParentControlledRecordContextCycleDeniesSafely(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	recordCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"A": {APIName: "A", SharingModel: constants.SharingModelControlledByParent, AccessPolicy: recordCtxAP},
			"B": {APIName: "B", SharingModel: constants.SharingModelControlledByParent, AccessPolicy: recordCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"A": {ChildObjectAPIName: "A", ParentObjectAPIName: "B", FieldAPIName: "BId", IsSharingParent: true},
			"B": {ChildObjectAPIName: "B", ParentObjectAPIName: "A", FieldAPIName: "AId", IsSharingParent: true},
		},
	}
	repo := &pcRepo{records: map[string]models.SObject{
		"A:a1": {"Id": "a1", "BId": "b1", "OwnerId": "other"},
		"B:b1": {"Id": "b1", "AId": "a1", "OwnerId": "other"},
	}}
	svc := makePCService(repo, md)

	defer func() {
		if r := recover(); r != nil {
			t.Fatalf("record-context cycle caused panic: %v", r)
		}
	}()
	if svc.CheckObjectPermissionOnRecord(ctx, md.schemas["A"], models.SObject{"Id": "a1", "BId": "b1", "OwnerId": "other"}, constants.PermEdit, user) {
		t.Fatal("record-context A->B->A cycle must deny (no owner match anywhere)")
	}
}

// TestParentControlledObjectWithoutRelationshipDenies pins the malformed-
// metadata defense: a schema declaring SharingModel=ControlledByParent but with
// no sharing-parent relationship row must not fall through to stale child OP
// rows.
func TestParentControlledObjectWithoutRelationshipDenies(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			// SharingModel set, but no entry in sharingParent map.
			"Lonely": {APIName: "Lonely", SharingModel: constants.SharingModelControlledByParent},
		},
		sharingParent: map[string]*models.Relationship{},
	}
	svc := makePCService(&pcRepo{}, md)
	if svc.CheckObjectPermissionWithUser(ctx, "Lonely", constants.PermEdit, user) {
		t.Fatal("malformed PC schema with no child OP must deny")
	}
	svc = makePCService(&pcRepo{objPerms: map[string]*models.ObjectPermissions{
		"Lonely": {PermissionsEdit: true},
	}}, md)
	if svc.CheckObjectPermissionWithUser(ctx, "Lonely", constants.PermEdit, user) {
		t.Fatal("malformed PC schema must ignore stale child OP grants")
	}

	md = &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"Broken": {APIName: "Broken", SharingModel: constants.SharingModelControlledByParent},
			"Parent": {APIName: "Parent"},
		},
		sharingParent: map[string]*models.Relationship{
			"Broken": {ChildObjectAPIName: "Broken", ParentObjectAPIName: "Parent", FieldAPIName: "MissingParentId", IsSharingParent: true},
		},
	}
	svc = makePCService(&pcRepo{objPerms: map[string]*models.ObjectPermissions{
		"Parent": {SobjectType: "Parent", PermissionsEdit: true},
	}}, md)
	if svc.CheckObjectPermissionWithUser(ctx, "Broken", constants.PermEdit, user) {
		t.Fatal("malformed PC schema with missing sharing-parent field must not inherit parent OP grants")
	}
}

// TestParentControlledMissingParentObjectDenies pins the malformed-edge defense
// for the "parent object missing" case: the sharing-parent FIELD is well-formed,
// but the parent OBJECT it points at is absent from metadata (partial/two-step
// apply or a deleted parent). The edge must be treated as malformed and fail
// closed, not fall through to stale parent ObjectPermissions grants.
func TestParentControlledMissingParentObjectDenies(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"Orphaned": {
				APIName:      "Orphaned",
				SharingModel: constants.SharingModelControlledByParent,
				Fields: []models.FieldMetadata{{
					APIName:     "GhostId",
					Type:        constants.FieldTypeReference,
					ReferenceTo: []string{"Ghost"},
				}},
			},
			// "Ghost" parent intentionally absent from schemas.
		},
		sharingParent: map[string]*models.Relationship{
			"Orphaned": {ChildObjectAPIName: "Orphaned", ParentObjectAPIName: "Ghost", FieldAPIName: "GhostId", IsSharingParent: true},
		},
	}
	svc := makePCService(&pcRepo{objPerms: map[string]*models.ObjectPermissions{
		"Ghost": {SobjectType: "Ghost", PermissionsEdit: true},
	}}, md)
	if svc.CheckObjectPermissionWithUser(ctx, "Orphaned", constants.PermEdit, user) {
		t.Fatal("CbP child whose parent object is missing from metadata must deny (malformed edge), not inherit stale parent OP grants")
	}
}

// TestCheckObjectPermissionOnRecordOrphanChildDeniesSafely — child record
// has empty parent FK; record-aware delegation returns false. Matches
// record_access.go:429-431 semantics.
func TestCheckObjectPermissionOnRecordOrphanChildDeniesSafely(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}
	editCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}
	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"OLI":         {APIName: "OLI", SharingModel: constants.SharingModelControlledByParent},
			"Opportunity": {APIName: "Opportunity", AccessPolicy: editCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"OLI": {ChildObjectAPIName: "OLI", ParentObjectAPIName: "Opportunity", FieldAPIName: "OpportunityId", IsSharingParent: true},
		},
	}
	svc := makePCService(&pcRepo{}, md)
	child := md.schemas["OLI"]
	if svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "c1", "OpportunityId": ""}, constants.PermEdit, user) {
		t.Fatal("orphan child must deny")
	}
}

func TestCheckRecordAccessParentControlledFallbackIsEdgeAware(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}

	tests := []struct {
		name     string
		field    models.FieldMetadata
		wantRead bool
	}{
		{
			name:     "optional edge keeps owner fallback",
			field:    models.FieldMetadata{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent"}},
			wantRead: true,
		},
		{
			name:     "required edge suppresses owner fallback",
			field:    models.FieldMetadata{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent"}, Required: true},
			wantRead: false,
		},
		{
			name:     "masterdetail edge suppresses owner fallback even when required is false",
			field:    models.FieldMetadata{APIName: "ParentId", Type: constants.FieldTypeMasterDetail, ReferenceTo: []string{"Parent"}},
			wantRead: false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			md := &pcMetadataStub{
				schemas: map[string]*models.ObjectMetadata{
					"Child": {
						APIName:      "Child",
						SharingModel: constants.SharingModelControlledByParent,
						Fields: []models.FieldMetadata{
							tc.field,
							{APIName: constants.FieldOwnerID},
						},
					},
					"Parent": {APIName: "Parent", SharingModel: constants.SharingModelPrivate, Fields: []models.FieldMetadata{{APIName: constants.FieldOwnerID}}},
				},
				sharingParent: map[string]*models.Relationship{
					"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
				},
			}
			repo := &pcRepo{records: map[string]models.SObject{
				"Parent:p1": {"Id": "p1", "OwnerId": "other"},
			}}
			svc := makePCService(repo, md)
			got := svc.CheckRecordAccess(ctx, md.schemas["Child"], models.SObject{
				"Id":       "c1",
				"ParentId": "p1",
				"OwnerId":  "u1",
			}, constants.PermRead, user)
			if got != tc.wantRead {
				t.Fatalf("CheckRecordAccess = %v; want %v", got, tc.wantRead)
			}
		})
	}

	t.Run("relationship field missing denies instead of owner fallback", func(t *testing.T) {
		md := &pcMetadataStub{
			schemas: map[string]*models.ObjectMetadata{
				"Child": {
					APIName:      "Child",
					SharingModel: constants.SharingModelControlledByParent,
					Fields:       []models.FieldMetadata{{APIName: constants.FieldOwnerID}},
				},
				"Parent": {APIName: "Parent", SharingModel: constants.SharingModelPrivate, Fields: []models.FieldMetadata{{APIName: constants.FieldOwnerID}}},
			},
			sharingParent: map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
		}
		repo := &pcRepo{records: map[string]models.SObject{
			"Parent:p1": {"Id": "p1", "OwnerId": "other"},
		}}
		svc := makePCService(repo, md)
		if svc.CheckRecordAccess(ctx, md.schemas["Child"], models.SObject{
			"Id":       "c1",
			"ParentId": "p1",
			"OwnerId":  "u1",
		}, constants.PermRead, user) {
			t.Fatal("malformed parent-controlled edge must deny instead of falling back to child owner")
		}
	})

	t.Run("child delete uses parent edit record reach", func(t *testing.T) {
		md := &pcMetadataStub{
			schemas: map[string]*models.ObjectMetadata{
				"Child": {
					APIName:      "Child",
					SharingModel: constants.SharingModelControlledByParent,
					Fields: []models.FieldMetadata{
						{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent"}, Required: true},
						{APIName: constants.FieldOwnerID},
					},
				},
				"Parent": {APIName: "Parent", SharingModel: constants.SharingModelPrivate, Fields: []models.FieldMetadata{{APIName: constants.FieldOwnerID}}},
			},
			sharingParent: map[string]*models.Relationship{
				"Child": {ChildObjectAPIName: "Child", ParentObjectAPIName: "Parent", FieldAPIName: "ParentId", IsSharingParent: true},
			},
		}
		repo := &pcRepo{
			records: map[string]models.SObject{
				"Parent:p1": {"Id": "p1", "OwnerId": "other"},
			},
			manualShares: map[string][]string{
				"Parent:p1:u1": {constants.PermEdit},
			},
		}
		svc := makePCService(repo, md)
		if !svc.CheckRecordAccess(ctx, md.schemas["Child"], models.SObject{
			"Id":       "c1",
			"ParentId": "p1",
			"OwnerId":  "other",
		}, constants.PermDelete, user) {
			t.Fatal("parent-controlled child Delete should derive record reach from parent Edit")
		}
	})
}

// --- F1/F2/F3 authoring-surface guard tests ------------------------------
// The shared pcMetadataStub below declares a single ControlledByParent child
// ("ChildPC") and a non-PC sibling ("ChildOK"). The guard helper consults
// GetSchema → GetParentControlledObject; we exercise both branches plus the
// nil-safety properties (empty SobjectType / nil receiver).

func newPCWriteGuardStub() *pcMetadataStub {
	return &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"ChildPC":  {APIName: "ChildPC", SharingModel: constants.SharingModelControlledByParent},
			"LonelyPC": {APIName: "LonelyPC", SharingModel: constants.SharingModelControlledByParent},
			"ChildOK":  {APIName: "ChildOK"},
			"Parent":   {APIName: "Parent"},
		},
		sharingParent: map[string]*models.Relationship{
			"ChildPC": {
				ChildObjectAPIName:  "ChildPC",
				ParentObjectAPIName: "Parent",
				FieldAPIName:        "ParentId",
				IsSharingParent:     true,
			},
		},
	}
}

// TestUpdateObjectPermissionRejectsParentControlledChild pins F1: the profile-
// level admin authoring surface refuses to persist an ObjectPermissions row
// for a parent-controlled child. Asserts spy call-count set-equality
// (upsertCalls == 0 on reject, == 1 on success) plus captured SobjectType on
// the success row — not just "got an error".
func TestUpdateObjectPermissionRejectsParentControlledChild(t *testing.T) {
	ctx := context.Background()
	md := newPCWriteGuardStub()

	t.Run("parent-controlled child is rejected", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdateObjectPermission(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildPC",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		if err == nil {
			t.Fatal("want ValidationError for parent-controlled child")
		}
		var ve *apperrors.ValidationError
		if !stderrors.As(err, &ve) {
			t.Fatalf("want *apperrors.ValidationError, got %T (%v)", err, err)
		}
		if strings.Contains(err.Error(), "permission_seeder.go") || strings.Contains(err.Error(), "PR #") {
			t.Fatalf("validation error must not expose implementation details: %v", err)
		}
		if !strings.Contains(err.Error(), "Access to ChildPC is inherited from Parent") ||
			!strings.Contains(err.Error(), "update the Parent object permissions") {
			t.Fatalf("validation error = %q; want admin-friendly inherited-access guidance", err.Error())
		}
		if repo.upsertCalls != 0 {
			t.Fatalf("must not reach repo on reject; upsertCalls = %d", repo.upsertCalls)
		}
	})

	t.Run("non-parent-controlled sibling persists", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdateObjectPermission(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildOK",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		if err != nil {
			t.Fatalf("want nil error for non-PC sibling, got %v", err)
		}
		if repo.upsertCalls != 1 {
			t.Fatalf("want upsertCalls == 1, got %d", repo.upsertCalls)
		}
		if got := repo.capturedUpserts[0].SobjectType; got != "ChildOK" {
			t.Fatalf("captured SobjectType = %q, want %q", got, "ChildOK")
		}
	})

	t.Run("unknown SobjectType fails closed", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdateObjectPermission(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "DoesNotExist",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		var ve *apperrors.ValidationError
		if !stderrors.As(err, &ve) {
			t.Fatalf("want *apperrors.ValidationError for unknown SobjectType, got %T (%v)", err, err)
		}
		if repo.upsertCalls != 0 {
			t.Fatalf("unknown SobjectType must not reach repo; upsertCalls = %d", repo.upsertCalls)
		}
	})
}

// TestUpdateObjectPermissionTxRejectsParentControlledChild pins F2 (TX variant
// of F1). Same shape as F1 — the only contract difference is that the repo
// upsert call expects to detect TX from context, which our stub ignores.
func TestUpdateObjectPermissionTxRejectsParentControlledChild(t *testing.T) {
	ctx := context.Background()
	md := newPCWriteGuardStub()

	t.Run("parent-controlled child is rejected", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdateObjectPermissionTx(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildPC",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		var ve *apperrors.ValidationError
		if !stderrors.As(err, &ve) {
			t.Fatalf("want *apperrors.ValidationError, got %T (%v)", err, err)
		}
		if repo.upsertCalls != 0 {
			t.Fatalf("must not reach repo on reject; upsertCalls = %d", repo.upsertCalls)
		}
	})

	t.Run("non-parent-controlled sibling persists in TX path", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdateObjectPermissionTx(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildOK",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		if err != nil {
			t.Fatalf("non-PC sibling must succeed: %v", err)
		}
		if repo.upsertCalls != 1 {
			t.Fatalf("want upsertCalls == 1, got %d", repo.upsertCalls)
		}
	})
}

// TestUpdatePermissionSetObjectPermissionRejectsParentControlledChild pins F3
// (permission-set authoring surface). Three rows: empty ParentID (preserves
// the existing error ordering), parent-controlled non-empty (new error,
// upsertCalls == 0), non-PC (success, upsertCalls == 1).
func TestUpdatePermissionSetObjectPermissionRejectsParentControlledChild(t *testing.T) {
	ctx := context.Background()
	md := newPCWriteGuardStub()

	t.Run("empty ParentID error preserved", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdatePermissionSetObjectPermission(ctx, models.ObjectPermissions{
			SobjectType:       "ChildPC", // would also fail the PC check, but ParentID error must fire first
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		var ve *apperrors.ValidationError
		if !stderrors.As(err, &ve) {
			t.Fatalf("want *apperrors.ValidationError, got %T (%v)", err, err)
		}
		if ve.Field != "permission_set_id" {
			t.Fatalf("want Field=permission_set_id (existing error), got %q", ve.Field)
		}
		if repo.upsertCalls != 0 {
			t.Fatalf("must not reach repo; upsertCalls = %d", repo.upsertCalls)
		}
	})

	t.Run("parent-controlled child is rejected", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdatePermissionSetObjectPermission(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildPC",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		var ve *apperrors.ValidationError
		if !stderrors.As(err, &ve) {
			t.Fatalf("want *apperrors.ValidationError, got %T (%v)", err, err)
		}
		if ve.Field != constants.FieldObjectPermissions_SobjectType {
			t.Fatalf("want Field=%s, got %q", constants.FieldObjectPermissions_SobjectType, ve.Field)
		}
		if repo.upsertCalls != 0 {
			t.Fatalf("must not reach repo; upsertCalls = %d", repo.upsertCalls)
		}
	})

	t.Run("non-parent-controlled sibling persists", func(t *testing.T) {
		repo := &pcRepo{}
		svc := makePCService(repo, md)
		err := svc.UpdatePermissionSetObjectPermission(ctx, models.ObjectPermissions{
			ParentID:          "ps1",
			SobjectType:       "ChildOK",
			PermissionsRead:   true,
			PermissionsCreate: true,
			PermissionsEdit:   true,
			PermissionsDelete: true,
		})
		if err != nil {
			t.Fatalf("non-PC sibling must succeed: %v", err)
		}
		if repo.upsertCalls != 1 {
			t.Fatalf("want upsertCalls == 1, got %d", repo.upsertCalls)
		}
	})
}

// TestRejectParentControlledChildWriteNilSafety pins the helper's defensive
// composition: empty SobjectType / nil service must return nil.
func TestRejectParentControlledChildWriteNilSafety(t *testing.T) {
	ctx := context.Background()
	md := newPCWriteGuardStub()
	svc := makePCService(&pcRepo{}, md)

	if err := svc.RejectParentControlledChildWrite(ctx, ""); err != nil {
		t.Fatalf("empty SobjectType must return nil, got %v", err)
	}
	if err := svc.RejectParentControlledChildWrite(ctx, "Unknown"); err == nil {
		t.Fatal("unknown SobjectType must fail closed")
	}
	if err := svc.RejectParentControlledChildWrite(ctx, "ChildOK"); err != nil {
		t.Fatalf("non-PC SobjectType must return nil, got %v", err)
	}
	if err := svc.RejectParentControlledChildWrite(ctx, "ChildPC"); err == nil {
		t.Fatal("PC SobjectType must return non-nil error")
	}
	if err := svc.RejectParentControlledChildWrite(ctx, "LonelyPC"); err == nil {
		t.Fatal("malformed PC SobjectType must return non-nil error")
	}

	// Nil PermissionService is also safe (defensive — exercised by callers
	// that may have a nil ps.permissions).
	var nilSvc *PermissionService
	if err := nilSvc.RejectParentControlledChildWrite(ctx, "ChildPC"); err != nil {
		t.Fatalf("nil receiver must return nil, got %v", err)
	}
}

// --- F4 multi-hop chain walk test ------------------------------------------

// TestCheckObjectPermissionOnRecordMultiHopAncestorNeedsRecordContext pins
// Codex Blocker 2: a chain A→B→C where only C has a record-context Edit AP.
// Before the fix, parent_controlled.go:66 inspected only B's one-hop AP,
// returned false, and routed into the non-record fast path
// (checkObjectPermissionWithState(B,…) → service.go:407-408 recurses to C →
// evaluateObjectAccessPolicy(C, record=nil) → false-deny).
//
// After the fix, the helper uses requiresRecordAwareCRUDDeferral which walks
// the full chain and returns true (C needs record context). The slow path then
// loads B's record and recurses via the grandparent branch
// (parent_controlled.go:115-119) into checkParentControlledRecordAwareCRUD
// for C with B's record as the carrier; the slow path then loads C's record
// and evaluates C's AP against U.
//
// Sub-cases:
//   - positive: U owns C's record → must grant; assert getByIDCalls == 2
//     (B record + C record).
//   - negative: flip C.OwnerId → must deny; assert getByIDCalls == 2 (proves
//     routing distinct from AP outcome).
func TestCheckObjectPermissionOnRecordMultiHopAncestorNeedsRecordContext(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "u1", ProfileID: "p1"}
	editCtxAP := &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectEdit: &models.AccessPolicyExpr{Kind: "recordFieldEqualsCurrentUser", Field: "OwnerId"},
	}

	md := &pcMetadataStub{
		schemas: map[string]*models.ObjectMetadata{
			"A": {APIName: "A", SharingModel: constants.SharingModelControlledByParent},
			"B": {APIName: "B", SharingModel: constants.SharingModelControlledByParent},
			"C": {APIName: "C", AccessPolicy: editCtxAP},
		},
		sharingParent: map[string]*models.Relationship{
			"A": {ChildObjectAPIName: "A", ParentObjectAPIName: "B", FieldAPIName: "BId", IsSharingParent: true},
			"B": {ChildObjectAPIName: "B", ParentObjectAPIName: "C", FieldAPIName: "CId", IsSharingParent: true},
		},
	}

	t.Run("U owns C's record grants edit", func(t *testing.T) {
		repo := &pcRepo{records: map[string]models.SObject{
			"B:b1": {"Id": "b1", "CId": "c1"},
			"C:c1": {"Id": "c1", "OwnerId": "u1"},
		}}
		svc := makePCService(repo, md)
		child := md.schemas["A"]
		if !svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "a1", "BId": "b1"}, constants.PermEdit, user) {
			t.Fatal("multi-hop: U owns C's record, want true; PR-#1242 regression")
		}
		if repo.getByIDCalls != 2 {
			t.Fatalf("multi-hop slow path must load B record + C record; getByIDCalls = %d, want 2", repo.getByIDCalls)
		}
	})

	t.Run("U does not own C's record denies", func(t *testing.T) {
		repo := &pcRepo{records: map[string]models.SObject{
			"B:b1": {"Id": "b1", "CId": "c1"},
			"C:c1": {"Id": "c1", "OwnerId": "other"},
		}}
		svc := makePCService(repo, md)
		child := md.schemas["A"]
		if svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "a1", "BId": "b1"}, constants.PermEdit, user) {
			t.Fatal("multi-hop: U does not own C's record, want false")
		}
		if repo.getByIDCalls != 2 {
			t.Fatalf("multi-hop slow path must still load both records (routing distinct from AP outcome); getByIDCalls = %d, want 2", repo.getByIDCalls)
		}
	})

	t.Run("simple chain without record-context AP still fast-paths", func(t *testing.T) {
		// A→B→C where C has NO AP — chain walker returns false → fast path
		// engages → no parent record loaded. Pins that the chain walk does not
		// over-trigger on parent-controlled depth alone.
		mdSimple := &pcMetadataStub{
			schemas: map[string]*models.ObjectMetadata{
				"A": {
					APIName:      "A",
					SharingModel: constants.SharingModelControlledByParent,
					Fields: []models.FieldMetadata{{
						APIName:     "BId",
						Type:        constants.FieldTypeReference,
						Required:    true,
						ReferenceTo: []string{"B"},
					}},
				},
				"B": {
					APIName:      "B",
					SharingModel: constants.SharingModelControlledByParent,
					Fields: []models.FieldMetadata{{
						APIName:     "CId",
						Type:        constants.FieldTypeReference,
						Required:    true,
						ReferenceTo: []string{"C"},
					}},
				},
				"C": {APIName: "C"}, // no AP
			},
			sharingParent: map[string]*models.Relationship{
				"A": {ChildObjectAPIName: "A", ParentObjectAPIName: "B", FieldAPIName: "BId", IsSharingParent: true},
				"B": {ChildObjectAPIName: "B", ParentObjectAPIName: "C", FieldAPIName: "CId", IsSharingParent: true},
			},
		}
		repo := &pcRepo{objPerms: map[string]*models.ObjectPermissions{
			"C": {PermissionsEdit: true},
		}}
		svc := makePCService(repo, mdSimple)
		child := mdSimple.schemas["A"]
		if !svc.CheckObjectPermissionOnRecord(ctx, child, models.SObject{"Id": "a1", "BId": "b1"}, constants.PermEdit, user) {
			t.Fatal("simple chain: want true (C OP grants Edit via non-record fast path)")
		}
		if repo.getByIDCalls != 0 {
			t.Fatalf("simple chain must skip record load; getByIDCalls = %d", repo.getByIDCalls)
		}
	})
}

// var _ = ports.PermissionRepository(nil) // keep ports referenced in the file
var _ ports.PermissionRepository = (*pcRepo)(nil)

// pcExistenceRepo overrides the manual-share / team-member existence probes on
// pcRepo so a BuildRLSResult test can drive the issue #2585 empty-arm-drop wiring.
type pcExistenceRepo struct {
	pcRepo
	hasShare bool
	hasTeam  bool
}

func (r *pcExistenceRepo) GetManualShareRecordIDs(context.Context, string, string) ([]string, error) {
	if r.hasShare {
		return []string{"x"}, nil
	}
	return nil, nil
}

func (r *pcExistenceRepo) GetTeamMemberRecordIDs(context.Context, string, string) ([]string, error) {
	if r.hasTeam {
		return []string{"x"}, nil
	}
	return nil, nil
}

// TestBuildRLSResultDropsEmptyShareTeamArms pins the issue #2585 production wiring:
// a zero-row existence probe flips IncludeManualShares/IncludeTeamMembers to false
// (so ToSQLFilter omits the semi-join arm), while a non-empty probe keeps the arm
// (its correlated subquery stays, evaluated at the final SELECT's snapshot — no
// over-grant window on a concurrent revoke).
func TestBuildRLSResultDropsEmptyShareTeamArms(t *testing.T) {
	ctx := context.Background()
	schema := &models.ObjectMetadata{
		APIName:      "Widget",
		SharingModel: constants.SharingModelPrivate,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, IsSystem: true},
			{APIName: constants.FieldOwnerID},
		},
	}
	md := &pcMetadataStub{schemas: map[string]*models.ObjectMetadata{"Widget": schema}}
	user := &models.UserSession{ID: "005-user", ProfileID: "00e-standard"}

	// Empty probes → arms dropped.
	dropRes := makePCService(&pcExistenceRepo{}, md).BuildRLSResult(ctx, schema, "Widget", user)
	if dropRes.IncludeManualShares || dropRes.IncludeTeamMembers {
		t.Fatalf("empty probes must drop share/team arms, got IncludeManualShares=%v IncludeTeamMembers=%v",
			dropRes.IncludeManualShares, dropRes.IncludeTeamMembers)
	}

	// Non-empty probes → arms kept (SELECT-time subquery).
	keepRes := makePCService(&pcExistenceRepo{hasShare: true, hasTeam: true}, md).BuildRLSResult(ctx, schema, "Widget", user)
	if !keepRes.IncludeManualShares || !keepRes.IncludeTeamMembers {
		t.Fatalf("non-empty probes must keep share/team arms, got IncludeManualShares=%v IncludeTeamMembers=%v",
			keepRes.IncludeManualShares, keepRes.IncludeTeamMembers)
	}
}
