package metadata

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/ports"
	domainSchema "github.com/nexuscrm/kernel/pkg/schema"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

func TestUpdateFieldRejectsCatalogAuthorityBeforeMutatingCachedSchema(t *testing.T) {
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:               "AuthorityValue__c",
			Label:                 "Authority Value",
			Type:                  constants.FieldTypeString,
			WriteProtectionPolicy: constants.FieldWriteProtectionAuthorityOnly,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Widget__c", "AuthorityValue__c", &models.FieldMetadata{
		WriteProtectionPolicy: constants.FieldWriteProtectionTrustedOnly,
	})
	if err == nil {
		t.Fatal("UpdateField accepted a catalog authority policy downgrade")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called for a rejected catalog authority update")
	}
	if got := schema.Fields[0].WriteProtectionPolicy; got != constants.FieldWriteProtectionAuthorityOnly {
		t.Fatalf("cached field policy mutated to %q after rejection", got)
	}
}

func TestUpdateFieldPersistsFormulaModeAndExpression(t *testing.T) {
	// Only active→active edits are allowed. Set up an active field and update it.
	oldFormula := "Amount__c * 2"
	oldMode := "active"
	oldReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &oldFormula,
			FormulaMode: &oldMode,
			ReturnType:  &oldReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	newFormula := "Amount__c * 3"
	newMode := "active"
	newReturnType := constants.FieldTypeDouble
	if err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:        constants.FieldTypeFormula,
		Formula:     &newFormula,
		FormulaMode: &newMode,
		ReturnType:  &newReturnType,
	}); err != nil {
		t.Fatalf("UpdateField returned error: %v", err)
	}

	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if schemaRepo.saved.Formula == nil || *schemaRepo.saved.Formula != newFormula {
		t.Fatalf("saved Formula = %#v, want %q", schemaRepo.saved.Formula, newFormula)
	}
	if schemaRepo.saved.FormulaMode == nil || *schemaRepo.saved.FormulaMode != newMode {
		t.Fatalf("saved FormulaMode = %#v, want %q", schemaRepo.saved.FormulaMode, newMode)
	}
	if schemaRepo.saved.ReturnType == nil || *schemaRepo.saved.ReturnType != newReturnType {
		t.Fatalf("saved ReturnType = %#v, want %q", schemaRepo.saved.ReturnType, newReturnType)
	}
}

func TestUpdateFieldRejectsFrozenFormulaExpressionEdit(t *testing.T) {
	// PATCH on a frozen formula field attempting to change the expression must
	// return a validation error — the PATCH path has no ADD/MODIFY COLUMN logic
	// for frozen-formula backing columns.
	frozenFormula := "Amount__c * 2"
	frozenMode := "frozen"
	frozenReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &frozenFormula,
			FormulaMode: &frozenMode,
			ReturnType:  &frozenReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	newFormula := "Amount__c * 99"
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:    constants.FieldTypeFormula,
		Formula: &newFormula,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error for frozen formula edit")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a frozen formula edit")
	}
}

func TestUpdateFieldRejectsActiveToFrozenModeSwitch(t *testing.T) {
	// PATCH that would flip an active formula to frozen must also be rejected.
	activeFormula := "Amount__c * 2"
	activeMode := "active"
	activeReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &activeFormula,
			FormulaMode: &activeMode,
			ReturnType:  &activeReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	newMode := "frozen"
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:        constants.FieldTypeFormula,
		FormulaMode: &newMode,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error when switching active→frozen")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for active→frozen switch")
	}
}

func TestUpdateFieldRejectsLegacyNilFormulaModeEdit(t *testing.T) {
	// A field with nil FormulaMode is legacy generated-column storage.
	// PATCH attempting to change the formula expression must return a validation
	// error — same as frozen — because this path has no ADD/MODIFY COLUMN logic.
	legacyFormula := "Amount__c * 2"
	legacyReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &legacyFormula,
			FormulaMode: nil, // legacy: no mode set
			ReturnType:  &legacyReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	newFormula := "Amount__c * 99"
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:    constants.FieldTypeFormula,
		Formula: &newFormula,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error for legacy nil-FormulaMode formula edit")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a legacy nil-FormulaMode field")
	}
}

func TestUpdateFieldRejectsLegacyBlankFormulaModeEdit(t *testing.T) {
	// A field with empty-string FormulaMode is also legacy generated-column storage.
	// PATCH attempting to change the formula expression must return a validation error.
	legacyFormula := "Amount__c * 2"
	legacyMode := ""
	legacyReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &legacyFormula,
			FormulaMode: &legacyMode, // legacy: blank mode
			ReturnType:  &legacyReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	newFormula := "Amount__c * 99"
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:    constants.FieldTypeFormula,
		Formula: &newFormula,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error for legacy blank-FormulaMode formula edit")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a legacy blank-FormulaMode field")
	}
}

func TestUpdateFieldUsesValidationServiceForFrozenFormulaEditRejection(t *testing.T) {
	frozenFormula := "Amount__c * 2"
	frozenMode := "frozen"
	frozenReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &frozenFormula,
			FormulaMode: &frozenMode,
			ReturnType:  &frozenReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	vs := NewValidationService(nil)
	ms.SetValidationService(vs)

	newFormula := "Amount__c * 99"
	expected := schema.Fields[0]
	expected.Formula = &newFormula
	wantErr := vs.ValidateFieldMetadataForUpdate(&expected, &schema.Fields[0])
	if wantErr == nil {
		t.Fatal("ValidateFieldMetadataForUpdate = nil, want shared frozen-formula rejection")
	}

	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:    constants.FieldTypeFormula,
		Formula: &newFormula,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error for frozen formula edit")
	}
	if err.Error() != wantErr.Error() {
		t.Fatalf("UpdateField error = %q, want ValidateFieldMetadataForUpdate error %q", err.Error(), wantErr.Error())
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a frozen formula edit")
	}
}

func TestUpdateFieldUsesValidationServiceForFormulaStorageModeEditRejection(t *testing.T) {
	formulaExpr := "Name"
	activeMode := "active"
	returnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "NameStored",
			Label:       "Name Stored",
			Type:        constants.FieldTypeFormula,
			Formula:     &formulaExpr,
			FormulaMode: &activeMode,
			ReturnType:  &returnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	vs := NewValidationService(nil)
	ms.SetValidationService(vs)

	writeMaterialized := "write_materialized"
	expected := schema.Fields[0]
	expected.FormulaStorageMode = &writeMaterialized
	wantErr := vs.ValidateFieldMetadataForUpdate(&expected, &schema.Fields[0])
	if wantErr == nil {
		t.Fatal("ValidateFieldMetadataForUpdate = nil, want shared formulaStorageMode rejection")
	}

	err := ms.UpdateField(context.Background(), "Widget__c", "NameStored", &models.FieldMetadata{
		Type:               constants.FieldTypeFormula,
		FormulaStorageMode: &writeMaterialized,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want validation error for formulaStorageMode edit")
	}
	if err.Error() != wantErr.Error() {
		t.Fatalf("UpdateField error = %q, want ValidateFieldMetadataForUpdate error %q", err.Error(), wantErr.Error())
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a formulaStorageMode edit")
	}
}

func TestUpdateFieldRollsBackFormulaMetadataWhenViewRebuildFails(t *testing.T) {
	oldFormula := "Amount__c * 2"
	oldMode := "active"
	oldReturnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula,
			Formula:     &oldFormula,
			FormulaMode: &oldMode,
			ReturnType:  &oldReturnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))
	rebuilder := &metadataUpdateFieldFormulaRebuilder{
		errs: []error{
			errors.New("compile updated formula"),
			nil,
		},
	}
	ms.SetFormulaViewRebuilder(rebuilder)

	newFormula := "Definitely_Not_A_Field__c * 3"
	newMode := "active"
	newReturnType := constants.FieldTypeDouble
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type:        constants.FieldTypeFormula,
		Formula:     &newFormula,
		FormulaMode: &newMode,
		ReturnType:  &newReturnType,
	})
	if err == nil {
		t.Fatal("UpdateField returned nil, want formula view rebuild error")
	}

	if len(schemaRepo.saves) != 2 {
		t.Fatalf("SaveFieldMetadataWithIDs calls = %d, want 2", len(schemaRepo.saves))
	}
	if rebuilder.calls != 2 {
		t.Fatalf("RebuildView calls = %d, want 2", rebuilder.calls)
	}
	firstSave := schemaRepo.saves[0]
	if firstSave.Formula == nil || *firstSave.Formula != newFormula {
		t.Fatalf("first saved Formula = %#v, want %q", firstSave.Formula, newFormula)
	}
	rollbackSave := schemaRepo.saves[1]
	if rollbackSave.Formula == nil || *rollbackSave.Formula != oldFormula {
		t.Fatalf("rollback saved Formula = %#v, want %q", rollbackSave.Formula, oldFormula)
	}
	if rollbackSave.ReturnType == nil || *rollbackSave.ReturnType != oldReturnType {
		t.Fatalf("rollback saved ReturnType = %#v, want %q", rollbackSave.ReturnType, oldReturnType)
	}
	if schema.Fields[0].Formula == nil || *schema.Fields[0].Formula != oldFormula {
		t.Fatalf("schema Formula after rollback = %#v, want %q", schema.Fields[0].Formula, oldFormula)
	}
}

