package prepo

import (
	"bytes"
	"context"
	"errors"
	"sort"
	"testing"
	"time"

	"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
	"github.com/tech/sendico/payments/storage/model"
	"github.com/tech/sendico/pkg/db/storable"
	"github.com/tech/sendico/pkg/merrors"
	pm "github.com/tech/sendico/pkg/model"
	paymenttypes "github.com/tech/sendico/pkg/payments/types"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"go.uber.org/zap/zaptest/observer"
)

func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) {
	store := newFakeStore()
	_, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}

	if len(store.indexes) != 6 {
		t.Fatalf("index count mismatch: got=%d want=6", len(store.indexes))
	}

	assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true)
	assertIndex(t, store.indexes[1], []string{"paymentRef"}, true)
	assertIndex(t, store.indexes[2], []string{"organizationRef", "idempotencyKey"}, true)
	assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false)
	assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false)
	assertIndex(t, store.indexes[5], []string{"state", "createdAt"}, false)
}

func TestCreateAndGet(t *testing.T) {
	now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
	store := newFakeStore()
	repo, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}
	repo.(*svc).now = func() time.Time { return now }

	org := bson.NewObjectID()
	payment := &agg.Payment{
		OrganizationBoundBase: modelOrg(org),
		IdempotencyKey:        "idem-1",
		QuotationRef:          "quote-1",
		State:                 agg.StateCreated,
		IntentSnapshot:        model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
		QuoteSnapshot:         &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
		StepExecutions: []agg.StepExecution{
			{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
		},
	}

	if err := repo.Create(context.Background(), payment); err != nil {
		t.Fatalf("Create returned error: %v", err)
	}
	if payment.ID.IsZero() {
		t.Fatal("expected generated id")
	}
	if payment.PaymentRef == "" {
		t.Fatal("expected generated payment_ref")
	}
	if payment.Version != 1 {
		t.Fatalf("version mismatch: got=%d want=1", payment.Version)
	}
	if !payment.CreatedAt.Equal(now) || !payment.UpdatedAt.Equal(now) {
		t.Fatalf("timestamps mismatch: created=%v updated=%v", payment.CreatedAt, payment.UpdatedAt)
	}

	gotByRef, err := repo.GetByPaymentRef(context.Background(), org, payment.PaymentRef)
	if err != nil {
		t.Fatalf("GetByPaymentRef returned error: %v", err)
	}
	if gotByRef.PaymentRef != payment.PaymentRef {
		t.Fatalf("payment_ref mismatch: got=%q want=%q", gotByRef.PaymentRef, payment.PaymentRef)
	}

	gotByIdem, err := repo.GetByIdempotencyKey(context.Background(), org, payment.IdempotencyKey)
	if err != nil {
		t.Fatalf("GetByIdempotencyKey returned error: %v", err)
	}
	if gotByIdem.PaymentRef != payment.PaymentRef {
		t.Fatalf("idempotency lookup mismatch: got=%q want=%q", gotByIdem.PaymentRef, payment.PaymentRef)
	}
}

func TestGetByIdempotencyKey_NotFoundDoesNotWarn(t *testing.T) {
	store := newFakeStore()
	core, observed := observer.New(zapcore.DebugLevel)
	repo, err := newWithStoreLogger(store, zap.New(core))
	if err != nil {
		t.Fatalf("newWithStoreLogger returned error: %v", err)
	}

	org := bson.NewObjectID()
	_, err = repo.GetByIdempotencyKey(context.Background(), org, "missing-idempotency-key")
	if !errors.Is(err, ErrPaymentNotFound) {
		t.Fatalf("expected ErrPaymentNotFound, got %v", err)
	}

	if got := observed.FilterMessage("Failed to get by idempotency key").Len(); got != 0 {
		t.Fatalf("expected no warning log for not found lookup, got=%d", got)
	}
	if got := observed.FilterMessage("Completed Get by idempotency key").Len(); got == 0 {
		t.Fatal("expected completion debug log for not found lookup")
	}
}

