package records

// Tests for normalizeObjectSecurityContract / validateObjectSecurityContract.
//
// These tests cover the SharingRule write-contract logic that runs after the
// generic required-field gate in
// backend/internal/interfaces/rest/sobject_records.go. The schema-level
// fact that SharingRule.Criteria is Nullable=true (which lets owner-based
// payloads reach this validator at all) is locked in by
// backend/internal/bootstrap/system_tables_spec_test.go.
//
// pingcap-inc/NexusCRM#395.

import (
	"context"
	"errors"
	"testing"

	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	metadataFeature "github.com/nexuscrm/metadata"
	"github.com/nexuscrm/permissions"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// -- helpers ---------------------------------------------------------------

func ownerBasedRoleRecord() models.SObject {
	return models.SObject{
		constants.FieldSharingRule_Name:             "qa_owner_role_rule",
		constants.FieldSharingRule_ObjectAPIName:    "Account",
		"AccessLevel":                               "Read",
		constants.FieldSharingRule_SharedFromRoleID: "00E000000000001",
		constants.FieldSharingRule_ShareWithRoleID:  "00E000000000002",
	}
}

func ownerBasedGroupRecord() models.SObject {
	return models.SObject{
		constants.FieldSharingRule_Name:              "qa_owner_group_rule",
		constants.FieldSharingRule_ObjectAPIName:     "Account",
		"AccessLevel":                                "Read",
		constants.FieldSharingRule_SharedFromGroupID: "00G000000000001",
		constants.FieldSharingRule_ShareWithGroupID:  "00G000000000002",
	}
}

func criteriaBasedRecord() models.SObject {
	return models.SObject{
		constants.FieldSharingRule_Name:            "qa_criteria_rule",
		constants.FieldSharingRule_ObjectAPIName:   "Account",
		"AccessLevel":                              "Read",
		constants.FieldSharingRule_Criteria:        `[{"field":"Industry","op":"=","value":"Tech"}]`,
		constants.FieldSharingRule_ShareWithRoleID: "00E000000000003",
	}
}

func assertValidationFieldErr(t *testing.T, err error, wantField string) {
	t.Helper()
	if err == nil {
		t.Fatalf("validateObjectSecurityContract = nil, want validation error on field %q", wantField)
	}
	var verr *appErrors.ValidationError
	if !errors.As(err, &verr) {
		t.Fatalf("validateObjectSecurityContract returned %T (%v), want *appErrors.ValidationError", err, err)
	}
	if verr.Field != wantField {
		t.Fatalf("validation error field = %q, want %q (msg=%q)", verr.Field, wantField, verr.Message)
	}
}

func TestValidateFieldPermissionsSecurityContractEditRequiresRead(t *testing.T) {
	err := validateObjectSecurityContract(constants.TableFieldPermissions, models.SObject{
		constants.FieldFieldPermissions_ParentID:        "0PS-test",
		constants.FieldFieldPermissions_SobjectType:     "Account",
		constants.FieldFieldPermissions_Field:           "Account.Name",
		constants.FieldFieldPermissions_PermissionsRead: false,
		constants.FieldFieldPermissions_PermissionsEdit: true,
	})
	assertValidationFieldErr(t, err, constants.FieldFieldPermissions_PermissionsEdit)
}

func TestValidateFieldPermissionsSecurityContractCoherentImagePasses(t *testing.T) {
	err := validateObjectSecurityContract(constants.TableFieldPermissions, models.SObject{
		constants.FieldFieldPermissions_ParentID:        "0PS-test",
		constants.FieldFieldPermissions_SobjectType:     "Account",
		constants.FieldFieldPermissions_Field:           "Account.Name",
		constants.FieldFieldPermissions_PermissionsRead: true,
		constants.FieldFieldPermissions_PermissionsEdit: true,
	})
	if err != nil {
		t.Fatalf("validateObjectSecurityContract() = %v, want coherent field grant", err)
	}
}

// -- valid acceptance cases (issue ACs 1, 2 and the group-symmetric variant) -

// TestValidateObjectSecurityContract_OwnerBased_RoleVariant_Nil pins issue
// AC #1 directly: an owner-based rule using SharedFromRoleId + ShareWithRoleId
// without any Criteria value must pass validation. This is the case the bug
// fix unblocks.
func TestValidateObjectSecurityContract_OwnerBased_RoleVariant_Nil(t *testing.T) {
	rec := ownerBasedRoleRecord()
	if err := validateSharingRuleShapeContract(rec); err != nil {
		t.Fatalf("owner-based role-to-role validate = %v, want nil", err)
	}
}

// TestValidateObjectSecurityContract_OwnerBased_GroupVariant_Nil mirrors the
// role variant on the group side: SharedFromGroupId + ShareWithGroupId is also
// a valid owner-based rule per Salesforce v63 and must pass.
func TestValidateObjectSecurityContract_OwnerBased_GroupVariant_Nil(t *testing.T) {
	rec := ownerBasedGroupRecord()
	if err := validateSharingRuleShapeContract(rec); err != nil {
		t.Fatalf("owner-based group-to-group validate = %v, want nil", err)
	}
}

// TestValidateObjectSecurityContract_CriteriaBased_Nil pins issue AC #2:
// criteria-based rules (Criteria set, no SharedFromRoleId or
// SharedFromGroupId) are accepted.
func TestValidateObjectSecurityContract_CriteriaBased_Nil(t *testing.T) {
	rec := criteriaBasedRecord()
	if err := validateSharingRuleShapeContract(rec); err != nil {
		t.Fatalf("criteria-based validate = %v, want nil", err)
	}
}

// -- cross-type rejection cases (issue AC 3 + group-symmetric variant) ------

// TestValidateObjectSecurityContract_CrossType_RoleVariant_Rejected pins issue
// AC #3: combining Criteria with SharedFromRoleId is invalid (the rule cannot
// be both criteria-based and owner-based).
func TestValidateObjectSecurityContract_CrossType_RoleVariant_Rejected(t *testing.T) {
	rec := criteriaBasedRecord()
	rec[constants.FieldSharingRule_SharedFromRoleID] = "00E000000000099"

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "Criteria")
}