// TestUpdateFieldAllowsMetadataOnlyEditOnLegacyPackInstalledComputedField
// covers Codex round-6 BLOCK on PR #964. The PR strengthened the metadata-API
// type-coherence guard to reject non-formula Type + non-empty Formula. That
// rejection lives in ValidateFieldMetadata, which UpdateField calls in its
// post-merge revalidation. Pack-installed fields like CampaignMember.Title
// (Type=string, FormulaMode="active", real formula) and OpportunityLineItem
// .TotalPrice (Type=currency, FormulaMode="active", real formula) already exist
// in greenfield official-pack installs — the install pipeline provisions the
// FormulaViewService substrate for them, which is why pack_field_shapes.go
// admits the shape. But the metadata API's strict reject was unconditional, so
// admins could no longer PATCH label / help text / options on those legacy
// fields without the request blowing up.
//
// The fix scopes the rejection behind an explicit migration-state contract:
// only legacy pack-owned active-scalar fields get the metadata-only
// presentation carve-out. The same shape without pack ownership must fall
// back to the shared create-strict formula/type rejection. Any mutation of
// Type/Formula/FormulaMode falls back to the create-strict rejection, so an
// UpdateField cannot launder a new dead-column shape via a legacy field.
//
// This test pins both directions on a CampaignMember.Title-shaped synthetic
// field:
//
//  1. Label-only edit → succeeds (metadata-only edit, triple unchanged).
//  2. Formula-expression edit on the same field → rejected (operator IS
//     mutating the offending dimension).
func TestUpdateFieldAllowsMetadataOnlyEditOnLegacyPackInstalledComputedField(t *testing.T) {
	// CampaignMember.Title-shaped synthetic: scalar public Type (string),
	// FormulaMode "active", real formula. Exact replica of the pack-installed
	// substrate fields named in the Codex finding.
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	helpText := "Computed display title"
	ownerPack := "salesforce-crm-standard"
	t.Run("label_only_patch_allowed", func(t *testing.T) {
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				HelpText:    &helpText,
				Type:        constants.FieldTypeString, // scalar public Type
				Formula:     &legacyFormula,            // legacy active-view-compute substrate
				FormulaMode: &legacyMode,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		// Pure metadata-only edit: change the label, leave Type/Formula
		// /FormulaMode unset on the inbound updates so the merge preserves
		// the legacy triple.
		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Label: "Display Title",
		})
		if err != nil {
			t.Fatalf("UpdateField (label-only edit on legacy pack-installed computed field) returned %v, want nil — metadata-only edits on the legacy shape must not regress (Codex round-6 BLOCK on #964)", err)
		}
		if schemaRepo.saved == nil {
			t.Fatal("SaveFieldMetadataWithIDs was not called; expected the label-only edit to persist")
		}
		if schemaRepo.saved.Label != "Display Title" {
			t.Fatalf("saved Label = %q, want %q", schemaRepo.saved.Label, "Display Title")
		}
		// The legacy triple must be preserved unchanged.
		if schemaRepo.saved.Type != constants.FieldTypeString {
			t.Errorf("saved Type = %q, want %q (must not mutate legacy shape)", schemaRepo.saved.Type, constants.FieldTypeString)
		}
		if schemaRepo.saved.Formula == nil || *schemaRepo.saved.Formula != legacyFormula {
			t.Errorf("saved Formula = %#v, want %q (must not mutate legacy shape)", schemaRepo.saved.Formula, legacyFormula)
		}
		if schemaRepo.saved.FormulaMode == nil || *schemaRepo.saved.FormulaMode != legacyMode {
			t.Errorf("saved FormulaMode = %#v, want %q (must not mutate legacy shape)", schemaRepo.saved.FormulaMode, legacyMode)
		}
	})

	t.Run("label_only_patch_rejected_when_not_pack_owned", func(t *testing.T) {
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				HelpText:    &helpText,
				Type:        constants.FieldTypeString,
				Formula:     &legacyFormula,
				FormulaMode: &legacyMode,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Label: "Display Title",
		})
		if err == nil {
			t.Fatal("UpdateField (label-only edit on non-pack-owned legacy active-scalar) returned nil, want shared formula/type rejection")
		}
		wantErr := FormulaExpressionOnNonFormulaTypeError("", "Title", constants.FieldTypeString)
		if err.Error() != wantErr.Error() {
			t.Fatalf("UpdateField error = %q, want shared helper error %q", err.Error(), wantErr.Error())
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a non-pack-owned legacy-shape PATCH")
		}
	})

	t.Run("label_only_patch_rejected_when_owner_pack_blank", func(t *testing.T) {
		blankOwnerPack := "   "
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				HelpText:    &helpText,
				Type:        constants.FieldTypeString,
				Formula:     &legacyFormula,
				FormulaMode: &legacyMode,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &blankOwnerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Label: "Display Title",
		})
		if err == nil {
			t.Fatal("UpdateField (label-only edit with blank ownerPack) returned nil, want shared formula/type rejection")
		}
		wantErr := FormulaExpressionOnNonFormulaTypeError("", "Title", constants.FieldTypeString)
		if err.Error() != wantErr.Error() {
			t.Fatalf("UpdateField error = %q, want shared helper error %q", err.Error(), wantErr.Error())
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a blank-ownerPack legacy-shape PATCH")
		}
	})

	t.Run("help_text_only_patch_allowed", func(t *testing.T) {
		// Mirror Codex's example — admin editing help text on
		// OpportunityLineItem.TotalPrice (Type=currency + active formula).
		currencyFormula := "Quantity * UnitPrice"
		schema := &models.ObjectMetadata{
			ID:      "obj_opportunitylineitem",
			APIName: "OpportunityLineItem",
			Fields: []models.FieldMetadata{{
				APIName:     "TotalPrice",
				Label:       "Total Price",
				Type:        constants.FieldTypeCurrency,
				Formula:     &currencyFormula,
				FormulaMode: &legacyMode,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		newHelp := "Computed from quantity and unit price"
		err := ms.UpdateField(context.Background(), "OpportunityLineItem", "TotalPrice", &models.FieldMetadata{
			HelpText: &newHelp,
		})
		if err != nil {
			t.Fatalf("UpdateField (help-text-only edit on OpportunityLineItem.TotalPrice-shaped legacy field) returned %v, want nil", err)
		}
		if schemaRepo.saved == nil || schemaRepo.saved.HelpText == nil || *schemaRepo.saved.HelpText != newHelp {
			t.Fatalf("saved HelpText = %#v, want %q", schemaRepo.saved.HelpText, newHelp)
		}
	})
}

// TestUpdateFieldRejectsReturnTypeChangeOnLegacyPackInstalledComputedField
// covers Codex round-7 BLOCK on PR #964. The round-6 exemption protected the
// (Type, FormulaMode, Formula) triple but excluded ReturnType from the
// equality check. FormulaViewService uses ReturnType when compiling the
// active computed view (v_<object>), so a PATCH that mutates ReturnType on a
// legacy pack-installed scalar+active computed field would slip past
// isLegacyComputedShapeUnchanged and leave the authoritative view stale.
//
// The fix extends the equality check to (Type, FormulaMode, Formula,
// ReturnType). This test pins:
//
//  1. PATCH that changes ReturnType on a legacy active-scalar → REJECTED.
//  2. PATCH that changes Formula on the same shape → REJECTED (regression
//     pin — the round-6 triple check already covered this, but the
//     companion case makes the protected surface explicit).
func TestUpdateFieldRejectsReturnTypeChangeOnLegacyPackInstalledComputedField(t *testing.T) {
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString
	ownerPack := "salesforce-crm-standard"

	t.Run("return_type_change_rejected", func(t *testing.T) {
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				Type:        constants.FieldTypeString, // scalar public Type
				Formula:     &legacyFormula,            // legacy active-view-compute substrate
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		// Operator changes ReturnType from "string" to "double" while leaving
		// Type, FormulaMode, and Formula identical. Without ReturnType in the
		// equality check, the legacy-shape exemption would fire and the view
		// would silently desync from metadata. Must fail closed.
		newReturnType := constants.FieldTypeDouble
		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			ReturnType: &newReturnType,
		})
		if err == nil {
			t.Fatal("UpdateField (ReturnType mutation on legacy pack-installed computed field) returned nil, want rejection — the v_<object> view-compile would desync from metadata (Codex round-7 BLOCK on #964)")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a ReturnType-changing PATCH on a legacy active-scalar")
		}
	})

	t.Run("formula_change_rejected", func(t *testing.T) {
		// Regression pin: the round-6 triple already protected Formula, but
		// keeping the assertion adjacent makes the full protected surface
		// explicit so a future refactor can't drop it without a failing test.
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				Type:        constants.FieldTypeString,
				Formula:     &legacyFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		newFormula := "Campaign.Name + ' :: ' + Contact.LastName"
		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Formula: &newFormula,
		})
		if err == nil {
			t.Fatal("UpdateField (Formula mutation on legacy pack-installed computed field) returned nil, want rejection — operator is mutating the protected dead-column shape")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Formula-changing PATCH on a legacy active-scalar")
		}
	})
}