func TestCreate_Duplicate(t *testing.T) {
	store := newFakeStore()
	repo, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}

	org := bson.NewObjectID()
	first := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateCreated, time.Now())
	second := newPaymentFixture(org, "idem-1", "quote-1", "pay-2", agg.StateCreated, time.Now())

	if err := repo.Create(context.Background(), first); err != nil {
		t.Fatalf("Create(first) returned error: %v", err)
	}
	err = repo.Create(context.Background(), second)
	if !errors.Is(err, ErrDuplicatePayment) {
		t.Fatalf("expected ErrDuplicatePayment, got %v", err)
	}
}

func TestUpdateCAS(t *testing.T) {
	now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
	later := now.Add(2 * time.Minute)

	store := newFakeStore()
	repoIface, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}
	repo := repoIface.(*svc)
	repo.now = func() time.Time { return now }

	org := bson.NewObjectID()
	payment := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, now)
	if err := repo.Create(context.Background(), payment); err != nil {
		t.Fatalf("Create returned error: %v", err)
	}

	repo.now = func() time.Time { return later }
	payment.State = agg.StateNeedsAttention
	payment.StepExecutions[0].State = agg.StepStateNeedsAttention
	if err := repo.UpdateCAS(context.Background(), payment, 1); err != nil {
		t.Fatalf("UpdateCAS returned error: %v", err)
	}
	if payment.Version != 2 {
		t.Fatalf("version mismatch: got=%d want=2", payment.Version)
	}
	if !payment.UpdatedAt.Equal(later) {
		t.Fatalf("updated_at mismatch: got=%v want=%v", payment.UpdatedAt, later)
	}

	// stale version update
	payment.State = agg.StateExecuting
	err = repo.UpdateCAS(context.Background(), payment, 1)
	if !errors.Is(err, ErrVersionConflict) {
		t.Fatalf("expected ErrVersionConflict, got %v", err)
	}

	// missing payment update
	missing := newPaymentFixture(org, "idem-x", "quote-x", "pay-x", agg.StateExecuting, now)
	missing.ID = bson.NewObjectID()
	missing.Version = 2
	err = repo.UpdateCAS(context.Background(), missing, 1)
	if !errors.Is(err, ErrPaymentNotFound) {
		t.Fatalf("expected ErrPaymentNotFound, got %v", err)
	}
}

func TestListByQuotationRefAndState(t *testing.T) {
	store := newFakeStore()
	repo, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}

	org := bson.NewObjectID()
	base := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)

	p1 := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, base.Add(3*time.Minute))
	p2 := newPaymentFixture(org, "idem-2", "quote-1", "pay-2", agg.StateExecuting, base.Add(2*time.Minute))
	p3 := newPaymentFixture(org, "idem-3", "quote-1", "pay-3", agg.StateSettled, base.Add(1*time.Minute))
	p4 := newPaymentFixture(org, "idem-4", "quote-2", "pay-4", agg.StateExecuting, base.Add(4*time.Minute))

	for _, p := range []*agg.Payment{p1, p2, p3, p4} {
		if err := repo.Create(context.Background(), p); err != nil {
			t.Fatalf("Create returned error: %v", err)
		}
	}

	page1, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
		OrganizationRef: org,
		QuotationRef:    "quote-1",
		Limit:           2,
	})
	if err != nil {
		t.Fatalf("ListByQuotationRef(page1) returned error: %v", err)
	}
	if len(page1.Items) != 2 {
		t.Fatalf("page1 size mismatch: got=%d want=2", len(page1.Items))
	}
	if page1.Items[0].PaymentRef != p1.PaymentRef || page1.Items[1].PaymentRef != p2.PaymentRef {
		t.Fatalf("page1 order mismatch: got=%q,%q", page1.Items[0].PaymentRef, page1.Items[1].PaymentRef)
	}
	if page1.NextCursor == nil {
		t.Fatal("expected next cursor")
	}

	page2, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
		OrganizationRef: org,
		QuotationRef:    "quote-1",
		Limit:           2,
		Cursor:          page1.NextCursor,
	})
	if err != nil {
		t.Fatalf("ListByQuotationRef(page2) returned error: %v", err)
	}
	if len(page2.Items) != 1 || page2.Items[0].PaymentRef != p3.PaymentRef {
		t.Fatalf("page2 mismatch")
	}
	if page2.NextCursor != nil {
		t.Fatalf("expected nil next cursor")
	}

	statePage, err := repo.ListByState(context.Background(), ListByStateInput{
		OrganizationRef: org,
		State:           agg.StateExecuting,
		Limit:           10,
	})
	if err != nil {
		t.Fatalf("ListByState returned error: %v", err)
	}
	if len(statePage.Items) != 3 {
		t.Fatalf("state page size mismatch: got=%d want=3", len(statePage.Items))
	}
	if statePage.Items[0].PaymentRef != p4.PaymentRef {
		t.Fatalf("state order mismatch: first=%q want=%q", statePage.Items[0].PaymentRef, p4.PaymentRef)
	}
}