// TestValidateObjectSecurityContract_CrossType_GroupVariant_Rejected covers
// the symmetric cross-type case on the group side: Criteria + SharedFromGroupId
// must also be rejected.
func TestValidateObjectSecurityContract_CrossType_GroupVariant_Rejected(t *testing.T) {
	rec := criteriaBasedRecord()
	rec[constants.FieldSharingRule_SharedFromGroupID] = "00G000000000099"

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "Criteria")
}

// -- missing-source rejection (issue AC 4) ---------------------------------

// TestValidateObjectSecurityContract_NoSourceAtAll_Rejected pins issue AC #4:
// a rule with neither Criteria nor SharedFromRoleId nor SharedFromGroupId has
// no source specified and must be rejected.
func TestValidateObjectSecurityContract_NoSourceAtAll_Rejected(t *testing.T) {
	rec := models.SObject{
		constants.FieldSharingRule_Name:            "qa_no_source_rule",
		constants.FieldSharingRule_ObjectAPIName:   "Account",
		"AccessLevel":                              "Read",
		constants.FieldSharingRule_ShareWithRoleID: "00E000000000003",
	}

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "SharedFrom")
}

// -- ShareWith symmetry coverage (line-37 == predicate) ---------------------
// These two tests pin the line-37 guard hasShareWithRole == hasShareWithGroup.
// Go bool equality covers both false==false (both empty) and true==true (both
// set). Without explicit coverage, a future refactor that breaks the `==` into
// e.g. `&&` would silently invert the gate's behavior.

// TestValidateObjectSecurityContract_ShareWithBothEmpty_Rejected covers the
// false==false branch: neither ShareWithRoleId nor ShareWithGroupId is set.
func TestValidateObjectSecurityContract_ShareWithBothEmpty_Rejected(t *testing.T) {
	rec := models.SObject{
		constants.FieldSharingRule_Name:             "qa_no_share_with",
		constants.FieldSharingRule_ObjectAPIName:    "Account",
		"AccessLevel":                               "Read",
		constants.FieldSharingRule_SharedFromRoleID: "00E000000000001",
	}

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "ShareWith")
}

// TestValidateObjectSecurityContract_ShareWithBothSet_Rejected covers the
// true==true branch: both ShareWithRoleId and ShareWithGroupId are set, which
// would target two recipients ambiguously.
func TestValidateObjectSecurityContract_ShareWithBothSet_Rejected(t *testing.T) {
	rec := ownerBasedRoleRecord()
	rec[constants.FieldSharingRule_ShareWithGroupID] = "00G000000000099"

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "ShareWith")
}

