package records

// persistence_update_access_test.go
//
// Regression coverage for issue #700 — PATCH record-existence leak.
// F1/F2: loadAndValidateAccess returns *appErrors.NotFoundError("Record","")
// uniformly for both missing records (F1) and inaccessible records (F2),
// closing the 400-vs-403 differential that let authenticated users
// determine which record IDs exist.
// F3: byte-identical error bodies for F1 and F2.
// F6: bulkErrorEntry routes NotFoundError to neutral processing_halted.

import (
	"context"
	"errors"
	"testing"

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

// ---------------------------------------------------------------------------
// F1: Missing record returns *appErrors.NotFoundError("Record", "")
// ---------------------------------------------------------------------------

func TestPrepareUpdateBeforeSave_MissingRecord_ReturnsRecordNotFound(t *testing.T) {
	schema := &models.ObjectMetadata{
		APIName:      "Account",
		SharingModel: constants.SharingModelPrivate,
	}
	permRepo := &stubUpsertPermRepo{grantEdit: true}
	metaRepo := &stubUpsertMetaRepo{schema: schema}
	repo := &stubUpsertRecordRepo{} // GetLock returns nil (default)

	ps := newUpsertTestPersistenceService(t, repo, permRepo, metaRepo, schema)
	ctx := context.Background()
	user := &models.UserSession{ID: "user-1", ProfileID: "prof-std"}

	_, err := ps.prepareUpdateBeforeSave(ctx, "Account", "nonexistent-id", schema, models.SObject{"Name": "x"}, nil, user)
	if err == nil {
		t.Fatal("expected error for missing record, got nil")
	}

	var nfErr *appErrors.NotFoundError
	if !errors.As(err, &nfErr) {
		t.Fatalf("err type = %T; want *appErrors.NotFoundError", err)
	}
	if nfErr.Resource != "Record" {
		t.Errorf("Resource = %q; want %q", nfErr.Resource, "Record")
	}
	if nfErr.ID != "" {
		t.Errorf("ID = %q; want empty", nfErr.ID)
	}
	if nfErr.Error() != "Record not found" {
		t.Errorf("Error() = %q; want %q", nfErr.Error(), "Record not found")
	}
}

// ---------------------------------------------------------------------------
// F2: Record access denied returns *appErrors.NotFoundError("Record", "")
// ---------------------------------------------------------------------------

func TestPrepareUpdateBeforeSave_RecordAccessDenied_ReturnsRecordNotFound(t *testing.T) {
	schema := &models.ObjectMetadata{
		APIName:      "Account",
		SharingModel: constants.SharingModelPrivate,
	}
	permRepo := &stubUpsertPermRepo{grantEdit: true}
	metaRepo := &stubUpsertMetaRepo{schema: schema}

	// GetLock returns a record owned by a different user → CheckRecordAccess fails
	repo := &stubUpsertRecordRepo{
		getLockFn: func(ctx context.Context, t string, id string) (models.SObject, error) {
			return models.SObject{
				constants.FieldID:      id,
				constants.FieldOwnerID: "other-user",
			}, nil
		},
	}

	ps := newUpsertTestPersistenceService(t, repo, permRepo, metaRepo, schema)
	ctx := context.Background()
	user := &models.UserSession{ID: "user-1", ProfileID: "prof-std"}

	_, err := ps.prepareUpdateBeforeSave(ctx, "Account", "real-inaccessible-id", schema, models.SObject{"Name": "x"}, nil, user)
	if err == nil {
		t.Fatal("expected error for inaccessible record, got nil")
	}

	var nfErr *appErrors.NotFoundError
	if !errors.As(err, &nfErr) {
		t.Fatalf("err type = %T; want *appErrors.NotFoundError", err)
	}
	if nfErr.Resource != "Record" {
		t.Errorf("Resource = %q; want %q", nfErr.Resource, "Record")
	}
	if nfErr.ID != "" {
		t.Errorf("ID = %q; want empty", nfErr.ID)
	}
	if nfErr.Error() != "Record not found" {
		t.Errorf("Error() = %q; want %q", nfErr.Error(), "Record not found")
	}
}

// ---------------------------------------------------------------------------
// F3: F1 and F2 errors are byte-identical (AC #3 wire spec)
// ---------------------------------------------------------------------------

func TestPrepareUpdateBeforeSave_F1F2_ErrorsAreStringEqual(t *testing.T) {
	schema := &models.ObjectMetadata{
		APIName:      "Account",
		SharingModel: constants.SharingModelPrivate,
	}
	permRepo := &stubUpsertPermRepo{grantEdit: true}
	metaRepo := &stubUpsertMetaRepo{schema: schema}

	for _, tc := range []struct {
		name string
		repo *stubUpsertRecordRepo
	}{
		{
			name: "F1-missing",
			repo: &stubUpsertRecordRepo{}, // GetLock returns nil
		},
		{
			name: "F2-access-denied",
			repo: &stubUpsertRecordRepo{
				getLockFn: func(ctx context.Context, t string, id string) (models.SObject, error) {
					return models.SObject{
						constants.FieldID:      id,
						constants.FieldOwnerID: "other-user",
					}, nil
				},
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ps := newUpsertTestPersistenceService(t, tc.repo, permRepo, metaRepo, schema)
			ctx := context.Background()
			user := &models.UserSession{ID: "user-1", ProfileID: "prof-std"}

			_, err := ps.prepareUpdateBeforeSave(ctx, "Account", "some-id", schema, models.SObject{"Name": "x"}, nil, user)
			if err == nil {
				t.Fatal("expected error, got nil")
			}

			var nfErr *appErrors.NotFoundError
			if !errors.As(err, &nfErr) {
				t.Fatalf("err type = %T; want *appErrors.NotFoundError", err)
			}
			if err.Error() != "Record not found" {
				t.Errorf("Error() = %q; want %q", err.Error(), "Record not found")
			}
		})
	}

	// Cross-case string equality: F1 error text == F2 error text
	t.Run("F1-F2-cross-case-string-equality", func(t *testing.T) {
		repoF1 := &stubUpsertRecordRepo{}
		repoF2 := &stubUpsertRecordRepo{
			getLockFn: func(ctx context.Context, t string, id string) (models.SObject, error) {
				return models.SObject{
					constants.FieldID:      id,
					constants.FieldOwnerID: "other-user",
				}, nil
			},
		}

		ps1 := newUpsertTestPersistenceService(t, repoF1, permRepo, metaRepo, schema)
		ps2 := newUpsertTestPersistenceService(t, repoF2, permRepo, metaRepo, schema)
		ctx := context.Background()
		user := &models.UserSession{ID: "user-1", ProfileID: "prof-std"}

		_, err1 := ps1.prepareUpdateBeforeSave(ctx, "Account", "id-1", schema, models.SObject{}, nil, user)
		_, err2 := ps2.prepareUpdateBeforeSave(ctx, "Account", "id-2", schema, models.SObject{}, nil, user)

		if err1 == nil || err2 == nil {
			t.Fatalf("both errors expected; err1=%v, err2=%v", err1, err2)
		}
		if err1.Error() != err2.Error() {
			t.Errorf("F1 error %q != F2 error %q; must be byte-identical", err1.Error(), err2.Error())
		}
	})
}

// ---------------------------------------------------------------------------
// F6: bulkErrorEntry routes NotFoundError to processing_halted
// ---------------------------------------------------------------------------

func TestBulkErrorEntry_NotFoundError_RoutesToProcessingHaltedWithRecordNotFound(t *testing.T) {
	entry := bulkErrorEntry("rec-x", appErrors.NewNotFoundError("Record", ""))

	if len(entry.Errors) != 1 {
		t.Fatalf("expected one error, got %#v", entry.Errors)
	}

	if entry.Errors[0].Kind != contract.RecordMutationErrorProcessingHalted {
		t.Errorf("kind = %q; want %q", entry.Errors[0].Kind, contract.RecordMutationErrorProcessingHalted)
	}
	if entry.Errors[0].Message != "Record not found" {
		t.Errorf("message = %q; want %q", entry.Errors[0].Message, "Record not found")
	}
}

func TestPrepareUpdateBeforeSave_ObjectPermissionsAllowsBeforeSaveRepairCandidate(t *testing.T) {
	schema := &models.ObjectMetadata{
		APIName: constants.TableObjectPermissions,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: constants.FieldObjectPermissions_ParentID, Type: constants.FieldTypeReference, Updateable: true},
			{APIName: constants.FieldObjectPermissions_SobjectType, Type: constants.FieldTypeString, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsRead, Type: constants.FieldTypeBoolean, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsCreate, Type: constants.FieldTypeBoolean, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsEdit, Type: constants.FieldTypeBoolean, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsDelete, Type: constants.FieldTypeBoolean, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsViewAllRecords, Type: constants.FieldTypeBoolean, Updateable: true},
			{APIName: constants.FieldObjectPermissions_PermissionsModifyAllRecords, Type: constants.FieldTypeBoolean, Updateable: true},
		},
	}
	repo := &stubUpsertRecordRepo{
		getLockFn: func(ctx context.Context, t string, id string) (models.SObject, error) {
			return models.SObject{
				constants.FieldID:                                            id,
				constants.FieldObjectPermissions_ParentID:                    "0PS000000000001",
				constants.FieldObjectPermissions_SobjectType:                 "Account",
				constants.FieldObjectPermissions_PermissionsRead:             true,
				constants.FieldObjectPermissions_PermissionsCreate:           false,
				constants.FieldObjectPermissions_PermissionsEdit:             false,
				constants.FieldObjectPermissions_PermissionsDelete:           false,
				constants.FieldObjectPermissions_PermissionsViewAllRecords:   false,
				constants.FieldObjectPermissions_PermissionsModifyAllRecords: false,
			}, nil
		},
	}
	ps := newUpsertTestPersistenceService(t, repo, &stubUpsertPermRepo{grantEdit: true}, &stubUpsertMetaRepo{schema: schema}, schema)

	_, err := ps.prepareUpdateBeforeSave(
		constants.WithSecurityContext(context.Background(), constants.SystemContext, "objectpermissions update validation test"),
		constants.TableObjectPermissions,
		"op-1",
		schema,
		models.SObject{
			constants.FieldObjectPermissions_PermissionsRead:   false,
			constants.FieldObjectPermissions_PermissionsCreate: true,
		},
		nil,
		nil,
	)
	if err != nil {
		t.Fatalf("prepareUpdateBeforeSave() error = %v, want nil before before-save repair", err)
	}
}

