package packs

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"encoding/json"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/models"
	cp "github.com/pingcap-inc/nexuscrm-contract/pkg/controlplane"
)

func TestSourceBackedRecordTypeProjectionAcceptsInlineCode(t *testing.T) {
	payload := []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"name":" Master Agreement ","code":" MA "},{"name":"Generic","code":null},{"name":"Fallback"}]}]}`)

	got, err := sourceBackedRecordTypeProjection(payload, "record_types.json", "example-pack")
	if err != nil {
		t.Fatalf("sourceBackedRecordTypeProjection: %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("sourceBackedRecordTypeProjection returned %d rows, want 3: %#v", len(got), got)
	}
	byName := map[string]sourceBackedRecordTypeRow{}
	for _, row := range got {
		byName[row.Name] = row
	}
	if row := byName["Master Agreement"]; row.Code == nil || *row.Code != "MA" {
		t.Fatalf("Master Agreement code = %#v, want MA", row.Code)
	}
	if row := byName["Generic"]; row.Code != nil {
		t.Fatalf("Generic code = %#v, want nil for explicit JSON null", row.Code)
	}
	if row := byName["Fallback"]; row.Code != nil {
		t.Fatalf("Fallback code = %#v, want nil for omitted code", row.Code)
	}
}

func TestSourceBackedRecordTypeProjectionRejectsBlankCode(t *testing.T) {
	payload := []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"name":"Master Agreement","code":"   "}]}]}`)

	_, err := sourceBackedRecordTypeProjection(payload, "record_types.json", "example-pack")
	if err == nil || !strings.Contains(err.Error(), "blank code") {
		t.Fatalf("sourceBackedRecordTypeProjection error = %v, want blank code rejection", err)
	}
}

func TestEncodeObjectAccessPolicyPreservesCacheInvalidationOnlyPolicy(t *testing.T) {
	raw, err := encodeObjectAccessPolicy(&models.ObjectAccessPolicy{
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{
				{Capability: "platform.security.cache.permissions"},
			},
		},
	})
	if err != nil {
		t.Fatalf("encodeObjectAccessPolicy: %v", err)
	}
	if string(raw) == "null" {
		t.Fatal("encodeObjectAccessPolicy encoded cacheInvalidation-only policy as null")
	}

	var got models.ObjectAccessPolicy
	if err := json.Unmarshal(raw, &got); err != nil {
		t.Fatalf("encoded AccessPolicy JSON is invalid: %v", err)
	}
	if got.CacheInvalidation == nil || len(got.CacheInvalidation.Handlers) != 1 {
		t.Fatalf("encoded cacheInvalidation handlers = %#v, want one handler", got.CacheInvalidation)
	}
	if got.CacheInvalidation.Handlers[0].Capability != "platform.security.cache.permissions" {
		t.Fatalf("encoded capability = %q, want platform.security.cache.permissions", got.CacheInvalidation.Handlers[0].Capability)
	}
}

func TestEncodeObjectAccessPolicyPreservesWriteValidationOnlyPolicy(t *testing.T) {
	raw, err := encodeObjectAccessPolicy(&models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{
			Validators: []models.RecordWriteValidationPolicy{
				{Phase: "before_create", Capability: "test.write.validator"},
			},
		},
	})
	if err != nil {
		t.Fatalf("encodeObjectAccessPolicy: %v", err)
	}
	if string(raw) == "null" {
		t.Fatal("encodeObjectAccessPolicy encoded writeValidation-only policy as null")
	}

	var got models.ObjectAccessPolicy
	if err := json.Unmarshal(raw, &got); err != nil {
		t.Fatalf("encoded AccessPolicy JSON is invalid: %v", err)
	}
	if got.WriteValidation == nil || len(got.WriteValidation.Validators) != 1 {
		t.Fatalf("encoded writeValidation validators = %#v, want one validator", got.WriteValidation)
	}
	if got.WriteValidation.Validators[0].Capability != "test.write.validator" {
		t.Fatalf("encoded capability = %q, want test.write.validator", got.WriteValidation.Validators[0].Capability)
	}
}

