package packs

import (
	"fmt"
	"strings"

	"github.com/nexuscrm/analytics"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/formulastorage"
	"github.com/nexuscrm/shared/pkg/models"
)

// validateObjectSpecFieldShapes walks all ObjectSpec fields and emits
// diagnostics for any field whose shape is non-canonical. A shape is canonical
// when downstream consumers (platform_core.go:308-313, schema_column_ops.go:162-170)
// can consume it raw without any further trimming, blank-skipping, or normalization.
//
// Contract: if this function returns no diagnostics for a given set of specs,
// callers on the source-backed pack install path MAY treat every
// field.ReferenceTo entry and every field.Options[i].Value as already canonical.
// Any defensive normalization downstream of this function is a parallel SSoT
// and is forbidden.
//
// The validator does NOT mutate specs — non-canonical input is rejected, never
// silently rewritten.
//
// Aggregating: all diagnostics are collected across all fields; no fail-fast.
func validateObjectSpecFieldShapes(specs []ObjectSpec) []PackDiagnostic {
	return validateObjectSpecFieldShapesWithResolver(specs, newObjectSpecFieldResolver(specs), true, false)
}

func validateObjectSpecFieldShapesWithResolver(specs []ObjectSpec, resolver objectSpecFieldResolver, allowLegacyActiveScalarFormula bool, allowLegacyAggregateFormulaAuthoring bool) []PackDiagnostic {
	var out []PackDiagnostic
	for _, spec := range specs {
		// Object-level shape: displayNameField must resolve to a real,
		// label-renderable field within spec.Fields. See
		// validateObjectSpecDisplayNameField below for the acceptance matrix.
		out = append(out, validateObjectSpecDisplayNameField(spec, resolver)...)
		for _, field := range spec.Fields {
			p := fieldShapePath(spec.APIName, field.APIName)
			if len(field.WritableReferenceTo) > 0 && !constants.IsReferenceType(constants.SchemaFieldType(field.Type)) {
				out = append(out, diag(
					"field.reference.writable_target_on_non_reference", p,
					"writableReferenceTo is only valid on reference fields",
				))
			}
			// Canonical-formulaMode gate (Codex round-4 BLOCK on #964): fires
			// regardless of Type. Previously this check lived only in the
			// non-formula branch below, so a formula-typed field could declare
			// formulaMode "ACTIVE" / " active " / "FROZEN" and pass validation
			// — yet downstream readers (source_backed_pack.go:367, source_pack
			// _ops.go:1186) match the canonical lowercase form raw and treat
			// non-canonical spellings as inactive. Pack-owned convergence
			// (schema_pack_owned_field.go:597) already treats those values as
			// active and skips physical DDL, so greenfield `setup apply` wrote
			// metadata with neither a backing column nor a rebuilt derived
			// view. Hoist the check so any non-canonical formulaMode — on any
			// Type — is rejected at the pack-install boundary. When this gate
			// fires we mark the formulaMode as bad so the formula-side switch
			// below doesn't pile on a redundant
			// field.formula.expression_on_non_formula_type diagnostic for the
			// same root cause (the field's runtime behavior is undefined once
			// formulaMode is non-canonical). Non-formula-shape checks
			// (reference/picklist) still run independently below.
			formulaModeCanonical := field.FormulaMode == "" || field.FormulaMode == "active" || field.FormulaMode == "frozen"
			if !formulaModeCanonical {
				out = append(out, diag("field.formula.unknown_formula_mode", p,
					fmt.Sprintf("formulaMode %q is not a canonical value; only \"\", \"active\", or \"frozen\" are accepted (exact lowercase, no surrounding whitespace) because downstream source-backed-pack readers — source_backed_pack.go:367, source_pack_ops.go:1186, and schema_pack_owned_field.go:597 — match the canonical form raw; a non-canonical spelling would install a dead column with no backing storage and no derived view", field.FormulaMode)))
			}
			formulaStorageModeCanonical := field.FormulaStorageMode == "" || field.FormulaStorageMode == formulastorage.ModeViewOnly || field.FormulaStorageMode == formulastorage.ModeWriteMaterialized
			if !formulaStorageModeCanonical {
				out = append(out, diag("field.formula.unknown_formula_storage_mode", p,
					fmt.Sprintf("formulaStorageMode %q is not a canonical value; only \"\", %q, or %q are accepted (exact lowercase, no surrounding whitespace) because resolver-first storage decisions are fail-closed in shared/pkg/formulastorage.Resolve, while source-backed-pack readers and record writers do not all recover the same way from malformed authoring", field.FormulaStorageMode, formulastorage.ModeViewOnly, formulastorage.ModeWriteMaterialized)))
			}
			// Codex round-9 BLOCK on #964: a formula-typed field with a real
			// formula expression AND blank formulaMode reaches NO compute path
			// downstream. Active-mode lookups (formula_view_service.go:1115
			// isActiveComputedField, source_backed_pack.go:367) require
			// formulaMode=="active" exactly; frozen save-time compute
			// (records/formula_compute.go:162, 282; persistence_bulk_insert
			// .go:624) requires formulaMode=="frozen" exactly. Blank hits
			// neither, yet pack-owned convergence (schema_pack_owned_field.go
			// :597) treats blank as "not active" and still emits a plain
			// physical column — the same dead-column shape the earlier rounds
			// of this PR closed for the non-formula-Type direction. Require an
			// explicit mode at the pack-install boundary so the formula-typed
			// path can never install metadata that no downstream code wires up.
			// The blank-formulaMode + blank-formula case (e.g. a placeholder
			// formula-typed field still being authored) remains allowed; the
			// formula switch arm below will independently reject it via
			// field.formula.no_return_type if returnType is also missing.
			if field.Type == "formula" && strings.TrimSpace(field.Formula) != "" && field.FormulaMode == "" {
				out = append(out, diag("field.formula.unknown_formula_mode", p,
					"formula-typed field with a non-empty formula expression requires an explicit formulaMode of \"active\" or \"frozen\"; blank formulaMode wires no compute path (formula_view_service.go:1115 / records/formula_compute.go:162 / persistence_bulk_insert.go:624 all match the canonical lowercase form exactly), so a plain physical column would be created with no value source"))
			}
			// Codex round-10 BLOCK on #964: a projection-backed field
			// (ProjectionConfig != nil) with anything other than the exact
			// canonical formulaMode "active" reaches NO compute path downstream.
			// The active-mode lookups that wire projection into the v_<obj>
			// derived view all require formulaMode == "active" exactly:
			//   - source_backed_pack.go:365-374 objectSpecRequiresDerivedView
			//     skips any field whose FormulaMode != "active", so a blank or
			//     "frozen" projection field never triggers derived-view rebuild
			//     even though the pack-owned convergence path
			//     (schema_pack_owned_field.go:597-609) still emits a plain
			//     physical column with no value source.
			//   - formula_view_service.go:1115 isActiveComputedField (called by
			//     dependency walker and the projection lookup path at
			//     formula_view_service.go:979-986, :1122) requires
			//     FormulaMode == "active" exactly; blank or "frozen" silently
			//     drops the projection from view-compute substrate.
			// Result without this gate: greenfield `setup apply` admits a
			// projection-backed field as a dead physical column. Live
			// substrate to keep working: PricebookEntry.Name and
			// CampaignMember.HasResponded in salesforce-crm-standard both
			// declare formulaMode "active" alongside their projection block,
			// so this gate does not regress the official pack.
			//
			// Exemption: when formulaMode is non-canonical
			// (formulaModeCanonical == false), the dedicated
			// unknown_formula_mode diagnostic already named the same field, so
			// suppress this projection diagnostic to avoid piling a second
			// diagnostic on the same root cause (mirrors the
			// expression_on_non_formula_type suppression below).
			if field.ProjectionConfig != nil && formulaModeCanonical && field.FormulaMode != "active" {
				out = append(out, diag("field.projection.requires_active_formula_mode", p,
					"projection-backed field (ProjectionConfig present) requires formulaMode \"active\"; blank or \"frozen\" formulaMode wires no compute path — source_backed_pack.go:365 objectSpecRequiresDerivedView and formula_view_service.go:1115 isActiveComputedField both require the exact canonical \"active\" spelling, so pack-owned convergence (schema_pack_owned_field.go:597) emits a plain physical column with no projection lookup wired up"))
			}
			// Type-coherence guard for #958, HOISTED above the switch (Codex
			// round-6 BLOCK on #964). The check must fire for every field
			// that carries a non-empty Formula expression with a non-formula
			// Type, regardless of which subtype-specific switch arm would
			// otherwise have matched. Before this hoist, reference /
			// masterdetail / picklist / multipicklist short-circuited into
			// their own arms first and skipped the check entirely, so a
			// source-backed pack could declare e.g. `type: "reference"` with a
			// non-empty formula and blank/"frozen" formulaMode and pass the
			// validator — exactly the dead-column shape the PR is meant to
			// block. Boolean and other scalar types not covered by any earlier
			// arm were ALSO skipped because the previous `case` was guarded by
			// `formulaModeCanonical && Formula != ""`, meaning a non-canonical
			// spelling would only emit unknown_formula_mode and miss the
			// expression-on-non-formula-type diagnostic. The hoisted form runs
			// independently of every subtype arm and only suppresses itself
			// for the legitimate scalar+active view-compute substrate.
			//
			//   Type == "formula"        → SKIP: a formula expression on a
			//                              formula-typed field is exactly the
			//                              canonical shape; the formula switch
			//                              arm below still validates returnType.
			//
			//   FormulaMode == "active" + non-canonical (formulaModeCanonical=false)
			//                             → SKIP: the unknown_formula_mode
			//                              diagnostic already named the same
			//                              field; piling on a second diagnostic
			//                              for the same root cause is noise.
			//
			//   FormulaMode == "active"  → SKIP: FormulaViewService projects
			//                              these via v_<obj> regardless of
			//                              public Type (formula_view_service.go
			//                              :1080-1083, isActiveComputedField at
			//                              :1115 does NOT gate on Type). Live
			//                              substrate: CampaignMember.Title
			//                              (type=string), OpportunityLineItem
			//                              .TotalPrice (type=currency), etc. —
			//                              see pr3_substrate_guardrails_test.go
			//                              :364-375.
			//
			//   FormulaMode == ""        → REJECT: no compute path is wired
			//                              (formula_compute.go:162 requires
			//                              Type==formula for frozen save-time
			//                              compute, and isActiveComputedField
			//                              requires FormulaMode=="active" for
			//                              view-time compute). #958's bug.
			//
			//   FormulaMode == "frozen"  → REJECT: frozen save-time compute
			//                              (formula_compute.go:162, 282)
			//                              requires Type==formula. Without it
			//                              the column never receives a value.
			if strings.TrimSpace(field.Formula) != "" && field.Type != "formula" && formulaModeCanonical && (field.FormulaMode != "active" || !allowLegacyActiveScalarFormula) {
				out = append(out, diag("field.formula.expression_on_non_formula_type", p,
					"formula expression on a non-formula-type field is only valid when formulaMode is \"active\" (view-time compute substrate); without an active formulaMode the field becomes a dead column at runtime (change type to 'formula', set formulaMode to 'active', or remove the formula expression)"))
			}
			if strings.TrimSpace(field.Formula) != "" {
				aggregates, err := analytics.FindAggregateAuthoringExprs(field.Formula)
				if err == nil && len(aggregates) > 0 && !allowLegacyAggregateFormulaAuthoring {
					out = append(out, diag("field.formula.aggregate_authoring_requires_rollupsummary", p,
						analytics.AggregateAuthoringRequiresRollupMessage))
					continue
				}
			}
			switch {
			case constants.IsReferenceType(constants.SchemaFieldType(field.Type)):
				// Covers both "reference" and "masterdetail" — all relationship
				// field types that carry a ReferenceTo slice consumed raw by FK DDL.
				out = append(out, validateReferenceShape(p, field.ReferenceTo)...)
				out = append(out, validateWritableReferenceShape(p, field.ReferenceTo, field.WritableReferenceTo)...)
			case field.Type == "picklist":
				out = append(out, validateOptionValues(p, "field.picklist.blank_option_value", field.Options)...)
			case field.Type == "multipicklist":
				out = append(out, validateOptionValues(p, "field.multipicklist.blank_option_value", field.Options)...)
			case field.Type == "formula":
				trimmed := strings.TrimSpace(field.ReturnType)
				if trimmed == "" {
					out = append(out, diag("field.formula.no_return_type", p,
						"formula field requires a non-empty returnType; Salesforce infers return type from expression at compile time, NexusCRM does not implement expression-level type inference at install time"))
				} else if trimmed != field.ReturnType {
					out = append(out, diag("field.formula.padded_return_type", p,
						"formula field returnType must be byte-identical to its strings.TrimSpace form; downstream DDL and type-resolution consumers read returnType raw without normalization"))
				}
			}
		}
	}
	return out
}