func TestUserDeactivationUpdateCanUseDeleteGrantWithoutEdit(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "admin-1", ProfileID: "profile-user-delete"}
	schema := userDeactivationUpdateSchema()
	repo := &stubUpsertRecordRepo{
		getLockFn: func(context.Context, string, string) (models.SObject, error) {
			return models.SObject{
				constants.FieldID:       "target-user",
				constants.FieldOwnerID:  user.ID,
				constants.FieldIsActive: true,
			}, nil
		},
	}
	ps := newUserDeactivationUpdateTestService(t, repo, &stubUpsertPermRepo{
		grantDelete: true,
		systemPermsByProfile: map[string]map[string]bool{
			user.ProfileID: {string(constants.PermManageUsers): true},
		},
	}, schema)

	deactivatePayload := models.SObject{
		"attributes":                 map[string]interface{}{"type": constants.TableUser},
		models.ObjectTypeMetadataKey: constants.TableUser,
		constants.FieldIsActive:      false,
	}
	_, updateAuth, err := ps.prepareUpdateOperation(ctx, constants.TableUser, deactivatePayload, user)
	if err != nil {
		t.Fatalf("prepareUpdateOperation() error = %v, want delete-backed deactivation authorization", err)
	}
	if !updateAuth.userDeactivationByDeletePermissionGrant || updateAuth.recordOperation != constants.PermDelete {
		t.Fatalf("update authorization = %+v, want delete-backed user deactivation", updateAuth)
	}

	preUpdate, err := ps.prepareUpdateBeforeSaveWithAuthorization(ctx, constants.TableUser, "target-user", schema, deactivatePayload, nil, user, updateAuth)
	if err != nil {
		t.Fatalf("prepareUpdateBeforeSaveWithAuthorization() error = %v", err)
	}
	if got, ok := preUpdate.effectiveUpdates[constants.FieldIsActive].(bool); !ok || got {
		t.Fatalf("effective IsActive update = %#v, want false", preUpdate.effectiveUpdates[constants.FieldIsActive])
	}
	if len(preUpdate.effectiveUpdates) != 1 {
		t.Fatalf("effective updates = %#v, want only IsActive", preUpdate.effectiveUpdates)
	}
}

