package programchecker

import (
	"context"
	"strings"
	"testing"

	"github.com/nexuscrm/shared/pkg/constants"
	sharedcontract "github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/models"
)

func TestCheckObjectAccessPolicyReferencesAcceptsResolvedPolicySlice(t *testing.T) {
	t.Parallel()

	ownerPack := "program-pack"
	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: constants.FieldOwnerID, Type: constants.FieldTypeReference},
		{APIName: "Visibility", Type: constants.FieldTypePicklist},
		{APIName: "TargetObjectType", Type: constants.FieldTypeString},
		{APIName: "TargetObjectId", Type: constants.FieldTypeString},
		{APIName: "Blocked", Type: constants.FieldTypeBoolean},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectRead: &models.AccessPolicyExpr{
			Kind:    "systemPermission",
			APIName: "ReadProgramNode",
		},
		ObjectCreate: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "ProgramParent",
			Operation: constants.PermCreate,
		},
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:  "recordFieldEqualsCurrentUser",
			Field: constants.FieldOwnerID,
		},
		ObjectDelete: &models.AccessPolicyExpr{
			Kind:    "systemPermission",
			APIName: "ReadProgramNode",
		},
		FieldRead: &models.AccessPolicyExpr{Inherit: "objectRead"},
		RowRead: &models.RowReadPolicy{
			Kind:            "ownerVisibility",
			OwnerField:      constants.FieldOwnerID,
			VisibilityField: "Visibility",
		},
		RecordMutation: &models.RecordMutationAccessPolicy{
			Deny: []models.RecordMutationDenial{{
				Surfaces: []string{models.RecordMutationSurfaceSingleUpdate},
				Field:    "Blocked",
				When: &models.RecordMutationCondition{
					RelatedExists: &models.RecordMutationRelatedExistsCondition{
						ObjectAPIName: "ProgramChild",
						ParentField:   "ParentId",
						Match: []models.RecordMutationFieldEqualsCondition{{
							Field: "Status",
							Value: "Open",
						}},
					},
				},
			}},
		},
		Lifecycle: &models.RecordLifecycleAccessPolicy{Hooks: []models.RecordLifecycleHookPolicy{{
			Phase:      string(sharedcontract.RecordLifecycleAfterCreateInTx),
			Capability: "program.lifecycle",
		}}},
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{Handlers: []models.RecordCacheInvalidationPolicy{{
			Capability: "program.cache",
		}}},
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{{
				Phase:      string(sharedcontract.RecordWriteValidationBeforeCreate),
				Capability: "program.validate",
			}},
			Canonicalizers: []models.RecordWriteCanonicalizationPolicy{{
				Phase:      string(sharedcontract.RecordWriteValidationBeforeCreate),
				Capability: "program.canonicalize",
			}},
			RequiredValidationRules: []models.RecordWriteValidationRuleRequirement{{
				Phase:     string(sharedcontract.RecordWriteValidationBeforeCreate),
				Name:      "Program_Node_Gate",
				OwnerPack: "program-pack",
			}, {
				Phase:         string(sharedcontract.RecordWriteValidationBeforeCreate),
				ObjectAPIName: "ProgramParent",
				Name:          "Program_Parent_Gate",
				OwnerPack:     "program-pack",
			}},
			OwnerResolution: &models.OwnerResolutionSpec{Paths: []models.OwnerResolutionPath{{
				Hops: []models.OwnerResolutionHop{
					{Self: true, Gate: &models.OwnerPredicate{Field: "Blocked", Is: false}},
					{ViaField: constants.FieldOwnerID, ToObject: "ProgramParent", SystemWhen: &models.OwnerPredicate{Field: "SystemManaged", Is: true}},
				},
			}}},
		},
	}

	reader := newFakeProgramMetadataReader(
		node,
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: "SystemManaged", Type: constants.FieldTypeBoolean},
		}),
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: "ParentId", Type: constants.FieldTypeReference},
			{APIName: "Status", Type: constants.FieldTypePicklist},
		}),
	)
	policy := &fakeObjectAccessPolicyReader{}
	capabilities := CapabilityCatalogReaderFunc(func(_ context.Context, apiName string) (bool, error) {
		return apiName == "ReadProgramNode", nil
	})
	validation := ValidationRuleReaderFunc(func(_ context.Context, objectAPIName string) ([]*models.ValidationRule, error) {
		switch objectAPIName {
		case "ProgramNode":
			return []*models.ValidationRule{{
				ObjectAPIName: "ProgramNode",
				Name:          "Program_Node_Gate",
				Active:        true,
				OwnerPack:     &ownerPack,
			}}, nil
		case "ProgramParent":
			return []*models.ValidationRule{{
				ObjectAPIName: "ProgramParent",
				Name:          "Program_Parent_Gate",
				Active:        true,
				OwnerPack:     &ownerPack,
			}}, nil
		default:
			return nil, nil
		}
	})

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata:     reader,
		Policy:       policy,
		Capabilities: capabilities,
		Validation:   validation,
		OwnerPack:    "program-pack",
	})
	if len(diags) != 0 {
		t.Fatalf("CheckObjectAccessPolicyReferences diagnostics = %#v, want none", diags)
	}
	if len(policy.accesses) == 0 {
		t.Fatal("policy reader was not called for access-policy metadata")
	}
}