// TestValidateObjectSecurityContract_SharedFromBothSet_Rejected covers the
// true==true branch of the SharedFrom == guard: both SharedFromRoleId and
// SharedFromGroupId set simultaneously is ambiguous and must be rejected.
// Counterpart to NoSourceAtAll_Rejected (false==false), together pinning
// both branches the comment in security_contracts.go:53 calls out.
func TestValidateObjectSecurityContract_SharedFromBothSet_Rejected(t *testing.T) {
	rec := ownerBasedRoleRecord()
	rec[constants.FieldSharingRule_SharedFromGroupID] = "00G000000000099"

	err := validateSharingRuleShapeContract(rec)
	assertValidationFieldErr(t, err, "SharedFrom")
}

// -- non-SharingRule no-op --------------------------------------------------

// TestValidateObjectSecurityContract_NonSharingRule_NoOp confirms the validator
// short-circuits for any object name other than SharingRule. A future refactor
// that broadens this contract beyond SharingRule must update this test
// deliberately.
func TestValidateObjectSecurityContract_NonSharingRule_NoOp(t *testing.T) {
	rec := models.SObject{
		"Name":     "irrelevant",
		"Industry": "Tech",
	}
	if err := validateObjectSecurityContract("Account", rec); err != nil {
		t.Fatalf("non-SharingRule object = %v, want nil (validator must no-op)", err)
	}
}

func securityCacheInvalidationTestService(objectName string, capability string) *PersistenceService {
	schema := cacheInvalidationProbeSchema(capability)
	schema.APIName = objectName
	return newRecordEntrypointTestService(schema, &captureRecordRepo{})
}

func TestCollectSecurityInvalidationPlan_ObjectPermissionsInvalidatesPermissionCache(t *testing.T) {
	ps := securityCacheInvalidationTestService(constants.TableObjectPermissions, securityCacheInvalidatePermissionsCapability)

	plan, err := ps.collectSecurityInvalidationPlan(context.Background(), constants.TableObjectPermissions, []securityRecordChange{{
		before: models.SObject{
			constants.FieldID: "op-1",
			constants.FieldObjectPermissions_PermissionsRead: false,
		},
		after: models.SObject{
			constants.FieldID: "op-1",
			constants.FieldObjectPermissions_PermissionsRead: true,
		},
	}})
	if err != nil {
		t.Fatalf("collectSecurityInvalidationPlan() error = %v, want nil", err)
	}
	if plan == nil {
		t.Fatal("collectSecurityInvalidationPlan() plan = nil, want non-nil plan")
	}
	if !plan.invalidatePermissions {
		t.Fatal("invalidatePermissions = false, want true for ObjectPermissions writes")
	}
	if plan.invalidateSharing {
		t.Fatal("invalidateSharing = true, want false for ObjectPermissions writes")
	}
	if plan.refreshRoleHierarchy {
		t.Fatal("refreshRoleHierarchy = true, want false for ObjectPermissions writes")
	}
	if len(plan.userIDs) != 0 {
		t.Fatalf("userIDs len = %d, want 0", len(plan.userIDs))
	}
}

func TestCollectSecurityInvalidationPlan_FieldPermissionsInvalidatesPermissionCache(t *testing.T) {
	ps := securityCacheInvalidationTestService(constants.TableFieldPermissions, securityCacheInvalidatePermissionsCapability)

	plan, err := ps.collectSecurityInvalidationPlan(context.Background(), constants.TableFieldPermissions, []securityRecordChange{{
		before: models.SObject{
			constants.FieldID: "fp-1",
			constants.FieldFieldPermissions_PermissionsEdit: true,
		},
		after: models.SObject{
			constants.FieldID: "fp-1",
			constants.FieldFieldPermissions_PermissionsEdit: false,
		},
	}})
	if err != nil {
		t.Fatalf("collectSecurityInvalidationPlan() error = %v, want nil", err)
	}
	if plan == nil {
		t.Fatal("collectSecurityInvalidationPlan() plan = nil, want non-nil plan")
	}
	if !plan.invalidatePermissions {
		t.Fatal("invalidatePermissions = false, want true for FieldPermissions writes")
	}
	if plan.invalidateSharing {
		t.Fatal("invalidateSharing = true, want false for FieldPermissions writes")
	}
	if plan.refreshRoleHierarchy {
		t.Fatal("refreshRoleHierarchy = true, want false for FieldPermissions writes")
	}
	if len(plan.userIDs) != 0 {
		t.Fatalf("userIDs len = %d, want 0", len(plan.userIDs))
	}
}