func TestValidationErrors(t *testing.T) {
	store := newFakeStore()
	repo, err := newWithStore(store)
	if err != nil {
		t.Fatalf("newWithStore returned error: %v", err)
	}
	org := bson.NewObjectID()

	tests := []struct {
		name string
		run  func() error
	}{
		{
			name: "create missing payment",
			run: func() error {
				return repo.Create(context.Background(), nil)
			},
		},
		{
			name: "create invalid state",
			run: func() error {
				p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.State("bad"), time.Now())
				return repo.Create(context.Background(), p)
			},
		},
		{
			name: "update expected version missing",
			run: func() error {
				p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, time.Now())
				p.ID = bson.NewObjectID()
				return repo.UpdateCAS(context.Background(), p, 0)
			},
		},
		{
			name: "list state invalid",
			run: func() error {
				_, err := repo.ListByState(context.Background(), ListByStateInput{
					OrganizationRef: org,
					State:           agg.State("bad"),
				})
				return err
			},
		},
		{
			name: "list cursor invalid",
			run: func() error {
				_, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
					OrganizationRef: org,
					QuotationRef:    "quote-1",
					Cursor:          &ListCursor{CreatedAt: time.Now()},
				})
				return err
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) {
				t.Fatalf("expected invalid argument error, got %v", err)
			}
		})
	}
}

type fakeStore struct {
	indexes []*indexDefinition
	docs    map[bson.ObjectID]*paymentDocument
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		docs: map[bson.ObjectID]*paymentDocument{},
	}
}

func (f *fakeStore) EnsureIndexes(defs []*indexDefinition) error {
	f.indexes = defs
	return nil
}

func (f *fakeStore) Create(_ context.Context, doc *paymentDocument) error {
	cloned, err := cloneDocument(doc)
	if err != nil {
		return err
	}
	for _, existing := range f.docs {
		if existing.OrganizationRef == cloned.OrganizationRef && existing.PaymentRef == cloned.PaymentRef {
			return ErrDuplicatePayment
		}
		if existing.OrganizationRef == cloned.OrganizationRef && existing.IdempotencyKey == cloned.IdempotencyKey {
			return ErrDuplicatePayment
		}
	}
	f.docs[cloned.ID] = cloned
	return nil
}

func (f *fakeStore) UpdateCAS(_ context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) {
	existing := f.docs[doc.ID]
	if existing == nil {
		return false, nil
	}
	if existing.OrganizationRef != doc.OrganizationRef {
		return false, nil
	}
	if existing.Version != expectedVersion {
		return false, nil
	}
	cloned, err := cloneDocument(doc)
	if err != nil {
		return false, err
	}
	f.docs[doc.ID] = cloned
	return true, nil
}

func (f *fakeStore) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) {
	for _, doc := range f.docs {
		if doc.OrganizationRef == orgRef && doc.PaymentRef == paymentRef {
			return cloneDocument(doc)
		}
	}
	return nil, ErrPaymentNotFound
}

func (f *fakeStore) GetByPaymentRefGlobal(_ context.Context, paymentRef string) (*paymentDocument, error) {
	for _, doc := range f.docs {
		if doc.PaymentRef == paymentRef {
			return cloneDocument(doc)
		}
	}
	return nil, ErrPaymentNotFound
}