func TestCheckObjectAccessPolicyReferencesAcceptsAssignmentEligibilityProjection(t *testing.T) {
	t.Parallel()

	referenceType := models.FieldType(constants.FieldTypeReference)
	member := objectForFlowCheck("Member__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Enabled__c", Type: constants.FieldTypeBoolean},
		{APIName: "Archived__c", Type: constants.FieldTypeBoolean},
		{APIName: "TierId__c", Type: constants.FieldTypeString, LogicalType: &referenceType, ReferenceTo: []string{"Tier__c"}},
	})
	member.AccessPolicy = &models.ObjectAccessPolicy{Lifecycle: &models.RecordLifecycleAccessPolicy{
		AssignmentPolicyEligibility: &models.AssignmentPolicyEligibilityLifecyclePolicy{
			Role:           models.AssignmentPolicyEligibilitySubjectPopulation,
			SubjectIDField: constants.FieldID,
			EnabledWhen: []models.AssignmentPolicyEligibilityBoolCondition{
				{Field: "Enabled__c", Equals: true},
				{Field: "Archived__c", Equals: false},
			},
			Selector: models.AssignmentPolicyEligibilitySelector{Reference: &models.AssignmentPolicyEligibilityReferenceSelector{
				SourceField: "TierId__c", TargetObject: "Tier__c", TargetField: "Label__c",
			}},
		},
		Hooks: []models.RecordLifecycleHookPolicy{
			{Phase: string(sharedcontract.RecordLifecycleAfterCreateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterUpdateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterDeleteInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
		},
	}}
	tier := objectForFlowCheck("Tier__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Label__c", Type: constants.FieldTypeString},
	})
	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(member, tier),
	})
	if len(diags) != 0 {
		t.Fatalf("CheckObjectAccessPolicyReferences diagnostics = %#v, want none", diags)
	}
}

func TestCheckObjectAccessPolicyReferencesRejectsAssignmentEligibilityWithoutRole(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("Workspace__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Active__c", Type: constants.FieldTypeBoolean},
		{APIName: "Label__c", Type: constants.FieldTypeString},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{Lifecycle: &models.RecordLifecycleAccessPolicy{
		AssignmentPolicyEligibility: &models.AssignmentPolicyEligibilityLifecyclePolicy{
			EnabledWhen: []models.AssignmentPolicyEligibilityBoolCondition{{Field: "Active__c", Equals: true}},
			Selector:    models.AssignmentPolicyEligibilitySelector{Field: "Label__c"},
		},
		Hooks: []models.RecordLifecycleHookPolicy{
			{Phase: string(sharedcontract.RecordLifecycleAfterCreateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterUpdateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterDeleteInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
		},
	}}
	diagnostics := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(node),
	})
	if got := countDiagnosticsWithCode(diagnostics, "metadata_program.invalid_policy_reference"); got != 1 ||
		!strings.Contains(diagnostics[0].Message, "role must be") {
		t.Fatalf("missing assignment-policy role diagnostics = %#v", diagnostics)
	}
}