func TestCollectSecurityInvalidationPlan_PermissionSetPermissionInvalidatesPermissionCache(t *testing.T) {
	ps := securityCacheInvalidationTestService(constants.TablePermissionSetPermission, securityCacheInvalidatePermissionsCapability)

	plan, err := ps.collectSecurityInvalidationPlan(context.Background(), constants.TablePermissionSetPermission, []securityRecordChange{{
		after: models.SObject{
			constants.FieldID: "psp-1",
			constants.FieldSysPermissionSetPermission_PermissionSetID:   "0PS-1",
			constants.FieldSysPermissionSetPermission_PermissionAPIName: "ViewSetup",
			constants.FieldSysPermissionSetPermission_IsEnabled:         true,
		},
	}})
	if err != nil {
		t.Fatalf("collectSecurityInvalidationPlan() error = %v, want nil", err)
	}
	if plan == nil {
		t.Fatal("collectSecurityInvalidationPlan() plan = nil, want non-nil plan")
	}
	if !plan.invalidatePermissions {
		t.Fatal("invalidatePermissions = false, want true for PermissionSetPermission writes")
	}
	if plan.invalidateSharing {
		t.Fatal("invalidateSharing = true, want false for PermissionSetPermission writes")
	}
	if plan.refreshRoleHierarchy {
		t.Fatal("refreshRoleHierarchy = true, want false for PermissionSetPermission writes")
	}
	if len(plan.userIDs) != 0 {
		t.Fatalf("userIDs len = %d, want 0", len(plan.userIDs))
	}
}

func TestInvalidateSecurityCachesForChangesInvalidatesOAuthCustomScopePolicy(t *testing.T) {
	ps := &PersistenceService{}
	calls := 0
	ps.SetOAuthScopePolicyInvalidator(func() {
		calls++
	})

	ps.invalidateSecurityCachesForChanges(context.Background(), constants.TableOAuthCustomScope, []securityRecordChange{{
		after: models.SObject{constants.FieldID: "scope-1"},
	}})
	if calls != 1 {
		t.Fatalf("oauth custom scope invalidator calls = %d, want 1", calls)
	}

	ps.invalidateSecurityCachesForChanges(context.Background(), constants.TableUser, []securityRecordChange{{
		after: models.SObject{constants.FieldID: "user-1"},
	}})
	if calls != 1 {
		t.Fatalf("oauth custom scope invalidator should ignore unrelated objects, calls = %d", calls)
	}
}

func TestInvalidateSecurityCachesForChangesDefersOAuthCustomScopePolicyUntilCommit(t *testing.T) {
	ps := &PersistenceService{txManager: sourceAwareInlineTxManager{}}
	calls := 0
	ps.SetOAuthScopePolicyInvalidator(func() {
		calls++
	})
	txCtx := ports.WithAfterCommitHooks(context.Background())

	ps.invalidateSecurityCachesForChanges(txCtx, constants.TableOAuthCustomScope, []securityRecordChange{{
		after: models.SObject{constants.FieldID: "scope-1"},
	}})
	if calls != 0 {
		t.Fatalf("oauth custom scope invalidator ran before commit, calls = %d", calls)
	}

	ports.RunAfterCommitHooks(txCtx)
	if calls != 1 {
		t.Fatalf("oauth custom scope invalidator calls after commit = %d, want 1", calls)
	}
}

