package analytics

import (
	"context"
	"fmt"
	"testing"
	"time"

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

type parentIDPageCall struct {
	afterID string
	limit   int
}

// recordingParentIDRepo records what the recalculation loop actually asked the
// repository for, so a test can prove the resume cursor and the count bound
// reach the keyset read instead of only shaping the returned progress.
type recordingParentIDRepo struct {
	*stubRollupCalcRepo
	pageCalls []parentIDPageCall
}

func (r *recordingParentIDRepo) GetParentIDsPage(ctx context.Context, objectName string, afterID string, limit int) ([]string, error) {
	r.pageCalls = append(r.pageCalls, parentIDPageCall{afterID: afterID, limit: limit})
	return r.stubRollupCalcRepo.GetParentIDsPage(ctx, objectName, afterID, limit)
}

// workClock advances by a fixed cost per parent actually written, not per clock
// read. Tying it to committed work keeps the "stops after N parents" assertions
// meaningful under refactors that add or remove clock reads, which a
// read-counting clock would silently turn into a different test.
type workClock struct {
	base      time.Time
	perParent time.Duration
	written   func() int
}

func (c *workClock) now() time.Time {
	return c.base.Add(c.perParent * time.Duration(c.written()))
}

func boundedRecalcParentIDs(n int) []string {
	ids := make([]string, 0, n)
	for i := 0; i < n; i++ {
		ids = append(ids, fmt.Sprintf("proj-%03d", i+1))
	}
	return ids
}

// newBoundedRecalcFixture builds a one-rollup-field parent object whose parents
// all recalculate successfully, so a test observes only bound behavior.
func newBoundedRecalcFixture(parentIDs []string) (*RollupService, *recordingParentIDRepo, *recalculateDerivedWriter) {
	const (
		parentObj = "Project"
		childObj  = "Task"
		relField  = "ProjectId"
	)

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{{
				APIName:    "TotalBudget",
				Type:       constants.FieldTypeRollupSummary,
				Updateable: false,
				RollupConfig: &models.RollupConfig{
					CalcType:          "SUM",
					SummaryObject:     childObj,
					SummaryField:      "Budget",
					RelationshipField: relField,
				},
			}},
		},
		childObj: {
			APIName: childObj,
			Fields: []models.FieldMetadata{
				{APIName: relField, Type: constants.FieldTypeReference},
				{APIName: "Budget", Type: constants.FieldTypeCurrency},
			},
		},
	})

	repo := &recordingParentIDRepo{
		stubRollupCalcRepo: &stubRollupCalcRepo{
			calcValue: float64(42),
			parentIDs: parentIDs,
		},
	}
	txm := &recordingRecalculateTxManager{}
	dw := &recalculateDerivedWriter{
		stubDerivedWriteCapture: &stubDerivedWriteCapture{},
		txManager:               txm,
	}
	svc := NewRollupService(repo, meta, txm, noopLogger())
	svc.SetDerivedWriter(dw)
	return svc, repo, dw
}

func writtenParentIDs(dw *recalculateDerivedWriter) []string {
	ids := make([]string, 0, len(dw.calls))
	for _, call := range dw.calls {
		ids = append(ids, call.recordID)
	}
	return ids
}

func assertIDs(t *testing.T, got []string, want []string, what string) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("%s = %v, want %v", what, got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("%s[%d] = %q, want %q (full=%v)", what, i, got[i], want[i], got)
		}
	}
}