func TestApplyRecordTypesForOwnerWritesAndChangesRecordTypeCode(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	for _, tc := range []struct {
		name    string
		payload string
		codeArg any
	}{
		{
			name:    "writes",
			payload: `{"recordTypes":[{"objectApiName":"Document","types":[{"name":"Master Agreement","code":"MA"}]}]}`,
			codeArg: "MA",
		},
		{
			name:    "changes",
			payload: `{"recordTypes":[{"objectApiName":"Document","types":[{"name":"Master Agreement","code":"PMA"}]}]}`,
			codeArg: "PMA",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			expectRecordTypeOwnerApply(t, mock, "example-pack", "Document", "obj-document", "Master Agreement", tc.codeArg)
			if err := ApplyRecordTypesForOwner(db, []byte(tc.payload), "example-pack"); err != nil {
				t.Fatalf("ApplyRecordTypesForOwner = %v", err)
			}
		})
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyRecordTypesForOwnerPayloadWithoutCodeDoesNotClearRecordTypeCode(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expectRecordTypeOwnerApply(t, mock, "example-pack", "Document", "obj-document", "Master Agreement", nil)

	payload := []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"name":"Master Agreement"}]}]}`)
	if err := ApplyRecordTypesForOwner(db, payload, "example-pack"); err != nil {
		t.Fatalf("ApplyRecordTypesForOwner = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyRecordTypesOwnerlessPayloadDoesNotClearRecordTypeCode(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT Id FROM _System_Object").
		WithArgs("Document").
		WillReturnRows(sqlmock.NewRows([]string{"Id"}).AddRow("obj-document"))
	developerName := utils.SanitizeDeveloperName("Master Agreement")
	mock.ExpectQuery("SELECT Id, OwnerPack, Name, IsDeleted\\s+FROM _System_RecordType\\s+WHERE ObjectId = \\? AND \\(DeveloperName = \\? OR Name = \\?\\)").
		WithArgs("obj-document", developerName, "Master Agreement").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "OwnerPack", "Name", "IsDeleted"}))
	mock.ExpectQuery("SELECT Id, Name, IsDeleted, COALESCE\\(OwnerPack, ''\\)\\s+FROM _System_RecordType\\s+WHERE ObjectId = \\? AND COALESCE\\(DeveloperName, ''\\) = \\?").
		WithArgs("obj-document", "").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "IsDeleted", "OwnerPack"}))
	mock.ExpectQuery("SELECT ObjectId, COALESCE\\(OwnerPack, ''\\), Name, COALESCE\\(DeveloperName, ''\\), IsDeleted\\s+FROM _System_RecordType\\s+WHERE Id = \\?\\s+LIMIT 1").
		WithArgs("rt-document-master").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectExec("INSERT INTO _System_RecordType").
		WithArgs("rt-document-master", "obj-document", "Master Agreement", nil, developerName, "", nil, "Document").
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectQuery("SELECT Id, ObjectId, COALESCE\\(OwnerPack, ''\\), Name, COALESCE\\(DeveloperName, ''\\), COALESCE\\(SobjectType, ''\\), IsDeleted\\s+FROM _System_RecordType\\s+WHERE Id = \\?\\s+LIMIT 1").
		WithArgs("rt-document-master").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ObjectId", "OwnerPack", "Name", "DeveloperName", "SobjectType", "IsDeleted"}).
			AddRow("rt-document-master", "obj-document", "", "Master Agreement", developerName, "Document", false))

	payload := []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"id":"rt-document-master","name":"Master Agreement"}]}]}`)
	if err := ApplyRecordTypes(db, payload); err != nil {
		t.Fatalf("ApplyRecordTypes = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyRecordTypesOwnerlessPayloadRejectsRecordTypeCode(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT Id FROM _System_Object").
		WithArgs("Document").
		WillReturnRows(sqlmock.NewRows([]string{"Id"}).AddRow("obj-document"))

	payload := []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"id":"rt-document-master","name":"Master Agreement","code":"MA"}]}]}`)
	err = ApplyRecordTypes(db, payload)
	if err == nil || !strings.Contains(err.Error(), "recordTypeCodeSeeds") {
		t.Fatalf("ApplyRecordTypes error = %v, want recordTypeCodeSeeds rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyAutoNumberFormatsRejectsRecordTypeCodeSeedsForOwnerPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	err = ApplyAutoNumberFormats(db, "example-pack", nil, []RecordTypeCodeSeed{{Name: "Master Agreement", Code: "MA"}})
	if err == nil || !strings.Contains(err.Error(), "not supported for owner-scoped packs") {
		t.Fatalf("ApplyAutoNumberFormats error = %v, want owner-pack seed rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyRecordTypeCodeSeedsLegacyWriterInitializesNullCodes(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	mock.ExpectExec("record_type_code IS NULL").
		WithArgs("MA", "Master Agreement").
		WillReturnResult(sqlmock.NewResult(1, 1))

	if err := ApplyRecordTypeCodeSeeds(db, []RecordTypeCodeSeed{{Name: "Master Agreement", Code: "MA"}}); err != nil {
		t.Fatalf("ApplyRecordTypeCodeSeeds = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedRecordTypeVerifyDetectsDeclaredRecordTypeCodeDrift(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	component, err := newSourceBackedRecordTypeComponent("example-pack", ManifestComponent{
		Kind:          ComponentKindRecordType,
		BusinessKey:   "record_types",
		Manageability: ManageabilityLocked,
		Path:          "record_types.json",
	}, []byte(`{"recordTypes":[{"objectApiName":"Document","types":[{"name":"Master Agreement","code":"MA"}]}]}`))
	if err != nil {
		t.Fatalf("newSourceBackedRecordTypeComponent: %v", err)
	}
	mock.ExpectQuery("SELECT o\\.ApiName, rt\\.Name, COALESCE\\(rt\\.DeveloperName, ''\\), COALESCE\\(rt\\.SobjectType, ''\\), rt\\.record_type_code").
		WithArgs("Document", "Master_Agreement", "Master Agreement", "Master_Agreement").
		WillReturnRows(sqlmock.NewRows([]string{
			"ApiName", "Name", "DeveloperName", "SobjectType", "record_type_code", "IsActive", "IsMaster", "BusinessProcessId", "IsDeleted", "OwnerPack",
		}).AddRow(
			"Document", "Master Agreement", "Master_Agreement", "Document", sql.NullString{String: "OLD", Valid: true}, true, false, nil, false, "example-pack",
		))
	mock.ExpectQuery("SELECT o.ApiName, rt.Name").
		WithArgs("example-pack").
		WillReturnRows(sqlmock.NewRows([]string{"ApiName", "Name", "DeveloperName"}).AddRow("Document", "Master Agreement", "Master_Agreement"))

	err = verifyPackComponentDefault(context.Background(), VerifyDeps{DB: db}, component)
	if err == nil || !strings.Contains(err.Error(), "projection drift") {
		t.Fatalf("VerifyComponent error = %v, want projection drift", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedListFieldsVerifyAcceptsExplicitEmptyListFields(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	component, err := newSourceBackedListFieldsComponent("example-pack", ManifestComponent{
		Kind:          ComponentKindListFields,
		BusinessKey:   "issue-list-fields",
		Manageability: ManageabilityDefaultsOnly,
		Path:          "list_fields.json",
	}, []byte(`{"Issue":[]}`))
	if err != nil {
		t.Fatalf("newSourceBackedListFieldsComponent: %v", err)
	}
	mock.ExpectQuery("SELECT ListFields, ListFieldsCustomized\\s+FROM _System_Object").
		WithArgs("Issue").
		WillReturnRows(sqlmock.NewRows([]string{"ListFields", "ListFieldsCustomized"}).AddRow(`[]`, false))

	if err := verifyPackComponentDefault(context.Background(), VerifyDeps{DB: db}, component); err != nil {
		t.Fatalf("VerifyComponent: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedObjectRecordMutationPolicyComponentAppliesAndVerifiesLockedOverlay(t *testing.T) {
	payload := []byte(`{
		"objectApiName": "Issue",
		"recordMutation": {
			"deny": [{
				"surfaces": ["singleCreate"],
				"field": "Status",
				"message": "Issue creation is closed by this app pack"
			}]
		}
	}`)
	component, err := newSourceBackedObjectRecordMutationPolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectRecordMutationPolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/issue_record_mutation_policy.json",
	}, payload, nil)
	if err != nil {
		t.Fatalf("newSourceBackedObjectRecordMutationPolicyComponent: %v", err)
	}

	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	liveBefore := &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{Kind: "permission", APIName: "ViewIssue"},
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{{
				Capability: "platform.security.cache.permissions",
			}},
		},
		Lifecycle: &models.RecordLifecycleAccessPolicy{
			Hooks: []models.RecordLifecycleHookPolicy{{
				Phase:      "after_create_in_tx",
				Capability: "jira.issue.lifecycle",
			}},
		},
	}
	liveBeforeJSON, err := json.Marshal(liveBefore)
	if err != nil {
		t.Fatalf("marshal live before policy: %v", err)
	}
	mock.ExpectQuery("SELECT Id, AccessPolicy, AccessPolicyRecordMutationPackName, AccessPolicyRecordMutationPackBusinessKey\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "AccessPolicy", "AccessPolicyRecordMutationPackName", "AccessPolicyRecordMutationPackBusinessKey"}).
			AddRow("obj-issue", string(liveBeforeJSON), nil, nil))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyRecordMutationPackName` = \\?, `AccessPolicyRecordMutationPackBusinessKey` = \\?, `AccessPolicyRecordMutationPackManageability` = \\?, `AccessPolicyRecordMutationPackValue` = CAST\\(\\? AS JSON\\), `LastModifiedDate` = NOW\\(\\)").
		WithArgs(accessPolicyJSONArg{t: t, wantObjectRead: true, wantCacheInvalidation: true, wantLifecycle: true, wantRecordMutation: true}, "jira-pack", "Issue", "locked", sqlmock.AnyArg(), "issue", "jira-pack", "Issue").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := component.Apply(context.Background(), ApplyDeps{DB: db}); err != nil {
		t.Fatalf("Apply: %v", err)
	}

	livePolicy := &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{Kind: "permission", APIName: "ViewIssue"},
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{{
				Capability: "platform.security.cache.permissions",
			}},
		},
		RecordMutation: &models.RecordMutationAccessPolicy{
			Deny: []models.RecordMutationDenial{{
				Surfaces: []string{models.RecordMutationSurfaceSingleCreate},
				Field:    "Status",
				Message:  "Issue creation is closed by this app pack",
			}},
		},
	}
	liveJSON, err := json.Marshal(livePolicy)
	if err != nil {
		t.Fatalf("marshal live policy: %v", err)
	}
	mock.ExpectQuery("SELECT AccessPolicy, AccessPolicyRecordMutationPackName, AccessPolicyRecordMutationPackBusinessKey, AccessPolicyRecordMutationPackManageability\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"AccessPolicy", "AccessPolicyRecordMutationPackName", "AccessPolicyRecordMutationPackBusinessKey", "AccessPolicyRecordMutationPackManageability"}).
			AddRow(string(liveJSON), "jira-pack", "Issue", "locked"))

	if err := verifyPackComponentDefault(context.Background(), VerifyDeps{DB: db}, component); err != nil {
		t.Fatalf("VerifyComponent: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedObjectRecordMutationPolicyComponentAcceptsIdempotentNoOpUpdate(t *testing.T) {
	row := sourceBackedObjectRecordMutationPolicyRow{
		ObjectAPIName: "Issue",
		RecordMutation: &models.RecordMutationAccessPolicy{
			Deny: []models.RecordMutationDenial{{
				Surfaces: []string{models.RecordMutationSurfaceSingleCreate},
				Field:    "Status",
				Message:  "Issue creation is closed by this app pack",
			}},
		},
		PackName:          "jira-pack",
		PackBusinessKey:   "Issue",
		PackManageability: "locked",
	}

	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	livePolicy := &models.ObjectAccessPolicy{RecordMutation: row.RecordMutation}
	liveJSON, err := json.Marshal(livePolicy)
	if err != nil {
		t.Fatalf("marshal live policy: %v", err)
	}
	packValue, err := canonicalRecordMutationPolicyPackValue(row.RecordMutation)
	if err != nil {
		t.Fatalf("canonical pack value: %v", err)
	}
	mock.ExpectQuery("SELECT Id, AccessPolicy, AccessPolicyRecordMutationPackName, AccessPolicyRecordMutationPackBusinessKey\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "AccessPolicy", "AccessPolicyRecordMutationPackName", "AccessPolicyRecordMutationPackBusinessKey"}).
			AddRow("obj-issue", string(liveJSON), "jira-pack", "Issue"))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyRecordMutationPackName` = \\?, `AccessPolicyRecordMutationPackBusinessKey` = \\?, `AccessPolicyRecordMutationPackManageability` = \\?, `AccessPolicyRecordMutationPackValue` = CAST\\(\\? AS JSON\\), `LastModifiedDate` = NOW\\(\\)").
		WithArgs(accessPolicyJSONArg{t: t, wantRecordMutation: true}, "jira-pack", "Issue", "locked", string(packValue), "issue", "jira-pack", "Issue").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectQuery("SELECT `AccessPolicy`, `AccessPolicyRecordMutationPackName`, `AccessPolicyRecordMutationPackBusinessKey`, `AccessPolicyRecordMutationPackManageability`, `AccessPolicyRecordMutationPackValue`\\s+FROM `_System_Object`\\s+WHERE LOWER\\(`ApiName`\\) = \\? AND COALESCE\\(`IsDeleted`, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"AccessPolicy", "AccessPolicyRecordMutationPackName", "AccessPolicyRecordMutationPackBusinessKey", "AccessPolicyRecordMutationPackManageability", "AccessPolicyRecordMutationPackValue"}).
			AddRow(string(liveJSON), "jira-pack", "Issue", "locked", string(packValue)))

	if err := applySourceBackedObjectRecordMutationPolicy(context.Background(), db, row); err != nil {
		t.Fatalf("applySourceBackedObjectRecordMutationPolicy: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedObjectRecordMutationPolicyComponentRejectsBusinessKeyTargetMismatch(t *testing.T) {
	_, err := newSourceBackedObjectRecordMutationPolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectRecordMutationPolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/case_record_mutation_policy.json",
	}, []byte(`{"objectApiName":"Case","recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`), nil)
	if err == nil {
		t.Fatal("newSourceBackedObjectRecordMutationPolicyComponent accepted businessKey/objectApiName mismatch")
	}
	if !strings.Contains(err.Error(), "businessKey") || !strings.Contains(err.Error(), "objectApiName") {
		t.Fatalf("error = %v, want businessKey/objectApiName mismatch", err)
	}
}

func TestSourceBackedObjectLifecyclePolicyComponentAppliesAndVerifiesLockedOverlay(t *testing.T) {
	payload := []byte(`{
		"objectApiName": "Issue",
		"lifecycle": {
			"hooks": [{
				"phase": "after_create_in_tx",
				"capability": "jira.issue.lifecycle"
			}]
		}
	}`)
	component, err := newSourceBackedObjectLifecyclePolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectLifecyclePolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/issue_lifecycle_policy.json",
	}, payload, nil)
	if err != nil {
		t.Fatalf("newSourceBackedObjectLifecyclePolicyComponent: %v", err)
	}

	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	liveBefore := &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{Kind: "permission", APIName: "ViewIssue"},
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{{
				Capability: "platform.security.cache.permissions",
			}},
		},
		RecordMutation: &models.RecordMutationAccessPolicy{
			Deny: []models.RecordMutationDenial{{
				Surfaces: []string{models.RecordMutationSurfaceSingleCreate},
				Message:  "closed",
			}},
		},
	}
	liveBeforeJSON, err := json.Marshal(liveBefore)
	if err != nil {
		t.Fatalf("marshal live before policy: %v", err)
	}
	mock.ExpectQuery("SELECT Id, AccessPolicy, AccessPolicyLifecyclePackName, AccessPolicyLifecyclePackBusinessKey\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "AccessPolicy", "AccessPolicyLifecyclePackName", "AccessPolicyLifecyclePackBusinessKey"}).
			AddRow("obj-issue", string(liveBeforeJSON), nil, nil))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyLifecyclePackName` = \\?, `AccessPolicyLifecyclePackBusinessKey` = \\?, `AccessPolicyLifecyclePackManageability` = \\?, `AccessPolicyLifecyclePackValue` = CAST\\(\\? AS JSON\\), `LastModifiedDate` = NOW\\(\\)").
		WithArgs(accessPolicyJSONArg{t: t, wantObjectRead: true, wantCacheInvalidation: true, wantLifecycle: true, wantRecordMutation: true}, "jira-pack", "Issue", "locked", sqlmock.AnyArg(), "issue", "jira-pack", "Issue").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := component.Apply(context.Background(), ApplyDeps{DB: db}); err != nil {
		t.Fatalf("Apply: %v", err)
	}

	livePolicy := &models.ObjectAccessPolicy{
		ObjectRead: &models.AccessPolicyExpr{Kind: "permission", APIName: "ViewIssue"},
		CacheInvalidation: &models.RecordCacheInvalidationAccessPolicy{
			Handlers: []models.RecordCacheInvalidationPolicy{{
				Capability: "platform.security.cache.permissions",
			}},
		},
		Lifecycle: &models.RecordLifecycleAccessPolicy{
			Hooks: []models.RecordLifecycleHookPolicy{{
				Phase:      "after_create_in_tx",
				Capability: "jira.issue.lifecycle",
			}},
		},
	}
	liveJSON, err := json.Marshal(livePolicy)
	if err != nil {
		t.Fatalf("marshal live policy: %v", err)
	}
	mock.ExpectQuery("SELECT AccessPolicy, AccessPolicyLifecyclePackName, AccessPolicyLifecyclePackBusinessKey, AccessPolicyLifecyclePackManageability\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"AccessPolicy", "AccessPolicyLifecyclePackName", "AccessPolicyLifecyclePackBusinessKey", "AccessPolicyLifecyclePackManageability"}).
			AddRow(string(liveJSON), "jira-pack", "Issue", "locked"))

	if err := verifyPackComponentDefault(context.Background(), VerifyDeps{DB: db}, component); err != nil {
		t.Fatalf("VerifyComponent: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedObjectLifecyclePolicyComponentAcceptsIdempotentNoOpUpdate(t *testing.T) {
	row := sourceBackedObjectLifecyclePolicyRow{
		ObjectAPIName: "Issue",
		Lifecycle: &models.RecordLifecycleAccessPolicy{
			Hooks: []models.RecordLifecycleHookPolicy{{
				Phase:      "after_create_in_tx",
				Capability: "jira.issue.lifecycle",
			}},
		},
		PackName:          "jira-pack",
		PackBusinessKey:   "Issue",
		PackManageability: "locked",
	}

	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	livePolicy := &models.ObjectAccessPolicy{Lifecycle: row.Lifecycle}
	liveJSON, err := json.Marshal(livePolicy)
	if err != nil {
		t.Fatalf("marshal live policy: %v", err)
	}
	packValue, err := canonicalLifecyclePolicyPackValue(row.Lifecycle)
	if err != nil {
		t.Fatalf("canonical pack value: %v", err)
	}
	mock.ExpectQuery("SELECT Id, AccessPolicy, AccessPolicyLifecyclePackName, AccessPolicyLifecyclePackBusinessKey\\s+FROM _System_Object\\s+WHERE LOWER\\(ApiName\\) = \\? AND COALESCE\\(IsDeleted, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "AccessPolicy", "AccessPolicyLifecyclePackName", "AccessPolicyLifecyclePackBusinessKey"}).
			AddRow("obj-issue", string(liveJSON), "jira-pack", "Issue"))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyLifecyclePackName` = \\?, `AccessPolicyLifecyclePackBusinessKey` = \\?, `AccessPolicyLifecyclePackManageability` = \\?, `AccessPolicyLifecyclePackValue` = CAST\\(\\? AS JSON\\), `LastModifiedDate` = NOW\\(\\)").
		WithArgs(accessPolicyJSONArg{t: t, wantLifecycle: true}, "jira-pack", "Issue", "locked", string(packValue), "issue", "jira-pack", "Issue").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectQuery("SELECT `AccessPolicy`, `AccessPolicyLifecyclePackName`, `AccessPolicyLifecyclePackBusinessKey`, `AccessPolicyLifecyclePackManageability`, `AccessPolicyLifecyclePackValue`\\s+FROM `_System_Object`\\s+WHERE LOWER\\(`ApiName`\\) = \\? AND COALESCE\\(`IsDeleted`, 0\\) = 0\\s+LIMIT 1").
		WithArgs("issue").
		WillReturnRows(sqlmock.NewRows([]string{"AccessPolicy", "AccessPolicyLifecyclePackName", "AccessPolicyLifecyclePackBusinessKey", "AccessPolicyLifecyclePackManageability", "AccessPolicyLifecyclePackValue"}).
			AddRow(string(liveJSON), "jira-pack", "Issue", "locked", string(packValue)))

	if err := applySourceBackedObjectLifecyclePolicy(context.Background(), db, row); err != nil {
		t.Fatalf("applySourceBackedObjectLifecyclePolicy: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedObjectLifecyclePolicyComponentRejectsBusinessKeyTargetMismatch(t *testing.T) {
	_, err := newSourceBackedObjectLifecyclePolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectLifecyclePolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/case_lifecycle_policy.json",
	}, []byte(`{"objectApiName":"Case","lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]}}`), nil)
	if err == nil {
		t.Fatal("newSourceBackedObjectLifecyclePolicyComponent accepted businessKey/objectApiName mismatch")
	}
	if !strings.Contains(err.Error(), "businessKey") || !strings.Contains(err.Error(), "objectApiName") {
		t.Fatalf("error = %v, want businessKey/objectApiName mismatch", err)
	}
}