func TestUserDeactivationUpdatePrefersDeleteGrantWhenEditFlsDeniesIsActive(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "admin-1", ProfileID: "profile-user-delete"}
	schema := userDeactivationUpdateSchema()
	repo := &stubUpsertRecordRepo{
		getLockFn: func(context.Context, string, string) (models.SObject, error) {
			return models.SObject{
				constants.FieldID:       "target-user",
				constants.FieldOwnerID:  user.ID,
				constants.FieldIsActive: true,
			}, nil
		},
	}
	ps := newUserDeactivationUpdateTestService(t, repo, &updateFLSBoundedPermRepo{
		stubUpsertPermRepo: stubUpsertPermRepo{
			grantEdit:   true,
			grantDelete: true,
			systemPermsByProfile: map[string]map[string]bool{
				user.ProfileID: {string(constants.PermManageUsers): true},
			},
		},
		fieldPerm: &models.FieldPermissions{
			SobjectType:     constants.TableUser,
			Field:           constants.FieldIsActive,
			PermissionsRead: true,
			PermissionsEdit: false,
		},
	}, schema)

	_, updateAuth, err := ps.prepareUpdateOperation(ctx, constants.TableUser, models.SObject{
		constants.FieldIsActive: false,
	}, user)
	if err != nil {
		t.Fatalf("prepareUpdateOperation() error = %v, want delete-backed deactivation authorization", err)
	}
	if !updateAuth.userDeactivationByDeletePermissionGrant {
		t.Fatalf("update authorization = %+v, want delete-backed path despite object Edit", updateAuth)
	}
	if updateAuth.recordOperation != constants.PermEdit {
		t.Fatalf("record operation = %s, want edit row access when object Edit already authorized the update", updateAuth.recordOperation)
	}

	if _, err := ps.prepareUpdateBeforeSaveWithAuthorization(ctx, constants.TableUser, "target-user", schema, models.SObject{
		constants.FieldIsActive: false,
	}, nil, user, updateAuth); err != nil {
		t.Fatalf("prepareUpdateBeforeSaveWithAuthorization() error = %v, want IsActive FLS deny bypassed for delete-backed deactivation", err)
	}
}