// TestRecalculateAllWalksWholeObjectAcrossBoundedResumedPasses is the unit-level
// form of the acceptance requirement: no single pass may exceed the bound, and
// looping on the returned cursor must still visit every parent exactly once and
// terminate on Complete rather than on an empty cursor.
func TestRecalculateAllWalksWholeObjectAcrossBoundedResumedPasses(t *testing.T) {
	const parentObj = "Project"
	ids := boundedRecalcParentIDs(5)

	var visited []string
	var passes int
	bounds := ports.RollupRecalculateBounds{MaxParents: 2}

	for {
		svc, repo, dw := newBoundedRecalcFixture(ids)
		progress, err := svc.RecalculateAll(context.Background(), parentObj, bounds)
		if err != nil {
			t.Fatalf("pass %d: RecalculateAll: %v", passes+1, err)
		}
		if progress.Recalculated > bounds.MaxParents {
			t.Fatalf("pass %d recalculated %d parents, want at most the %d bound",
				passes+1, progress.Recalculated, bounds.MaxParents)
		}
		if len(repo.pageCalls) == 0 {
			t.Fatalf("pass %d made no keyset read", passes+1)
		}
		if got := repo.pageCalls[0].afterID; got != bounds.AfterID {
			t.Fatalf("pass %d first keyset read afterID = %q, want the supplied cursor %q",
				passes+1, got, bounds.AfterID)
		}
		if got := repo.pageCalls[0].limit; got > bounds.MaxParents {
			t.Fatalf("pass %d first keyset read limit = %d, want it clamped to the %d bound",
				passes+1, got, bounds.MaxParents)
		}

		visited = append(visited, writtenParentIDs(dw)...)
		passes++
		if progress.Complete {
			break
		}
		if progress.NextAfterID == "" {
			t.Fatalf("pass %d returned Complete=false with no cursor to resume from", passes)
		}
		bounds.AfterID = progress.NextAfterID
		if passes > len(ids)+1 {
			t.Fatalf("cursor loop did not converge after %d passes; visited=%v", passes, visited)
		}
	}

	assertIDs(t, visited, ids, "parents visited across the cursor loop")
	if passes != 3 {
		t.Fatalf("passes = %d, want 3 (2 + 2 + 1 under a bound of 2 over 5 parents)", passes)
	}
}

// TestRecalculateAllStopsWhenTimeBudgetIsExhausted pins the deadline guard.
// Each committed parent costs 20s against a 45s window, so the check after the
// third parent (t=60s) is the first to reach the deadline: three parents
// committed, two left behind a cursor.
func TestRecalculateAllStopsWhenTimeBudgetIsExhausted(t *testing.T) {
	const parentObj = "Project"
	ids := boundedRecalcParentIDs(5)

	svc, _, dw := newBoundedRecalcFixture(ids)
	base := time.Unix(0, 0).UTC()
	svc.now = (&workClock{base: base, perParent: 20 * time.Second, written: func() int { return len(dw.calls) }}).now

	progress, err := svc.RecalculateAll(context.Background(), parentObj, ports.RollupRecalculateBounds{
		Deadline: base.Add(45 * time.Second),
	})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if progress.Recalculated != 3 {
		t.Fatalf("recalculated = %d, want 3 before the 45s budget is reached", progress.Recalculated)
	}
	if progress.Complete {
		t.Fatal("Complete = true, want false: two parents were never visited")
	}
	if progress.NextAfterID != "proj-003" {
		t.Fatalf("NextAfterID = %q, want %q (the last committed parent)", progress.NextAfterID, "proj-003")
	}
	assertIDs(t, writtenParentIDs(dw), ids[:3], "parents written before the budget fired")
}

// TestRecalculateAllReportsCompleteWhenBoundFiresOnFinalParent covers the
// complementary branch: a bound that fires on the object's last parent has left
// nothing behind, so the pass must report Complete rather than hand back a
// cursor that would only yield an empty page.
func TestRecalculateAllReportsCompleteWhenBoundFiresOnFinalParent(t *testing.T) {
	const parentObj = "Project"
	ids := boundedRecalcParentIDs(3)

	svc, _, dw := newBoundedRecalcFixture(ids)
	base := time.Unix(0, 0).UTC()
	svc.now = (&workClock{base: base, perParent: 20 * time.Second, written: func() int { return len(dw.calls) }}).now

	progress, err := svc.RecalculateAll(context.Background(), parentObj, ports.RollupRecalculateBounds{
		Deadline: base.Add(45 * time.Second),
	})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if progress.Recalculated != 3 {
		t.Fatalf("recalculated = %d, want all 3 parents", progress.Recalculated)
	}
	if !progress.Complete {
		t.Fatal("Complete = false, want true: the budget fired only after the last parent")
	}
	if progress.NextAfterID != "" {
		t.Fatalf("NextAfterID = %q on a complete scan; a driver looping on a non-empty cursor would never stop",
			progress.NextAfterID)
	}
	assertIDs(t, writtenParentIDs(dw), ids, "parents written")
}