func TestUpdateFieldRejectsTypeChangeOnLegacyPackInstalledComputedFieldBeforeDDL(t *testing.T) {
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString
	ownerPack := "salesforce-crm-standard"

	schema := &models.ObjectMetadata{
		ID:      "obj_campaignmember",
		APIName: "CampaignMember",
		Fields: []models.FieldMetadata{{
			APIName:     "Title",
			Label:       "Title",
			Type:        constants.FieldTypeString,
			Formula:     &legacyFormula,
			FormulaMode: &legacyMode,
			ReturnType:  &legacyReturnType,
			OwnerKind:   constants.FieldOwnerKindPack,
			OwnerPack:   &ownerPack,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
		Type: constants.FieldTypeFormula,
	})
	if err == nil {
		t.Fatal("UpdateField (Type mutation on legacy pack-installed computed field) returned nil, want rejection before DDL")
	}
	if len(schemaRepo.modified) != 0 {
		t.Fatalf("ModifyColumn calls = %d, want 0 because ValidateFieldMetadataForUpdate must fail closed before DDL", len(schemaRepo.modified))
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Type-changing PATCH on a legacy active-scalar")
	}
}

func TestUpdateFieldLegacyPackOwnedTrackHistoryPatchSemantics(t *testing.T) {
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString
	ownerPack := "salesforce-crm-standard"

	newLegacySchema := func(trackHistory bool) *models.ObjectMetadata {
		return &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:      "Title",
				Label:        "Title",
				Type:         constants.FieldTypeString,
				Formula:      &legacyFormula,
				FormulaMode:  &legacyMode,
				ReturnType:   &legacyReturnType,
				TrackHistory: trackHistory,
				OwnerKind:    constants.FieldOwnerKindPack,
				OwnerPack:    &ownerPack,
			}},
		}
	}

	t.Run("explicit_track_history_patch_is_rejected", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newLegacySchema(false)},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateFieldWithPatchPresence(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			TrackHistory: true,
		}, false, false, true, false)
		if err == nil {
			t.Fatal("UpdateFieldWithPatchPresence(trackHistory-only edit on legacy active-scalar) = nil, want presentation-only rejection")
		}
		if schemaRepo.saved != nil {
			t.Fatalf("saved TrackHistory = %#v, want no save on rejected patch", schemaRepo.saved)
		}
	})

	t.Run("omitted_track_history_does_not_clear_existing_value", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newLegacySchema(true)},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Label: "Display Title",
		})
		if err != nil {
			t.Fatalf("UpdateField(label-only edit with omitted trackHistory) = %v, want nil", err)
		}
		if schemaRepo.saved == nil || !schemaRepo.saved.TrackHistory {
			t.Fatalf("saved TrackHistory = %#v, want true (PATCH omission must preserve existing value)", schemaRepo.saved)
		}
	})
}

func TestUpdateFieldWithPatchPresenceRespectsReadProtectionPolicyPresenceBit(t *testing.T) {
	newSchema := func() *models.ObjectMetadata {
		return &models.ObjectMetadata{
			ID:      "obj_contact",
			APIName: "Contact",
			Fields: []models.FieldMetadata{{
				APIName:              "Secret__c",
				Label:                "Secret",
				Type:                 constants.FieldTypeString,
				ReadProtectionPolicy: constants.FieldReadProtectionBlocked,
			}},
		}
	}

	t.Run("explicit_clear_allowed_when_key_present", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newSchema()},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateFieldWithPatchPresence(context.Background(), "Contact", "Secret__c", &models.FieldMetadata{
			ReadProtectionPolicy: "",
		}, false, false, false, true)
		if err != nil {
			t.Fatalf("UpdateFieldWithPatchPresence(read-protection clear) = %v, want nil", err)
		}
		if schemaRepo.saved == nil || schemaRepo.saved.ReadProtectionPolicy != "" {
			t.Fatalf("saved ReadProtectionPolicy = %#v, want empty string after explicit clear", schemaRepo.saved.ReadProtectionPolicy)
		}
	})

	t.Run("omit_key_preserves_existing_value", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newSchema()},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateFieldWithPatchPresence(context.Background(), "Contact", "Secret__c", &models.FieldMetadata{
			ReadProtectionPolicy: "",
		}, false, false, false, false)
		if err != nil {
			t.Fatalf("UpdateFieldWithPatchPresence(omitted readProtectionPolicy) = %v, want nil", err)
		}
		if schemaRepo.saved == nil || schemaRepo.saved.ReadProtectionPolicy != constants.FieldReadProtectionBlocked {
			t.Fatalf("saved ReadProtectionPolicy = %#v, want %q when field was omitted", constants.FieldReadProtectionBlocked, schemaRepo.saved.ReadProtectionPolicy)
		}
	})
}

func TestUpdateFieldRejectsMutationOnNonPackOwnedLegacyActiveScalarField(t *testing.T) {
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString

	newLegacySchema := func() *models.ObjectMetadata {
		return &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				Type:        constants.FieldTypeString,
				Formula:     &legacyFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
			}},
		}
	}

	t.Run("type_change_to_formula_rejected_before_ddl", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newLegacySchema()},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Type: constants.FieldTypeFormula,
		})
		if err == nil {
			t.Fatal("UpdateField (Type mutation on non-pack-owned legacy active-scalar field) returned nil, want fail-closed rejection before DDL")
		}
		if len(schemaRepo.modified) != 0 {
			t.Fatalf("ModifyColumn calls = %d, want 0 for non-pack-owned legacy active-scalar type mutation", len(schemaRepo.modified))
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Type-changing PATCH on a non-pack-owned legacy active-scalar")
		}
	})

	t.Run("formula_clear_rejected", func(t *testing.T) {
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: newLegacySchema()},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		clearedFormula := ""
		err := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Formula: &clearedFormula,
		})
		if err == nil {
			t.Fatal("UpdateField (Formula clear on non-pack-owned legacy active-scalar field) returned nil, want fail-closed rejection")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Formula-clearing PATCH on a non-pack-owned legacy active-scalar")
		}
	})
}

