// Package packs — curated layout reconcile.
//
// applyCuratedPageLayout and verifyCuratedPageLayout share a single state
// machine (see curated_layout_state_machine.go). Both functions:
//
//  1. observeCuratedLayoutState(ctx, db, curatedID, objectAPIName)
//  2. classifyCuratedLayout(observed) → curatedLayoutCell
//  3. stateMatrix[cell] pins (verify result, apply action)
//
// Apply and verify cannot disagree on what should happen in any cell
// because they consult the same table. A new state (i.e. a Codex BLOCK
// pointing at a cell not in the matrix) is a substrate gap: classify
// returns cellUnmodeledFailClosed and both apply and verify hard-fail
// rather than silently diverge.
package packs

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/pingcap-inc/nexuscrm-controlplane/layoutownership"
)

func applyCuratedPageLayout(
	ctx context.Context,
	db *sql.DB,
	layout models.PageLayout,
	assignmentID string,
	logName string,
	packName string,
) error {
	if layout.ID == "" || layout.ObjectAPIName == "" {
		return fmt.Errorf("curated %s layout missing canonical identity", logName)
	}

	obs, err := observeCuratedLayoutState(ctx, db, layout, packName)
	if err != nil {
		return fmt.Errorf("observe curated %s layout state: %w", logName, err)
	}

	cell := classifyCuratedLayout(obs)
	plan, ok := stateMatrix[cell]
	if !ok {
		return fmt.Errorf("curated %s layout in unmodeled state %s; this is a substrate gap", logName, cell.name())
	}

	return executeApplyAction(ctx, db, layout, assignmentID, logName, packName, cell, obs, plan.apply)
}