func validateWritableReferenceShape(path string, referenceTo, writableReferenceTo []string) []PackDiagnostic {
	if len(writableReferenceTo) == 0 {
		return nil
	}
	if diags := validateReferenceShape(path+".writableReferenceTo", writableReferenceTo); len(diags) > 0 {
		return diags
	}
	for _, writableTarget := range writableReferenceTo {
		if !containsStringFold(referenceTo, writableTarget) {
			return []PackDiagnostic{diag(
				"field.reference.writable_target_not_declared", path,
				fmt.Sprintf("writableReferenceTo target %q must also be declared in referenceTo", writableTarget),
			)}
		}
	}
	return nil
}

// validateReferenceShape checks a reference field's ReferenceTo slice for three
// classes of non-canonical shape, in priority order (blank wins over padded):
//
//	no_target     — nil, empty, or all-entries-blank-after-TrimSpace
//	blank_target  — at least one blank entry AND at least one non-blank entry
//	padded_target — no blank entries, but at least one entry whose TrimSpace
//	                form differs from its original form
func validateReferenceShape(path string, referenceTo []string) []PackDiagnostic {
	var blankCount, paddedCount, canonicalCount int
	for _, target := range referenceTo {
		trimmed := strings.TrimSpace(target)
		switch {
		case trimmed == "":
			blankCount++
		case trimmed != target:
			paddedCount++
		default:
			canonicalCount++
		}
	}

	if canonicalCount == 0 && paddedCount == 0 {
		// nil slice, empty slice, or all entries are blank after trim.
		return []PackDiagnostic{diag(
			"field.reference.no_target", path,
			"reference field referenceTo must contain at least one non-blank, non-padded target object API name",
		)}
	}
	if blankCount > 0 {
		// Mixed-blank: slice has at least one blank entry alongside at least one
		// non-blank entry. Blank wins over padded per issue #489 R11.
		return []PackDiagnostic{diag(
			"field.reference.blank_target", path,
			"reference field referenceTo contains one or more blank entries; every entry must be a non-blank object API name",
		)}
	}
	if paddedCount > 0 {
		// No blank entries, but at least one entry has leading or trailing
		// whitespace. The padded form reaches FK DDL generation as-is and
		// produces a backtick-wrapped padded identifier.
		return []PackDiagnostic{diag(
			"field.reference.padded_target", path,
			"reference field referenceTo contains one or more padded entries; every entry must be byte-identical to its strings.TrimSpace form",
		)}
	}
	return nil
}