func TestSourceBackedObjectLifecyclePolicyComponentRejectsFullAccessPolicyBlob(t *testing.T) {
	_, err := newSourceBackedObjectLifecyclePolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectLifecyclePolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/issue_access_policy.json",
	}, []byte(`{"objectApiName":"Issue","accessPolicy":{"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]}}}`), nil)
	if err == nil || !strings.Contains(err.Error(), "owns only lifecycle") {
		t.Fatalf("newSourceBackedObjectLifecyclePolicyComponent error = %v, want full AccessPolicy rejection", err)
	}
}

func TestObjectRecordMutationPolicyComponentContractIsLockedAppPackSurface(t *testing.T) {
	contract, ok := ComponentKindContractFor(ComponentKindObjectRecordMutationPolicy)
	if !ok {
		t.Fatal("object_record_mutation_policy component contract is missing")
	}
	if contract.Phase != InstallPlanPhaseObjectMetadata {
		t.Fatalf("phase = %q, want %q", contract.Phase, InstallPlanPhaseObjectMetadata)
	}
	if _, ok := contract.Manageabilities[ManageabilityLocked]; !ok || len(contract.Manageabilities) != 1 {
		t.Fatalf("manageabilities = %#v, want locked only", contract.Manageabilities)
	}
	if contract.OwnerDiscriminator != ComponentOwnerStrategySource {
		t.Fatalf("owner discriminator = %q, want source-backed object overlay", contract.OwnerDiscriminator)
	}
	if !DefaultSourceBackedComponentKindRegistry().Supports(ComponentKindObjectRecordMutationPolicy) {
		t.Fatal("object_record_mutation_policy is not source-backed")
	}
	if _, ok := appPackInstallableComponentKinds()[ComponentKindObjectRecordMutationPolicy]; !ok {
		t.Fatal("object_record_mutation_policy is not installable by app packs")
	}
}

