package analytics

import (
	"context"
	"testing"

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

// ---------------------------------------------------------------------------
// ProcessBulkRollups parity tests (issue #1193)
// ---------------------------------------------------------------------------

// TestProcessBulkRollups_UsesFindAffectedRollups verifies that
// ProcessBulkRollups delegates tuple discovery to FindAffectedRollups
// (the single SSoT for affected rollup tuples), not to reference-field
// scanning. This is the core parity test: the bulk path must use the
// same discovery mechanism as the single-record path.
func TestProcessBulkRollups_UsesFindAffectedRollups(t *testing.T) {
	const (
		parentObj   = "Project"
		parentID    = "proj-001"
		childObj    = "Task"
		rollupField = "TotalBudget"
		relField    = "ProjectId"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    rollupField,
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	dw := &stubDerivedWriteCapture{}

	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	childRecords := []models.SObject{
		{relField: parentID, "Budget": float64(10)},
		{relField: parentID, "Budget": float64(20)},
	}
	landedRows := []bool{true, true}

	if err := svc.ProcessBulkRollups(context.Background(), childObj, childRecords, landedRows); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	// With deduplication by (parentObj, parentID, rollupField), both children
	// under the same parent produce exactly 1 UpdateDerived call (one
	// recompute covers all children).
	if len(dw.calls) != 1 {
		t.Fatalf("expected 1 UpdateDerived call (deduplicated), got %d", len(dw.calls))
	}
	if dw.calls[0].objectName != parentObj {
		t.Errorf("objectName = %q, want %q", dw.calls[0].objectName, parentObj)
	}
	if dw.calls[0].recordID != parentID {
		t.Errorf("recordID = %q, want %q", dw.calls[0].recordID, parentID)
	}
}

// TestProcessBulkRollups_DeduplicatesExactTuples verifies that the dedup
// key is (parentObj, parentID, rollupField), not just (parentObj, parentID).
// Multiple rollup fields on the same parent must still each be recalculated,
// but they should merge into one parent update.
func TestProcessBulkRollups_DeduplicatesExactTuples(t *testing.T) {
	const (
		parentObj = "Project"
		parentID  = "proj-001"
		childObj  = "Task"
		relField  = "ProjectId"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    "TotalBudget",
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			},
			{
				APIName:    "TaskCount",
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "COUNT",
					SummaryObject:     childObj,
					SummaryField:      "Id",
					RelationshipField: relField,
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	dw := &stubDerivedWriteCapture{}

	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	// 3 children under same parent — with 2 rollup fields, we expect
	// exactly 1 merged UpdateDerived call covering both fields, not 2 or 6.
	childRecords := []models.SObject{
		{relField: parentID, "Budget": float64(10)},
		{relField: parentID, "Budget": float64(20)},
		{relField: parentID, "Budget": float64(30)},
	}
	landedRows := []bool{true, true, true}

	if err := svc.ProcessBulkRollups(context.Background(), childObj, childRecords, landedRows); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	if len(dw.calls) != 1 {
		t.Fatalf("expected 1 merged UpdateDerived call (both rollup fields deduped across children), got %d", len(dw.calls))
	}

	// Verify the merged update covers both rollup fields.
	fieldsUpdated := dw.calls[0].updates
	if fieldsUpdated["TotalBudget"] == nil {
		t.Error("missing TotalBudget in merged UpdateDerived payload")
	}
	if fieldsUpdated["TaskCount"] == nil {
		t.Error("missing TaskCount in merged UpdateDerived payload")
	}
}

// TestProcessBulkRollups_SkipsNonLandedRows verifies that INSERT IGNORE
// rows (landed=false) do not contribute to rollup fanout. Only truly
// inserted rows should trigger parent rollup updates.
func TestProcessBulkRollups_SkipsNonLandedRows(t *testing.T) {
	const (
		parentObj   = "Project"
		parentID1   = "proj-001"
		parentID2   = "proj-002"
		childObj    = "Task"
		rollupField = "TotalBudget"
		relField    = "ProjectId"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    rollupField,
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	dw := &stubDerivedWriteCapture{}

	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	childRecords := []models.SObject{
		{relField: parentID1, "Budget": float64(10)}, // landed
		{relField: parentID1, "Budget": float64(20)}, // NOT landed (INSERT IGNORE)
		{relField: parentID2, "Budget": float64(30)}, // landed
	}
	landedRows := []bool{true, false, true}

	if err := svc.ProcessBulkRollups(context.Background(), childObj, childRecords, landedRows); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	// Only 2 unique parents from landed rows: proj-001 and proj-002.
	// The non-landed row (proj-001) must not add a duplicate.
	if len(dw.calls) != 2 {
		t.Fatalf("expected 2 UpdateDerived calls (only landed rows), got %d", len(dw.calls))
	}

	parentIDs := map[string]bool{}
	for _, call := range dw.calls {
		parentIDs[call.recordID] = true
	}
	if !parentIDs[parentID1] {
		t.Error("missing rollup call for proj-001 (landed row)")
	}
	if !parentIDs[parentID2] {
		t.Error("missing rollup call for proj-002 (landed row)")
	}
}

// TestProcessBulkRollups_MultipleRelationshipFieldsToSameParent verifies
// that when a child has multiple relationship fields pointing to the same
// parent object (each feeding a different rollup field), all affected
// rollup tuples are discovered and processed.
func TestProcessBulkRollups_MultipleRelationshipFieldsToSameParent(t *testing.T) {
	const (
		parentObj = "Project"
		parentID  = "proj-001"
		childObj  = "Task"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    "TotalBudget",
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: "ProjectId",
				},
			},
			{
				APIName:    "SponsorCount",
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "COUNT",
					SummaryObject:     childObj,
					SummaryField:      "Id",
					RelationshipField: "SponsorId",
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	dw := &stubDerivedWriteCapture{}

	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	// One child with two relationship fields both pointing to the same parent.
	childRecords := []models.SObject{
		{"ProjectId": parentID, "SponsorId": parentID, "Budget": float64(10)},
	}
	landedRows := []bool{true}

	if err := svc.ProcessBulkRollups(context.Background(), childObj, childRecords, landedRows); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	// Two different rollup fields on the same parent, both affected by this
	// child record via different relationship fields. They should merge into
	// one parent update.
	if len(dw.calls) != 1 {
		t.Fatalf("expected 1 merged UpdateDerived call (both rollup fields via different rel fields), got %d", len(dw.calls))
	}
	if updates := dw.calls[0].updates; updates["TotalBudget"] == nil || updates["SponsorCount"] == nil {
		t.Fatalf("merged updates = %#v, want TotalBudget + SponsorCount", updates)
	}
}

// TestProcessBulkRollups_SharesProcessAffectedRollups verifies that
// ProcessBulkRollups produces the same UpdateDerived calls as calling
// ProcessRollups individually for each unique affected tuple. This
// confirms the shared processAffectedRollups path.
func TestProcessBulkRollups_SharesProcessAffectedRollups(t *testing.T) {
	const (
		parentObj   = "Project"
		parentID    = "proj-001"
		childObj    = "Task"
		rollupField = "TotalBudget"
		relField    = "ProjectId"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    rollupField,
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}

	// Call ProcessRollups (single-record path).
	dwSingle := &stubDerivedWriteCapture{}
	svcSingle := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svcSingle.SetDerivedWriter(dwSingle)
	childRecord := models.SObject{relField: parentID, "Budget": float64(10)}
	if err := svcSingle.ProcessRollups(context.Background(), childObj, childRecord); err != nil {
		t.Fatalf("ProcessRollups error: %v", err)
	}

	// Call ProcessBulkRollups (bulk path) with the same child record.
	dwBulk := &stubDerivedWriteCapture{}
	svcBulk := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svcBulk.SetDerivedWriter(dwBulk)
	if err := svcBulk.ProcessBulkRollups(context.Background(), childObj, []models.SObject{childRecord}, []bool{true}); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	// Both paths must produce identical UpdateDerived calls.
	if len(dwSingle.calls) != len(dwBulk.calls) {
		t.Fatalf("ProcessRollups produced %d calls, ProcessBulkRollups produced %d; want same", len(dwSingle.calls), len(dwBulk.calls))
	}
	for i, singleCall := range dwSingle.calls {
		bulkCall := dwBulk.calls[i]
		if singleCall.objectName != bulkCall.objectName {
			t.Errorf("call %d: objectName single=%q bulk=%q", i, singleCall.objectName, bulkCall.objectName)
		}
		if singleCall.recordID != bulkCall.recordID {
			t.Errorf("call %d: recordID single=%q bulk=%q", i, singleCall.recordID, bulkCall.recordID)
		}
	}
}

// TestProcessBulkRollups_EmptyInput verifies that ProcessBulkRollups
// returns nil error for empty input without calling UpdateDerived.
func TestProcessBulkRollups_EmptyInput(t *testing.T) {
	meta := newFakeMetadata(nil)
	repo := &stubRollupCalcRepo{}
	dw := &stubDerivedWriteCapture{}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	if err := svc.ProcessBulkRollups(context.Background(), "Task", nil, nil); err != nil {
		t.Fatalf("ProcessBulkRollups empty input: %v", err)
	}
	if len(dw.calls) != 0 {
		t.Fatalf("expected 0 UpdateDerived calls for empty input, got %d", len(dw.calls))
	}
}

// TestProcessBulkRollups_AllNonLanded verifies that when all rows are
// non-landed (all INSERT IGNORE), no rollup processing occurs.
func TestProcessBulkRollups_AllNonLanded(t *testing.T) {
	const (
		parentObj   = "Project"
		parentID    = "proj-001"
		childObj    = "Task"
		rollupField = "TotalBudget"
		relField    = "ProjectId"
	)

	parentSchema := &models.ObjectMetadata{
		APIName: parentObj,
		Fields: []models.FieldMetadata{
			{
				APIName:    rollupField,
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			},
		},
	}

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{parentObj: parentSchema})
	repo := &stubRollupCalcRepo{calcValue: float64(42)}
	dw := &stubDerivedWriteCapture{}
	svc := NewRollupService(repo, meta, alwaysPessimisticRollupTxManager{}, noopLogger())
	svc.SetDerivedWriter(dw)

	childRecords := []models.SObject{
		{relField: parentID, "Budget": float64(10)},
	}
	landedRows := []bool{false} // all non-landed

	if err := svc.ProcessBulkRollups(context.Background(), childObj, childRecords, landedRows); err != nil {
		t.Fatalf("ProcessBulkRollups error: %v", err)
	}

	if len(dw.calls) != 0 {
		t.Fatalf("expected 0 UpdateDerived calls when all rows non-landed, got %d", len(dw.calls))
	}
}