func (f *fakeStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) {
	for _, doc := range f.docs {
		if doc.OrganizationRef == orgRef && doc.IdempotencyKey == idempotencyKey {
			return cloneDocument(doc)
		}
	}
	return nil, ErrPaymentNotFound
}

func (f *fakeStore) GetByID(_ context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) {
	doc := f.docs[id]
	if doc == nil || doc.OrganizationRef != orgRef {
		return nil, ErrPaymentNotFound
	}
	return cloneDocument(doc)
}

func (f *fakeStore) ListByQuotationRef(_ context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
	return f.list(func(doc *paymentDocument) bool {
		return doc.OrganizationRef == orgRef && doc.QuotationRef == quotationRef
	}, cursor, limit)
}

func (f *fakeStore) ListByState(_ context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
	return f.list(func(doc *paymentDocument) bool {
		return doc.OrganizationRef == orgRef && doc.State == state
	}, cursor, limit)
}

func (f *fakeStore) ListByStateGlobal(_ context.Context, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
	return f.list(func(doc *paymentDocument) bool {
		return doc.State == state
	}, cursor, limit)
}

func (f *fakeStore) list(match func(*paymentDocument) bool, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
	items := make([]*paymentDocument, 0)
	for _, doc := range f.docs {
		if !match(doc) {
			continue
		}
		if cursor != nil {
			if !isBeforeCursor(doc, *cursor) {
				continue
			}
		}
		cloned, err := cloneDocument(doc)
		if err != nil {
			return nil, err
		}
		items = append(items, cloned)
	}

	sort.Slice(items, func(i, j int) bool {
		left := items[i]
		right := items[j]
		if !left.CreatedAt.Equal(right.CreatedAt) {
			return left.CreatedAt.After(right.CreatedAt)
		}
		return bytes.Compare(left.ID[:], right.ID[:]) > 0
	})

	if int64(len(items)) > limit {
		items = items[:limit]
	}
	return items, nil
}

func isBeforeCursor(doc *paymentDocument, cursor listCursor) bool {
	if doc.CreatedAt.Before(cursor.CreatedAt) {
		return true
	}
	if doc.CreatedAt.After(cursor.CreatedAt) {
		return false
	}
	return bytes.Compare(doc.ID[:], cursor.ID[:]) < 0
}

func assertIndex(t *testing.T, def *indexDefinition, fields []string, unique bool) {
	t.Helper()
	if def == nil {
		t.Fatal("expected index definition")
	}
	if def.Unique != unique {
		t.Fatalf("index unique mismatch: got=%v want=%v", def.Unique, unique)
	}
	if len(def.Keys) != len(fields) {
		t.Fatalf("index key count mismatch: got=%d want=%d", len(def.Keys), len(fields))
	}
	for i := range fields {
		if def.Keys[i].Field != fields[i] {
			t.Fatalf("index key[%d] mismatch: got=%q want=%q", i, def.Keys[i].Field, fields[i])
		}
	}
}

func newPaymentFixture(org bson.ObjectID, idem, quote, paymentRef string, state agg.State, createdAt time.Time) *agg.Payment {
	return &agg.Payment{
		Base:                  modelBase(createdAt),
		OrganizationBoundBase: modelOrg(org),
		PaymentRef:            paymentRef,
		IdempotencyKey:        idem,
		QuotationRef:          quote,
		ClientPaymentRef:      "client-" + paymentRef,
		IntentSnapshot:        model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
		QuoteSnapshot:         &model.PaymentQuoteSnapshot{QuoteRef: quote},
		State:                 state,
		Version:               1,
		StepExecutions: []agg.StepExecution{
			{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
		},
	}
}

func modelOrg(org bson.ObjectID) pm.OrganizationBoundBase {
	return pm.OrganizationBoundBase{OrganizationRef: org}
}

func modelBase(createdAt time.Time) storable.Base {
	return storable.Base{
		ID:        bson.NewObjectID(),
		CreatedAt: createdAt.UTC(),
		UpdatedAt: createdAt.UTC(),
	}
}

func testMoney() *paymenttypes.Money {
	return &paymenttypes.Money{Amount: "10", Currency: "USDT"}
}