func executeApplyAction(
	ctx context.Context,
	db *sql.DB,
	layout models.PageLayout,
	assignmentID string,
	logName string,
	packName string,
	cell curatedLayoutCell,
	obs *observedCuratedLayoutState,
	action applyAction,
) error {
	switch action.op {
	case applyNoop:
		log.Printf("   ⚠️  curated %s layout already in steady state %s; no action", logName, cell.name())
		return nil

	case applyConflict:
		return &curatedPageLayoutRoutingConflictError{
			logName: logName,
			detail:  conflictDetail(cell, obs),
		}

	case applyUnmodeled:
		return fmt.Errorf("curated %s layout in unmodeled state; classification failed", logName)

	case applyRefreshManagedCuratedAtCurated:
		// Routing is already correct and the row is pack-managed; refresh
		// row content so source-backed reapply repairs config/payload drift.
		// Admin-owned rows reach a different cell (cellOK*AdminOwned) routed
		// to applyNoop, so we don't need an internal ownership guard here.
		layout.IsDefault = true
		if err := layoutownership.UpsertManagedPageLayout(ctx, db, layout, curatedLayoutSource, packName); err != nil {
			return fmt.Errorf("refresh curated %s layout content: %w", logName, err)
		}
		log.Printf("   ✅ %s curated layout content refreshed while preserving route (cell %s)", logName, cell.name())
		return nil

	case applySeedManagedFromGreenfield:
		// Greenfield: no live layouts, no wildcard, no admin default.
		// Upsert curated row, insert wildcard with managed_seed, set
		// the curated row's IsDefault flag (clearing others as a safety).
		if err := layoutownership.EnsureManagedPageLayoutAndAdoptWildcardRouteAtomically(
			ctx, db, layout, curatedLayoutSource,
			assignmentID, layout.ObjectAPIName, layout.ID,
			models.LayoutAssignmentRouteSourceManagedSeed, packName,
		); err != nil {
			return fmt.Errorf("seed curated %s layout: %w", logName, err)
		}
		log.Printf("   ✅ %s curated layout seeded managed_seed wildcard route (cell %s)", logName, cell.name())
		return nil

	case applyUpsertCuratedAdoptManagedSeed:
		// Upsert curated row to canonical config, then adopt wildcard
		// (managed_seed) pointing at curated. Replaces any existing
		// wildcard rows (orphans, non-admin, opaque RouteSource).
		// Admin-owned curated rows reach a different cell (Routing*AdminOwned)
		// routed to applyAdoptManagedSeedPreservingRow or
		// applyCanonicalizeAdminRouteAtTargetPreservingRow; no row-ownership
		// guard is needed inside this action.
		if err := layoutownership.EnsureManagedPageLayoutAndAdoptWildcardRouteAtomically(
			ctx, db, layout, curatedLayoutSource,
			assignmentID, layout.ObjectAPIName, layout.ID,
			models.LayoutAssignmentRouteSourceManagedSeed, packName,
		); err != nil {
			return fmt.Errorf("upsert curated %s layout and adopt managed_seed wildcard: %w", logName, err)
		}
		log.Printf("   ✅ %s curated layout reconciled to managed_seed wildcard at %s (cell %s)", logName, layout.ID, cell.name())
		return nil

	case applyAdoptManagedSeedPreservingRow:
		// Curated row is admin-owned; preserve it. Adopt managed_seed
		// wildcard at curated without upserting the row.
		if err := layoutownership.AdoptWildcardLayoutRouteAtomically(
			ctx, db, assignmentID, layout.ObjectAPIName, layout.ID,
			models.LayoutAssignmentRouteSourceManagedSeed, packName,
		); err != nil {
			return fmt.Errorf("adopt managed_seed wildcard for admin-owned curated %s layout: %w", logName, err)
		}
		log.Printf("   ✅ %s adopted managed_seed wildcard preserving admin-owned curated row %s (cell %s)", logName, layout.ID, cell.name())
		return nil

	case applyUpsertCuratedCanonicalizeAdminRouteAtTarget:
		// Wildcard points at admin-owned other layout; canonicalize the
		// route to admin_authored while preserving the target. Also
		// upsert curated row (managed) so runtime fallback has the pack
		// content available, and clear curated row's IsDefault flag
		// since the wildcard target is elsewhere.
		if obs.wildcardTarget == nil {
			return fmt.Errorf("curated %s layout cell %s requires wildcard target but observed nil", logName, cell.name())
		}
		if err := layoutownership.EnsureManagedPageLayoutAndAdoptWildcardRouteAtomically(
			ctx, db, layout, curatedLayoutSource,
			assignmentID, layout.ObjectAPIName, obs.wildcardTarget.LayoutID,
			models.LayoutAssignmentRouteSourceAdminAuthored, packName,
		); err != nil {
			return fmt.Errorf("canonicalize admin-authored wildcard for curated %s layout: %w", logName, err)
		}
		log.Printf("   ✅ %s canonicalized wildcard at admin-owned layout %s to admin_authored (cell %s)", logName, obs.wildcardTarget.LayoutID, cell.name())
		return nil

	case applyUpsertCuratedClearDefaultFlag:
		// Admin-authored route legitimately points elsewhere and the curated
		// row is pack-managed; keep the row landed (managed config) so runtime
		// resolution has it as fallback, and ensure its IsDefault flag is
		// clear so it doesn't compete with the admin's chosen target.
		// Admin-owned rows reach cellOKAdminAuthoredAtAdminOtherAdminOwned
		// → applyClearCuratedDefaultFlagOnly; no row-ownership guard here.
		if err := layoutownership.EnsureManagedPageLayoutAndClearDefaultAtomically(
			ctx, db, layout, curatedLayoutSource, layout.ID, packName,
		); err != nil {
			return fmt.Errorf("preserve curated %s layout under admin-authored wildcard: %w", logName, err)
		}
		log.Printf("   ⚠️  %s curated layout preserved (default flag cleared) under admin-authored wildcard (cell %s)", logName, cell.name())
		return nil

	case applyCanonicalizeAdminRouteAtTargetPreservingRow:
		// Routing-only repair: rebuild the wildcard to point at the existing
		// target with RouteSource=admin_authored, leaving the curated row
		// untouched. Used by cellRoutingCanonicalizeAdminRouteAdminOwned
		// (target ≠ curated, target admin-owned, row admin-owned) and
		// cellRoutingRepairOpaqueSourceAdminOwned (target == curated, opaque
		// route, row admin-owned): in both cases the admin owns the curated
		// row content (L2 authority), and the only repair is to make the
		// route source reflect admin ownership.
		if obs.wildcardTarget == nil {
			return fmt.Errorf("curated %s layout cell %s requires wildcard target but observed nil", logName, cell.name())
		}
		if err := layoutownership.AdoptWildcardLayoutRouteAtomically(
			ctx, db, assignmentID, layout.ObjectAPIName, obs.wildcardTarget.LayoutID,
			models.LayoutAssignmentRouteSourceAdminAuthored, packName,
		); err != nil {
			return fmt.Errorf("canonicalize admin-authored wildcard preserving admin-owned curated %s row: %w", logName, err)
		}
		log.Printf("   ✅ %s canonicalized wildcard at %s to admin_authored, admin-owned curated row %s preserved (cell %s)", logName, obs.wildcardTarget.LayoutID, layout.ID, cell.name())
		return nil

	case applyClearCuratedDefaultFlagOnly:
		// Admin-authored route legitimately points elsewhere and the curated
		// row is admin-owned (L2 row content must be preserved). Only clear
		// the curated row's IsDefault flag so it doesn't compete with the
		// admin's chosen target; never touch row content.
		if err := layoutownership.ClearPageLayoutDefaultFlagByID(ctx, db, layout.ID); err != nil {
			return fmt.Errorf("clear admin-owned curated %s default flag: %w", logName, err)
		}
		log.Printf("   ⚠️  %s admin-owned curated row preserved under admin-authored wildcard (cell %s)", logName, cell.name())
		return nil

	case applyMergePackRecordPanelsPreservingRow:
		// Curated row is admin-owned (L2 authority): the admin owns its
		// sections/panel edits, so the row must not be overwritten. But
		// pack-authored recordPanels (e.g. crm.recordActivityFeed) are pack
		// composition, not admin content — additively merge any that are
		// missing (keyed by panel Id) so pack panel ADDITIONS still reach a
		// layout the tenant has customized. Purely additive: never removes,
		// reorders, or mutates existing panels, and never touches the ownership
		// tuple. Idempotent — a no-op once every pack panel is already present,
		// so the cell classification is unchanged (convergent).
		added, err := layoutownership.MergePackRecordPanelsIntoAdminOwnedLayout(ctx, db, layout.ID, layout.RecordPanels)
		if err != nil {
			return fmt.Errorf("merge pack record panels into admin-owned curated %s layout: %w", logName, err)
		}
		if added == 0 {
			log.Printf("   ⚠️  %s admin-owned curated row already carries all pack record panels; no action (cell %s)", logName, cell.name())
			return nil
		}
		log.Printf("   ✅ %s merged %d pack record panel(s) into admin-owned curated row, preserving admin content (cell %s)", logName, added, cell.name())
		return nil

	case applyMergePackRecordPanelsAndClearDefaultPreservingRow:
		// Admin routed the default to a different layout, but the curated row is
		// the pack-managed fallback and admin-owned. Keep its pack recordPanels
		// current (additive merge, preserving admin content) AND clear its
		// default flag so it does not compete with the admin's chosen target.
		// Mirrors applyUpsertCuratedClearDefaultFlag for managed rows, so the
		// broadened content-verify converges on this cell too.
		added, err := layoutownership.MergePackRecordPanelsIntoAdminOwnedLayout(ctx, db, layout.ID, layout.RecordPanels)
		if err != nil {
			return fmt.Errorf("merge pack record panels into admin-owned curated %s layout under admin-authored wildcard: %w", logName, err)
		}
		if err := layoutownership.ClearPageLayoutDefaultFlagByID(ctx, db, layout.ID); err != nil {
			return fmt.Errorf("clear admin-owned curated %s default flag: %w", logName, err)
		}
		if added == 0 {
			log.Printf("   ⚠️  %s admin-owned curated row preserved (default flag cleared) under admin-authored wildcard; already carries all pack record panels (cell %s)", logName, cell.name())
			return nil
		}
		log.Printf("   ✅ %s merged %d pack record panel(s) into admin-owned curated row and cleared default flag under admin-authored wildcard (cell %s)", logName, added, cell.name())
		return nil
	}
	return fmt.Errorf("curated %s layout cell %s has unknown apply op %d", logName, cell.name(), int(action.op))
}