// validateOptionValues checks picklist and multipicklist option values for
// blank-after-TrimSpace or padded (trimmed != original) entries. Both cases
// are reported under the same diagnostic code because the downstream consumer
// reads option values raw — a blank or padded value is equally non-canonical.
// Emits at most one diagnostic per field (fail-on-first-bad-option within a
// field; aggregation is across fields, handled by validateObjectSpecFieldShapes).
func validateOptionValues(path, code string, options models.PicklistOptions) []PackDiagnostic {
	for _, opt := range options {
		trimmed := strings.TrimSpace(opt.Value)
		if trimmed == "" || trimmed != opt.Value {
			return []PackDiagnostic{diag(code, path,
				"picklist/multipicklist option values must be non-blank and byte-identical to their strings.TrimSpace form",
			)}
		}
	}
	return nil
}

func containsStringFold(values []string, target string) bool {
	for _, value := range values {
		if strings.EqualFold(value, target) {
			return true
		}
	}
	return false
}

func fieldShapePath(objectAPIName, fieldAPIName string) string {
	return fmt.Sprintf("%s.%s", objectAPIName, fieldAPIName)
}

// constants.IsDisplayNameFieldRenderableType defines the SchemaFieldType values
// that the frontend can render as a record display label. It drives the
// validateObjectSpecDisplayNameField acceptance gate.
//
// Acceptance rationale:
//   - reference / masterdetail — canonical case. getListRecordName
//     (frontend/src/infrastructure/api/data/recordUtils.ts:167) resolves
//     <displayNameField>_Display and <displayNameField>_Name siblings that the
//     RecordUI flattener (selectRecordFromUI, Pass 2) injects from the
//     lookup's relationship record. These require a canonical relationshipName
//     because list expansion selects RelationshipName.Name, not raw LookupId.Name.
//   - string / textarea / email / phone / url / autonumber — text-class
//     identifier types. The list-view resolver's _Display/_Name lookup misses
//     for these (no relationship sibling), but getRecordName's intrinsic
//     fallback still renders them as a plain label via the field's own scalar
//     value.
//
// Rejected types and why:
//   - boolean / numerics (double, currency, percent) / date / datetime —
//     not a label; renders as a value, not an identity.
//   - picklist / multipicklist — encodes a value, not a display name; the
//     frontend renders the value, not a label, and option values are
//     case-sensitive enums.
//   - richtextarea — HTML payload, not a plain label.
//   - json / encryptedstring / password — not renderable as a label.
//   - formula / rollupsummary — compute-derived; the compute path is wired
//     by separate validators (FormulaMode / RollupConfig) and the result type
//     is not statically resolvable here. Out of scope for displayNameField
//     validation at admit time.
//
// validateObjectSpecDisplayNameField enforces the semantic contract for
// ObjectSpec.DisplayNameField (issue #1110 / PR #1133 Ian P1):
//
//  1. Empty (untrimmed) is allowed — DisplayNameField is optional.
//  2. Whitespace-only is rejected — pre-trim non-empty but post-trim empty is
//     a non-canonical input that survives the if-non-empty gate at
//     platform_core.go:507-509 and reaches _System_Object as a padded string.
//  3. The named display source MUST resolve to an entry in spec.Fields by
//     case-sensitive ApiName match, relationshipName match, or one-hop
//     relationship child path whose parent resolves to a reference /
//     masterdetail field.
//  4. The resolved field's Type MUST be a label-renderable type per
//     constants.IsDisplayNameFieldRenderableType. Reference/masterdetail is the
//     canonical case (drives _Display/_Name sibling resolution); text-class
//     types are renderable via the intrinsic fallback.
//  5. Reference/masterdetail display-name fields MUST be single-target and
//     non-polymorphic. The current list-name hydration path resolves lookup
//     labels from ReferenceTo[0], so multi-target relationships would render
//     wrong labels for rows whose concrete target is not that first entry.
//  6. Reference/masterdetail display-name fields MUST carry a non-blank,
//     trimmed relationshipName. Without it, useRecordList can only select the
//     raw lookup column and getListRecordName renders the lookup Id instead of
//     a related record label.
//
// Returns all diagnostics for a single object's display-name; aggregation
// across objects is handled by the enclosing validateObjectSpecFieldShapes
// loop.
func validateObjectSpecDisplayNameField(spec ObjectSpec, resolver objectSpecFieldResolver) []PackDiagnostic {
	raw := spec.DisplayNameField
	if raw == "" {
		// Optional — no display-name override declared.
		return nil
	}
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		// R2: pre-trim non-empty (passed the != "" gate) but whitespace-only.
		return []PackDiagnostic{diag(
			"object.display_name_field.blank", spec.APIName+".displayNameField",
			"displayNameField is whitespace-only; either omit it or set it to a field ApiName from spec.Fields",
		)}
	}
	if trimmed != raw {
		// Padded variant — downstream readers consume the value raw and would
		// look up a padded ApiName that never resolves. Treat as a hard reject
		// (same posture as field.reference.padded_target).
		return []PackDiagnostic{diag(
			"object.display_name_field.padded", spec.APIName+".displayNameField",
			fmt.Sprintf("displayNameField %q has leading or trailing whitespace; value must be byte-identical to its strings.TrimSpace form (downstream consumers — platform_core.go:507 and frontend getListRecordName — match the ApiName raw)", raw),
		)}
	}

	// R3: resolve by case-sensitive ApiName, relationshipName, or one-hop
	// relationship child path within spec.Fields.
	childField, hasChildPath := displayNameChildPath(trimmed)
	if hasChildPath && !displayNameDotPathSegmentsAreCanonical(trimmed) {
		return []PackDiagnostic{diag(
			"object.display_name_field.segment_padded", spec.APIName+".displayNameField",
			fmt.Sprintf("displayNameField %q has padded relationship path segment(s); parent and child segments must be byte-identical to their strings.TrimSpace forms", trimmed),
		)}
	}
	resolved := resolveObjectSpecDisplayNameField(trimmed, spec)
	if resolved == nil {
		return []PackDiagnostic{diag(
			"object.display_name_field.unresolved", spec.APIName+".displayNameField",
			fmt.Sprintf("displayNameField %q does not resolve to any field, relationshipName, or one-hop relationship child path in spec.Fields", trimmed),
		)}
	}

	// R4: type must be label-renderable.
	resolvedType := constants.NormalizeFieldType(resolved.Type)
	if !constants.IsDisplayNameFieldRenderableType(resolvedType) {
		return []PackDiagnostic{diag(
			"object.display_name_field.wrong_type", spec.APIName+".displayNameField",
			fmt.Sprintf("displayNameField %q resolves to a field of type %q, which is not a label-renderable type; accepted types are reference, masterdetail, string, textarea, email, phone, url, autonumber", trimmed, resolved.Type),
		)}
	}
	if constants.IsReferenceType(resolvedType) {
		if resolved.IsPolymorphic || len(resolved.ReferenceTo) != 1 {
			return []PackDiagnostic{diag(
				"object.display_name_field.reference_not_single_target", spec.APIName+".displayNameField",
				fmt.Sprintf("displayNameField %q resolves to relationship field %q with referenceTo=%v and isPolymorphic=%t; reference/masterdetail display-name fields must have exactly one concrete target because list-name hydration resolves lookup labels from ReferenceTo[0]", trimmed, resolved.APIName, resolved.ReferenceTo, resolved.IsPolymorphic),
			)}
		}
		relationshipName := strings.TrimSpace(resolved.RelationshipName)
		if relationshipName == "" {
			return []PackDiagnostic{diag(
				"object.display_name_field.relationship_name_missing", spec.APIName+".displayNameField",
				fmt.Sprintf("displayNameField %q resolves to relationship field %q, but that field has no relationshipName; reference/masterdetail display-name fields must declare the RelationshipName used by describe/list expansion", trimmed, resolved.APIName),
			)}
		}
		if relationshipName != resolved.RelationshipName {
			return []PackDiagnostic{diag(
				"object.display_name_field.relationship_name_padded", spec.APIName+".displayNameField",
				fmt.Sprintf("displayNameField %q resolves to relationship field %q with padded relationshipName %q; relationshipName must be byte-identical to its strings.TrimSpace form", trimmed, resolved.APIName, resolved.RelationshipName),
			)}
		}
		if hasChildPath {
			if childDiag := validateObjectSpecDisplayNameChild(spec, trimmed, childField, resolved, resolver); childDiag != nil {
				return childDiag
			}
		}
	}
	return nil
}