func TestObjectLifecyclePolicyComponentContractIsLockedAppPackSurface(t *testing.T) {
	contract, ok := ComponentKindContractFor(ComponentKindObjectLifecyclePolicy)
	if !ok {
		t.Fatal("object_lifecycle_policy component contract is missing")
	}
	if contract.Phase != InstallPlanPhaseObjectMetadata {
		t.Fatalf("phase = %q, want %q", contract.Phase, InstallPlanPhaseObjectMetadata)
	}
	if _, ok := contract.Manageabilities[ManageabilityLocked]; !ok || len(contract.Manageabilities) != 1 {
		t.Fatalf("manageabilities = %#v, want locked only", contract.Manageabilities)
	}
	if contract.OwnerDiscriminator != ComponentOwnerStrategySource {
		t.Fatalf("owner discriminator = %q, want source-backed object overlay", contract.OwnerDiscriminator)
	}
	if !DefaultSourceBackedComponentKindRegistry().Supports(ComponentKindObjectLifecyclePolicy) {
		t.Fatal("object_lifecycle_policy is not source-backed")
	}
	if _, ok := appPackInstallableComponentKinds()[ComponentKindObjectLifecyclePolicy]; !ok {
		t.Fatal("object_lifecycle_policy is not installable by app packs")
	}
}

func TestSourceBackedObjectRecordMutationPolicyComponentRejectsFullAccessPolicyBlob(t *testing.T) {
	_, err := newSourceBackedObjectRecordMutationPolicyComponent("jira-pack", ManifestComponent{
		Kind:          ComponentKindObjectRecordMutationPolicy,
		BusinessKey:   "Issue",
		Manageability: ManageabilityLocked,
		Path:          "policies/issue_access_policy.json",
	}, []byte(`{"objectApiName":"Issue","accessPolicy":{"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}}`), nil)
	if err == nil || !strings.Contains(err.Error(), "owns only recordMutation") {
		t.Fatalf("newSourceBackedObjectRecordMutationPolicyComponent error = %v, want full AccessPolicy rejection", err)
	}
}

