package controlplane

import (
	"context"
	"regexp"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/pingcap-inc/nexuscrm-controlplane/packs"
)

// platformUXPackName is the canonical name returned by PlatformUXPack.Name().
const platformUXPackName = "platform-ux"

// makeOracleWithPageLayout builds a minimal l0Oracle that:
//   - recognises objectAPIName as an L0 object
//   - declares one PageLayout component owned by ownerPack
func makeOracleWithPageLayout(objectAPIName, layoutID, ownerPack string) *l0Oracle {
	businessKey := packs.JoinComponentBusinessKey(objectAPIName, layoutID)
	component := packs.DeclaredComponent{
		PackName:    ownerPack,
		Kind:        packs.ComponentKindPageLayout,
		BusinessKey: businessKey,
	}
	o := &l0Oracle{
		objectNames:        map[string]struct{}{objectAPIName: {}},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
	}
	key := declaredComponentKey(ownerPack, packs.ComponentKindPageLayout, businessKey)
	o.declaredComponents[key] = component
	return o
}

// makeOracleWithLayoutAssignment builds a minimal l0Oracle that:
//   - recognises objectAPIName as an L0 object
//   - declares one LayoutAssignment component owned by ownerPack
func makeOracleWithLayoutAssignment(objectAPIName, assignmentID, ownerPack string) *l0Oracle {
	businessKey := packs.JoinComponentBusinessKey(objectAPIName, assignmentID)
	component := packs.DeclaredComponent{
		PackName:    ownerPack,
		Kind:        packs.ComponentKindLayoutAssignment,
		BusinessKey: businessKey,
	}
	o := &l0Oracle{
		objectNames:        map[string]struct{}{objectAPIName: {}},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
	}
	key := declaredComponentKey(ownerPack, packs.ComponentKindLayoutAssignment, businessKey)
	o.declaredComponents[key] = component
	return o
}

// makeOracleWithCalloutProfile builds a minimal l0Oracle that:
//   - includes packName in runtimePacksSorted (so the IN predicate returns rows)
//   - declares one ConnectorCalloutProfile component with the given packName and profileName
func makeOracleWithCalloutProfile(packName, profileName string) *l0Oracle {
	businessKey := packs.JoinComponentBusinessKey(packName, profileName)
	component := packs.DeclaredComponent{
		PackName:    packName,
		Kind:        packs.ComponentKindConnectorCalloutProfile,
		BusinessKey: businessKey,
	}
	o := &l0Oracle{
		objectNames:        map[string]struct{}{},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{packName: {}},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
		runtimePacksSorted: []string{packName},
	}
	key := declaredComponentKey(packName, packs.ComponentKindConnectorCalloutProfile, businessKey)
	o.declaredComponents[key] = component
	return o
}

// makeOracleWithConnectorRateLimit builds a minimal l0Oracle that:
//   - includes packName in runtimePacksSorted (so the IN predicate returns rows)
//   - declares one ConnectorRateLimit component keyed by packName/endpointClass
func makeOracleWithConnectorRateLimit(packName, endpointClass string) *l0Oracle {
	businessKey := packs.JoinComponentBusinessKey(packName, endpointClass)
	component := packs.DeclaredComponent{
		PackName:    packName,
		Kind:        packs.ComponentKindConnectorRateLimit,
		BusinessKey: businessKey,
	}
	o := &l0Oracle{
		objectNames:        map[string]struct{}{},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{packName: {}},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
		runtimePacksSorted: []string{packName},
	}
	key := declaredComponentKey(packName, packs.ComponentKindConnectorRateLimit, businessKey)
	o.declaredComponents[key] = component
	return o
}

// makeOracleWithConnectorInboundHandlerConfig builds a minimal l0Oracle that:
//   - includes namespace in runtimePacksSorted (so the IN predicate returns rows)
//   - declares one ConnectorInboundHandlerConfig component keyed by namespace:capabilityKind
func makeOracleWithConnectorInboundHandlerConfig(namespace, capabilityKind string) *l0Oracle {
	businessKey := namespace + ":" + capabilityKind
	return makeOracleWithConnectorInboundHandlerConfigBusinessKey(namespace, capabilityKind, businessKey)
}