func verifyCuratedPageLayout(db *sql.DB, expected models.PageLayout, packName, logName string) error {
	obs, err := observeCuratedLayoutState(context.Background(), db, expected, packName)
	if err != nil {
		return fmt.Errorf("observe curated %s layout state: %w", logName, err)
	}
	cell := classifyCuratedLayout(obs)
	plan, ok := stateMatrix[cell]
	if !ok {
		return fmt.Errorf("curated %s layout in unmodeled state %s; this is a substrate gap", logName, cell.name())
	}

	switch plan.verify {
	case vrOK:
		return nil
	case vrMissing:
		return &curatedPageLayoutMissingError{logName: logName, layoutID: expected.ID}
	case vrRoutingDrift:
		return &curatedPageLayoutRoutingError{logName: logName, detail: routingDetail(cell, obs)}
	case vrConflict:
		return &curatedPageLayoutRoutingConflictError{logName: logName, detail: conflictDetail(cell, obs)}
	case vrUnmodeled:
		return fmt.Errorf("curated %s layout in unmodeled state %s; this is a substrate gap", logName, cell.name())
	}
	return fmt.Errorf("curated %s layout cell %s has unknown verify result %d", logName, cell.name(), int(plan.verify))
}

func resolveManagedCuratedLayoutRow(
	ctx context.Context,
	db *sql.DB,
	expected models.PageLayout,
	_ string,
) (*layoutownership.PageLayoutRow, string, error) {
	row, err := layoutownership.LoadPageLayoutRow(ctx, db, expected.ID)
	if err != nil {
		return nil, expected.ID, err
	}
	if row == nil {
		return nil, expected.ID, nil
	}
	return row, row.ID, nil
}