// TestUpdateFieldStillRejectsFreshDeadColumnShape ensures the legacy-shape
// exemption added for Codex round-6 BLOCK on #964 does NOT widen the gate.
// If the existing field is genuine formula-typed and the PATCH attempts to
// demote it to scalar while leaving the formula expression intact, the create-
// strict rejection still fires — because (Type, FormulaMode, Formula) is NOT
// byte-identical to existing (Type changed from formula → string).
//
// In practice the type-change pre-DDL provisional check at field_update.go:137
// catches this first, but pinning it here documents the contract.
func TestUpdateFieldStillRejectsFreshDeadColumnShape(t *testing.T) {
	// Existing field is genuinely formula-typed — the offending shape is NOT
	// pre-existing. UpdateField that demotes to string while preserving the
	// formula expression must be REJECTED.
	formula := "Amount__c * 2"
	mode := "active"
	returnType := constants.FieldTypeString
	schema := &models.ObjectMetadata{
		ID:      "obj_widget",
		APIName: "Widget__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Score__c",
			Label:       "Score",
			Type:        constants.FieldTypeFormula, // pre-existing legitimate shape
			Formula:     &formula,
			FormulaMode: &mode,
			ReturnType:  &returnType,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	// Operator demotes Type formula → string while leaving Formula intact —
	// classic "introduce a dead column via PATCH" shape. Must fail closed.
	err := ms.UpdateField(context.Background(), "Widget__c", "Score__c", &models.FieldMetadata{
		Type: constants.FieldTypeString,
	})
	if err == nil {
		t.Fatal("UpdateField (formula→string demotion with Formula intact) returned nil, want rejection — legacy-shape exemption must not widen the metadata-API gate")
	}
}

func TestUpdateFieldRejectsReferenceToRetargetOnCascadeForeignKey(t *testing.T) {
	cascade := constants.DeleteRuleCascade
	schema := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{{
			APIName:     "Parent__c",
			Label:       "Parent",
			Type:        constants.FieldTypeReference,
			ReferenceTo: []string{"Parent__c"},
			DeleteRule:  &cascade,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	err := ms.UpdateField(context.Background(), "Child__c", "Parent__c", &models.FieldMetadata{
		ReferenceTo: []string{"OtherParent__c"},
	})
	if err == nil {
		t.Fatal("UpdateField allowed ReferenceTo retarget on a cascading foreign key")
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called for rejected cascade FK ReferenceTo retarget")
	}
}

func TestUpdateFieldDoesNotMutateCascadeForeignKeyClassificationFields(t *testing.T) {
	setNull := constants.DeleteRuleSetNull
	cascade := constants.DeleteRuleCascade
	schema := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{{
			APIName:        "Parent__c",
			Label:          "Parent",
			Type:           constants.FieldTypeReference,
			ReferenceTo:    []string{"Parent__c"},
			DeleteRule:     &setNull,
			IsMasterDetail: true,
			IsPolymorphic:  true,
		}},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepo{}
	ms := NewMetadataService(
		&metadataUpdateFieldRepo{schema: schema},
		NewSchemaManager(schemaRepo),
		nil,
	)
	ms.SetValidationService(NewValidationService(nil))

	if err := ms.UpdateField(context.Background(), "Child__c", "Parent__c", &models.FieldMetadata{
		Label:          "Parent Updated",
		ReferenceTo:    []string{"parent__c"},
		DeleteRule:     &cascade,
		IsMasterDetail: false,
		IsPolymorphic:  false,
	}); err != nil {
		t.Fatalf("UpdateField: %v", err)
	}
	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if schemaRepo.saved.DeleteRule == nil || *schemaRepo.saved.DeleteRule != setNull {
		t.Fatalf("saved DeleteRule = %#v, want %q", schemaRepo.saved.DeleteRule, setNull)
	}
	if !schemaRepo.saved.IsMasterDetail {
		t.Fatal("saved IsMasterDetail = false, want true")
	}
	if !schemaRepo.saved.IsPolymorphic {
		t.Fatal("saved IsPolymorphic = false, want true")
	}
}

// TestUpdateFieldRejectsStructuralMutationOnLegacyPackInstalledComputedField
// covers Codex round-8 BLOCK on PR #964. R6 introduced a legacy-shape
// exemption keyed on a (Type, FormulaMode, Formula) triple — R7 extended it
// to a quadruple by adding ReturnType — but the carve-out was still keyed on
// "the protected tuple is unchanged" rather than "ONLY presentation-only
// facets differ". UpdateField applies a long list of inbound mutations
// (ReferenceTo, WritableReferenceTo, Options, Required, IsUnique, Validator*,
// ChildRelationshipName, MasterDetail*, DefaultValue*, MinValue, MaxValue,
// MaxLength, Precision, Scale, OwnerPack, WriteProtectionPolicy, …) BEFORE
// the post-merge revalidation runs. With the quadruple-only model, all of
// those facets could mutate on a pack-installed active-scalar computed field
// and the carve-out would still return success — the metadata API has no
// DDL / rebuild / rollback for any of them, so e.g. a PATCH that changes
// ReferenceTo on a legacy active computed reference would immediately
// reinterpret computed IDs against a different target object.
//
// The R8 fix flips the model: the carve-out now walks a presentation-only
// WHITELIST (Label / HelpText). Any non-whitelisted field
// differing between updated and existing drops the exemption, so the
// create-strict rejection fires. These cases pin each structural rejection
// class Codex enumerated.
func TestUpdateFieldRejectsStructuralMutationOnLegacyPackInstalledComputedField(t *testing.T) {
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString
	helpText := "computed display title"
	ownerPack := "salesforce-crm-standard"

	// newLegacyComputedReferenceSchema builds a pack-installed-style active
	// computed reference field — the exact shape Codex R8 named as the
	// "reinterpret computed IDs against a different target object" leak.
	newLegacyComputedReferenceSchema := func() *models.ObjectMetadata {
		referenceFormula := "Account.Owner.Id"
		return &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "ComputedOwnerLookup",
				Label:       "Computed Owner Lookup",
				HelpText:    &helpText,
				Type:        constants.FieldTypeReference, // scalar public Type
				ReferenceTo: []string{"User"},             // pack-installed target
				Formula:     &referenceFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
	}

	t.Run("reference_to_change_rejected", func(t *testing.T) {
		schema := newLegacyComputedReferenceSchema()
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		// Operator swaps ReferenceTo from User → Contact while leaving the
		// protected (Type, FormulaMode, Formula, ReturnType) quadruple
		// identical. R6/R7 model would have returned success. R8 must
		// reject: any non-whitelisted field differing drops the exemption.
		err := ms.UpdateField(context.Background(), "CampaignMember", "ComputedOwnerLookup", &models.FieldMetadata{
			ReferenceTo: []string{"Contact"},
		})
		if err == nil {
			t.Fatal("UpdateField (ReferenceTo mutation on legacy active computed reference) returned nil, want rejection — the computed IDs would silently reinterpret against the new target object with no DDL/rebuild/rollback (Codex round-8 BLOCK on #964)")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a ReferenceTo-changing PATCH on a legacy active computed reference")
		}
	})

	t.Run("options_change_rejected", func(t *testing.T) {
		// Legacy active-picklist computed shape: Type=picklist + real formula.
		picklistFormula := "IF(Stage == 'Won', 'A', 'B')"
		schema := &models.ObjectMetadata{
			ID:      "obj_opportunity",
			APIName: "Opportunity",
			Fields: []models.FieldMetadata{{
				APIName:     "ComputedBucket",
				Label:       "Computed Bucket",
				Type:        constants.FieldTypePicklist,
				Options:     models.PicklistOptions{{Value: "A", Label: "Alpha"}, {Value: "B", Label: "Beta"}},
				Formula:     &picklistFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "Opportunity", "ComputedBucket", &models.FieldMetadata{
			Options: models.PicklistOptions{{Value: "A", Label: "Alpha"}, {Value: "C", Label: "Charlie"}},
		})
		if err == nil {
			t.Fatal("UpdateField (Options mutation on legacy active-picklist computed field) returned nil, want rejection — admitted picklist values would diverge from what the formula computes")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for an Options-changing PATCH on a legacy active computed picklist")
		}
	})

	t.Run("required_change_rejected", func(t *testing.T) {
		// Legacy active-scalar computed shape with Required=false. PATCH
		// flips Required → true without touching the protected quadruple.
		stringFormula := "Account.Name + ' - ' + Contact.LastName"
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "ComputedTitle",
				Label:       "Computed Title",
				Type:        constants.FieldTypeString,
				Required:    false,
				Formula:     &stringFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		err := ms.UpdateField(context.Background(), "CampaignMember", "ComputedTitle", &models.FieldMetadata{
			Required: true,
		})
		if err == nil {
			t.Fatal("UpdateField (Required mutation on legacy active-scalar computed field) returned nil, want rejection — Required is a behavioral constraint with no DDL/rebuild path on this PATCH route")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Required-changing PATCH on a legacy active-scalar")
		}
	})

	t.Run("validator_change_rejected", func(t *testing.T) {
		// Legacy active-scalar computed shape. PATCH attempts to set a
		// Validator (e.g. attach a validation rule) without touching the
		// protected quadruple.
		stringFormula := "Account.Name"
		schema := &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "ComputedName",
				Label:       "Computed Name",
				Type:        constants.FieldTypeString,
				Formula:     &stringFormula,
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		newValidator := "state_transition"
		newConfig := `{"transitions":[]}`
		err := ms.UpdateField(context.Background(), "CampaignMember", "ComputedName", &models.FieldMetadata{
			Validator:       &newValidator,
			ValidatorConfig: &newConfig,
		})
		if err == nil {
			t.Fatal("UpdateField (Validator/ValidatorConfig mutation on legacy active-scalar computed field) returned nil, want rejection — admitting a validator on a pack-owned computed field side-steps the create-strict guard")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Validator-changing PATCH on a legacy active-scalar")
		}
	})
}

// TestUpdateFieldRejectsFormulaClearOnLegacyPackInstalledComputedField covers
// Codex round-9 BLOCK on PR #964. R8's reflection-based whitelist closes the
// gate when the post-merge state STILL carries the non-formula-Type +
// non-empty-Formula shape (the precondition for the
// expression_on_non_formula_type guard to fire). The remaining hole: a PATCH
// that CLEARS Formula on a legacy active-scalar leaves post-merge Formula
// nil/empty, the structural guard short-circuits before the whitelist runs,
// and the update silently persists metadata that no longer qualifies as
// active-computed — no DDL, no FormulaViewService substrate teardown, no
// rollback hook.
//
// The fix adds a pre-check in validateFieldMetadata that fires whenever
// `existing` carries the legacy active-scalar shape AND `updated`'s formula
// expression differs from existing's (including a clear). The check runs
// before the non-formula-Type guard so the post-merge Formula state cannot
// short-circuit it.
//
// Cases:
//  1. PATCH that clears Formula on a legacy active-scalar → REJECTED.
//  2. PATCH that changes Formula on a legacy active-scalar → REJECTED
//     (regression pin — R7 quadruple already covered this, kept adjacent for
//     full-surface visibility).
//  3. PATCH that only changes Label on a legacy active-scalar → ALLOWED
//     (R6 happy-path stays).
func TestUpdateFieldRejectsFormulaClearOnLegacyPackInstalledComputedField(t *testing.T) {
	legacyFormula := "Campaign.Name + ' / ' + Contact.LastName"
	legacyMode := "active"
	legacyReturnType := constants.FieldTypeString
	ownerPack := "salesforce-crm-standard"

	newLegacyActiveScalarSchema := func() *models.ObjectMetadata {
		return &models.ObjectMetadata{
			ID:      "obj_campaignmember",
			APIName: "CampaignMember",
			Fields: []models.FieldMetadata{{
				APIName:     "Title",
				Label:       "Title",
				Type:        constants.FieldTypeString, // scalar public Type
				Formula:     &legacyFormula,            // legacy active-view-compute substrate
				FormulaMode: &legacyMode,
				ReturnType:  &legacyReturnType,
				OwnerKind:   constants.FieldOwnerKindPack,
				OwnerPack:   &ownerPack,
			}},
		}
	}

	t.Run("formula_clear_rejected", func(t *testing.T) {
		// Operator clears Formula via a pointer to empty string. The
		// field_update.go local guard at lines 95-104 passes because both
		// existing mode and incoming (nil-defaulted) mode read as "active",
		// the merge writes the empty Formula, and the post-merge
		// revalidation hits the round-9 pre-check before the structural
		// non-formula-Type guard can short-circuit on the empty Formula.
		clearedFormula := ""
		schema := newLegacyActiveScalarSchema()
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		updateErr := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Formula: &clearedFormula,
		})
		if updateErr == nil {
			t.Fatal("UpdateField (Formula clear on legacy active-scalar) returned nil, want rejection — the field would silently demote to a plain physical column with no FormulaViewService teardown or rollback (Codex round-9 BLOCK on #964)")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Formula-clearing PATCH on a legacy active-scalar")
		}
	})

	t.Run("formula_change_rejected_regression_pin", func(t *testing.T) {
		// Regression pin: a non-empty → different-non-empty Formula change
		// was already rejected by the R7 quadruple check. Keep the assertion
		// adjacent so a future refactor that drops one path cannot quietly
		// leave the other open.
		schema := newLegacyActiveScalarSchema()
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		newFormula := "Campaign.Name + ' :: ' + Contact.LastName"
		updateErr := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Formula: &newFormula,
		})
		if updateErr == nil {
			t.Fatal("UpdateField (Formula change on legacy active-scalar) returned nil, want rejection")
		}
		if schemaRepo.saved != nil {
			t.Fatal("SaveFieldMetadataWithIDs was called but should not have been for a Formula-changing PATCH on a legacy active-scalar")
		}
	})

	t.Run("label_only_patch_still_allowed", func(t *testing.T) {
		// R6 happy-path regression pin: a presentation-only edit on a
		// legacy active-scalar must continue to succeed.
		schema := newLegacyActiveScalarSchema()
		schemaRepo := &metadataUpdateFieldSchemaRepo{}
		ms := NewMetadataService(
			&metadataUpdateFieldRepo{schema: schema},
			NewSchemaManager(schemaRepo),
			nil,
		)
		ms.SetValidationService(NewValidationService(nil))

		updateErr := ms.UpdateField(context.Background(), "CampaignMember", "Title", &models.FieldMetadata{
			Label: "Display Title",
		})
		if updateErr != nil {
			t.Fatalf("UpdateField (Label-only edit on legacy active-scalar) returned %v, want nil — R6 presentation-only carve-out must not regress", updateErr)
		}
		if schemaRepo.saved == nil || schemaRepo.saved.Label != "Display Title" {
			t.Fatalf("saved Label = %#v, want \"Display Title\"", schemaRepo.saved)
		}
		// The legacy shape must be preserved.
		if schemaRepo.saved.Formula == nil || *schemaRepo.saved.Formula != legacyFormula {
			t.Errorf("saved Formula = %#v, want %q (label-only edit must not mutate legacy shape)", schemaRepo.saved.Formula, legacyFormula)
		}
	})
}

type metadataUpdateFieldRepo struct {
	ports.MetadataRepository
	schema *models.ObjectMetadata
}

func (r *metadataUpdateFieldRepo) GetSchemaByAPIName(ctx context.Context, apiName string) (*models.ObjectMetadata, error) {
	return r.schema, nil
}

type metadataUpdateFieldSchemaRepo struct {
	ports.SchemaRepository
	saved    *models.FieldMetadata
	saves    []models.FieldMetadata
	modified []domainSchema.ColumnDefinition
}

func (r *metadataUpdateFieldSchemaRepo) SaveFieldMetadataWithIDs(ctx context.Context, field *models.FieldMetadata, objectID string, objectAPIName string, fieldID string, tx interface{}) error {
	cloned := *field
	r.saved = &cloned
	r.saves = append(r.saves, cloned)
	return nil
}

func (r *metadataUpdateFieldSchemaRepo) ModifyColumn(tableName, colName string, col domainSchema.ColumnDefinition) error {
	r.modified = append(r.modified, col)
	return nil
}

func (r *metadataUpdateFieldSchemaRepo) MapFieldTypeToSQL(fieldType string) string {
	return fieldType
}

type metadataUpdateFieldFormulaRebuilder struct {
	errs  []error
	calls int
}

func (r *metadataUpdateFieldFormulaRebuilder) RebuildView(ctx context.Context, objectAPIName string) error {
	r.calls++
	if len(r.errs) == 0 {
		return nil
	}
	err := r.errs[0]
	r.errs = r.errs[1:]
	return err
}

// ---------------------------------------------------------------------------
// Rollupsummary promotion / demotion / config-only edit tests
// ---------------------------------------------------------------------------

// metadataUpdateFieldRepoWithAllSchemas extends the base repo stub with
// GetAllSchemas support for rollup graph validation.
type metadataUpdateFieldRepoWithAllSchemas struct {
	metadataUpdateFieldRepo
	allSchemas []*models.ObjectMetadata
}

func (r *metadataUpdateFieldRepoWithAllSchemas) GetAllSchemas(ctx context.Context) ([]*models.ObjectMetadata, error) {
	return r.allSchemas, nil
}

// metadataUpdateFieldSchemaRepoWithModify tracks ModifyColumn calls.
type metadataUpdateFieldSchemaRepoWithModify struct {
	metadataUpdateFieldSchemaRepo
	modifiedCols []domainSchema.ColumnDefinition
}

func (r *metadataUpdateFieldSchemaRepoWithModify) ModifyColumn(tableName, colName string, col domainSchema.ColumnDefinition) error {
	r.modifiedCols = append(r.modifiedCols, col)
	return nil
}

func (r *metadataUpdateFieldSchemaRepoWithModify) MapFieldTypeToSQL(fieldType string) string {
	// Provide a minimal mapping for test types.
	switch strings.ToUpper(fieldType) {
	case "CURRENCY":
		return "DECIMAL(18,2)"
	case "STRING", "TEXT":
		return "TEXT"
	case "DOUBLE":
		return "DOUBLE"
	case "INT", "INTEGER":
		return "BIGINT"
	default:
		return "INVALID_TYPE_ERROR"
	}
}

func TestUpdateFieldTypeChangeKeepsRequiredBusinessColumnNullable(t *testing.T) {
	schema := &models.ObjectMetadata{
		ID:      "obj_case",
		APIName: "Case",
		Fields: []models.FieldMetadata{{
			APIName:  "Resolution__c",
			Label:    "Resolution",
			Type:     constants.FieldTypeString,
			Required: true,
		}},
	}
	repo := &metadataUpdateFieldRepoWithAllSchemas{
		metadataUpdateFieldRepo: metadataUpdateFieldRepo{schema: schema},
		allSchemas:              []*models.ObjectMetadata{schema},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(repo, NewSchemaManager(schemaRepo), nil)

	if err := ms.UpdateFieldWithPatchPresence(context.Background(), "Case", "Resolution__c", &models.FieldMetadata{
		Type: constants.FieldTypeCurrency,
	}, false, false, false, false); err != nil {
		t.Fatalf("UpdateField returned error: %v", err)
	}
	if len(schemaRepo.modifiedCols) != 1 {
		t.Fatalf("ModifyColumn calls = %d, want 1", len(schemaRepo.modifiedCols))
	}
	if !schemaRepo.modifiedCols[0].Nullable {
		t.Fatalf("ModifyColumn Nullable = false, want true; required remains save-time validation")
	}
	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if !schemaRepo.saved.Required {
		t.Fatalf("saved Required = false, want true")
	}
}

// TestUpdateFieldPromotesToRollupSummary verifies that PATCH promotion from a
// non-rollupsummary type to rollupsummary:
//   - bypasses MapFieldTypeToSQL (which would return INVALID_TYPE_ERROR)
//   - uses the resolver's ResolveRollupSQLType for the DDL column type
//   - persists a canonicalized RollupConfig
func TestUpdateFieldPromotesToRollupSummary(t *testing.T) {
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{
			{APIName: "Total__c", Label: "Total", Type: constants.FieldTypeCurrency},
		},
	}

	repo := &metadataUpdateFieldRepoWithAllSchemas{
		metadataUpdateFieldRepo: metadataUpdateFieldRepo{schema: parentObj},
		allSchemas:              []*models.ObjectMetadata{parentObj, childObj},
	}
	// Override GetSchemaByAPIName to return the right object for lookups.
	repo.schema = parentObj
	// We need a custom repo that returns childObj for child lookups.
	dualRepo := &dualLookupRepo{parent: parentObj, child: childObj, all: []*models.ObjectMetadata{parentObj, childObj}}

	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		Type: constants.FieldTypeRollupSummary,
		RollupConfig: &models.RollupConfig{
			CalcType:          "  sum  ", // lowercase + whitespace → canonicalized to SUM
			SummaryObject:     "  child__c  ",
			SummaryField:      "  amount__c  ",
			RelationshipField: "  parentid  ",
		},
	})
	if err != nil {
		t.Fatalf("UpdateField (promotion to rollupsummary) returned error: %v", err)
	}

	// Verify the field was promoted.
	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if schemaRepo.saved.Type != constants.FieldTypeRollupSummary {
		t.Errorf("saved Type = %q, want rollupsummary", schemaRepo.saved.Type)
	}

	// Verify RollupConfig was canonicalized.
	cfg := schemaRepo.saved.RollupConfig
	if cfg == nil {
		t.Fatal("saved RollupConfig is nil")
	}
	if cfg.CalcType != "SUM" {
		t.Errorf("CalcType = %q, want SUM (canonicalized)", cfg.CalcType)
	}
	if cfg.SummaryObject != "Child__c" {
		t.Errorf("SummaryObject = %q, want Child__c (canonicalized from schema lookup)", cfg.SummaryObject)
	}
	if cfg.SummaryField != "Amount__c" {
		t.Errorf("SummaryField = %q, want Amount__c (canonicalized from schema lookup)", cfg.SummaryField)
	}
	if cfg.RelationshipField != "ParentId" {
		t.Errorf("RelationshipField = %q, want ParentId (canonicalized from schema lookup)", cfg.RelationshipField)
	}

	// Verify the DDL used the resolver's SQL type, not MapFieldTypeToSQL.
	if len(schemaRepo.modifiedCols) != 1 {
		t.Fatalf("ModifyColumn calls = %d, want 1", len(schemaRepo.modifiedCols))
	}
	if schemaRepo.modifiedCols[0].Type != "DECIMAL(65,30)" {
		t.Errorf("ModifyColumn SQL type = %q, want DECIMAL(65,30) (resolver output for SUM)", schemaRepo.modifiedCols[0].Type)
	}
}