// TestRecalculateAllWithoutBoundsWalksEverythingAndReportsComplete keeps the
// zero-value contract honest: callers that are not serving a request still get
// the original walk-everything behavior, now with an explicit terminal signal.
func TestRecalculateAllWithoutBoundsWalksEverythingAndReportsComplete(t *testing.T) {
	const parentObj = "Project"
	ids := boundedRecalcParentIDs(5)

	svc, repo, dw := newBoundedRecalcFixture(ids)
	base := time.Unix(0, 0).UTC()
	svc.now = (&workClock{base: base, perParent: time.Hour, written: func() int { return len(dw.calls) }}).now

	progress, err := svc.RecalculateAll(context.Background(), parentObj, ports.RollupRecalculateBounds{})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if progress.Recalculated != len(ids) {
		t.Fatalf("recalculated = %d, want %d: a zero-value bounds must not bound anything",
			progress.Recalculated, len(ids))
	}
	if !progress.Complete {
		t.Fatal("Complete = false, want true for an unbounded pass over a fully read object")
	}
	if progress.NextAfterID != "" {
		t.Fatalf("NextAfterID = %q on a complete scan, want empty", progress.NextAfterID)
	}
	if got := repo.pageCalls[0].limit; got != rollupRecalculatePageSize {
		t.Fatalf("keyset read limit = %d, want the full page size %d when no count bound is set",
			got, rollupRecalculatePageSize)
	}
	assertIDs(t, writtenParentIDs(dw), ids, "parents written")
}

// TestRecalculateAllReportsCompleteOnEmptyObject proves the terminal signal does
// not depend on having recalculated anything — the case that makes an empty
// cursor unusable as "done".
func TestRecalculateAllReportsCompleteOnEmptyObject(t *testing.T) {
	svc, _, _ := newBoundedRecalcFixture(nil)

	progress, err := svc.RecalculateAll(context.Background(), "Project", ports.RollupRecalculateBounds{MaxParents: 2})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if progress.Recalculated != 0 || progress.NextAfterID != "" {
		t.Fatalf("progress = %+v, want no work and no cursor", progress)
	}
	if !progress.Complete {
		t.Fatal("Complete = false on an empty object; an empty cursor alone cannot tell a driver to stop")
	}
}

// TestRecalculateAllReportsCompleteOnObjectWithNoRollupFields covers the other
// early return that must still terminate a driver. An object with no rollup
// summary fields has nothing to do, and a driver told to branch on Complete
// would re-request forever if this path returned the zero value.
func TestRecalculateAllReportsCompleteOnObjectWithNoRollupFields(t *testing.T) {
	const parentObj = "Project"

	meta := newFakeMetadata(map[string]*models.ObjectMetadata{
		parentObj: {
			APIName: parentObj,
			Fields: []models.FieldMetadata{
				{APIName: "Name", Type: constants.FieldTypeString},
			},
		},
	})
	repo := &recordingParentIDRepo{
		stubRollupCalcRepo: &stubRollupCalcRepo{parentIDs: boundedRecalcParentIDs(3)},
	}
	txm := &recordingRecalculateTxManager{}
	svc := NewRollupService(repo, meta, txm, noopLogger())
	svc.SetDerivedWriter(&recalculateDerivedWriter{
		stubDerivedWriteCapture: &stubDerivedWriteCapture{},
		txManager:               txm,
	})

	progress, err := svc.RecalculateAll(context.Background(), parentObj, ports.RollupRecalculateBounds{})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if progress.Recalculated != 0 || progress.NextAfterID != "" {
		t.Fatalf("progress = %+v, want no work and no cursor", progress)
	}
	if !progress.Complete {
		t.Fatal("Complete = false for an object with no rollup fields; a driver branching on Complete would never stop")
	}
	if len(repo.pageCalls) != 0 {
		t.Fatalf("keyset reads = %d, want 0 when there is nothing to recalculate", len(repo.pageCalls))
	}
}

// TestRecalculateAllResumesFromACursorNamingNoExistingParent pins the keyset
// semantics the resume contract depends on: the cursor is a key comparison, not
// a lookup, so a cursor whose parent was deleted mid-scan continues from that
// key instead of silently restarting the object.
func TestRecalculateAllResumesFromACursorNamingNoExistingParent(t *testing.T) {
	const parentObj = "Project"
	ids := []string{"proj-001", "proj-003"}

	svc, _, dw := newBoundedRecalcFixture(ids)
	progress, err := svc.RecalculateAll(context.Background(), parentObj, ports.RollupRecalculateBounds{
		AfterID: "proj-002",
	})
	if err != nil {
		t.Fatalf("RecalculateAll: %v", err)
	}
	if !progress.Complete {
		t.Fatalf("Complete = false, want true; progress = %+v", progress)
	}
	assertIDs(t, writtenParentIDs(dw), []string{"proj-003"}, "parents written after a vanished cursor")
}