func makeOracleWithConnectorInboundHandlerConfigBusinessKey(namespace, capabilityKind, businessKey string) *l0Oracle {
	component := packs.DeclaredComponent{
		PackName:    namespace,
		Kind:        packs.ComponentKindConnectorInboundHandlerConfig,
		BusinessKey: businessKey,
	}
	o := &l0Oracle{
		objectNames:        map[string]struct{}{},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{namespace: {}},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
		runtimePacksSorted: []string{namespace},
	}
	key := declaredComponentKey(namespace, packs.ComponentKindConnectorInboundHandlerConfig, businessKey)
	o.declaredComponents[key] = component
	return o
}

func ownerStampedQueryPattern(ownerExpr, keyExpr, sampleExpr, from string) string {
	return `(?s)SELECT\s+` + regexp.QuoteMeta(ownerExpr) +
		`,\s+` + regexp.QuoteMeta(keyExpr) +
		`,\s+` + regexp.QuoteMeta(sampleExpr) +
		`\s+FROM\s+` + regexp.QuoteMeta(from)
}

// expectOwnerStampedChecksEmptyExcept sets up sqlmock expectations for
// checkOwnerStampedDeclaredComponents. All tables before targetTable return
// empty rows; targetTable's query returns the supplied targetRows.
// Tables after targetTable return empty rows.
func expectOwnerStampedChecksEmptyExcept(mock sqlmock.Sqlmock, targetTable string, targetRows *sqlmock.Rows, targetQueryPattern ...string) {
	// Ordered list of tables that checkOwnerStampedDeclaredComponents iterates.
	tables := []string{
		"_System_RecordType",
		"_System_ValidationRule",
		"_System_ReportType",
		"_System_Action",
		"_System_StandardAction",
		"_System_Permission",
		"_System_CustomPermission",
		"_System_RecordTypePicklistValue",
		"_System_ConnectorRateLimit",
		"_System_ConnectorInboundHandlerConfig",
		"_System_ConnectorCalloutProfile",
		"_System_EmailTemplate",
		"_System_Object.AccessPolicyRecordMutation",
		"_System_Object.AccessPolicyLifecycle",
	}
	for _, t := range tables {
		queryPattern := t
		switch t {
		case "_System_Object.AccessPolicyRecordMutation":
			queryPattern = ownerStampedQueryPattern(
				"o.AccessPolicyRecordMutationPackName",
				"o.AccessPolicyRecordMutationPackBusinessKey",
				"CONCAT(COALESCE(o.AccessPolicyRecordMutationPackName, ''), ':', o.ApiName)",
				"_System_Object o",
			)
		case "_System_Object.AccessPolicyLifecycle":
			queryPattern = ownerStampedQueryPattern(
				"o.AccessPolicyLifecyclePackName",
				"o.AccessPolicyLifecyclePackBusinessKey",
				"CONCAT(COALESCE(o.AccessPolicyLifecyclePackName, ''), ':', o.ApiName)",
				"_System_Object o",
			)
		}
		if t == targetTable {
			if len(targetQueryPattern) > 0 {
				queryPattern = targetQueryPattern[0]
			}
			mock.ExpectQuery(queryPattern).WillReturnRows(targetRows)
		} else {
			mock.ExpectQuery(queryPattern).WillReturnRows(sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}))
		}
	}
}

// --- checkOwnerStampedDeclaredComponents (ConnectorRateLimit) ---

// TestVerifyL0_ConnectorRateLimitLiveRowMatchesDeclaredComponent seeds a declared
// connector_rate_limit component keyed by packName/endpointClass and a matching
// live _System_ConnectorRateLimit row. verify_l0 must not emit an ownership gap.
func TestVerifyL0_ConnectorRateLimitLiveRowMatchesDeclaredComponent(t *testing.T) {
	const (
		packName      = "acme"
		endpointClass = "bulk-api"
	)

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

	liveRows := sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}).
		AddRow(packName, packName+"/"+endpointClass, packName+":"+endpointClass)

	expectOwnerStampedChecksEmptyExcept(mock, "_System_ConnectorRateLimit", liveRows,
		ownerStampedQueryPattern(
			"crl.OwnerPack",
			"CONCAT(COALESCE(crl.OwnerPack, ''), '/', crl.EndpointClass)",
			"CONCAT(COALESCE(crl.OwnerPack, ''), ':', crl.EndpointClass)",
			"_System_ConnectorRateLimit crl",
		))

	oracle := makeOracleWithConnectorRateLimit(packName, endpointClass)
	report := &l0DiagnosticReport{}
	if err := checkOwnerStampedDeclaredComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkOwnerStampedDeclaredComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps for matching declared+live row; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// --- checkOwnerStampedDeclaredComponents (ConnectorInboundHandlerConfig) ---