// TestUpdateFieldDemotesFromRollupSummary verifies that PATCH demotion from
// rollupsummary to another type:
//   - uses MapFieldTypeToSQL for the new type's DDL
//   - clears the stale RollupConfig
func TestUpdateFieldDemotesFromRollupSummary(t *testing.T) {
	returnType := constants.FieldTypeCurrency
	precision := 18
	scale := 2
	rollupCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Amount__c",
		RelationshipField: "ParentId",
	}
	schema := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &rollupCfg,
			ReturnType:   &returnType,
			Precision:    &precision,
			Scale:        &scale,
		}},
	}

	repo := &metadataUpdateFieldRepoWithAllSchemas{
		metadataUpdateFieldRepo: metadataUpdateFieldRepo{schema: schema},
		allSchemas:              []*models.ObjectMetadata{schema},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		repo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		Type: constants.FieldTypeCurrency,
	})
	if err != nil {
		t.Fatalf("UpdateField (demotion from rollupsummary) returned error: %v", err)
	}

	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if schemaRepo.saved.Type != constants.FieldTypeCurrency {
		t.Errorf("saved Type = %q, want currency", schemaRepo.saved.Type)
	}
	// RollupConfig must be cleared on demotion.
	if schemaRepo.saved.RollupConfig != nil {
		t.Errorf("saved RollupConfig = %+v, want nil (cleared on demotion)", schemaRepo.saved.RollupConfig)
	}
	if schemaRepo.saved.ReturnType != nil || schemaRepo.saved.Precision != nil || schemaRepo.saved.Scale != nil {
		t.Errorf("saved rollup output facets = %+v, want cleared on demotion", schemaRepo.saved)
	}
	// DDL must use MapFieldTypeToSQL for the new type.
	if len(schemaRepo.modifiedCols) != 1 {
		t.Fatalf("ModifyColumn calls = %d, want 1", len(schemaRepo.modifiedCols))
	}
	if schemaRepo.modifiedCols[0].Type != "DECIMAL(18,2)" {
		t.Errorf("ModifyColumn SQL type = %q, want DECIMAL(18,2) (MapFieldTypeToSQL for currency)", schemaRepo.modifiedCols[0].Type)
	}
}