func TestObjectLifecyclePolicyPackSweeperRejectsStaleOwnedRows(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-issue", "Issue", `{"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]}}`).
			AddRow("obj-stale", "Stale", `{"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.stale.lifecycle"}]}}`))

	err = verifyObjectLifecyclePolicyPackSweep(context.Background(), db, "jira-pack", &objectLifecyclePolicyPackBatch{
		declaredObjects: []string{"Issue"},
	})
	if err == nil || !strings.Contains(err.Error(), "Stale") {
		t.Fatalf("verifyObjectLifecyclePolicyPackSweep error = %v, want stale row rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectLifecyclePolicyPackSweeperClearsOnlyLifecycle(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]},"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyLifecyclePackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantRecordMutation: true, forbidLifecycle: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectLifecyclePolicyPackSweep(context.Background(), db, "jira-pack", &objectLifecyclePolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectLifecyclePolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectLifecyclePolicyPackSweeperPreservesCacheInvalidationOnlyPolicy(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"cacheInvalidation":{"handlers":[{"capability":"platform.security.cache.permissions"}]},"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyLifecyclePackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantCacheInvalidation: true, forbidLifecycle: true, forbidRecordMutation: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectLifecyclePolicyPackSweep(context.Background(), db, "jira-pack", &objectLifecyclePolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectLifecyclePolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectLifecyclePolicyPackSweeperPreservesWriteValidationOnlyPolicy(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"writeValidation":{"validators":[{"phase":"before_create","capability":"test.write.validator"}]},"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyLifecyclePackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantWriteValidation: true, forbidLifecycle: true, forbidRecordMutation: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectLifecyclePolicyPackSweep(context.Background(), db, "jira-pack", &objectLifecyclePolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectLifecyclePolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectRecordMutationPolicyPackSweeperRejectsStaleOwnedRows(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-issue", "Issue", `{"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`).
			AddRow("obj-stale", "Stale", `{"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`))

	err = verifyObjectRecordMutationPolicyPackSweep(context.Background(), db, "jira-pack", &objectRecordMutationPolicyPackBatch{
		declaredObjects: []string{"Issue"},
	})
	if err == nil || !strings.Contains(err.Error(), "Stale") {
		t.Fatalf("verifyObjectRecordMutationPolicyPackSweep error = %v, want stale row rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectRecordMutationPolicyPackSweeperClearsOnlyRecordMutation(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"lifecycle":{"hooks":[{"phase":"after_create_in_tx","capability":"jira.issue.lifecycle"}]},"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyRecordMutationPackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantLifecycle: true, forbidRecordMutation: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectRecordMutationPolicyPackSweep(context.Background(), db, "jira-pack", &objectRecordMutationPolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectRecordMutationPolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectRecordMutationPolicyPackSweeperPreservesCacheInvalidationOnlyPolicy(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"cacheInvalidation":{"handlers":[{"capability":"platform.security.cache.permissions"}]},"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyRecordMutationPackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantCacheInvalidation: true, forbidRecordMutation: true, forbidLifecycle: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectRecordMutationPolicyPackSweep(context.Background(), db, "jira-pack", &objectRecordMutationPolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectRecordMutationPolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestObjectRecordMutationPolicyPackSweeperPreservesWriteValidationOnlyPolicy(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("SELECT `Id`, `ApiName`, `AccessPolicy` FROM `_System_Object`").
		WithArgs("jira-pack").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ApiName", "AccessPolicy"}).
			AddRow("obj-stale", "Stale", `{"writeValidation":{"validators":[{"phase":"before_update","capability":"test.write.validator"}]},"recordMutation":{"deny":[{"surfaces":["singleCreate"],"message":"closed"}]}}`))
	mock.ExpectExec("UPDATE `_System_Object` SET `AccessPolicy` = CAST\\(\\? AS JSON\\), `AccessPolicyRecordMutationPackName` = NULL").
		WithArgs(accessPolicyJSONArg{t: t, wantWriteValidation: true, forbidRecordMutation: true, forbidLifecycle: true}, "obj-stale", "jira-pack").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := applyObjectRecordMutationPolicyPackSweep(context.Background(), db, "jira-pack", &objectRecordMutationPolicyPackBatch{}); err != nil {
		t.Fatalf("applyObjectRecordMutationPolicyPackSweep: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

type accessPolicyJSONArg struct {
	t                     *testing.T
	wantObjectRead        bool
	wantCacheInvalidation bool
	wantWriteValidation   bool
	wantLifecycle         bool
	wantRecordMutation    bool
	forbidRecordMutation  bool
	forbidLifecycle       bool
}

func (a accessPolicyJSONArg) Match(value driver.Value) bool {
	a.t.Helper()
	raw, ok := value.(string)
	if !ok {
		bytes, ok := value.([]byte)
		if !ok {
			a.t.Logf("AccessPolicy SQL arg type = %T, want string/[]byte", value)
			return false
		}
		raw = string(bytes)
	}
	var policy models.ObjectAccessPolicy
	if err := json.Unmarshal([]byte(raw), &policy); err != nil {
		a.t.Logf("AccessPolicy SQL arg is invalid JSON %q: %v", raw, err)
		return false
	}
	if a.wantObjectRead && policy.ObjectRead == nil {
		a.t.Logf("AccessPolicy SQL arg dropped objectRead: %s", raw)
		return false
	}
	if a.wantCacheInvalidation && policy.CacheInvalidation == nil {
		a.t.Logf("AccessPolicy SQL arg dropped cacheInvalidation: %s", raw)
		return false
	}
	if a.wantWriteValidation && policy.WriteValidation == nil {
		a.t.Logf("AccessPolicy SQL arg dropped writeValidation: %s", raw)
		return false
	}
	if a.wantLifecycle && policy.Lifecycle == nil {
		a.t.Logf("AccessPolicy SQL arg dropped lifecycle: %s", raw)
		return false
	}
	if a.forbidLifecycle && policy.Lifecycle != nil {
		a.t.Logf("AccessPolicy SQL arg kept lifecycle: %s", raw)
		return false
	}
	if a.wantRecordMutation && policy.RecordMutation == nil {
		a.t.Logf("AccessPolicy SQL arg missing recordMutation: %s", raw)
		return false
	}
	if a.forbidRecordMutation && policy.RecordMutation != nil {
		a.t.Logf("AccessPolicy SQL arg kept recordMutation: %s", raw)
		return false
	}
	return true
}

func TestSourceBackedStandardFlowComponentVerifyRejectsStaleActiveMetadata(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	sourcePayload := []byte(`{"label":"Current Flow","processType":"AutoLaunchedFlow"}`)
	stalePayload := []byte(`{"label":"Stale Flow","processType":"AutoLaunchedFlow"}`)
	component, err := newSourceBackedStandardFlowComponent("source-backed-pack", ManifestComponent{
		Kind:        ComponentKindStandardFlow,
		BusinessKey: "Current_Flow",
		Path:        "flows/Current_Flow.flow.json",
	}, sourcePayload, nil, nil, nil)
	if err != nil {
		t.Fatalf("newSourceBackedStandardFlowComponent: %v", err)
	}
	verifier, ok := component.(componentCustomVerifier)
	if !ok {
		t.Fatalf("standard_flow component %T does not implement component verifier", component)
	}

	mock.ExpectQuery("SELECT Id, OwnerPack, ActiveVersionId, IsDeleted, IsCustomized, ProcessType, TriggerType, TriggerObjectOrEvent").
		WithArgs("Current_Flow").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "OwnerPack", "ActiveVersionId", "IsDeleted", "IsCustomized", "ProcessType", "TriggerType", "TriggerObjectOrEvent",
		}).AddRow(
			"flowdef_current",
			"source-backed-pack",
			"flowver_stale_active",
			false,
			false,
			"AutoLaunchedFlow",
			nil,
			nil,
		))
	mock.ExpectQuery("SELECT Id, Status, Source, Metadata, IsDeleted").
		WithArgs("flowver_stale_active", "flowdef_current").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Status", "Source", "Metadata", "IsDeleted"}).
			AddRow("flowver_stale_active", "Active", "standard", string(stalePayload), false))

	err = verifier.VerifyComponent(context.Background(), VerifyDeps{DB: db})
	if err == nil {
		t.Fatal("VerifyComponent = nil, want stale active metadata rejection")
	}
	if !strings.Contains(err.Error(), "active version flowver_stale_active metadata differs from shipped metadata") {
		t.Fatalf("error = %v, want active metadata drift", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedStandardFlowComponentRejectsLegacyEmailAlert(t *testing.T) {
	payload := []byte(`{"label":"Legacy Flow","processType":"AutoLaunchedFlow","actionCalls":[{"name":"Legacy_Email_Alert","label":"Legacy Email Alert","actionName":"Account.Legacy_Alert","actionType":"emailAlert"}]}`)

	_, err := newSourceBackedStandardFlowComponent("source-backed-pack", ManifestComponent{
		Kind:        ComponentKindStandardFlow,
		BusinessKey: "Legacy_Flow",
		Path:        "flows/Legacy_Flow.flow.json",
	}, payload, nil, nil, nil)
	if err == nil {
		t.Fatal("newSourceBackedStandardFlowComponent accepted legacy emailAlert actionCall")
	}
	for _, want := range []string{"standard_flow Legacy_Flow.flow.json", "Legacy_Email_Alert", "actionType=emailAlert", "sendEmail", "remove it"} {
		if !strings.Contains(err.Error(), want) {
			t.Fatalf("error = %v, want substring %q", err, want)
		}
	}
}

func expectRecordTypeOwnerApply(t *testing.T, mock sqlmock.Sqlmock, ownerPack, objectAPIName, objectID, recordTypeName string, codeArg any) {
	t.Helper()
	developerName := utils.SanitizeDeveloperName(recordTypeName)
	expectedID := generatedRecordTypeID(ownerPack, objectAPIName, developerName)
	mock.ExpectQuery("SELECT Id FROM _System_Object").
		WithArgs(objectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"Id"}).AddRow(objectID))
	mock.ExpectQuery("SELECT Id, OwnerPack, Name, IsDeleted\\s+FROM _System_RecordType\\s+WHERE ObjectId = \\? AND \\(DeveloperName = \\? OR Name = \\?\\)").
		WithArgs(objectID, developerName, recordTypeName).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "OwnerPack", "Name", "IsDeleted"}))
	mock.ExpectQuery("SELECT Id, Name, IsDeleted, COALESCE\\(OwnerPack, ''\\)\\s+FROM _System_RecordType\\s+WHERE ObjectId = \\? AND COALESCE\\(DeveloperName, ''\\) = \\?").
		WithArgs(objectID, "").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "IsDeleted", "OwnerPack"}))
	mock.ExpectQuery("SELECT ObjectId, COALESCE\\(OwnerPack, ''\\), Name, COALESCE\\(DeveloperName, ''\\), IsDeleted\\s+FROM _System_RecordType\\s+WHERE Id = \\?\\s+LIMIT 1").
		WithArgs(expectedID).
		WillReturnError(sql.ErrNoRows)
	mock.ExpectExec("INSERT INTO _System_RecordType").
		WithArgs(expectedID, objectID, recordTypeName, codeArg, developerName, ownerPack, nil, objectAPIName).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectQuery("SELECT Id, ObjectId, COALESCE\\(OwnerPack, ''\\), Name, COALESCE\\(DeveloperName, ''\\), COALESCE\\(SobjectType, ''\\), IsDeleted\\s+FROM _System_RecordType\\s+WHERE Id = \\?\\s+LIMIT 1").
		WithArgs(expectedID).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ObjectId", "OwnerPack", "Name", "DeveloperName", "SobjectType", "IsDeleted"}).
			AddRow(expectedID, objectID, ownerPack, recordTypeName, developerName, objectAPIName, false))
	mock.ExpectQuery("SELECT Id, ObjectId, DeveloperName").
		WithArgs(ownerPack).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "ObjectId", "DeveloperName"}).AddRow(expectedID, objectID, developerName))
}