// TestVerifyL0_ConnectorInboundHandlerConfigLiveRowMatchesDeclaredComponent seeds a declared
// connector_inbound_handler_config component keyed by namespace:capabilityKind and a matching
// live _System_ConnectorInboundHandlerConfig row. verify_l0 must not emit an ownership gap.
func TestVerifyL0_ConnectorInboundHandlerConfigLiveRowMatchesDeclaredComponent(t *testing.T) {
	const (
		namespace      = "acme"
		capabilityKind = "signature_verifier"
	)

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

	liveRows := sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}).
		AddRow(namespace, namespace+":"+capabilityKind, namespace+":"+capabilityKind)

	expectOwnerStampedChecksEmptyExcept(mock, "_System_ConnectorInboundHandlerConfig", liveRows,
		ownerStampedQueryPattern(
			"cihc.Namespace",
			"CONCAT(COALESCE(cihc.Namespace, ''), ':', cihc.CapabilityKind)",
			"CONCAT(COALESCE(cihc.Namespace, ''), ':', cihc.CapabilityKind)",
			"_System_ConnectorInboundHandlerConfig cihc",
		))

	oracle := makeOracleWithConnectorInboundHandlerConfig(namespace, capabilityKind)
	report := &l0DiagnosticReport{}
	if err := checkOwnerStampedDeclaredComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkOwnerStampedDeclaredComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps for matching declared+live row; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestVerifyL0_ConnectorInboundHandlerConfigCapabilityOnlyKeyFlaggedAsGap proves
// the declared-component lookup uses the full namespace:capabilityKind business
// key instead of accepting the legacy CapabilityKind-only shape.
func TestVerifyL0_ConnectorInboundHandlerConfigCapabilityOnlyKeyFlaggedAsGap(t *testing.T) {
	const (
		namespace      = "acme"
		capabilityKind = "signature_verifier"
	)

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

	liveRows := sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}).
		AddRow(namespace, namespace+":"+capabilityKind, namespace+":"+capabilityKind)

	expectOwnerStampedChecksEmptyExcept(mock, "_System_ConnectorInboundHandlerConfig", liveRows,
		ownerStampedQueryPattern(
			"cihc.Namespace",
			"CONCAT(COALESCE(cihc.Namespace, ''), ':', cihc.CapabilityKind)",
			"CONCAT(COALESCE(cihc.Namespace, ''), ':', cihc.CapabilityKind)",
			"_System_ConnectorInboundHandlerConfig cihc",
		))

	oracle := makeOracleWithConnectorInboundHandlerConfigBusinessKey(namespace, capabilityKind, capabilityKind)
	report := &l0DiagnosticReport{}
	if err := checkOwnerStampedDeclaredComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkOwnerStampedDeclaredComponents: %v", err)
	}
	if len(report.ownershipGaps) == 0 {
		t.Fatal("expected an ownership gap for CapabilityKind-only ConnectorInboundHandlerConfig key; got none")
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// --- checkOwnerStampedDeclaredComponents (ConnectorCalloutProfile) ---

// TestVerifyL0_ConnectorCalloutProfileLiveRowMatchesDeclaredComponent seeds a declared
// connector_callout_profile component (Pack="acme", ProfileName="default") and a matching
// live _System_ConnectorCalloutProfile row. verify_l0 must not emit an ownership gap.
func TestVerifyL0_ConnectorCalloutProfileLiveRowMatchesDeclaredComponent(t *testing.T) {
	const (
		packName    = "acme"
		profileName = "default"
	)

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

	// The live row has OwnerPack="acme", ProfileName="default".
	// After the fix, keyExpr = CONCAT(COALESCE(ccp.OwnerPack,''),'/',ccp.ProfileName)
	// so the scanned businessKey will be "acme/default", matching JoinComponentBusinessKey.
	liveRows := sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}).
		AddRow(packName, packName+"/"+profileName, packName+":"+profileName)

	expectOwnerStampedChecksEmptyExcept(mock, "_System_ConnectorCalloutProfile", liveRows,
		ownerStampedQueryPattern(
			"ccp.OwnerPack",
			"CONCAT(COALESCE(ccp.OwnerPack, ''), '/', ccp.ProfileName)",
			"CONCAT(COALESCE(ccp.OwnerPack, ''), ':', ccp.ProfileName)",
			"_System_ConnectorCalloutProfile ccp",
		))

	oracle := makeOracleWithCalloutProfile(packName, profileName)
	report := &l0DiagnosticReport{}
	if err := checkOwnerStampedDeclaredComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkOwnerStampedDeclaredComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps for matching declared+live row; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestVerifyL0_ConnectorCalloutProfileUnstampedRowFlaggedAsGap seeds a live