// TestUpdateFieldRollupConfigEditCanonicalizes verifies that a config-only
// PATCH on an existing rollupsummary field canonicalizes the new config.
func TestUpdateFieldRollupConfigEditCanonicalizes(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Amount__c",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "NewField__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{parent: parentObj, child: childObj, all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	// PATCH that changes SummaryField with whitespace/lowercase.
	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "  child__c  ",
			SummaryField:      "  newfield__c  ",
			RelationshipField: "ParentId",
		},
	})
	if err != nil {
		t.Fatalf("UpdateField (config-only rollup edit) returned error: %v", err)
	}

	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	cfg := schemaRepo.saved.RollupConfig
	if cfg == nil {
		t.Fatal("saved RollupConfig is nil")
	}
	if cfg.SummaryField != "NewField__c" {
		t.Errorf("SummaryField = %q, want NewField__c (canonicalized)", cfg.SummaryField)
	}
	if cfg.SummaryObject != "Child__c" {
		t.Errorf("SummaryObject = %q, want Child__c (canonicalized)", cfg.SummaryObject)
	}
}

// TestUpdateFieldRollupConfigEditNoSpuriousAlter verifies that a config-only
// edit that does NOT change the resolved SQL type does not trigger a DDL ALTER.
func TestUpdateFieldRollupConfigEditNoSpuriousAlter(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Amount__c",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
			{APIName: "NewParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{parent: parentObj, child: childObj, all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	// PATCH that only changes RelationshipField — resolved SQL type stays
	// DECIMAL(65,30) for SUM, so no ALTER should be issued.
	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "Child__c",
			SummaryField:      "Amount__c",
			RelationshipField: "NewParentId",
		},
	})
	if err != nil {
		t.Fatalf("UpdateField (config-only, no SQL type change) returned error: %v", err)
	}

	// No ModifyColumn should have been called.
	if len(schemaRepo.modifiedCols) != 0 {
		t.Errorf("ModifyColumn calls = %d, want 0 (no spurious ALTER)", len(schemaRepo.modifiedCols))
	}
}