func TestCheckObjectAccessPolicyReferencesDoesNotReinterpretAppLifecycleCapability(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("Member__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{Lifecycle: &models.RecordLifecycleAccessPolicy{
		Hooks: []models.RecordLifecycleHookPolicy{
			{Phase: string(sharedcontract.RecordLifecycleAfterCreateInTx), Capability: "app.assignment-policy-eligibility"},
			{Phase: string(sharedcontract.RecordLifecycleAfterUpdateInTx), Capability: "app.assignment-policy-eligibility"},
			{Phase: string(sharedcontract.RecordLifecycleAfterDeleteInTx), Capability: "app.assignment-policy-eligibility"},
		},
	}}
	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(node),
	})
	for _, diagnostic := range diags {
		if strings.Contains(diagnostic.Message, "requires its Catalog projection") {
			t.Fatalf("app-owned lifecycle capability was reinterpreted as the platform assignment-policy capability: %#v", diags)
		}
	}
}

func TestCheckObjectAccessPolicyReferencesRejectsAssignmentEligibilityTypeDrift(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("Member__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Enabled__c", Type: constants.FieldTypeString},
		{APIName: "TierId__c", Type: constants.FieldTypeString},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{Lifecycle: &models.RecordLifecycleAccessPolicy{
		AssignmentPolicyEligibility: &models.AssignmentPolicyEligibilityLifecyclePolicy{
			Role:           models.AssignmentPolicyEligibilitySubjectPopulation,
			SubjectIDField: constants.FieldID,
			EnabledWhen:    []models.AssignmentPolicyEligibilityBoolCondition{{Field: "Enabled__c", Equals: true}},
			Selector: models.AssignmentPolicyEligibilitySelector{Reference: &models.AssignmentPolicyEligibilityReferenceSelector{
				SourceField: "TierId__c", TargetObject: "Tier__c", TargetField: "Enabled__c",
			}},
		},
		Hooks: []models.RecordLifecycleHookPolicy{
			{Phase: string(sharedcontract.RecordLifecycleAfterCreateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterUpdateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterDeleteInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
		},
	}}
	tier := objectForFlowCheck("Tier__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Enabled__c", Type: constants.FieldTypeBoolean},
	})
	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(node, tier),
	})
	if got := countDiagnosticsWithCode(diags, "metadata_program.invalid_policy_reference"); got != 3 {
		t.Fatalf("invalid assignment-policy type diagnostics = %d, want 3: %#v", got, diags)
	}
}

func TestCheckObjectAccessPolicyReferencesRejectsAssignmentEligibilityReferenceTargetDrift(t *testing.T) {
	t.Parallel()

	member := objectForFlowCheck("Member__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Enabled__c", Type: constants.FieldTypeBoolean},
		{APIName: "TierId__c", Type: constants.FieldTypeReference, ReferenceTo: []string{"OtherTier__c"}},
	})
	member.AccessPolicy = &models.ObjectAccessPolicy{Lifecycle: &models.RecordLifecycleAccessPolicy{
		AssignmentPolicyEligibility: &models.AssignmentPolicyEligibilityLifecyclePolicy{
			Role:           models.AssignmentPolicyEligibilitySubjectPopulation,
			SubjectIDField: constants.FieldID,
			EnabledWhen:    []models.AssignmentPolicyEligibilityBoolCondition{{Field: "Enabled__c", Equals: true}},
			Selector: models.AssignmentPolicyEligibilitySelector{Reference: &models.AssignmentPolicyEligibilityReferenceSelector{
				SourceField: "TierId__c", TargetObject: "Tier__c", TargetField: "Label__c",
			}},
		},
		Hooks: []models.RecordLifecycleHookPolicy{
			{Phase: string(sharedcontract.RecordLifecycleAfterCreateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterUpdateInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
			{Phase: string(sharedcontract.RecordLifecycleAfterDeleteInTx), Capability: sharedcontract.AssignmentPolicyEligibilityLifecycleCapability},
		},
	}}
	tier := objectForFlowCheck("Tier__c", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "Label__c", Type: constants.FieldTypeString},
	})
	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(member, tier),
	})
	if got := countDiagnosticsWithCode(diags, "metadata_program.invalid_policy_reference"); got != 1 {
		t.Fatalf("invalid assignment-policy reference-target diagnostics = %d, want 1: %#v", got, diags)
	}
}

