package jobs

import (
	"context"
	"strings"
	"sync"
	"testing"
	"time"
)

// TestNew verifies New returns a functional Scheduler.
func TestNew(t *testing.T) {
	s := New()
	if s == nil {
		t.Fatal("New() returned nil")
	}
	if s.cron == nil {
		t.Error("Scheduler.cron is nil")
	}
	if s.baseCtx == nil {
		t.Error("Scheduler.baseCtx is nil")
	}
	if s.cancel == nil {
		t.Error("Scheduler.cancel is nil")
	}
}

// TestRegister_ValidCron accepts standard 5-field cron expressions.
func TestRegister_ValidCron(t *testing.T) {
	exprs := []string{
		"*/5 * * * *",
		"0 * * * *",
		"0 3 * * *",
		"30 3 * * *",
		"* * * * *",
		"0 0 1 1 *",
	}
	for _, expr := range exprs {
		t.Run(expr, func(t *testing.T) {
			s := New()
			s.Register(Job{
				Name:     "test",
				Schedule: expr,
				Handler:  func(ctx context.Context) (int, error) { return 0, nil },
			})
			if len(s.registry) != 1 {
				t.Errorf("expected 1 job, got %d", len(s.registry))
			}
		})
	}
}

// TestRegister_InvalidCron panics on garbage expressions.
func TestRegister_InvalidCron(t *testing.T) {
	exprs := []string{
		"not-a-cron",
		"",
		"* * * *",
		"70 * * * *",
	}
	for _, expr := range exprs {
		t.Run(expr, func(t *testing.T) {
			s := New()
			defer func() {
				if r := recover(); r == nil {
					t.Error("expected panic for invalid cron expression")
				}
			}()
			s.Register(Job{
				Name:     "bad",
				Schedule: expr,
				Handler:  func(ctx context.Context) (int, error) { return 0, nil },
			})
		})
	}
}

// TestWrapJob_RunsHandler verifies the wrapped function calls the handler.
func TestWrapJob_RunsHandler(t *testing.T) {
	s := New()
	var ran bool
	job := Job{
		Name: "test-run",
		Handler: func(ctx context.Context) (int, error) {
			ran = true
			return 0, nil
		},
	}
	fn := s.wrapJob(job)
	fn()
	if !ran {
		t.Error("handler was not called")
	}
}

// TestWrapJob_PanicRecovery catches panics in the handler.
func TestWrapJob_PanicRecovery(t *testing.T) {
	s := New()
	job := Job{
		Name: "test-panic",
		Handler: func(ctx context.Context) (int, error) {
			panic("deliberate panic")
		},
	}
	fn := s.wrapJob(job)

	// Should not propagate the panic
	fn()
}

// TestWrapJob_TimeoutCancelsContext verifies the handler receives a deadline.
func TestWrapJob_TimeoutCancelsContext(t *testing.T) {
	s := New()
	ctxReceived := make(chan context.Context, 1)
	job := Job{
		Name:    "test-timeout",
		Timeout: 10 * time.Millisecond,
		Handler: func(ctx context.Context) (int, error) {
			ctxReceived <- ctx
			// Block until context is cancelled
			<-ctx.Done()
			return 0, ctx.Err()
		},
	}

	fn := s.wrapJob(job)
	done := make(chan error, 1)
	go func() {
		fn()
		done <- nil
	}()

	select {
	case ctx := <-ctxReceived:
		deadline, ok := ctx.Deadline()
		if !ok {
			t.Error("expected a deadline on the context")
		}
		if time.Until(deadline) > 10*time.Millisecond {
			t.Errorf("deadline too far in the future: %v", time.Until(deadline))
		}
	case <-time.After(time.Second):
		t.Fatal("handler was not called within 1s")
	}

	select {
	case <-done:
		// Finished
	case <-time.After(time.Second):
		t.Fatal("job did not complete within 1s")
	}
}

// TestWrapJob_ConcurrencySkip skips the second invocation when one is inflight.
func TestWrapJob_ConcurrencySkip(t *testing.T) {
	s := New()
	var mu sync.Mutex
	callCount := 0
	block := make(chan struct{})

	job := Job{
		Name:        "test-concurrency",
		Concurrency: 1,
		Handler: func(ctx context.Context) (int, error) {
			mu.Lock()
			callCount++
			mu.Unlock()
			<-block // Block until test releases
			return 0, nil
		},
	}
	fn := s.wrapJob(job)

	// First invocation — will block inside handler
	go fn()

	// Wait for first invocation to enter handler
	mu.Lock()
	firstStarted := callCount == 1
	mu.Unlock()
	if !firstStarted {
		// Give it time
		time.Sleep(50 * time.Millisecond)
	}

	// Second invocation — should skip because first is still running
	fn()

	mu.Lock()
	if callCount > 1 {
		t.Errorf("expected callCount 1 (skip), got %d", callCount)
	}
	mu.Unlock()

	// Clean up
	close(block)
}