// TestUpdateFieldRollupConfigEditTypeChangeTriggersAlter verifies that a
// config-only edit that DOES change the resolved SQL type triggers a DDL ALTER.
func TestUpdateFieldRollupConfigEditTypeChangeTriggersAlter(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "COUNT",
		SummaryObject:     "Child__c",
		SummaryField:      "*",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{parent: parentObj, child: childObj, all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	// Change CalcType from COUNT → MIN. COUNT resolves to DECIMAL(65,30),
	// MIN resolves to the source field's type (DECIMAL(18,2) for currency).
	// This must trigger an ALTER.
	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "MIN",
			SummaryObject:     "Child__c",
			SummaryField:      "Amount__c",
			RelationshipField: "ParentId",
		},
	})
	if err != nil {
		t.Fatalf("UpdateField (config-only, SQL type change) returned error: %v", err)
	}

	if len(schemaRepo.modifiedCols) != 1 {
		t.Fatalf("ModifyColumn calls = %d, want 1 (ALTER required for SQL type change)", len(schemaRepo.modifiedCols))
	}
	if schemaRepo.modifiedCols[0].Type != "DECIMAL(18,2)" {
		t.Errorf("ModifyColumn SQL type = %q, want DECIMAL(18,2) (MIN of currency)", schemaRepo.modifiedCols[0].Type)
	}
}

// TestUpdateFieldPromotesToRollupSummary_CycleRejected verifies that metadata
// CRUD rejects a promotion that would create a rollup cycle.
func TestUpdateFieldPromotesToRollupSummary_CycleRejected(t *testing.T) {
	// ObjectA already has a rollup that summarizes ObjectB.
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "ObjectB",
		SummaryField:      "Amount",
		RelationshipField: "ObjectAId",
	}
	objA := &models.ObjectMetadata{
		ID:      "obj_a",
		APIName: "ObjectA",
		Fields: []models.FieldMetadata{
			{APIName: "Total", Type: constants.FieldTypeRollupSummary, RollupConfig: &existingCfg},
			{APIName: "ObjectBId", Type: constants.FieldTypeReference, ReferenceTo: []string{"ObjectB"}},
		},
	}
	objB := &models.ObjectMetadata{
		ID:      "obj_b",
		APIName: "ObjectB",
		Fields: []models.FieldMetadata{
			{APIName: "Amount", Type: constants.FieldTypeCurrency},
			{APIName: "ObjectAId", Type: constants.FieldTypeReference, ReferenceTo: []string{"ObjectA"}},
			// Now try to add a rollup on ObjectB that summarizes ObjectA → cycle.
			{APIName: "BackRef", Label: "Back", Type: constants.FieldTypeCurrency},
		},
	}

	dualRepo := &dualLookupRepo{parent: objB, child: objA, all: []*models.ObjectMetadata{objA, objB}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "ObjectB", "BackRef", &models.FieldMetadata{
		Type: constants.FieldTypeRollupSummary,
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "ObjectA",
			SummaryField:      "Total",
			RelationshipField: "ObjectBId",
		},
	})
	if err == nil {
		t.Fatal("UpdateField (promotion creating cycle) returned nil, want error")
	}
	if !strings.Contains(strings.ToLower(err.Error()), "cycle") {
		t.Errorf("error must mention 'cycle', got: %v", err)
	}
}

func TestUpdateFieldRollupConfigEditMissingSummaryFieldRejected(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Amount__c",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "Child__c",
			SummaryField:      "Missing__c",
			RelationshipField: "ParentId",
		},
	})
	if err == nil {
		t.Fatal("UpdateField config-only missing summary field returned nil, want rejection")
	}
	if !strings.Contains(err.Error(), "source field SQL type") && !strings.Contains(err.Error(), "summaryField") {
		t.Fatalf("error = %v, want summary-field resolution diagnostic", err)
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called for invalid config-only rollup edit")
	}
}