func TestCheckObjectAccessPolicyReferencesValidatesGroupedTargetRecordReadPolicy(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: "WhoType", Type: constants.FieldTypeString},
		{APIName: "WhoId", Type: constants.FieldTypeReference},
		{APIName: "WhatType", Type: constants.FieldTypeString},
		{APIName: "WhatId", Type: constants.FieldTypeReference},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{RowRead: &models.RowReadPolicy{
		Kind: "targetRecordRead",
		TargetAssociations: []models.RowReadTargetAssociation{
			{TargetObjectField: "WhoType", TargetRecordField: "WhoId"},
			{TargetObjectField: "WhatType", TargetRecordField: "WhatId"},
		},
	}}
	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(node),
	})
	if len(diags) != 0 {
		t.Fatalf("valid grouped targetRecordRead diagnostics = %#v", diags)
	}

	node.AccessPolicy.RowRead.TargetObjectField = "WhoType"
	diags = CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: newFakeProgramMetadataReader(node),
	})
	assertDiagnosticCode(t, diags, "metadata_program.invalid_policy_reference")
	assertDiagnosticPointer(t, diags, "/accessPolicy/rowRead/targetAssociations")
}

func TestCheckObjectAccessPolicyReferencesReportsUnresolvedPolicyRefs(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{
		{APIName: constants.FieldID, Type: constants.FieldTypeString},
		{APIName: constants.FieldOwnerID, Type: constants.FieldTypeReference},
	})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{
			Kind:    "systemPermission",
			APIName: "MissingProgramPermission",
		},
		ObjectCreate: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "MissingObject",
			Operation: "Merge",
		},
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:  "recordFieldEqualsCurrentUser",
			Field: "MissingOwner",
		},
		ObjectDelete: &models.AccessPolicyExpr{Kind: "unsupportedPolicyKind"},
		FieldRead:    &models.AccessPolicyExpr{Inherit: "badTarget"},
		FieldEdit: &models.AccessPolicyExpr{
			Kind:    "permission",
			APIName: "MissingProgramPermission",
		},
		RowRead: &models.RowReadPolicy{
			Kind:            "ownerVisibility",
			VisibilityField: "MissingVisibility",
		},
		RecordMutation: &models.RecordMutationAccessPolicy{Deny: []models.RecordMutationDenial{{
			Surfaces: []string{"singleUpdtae"},
			When: &models.RecordMutationCondition{RelatedExists: &models.RecordMutationRelatedExistsCondition{
				ObjectAPIName: "ProgramChild",
				ParentField:   "MissingParent",
			}},
		}}},
		Lifecycle: &models.RecordLifecycleAccessPolicy{Hooks: []models.RecordLifecycleHookPolicy{{
			Phase: "after_commit",
		}}},
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{{
				Phase: "during_create",
			}},
			RequiredValidationRules: []models.RecordWriteValidationRuleRequirement{{
				Phase: string(sharedcontract.RecordWriteValidationBeforeCreate),
			}},
			OwnerResolution: &models.OwnerResolutionSpec{Paths: []models.OwnerResolutionPath{
				{Hops: []models.OwnerResolutionHop{
					{Self: true, Gate: &models.OwnerPredicate{Field: "MissingGate"}},
					{ViaField: "MissingOwnerLink", ToObject: "MissingOwner", SystemWhen: &models.OwnerPredicate{Field: "MissingSystemFlag"}},
				}},
				{Hops: []models.OwnerResolutionHop{{Self: true}}},
			}},
		},
	}

	reader := newFakeProgramMetadataReader(
		node,
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}}),
	)
	capabilities := CapabilityCatalogReaderFunc(func(context.Context, string) (bool, error) {
		return false, nil
	})

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata:     reader,
		Capabilities: capabilities,
	})
	assertDiagnosticCode(t, diags, "metadata_program.reader_error")
	assertDiagnosticCode(t, diags, "metadata_program.missing_capability")
	assertDiagnosticCode(t, diags, "metadata_program.missing_object")
	assertDiagnosticCode(t, diags, "metadata_program.missing_field")
	assertDiagnosticCode(t, diags, "metadata_program.invalid_policy_reference")
	assertDiagnosticPointer(t, diags, "/accessPolicy/objectRead/apiName")
	assertDiagnosticPointer(t, diags, "/accessPolicy/fieldEdit/kind")
	assertDiagnosticPointer(t, diags, "/accessPolicy/rowRead/visibilityField")
	assertDiagnosticPointer(t, diags, "/accessPolicy/recordMutation/deny/0/surfaces/0")
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/ownerResolution/paths/0/hops/0/gate/field")
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/ownerResolution/paths/1/hops")
}

