package connector_syncstate

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
	"testing"
	"time"
)

// --- mock pack resolver ---

type mockPackResolver struct {
	installID string
	active    bool
	err       error
}

func (m *mockPackResolver) ResolveConnectorPackByNamespace(_ context.Context, _ string) (string, bool, error) {
	return m.installID, m.active, m.err
}

// --- mock repository ---

type mockRepo struct {
	mu   sync.Mutex
	rows map[string]*SyncState // keyed by installID+"|"+object+"|"+direction
}

func newMockRepo() *mockRepo {
	return &mockRepo{rows: make(map[string]*SyncState)}
}

func rowKey(installID, object, direction string) string {
	return installID + "|" + object + "|" + direction
}

func (r *mockRepo) Get(_ context.Context, installID, object, direction string) (*SyncState, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.rows[rowKey(installID, object, direction)]
	if !ok {
		return nil, nil
	}
	cp := *s
	return &cp, nil
}

func (r *mockRepo) UpsertCursor(_ context.Context, installID, namespace, object, direction, cursor string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	key := rowKey(installID, object, direction)
	now := time.Now().UTC()
	if s, ok := r.rows[key]; ok {
		s.Cursor = cursor
		s.LastSyncAt = sql.NullTime{Time: now, Valid: true}
		s.LastModifiedDate = now
	} else {
		r.rows[key] = &SyncState{
			ID:               "id-" + key,
			InstalledPackId:  installID,
			Namespace:        namespace,
			Object:           object,
			Direction:        direction,
			Cursor:           cursor,
			LastSyncAt:       sql.NullTime{Time: now, Valid: true},
			IsActive:         true,
			CreatedDate:      now,
			LastModifiedDate: now,
		}
	}
	return nil
}

func (r *mockRepo) UpsertError(_ context.Context, installID, namespace, object, direction, errorMsg string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	key := rowKey(installID, object, direction)
	now := time.Now().UTC()
	if s, ok := r.rows[key]; ok {
		s.LastErrorMessage = errorMsg
		s.LastErrorAt = sql.NullTime{Time: now, Valid: true}
		s.RunningErrorCount++
		s.LastModifiedDate = now
	} else {
		r.rows[key] = &SyncState{
			ID:                "id-" + key,
			InstalledPackId:   installID,
			Namespace:         namespace,
			Object:            object,
			Direction:         direction,
			LastErrorMessage:  errorMsg,
			LastErrorAt:       sql.NullTime{Time: now, Valid: true},
			RunningErrorCount: 1,
			IsActive:          true,
			CreatedDate:       now,
			LastModifiedDate:  now,
		}
	}
	return nil
}

func (r *mockRepo) ResetErrors(_ context.Context, installID, object, direction string) (int64, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	key := rowKey(installID, object, direction)
	s, ok := r.rows[key]
	if !ok {
		return 0, nil
	}
	s.LastErrorMessage = ""
	s.LastErrorAt = sql.NullTime{}
	s.RunningErrorCount = 0
	s.LastModifiedDate = time.Now().UTC()
	return 1, nil
}

func (r *mockRepo) ListByInstallID(_ context.Context, installID string) ([]SyncState, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []SyncState
	for _, s := range r.rows {
		if s.InstalledPackId == installID {
			cp := *s
			out = append(out, cp)
		}
	}
	return out, nil
}

// --- helpers ---

func newService(repo *mockRepo, active bool) *Service {
	return NewService(repo, &mockPackResolver{installID: "install-1", active: active})
}

// --- Test 1: GetSyncState_NotFound ---