func displayNameChildPath(value string) (string, bool) {
	if strings.Count(value, ".") != 1 {
		return "", false
	}
	parts := strings.SplitN(value, ".", 2)
	child := strings.TrimSpace(parts[1])
	return child, child != ""
}

func displayNameDotPathSegmentsAreCanonical(value string) bool {
	if strings.Count(value, ".") != 1 {
		return true
	}
	parts := strings.SplitN(value, ".", 2)
	return parts[0] == strings.TrimSpace(parts[0]) && parts[1] == strings.TrimSpace(parts[1])
}

func validateObjectSpecDisplayNameChild(spec ObjectSpec, displayNameField string, childField string, referenceField *FieldSpec, resolver objectSpecFieldResolver) []PackDiagnostic {
	path := spec.APIName + ".displayNameField"
	if !isDisplayNameChildAPIName(childField) {
		return []PackDiagnostic{diag(
			"object.display_name_field.child_invalid_api_name", path,
			fmt.Sprintf("displayNameField %q has relationship child %q that is not a legal field ApiName", displayNameField, childField),
		)}
	}
	if referenceField == nil || len(referenceField.ReferenceTo) != 1 {
		return []PackDiagnostic{diag(
			"object.display_name_field.child_target_unresolved", path,
			fmt.Sprintf("displayNameField %q cannot resolve child field %q because its parent relationship target is not single-target", displayNameField, childField),
		)}
	}
	targetAPIName := strings.TrimSpace(referenceField.ReferenceTo[0])
	canonicalTarget, ok := resolver.canonicalObjectAPIName(targetAPIName)
	if !ok {
		return []PackDiagnostic{diag(
			"object.display_name_field.child_target_unresolved", path,
			fmt.Sprintf("displayNameField %q references target object %q, but that object schema is not present for child-field validation", displayNameField, targetAPIName),
		)}
	}
	childType, ok := resolver.displayNameChildFieldType(canonicalTarget, childField)
	if !ok {
		return []PackDiagnostic{diag(
			"object.display_name_field.child_unresolved", path,
			fmt.Sprintf("displayNameField %q references child field %q, but %s.Fields has no case-sensitive ApiName match", displayNameField, childField, canonicalTarget),
		)}
	}
	if !constants.IsDisplayNameRelationshipChildType(childType) {
		return []PackDiagnostic{diag(
			"object.display_name_field.child_wrong_type", path,
			fmt.Sprintf("displayNameField %q references child field %q of type %q, which is not a scalar label/search type; accepted child types are string, textarea, email, phone, url, autonumber", displayNameField, childField, childType),
		)}
	}
	return nil
}