func TestCheckObjectAccessPolicyReferencesRejectsUnresolvedRequiredValidationRules(t *testing.T) {
	t.Parallel()

	ownerPack := "program-pack"
	otherPack := "other-pack"
	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			RequiredValidationRules: []models.RecordWriteValidationRuleRequirement{
				{Phase: string(sharedcontract.RecordWriteValidationBeforeCreate), Name: "Active_Rule", OwnerPack: ownerPack},
				{Phase: string(sharedcontract.RecordWriteValidationBeforeCreate), Name: "Customized_Rule", OwnerPack: ownerPack},
				{Phase: string(sharedcontract.RecordWriteValidationBeforeCreate), Name: "Inactive_Rule", OwnerPack: ownerPack},
				{Phase: string(sharedcontract.RecordWriteValidationBeforeCreate), Name: "Wrong_Owner_Rule", OwnerPack: ownerPack},
				{Phase: string(sharedcontract.RecordWriteValidationBeforeCreate), Name: "Missing_Rule", OwnerPack: ownerPack},
			},
		},
	}
	reader := newFakeProgramMetadataReader(node)
	validation := ValidationRuleReaderFunc(func(_ context.Context, objectAPIName string) ([]*models.ValidationRule, error) {
		if objectAPIName != "ProgramNode" {
			return nil, nil
		}
		return []*models.ValidationRule{
			{ObjectAPIName: "ProgramNode", Name: "Active_Rule", Active: true, OwnerPack: &ownerPack},
			{ObjectAPIName: "ProgramNode", Name: "Customized_Rule", Active: true, IsCustomized: true, OwnerPack: &ownerPack},
			{ObjectAPIName: "ProgramNode", Name: "Inactive_Rule", Active: false, OwnerPack: &ownerPack},
			{ObjectAPIName: "ProgramNode", Name: "Wrong_Owner_Rule", Active: true, OwnerPack: &otherPack},
		}, nil
	})

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata:   reader,
		Validation: validation,
	})
	assertDiagnosticCode(t, diags, "metadata_program.missing_validation_rule")
	if got := countDiagnosticsWithCode(diags, "metadata_program.missing_validation_rule"); got != 4 {
		t.Fatalf("missing_validation_rule diagnostics = %d, want 4: %#v", got, diags)
	}
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/requiredValidationRules/1")
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/requiredValidationRules/2")
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/requiredValidationRules/3")
	assertDiagnosticPointer(t, diags, "/accessPolicy/writeValidation/requiredValidationRules/4")
}