func TestGetSyncState_NotFound(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	_, err := svc.GetSyncState(context.Background(), "test-pack", "Lead", DirectionInbound)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

// --- Test 2: UpdateCursor_RoundTrip ---

func TestUpdateCursor_RoundTrip(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	err := svc.UpdateCursor(context.Background(), "test-pack", "Lead", DirectionInbound, "cursor-abc")
	if err != nil {
		t.Fatalf("UpdateCursor: %v", err)
	}

	state, err := svc.GetSyncState(context.Background(), "test-pack", "Lead", DirectionInbound)
	if err != nil {
		t.Fatalf("GetSyncState: %v", err)
	}
	if state.Cursor != "cursor-abc" {
		t.Errorf("expected cursor 'cursor-abc', got %q", state.Cursor)
	}
	if !state.LastSyncAt.Valid {
		t.Error("expected LastSyncAt to be set after UpdateCursor")
	}
}

// --- Test 3: UpdateCursor_Concurrent_NoTornWrites ---

func TestUpdateCursor_Concurrent_NoTornWrites(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	const n = 10
	var wg sync.WaitGroup
	wg.Add(n)
	errs := make([]error, n)

	for i := 0; i < n; i++ {
		i := i
		go func() {
			defer wg.Done()
			cursor := fmt.Sprintf("cursor-%d", i)
			errs[i] = svc.UpdateCursor(context.Background(), "test-pack", "Lead", DirectionInbound, cursor)
		}()
	}
	wg.Wait()

	for i, err := range errs {
		if err != nil {
			t.Errorf("goroutine %d error: %v", i, err)
		}
	}

	state, err := svc.GetSyncState(context.Background(), "test-pack", "Lead", DirectionInbound)
	if err != nil {
		t.Fatalf("GetSyncState after concurrent writes: %v", err)
	}
	// Verify final cursor is one of the 10 valid cursors (not empty, not corrupted).
	found := false
	for i := 0; i < n; i++ {
		if state.Cursor == fmt.Sprintf("cursor-%d", i) {
			found = true
			break
		}
	}
	if !found {
		t.Errorf("final cursor %q is not one of the 10 expected cursor values", state.Cursor)
	}
}

// --- Test 4: ResetErrors_AfterRecordErrors ---

func TestResetErrors_AfterRecordErrors(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	ctx := context.Background()
	for i := 0; i < 3; i++ {
		if err := svc.RecordError(ctx, "test-pack", "Contact", DirectionOutbound, "err msg"); err != nil {
			t.Fatalf("RecordError %d: %v", i, err)
		}
	}

	state, err := svc.GetSyncState(ctx, "test-pack", "Contact", DirectionOutbound)
	if err != nil {
		t.Fatalf("GetSyncState: %v", err)
	}
	if state.RunningErrorCount != 3 {
		t.Errorf("expected RunningErrorCount=3, got %d", state.RunningErrorCount)
	}
	if state.LastErrorMessage == "" {
		t.Error("expected non-empty LastErrorMessage after 3 RecordError calls")
	}

	if err := svc.ResetErrors(ctx, "test-pack", "Contact", DirectionOutbound); err != nil {
		t.Fatalf("ResetErrors: %v", err)
	}

	state2, err := svc.GetSyncState(ctx, "test-pack", "Contact", DirectionOutbound)
	if err != nil {
		t.Fatalf("GetSyncState after reset: %v", err)
	}
	if state2.RunningErrorCount != 0 {
		t.Errorf("expected RunningErrorCount=0 after reset, got %d", state2.RunningErrorCount)
	}
	if state2.LastErrorMessage != "" {
		t.Errorf("expected empty LastErrorMessage after reset, got %q", state2.LastErrorMessage)
	}
}

// --- Test 5: ResetErrors_MissingRow_NotFound ---

func TestResetErrors_MissingRow_NotFound(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	err := svc.ResetErrors(context.Background(), "test-pack", "NoRow", DirectionInbound)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound for missing row, got %v", err)
	}
}

// --- Test 6: NoopConnectorReuse ---