func isDisplayNameChildAPIName(apiName string) bool {
	if apiName == "" {
		return false
	}
	for _, c := range apiName {
		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
			continue
		}
		return false
	}
	return true
}

func resolveObjectSpecDisplayNameField(value string, spec ObjectSpec) *FieldSpec {
	if strings.Count(value, ".") == 1 {
		parts := strings.SplitN(value, ".", 2)
		parentRaw := parts[0]
		childRaw := parts[1]
		parent := strings.TrimSpace(parentRaw)
		child := strings.TrimSpace(childRaw)
		if parent == "" || child == "" || strings.Contains(child, ".") {
			return nil
		}
		if parent != parentRaw || child != childRaw {
			return nil
		}
		return resolveObjectSpecDisplayNameReferenceField(parent, spec)
	}
	if strings.Contains(value, ".") {
		return nil
	}
	for i := range spec.Fields {
		if spec.Fields[i].APIName == value {
			return &spec.Fields[i]
		}
	}
	return resolveObjectSpecDisplayNameReferenceField(value, spec)
}

func resolveObjectSpecDisplayNameReferenceField(parent string, spec ObjectSpec) *FieldSpec {
	for i := range spec.Fields {
		field := &spec.Fields[i]
		if field.APIName == parent {
			if constants.IsReferenceType(constants.NormalizeFieldType(field.Type)) {
				return field
			}
			return nil
		}
	}
	for i := range spec.Fields {
		field := &spec.Fields[i]
		if !constants.IsReferenceType(constants.NormalizeFieldType(field.Type)) {
			continue
		}
		if field.RelationshipName == parent {
			return field
		}
	}
	return nil
}