func TestCheckObjectAccessPolicyReferencesRejectsObjectExpressionsWithoutDerivedMode(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{Kind: "systemPermission", APIName: "ReadProgramNode"},
	}
	reader := newFakeProgramMetadataReader(node)
	capabilities := CapabilityCatalogReaderFunc(func(_ context.Context, apiName string) (bool, error) {
		return apiName == "ReadProgramNode", nil
	})

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata:     reader,
		Policy:       &fakeObjectAccessPolicyReader{},
		Capabilities: capabilities,
	})
	assertDiagnosticPointer(t, diags, "/accessPolicy/mode")
}

func TestCheckObjectAccessPolicyReferencesRejectsObjectPermissionOperationAlias(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectRead: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "ProgramParent",
			Operation: "objectRead",
		},
	}
	reader := newFakeProgramMetadataReader(
		node,
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}}),
	)

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: reader,
		Policy:   &fakeObjectAccessPolicyReader{},
	})
	assertDiagnosticPointer(t, diags, "/accessPolicy/objectRead/operation")
}

func TestCheckObjectAccessPolicyReferencesAcceptsAllRecordObjectPermissionOperations(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectRead: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "ProgramParent",
			Operation: constants.PermViewAllRecords,
		},
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "ProgramParent",
			Operation: constants.PermModifyAllRecords,
		},
	}
	reader := newFakeProgramMetadataReader(
		node,
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}}),
	)

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: reader,
		Policy:   &fakeObjectAccessPolicyReader{},
	})
	if len(diags) != 0 {
		t.Fatalf("CheckObjectAccessPolicyReferences diagnostics = %#v, want none", diags)
	}
}

func TestCheckObjectAccessPolicyReferencesRejectsAllRecordObjectPermissionOnDerivedTarget(t *testing.T) {
	t.Parallel()

	node := objectForFlowCheck("ProgramNode", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	node.AccessPolicy = &models.ObjectAccessPolicy{
		Mode: "derived",
		ObjectEdit: &models.AccessPolicyExpr{
			Kind:      "objectPermission",
			Object:    "DerivedParent",
			Operation: constants.PermModifyAllRecords,
		},
	}
	derivedParent := objectForFlowCheck("DerivedParent", []models.FieldMetadata{{APIName: constants.FieldID, Type: constants.FieldTypeString}})
	derivedParent.AccessPolicy = &models.ObjectAccessPolicy{
		Mode:       "derived",
		ObjectRead: &models.AccessPolicyExpr{Kind: "systemPermission", APIName: "ReadDerivedParent"},
	}
	reader := newFakeProgramMetadataReader(node, derivedParent)

	diags := CheckObjectAccessPolicyReferences(context.Background(), ObjectAccessPolicyReferenceCheckOptions{
		Metadata: reader,
		Policy:   &fakeObjectAccessPolicyReader{},
	})
	assertDiagnosticPointer(t, diags, "/accessPolicy/objectEdit/operation")
}

type fakeObjectAccessPolicyReader struct {
	accesses []string
}

func (r *fakeObjectAccessPolicyReader) Access(_ context.Context, _ *models.UserSession, objectName string, _ sharedcontract.AccessRequest) (*sharedcontract.EffectiveObjectAccess, error) {
	r.accesses = append(r.accesses, objectName)
	return &sharedcontract.EffectiveObjectAccess{}, nil
}

func (r *fakeObjectAccessPolicyReader) HasCapability(context.Context, *models.UserSession, sharedcontract.Capability) bool {
	return false
}

func assertDiagnosticPointer(t *testing.T, diags Diagnostics, pointer string) {
	t.Helper()
	for _, diag := range diags {
		if diag.JSONPointer == pointer {
			return
		}
	}
	t.Fatalf("diagnostic pointer %q not found in %#v", pointer, diags)
}

func countDiagnosticsWithCode(diags Diagnostics, code string) int {
	count := 0
	for _, diag := range diags {
		if diag.Code == code {
			count++
		}
	}
	return count
}