func TestUpdateFieldRejectsRollupConfigOnNonRollupField(t *testing.T) {
	schema := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName: "Amount__c",
			Label:   "Amount",
			Type:    constants.FieldTypeCurrency,
		}},
	}

	repo := &metadataUpdateFieldRepoWithAllSchemas{
		metadataUpdateFieldRepo: metadataUpdateFieldRepo{schema: schema},
		allSchemas:              []*models.ObjectMetadata{schema},
	}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		repo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Amount__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "Child__c",
			SummaryField:      "Amount__c",
			RelationshipField: "ParentId",
		},
	})
	if err == nil {
		t.Fatal("UpdateField accepted rollupConfig on non-rollup field, want rejection")
	}
	if !strings.Contains(err.Error(), "rollupsummary") {
		t.Fatalf("error = %v, want rollupsummary diagnostic", err)
	}
	if schemaRepo.saved != nil {
		t.Fatal("SaveFieldMetadataWithIDs was called for non-rollup rollupConfig PATCH")
	}
	if len(schemaRepo.modifiedCols) != 0 {
		t.Fatalf("ModifyColumn calls = %d, want 0", len(schemaRepo.modifiedCols))
	}
}

func TestUpdateFieldRollupLabelOnlyEditSkipsDriftedRollupConfigRevalidation(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Missing__c",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Old Label",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		Label: "New Label",
	})
	if err != nil {
		t.Fatalf("UpdateField (label-only on drifted rollup) returned error: %v", err)
	}
	if schemaRepo.saved == nil {
		t.Fatal("SaveFieldMetadataWithIDs was not called")
	}
	if schemaRepo.saved.Label != "New Label" {
		t.Fatalf("saved Label = %q, want New Label", schemaRepo.saved.Label)
	}
	if len(schemaRepo.modifiedCols) != 0 {
		t.Fatalf("ModifyColumn calls = %d, want 0 for label-only edit", len(schemaRepo.modifiedCols))
	}
}

func TestUpdateFieldRollupConfigRepairAllowsInvalidPreviousConfig(t *testing.T) {
	existingCfg := models.RollupConfig{
		CalcType:          "SUM",
		SummaryObject:     "Child__c",
		SummaryField:      "Missing__c",
		RelationshipField: "ParentId",
	}
	childObj := &models.ObjectMetadata{
		ID:      "obj_child",
		APIName: "Child__c",
		Fields: []models.FieldMetadata{
			{APIName: "Amount__c", Type: constants.FieldTypeCurrency},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Parent__c"}},
		},
	}
	parentObj := &models.ObjectMetadata{
		ID:      "obj_parent",
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{{
			APIName:      "Total__c",
			Label:        "Total",
			Type:         constants.FieldTypeRollupSummary,
			RollupConfig: &existingCfg,
		}},
	}

	dualRepo := &dualLookupRepo{all: []*models.ObjectMetadata{parentObj, childObj}}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	err := ms.UpdateField(context.Background(), "Parent__c", "Total__c", &models.FieldMetadata{
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "Child__c",
			SummaryField:      "Amount__c",
			RelationshipField: "ParentId",
		},
	})
	if err != nil {
		t.Fatalf("UpdateField (repair drifted rollup config) returned error: %v", err)
	}
	if schemaRepo.saved == nil || schemaRepo.saved.RollupConfig == nil {
		t.Fatal("SaveFieldMetadataWithIDs did not persist repaired rollup config")
	}
	if schemaRepo.saved.RollupConfig.SummaryField != "Amount__c" {
		t.Fatalf("saved SummaryField = %q, want Amount__c", schemaRepo.saved.RollupConfig.SummaryField)
	}
	if len(schemaRepo.modifiedCols) != 1 {
		t.Fatalf("ModifyColumn calls = %d, want 1 for best-effort repair ALTER", len(schemaRepo.modifiedCols))
	}
	if schemaRepo.modifiedCols[0].Type != "DECIMAL(65,30)" {
		t.Fatalf("ModifyColumn SQL type = %q, want DECIMAL(65,30)", schemaRepo.modifiedCols[0].Type)
	}
}

// TestUpdateFieldPromotesToRollupSummary_DepthRejected verifies that metadata
// CRUD rejects a promotion that would exceed the maximum rollup chain depth.
// This ensures metadata CRUD and pack/apply enforce the same depth cap.
func TestUpdateFieldPromotesToRollupSummary_DepthRejected(t *testing.T) {
	// Build a linear chain: Depth0→Depth1→...→Depth7 (8 objects, 7 existing edges).
	// Each Depth_i has a rollup field summarizing Depth_{i+1}.
	// MaxRollupDepth = 8, so depth 7 is at cap and passes.
	// We then try to promote a field on Depth7 to rollupsummary summarizing Depth8,
	// which would push depth to 8 (exceeds MaxRollupDepth).
	schemas := make([]*models.ObjectMetadata, 9)
	for i := 0; i < 9; i++ {
		name := fmt.Sprintf("Depth%d", i)
		obj := &models.ObjectMetadata{
			ID:      fmt.Sprintf("obj_depth%d", i),
			APIName: name,
		}
		if i > 0 {
			obj.Fields = append(obj.Fields, models.FieldMetadata{
				APIName:     fmt.Sprintf("Depth%dId", i-1),
				Type:        constants.FieldTypeReference,
				ReferenceTo: []string{fmt.Sprintf("Depth%d", i-1)},
			})
		}
		if i < 7 {
			childName := fmt.Sprintf("Depth%d", i+1)
			obj.Fields = append(obj.Fields, models.FieldMetadata{
				APIName: fmt.Sprintf("F%d", i),
				Type:    constants.FieldTypeRollupSummary,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childName,
					SummaryField:      fmt.Sprintf("F%d", i+1),
					RelationshipField: fmt.Sprintf("Depth%dId", i),
				},
			})
		}
		if i == 7 {
			// Depth7 has a currency field that we'll try to promote to rollupsummary.
			obj.Fields = append(obj.Fields, models.FieldMetadata{
				APIName: "F7",
				Label:   "Rollup Target",
				Type:    constants.FieldTypeCurrency,
			})
		}
		if i == 8 {
			obj.Fields = append(obj.Fields, models.FieldMetadata{
				APIName: "Amount",
				Type:    constants.FieldTypeCurrency,
			})
		}
		schemas[i] = obj
	}

	dualRepo := &dualLookupRepo{all: schemas}
	schemaRepo := &metadataUpdateFieldSchemaRepoWithModify{}
	ms := NewMetadataService(
		dualRepo,
		NewSchemaManager(schemaRepo),
		nil,
	)

	// Try to promote F7 on Depth7 to rollupsummary summarizing Depth8.
	// This extends the chain to depth 8, exceeding MaxRollupDepth.
	err := ms.UpdateField(context.Background(), "Depth7", "F7", &models.FieldMetadata{
		Type: constants.FieldTypeRollupSummary,
		RollupConfig: &models.RollupConfig{
			CalcType:          "SUM",
			SummaryObject:     "Depth8",
			SummaryField:      "Amount",
			RelationshipField: "Depth7Id",
		},
	})
	if err == nil {
		t.Fatal("UpdateField (promotion exceeding max depth) returned nil, want error")
	}
	if !strings.Contains(strings.ToLower(err.Error()), "depth") {
		t.Errorf("error must mention 'depth', got: %v", err)
	}
}

// dualLookupRepo is a test repo that returns different objects based on
// API name lookup, supporting both parent and child object resolution.
type dualLookupRepo struct {
	ports.MetadataRepository
	parent *models.ObjectMetadata
	child  *models.ObjectMetadata
	all    []*models.ObjectMetadata
}

func (r *dualLookupRepo) GetSchemaByAPIName(ctx context.Context, apiName string) (*models.ObjectMetadata, error) {
	for _, obj := range r.all {
		if strings.EqualFold(obj.APIName, apiName) {
			return obj, nil
		}
	}
	return nil, nil
}

func (r *dualLookupRepo) GetAllSchemas(ctx context.Context) ([]*models.ObjectMetadata, error) {
	return r.all, nil
}
