package mail_test

import (
	"context"
	"errors"
	"sync"
	"testing"
	"time"

	"galaxy/backend/internal/config"
	"galaxy/backend/internal/mail"

	"github.com/google/uuid"
	"go.uber.org/zap/zaptest"
)

// recordingSender is a SMTPSender stub with programmable per-call
// behaviour. Tests append behaviours; each Send pops the head.
type recordingSender struct {
	mu        sync.Mutex
	sent      []mail.OutboundMessage
	behaviour []func(mail.OutboundMessage) error
}

func newRecordingSender() *recordingSender { return &recordingSender{} }

func (r *recordingSender) Send(_ context.Context, msg mail.OutboundMessage) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.sent = append(r.sent, msg)
	if len(r.behaviour) == 0 {
		return nil
	}
	fn := r.behaviour[0]
	r.behaviour = r.behaviour[1:]
	return fn(msg)
}

func (r *recordingSender) snapshot() []mail.OutboundMessage {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]mail.OutboundMessage, len(r.sent))
	copy(out, r.sent)
	return out
}

// recordingAdminNotifier captures every dead-letter notification call.
type recordingAdminNotifier struct {
	mu    sync.Mutex
	calls int
}

func (r *recordingAdminNotifier) OnDeadLetter(_ context.Context, _ uuid.UUID, _, _ string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.calls++
}

func (r *recordingAdminNotifier) count() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.calls
}

// buildService spins up a Service backed by a real Postgres testcontainer.
// The fake clock and configurable max-attempts let tests exercise the
// retry / dead-letter branches without real time.
func buildService(t *testing.T, sender mail.SMTPSender, admin mail.AdminNotifier, maxAttempts int, now func() time.Time) *mail.Service {
	t.Helper()
	db := startPostgres(t)
	svc := mail.NewService(mail.Deps{
		Store:  mail.NewStore(db),
		SMTP:   sender,
		Admin:  admin,
		Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: maxAttempts},
		Now:    now,
		Logger: zaptest.NewLogger(t),
	})
	return svc
}

func TestWorkerSuccessFirstAttempt(t *testing.T) {
	t.Parallel()

	sender := newRecordingSender()
	admin := &recordingAdminNotifier{}
	svc := buildService(t, sender, admin, 3, time.Now)

	if err := svc.EnqueueLoginCode(context.Background(), "alice@example.test", "111111", 5*time.Minute); err != nil {
		t.Fatalf("enqueue: %v", err)
	}

	worker := mail.NewWorker(svc)
	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick: %v", err)
	}

	sent := sender.snapshot()
	if len(sent) != 1 {
		t.Fatalf("got %d sent messages, want 1", len(sent))
	}
	if sent[0].Subject == "" || len(sent[0].Body) == 0 {
		t.Fatalf("sent message missing fields: %+v", sent[0])
	}

	page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(page.Items) != 1 {
		t.Fatalf("want 1 delivery, got %d", len(page.Items))
	}
	if page.Items[0].Status != mail.StatusSent {
		t.Fatalf("status=%q want sent", page.Items[0].Status)
	}
	if page.Items[0].Attempts != 1 {
		t.Fatalf("attempts=%d want 1", page.Items[0].Attempts)
	}
	if admin.count() != 0 {
		t.Fatalf("admin notifier must not fire on success, got %d", admin.count())
	}
}

func TestWorkerTransientThenDeadLetter(t *testing.T) {
	t.Parallel()

	sender := newRecordingSender()
	sender.behaviour = []func(mail.OutboundMessage) error{
		func(mail.OutboundMessage) error { return errors.New("smtp transient #1") },
		func(mail.OutboundMessage) error { return errors.New("smtp transient #2") },
	}
	admin := &recordingAdminNotifier{}

	// Start the fake clock 2 hours behind wall-clock so the
	// `finishedAt + backoff` computed by ScheduleRetry lands in the
	// past relative to DB `now()` and the second tick re-claims the
	// row immediately.
	clock := time.Now().UTC().Add(-2 * time.Hour)
	svc := buildService(t, sender, admin, 2, func() time.Time { return clock })

	if err := svc.EnqueueLoginCode(context.Background(), "bob@example.test", "222222", 5*time.Minute); err != nil {
		t.Fatalf("enqueue: %v", err)
	}

	worker := mail.NewWorker(svc)
	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick #1: %v", err)
	}

	page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if got := page.Items[0].Status; got != mail.StatusRetrying {
		t.Fatalf("after first failure status=%q want retrying", got)
	}

	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick #2: %v", err)
	}

	page, err = svc.AdminListDeliveries(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list 2: %v", err)
	}
	if got := page.Items[0].Status; got != mail.StatusDeadLettered {
		t.Fatalf("after second failure status=%q want dead_lettered", got)
	}
	if page.Items[0].Attempts != 2 {
		t.Fatalf("attempts=%d want 2", page.Items[0].Attempts)
	}
	if admin.count() != 1 {
		t.Fatalf("admin notifier calls=%d want 1", admin.count())
	}

	// Check dead-letter row exists.
	dl, err := svc.AdminListDeadLetters(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list dead-letters: %v", err)
	}
	if dl.Total != 1 {
		t.Fatalf("dead-letter total=%d want 1", dl.Total)
	}
}

func TestWorkerPermanentDeadLettersImmediately(t *testing.T) {
	t.Parallel()

	sender := newRecordingSender()
	sender.behaviour = []func(mail.OutboundMessage) error{
		func(mail.OutboundMessage) error { return &mail.SendError{Err: errors.New("rejected"), Permanent: true} },
	}
	admin := &recordingAdminNotifier{}
	svc := buildService(t, sender, admin, 5, time.Now)

	if err := svc.EnqueueLoginCode(context.Background(), "e@example.test", "333333", 5*time.Minute); err != nil {
		t.Fatalf("enqueue: %v", err)
	}

	worker := mail.NewWorker(svc)
	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick: %v", err)
	}

	page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if got := page.Items[0].Status; got != mail.StatusDeadLettered {
		t.Fatalf("status=%q want dead_lettered after permanent error", got)
	}
	if admin.count() != 1 {
		t.Fatalf("admin notifier calls=%d want 1", admin.count())
	}
}

func TestWorkerRespectsNextAttemptAt(t *testing.T) {
	t.Parallel()

	sender := newRecordingSender()
	sender.behaviour = []func(mail.OutboundMessage) error{
		func(mail.OutboundMessage) error { return errors.New("transient") },
	}
	// Push the fake clock far into the future so the post-retry
	// next_attempt_at lands well past wall-clock now() and the second
	// tick deterministically skips the row.
	clock := time.Now().UTC().Add(24 * time.Hour)
	admin := &recordingAdminNotifier{}
	svc := buildService(t, sender, admin, 5, func() time.Time { return clock })

	if err := svc.EnqueueLoginCode(context.Background(), "f@example.test", "444444", 5*time.Minute); err != nil {
		t.Fatalf("enqueue: %v", err)
	}
	worker := mail.NewWorker(svc)
	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick #1: %v", err)
	}

	// Without advancing the clock the next tick must skip the row
	// because next_attempt_at > now().
	if err := worker.Tick(context.Background()); err != nil {
		t.Fatalf("tick #2: %v", err)
	}
	if got := len(sender.snapshot()); got != 1 {
		t.Fatalf("sender saw %d messages while still backing off, want 1", got)
	}
}
