package analytics

import (
	"context"
	"sort"
	"testing"

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

// ---------------------------------------------------------------------------
// Reparent-aware merged lockset parity tests
// ---------------------------------------------------------------------------

// TestProcessRollupsForUpdate_MergesOldAndNewParents verifies that a reparent
// update (child moves from P1 to P2) produces a merged affected set containing
// both P1 and P2, each exactly once.
func TestProcessRollupsForUpdate_MergesOldAndNewParents(t *testing.T) {
	const (
		parentObj   = "Parent"
		childObj    = "Child"
		rollupField = "TotalAmount"
		relField    = "ParentId"
		oldParentID = "p-001"
		newParentID = "p-002"
	)

	schemas := map[string]*models.ObjectMetadata{
		childObj: {APIName: childObj},
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{
					APIName: rollupField,
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "SUM",
						SummaryObject:     childObj,
						SummaryField:      "Amount",
						RelationshipField: relField,
					},
				},
			},
		},
	}

	meta := newFakeMetadata(schemas)
	repo := &stubRollupCalcRepo{calcValue: float64(100)}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	dw := &stubDerivedWriteCapture{}
	svc.SetDerivedWriter(dw)

	finalRecord := models.SObject{relField: newParentID, "Amount": float64(50)}
	oldRecord := models.SObject{relField: oldParentID, "Amount": float64(50)}

	if err := svc.ProcessRollupsForUpdate(context.Background(), childObj, finalRecord, oldRecord); err != nil {
		t.Fatalf("ProcessRollupsForUpdate error: %v", err)
	}

	// Both old and new parent must be updated exactly once.
	parentCounts := map[string]int{}
	for _, call := range dw.calls {
		parentCounts[call.recordID]++
	}
	if parentCounts[oldParentID] != 1 {
		t.Errorf("old parent %s updated %d times, want 1", oldParentID, parentCounts[oldParentID])
	}
	if parentCounts[newParentID] != 1 {
		t.Errorf("new parent %s updated %d times, want 1", newParentID, parentCounts[newParentID])
	}
	if len(parentCounts) != 2 {
		t.Errorf("expected exactly 2 distinct parents updated, got %d: %v", len(parentCounts), parentCounts)
	}
}

// TestProcessRollupsForUpdate_DedupesSameParent verifies that when the parent
// field does NOT change (no reparent), the parent is processed exactly once
// rather than twice.
func TestProcessRollupsForUpdate_DedupesSameParent(t *testing.T) {
	const (
		parentObj   = "Parent"
		childObj    = "Child"
		rollupField = "TotalAmount"
		relField    = "ParentId"
		parentID    = "p-001"
	)

	schemas := map[string]*models.ObjectMetadata{
		childObj: {APIName: childObj},
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{
					APIName: rollupField,
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "SUM",
						SummaryObject:     childObj,
						SummaryField:      "Amount",
						RelationshipField: relField,
					},
				},
			},
		},
	}

	meta := newFakeMetadata(schemas)
	repo := &stubRollupCalcRepo{calcValue: float64(100)}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	dw := &stubDerivedWriteCapture{}
	svc.SetDerivedWriter(dw)

	// Same parent in both final and old — no reparent.
	finalRecord := models.SObject{relField: parentID, "Amount": float64(50)}
	oldRecord := models.SObject{relField: parentID, "Amount": float64(30)}

	if err := svc.ProcessRollupsForUpdate(context.Background(), childObj, finalRecord, oldRecord); err != nil {
		t.Fatalf("ProcessRollupsForUpdate error: %v", err)
	}

	// Parent must be updated exactly once, not twice.
	parentCounts := map[string]int{}
	for _, call := range dw.calls {
		parentCounts[call.recordID]++
	}
	if parentCounts[parentID] != 1 {
		t.Errorf("parent %s updated %d times, want 1 (deduped)", parentID, parentCounts[parentID])
	}
}