// verifyCuratedPageLayoutConfig compares pack-authored facts against the live
// persisted layout. The comparison is split into three independent axes:
//
//  1. Content axis: every PageLayout field EXCEPT RelatedLists, byte-compared
//     after canonicalization. Pack authors control these facts directly (Sections,
//     LayoutName, Tabs, RecordStates, etc.).
//  2. Payload-authored RelatedLists axis: the SUBSET of live.RelatedLists whose
//     (ObjectAPIName, LookupField) identity matches entries the pack declared in
//     its payload. Entries present in live but not declared by the payload
//     (seed-augmented by seedSourceBackedRelatedLists, or appended by tenant
//     overlays) are intentionally ignored — they are DB-derived or tenant-authored,
//     not pack-authored.
//  3. Seed-sentinel axis (active only when payloadAuthoredRelatedLists is empty):
//     catches "live curated layout lost all seeded RelatedLists" without
//     reintroducing apply/verify re-derivation non-determinism. Non-emptiness
//     check only — NOT content equality. See checkLiveRelatedListsSeedSentinel.
//
// payloadAuthoredRelatedLists is the value of layout.RelatedLists BEFORE any
// seedSourceBackedRelatedLists augmentation. The caller passes the pre-seed
// payload value so the axis 2 comparison is anchored to what the pack declared,
// not to what the DB derived at an arbitrary point in time.
//
// relatedListRules is the pack's active related-list rule set, passed through
// to axis 3 for candidate resolution. Pass nil for packs (e.g. User) that have
// no related-list rules — axis 3 will use the default resolution path.
//
// For Campaign/Case/Contact/User (payload relatedLists: []), axis 2 is a no-op.
// Axis 1 catches any content drift on Sections, LayoutName, etc.
// Axis 3 catches live empty when DB still has recommended candidates.
func verifyCuratedPageLayoutConfig(
	ctx context.Context,
	db *sql.DB,
	expected models.PageLayout,
	payloadAuthoredRelatedLists []models.RelatedListConfig,
	relatedListRules []metadata.RelatedListRule,
	logName string,
) error {
	return verifyCuratedPageLayoutConfigForPack(ctx, db, expected, payloadAuthoredRelatedLists, relatedListRules, "", logName)
}