// _System_ConnectorCalloutProfile row with no corresponding declared component.
// verify_l0 must report an ownership gap for this row.
func TestVerifyL0_ConnectorCalloutProfileUnstampedRowFlaggedAsGap(t *testing.T) {
	const (
		packName    = "acme"
		profileName = "orphan"
	)

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

	// Live row exists but no declared component registered in oracle.
	liveRows := sqlmock.NewRows([]string{"ownerPack", "businessKey", "sample"}).
		AddRow(packName, packName+"/"+profileName, packName+":"+profileName)

	expectOwnerStampedChecksEmptyExcept(mock, "_System_ConnectorCalloutProfile", liveRows,
		ownerStampedQueryPattern(
			"ccp.OwnerPack",
			"CONCAT(COALESCE(ccp.OwnerPack, ''), '/', ccp.ProfileName)",
			"CONCAT(COALESCE(ccp.OwnerPack, ''), ':', ccp.ProfileName)",
			"_System_ConnectorCalloutProfile ccp",
		))

	// Oracle knows packName (so row passes the IN predicate) but has no declared component.
	o := &l0Oracle{
		objectNames:        map[string]struct{}{},
		tableNames:         map[string]struct{}{},
		fieldKeys:          map[string]struct{}{},
		relationshipKeys:   map[string]struct{}{},
		listViewKeys:       map[string]struct{}{},
		runtimePackNames:   map[string]struct{}{packName: {}},
		declaredComponents: map[string]packs.DeclaredComponent{},
		permissionAPINames: map[string]struct{}{},
		profileIDs:         map[string]struct{}{},
		runtimePacksSorted: []string{packName},
	}
	report := &l0DiagnosticReport{}
	if err := checkOwnerStampedDeclaredComponents(context.Background(), db, o, report); err != nil {
		t.Fatalf("checkOwnerStampedDeclaredComponents: %v", err)
	}
	if len(report.ownershipGaps) == 0 {
		t.Fatal("expected an ownership gap for orphan ConnectorCalloutProfile row; got none")
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// --- checkPageLayoutComponents ---

// TestCheckPageLayoutComponents_UsesExplicitOwnerPackOverDerivation verifies that a
// row with OwnerPack='source-pack-a' and Source='curated' is resolved as ownerPack
// 'source-pack-a', not the Source-string derivation (platformUXPackName).
// If the explicit column is ignored, oracle.declaredComponent would look up
// 'platform-ux' instead of 'source-pack-a' and report a gap.
func TestCheckPageLayoutComponents_UsesExplicitOwnerPackOverDerivation(t *testing.T) {
	const (
		objectAPIName = "Account"
		layoutID      = "layout-001"
		layoutName    = "Account Layout"
		explicitPack  = "source-pack-a"
	)

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

	cols := []string{"Id", "ObjectApiName", "LayoutName", "Source", "IsCustomized", "Config", "OwnerPack"}
	rows := sqlmock.NewRows(cols).AddRow(layoutID, objectAPIName, layoutName, "curated", 0, "{}", explicitPack)
	mock.ExpectQuery(`SELECT Id`).WillReturnRows(rows)

	oracle := makeOracleWithPageLayout(objectAPIName, layoutID, explicitPack)
	report := &l0DiagnosticReport{}
	if err := checkPageLayoutComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkPageLayoutComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestCheckPageLayoutComponents_FallsBackToSourceDerivationWhenOwnerPackNull verifies
// that a row with OwnerPack=” (NULL in DB, COALESCE'd to ”) and Source='curated'
// falls back to l0PageLayoutOwnerPack('curated') = platformUXPackName.
func TestCheckPageLayoutComponents_FallsBackToSourceDerivationWhenOwnerPackNull(t *testing.T) {
	const (
		objectAPIName = "Account"
		layoutID      = "layout-002"
		layoutName    = "Account Layout Fallback"
	)

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

	cols := []string{"Id", "ObjectApiName", "LayoutName", "Source", "IsCustomized", "Config", "OwnerPack"}
	// OwnerPack is empty string (simulating NULL via COALESCE)
	rows := sqlmock.NewRows(cols).AddRow(layoutID, objectAPIName, layoutName, "curated", 0, "{}", "")
	mock.ExpectQuery(`SELECT Id`).WillReturnRows(rows)

	// Oracle declares the component under the Source-derived pack name.
	oracle := makeOracleWithPageLayout(objectAPIName, layoutID, platformUXPackName)
	report := &l0DiagnosticReport{}
	if err := checkPageLayoutComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkPageLayoutComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps on fallback; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// --- checkLayoutAssignmentComponents ---

// TestCheckLayoutAssignmentComponents_UsesExplicitOwnerPackOverDerivation verifies that
// a LayoutAssignment row with OwnerPack='source-pack-b' is resolved as 'source-pack-b',
// not the multi-column heuristic (platformUXPackName).
func TestCheckLayoutAssignmentComponents_UsesExplicitOwnerPackOverDerivation(t *testing.T) {
	const (
		objectAPIName = "Contact"
		assignmentID  = "assign-001"
		layoutID      = "layout-003"
		explicitPack  = "source-pack-b"
	)

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

	cols := []string{"Id", "ObjectApiName", "LayoutId", "RouteSource", "OwnerPack", "LayoutSource", "LayoutCustomized"}
	rows := sqlmock.NewRows(cols).AddRow(
		assignmentID, objectAPIName, layoutID,
		"managed_seed", explicitPack,
		"curated", 0,
	)
	mock.ExpectQuery(`SELECT j\.Id`).WillReturnRows(rows)

	oracle := makeOracleWithLayoutAssignment(objectAPIName, assignmentID, explicitPack)
	report := &l0DiagnosticReport{}
	if err := checkLayoutAssignmentComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkLayoutAssignmentComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestCheckLayoutAssignmentComponents_FallsBackToSourceDerivationWhenOwnerPackNull verifies
// that a LayoutAssignment row with OwnerPack=” (NULL via COALESCE), RouteSource='managed_seed',
// and joined layout Source='curated' falls back to the heuristic → platformUXPackName.
func TestCheckLayoutAssignmentComponents_FallsBackToSourceDerivationWhenOwnerPackNull(t *testing.T) {
	const (
		objectAPIName = "Contact"
		assignmentID  = "assign-002"
		layoutID      = "layout-004"
	)

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

	cols := []string{"Id", "ObjectApiName", "LayoutId", "RouteSource", "OwnerPack", "LayoutSource", "LayoutCustomized"}
	rows := sqlmock.NewRows(cols).AddRow(
		assignmentID, objectAPIName, layoutID,
		"managed_seed", "", // OwnerPack is NULL/empty
		"curated", 0,
	)
	mock.ExpectQuery(`SELECT j\.Id`).WillReturnRows(rows)

	// Oracle declares under the heuristic-derived pack name.
	oracle := makeOracleWithLayoutAssignment(objectAPIName, assignmentID, platformUXPackName)
	report := &l0DiagnosticReport{}
	if err := checkLayoutAssignmentComponents(context.Background(), db, oracle, report); err != nil {
		t.Fatalf("checkLayoutAssignmentComponents: %v", err)
	}
	if len(report.ownershipGaps) > 0 {
		t.Fatalf("expected no ownership gaps on fallback; got: %v", report.ownershipGaps)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}