func TestNoopConnectorReuse(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	ctx := context.Background()
	const ns = "noop-connector"
	const obj = "__test_noop__"
	const dir = DirectionInbound

	// UpdateCursor round-trip
	if err := svc.UpdateCursor(ctx, ns, obj, dir, "tok-1"); err != nil {
		t.Fatalf("UpdateCursor: %v", err)
	}
	st, err := svc.GetSyncState(ctx, ns, obj, dir)
	if err != nil {
		t.Fatalf("GetSyncState: %v", err)
	}
	if st.Cursor != "tok-1" {
		t.Errorf("cursor: want 'tok-1', got %q", st.Cursor)
	}

	// RecordError
	if err := svc.RecordError(ctx, ns, obj, dir, "connect timeout"); err != nil {
		t.Fatalf("RecordError: %v", err)
	}

	// ResetErrors
	if err := svc.ResetErrors(ctx, ns, obj, dir); err != nil {
		t.Fatalf("ResetErrors: %v", err)
	}
	st2, err := svc.GetSyncState(ctx, ns, obj, dir)
	if err != nil {
		t.Fatalf("GetSyncState after reset: %v", err)
	}
	if st2.RunningErrorCount != 0 {
		t.Errorf("RunningErrorCount after reset: want 0, got %d", st2.RunningErrorCount)
	}
}

// --- Test 7: InactivePack_FailsClosed ---

func TestInactivePack_FailsClosed(t *testing.T) {
	repo := newMockRepo()
	svc := NewService(repo, &mockPackResolver{installID: "install-1", active: false})

	ctx := context.Background()
	const ns = "inactive-pack"

	if _, err := svc.GetSyncState(ctx, ns, "Obj", DirectionInbound); !errors.Is(err, ErrConnectorPackInactive) {
		t.Errorf("GetSyncState: expected ErrConnectorPackInactive, got %v", err)
	}
	if err := svc.UpdateCursor(ctx, ns, "Obj", DirectionInbound, "x"); !errors.Is(err, ErrConnectorPackInactive) {
		t.Errorf("UpdateCursor: expected ErrConnectorPackInactive, got %v", err)
	}
	if err := svc.RecordError(ctx, ns, "Obj", DirectionInbound, "e"); !errors.Is(err, ErrConnectorPackInactive) {
		t.Errorf("RecordError: expected ErrConnectorPackInactive, got %v", err)
	}
	if err := svc.ResetErrors(ctx, ns, "Obj", DirectionInbound); !errors.Is(err, ErrConnectorPackInactive) {
		t.Errorf("ResetErrors: expected ErrConnectorPackInactive, got %v", err)
	}
}

// --- Test 8: InvalidDirection_FailsValidation ---

func TestInvalidDirection_FailsValidation(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	ctx := context.Background()

	if _, err := svc.GetSyncState(ctx, "p", "O", "badvalue"); !errors.Is(err, ErrInvalidDirection) {
		t.Errorf("GetSyncState: expected ErrInvalidDirection, got %v", err)
	}
	if err := svc.UpdateCursor(ctx, "p", "O", "badvalue", "x"); !errors.Is(err, ErrInvalidDirection) {
		t.Errorf("UpdateCursor: expected ErrInvalidDirection, got %v", err)
	}
	if err := svc.RecordError(ctx, "p", "O", "badvalue", "e"); !errors.Is(err, ErrInvalidDirection) {
		t.Errorf("RecordError: expected ErrInvalidDirection, got %v", err)
	}
	if err := svc.ResetErrors(ctx, "p", "O", "badvalue"); !errors.Is(err, ErrInvalidDirection) {
		t.Errorf("ResetErrors: expected ErrInvalidDirection, got %v", err)
	}
}

// --- Test: ListByNamespace returns all rows for namespace ---

func TestListByNamespace_ReturnsAllRows(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	ctx := context.Background()
	const ns = "test-pack"

	// Insert 3 rows for the target namespace and 1 for another.
	tuples := []struct{ obj, dir string }{
		{"Lead", DirectionOutbound},
		{"Contact", DirectionInbound},
		{"Lead", DirectionInbound},
	}
	for _, tt := range tuples {
		if err := svc.UpdateCursor(ctx, ns, tt.obj, tt.dir, "c"); err != nil {
			t.Fatalf("UpdateCursor(%s,%s): %v", tt.obj, tt.dir, err)
		}
	}
	// Different namespace with same installID — verifies installID-scoping works at the mock layer.
	// In production, "other-pack" would resolve to a different installID via the pack resolver.
	// Here we directly inject into repo to simulate a different install.
	repo.mu.Lock()
	otherKey := rowKey("other-install", "Lead", DirectionInbound)
	repo.rows[otherKey] = &SyncState{
		ID:              "id-other",
		InstalledPackId: "other-install",
		Namespace:       "other-pack",
		Object:          "Lead",
		Direction:       DirectionInbound,
		IsActive:        true,
	}
	repo.mu.Unlock()

	states, err := svc.ListByNamespace(ctx, ns)
	if err != nil {
		t.Fatalf("ListByNamespace: %v", err)
	}
	if len(states) != 3 {
		t.Errorf("expected 3 states for %q, got %d", ns, len(states))
	}
	for _, s := range states {
		if s.InstalledPackId != "install-1" {
			t.Errorf("unexpected InstalledPackId %q in results (expected install-1)", s.InstalledPackId)
		}
	}
}