// TestProcessRollupsForUpdate_DeterministicLockOrder verifies that the merged
// affected set is processed in a deterministic sorted order by
// (parentObj, parentID, rollupField), preventing deadlocks when concurrent
// transactions reparent in opposite directions.
func TestProcessRollupsForUpdate_DeterministicLockOrder(t *testing.T) {
	const (
		parentObj   = "Parent"
		childObj    = "Child"
		rollupField = "TotalAmount"
		relField    = "ParentId"
	)

	schemas := map[string]*models.ObjectMetadata{
		childObj: {APIName: childObj},
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{
					APIName: rollupField,
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "SUM",
						SummaryObject:     childObj,
						SummaryField:      "Amount",
						RelationshipField: relField,
					},
				},
			},
		},
	}

	meta := newFakeMetadata(schemas)
	repo := &stubRollupCalcRepo{calcValue: float64(100)}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	dw := &stubDerivedWriteCapture{}
	svc.SetDerivedWriter(dw)

	// Reparent from P2 to P1 (opposite direction from the first test).
	finalRecord := models.SObject{relField: "p-001", "Amount": float64(50)}
	oldRecord := models.SObject{relField: "p-002", "Amount": float64(50)}

	if err := svc.ProcessRollupsForUpdate(context.Background(), childObj, finalRecord, oldRecord); err != nil {
		t.Fatalf("ProcessRollupsForUpdate error: %v", err)
	}

	// Regardless of reparent direction, parents must be processed in sorted
	// order: p-001 before p-002.
	if len(dw.calls) != 2 {
		t.Fatalf("expected 2 UpdateDerived calls, got %d", len(dw.calls))
	}
	if dw.calls[0].recordID > dw.calls[1].recordID {
		t.Errorf("nondeterministic lock order: first=%s, second=%s; want sorted ascending",
			dw.calls[0].recordID, dw.calls[1].recordID)
	}
}

// TestProcessRollupsForUpdate_CycleGuardAcrossReparent verifies that the
// visited-key cycle guard works correctly across the merged lockset boundary.
// If both images produce the same affected rollup key for a relevant update,
// the deduplication must prevent double-processing, and the visited-key guard
// must still catch genuine cycles.
func TestProcessRollupsForUpdate_CycleGuardAcrossReparent(t *testing.T) {
	const (
		parentObj   = "CycleParent"
		childObj    = "CycleChild"
		rollupField = "CycleRollup"
		relField    = "ParentId"
		parentID    = "cp-001"
	)

	schemas := map[string]*models.ObjectMetadata{
		childObj: {APIName: childObj},
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{
					APIName: rollupField,
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "SUM",
						SummaryObject:     childObj,
						SummaryField:      "Amount",
						RelationshipField: relField,
					},
				},
			},
		},
	}

	meta := newFakeMetadata(schemas)
	repo := &stubRollupCalcRepo{calcValue: float64(1)}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	dw := &stubDerivedWriteCapture{}
	svc.SetDerivedWriter(dw)

	// Same parent in both images with a source-field change — deduped to
	// single processing.
	finalRecord := models.SObject{relField: parentID, "Amount": float64(2)}
	oldRecord := models.SObject{relField: parentID, "Amount": float64(1)}

	if err := svc.ProcessRollupsForUpdate(context.Background(), childObj, finalRecord, oldRecord); err != nil {
		t.Fatalf("ProcessRollupsForUpdate error: %v", err)
	}

	// Parent processed exactly once (deduped), not twice.
	if len(dw.calls) != 1 {
		t.Errorf("expected 1 UpdateDerived call (deduped same parent), got %d", len(dw.calls))
	}
}