func verifyCuratedPageLayoutConfigForPack(
	ctx context.Context,
	db *sql.DB,
	expected models.PageLayout,
	payloadAuthoredRelatedLists []models.RelatedListConfig,
	relatedListRules []metadata.RelatedListRule,
	packName string,
	logName string,
) error {
	var (
		row *layoutownership.PageLayoutRow
		err error
	)
	row, _, err = resolveManagedCuratedLayoutRow(ctx, db, expected, packName)
	if err != nil {
		return fmt.Errorf("query curated %s layout config: %w", logName, err)
	}
	if row == nil {
		return &curatedPageLayoutMissingError{logName: logName, layoutID: expected.ID}
	}
	live, err := layoutownership.DecodePageLayoutConfig([]byte(row.ConfigJSON))
	if err != nil {
		return fmt.Errorf("parse curated %s layout config: %w", logName, err)
	}
	expected.ID = row.ID

	if layoutownership.IsAdminOwnedPageLayout(row.IsCustomized, row.Source) {
		// Admin owns this row's content (sections, related lists, panel edits),
		// so we do NOT enforce full content equality — that would false-positive
		// on legitimate admin edits. But pack-authored recordPanels are pack
		// composition the admin cannot author via the layout editor, and apply
		// additively merges them (applyMergePackRecordPanelsPreservingRow).
		// Enforce only that every pack-owned recordPanel is present by Id; a
		// missing one is repairable config drift that apply-packs converges via
		// the additive merge (IsCuratedPageLayoutConfigDrift is in the reapply
		// allowlist). This is the narrow "content axis" the admin-owned branch
		// still owns — never section/related-list equality.
		if missing := missingPackRecordPanels(expected.RecordPanels, live.RecordPanels); len(missing) > 0 {
			return &curatedPageLayoutConfigDriftError{logName: logName}
		}
		return nil
	}

	// Axis 1: content equality excluding RelatedLists.
	if err := compareLayoutContentExcludingRelatedLists(expected, live); err != nil {
		return &curatedPageLayoutConfigDriftError{logName: logName}
	}
	// Axis 2: payload-authored RelatedLists by (ObjectAPIName, LookupField).
	if err := comparePayloadAuthoredRelatedLists(payloadAuthoredRelatedLists, live.RelatedLists); err != nil {
		return &curatedPageLayoutConfigDriftError{logName: logName}
	}
	// Axis 3: seed sentinel (only when no payload-authored entries).
	if len(payloadAuthoredRelatedLists) == 0 {
		if err := checkLiveRelatedListsSeedSentinel(db, expected.ObjectAPIName, live.RelatedLists, relatedListRules); err != nil {
			return &curatedPageLayoutConfigDriftError{logName: logName}
		}
	}
	return nil
}