func TestUserDeactivationUpdateKeepsEditRowAccessWhenEditPathSucceeds(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "admin-1", ProfileID: "profile-user-delete"}
	schema := userDeactivationUpdateSchema()
	schema.SharingModel = constants.SharingModelPrivate
	repo := &stubUpsertRecordRepo{
		getLockFn: func(context.Context, string, string) (models.SObject, error) {
			return models.SObject{
				constants.FieldID:       "target-user",
				constants.FieldOwnerID:  "other-user",
				constants.FieldIsActive: true,
			}, nil
		},
	}
	ps := newUserDeactivationUpdateTestService(t, repo, &manualShareUserDeactivationPermRepo{
		stubUpsertPermRepo: stubUpsertPermRepo{
			grantEdit:   true,
			grantDelete: true,
			systemPermsByProfile: map[string]map[string]bool{
				user.ProfileID: {string(constants.PermManageUsers): true},
			},
		},
		manualShareLevels: []string{constants.PermEdit},
	}, schema)

	_, updateAuth, err := ps.prepareUpdateOperation(ctx, constants.TableUser, models.SObject{
		constants.FieldIsActive: false,
	}, user)
	if err != nil {
		t.Fatalf("prepareUpdateOperation() error = %v, want deactivation authorization", err)
	}
	if !updateAuth.userDeactivationByDeletePermissionGrant || updateAuth.recordOperation != constants.PermEdit {
		t.Fatalf("update authorization = %+v, want delete grant with edit row access", updateAuth)
	}

	if _, err := ps.prepareUpdateBeforeSaveWithAuthorization(ctx, constants.TableUser, "target-user", schema, models.SObject{
		constants.FieldIsActive: false,
	}, nil, user, updateAuth); err != nil {
		t.Fatalf("prepareUpdateBeforeSaveWithAuthorization() error = %v, want edit manual share to authorize row access", err)
	}
}