// TestWrapJob_ConcurrencyNoLimit allows multiple invocations.
func TestWrapJob_ConcurrencyNoLimit(t *testing.T) {
	s := New()
	block := make(chan struct{})
	var mu sync.Mutex
	callCount := 0
	started := make(chan struct{}, 2)

	job := Job{
		Name:        "test-no-limit",
		Concurrency: 0, // unlimited
		Handler: func(ctx context.Context) (int, error) {
			mu.Lock()
			callCount++
			mu.Unlock()
			started <- struct{}{}
			<-block
			return 0, nil
		},
	}
	fn := s.wrapJob(job)

	go fn()
	go fn()

	// Both should start
	<-started
	<-started

	mu.Lock()
	if callCount != 2 {
		t.Errorf("expected callCount 2, got %d", callCount)
	}
	mu.Unlock()

	close(block)
}

// TestStartAndShutdown verifies the scheduler lifecycle.
func TestStartAndShutdown(t *testing.T) {
	s := New()
	s.Register(Job{
		Name:     "test-lifecycle",
		Schedule: "0 0 1 1 *", // Once a year — won't fire during test
		Handler:  func(ctx context.Context) (int, error) { return 0, nil },
	})
	s.Start()

	select {
	case <-s.Shutdown():
		// Clean shutdown
	case <-time.After(time.Second):
		t.Fatal("shutdown did not complete within 1s")
	}
}

// TestShutdown_CancelsBaseContext verifies in-flight jobs receive cancellation.
func TestShutdown_CancelsBaseContext(t *testing.T) {
	s := New()
	ctxReceived := make(chan context.Context, 1)
	block := make(chan struct{})

	s.Register(Job{
		Name:     "test-shutdown-cancel",
		Schedule: "* * * * *", // Will fire within 60s — but we test wrapJob directly
		Handler:  func(ctx context.Context) (int, error) { return 0, nil },
	})

	// Test that the base context is cancelled on shutdown
	job := Job{
		Name: "test-cancel",
		Handler: func(ctx context.Context) (int, error) {
			ctxReceived <- ctx
			<-block
			return 0, nil
		},
	}
	fn := s.wrapJob(job)

	go fn()

	select {
	case <-ctxReceived:
		// Handler started
	case <-time.After(time.Second):
		t.Fatal("handler was not called within 1s")
	}

	// Shutdown should cancel the base context
	shutdownDone := make(chan struct{})
	go func() {
		<-s.Shutdown()
		close(shutdownDone)
	}()

	// The handler's context should be cancelled now
	select {
	case <-shutdownDone:
		// Good
	case <-time.After(time.Second):
		t.Fatal("shutdown did not complete within 1s")
	}

	close(block)
}

// ============================================================
// RandomPrefix / coloredDuration / coloredRows Tests
// ============================================================

func TestRandomPrefix_NonEmpty(t *testing.T) {
	p := RandomPrefix()
	if p == "" {
		t.Error("expected RandomPrefix to return non-empty string")
	}
	if len(p) < 8 {
		t.Errorf("expected RandomPrefix to be at least 8 chars, got %q (len=%d)", p, len(p))
	}
}

func TestRandomPrefix_StableDuringRun(t *testing.T) {
	a := RandomPrefix()
	b := RandomPrefix()
	if a != b {
		t.Errorf("expected RandomPrefix to be stable, got %q != %q", a, b)
	}
}

func TestColoredDuration_Fast(t *testing.T) {
	out := coloredDuration(100 * time.Millisecond)
	if !strings.Contains(out, "100ms") {
		t.Errorf("expected 100ms in output, got %q", out)
	}
	if !strings.Contains(out, "[32m") {
		t.Errorf("expected green color (32) for fast duration, got %q", out)
	}
	if !strings.HasSuffix(out, "[0m") {
		t.Error("expected output to end with color reset")
	}
}

func TestColoredDuration_Medium(t *testing.T) {
	out := coloredDuration(2 * time.Second)
	if !strings.Contains(out, "2s") {
		t.Errorf("expected 2s in output, got %q", out)
	}
	if !strings.Contains(out, "[33m") {
		t.Errorf("expected yellow color (33) for medium duration, got %q", out)
	}
	if !strings.HasSuffix(out, "[0m") {
		t.Error("expected output to end with color reset")
	}
}

func TestColoredDuration_Slow(t *testing.T) {
	out := coloredDuration(10 * time.Second)
	if !strings.Contains(out, "10s") {
		t.Errorf("expected 10s in output, got %q", out)
	}
	if !strings.Contains(out, "[31m") {
		t.Errorf("expected red color (31) for slow duration, got %q", out)
	}
	if !strings.HasSuffix(out, "[0m") {
		t.Error("expected output to end with color reset")
	}
}

func TestColoredRows_Singular(t *testing.T) {
	out := coloredRows(1)
	if !strings.Contains(out, "1 row") {
		t.Errorf("expected '1 row' in output, got %q", out)
	}
	if !strings.Contains(out, "[34;1m") {
		t.Errorf("expected bold blue (34;1) color, got %q", out)
	}
	if !strings.HasSuffix(out, "[0m") {
		t.Error("expected output to end with color reset")
	}
}