func TestInvalidateSecurityCachesForChangesFallbackFlushesAllSecurityCaches(t *testing.T) {
	ctx := context.Background()
	schema := cacheInvalidationProbeSchema(securityCacheInvalidateSharingRulesCapability)
	schema.APIName = constants.TableSharingRule
	schema.SharingModel = constants.SharingModelPrivate
	parentRoleID := "00E-parent"
	childRoleID := "00E-child"
	metaRepo := &stubUpsertMetaRepo{schema: schema}
	metaSvc := metadataFeature.NewMetadataService(metaRepo, nil, nil)
	permRepo := &stubUpsertPermRepo{
		roles: []*models.UserRole{
			{ID: parentRoleID},
			{ID: childRoleID, ParentRoleID: &parentRoleID},
		},
	}
	permSvc := permissions.NewPermissionService(permRepo, metaSvc, nil)
	permCache := &restorePermissionCacheSpy{}
	permSvc.SetCacheInvalidator(permCache)
	ps := &PersistenceService{
		metadata:           metaSvc,
		permissions:        permSvc,
		cacheInvalidations: newRecordCacheInvalidations(),
	}
	user := &models.UserSession{ID: "005-user", ProfileID: "00e-profile"}
	record := models.SObject{constants.FieldID: "rec-1"}

	permSvc.CheckRecordAccess(ctx, schema, record, constants.PermRead, user)
	permSvc.CheckRecordAccess(ctx, schema, record, constants.PermRead, user)
	if metaRepo.sharingRuleCalls != 1 {
		t.Fatalf("sharing rule metadata calls before fallback = %d, want cached at 1", metaRepo.sharingRuleCalls)
	}

	permRepo.roles = []*models.UserRole{{ID: parentRoleID}}
	if !securityContractContainsString(permSvc.GetRoleDescendants(parentRoleID), childRoleID) {
		t.Fatalf("role hierarchy precondition failed: child role was not cached under parent")
	}

	ps.invalidateSecurityCachesForChanges(ctx, schema.APIName, []securityRecordChange{{
		after: models.SObject{constants.FieldID: "probe-1"},
	}})

	if permCache.invalidatedAll != 1 {
		t.Fatalf("global permission cache invalidations = %d, want 1", permCache.invalidatedAll)
	}
	permSvc.CheckRecordAccess(ctx, schema, record, constants.PermRead, user)
	if metaRepo.sharingRuleCalls != 2 {
		t.Fatalf("sharing rule metadata calls after fallback = %d, want cache miss at 2", metaRepo.sharingRuleCalls)
	}
	if securityContractContainsString(permSvc.GetRoleDescendants(parentRoleID), childRoleID) {
		t.Fatalf("role hierarchy still contains %q under %q after fallback refresh", childRoleID, parentRoleID)
	}
}

func TestInvalidateSecurityCachesForChangesFallsBackWhenSecurityCapabilitiesMissing(t *testing.T) {
	schema := cacheInvalidationProbeSchema()
	schema.APIName = constants.TableObjectPermissions
	ps := newRecordEntrypointTestService(schema, &captureRecordRepo{})
	permCache := &restorePermissionCacheSpy{}
	ps.permissions.SetCacheInvalidator(permCache)

	ps.invalidateSecurityCachesForChanges(context.Background(), constants.TableObjectPermissions, []securityRecordChange{{
		after: models.SObject{constants.FieldID: "op-1"},
	}})

	if permCache.invalidatedAll != 1 {
		t.Fatalf("global permission cache invalidations = %d, want 1", permCache.invalidatedAll)
	}
}

func securityContractContainsString(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}

type connectedAppInvalidationHookStub struct {
	appIDs []string
}

func (h *connectedAppInvalidationHookStub) OnConnectedAppChanged(appID string) {
	h.appIDs = append(h.appIDs, appID)
}

func TestInvalidateConnectedAppCacheDefersUntilCommit(t *testing.T) {
	hook := &connectedAppInvalidationHookStub{}
	ps := &PersistenceService{txManager: sourceAwareInlineTxManager{}}
	ps.SetConnectedAppInvalidationHook(hook)
	txCtx := ports.WithAfterCommitHooks(context.Background())

	ps.invalidateConnectedAppCache(txCtx, "app-001")
	if len(hook.appIDs) != 0 {
		t.Fatalf("connected app invalidation ran before commit: %#v", hook.appIDs)
	}

	ports.RunAfterCommitHooks(txCtx)
	if len(hook.appIDs) != 1 || hook.appIDs[0] != "app-001" {
		t.Fatalf("connected app invalidations = %#v, want [app-001]", hook.appIDs)
	}
}

// -- normalize → validate roundtrip ----------------------------------------

// TestValidateObjectSecurityContract_NormalizeRoundtrip_NullCriteria_OwnerBasedAccepted
// pins the contract that a payload arriving with Criteria=null (or empty
// string, or the literal placeholder "[]") is normalized to absent before the
// validator sees it, and the result routes through the owner-based branch
// without false positives. Without this test, a future change to the
// normalization rule (e.g. dropping the null→"" coercion) would silently
// reintroduce the wire-shape divergence covered by issue ACs 1 and 4.
func TestValidateObjectSecurityContract_NormalizeRoundtrip_NullCriteria_OwnerBasedAccepted(t *testing.T) {
	cases := []struct {
		name     string
		criteria interface{}
	}{
		{"explicit_null", nil},
		{"empty_string", ""},
		{"empty_array_literal", "[]"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := ownerBasedRoleRecord()
			rec[constants.FieldSharingRule_Criteria] = tc.criteria

			normalizeObjectSecurityContract(constants.TableSharingRule, rec)

			if err := validateSharingRuleShapeContract(rec); err != nil {
				t.Fatalf("after normalize, owner-based with %s Criteria validate = %v, want nil",
					tc.name, err)
			}
		})
	}
}
