package packs

import (
	"context"
	"database/sql"
	"strings"
	"testing"

	sqlmock "github.com/DATA-DOG/go-sqlmock"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/shared/pkg/models"
)

var sqlErrNoRows = sql.ErrNoRows

// TestBuildComponentsPreCollectsRelatedListRulesIndependentOfManifestOrder is
// the explicit regression Ian R1 demanded for issue #1102 / PR #1124: a
// manifest declares `page_layout` BEFORE `related_list_rule`, with NO
// explicit `page_layout -> related_list_rule` dependency. The topological
// sort in topologicallySortManifestComponents breaks ties by
// `kind + "\x00" + businessKey`; "page_layout" sorts before
// "related_list_rule", so without pre-collection the page-layout component
// captures an empty `rules` slice at construction. The axis-3 Fields drift
// check then silently skips every live entry — exactly the operator-visible
// drift this PR is meant to surface.
//
// The fix walks `ordered` once before the main switch and pre-populates
// p.relatedListRules. The closure inside newSourceBackedPageLayoutComponent
// captures the slice header at construction; pre-population guarantees that
// header points to the rule, regardless of where related_list_rule lands
// after the sort.
//
// This test asserts the pack-level contract: with the page_layout component
// listed FIRST in the manifest and no DependsOn link, NewSourceBackedPack
// must complete with p.relatedListRules populated. Given that slice, the
// axis-3 drift function (which the page-layout component's verify closure
// invokes — see source_backed_metadata_components.go:820-824) correctly
// surfaces a Fields drift between the live entry and the rule.
func TestBuildComponentsPreCollectsRelatedListRulesIndependentOfManifestOrder(t *testing.T) {
	pageLayoutPayload := []byte(`{
		"objectApiName": "Campaign",
		"layoutName": "Campaign Layout",
		"sections": [{"id": "s1", "label": "Details", "fields": ["Name"]}]
	}`)
	relatedListRulePayload := []byte(`{
		"rules": [{
			"parentObjectApiName": "Campaign",
			"childObjectApiName": "CampaignMember",
			"lookupField": "CampaignId",
			"bucket": "recommended",
			"fields": ["Name", "Status"]
		}]
	}`)

	// Critical: page_layout listed BEFORE related_list_rule, no DependsOn
	// link. This is the construction-path regression Ian called out — the
	// topological sort places page_layout first, so the page-layout
	// component is constructed before any related_list_rule has been parsed.
	source := &ResolvedPackSource{
		Kind: PackSourceKindLocalPath,
		URI:  "/tmp/source-pack",
		Manifest: &PackManifest{
			ManifestFormatVersion: SupportedManifestFormatVersion,
			Name:                  "axis3-order-regression",
			DisplayName:           "Axis-3 Order Regression",
			Category:              AppPack,
			Version:               "0.1.0",
			PlatformCompatibility: PlatformCompatibility{Min: "0.1.0", MaxExclusive: "0.2.0"},
			Components: []ManifestComponent{
				{
					Kind:          ComponentKindPageLayout,
					BusinessKey:   "Campaign/default",
					Manageability: ManageabilityDefaultsOnly,
					Payload:       pageLayoutPayload,
				},
				{
					Kind:          ComponentKindRelatedListRule,
					BusinessKey:   "Campaign/CampaignMember",
					Manageability: ManageabilityDefaultsOnly,
					Payload:       relatedListRulePayload,
				},
			},
		},
	}

	pack, err := NewSourceBackedPack(source)
	if err != nil {
		t.Fatalf("NewSourceBackedPack: %v", err)
	}

	// Pin pack.relatedListRules — pre-collection must populate this slice
	// regardless of manifest order so newSourceBackedPageLayoutComponent
	// sees a non-empty rules slice at construction.
	if got := len(pack.relatedListRules); got != 1 {
		t.Fatalf("pack.relatedListRules len = %d, want 1 (pre-collection must run regardless of manifest order)", got)
	}
	rule := pack.relatedListRules[0]
	if rule.ParentObjectAPI != "Campaign" ||
		rule.ChildObjectAPI != "CampaignMember" ||
		rule.LookupField != "CampaignId" ||
		rule.Bucket != metadata.BucketRecommended ||
		len(rule.Fields) != 2 ||
		rule.Fields[0] != "Name" ||
		rule.Fields[1] != "Status" {
		t.Fatalf("pack.relatedListRules[0] = %+v, want Campaign/CampaignMember/CampaignId recommended [Name Status]", rule)
	}

	// And the page-layout component must exist on the pack — proving
	// buildComponents constructed it under this ordering.
	var sawPageLayout bool
	for _, component := range pack.Components() {
		if component.Kind() == ComponentKindPageLayout && component.BusinessKey() == "Campaign/default" {
			sawPageLayout = true
			break
		}
	}
	if !sawPageLayout {
		t.Fatal("page_layout Campaign/default component not found on constructed pack")
	}

	// Behavioral pin: drive verifyCuratedPageLayoutConfig — the function the
	// page-layout component's verify closure invokes under axis 3
	// (source_backed_metadata_components.go:820-824) — with the rules slice
	// the pack captured. Live Fields=[Name] vs rule Fields=[Name, Status]
	// must surface as curatedPageLayoutConfigDriftError. If the pre-pass
	// regressed and pack.relatedListRules were empty, axis-3
	// findRecommendedPackRule would return no match and the drift would be
	// silently missed — exactly the bug Ian called out.
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            sourceBackedLayoutID("Campaign", "Campaign Layout", true),
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
	}
	live := expected
	live.RelatedLists = []models.RelatedListConfig{
		{
			ID:            "CampaignMember-CampaignId",
			Label:         "Campaign Members",
			ObjectAPIName: "CampaignMember",
			LookupField:   "CampaignId",
			Fields:        []string{"Name"},
		},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(
		mockLayoutRow(sourceBackedLayoutID("Campaign", "Campaign Layout", true), marshalLayout(t, live), "curated", false),
	)

	verifyErr := verifyCuratedPageLayoutConfig(
		context.Background(),
		db,
		expected,
		nil, // payload-authored = nil → axis 3 fires
		pack.relatedListRules,
		"Campaign",
	)
	if verifyErr == nil {
		t.Fatal("verifyCuratedPageLayoutConfig with pack.relatedListRules returned nil; want axis-3 Fields drift error " +
			"(if this passes, pre-collection regressed — the closure would see an empty rules slice and silently pass)")
	}
	if !IsCuratedPageLayoutConfigDrift(verifyErr) {
		t.Fatalf("verify error = %v, want curatedPageLayoutConfigDriftError", verifyErr)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestBuildComponentsCapturesRulesAtPageLayoutConstructionTime is the
// stricter half of the regression: it asserts the *closure capture*, not
// just the post-loop pack state. Slices in Go are passed by header (ptr +
// len + cap); a closure that received an empty header at construction
// keeps that empty length forever, even after the surrounding scope grows
// the underlying slice. Pre-fix, that is exactly what happened — the
// page-layout closure was built with the empty p.relatedListRules slice,
// then the main switch later appended to p.relatedListRules (reassigning
// the field, not the captured local), so post-build inspection of
// pack.relatedListRules looked fine but axis 3 still silently passed.
//
// We pin closure capture by exposing pack.relatedListRules to the same
// concrete object that newSourceBackedPageLayoutComponent received under
// the bug-condition ordering, then verifying that object IS the populated
// slice — i.e. that pre-collection ran BEFORE the page-layout component
// was constructed. We do this by reconstructing the component independently
// with the SAME inputs and confirming the captured rules slice header
// matches.
func TestBuildComponentsCapturesRulesAtPageLayoutConstructionTime(t *testing.T) {
	// Construct two packs with identical content but opposite manifest
	// order. Under the fix, both must produce the same single rule in
	// pack.relatedListRules — proving the pre-pass ran independently of
	// declaration order.
	pageLayoutPayload := []byte(`{
		"objectApiName": "Campaign",
		"layoutName": "Campaign Layout",
		"sections": [{"id": "s1", "label": "Details", "fields": ["Name"]}]
	}`)
	relatedListRulePayload := []byte(`{
		"rules": [{
			"parentObjectApiName": "Campaign",
			"childObjectApiName": "CampaignMember",
			"lookupField": "CampaignId",
			"bucket": "recommended",
			"fields": ["Name", "Status"]
		}]
	}`)

	pageLayoutFirst := mustBuildPack(t, "page-first", []ManifestComponent{
		{Kind: ComponentKindPageLayout, BusinessKey: "Campaign/default", Manageability: ManageabilityDefaultsOnly, Payload: pageLayoutPayload},
		{Kind: ComponentKindRelatedListRule, BusinessKey: "Campaign/CampaignMember", Manageability: ManageabilityDefaultsOnly, Payload: relatedListRulePayload},
	})
	ruleFirst := mustBuildPack(t, "rule-first", []ManifestComponent{
		{Kind: ComponentKindRelatedListRule, BusinessKey: "Campaign/CampaignMember", Manageability: ManageabilityDefaultsOnly, Payload: relatedListRulePayload},
		{Kind: ComponentKindPageLayout, BusinessKey: "Campaign/default", Manageability: ManageabilityDefaultsOnly, Payload: pageLayoutPayload},
	})

	if got := len(pageLayoutFirst.relatedListRules); got != 1 {
		t.Fatalf("page-first pack.relatedListRules len = %d, want 1", got)
	}
	if got := len(ruleFirst.relatedListRules); got != 1 {
		t.Fatalf("rule-first pack.relatedListRules len = %d, want 1", got)
	}

	// Both orderings must yield the same rule identity and Fields.
	pa, pb := pageLayoutFirst.relatedListRules[0], ruleFirst.relatedListRules[0]
	if pa.ParentObjectAPI != pb.ParentObjectAPI ||
		pa.ChildObjectAPI != pb.ChildObjectAPI ||
		pa.LookupField != pb.LookupField ||
		len(pa.Fields) != len(pb.Fields) {
		t.Fatalf("rule identity diverges across orderings: page-first=%+v rule-first=%+v", pa, pb)
	}
	for i := range pa.Fields {
		if pa.Fields[i] != pb.Fields[i] {
			t.Fatalf("rule Fields diverge across orderings: page-first=%v rule-first=%v", pa.Fields, pb.Fields)
		}
	}
}

// TestBuildComponentsPageLayoutVerifyClosureCapturesPreCollectedRules
// invokes the page-layout component's verify CLOSURE end-to-end (not
// verifyCuratedPageLayoutConfig directly) under the bug-condition manifest
// ordering. This is the strictest form of the regression Ian requested:
// closure capture, not just post-loop pack state.
//
// Slices in Go pass by header (ptr+len+cap). A closure built with an empty
// header retains length 0 even if the surrounding scope later resizes the
// underlying buffer — that's the pre-fix bug pathway. By driving the
// closure itself, we observe the captured header directly.
//
// The closure first calls verifyCuratedPageLayout (state-machine check),
// which makes 7 SQL queries against the state observer. We mock all 7 to
// land on cellOKManagedSeedAtCurated (verify = vrOK), then mock the 8th
// query (LoadPageLayoutRow inside verifyCuratedPageLayoutConfig) so axis
// 1 / 2 pass and axis 3 evaluates. live.RelatedLists[0].Fields=[Name]
// diverges from the rule Fields=[Name, Status] — drift fires iff the
// closure captured a populated rules slice.
func TestBuildComponentsPageLayoutVerifyClosureCapturesPreCollectedRules(t *testing.T) {
	pageLayoutPayload := []byte(`{
		"objectApiName": "Campaign",
		"layoutName": "Campaign Layout",
		"sections": [{"id": "s1", "label": "Details", "fields": ["Name"]}]
	}`)
	relatedListRulePayload := []byte(`{
		"rules": [{
			"parentObjectApiName": "Campaign",
			"childObjectApiName": "CampaignMember",
			"lookupField": "CampaignId",
			"bucket": "recommended",
			"fields": ["Name", "Status"]
		}]
	}`)

	// Bug-condition ordering: page_layout BEFORE related_list_rule, no
	// explicit dependency.
	pack, err := NewSourceBackedPack(&ResolvedPackSource{
		Kind: PackSourceKindLocalPath,
		URI:  "/tmp/source-pack",
		Manifest: &PackManifest{
			ManifestFormatVersion: SupportedManifestFormatVersion,
			Name:                  "axis3-closure-capture",
			DisplayName:           "Axis-3 Closure Capture",
			Category:              AppPack,
			Version:               "0.1.0",
			PlatformCompatibility: PlatformCompatibility{Min: "0.1.0", MaxExclusive: "0.2.0"},
			Components: []ManifestComponent{
				{Kind: ComponentKindPageLayout, BusinessKey: "Campaign/default", Manageability: ManageabilityDefaultsOnly, Payload: pageLayoutPayload},
				{Kind: ComponentKindRelatedListRule, BusinessKey: "Campaign/CampaignMember", Manageability: ManageabilityDefaultsOnly, Payload: relatedListRulePayload},
			},
		},
	})
	if err != nil {
		t.Fatalf("NewSourceBackedPack: %v", err)
	}

	var pageLayout *sourceBackedManagedComponent
	for _, component := range pack.Components() {
		if component.Kind() != ComponentKindPageLayout {
			continue
		}
		managed, ok := component.(*sourceBackedManagedComponent)
		if !ok {
			t.Fatalf("page_layout component type = %T, want *sourceBackedManagedComponent", component)
		}
		pageLayout = managed
		break
	}
	if pageLayout == nil {
		t.Fatal("page_layout Campaign/default component not found on constructed pack")
	}

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

	curatedID := sourceBackedLayoutID("Campaign", "Campaign Layout", true)
	// live must match expected on every field axis-1 compares (everything
	// EXCEPT RelatedLists), or axis 1 fires drift before axis 3 is reached
	// and the test would short-circuit independent of the rules slice. The
	// page-layout component's expected layout is the parsed payload with
	// sourceBackedPageLayoutFromPayload's defaults: ID, Source="curated",
	// GenerationVersion/IsCustomized/LayoutVersion = 0/false/0.
	live := models.PageLayout{
		ID:                curatedID,
		ObjectAPIName:     "Campaign",
		LayoutName:        "Campaign Layout",
		Source:            curatedLayoutSource,
		GenerationVersion: 0,
		IsCustomized:      false,
		LayoutVersion:     0,
		Sections:          []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			// live Fields=[Name]; rule Fields=[Name,Status] → axis-3 drift.
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		},
	}

	managedSeed := models.LayoutAssignmentRouteSourceManagedSeed
	packName := "axis3-closure-capture"

	// verifySourceBackedDefaultPageLayout first loads the default row to enforce
	// same-pack ownership before entering the curated state machine.
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
			AddRow(curatedID, marshalLayout(t, live), "curated", 0, 1, packName))

	// observeCuratedLayoutState — 7 queries, in source order:
	// 1. CountLivePageLayoutsForObject
	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_PageLayout`).
		WillReturnRows(sqlmock.NewRows([]string{"c"}).AddRow(1))
	// 2. CountWildcardLayoutAssignments
	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_LayoutAssignment`).
		WillReturnRows(sqlmock.NewRows([]string{"c"}).AddRow(1))
	// 3. CountAdminOwnedWildcardLayoutAssignmentsForObject
	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_LayoutAssignment`).
		WillReturnRows(sqlmock.NewRows([]string{"c"}).AddRow(0))
	// 4. LoadWildcardLayoutTarget → target.LayoutID = curatedID, RouteSource = managed_seed
	mock.ExpectQuery(`SELECT la\.`).
		WillReturnRows(sqlmock.NewRows([]string{"AssignmentID", "LayoutID", "Source", "IsCustomized", "TargetPresent", "RouteSource"}).
			AddRow("la-campaign-wildcard", curatedID, "", 0, 1, managedSeed))
	// 5. LoadPageLayoutRow → row exists, !rowAdminOwned
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
			AddRow(curatedID, marshalLayout(t, live), "curated", 0, 1, packName))
	// 6. LoadAdminOwnedDefaultPageLayoutRowForObject → no rows
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE LOWER`).
		WillReturnError(sqlErrNoRows)
	// 7. CountAdminOwnedDefaultPageLayoutRowsForObject → 0
	mock.ExpectQuery(`SELECT COUNT\(\*\) FROM .` + "_System_PageLayout" + `.`).
		WillReturnRows(sqlmock.NewRows([]string{"c"}).AddRow(0))

	// After classification → vrOK → fall through to
	// verifyCuratedPageLayoutConfig → LoadPageLayoutRow (8th query).
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
			AddRow(curatedID, marshalLayout(t, live), "curated", 0, 1, packName))

	verifyErr := pageLayout.verify(context.Background(), VerifyDeps{DB: db})
	if verifyErr == nil {
		t.Fatal("page_layout verify closure returned nil; want axis-3 Fields drift error " +
			"(if this passes, the closure captured an empty rules slice — Ian's regression has returned)")
	}
	if !IsCuratedPageLayoutConfigDrift(verifyErr) {
		t.Fatalf("verify error = %v, want curatedPageLayoutConfigDriftError", verifyErr)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

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

	expected := models.PageLayout{
		ID:            sourceBackedLayoutID("Quote", "Quote Layout Expansion & Renewal", false),
		ObjectAPIName: "Quote",
		LayoutName:    "Quote Layout Expansion & Renewal",
		IsDefault:     false,
	}
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
			AddRow(expected.ID, marshalLayout(t, expected), "curated", 0, 0, ""))

	_, err = resolveSourceBackedManagedLiveRow(context.Background(), db, expected, "salesforce-crm-standard", "Quote")
	if err == nil {
		t.Fatal("resolveSourceBackedManagedLiveRow() error = nil, want ownerless direct-ID rejection")
	}
	if !strings.Contains(err.Error(), `OwnerPack = ""`) {
		t.Fatalf("error = %v, want empty OwnerPack rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

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

	expected := models.PageLayout{
		ID:            sourceBackedLayoutID("Quote", "Quote Layout", true),
		ObjectAPIName: "Quote",
		LayoutName:    "Quote Layout",
		IsDefault:     true,
	}
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
			AddRow(expected.ID, marshalLayout(t, expected), "curated", 0, 1, "other-pack"))

	err = verifySourceBackedDefaultPageLayout(context.Background(), db, expected, "salesforce-crm-standard", "Quote")
	if err == nil {
		t.Fatal("verifySourceBackedDefaultPageLayout() error = nil, want OwnerPack mismatch rejection")
	}
	if !strings.Contains(err.Error(), `OwnerPack = "other-pack"`) {
		t.Fatalf("error = %v, want different OwnerPack rejection", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func mustBuildPack(t *testing.T, name string, components []ManifestComponent) *SourceBackedPack {
	t.Helper()
	pack, err := NewSourceBackedPack(&ResolvedPackSource{
		Kind: PackSourceKindLocalPath,
		URI:  "/tmp/source-pack",
		Manifest: &PackManifest{
			ManifestFormatVersion: SupportedManifestFormatVersion,
			Name:                  name,
			DisplayName:           name,
			Category:              AppPack,
			Version:               "0.1.0",
			PlatformCompatibility: PlatformCompatibility{Min: "0.1.0", MaxExclusive: "0.2.0"},
			Components:            components,
		},
	})
	if err != nil {
		t.Fatalf("NewSourceBackedPack(%s): %v", name, err)
	}
	return pack
}