// TestProcessRollupsForUpdate_MultipleRollupFieldsOnSameParent verifies that
// when a parent has multiple rollup fields, they are merged into a single
// derived parent update per parent even when the child is reparented.
func TestProcessRollupsForUpdate_MultipleRollupFieldsOnSameParent(t *testing.T) {
	const (
		parentObj = "Parent"
		childObj  = "Child"
		relField  = "ParentId"
		oldPID    = "p-old"
		newPID    = "p-new"
	)

	schemas := map[string]*models.ObjectMetadata{
		childObj: {APIName: childObj},
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{
					APIName: "TotalAmount",
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "SUM",
						SummaryObject:     childObj,
						SummaryField:      "Amount",
						RelationshipField: relField,
					},
				},
				{
					APIName: "TotalCount",
					Type:    constants.FieldTypeRollupSummary,
					RollupConfig: &models.RollupConfig{
						CalcType:          "COUNT",
						SummaryObject:     childObj,
						SummaryField:      "Id",
						RelationshipField: relField,
					},
				},
			},
		},
	}

	meta := newFakeMetadata(schemas)
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	dw := &stubDerivedWriteCapture{}
	svc.SetDerivedWriter(dw)

	finalRecord := models.SObject{relField: newPID, "Amount": float64(50)}
	oldRecord := models.SObject{relField: oldPID, "Amount": float64(30)}

	if err := svc.ProcessRollupsForUpdate(context.Background(), childObj, finalRecord, oldRecord); err != nil {
		t.Fatalf("ProcessRollupsForUpdate error: %v", err)
	}

	// Each parent should get exactly 1 UpdateDerived call with both rollup fields.
	parentCalls := map[string]capturedDerivedWrite{}
	for _, call := range dw.calls {
		parentCalls[call.recordID] = call
	}
	if got := len(dw.calls); got != 2 {
		t.Fatalf("UpdateDerived calls = %d, want 2 (one merged update per parent)", got)
	}
	if call, ok := parentCalls[oldPID]; !ok {
		t.Fatalf("old parent %s: missing merged UpdateDerived call", oldPID)
	} else if len(call.updates) != 2 || call.updates["TotalAmount"] == nil || call.updates["TotalCount"] == nil {
		t.Errorf("old parent %s updates = %#v, want merged TotalAmount + TotalCount", oldPID, call.updates)
	}
	if call, ok := parentCalls[newPID]; !ok {
		t.Fatalf("new parent %s: missing merged UpdateDerived call", newPID)
	} else if len(call.updates) != 2 || call.updates["TotalAmount"] == nil || call.updates["TotalCount"] == nil {
		t.Errorf("new parent %s updates = %#v, want merged TotalAmount + TotalCount", newPID, call.updates)
	}
}

// TestProcessRollupsForUpdate_SortStability verifies the sort key for
// AffectedRollup items is (parentObj, parentID, rollupField) and produces
// stable ordering even with different object types.
func TestProcessRollupsForUpdate_SortStability(t *testing.T) {
	items := []AffectedRollup{
		{ParentObjName: "B", ParentID: "2", RollupField: models.FieldMetadata{APIName: "X"}},
		{ParentObjName: "A", ParentID: "1", RollupField: models.FieldMetadata{APIName: "Y"}},
		{ParentObjName: "A", ParentID: "1", RollupField: models.FieldMetadata{APIName: "X"}},
		{ParentObjName: "A", ParentID: "2", RollupField: models.FieldMetadata{APIName: "X"}},
	}

	sort.Sort(affectedRollupByParentKey(items))

	want := []AffectedRollup{
		{ParentObjName: "A", ParentID: "1", RollupField: models.FieldMetadata{APIName: "X"}},
		{ParentObjName: "A", ParentID: "1", RollupField: models.FieldMetadata{APIName: "Y"}},
		{ParentObjName: "A", ParentID: "2", RollupField: models.FieldMetadata{APIName: "X"}},
		{ParentObjName: "B", ParentID: "2", RollupField: models.FieldMetadata{APIName: "X"}},
	}

	for i, got := range items {
		if got.ParentObjName != want[i].ParentObjName ||
			got.ParentID != want[i].ParentID ||
			got.RollupField.APIName != want[i].RollupField.APIName {
			t.Errorf("item[%d] = (%s,%s,%s), want (%s,%s,%s)",
				i,
				got.ParentObjName, got.ParentID, got.RollupField.APIName,
				want[i].ParentObjName, want[i].ParentID, want[i].RollupField.APIName)
		}
	}
}