// checkLiveRelatedListsSeedSentinel catches the "live curated layout
// lost all seeded RelatedLists" regression class. It is a non-emptiness
// sentinel — NOT a content equality check — so it does not reintroduce
// the apply/verify re-derivation non-determinism that was the root
// cause of issue #424 REPRO-B.
//
// Fires only when the pack authored no RelatedLists (so this is the
// only axis able to detect the loss). Triggers BLOCK iff:
//   - len(live) == 0 AND
//   - getRelatedListCandidates(db, objectAPIName) yields at least one
//     resolved candidate with IsRecommended == true.
//
// The "DB at apply time had candidates → seeded live; admin / tenant
// overlay / manual SQL later cleared live" failure mode is the
// motivating case. See:
//   - related_list_backfill.go:83 (curated layouts excluded from refill)
//   - tenantoverlay/related_list_overlays.go:184 (overlay UPDATE does
//     not flip IsCustomized — so admin-owned bypass does not engage)
//   - frontend/src/core/hooks/useMetadata.ts:57 ("Empty relatedLists
//     means admin intentionally cleared or not yet seeded")
func checkLiveRelatedListsSeedSentinel(
	db *sql.DB,
	objectAPIName string,
	live []models.RelatedListConfig,
	rules []metadata.RelatedListRule,
) error {
	if len(live) > 0 {
		for _, lr := range live {
			rule, ok := findRecommendedPackRule(rules, objectAPIName, lr.ObjectAPIName, lr.LookupField)
			if !ok {
				continue
			}
			if len(rule.Fields) == 0 {
				continue
			}
			if !stringSlicesEqual(lr.Fields, rule.Fields) {
				return errors.New(
					"seed-sentinel: live RelatedList Fields drift for " +
						lr.ObjectAPIName + "/" + lr.LookupField,
				)
			}
		}
		return nil
	}
	nameFieldMap, err := loadNameFieldMap(db)
	if err != nil {
		return fmt.Errorf("seed-sentinel: load name field map: %w", err)
	}
	candidates, err := getRelatedListCandidates(db, objectAPIName)
	if err != nil {
		return fmt.Errorf("seed-sentinel: get related list candidates: %w", err)
	}
	resolved := resolveMetadataRelatedListCandidatesWithRules(objectAPIName, nameFieldMap, candidates, rules)
	for _, c := range resolved {
		if c.IsRecommended {
			return errors.New("seed-sentinel: live RelatedLists empty but DB has at least one recommended candidate")
		}
	}
	return nil
}

// findRecommendedPackRule looks up a Recommended (or empty-bucket) rule matching
// the given (parent, child, lookup) identity. Mirrors apply's normalization:
// empty bucket → recommended (features/metadata/related_list_defaults.go:242-251).
func findRecommendedPackRule(
	rules []metadata.RelatedListRule,
	parent, child, lookup string,
) (metadata.RelatedListRule, bool) {
	for _, rule := range rules {
		if !strings.EqualFold(rule.ParentObjectAPI, parent) {
			continue
		}
		if !strings.EqualFold(rule.ChildObjectAPI, child) {
			continue
		}
		if !strings.EqualFold(rule.LookupField, lookup) {
			continue
		}
		if rule.Bucket != metadata.BucketRecommended && rule.Bucket != "" {
			continue
		}
		return rule, true
	}
	return metadata.RelatedListRule{}, false
}