// ValidateSpecDisplayNameFields exposes the source-backed object-spec
// displayNameField contract for non-pack callers that admit ObjectSpec payloads.
func ValidateSpecDisplayNameFields(specs []ObjectSpec) error {
	return validateSpecDisplayNameFields(specs)
}

// validateSpecDisplayNameFields wraps validateObjectSpecDisplayNameField in
// an error-returning shape suitable for the admit-time / verify-time apply
// pipeline (applyObjectSpecsWithResolverAndValidation,
// sourceBackedObjectSpecComponent.VerifyComponent). Returns nil when every
// object's display-name configuration is canonical and resolves to a
// label-renderable field; returns a joined error listing every offending
// object otherwise.
func validateSpecDisplayNameFields(specs []ObjectSpec) error {
	return validateSpecDisplayNameFieldsWithResolver(specs, newObjectSpecFieldResolver(specs))
}

func validateSpecDisplayNameFieldsWithResolver(specs []ObjectSpec, resolver objectSpecFieldResolver) error {
	var diags []PackDiagnostic
	for _, spec := range specs {
		diags = append(diags, validateObjectSpecDisplayNameField(spec, resolver)...)
	}
	if len(diags) == 0 {
		return nil
	}
	msgs := make([]string, 0, len(diags))
	for _, d := range diags {
		msgs = append(msgs, fmt.Sprintf("%s: %s (%s)", d.Path, d.Message, d.Code))
	}
	return fmt.Errorf("displayNameField validation failed:\n  - %s", strings.Join(msgs, "\n  - "))
}