func TestUserDeactivationDeleteGrantDoesNotAuthorizeOtherUserUpdates(t *testing.T) {
	ctx := context.Background()
	user := &models.UserSession{ID: "admin-1", ProfileID: "profile-user-delete"}
	schema := userDeactivationUpdateSchema()
	ps := newUserDeactivationUpdateTestService(t, &stubUpsertRecordRepo{}, &stubUpsertPermRepo{
		grantDelete: true,
		systemPermsByProfile: map[string]map[string]bool{
			user.ProfileID: {string(constants.PermManageUsers): true},
		},
	}, schema)

	for _, updates := range []models.SObject{
		{constants.FieldName: "Renamed User"},
		{constants.FieldIsActive: true},
		{constants.FieldIsActive: false, constants.FieldName: "Renamed User"},
	} {
		if _, _, err := ps.prepareUpdateOperation(ctx, constants.TableUser, updates, user); err == nil {
			t.Fatalf("prepareUpdateOperation(%#v) error = nil, want delete grant not to authorize non-deactivation update", updates)
		}
	}
}

type manualShareUserDeactivationPermRepo struct {
	stubUpsertPermRepo
	manualShareLevels []string
}

func (r *manualShareUserDeactivationPermRepo) GetManualShareAccessLevels(context.Context, string, string, string) ([]string, error) {
	return append([]string(nil), r.manualShareLevels...), nil
}

func userDeactivationUpdateSchema() *models.ObjectMetadata {
	return &models.ObjectMetadata{
		APIName:                 constants.TableUser,
		RequiredWritePermission: string(constants.PermManageUsers),
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
			{APIName: constants.FieldOwnerID, Type: constants.FieldTypeString},
			{APIName: constants.FieldName, Type: constants.FieldTypeString, Updateable: true},
			{APIName: constants.FieldIsActive, Type: constants.FieldTypeBoolean, Updateable: true},
		},
		AccessPolicy: &models.ObjectAccessPolicy{
			WriteValidation: &models.RecordWriteValidationAccessPolicy{
				Validators: []models.RecordWriteValidationPolicy{{
					Phase:      string(contract.RecordWriteValidationBeforeDelete),
					Capability: securityWriteValidateUserDeleteLifecycleCapability,
				}},
			},
		},
	}
}

func newUserDeactivationUpdateTestService(t *testing.T, recRepo *stubUpsertRecordRepo, permRepo ports.PermissionRepository, schema *models.ObjectMetadata) *PersistenceService {
	t.Helper()
	metaSvc := metadata.NewMetadataService(&stubUpsertMetaRepo{schema: schema}, nil, nil)
	permSvc := permissions.NewPermissionService(permRepo, metaSvc, nil)
	return &PersistenceService{
		repo:        recRepo,
		metadata:    metaSvc,
		permissions: permSvc,
	}
}