// stringSlicesEqual compares two string slices element-wise, order-sensitive.
func stringSlicesEqual(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// missingPackRecordPanels returns the Ids of pack-authored recordPanels that
// are absent from the live layout, matched case-insensitively on trimmed Id
// via layoutownership.RecordPanelDedupKey (the SAME key the additive-merge
// applier uses, so verify and apply agree and converge). Empty pack panels or
// blank Ids are ignored. Used only on the admin-owned content axis: a non-empty
// result is repairable config drift the merge apply op resolves.
func missingPackRecordPanels(packPanels, livePanels []models.RecordPanelConfig) []string {
	if len(packPanels) == 0 {
		return nil
	}
	present := make(map[string]struct{}, len(livePanels))
	for _, p := range livePanels {
		present[layoutownership.RecordPanelDedupKey(p.ID)] = struct{}{}
	}
	var missing []string
	for _, p := range packPanels {
		id := strings.TrimSpace(p.ID)
		if id == "" {
			continue
		}
		if _, ok := present[layoutownership.RecordPanelDedupKey(id)]; !ok {
			missing = append(missing, id)
		}
	}
	return missing
}

// compareLayoutContentExcludingRelatedLists strips RelatedLists from local
// copies of expected and live, then byte-compares their canonical JSON.
// canonicalPageLayoutConfigJSON itself is NOT modified; the strip happens on
// value copies here and does not affect any other caller (e.g. user_page_layout.go
// payload-hash computation).
func compareLayoutContentExcludingRelatedLists(expected, live models.PageLayout) error {
	expectedNoRL := expected
	expectedNoRL.RelatedLists = nil
	liveNoRL := live
	liveNoRL.RelatedLists = nil
	expectedJSON, err := canonicalPageLayoutConfigJSON(expectedNoRL)
	if err != nil {
		return fmt.Errorf("canonicalize expected layout (no RelatedLists): %w", err)
	}
	liveJSON, err := canonicalPageLayoutConfigJSON(liveNoRL)
	if err != nil {
		return fmt.Errorf("canonicalize live layout (no RelatedLists): %w", err)
	}
	if !bytes.Equal(expectedJSON, liveJSON) {
		return errors.New("content drift")
	}
	return nil
}

// relatedListIdentityKey returns the stable identity for a related list entry.
// Mirrors the (ObjectAPIName, LookupField) identity used by:
//   - tenantoverlay/related_list_overlays.go:280 relatedListMatches
//   - features/metadata/related_list_defaults.go relatedListDisplayIdentity
//   - packs/related_list_backfill.go:147 ID canonicalization
//
// Case-insensitive (matches the EqualFold convention used by all three callers).
func relatedListIdentityKey(rl models.RelatedListConfig) string {
	return strings.ToLower(rl.ObjectAPIName) + "|" + strings.ToLower(rl.LookupField)
}

// comparePayloadAuthoredRelatedLists confirms every payload-authored entry
// exists in live with matching content, joining by (ObjectAPIName, LookupField)
// identity. Live entries NOT matched by payload are ignored (seed-augmented or
// tenant-overlaid; not pack-authored). Payload entries missing from live OR
// differing in content trigger drift.
func comparePayloadAuthoredRelatedLists(
	payloadAuthored []models.RelatedListConfig,
	live []models.RelatedListConfig,
) error {
	if len(payloadAuthored) == 0 {
		return nil
	}
	liveByIdentity := make(map[string]models.RelatedListConfig, len(live))
	for _, rl := range live {
		liveByIdentity[relatedListIdentityKey(rl)] = rl
	}
	for _, want := range payloadAuthored {
		got, ok := liveByIdentity[relatedListIdentityKey(want)]
		if !ok {
			return fmt.Errorf(
				"payload-authored RelatedList %s/%s missing from live layout",
				want.ObjectAPIName, want.LookupField)
		}
		wantNorm := normalizeRelatedListConfigForCompare(want)
		gotNorm := normalizeRelatedListConfigForCompare(got)
		if !relatedListConfigEqual(wantNorm, gotNorm) {
			return fmt.Errorf(
				"payload-authored RelatedList %s/%s drifted",
				want.ObjectAPIName, want.LookupField)
		}
	}
	return nil
}

// normalizeRelatedListConfigForCompare normalizes mutable-shape fields for
// byte-stable comparison:
//   - Fields: nil → []string{} (avoid null vs [] false positives). Field ORDER
//     is preserved — fields render in payload order in the UI, so order is semantic.
//   - PrefillMappings: nil → empty map (avoid null vs {} false positives).
//
// ID is intentionally not normalized here and not used in equality (see
// relatedListConfigEqual). The platform's stable identity is (ObjectAPIName,
// LookupField), not ID — and ID equality would reintroduce a case-sensitive
// failure mode if ObjectAPIName casing differs between payload and live.
func normalizeRelatedListConfigForCompare(rl models.RelatedListConfig) models.RelatedListConfig {
	if rl.Fields == nil {
		rl.Fields = []string{}
	}
	if rl.PrefillMappings == nil {
		rl.PrefillMappings = map[string]string{}
	}
	return rl
}

// relatedListConfigEqual compares two normalized configs field by field,
// EXCLUDING the ID field (identity is the (ObjectAPIName, LookupField) tuple
// already used for the lookup join). Uses EqualFold for ObjectAPIName and
// LookupField; exact equality for Label and SecondaryLabel; order-sensitive
// comparison for Fields (UI render order is semantic).
func relatedListConfigEqual(a, b models.RelatedListConfig) bool {
	if a.Label != b.Label ||
		a.SecondaryLabel != b.SecondaryLabel ||
		!strings.EqualFold(a.ObjectAPIName, b.ObjectAPIName) ||
		!strings.EqualFold(a.LookupField, b.LookupField) {
		return false
	}
	if len(a.Fields) != len(b.Fields) {
		return false
	}
	for i := range a.Fields {
		if a.Fields[i] != b.Fields[i] { // order-sensitive (UI render order)
			return false
		}
	}
	if len(a.PrefillMappings) != len(b.PrefillMappings) {
		return false
	}
	for k, v := range a.PrefillMappings {
		if b.PrefillMappings[k] != v {
			return false
		}
	}
	return true
}

type curatedPageLayoutConfigDriftError struct {
	logName string
}

func (e *curatedPageLayoutConfigDriftError) Error() string {
	return fmt.Sprintf("curated %s layout config drifted; run 'go run ./cmd/setup apply'", e.logName)
}

func IsCuratedPageLayoutConfigDrift(err error) bool {
	var target *curatedPageLayoutConfigDriftError
	return errors.As(err, &target)
}

type curatedPageLayoutMissingError struct {
	logName  string
	layoutID string
}

func (e *curatedPageLayoutMissingError) Error() string {
	return fmt.Sprintf("curated %s layout missing (id=%s); run 'go run ./cmd/setup apply'", e.logName, e.layoutID)
}

func IsCuratedPageLayoutMissing(err error) bool {
	var target *curatedPageLayoutMissingError
	return errors.As(err, &target)
}

type curatedPageLayoutRoutingError struct {
	logName string
	detail  string
}

func (e *curatedPageLayoutRoutingError) Error() string {
	return fmt.Sprintf("curated %s layout routing drifted: %s; run 'go run ./cmd/setup apply-packs'", e.logName, e.detail)
}

// IsCuratedPageLayoutRoutingDrift identifies repairable routing-drift errors.
// source_pack_ops.isRepairableInstalledPackApplyAllError must include this
// predicate in its layout-repairable allowlist so verify→reapply→verify
// can converge on cells the state matrix marks as routing-class.
func IsCuratedPageLayoutRoutingDrift(err error) bool {
	var target *curatedPageLayoutRoutingError
	return errors.As(err, &target)
}

type curatedPageLayoutRoutingConflictError struct {
	logName string
	detail  string
}

func (e *curatedPageLayoutRoutingConflictError) Error() string {
	return fmt.Sprintf("curated %s layout routing conflict: %s; operator resolution required before apply-packs can converge", e.logName, e.detail)
}

func IsCuratedPageLayoutRoutingConflict(err error) bool {
	var target *curatedPageLayoutRoutingConflictError
	return errors.As(err, &target)
}

func canonicalPageLayoutConfigJSON(layout models.PageLayout) ([]byte, error) {
	layout.IsDefault = false
	layout.IsDeleted = false
	layout.CreatedDate = time.Time{}
	layout.LastModifiedDate = time.Time{}
	return json.Marshal(layout)
}