func TestColoredRows_Plural(t *testing.T) {
	out := coloredRows(3)
	if !strings.Contains(out, "3 rows") {
		t.Errorf("expected '3 rows' in output, got %q", out)
	}
	if !strings.Contains(out, "[34;1m") {
		t.Errorf("expected bold blue (34;1) color, got %q", out)
	}
}

func TestColoredRows_Zero(t *testing.T) {
	out := coloredRows(0)
	if !strings.Contains(out, "0 rows") {
		t.Errorf("expected '0 rows' in output, got %q", out)
	}
}

func TestColoredRows_Reset(t *testing.T) {
	if !strings.HasSuffix(coloredRows(0), "[0m") {
		t.Error("expected output to end with color reset")
	}
	if !strings.HasSuffix(coloredRows(5), "[0m") {
		t.Error("expected output to end with color reset")
	}
}

// ============================================================
// RegisterAll Tests
// ============================================================

// TestRegisterAll_RegistersExpectedJobs verifies RegisterAll registers exactly 19 jobs
// with all required fields populated (non-empty Name, non-empty Schedule, non-nil
// Handler, positive Timeout).
func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
	t.Parallel()

	s := New()
	RegisterAll(s)

	if got := len(s.registry); got != 19 {
		t.Fatalf("RegisterAll() registered %d jobs, want 19", got)
	}

	registered := make(map[string]Job, len(s.registry))
	for _, j := range s.registry {
		registered[j.Name] = j
	}

	expected := expectedJobNames()

	for name := range expected {
		job, ok := registered[name]
		if !ok {
			t.Errorf("missing expected job %q", name)
			continue
		}
		requireValidJob(t, job)
	}

	for name := range registered {
		if !expected[name] {
			t.Errorf("unexpected job %q", name)
		}
	}
}

// TestRegisterAll_ValidSchedules verifies all cron expressions in registered jobs
// parse without panic. RegisterAll internally calls Register, which parses every
// schedule; if this test completes without panic, all 19 schedules are valid.
func TestRegisterAll_ValidSchedules(t *testing.T) {
	t.Parallel()

	s := New()
	// RegisterAll panics on invalid cron — no panic means all schedules are valid.
	RegisterAll(s)
}

// TestRegisterAll_HandlerSignatures verifies every registered job has a non-nil
// handler function, confirming all handlers are properly wired at compile time.
func TestRegisterAll_HandlerSignatures(t *testing.T) {
	t.Parallel()

	s := New()
	RegisterAll(s)

	for _, j := range s.registry {
		if j.Handler == nil {
			t.Errorf("job %q has nil Handler", j.Name)
		}
	}
}

func expectedJobNames() map[string]bool {
	return map[string]bool{
		"cleanup-reservations":                true,
		"cleanup-expired-deposits":            true,
		"cleanup-rate-limiters":               true,
		"cleanup-gdpr-export-cache":           true,
		"cleanup-progressive-rate-limiter":    true,
		"cleanup-expired-loyalty-redemptions": true,
		"cleanup-old-idempotency-keys":        true,
		"cleanup-revoked-jtis":                true,
		"cleanup-stale-login-entries":         true,
		"anonymize-stale-guest-accounts":      true,
		"cleanup-expired-financial-records":   true,
		"cleanup-idle-accounts":               true,
		"cleanup-expired-gift-cards":          true,
		"cleanup-old-name-history":            true,
		"notify-unpaid-1-week":                true,
		"notify-unpaid-1-month":               true,
		"transition-discount-campaigns":       true,
		"cleanup-verification-codes":          true,
		"cleanup-refresh-tokens":              true,
	}
}

// TestRegisterAll_NoDuplicateCronExpressions verifies that no two jobs share the
// exact same cron expression. Identical schedules risk DB contention and should
// be deliberately staggered.
func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) {
	t.Parallel()

	s := New()
	RegisterAll(s)

	cronJobs := make(map[string][]string)
	for _, j := range s.registry {
		cronJobs[j.Schedule] = append(cronJobs[j.Schedule], j.Name)
	}

	// Known-intentional groupings: jobs at the same frequency that touch
	// disjoint tables (no contention risk).
	knownGroupings := map[int]bool{
		4: true, // */5 * * * * — 4 cleanup jobs, different domains
		5: true, // 0 * * * *  — 5 hourly cleanup jobs, different tables
		2: true, // 0 2 * * *  — 2 daily cleanup jobs, different tables
	}

	for cron, jobs := range cronJobs {
		if len(jobs) > 1 && !knownGroupings[len(jobs)] {
			t.Errorf("cron %q shared by %d jobs: %v — stagger to avoid contention", cron, len(jobs), jobs)
		}
	}
}

func requireValidJob(t *testing.T, j Job) {
	t.Helper()
	if j.Name == "" {
		t.Error("job has empty Name")
	}
	if j.Schedule == "" {
		t.Errorf("job %q has empty Schedule", j.Name)
	}
	if j.Handler == nil {
		t.Errorf("job %q has nil Handler", j.Name)
	}
	if j.Timeout <= 0 {
		t.Errorf("job %q has non-positive Timeout (%v)", j.Name, j.Timeout)
	}
}