// --- Test: RebirthDoesNotInheritCursor ---

// TestRebirthDoesNotInheritCursor proves that same-namespace pack rebirth
// (new InstallID) produces a fresh ErrNotFound rather than inheriting the
// previous incarnation's cursor/error state.
func TestRebirthDoesNotInheritCursor(t *testing.T) {
	repo := newMockRepo()
	resolver := &mockPackResolver{installID: "install-1", active: true}
	svc := NewService(repo, resolver)

	ctx := context.Background()
	const ns = "my-pack"

	// Install-1 writes a cursor.
	if err := svc.UpdateCursor(ctx, ns, "Lead", DirectionInbound, "cur-A"); err != nil {
		t.Fatalf("UpdateCursor install-1: %v", err)
	}
	st, err := svc.GetSyncState(ctx, ns, "Lead", DirectionInbound)
	if err != nil {
		t.Fatalf("GetSyncState install-1: %v", err)
	}
	if st.Cursor != "cur-A" {
		t.Fatalf("install-1 cursor: want cur-A, got %q", st.Cursor)
	}

	// Simulate pack rebirth: same namespace, new PackInstallId.
	resolver.installID = "install-2"

	// Install-2 must not inherit install-1's cursor.
	_, err = svc.GetSyncState(ctx, ns, "Lead", DirectionInbound)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("after rebirth: expected ErrNotFound for install-2, got %v", err)
	}
}

// --- Test: RecordError truncates long error messages ---

// TestRecordError_TruncatesLongMessage asserts that a worker-supplied error
// string exceeding errorMessageMaxRunes is stored truncated (256 runes + "…"),
// closing the raw-leak surface on /api/connectors/:namespace/health.
func TestRecordError_TruncatesLongMessage(t *testing.T) {
	repo := newMockRepo()
	svc := newService(repo, true)

	// Build a 1000-rune string using a mix of ASCII and multi-byte runes to
	// exercise the rune-safe truncation path.
	runes := make([]rune, 1000)
	for i := range runes {
		if i%10 == 0 {
			runes[i] = '日' // 3-byte UTF-8 rune
		} else {
			runes[i] = 'x'
		}
	}
	longMsg := string(runes)

	ctx := context.Background()
	if err := svc.RecordError(ctx, "test-pack", "Lead", DirectionInbound, longMsg); err != nil {
		t.Fatalf("RecordError: %v", err)
	}

	state, err := svc.GetSyncState(ctx, "test-pack", "Lead", DirectionInbound)
	if err != nil {
		t.Fatalf("GetSyncState: %v", err)
	}

	stored := []rune(state.LastErrorMessage)
	// Expect exactly errorMessageMaxRunes runes + the "…" ellipsis suffix.
	// The ellipsis is a single rune (U+2026), so total rune count = 257.
	wantLen := errorMessageMaxRunes + 1 // 256 content runes + 1 ellipsis rune
	if len(stored) != wantLen {
		t.Errorf("stored rune count: want %d (256 + ellipsis), got %d; value=%q",
			wantLen, len(stored), state.LastErrorMessage)
	}
	if stored[len(stored)-1] != '…' {
		t.Errorf("stored message must end with '…'; got rune %q", stored[len(stored)-1])
	}
	// Verify the first 256 runes match the input prefix.
	if string(stored[:errorMessageMaxRunes]) != string(runes[:errorMessageMaxRunes]) {
		t.Error("stored prefix does not match input prefix")
	}
}