func TestLiveSourceBackedAutoNumberFormatProjectionCanonicalizesTokenRefs(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	const sourceTokenRefs = `[
		{
			"tokenLiteral": "{Quote.Account_Country_Code__c}",
			"targetObject": "Quote",
			"targetField": "Account_Country_Code__c",
			"readStrategy": "caller_txn",
			"directField": "Account_Country_Code__c",
			"fkChains": [
				[
					{
						"fk": "AccountId",
						"targetObject": "Account",
						"targetField": "BillingCountryCode"
					}
				]
			]
		}
	]`

	payload := []byte(`[
		{
			"objectName": "Quote",
			"fieldApiName": "QuoteNumber",
			"format": "{Quote.Account_Country_Code__c}-QT-{YY}{MM}{DD}{00}",
			"scope": "per_prefix",
			"start": 1,
			"legacySource": "_System_AutoNumber",
			"tokenRefsJson": ` + strconv.Quote(sourceTokenRefs) + `
		}
	]`)

	_, expected, err := sourceBackedAutoNumberFormatProjection(payload, "test")
	if err != nil {
		t.Fatalf("sourceBackedAutoNumberFormatProjection: %v", err)
	}

	const tidbTokenRefs = `[{"directField": "Account_Country_Code__c", "fkChains": [[{"fk": "AccountId", "targetField": "BillingCountryCode", "targetObject": "Account"}]], "readStrategy": "caller_txn", "targetField": "Account_Country_Code__c", "targetObject": "Quote", "tokenLiteral": "{Quote.Account_Country_Code__c}"}]`
	mock.ExpectQuery(regexp.QuoteMeta("SELECT o.ApiName, f.ApiName, f.auto_number_format, f.auto_number_scope, f.auto_number_start, f.auto_number_token_refs")).
		WithArgs("Quote", "QuoteNumber", "test").
		WillReturnRows(sqlmock.NewRows([]string{
			"ApiName",
			"ApiName",
			"auto_number_format",
			"auto_number_scope",
			"auto_number_start",
			"auto_number_token_refs",
		}).AddRow(
			"Quote",
			"QuoteNumber",
			"{Quote.Account_Country_Code__c}-QT-{YY}{MM}{DD}{00}",
			"per_prefix",
			1,
			tidbTokenRefs,
		))

	live, err := liveSourceBackedAutoNumberFormatProjection(
		context.Background(),
		VerifyDeps{DB: db},
		"test",
		expected,
	)
	if err != nil {
		t.Fatalf("liveSourceBackedAutoNumberFormatProjection: %v", err)
	}

	if !reflect.DeepEqual(live, expected) {
		t.Fatalf("live projection = %#v, want %#v", live, expected)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifySourceBackedReportTypesRejectsSectionsDrift(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	payload := sourceBackedReportTypesPayload{ReportTypes: []sourceBackedReportTypeSeed{{
		FullName:   "Account_With_Contacts",
		Label:      "Account Reports",
		BaseObject: "Account",
		Deployed:   true,
		Sections:   json.RawMessage(`[{"name":"Main","columns":["Name"]}]`),
		Categories: json.RawMessage(`["pipeline"]`),
		Join:       json.RawMessage(`{"type":"inner","left":"Account","right":"Contact"}`),
		Metadata:   json.RawMessage(`{"columns":["Name"]}`),
	}}}

	mock.ExpectQuery("SELECT Label, Description, BaseObject, Deployed, Autogenerated, Sections, Categories").
		WithArgs("Account_With_Contacts").
		WillReturnRows(sqlmock.NewRows([]string{
			"Label", "Description", "BaseObject", "Deployed", "Autogenerated",
			"Sections", "Categories", "Join", "Metadata", "OwnerPack",
		}).AddRow(
			"Account Reports", nil, "Account", 1, 0,
			`[{"name":"Drifted","columns":["Name"]}]`,
			`["pipeline"]`,
			`{"right":"Contact","left":"Account","type":"inner"}`,
			`{"columns":["Name"]}`,
			"salesforce-crm-standard",
		))

	err = verifySourceBackedReportTypes(context.Background(), db, payload, "salesforce-crm-standard")
	if err == nil || !strings.Contains(err.Error(), "sections drifted") {
		t.Fatalf("verifySourceBackedReportTypes error = %v, want sections drift", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSourceBackedPackReportTypeSweeperUsesCompleteComponentUnion(t *testing.T) {
	for _, tc := range []struct {
		name     string
		external bool
	}{
		{name: "internal"},
		{name: "external", external: true},
	} {
		t.Run(tc.name, func(t *testing.T) {
			options := []SourceBackedPackOption{}
			if tc.external {
				fakeReportTypeHandlerFires = 0
				options = append(options, withExternalBuildRoutesForTest(reportTypeRoutes(t, fakeReportTypeHandler)))
			}
			pack, err := NewSourceBackedPack(reportTypeSweeperTestSource(), options...)
			if err != nil {
				t.Fatalf("NewSourceBackedPack: %v", err)
			}
			if tc.external && fakeReportTypeHandlerFires != 2 {
				t.Fatalf("external report type handler fires = %d, want 2", fakeReportTypeHandlerFires)
			}

			reportTypePlan := reportTypeOnlyInstallPlan(t, pack)
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("sqlmock: %v", err)
			}
			defer db.Close()
			for _, fullName := range []string{"OpportunityFieldAuditHistory", "OpportunityHistory"} {
				mock.ExpectQuery("SELECT OwnerPack").WithArgs(fullName).WillReturnError(sql.ErrNoRows)
				mock.ExpectExec("INSERT INTO _System_ReportType").WillReturnResult(sqlmock.NewResult(1, 1))
			}
			mock.ExpectQuery("SELECT FullName").
				WithArgs("salesforce-crm-standard").
				WillReturnRows(sqlmock.NewRows([]string{"FullName"}).
					AddRow("AccountHistory").
					AddRow("OpportunityFieldAuditHistory").
					AddRow("OpportunityFieldHistory").
					AddRow("OpportunityHistory"))
			mock.ExpectExec("UPDATE _System_ReportType[\\s\\S]*WHERE FullName = \\? AND OwnerPack = \\? AND COALESCE\\(IsDeleted, 0\\) = 0").
				WithArgs(sqlmock.AnyArg(), "AccountHistory", "salesforce-crm-standard").
				WillReturnResult(sqlmock.NewResult(0, 1))
			mock.ExpectExec("UPDATE _System_ReportType[\\s\\S]*WHERE FullName = \\? AND OwnerPack = \\? AND COALESCE\\(IsDeleted, 0\\) = 0").
				WithArgs(sqlmock.AnyArg(), "OpportunityFieldHistory", "salesforce-crm-standard").
				WillReturnResult(sqlmock.NewResult(0, 1))

			if err := reportTypePlan.Apply(context.Background(), ApplyDeps{DB: db}, InstallPlanHooks{}); err != nil {
				t.Fatalf("report type InstallPlan Apply: %v", err)
			}
			if err := mock.ExpectationsWereMet(); err != nil {
				t.Fatalf("sql expectations: %v", err)
			}
		})
	}
}

func TestSourceBackedPackWithoutReportTypesStillRunsEmptyUnionSweeper(t *testing.T) {
	pack, err := NewSourceBackedPack(&ResolvedPackSource{Manifest: &PackManifest{
		Name: "empty-report-types", Category: AppPack,
	}})
	if err != nil {
		t.Fatalf("NewSourceBackedPack: %v", err)
	}
	reportTypePlan := reportTypeOnlyInstallPlan(t, pack)
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT FullName").
		WithArgs("empty-report-types").
		WillReturnRows(sqlmock.NewRows([]string{"FullName"}).AddRow("Obsolete_Report"))
	mock.ExpectExec("UPDATE _System_ReportType[\\s\\S]*WHERE FullName = \\? AND OwnerPack = \\? AND COALESCE\\(IsDeleted, 0\\) = 0").
		WithArgs(sqlmock.AnyArg(), "Obsolete_Report", "empty-report-types").
		WillReturnResult(sqlmock.NewResult(0, 1))

	if err := reportTypePlan.Apply(context.Background(), ApplyDeps{DB: db}, InstallPlanHooks{}); err != nil {
		t.Fatalf("empty report type InstallPlan Apply: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestManagedReportTypeRegistrationOwnsSetReconciliation(t *testing.T) {
	registration := testComponentKindRegistration(cp.ComponentKindReportType, "_System_ReportType")
	registration.BuildHandler = func(buildContext cp.ComponentBuildContext, _ cp.ComponentBuildRequest) error {
		return buildContext.AddManagedComponent(cp.ComponentBuildManagedComponent{ExpectedProjection: []byte(`{}`)})
	}
	for _, tc := range []struct {
		name   string
		source *ResolvedPackSource
		want   int
	}{
		{name: "declared components", source: reportTypeSweeperTestSource(), want: 2},
		{name: "undeclared kind has no set authority", source: &ResolvedPackSource{Manifest: &PackManifest{Name: "empty-report-types", Category: AppPack}}, want: 0},
	} {
		t.Run(tc.name, func(t *testing.T) {
			pack, err := NewSourceBackedPack(tc.source, WithComponentKindRegistrations([]cp.ComponentKindRegistration{registration}))
			if err != nil {
				t.Fatalf("NewSourceBackedPack: %v", err)
			}
			for _, component := range pack.sweepComponents {
				if component.Kind() == ComponentKindReportType && component.BusinessKey() == JoinComponentBusinessKey(pack.Name(), "@sweeper") {
					t.Fatal("managed report type registration retained legacy report type sweeper")
				}
			}
			managedSets, err := pack.externalManagedSetComponents()
			if err != nil {
				t.Fatalf("externalManagedSetComponents: %v", err)
			}
			if tc.want == 0 {
				if len(managedSets) != 0 {
					t.Fatalf("managed set components = %d, want none for undeclared kind", len(managedSets))
				}
				return
			}
			if len(managedSets) != 1 {
				t.Fatalf("managed set components = %d, want 1", len(managedSets))
			}
			set, ok := managedSets[0].(*sourceBackedExternalManagedSetComponent)
			if !ok || set.Kind() != ComponentKindReportType || len(set.declaredKeys) != tc.want {
				t.Fatalf("managed report type set = %#v, want %d declared keys", managedSets[0], tc.want)
			}
		})
	}
}

func reportTypeSweeperTestSource() *ResolvedPackSource {
	return &ResolvedPackSource{Manifest: &PackManifest{
		Name: "salesforce-crm-standard", Category: AppPack,
		Components: []ManifestComponent{
			{
				Kind: ComponentKindReportType, BusinessKey: "OpportunityFieldAuditHistory", Manageability: ManageabilityLocked,
				Payload: []byte(`{"reportTypes":[{"fullName":"OpportunityFieldAuditHistory","label":"Opportunity Field Audit History","baseObject":"Opportunity","deployed":true}]}`),
			},
			{
				Kind: ComponentKindReportType, BusinessKey: "OpportunityHistory", Manageability: ManageabilityLocked,
				Payload: []byte(`{"reportTypes":[{"fullName":"OpportunityHistory","label":"Opportunity History","baseObject":"Opportunity","deployed":true}]}`),
			},
		},
	}}
}

func reportTypeOnlyInstallPlan(t *testing.T, pack *SourceBackedPack) InstallPlan {
	t.Helper()
	plan, err := pack.InstallPlan()
	if err != nil {
		t.Fatalf("InstallPlan: %v", err)
	}
	var reportTypes []PackComponent
	for _, component := range plan.ComponentsForPhase(InstallPlanPhaseReporting) {
		if component.Kind() == ComponentKindReportType {
			reportTypes = append(reportTypes, component)
		}
	}
	declaredCount := 0
	for _, component := range pack.Components() {
		if component.Kind() == ComponentKindReportType {
			declaredCount++
		}
	}
	if len(reportTypes) != declaredCount+1 {
		t.Fatalf("report type install steps = %d, want %d declarations plus one sweeper", len(reportTypes), declaredCount)
	}
	if got := reportTypes[len(reportTypes)-1].BusinessKey(); got != JoinComponentBusinessKey(pack.Name(), "@sweeper") {
		t.Fatalf("last report type install step = %q, want pack sweeper", got)
	}
	reportTypePlan, err := NewInstallPlan(reportTypes)
	if err != nil {
		t.Fatalf("report type NewInstallPlan: %v", err)
	}
	return reportTypePlan
}

func TestVerifySourceBackedReportTypePackSweeperRejectsStaleOwnerRows(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	batch := &sourceBackedReportTypePackBatch{declaredFullNames: []string{"Account_With_Contacts"}}
	mock.ExpectQuery("SELECT FullName").
		WithArgs("salesforce-crm-standard").
		WillReturnRows(sqlmock.NewRows([]string{"FullName"}).
			AddRow("Account_With_Contacts").
			AddRow("Obsolete_Report"))

	verifier := newSourceBackedReportTypePackSweeperComponent("salesforce-crm-standard", batch).(componentCustomVerifier)
	err = verifier.VerifyComponent(context.Background(), VerifyDeps{DB: db})
	if err == nil || !strings.Contains(err.Error(), "Obsolete_Report") {
		t.Fatalf("report type pack sweeper verify error = %v, want stale owner row", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestLiveAutoNumberFormatProjectionIgnoresDifferentOwnerPack asserts that a
// _System_Field row whose OwnerPack does not match the pack being verified is
// invisible to liveSourceBackedAutoNumberFormatProjection.  Before the fix the
// WHERE clause lacked the OwnerPack predicate, so a partial install by a
// different pack could silently satisfy this pack's verify pass.
func TestLiveAutoNumberFormatProjectionIgnoresDifferentOwnerPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := []sourceBackedAutoNumberFormatRow{{
		ObjectName:   "Case",
		FieldAPIName: "CaseNumber",
		Format:       "CASE-{0000}",
		Scope:        "global",
		Start:        1,
	}}

	// The query must include the OwnerPack predicate; sqlmock's default mode
	// matches as a substring regex so we can anchor on the distinctive clause.
	mock.ExpectQuery("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs("Case", "CaseNumber", "pack-a").
		WillReturnRows(sqlmock.NewRows([]string{
			"o.ApiName", "f.ApiName",
			"auto_number_format", "auto_number_scope", "auto_number_start", "auto_number_token_refs",
		}))
	// WillReturnRows with zero rows causes QueryRowContext to return sql.ErrNoRows.

	_, gotErr := liveSourceBackedAutoNumberFormatProjection(
		context.Background(),
		VerifyDeps{DB: db},
		"pack-a",
		expected,
	)
	if gotErr == nil {
		t.Fatal("liveSourceBackedAutoNumberFormatProjection = nil, want missing-field error")
	}
	if !strings.Contains(gotErr.Error(), "Case.CaseNumber is missing") {
		t.Fatalf("error = %v, want 'Case.CaseNumber is missing'", gotErr)
	}
	// Verify the SQL expectation with the OwnerPack predicate was actually hit.
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestLiveAutoNumberFormatProjectionMatchesCorrectOwnerPack asserts that a
// _System_Field row whose OwnerPack matches the pack being verified is returned
// successfully.
func TestLiveAutoNumberFormatProjectionMatchesCorrectOwnerPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := []sourceBackedAutoNumberFormatRow{{
		ObjectName:   "Case",
		FieldAPIName: "CaseNumber",
		Format:       "CASE-{0000}",
		Scope:        "global",
		Start:        1,
	}}

	mock.ExpectQuery("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs("Case", "CaseNumber", "pack-a").
		WillReturnRows(sqlmock.NewRows([]string{
			"o.ApiName", "f.ApiName",
			"auto_number_format", "auto_number_scope", "auto_number_start", "auto_number_token_refs",
		}).AddRow(
			"Case", "CaseNumber",
			sql.NullString{String: "CASE-{0000}", Valid: true},
			sql.NullString{String: "global", Valid: true},
			sql.NullInt64{Int64: 1, Valid: true},
			sql.NullString{},
		))

	got, err := liveSourceBackedAutoNumberFormatProjection(
		context.Background(),
		VerifyDeps{DB: db},
		"pack-a",
		expected,
	)
	if err != nil {
		t.Fatalf("liveSourceBackedAutoNumberFormatProjection = %v, want nil", err)
	}
	rows, ok := got.([]sourceBackedAutoNumberFormatRow)
	if !ok || len(rows) != 1 {
		t.Fatalf("got %v, want 1 row", got)
	}
	if rows[0].ObjectName != "Case" || rows[0].FieldAPIName != "CaseNumber" {
		t.Fatalf("got row %+v, want Case.CaseNumber", rows[0])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestApplyAutoNumberFormatSeedsSkipsMismatchedOwnerPack asserts that the apply
// UPDATE includes the OwnerPack predicate and silently skips (RowsAffected=0)
// a field owned by a different pack, matching the existing "field not present"
// silent-skip contract.
func TestApplyAutoNumberFormatSeedsSkipsMismatchedOwnerPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	seeds := []AutoNumberFormatSeed{{
		ObjectName:   "Case",
		FieldApiName: "CaseNumber",
		Format:       "CASE-{0000}",
		Scope:        "global",
		Start:        1,
		// TokenRefsJSON empty — second UPDATE uses nil arg
	}}

	// First UPDATE: auto_number_format/scope/start with the OwnerPack predicate.
	// The trailing arg must be the packName "pack-a".
	// RowsAffected=0 simulates a field owned by a different pack (silently skipped).
	mock.ExpectExec("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs(
			seeds[0].Format, seeds[0].Scope, seeds[0].Start,
			seeds[0].ObjectName, seeds[0].FieldApiName,
			seeds[0].Format, seeds[0].Scope, seeds[0].Start,
			"pack-a",
		).
		WillReturnResult(sqlmock.NewResult(0, 0))

	// Second UPDATE: auto_number_token_refs with the OwnerPack predicate.
	// TokenRefsJSON is empty so both value args are nil; trailing arg must be packName "pack-a".
	mock.ExpectExec("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs(nil, seeds[0].ObjectName, seeds[0].FieldApiName, nil, "pack-a").
		WillReturnResult(sqlmock.NewResult(0, 0))

	if err := ApplyAutoNumberFormatSeeds(db, "pack-a", seeds); err != nil {
		t.Fatalf("ApplyAutoNumberFormatSeeds = %v, want nil (silent skip on RowsAffected=0)", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestApplyAutoNumberFormatSeedsAdminPathMatchesNullOwnerPack asserts that
// passing packName=="" lands on the NULL-OwnerPack branch
// (`COALESCE(f.OwnerPack, ”) = ”`), which is the correct path for tenant
// overlay / admin-authored fields persisted with OwnerPack=NULL.
func TestApplyAutoNumberFormatSeedsAdminPathMatchesNullOwnerPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	seeds := []AutoNumberFormatSeed{{
		ObjectName:   "Case",
		FieldApiName: "CaseNumber",
		Format:       "CASE-{0000}",
		Scope:        "global",
		Start:        1,
		// TokenRefsJSON empty
	}}

	// First UPDATE: trailing arg must be "" to match COALESCE(f.OwnerPack,'')=''
	// RowsAffected=1 simulates a successful tenant overlay field update.
	mock.ExpectExec("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs(
			seeds[0].Format, seeds[0].Scope, seeds[0].Start,
			seeds[0].ObjectName, seeds[0].FieldApiName,
			seeds[0].Format, seeds[0].Scope, seeds[0].Start,
			"",
		).
		WillReturnResult(sqlmock.NewResult(1, 1))

	// Second UPDATE: token_refs; trailing arg must be "" for the admin path.
	mock.ExpectExec("COALESCE\\(f\\.OwnerPack, ''\\) = \\?").
		WithArgs(nil, seeds[0].ObjectName, seeds[0].FieldApiName, nil, "").
		WillReturnResult(sqlmock.NewResult(0, 0))

	if err := ApplyAutoNumberFormatSeeds(db, "", seeds); err != nil {
		t.Fatalf("ApplyAutoNumberFormatSeeds(admin path) = %v, want nil", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestNewSourceBackedAutoNumberFormatComponentRejectsEmptyPackName asserts that
// constructing an auto-number format component with an empty or whitespace-only
// manifest name returns an error, preventing a malformed source-backed pack from
// using the admin-path NULL-OwnerPack predicate.
func TestNewSourceBackedAutoNumberFormatComponentRejectsEmptyPackName(t *testing.T) {
	payload := []byte(`[{"objectName":"Case","fieldApiName":"CaseNumber","format":"CASE-{0000}","scope":"global","start":1}]`)
	component := ManifestComponent{Kind: ComponentKindAutoNumberFormat, Path: "autonumber.json"}

	for _, name := range []string{"", "   "} {
		_, err := newSourceBackedAutoNumberFormatComponent(name, component, payload)
		if err == nil {
			t.Errorf("newSourceBackedAutoNumberFormatComponent(%q) = nil error, want error", name)
		}
	}
}

// TestNewSourceBackedAutoNumberCounterComponentRejectsEmptyPackName asserts that
// constructing an auto-number counter component with an empty or whitespace-only
// manifest name returns an error.
func TestNewSourceBackedAutoNumberCounterComponentRejectsEmptyPackName(t *testing.T) {
	payload := []byte(`[{"objectName":"Case","fieldApiName":"CaseNumber","format":"CASE-{0000}","scope":"global","start":1}]`)
	component := ManifestComponent{Kind: ComponentKindAutoNumberFormat, Path: "autonumber.json"}

	for _, name := range []string{"", "   "} {
		_, err := newSourceBackedAutoNumberCounterComponent(name, component, payload)
		if err == nil {
			t.Errorf("newSourceBackedAutoNumberCounterComponent(%q) = nil error, want error", name)
		}
	}
}

// TestNewSourceBackedPageLayoutComponentRejectsEmptyPackName asserts that
// constructing a source-backed page-layout component with an empty or
// whitespace-only manifest name returns an error, preventing a malformed pack
// from using the admin-path NULL-OwnerPack slot reserved for tenant overlay.
func TestNewSourceBackedPageLayoutComponentRejectsEmptyPackName(t *testing.T) {
	payload := []byte(`{"objectApiName":"Widget","layoutName":"Widget Layout","sections":[]}`)
	component := ManifestComponent{Kind: ComponentKindPageLayout, Path: "widget.json"}

	for _, name := range []string{"", "   "} {
		_, err := newSourceBackedPageLayoutComponent(name, component, payload, nil)
		if err == nil {
			t.Errorf("newSourceBackedPageLayoutComponent(%q) = nil error, want error", name)
		}
	}
}

// TestNewSourceBackedWebhookSecretComponent verifies that the source-backed
// webhook secret constructor correctly parses the manifest payload and wires
// apply/projection to the existing webhook_secret_component.go helpers.
func TestNewSourceBackedWebhookSecretComponent(t *testing.T) {
	packName := "connector-pack"
	payload := []byte(`{"name":"events","algorithm":"HMAC-SHA256"}`)
	wantKey := JoinComponentBusinessKey(packName, "events")
	// BusinessKey is carried by the ManifestComponent as authored in the pack
	// manifest — the source-backed wrapper does not derive it from the payload.
	component := ManifestComponent{Kind: ComponentKindWebhookSecret, Path: "webhook_secret.json", BusinessKey: wantKey}

	comp, err := newSourceBackedWebhookSecretComponent(packName, component, payload)
	if err != nil {
		t.Fatalf("newSourceBackedWebhookSecretComponent: %v", err)
	}
	if comp.Kind() != ComponentKindWebhookSecret {
		t.Errorf("Kind() = %q, want %q", comp.Kind(), ComponentKindWebhookSecret)
	}
	if comp.BusinessKey() != wantKey {
		t.Errorf("BusinessKey() = %q, want %q", comp.BusinessKey(), wantKey)
	}
	proj, err := comp.ExpectedProjection()
	if err != nil {
		t.Fatalf("ExpectedProjection: %v", err)
	}
	row, ok := proj.(webhookSecretProjectionRow)
	if !ok {
		t.Fatalf("ExpectedProjection type = %T, want webhookSecretProjectionRow", proj)
	}
	if row.OwnerPack != packName {
		t.Errorf("OwnerPack = %q, want %q", row.OwnerPack, packName)
	}
	if row.Name != "events" {
		t.Errorf("Name = %q, want %q", row.Name, "events")
	}
	if row.Algorithm != "HMAC-SHA256" {
		t.Errorf("Algorithm = %q, want %q", row.Algorithm, "HMAC-SHA256")
	}
}

// TestNewSourceBackedWebhookSecretComponentApply verifies that Apply calls
// upsertWebhookSecretDefinition (issues an INSERT INTO _System_WebhookSecret).
func TestNewSourceBackedWebhookSecretComponentApply(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	packName := "connector-pack"
	payload := []byte(`{"name":"orders","algorithm":"HMAC-SHA256","description":"order events"}`)
	component := ManifestComponent{Kind: ComponentKindWebhookSecret, Path: "webhook_secret.json", BusinessKey: JoinComponentBusinessKey(packName, "orders")}

	comp, err := newSourceBackedWebhookSecretComponent(packName, component, payload)
	if err != nil {
		t.Fatalf("newSourceBackedWebhookSecretComponent: %v", err)
	}

	mock.ExpectExec(regexp.QuoteMeta("INSERT INTO _System_WebhookSecret")).
		WillReturnResult(sqlmock.NewResult(1, 1))

	if err := comp.Apply(context.Background(), ApplyDeps{DB: db}); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestNewSourceBackedWebhookSecretComponent_RejectsBusinessKeyMismatch verifies
// that a ManifestComponent whose BusinessKey does not match the identity derived
// from (packName, def.Name) causes construction to fail closed. Without this
// check a typo or copy-paste error in the manifest lets the sweeper see a
// declared name that differs from the installed row key, either orphaning the
// just-installed row or creating a duplicate collision on the live DB row.
func TestNewSourceBackedWebhookSecretComponent_RejectsBusinessKeyMismatch(t *testing.T) {
	packName := "connector-pack"
	payload := []byte(`{"name":"actual-name","algorithm":"HMAC-SHA256"}`)
	component := ManifestComponent{
		Kind:        ComponentKindWebhookSecret,
		Path:        "webhook_secret.json",
		BusinessKey: "wrong-pack/wrong-name",
	}
	_, err := newSourceBackedWebhookSecretComponent(packName, component, payload)
	if err == nil {
		t.Fatal("newSourceBackedWebhookSecretComponent: expected error for businessKey mismatch, got nil")
	}
	if !strings.Contains(err.Error(), "wrong-pack/wrong-name") || !strings.Contains(err.Error(), "connector-pack/actual-name") {
		t.Errorf("error %q should mention both the manifest key and expected key", err.Error())
	}
}

// TestNewSourceBackedWebhookSecretComponentRejectsEmptyPackName mirrors the
// analogous empty-name guards on other source-backed component constructors.
func TestNewSourceBackedWebhookSecretComponentRejectsEmptyPackName(t *testing.T) {
	payload := []byte(`{"name":"events","algorithm":"HMAC-SHA256"}`)
	component := ManifestComponent{Kind: ComponentKindWebhookSecret, Path: "webhook_secret.json"}

	for _, name := range []string{"", "   "} {
		_, err := newSourceBackedWebhookSecretComponent(name, component, payload)
		if err == nil {
			t.Errorf("newSourceBackedWebhookSecretComponent(%q) = nil error, want error", name)
		}
	}
}
