package notification_test

import (
	"context"
	"database/sql"
	"errors"
	"net/url"
	"sync"
	"testing"
	"time"

	"galaxy/backend/internal/config"
	"galaxy/backend/internal/notification"
	backendpg "galaxy/backend/internal/postgres"
	"galaxy/backend/internal/user"
	"galaxy/backend/push"
	pgshared "galaxy/postgres"

	"github.com/google/uuid"
	testcontainers "github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
	"go.uber.org/zap/zaptest"
)

const (
	pgImage    = "postgres:16-alpine"
	pgUser     = "galaxy"
	pgPassword = "galaxy"
	pgDatabase = "galaxy_backend"
	pgSchema   = "backend"
	pgStartup  = 90 * time.Second
	pgOpTO     = 10 * time.Second
)

// startPostgres mirrors the mail/auth scaffolding: spin up Postgres,
// apply migrations, return *sql.DB.
func startPostgres(t *testing.T) *sql.DB {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
	t.Cleanup(cancel)

	pgContainer, err := tcpostgres.Run(ctx, pgImage,
		tcpostgres.WithDatabase(pgDatabase),
		tcpostgres.WithUsername(pgUser),
		tcpostgres.WithPassword(pgPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(pgStartup),
		),
	)
	if err != nil {
		t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
	}
	t.Cleanup(func() {
		if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
			t.Errorf("terminate postgres container: %v", termErr)
		}
	})

	baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("connection string: %v", err)
	}
	scoped, err := dsnWithSearchPath(baseDSN, pgSchema)
	if err != nil {
		t.Fatalf("scope dsn: %v", err)
	}
	cfg := pgshared.DefaultConfig()
	cfg.PrimaryDSN = scoped
	cfg.OperationTimeout = pgOpTO
	db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
	if err != nil {
		t.Fatalf("open primary: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
		t.Fatalf("ping: %v", err)
	}
	if err := backendpg.ApplyMigrations(ctx, db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	return db
}

func dsnWithSearchPath(baseDSN, schema string) (string, error) {
	parsed, err := url.Parse(baseDSN)
	if err != nil {
		return "", err
	}
	values := parsed.Query()
	values.Set("search_path", schema)
	if values.Get("sslmode") == "" {
		values.Set("sslmode", "disable")
	}
	parsed.RawQuery = values.Encode()
	return parsed.String(), nil
}

// recordingMailer captures every EnqueueTemplate call.
type recordingMailer struct {
	mu    sync.Mutex
	calls []recordedEnqueue
	err   error
}

type recordedEnqueue struct {
	TemplateID     string
	Recipient      string
	Payload        map[string]any
	IdempotencyKey string
}

func (r *recordingMailer) EnqueueTemplate(_ context.Context, templateID, recipient string, payload map[string]any, idempotencyKey string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.err != nil {
		return r.err
	}
	r.calls = append(r.calls, recordedEnqueue{
		TemplateID:     templateID,
		Recipient:      recipient,
		Payload:        payload,
		IdempotencyKey: idempotencyKey,
	})
	return nil
}

func (r *recordingMailer) Calls() []recordedEnqueue {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]recordedEnqueue, len(r.calls))
	copy(out, r.calls)
	return out
}

// recordingPush captures every PublishClientEvent call.
type recordingPush struct {
	mu    sync.Mutex
	calls []recordedPushEvent
}

type recordedPushEvent struct {
	UserID    uuid.UUID
	Kind      string
	Payload   map[string]any
	EventID   string
	RequestID string
	TraceID   string
}

func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, event push.Event, eventID, requestID, traceID string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	kind := ""
	var payload map[string]any
	if event != nil {
		kind = event.Kind()
		if jsonEvent, ok := event.(push.JSONEvent); ok {
			payload = jsonEvent.Payload
		}
	}
	r.calls = append(r.calls, recordedPushEvent{
		UserID:    userID,
		Kind:      kind,
		Payload:   payload,
		EventID:   eventID,
		RequestID: requestID,
		TraceID:   traceID,
	})
	return nil
}

func (r *recordingPush) Calls() []recordedPushEvent {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]recordedPushEvent, len(r.calls))
	copy(out, r.calls)
	return out
}

// stubAccounts hands back a fixed account record for any user_id, so
// tests don't need to seed the accounts table.
type stubAccounts struct {
	account user.Account
	err     error
}

func (s *stubAccounts) GetAccount(_ context.Context, userID uuid.UUID) (user.Account, error) {
	if s.err != nil {
		return user.Account{}, s.err
	}
	out := s.account
	out.UserID = userID
	return out, nil
}

func newService(t *testing.T, db *sql.DB, mailer notification.Mailer, push notification.PushPublisher, accounts notification.AccountResolver, adminEmail string) *notification.Service {
	t.Helper()
	cfg := config.NotificationConfig{
		AdminEmail:     adminEmail,
		WorkerInterval: 10 * time.Millisecond,
		MaxAttempts:    3,
	}
	return notification.NewService(notification.Deps{
		Store:    notification.NewStore(db),
		Mail:     mailer,
		Push:     push,
		Accounts: accounts,
		Config:   cfg,
		Logger:   zaptest.NewLogger(t),
	})
}

func TestSubmitFansOutLobbyInviteToPushAndEmail(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	mailer := &recordingMailer{}
	push := &recordingPush{}
	accounts := &stubAccounts{account: user.Account{
		Email:             "alice@example.test",
		PreferredLanguage: "en",
	}}
	svc := newService(t, db, mailer, push, accounts, "")

	recipient := uuid.New()
	id, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindLobbyInviteReceived,
		IdempotencyKey: "invite:" + uuid.NewString(),
		Recipients:     []uuid.UUID{recipient},
		Payload: map[string]any{
			"game_id":         uuid.NewString(),
			"inviter_user_id": uuid.NewString(),
		},
	})
	if err != nil {
		t.Fatalf("submit: %v", err)
	}
	if id == uuid.Nil {
		t.Fatal("submit returned nil id")
	}

	// Best-effort dispatch ran synchronously; both channels should
	// have observed exactly one call.
	if got := len(push.Calls()); got != 1 {
		t.Errorf("push calls=%d, want 1", got)
	}
	if got := len(mailer.Calls()); got != 1 {
		t.Errorf("mail calls=%d, want 1", got)
	} else {
		call := mailer.Calls()[0]
		if call.Recipient != "alice@example.test" {
			t.Errorf("mail recipient=%q", call.Recipient)
		}
		if call.TemplateID != notification.KindLobbyInviteReceived {
			t.Errorf("mail template=%q", call.TemplateID)
		}
	}
}

func TestSubmitIsIdempotent(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{account: user.Account{Email: "x@example.test"}}, "")

	intent := notification.Intent{
		Kind:           notification.KindLobbyApplicationSubmitted,
		IdempotencyKey: "dedupe-key",
		Recipients:     []uuid.UUID{uuid.New()},
		Payload:        map[string]any{"game_id": uuid.NewString(), "application_id": uuid.NewString()},
	}
	first, err := svc.Submit(context.Background(), intent)
	if err != nil {
		t.Fatalf("first submit: %v", err)
	}
	second, err := svc.Submit(context.Background(), intent)
	if err != nil {
		t.Fatalf("second submit: %v", err)
	}
	if first != second {
		t.Fatalf("idempotent submit must return same id: %s vs %s", first, second)
	}
}

func TestSubmitMalformedPersists(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{}, "")

	id, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           "nonsense.kind",
		IdempotencyKey: "anything",
		Recipients:     []uuid.UUID{uuid.New()},
	})
	if err != nil {
		t.Fatalf("submit: %v", err)
	}
	if id != uuid.Nil {
		t.Fatalf("malformed submit must return nil id, got %s", id)
	}

	page, err := svc.AdminListMalformed(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list malformed: %v", err)
	}
	if page.Total < 1 {
		t.Fatalf("malformed total=%d, want >= 1", page.Total)
	}
}

func TestSubmitAdminEmailSkipsWhenNotConfigured(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	mailer := &recordingMailer{}
	svc := newService(t, db, mailer, &recordingPush{}, &stubAccounts{}, "")

	id, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindRuntimeImagePullFailed,
		IdempotencyKey: "ipf-1",
		Payload:        map[string]any{"game_id": uuid.NewString(), "image_ref": "registry/img:tag"},
	})
	if err != nil {
		t.Fatalf("submit: %v", err)
	}
	if id == uuid.Nil {
		t.Fatal("admin submit returned nil id")
	}
	if got := len(mailer.Calls()); got != 0 {
		t.Errorf("mail calls=%d, want 0 (admin email unset)", got)
	}
}

func TestSubmitAdminEmailDispatchesWhenConfigured(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	mailer := &recordingMailer{}
	svc := newService(t, db, mailer, &recordingPush{}, &stubAccounts{}, "ops@example.test")

	if _, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindRuntimeContainerStartFailed,
		IdempotencyKey: "csf-1",
		Payload:        map[string]any{"game_id": uuid.NewString()},
	}); err != nil {
		t.Fatalf("submit: %v", err)
	}
	calls := mailer.Calls()
	if len(calls) != 1 {
		t.Fatalf("mail calls=%d, want 1", len(calls))
	}
	if calls[0].Recipient != "ops@example.test" {
		t.Errorf("admin recipient=%q", calls[0].Recipient)
	}
}

func TestSubmitMissingAccountSkipsEmail(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	mailer := &recordingMailer{}
	push := &recordingPush{}
	accounts := &stubAccounts{err: user.ErrAccountNotFound}
	svc := newService(t, db, mailer, push, accounts, "")

	if _, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindLobbyApplicationApproved,
		IdempotencyKey: "missing-1",
		Recipients:     []uuid.UUID{uuid.New()},
		Payload:        map[string]any{"game_id": uuid.NewString()},
	}); err != nil {
		t.Fatalf("submit: %v", err)
	}
	if got := len(mailer.Calls()); got != 0 {
		t.Errorf("mail calls=%d want 0 when account missing", got)
	}
	if got := len(push.Calls()); got != 0 {
		t.Errorf("push calls=%d want 0 when account missing", got)
	}
}

func TestWorkerRetryAndDeadLetter(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	failingMailer := &recordingMailer{err: errors.New("smtp down")}
	push := &recordingPush{}
	accounts := &stubAccounts{account: user.Account{Email: "alice@example.test", PreferredLanguage: "en"}}
	svc := newService(t, db, failingMailer, push, accounts, "")

	// MaxAttempts=3 from newService config. Submit fires one
	// best-effort attempt; subsequent Tick calls drive attempts 2 and
	// 3, the last one dead-letters.
	if _, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindLobbyInviteReceived,
		IdempotencyKey: "fail-1",
		Recipients:     []uuid.UUID{uuid.New()},
		Payload:        map[string]any{"game_id": uuid.NewString(), "inviter_user_id": uuid.NewString()},
	}); err != nil {
		t.Fatalf("submit: %v", err)
	}

	// Force every retry to be due immediately.
	if _, err := db.Exec(`UPDATE backend.notification_routes SET next_attempt_at = now() WHERE channel = 'email'`); err != nil {
		t.Fatalf("force due: %v", err)
	}
	worker := notification.NewWorker(svc)
	for range 5 {
		if err := worker.Tick(context.Background()); err != nil {
			t.Fatalf("tick: %v", err)
		}
		if _, err := db.Exec(`UPDATE backend.notification_routes SET next_attempt_at = now() WHERE channel = 'email' AND status = 'retrying'`); err != nil {
			t.Fatalf("force due: %v", err)
		}
	}

	dead, err := svc.AdminListDeadLetters(context.Background(), 1, 10)
	if err != nil {
		t.Fatalf("list dead-letters: %v", err)
	}
	if dead.Total < 1 {
		t.Fatalf("expected dead-letter row, got total=%d (mail attempts=%d)", dead.Total, len(failingMailer.Calls()))
	}
}

func TestOnUserDeletedSkipsPendingRoutes(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	failingMailer := &recordingMailer{err: errors.New("smtp down")}
	push := &recordingPush{}
	userID := uuid.New()
	accounts := &stubAccounts{account: user.Account{Email: "alice@example.test", PreferredLanguage: "en"}}
	svc := newService(t, db, failingMailer, push, accounts, "")

	// Submit something that owns user_id so the cascade picks it up.
	if _, err := svc.Submit(context.Background(), notification.Intent{
		Kind:           notification.KindLobbyApplicationApproved,
		IdempotencyKey: "cascade-1",
		Recipients:     []uuid.UUID{userID},
		Payload:        map[string]any{"game_id": uuid.NewString()},
	}); err != nil {
		t.Fatalf("submit: %v", err)
	}

	if err := svc.OnUserDeleted(context.Background(), userID); err != nil {
		t.Fatalf("OnUserDeleted: %v", err)
	}

	var skipped int
	if err := db.QueryRow(`
		SELECT COUNT(*)
		FROM backend.notification_routes r
		JOIN backend.notifications n ON n.notification_id = r.notification_id
		WHERE n.user_id = $1 AND r.status = 'skipped'
	`, userID).Scan(&skipped); err != nil {
		t.Fatalf("count skipped: %v", err)
	}
	if skipped == 0 {
		t.Fatal("expected at least one skipped route after cascade")
	}
}

func TestAdminGetMissing(t *testing.T) {
	t.Parallel()
	db := startPostgres(t)
	svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{}, "")
	if _, err := svc.AdminGetNotification(context.Background(), uuid.New()); !errors.Is(err, notification.ErrNotificationNotFound) {
		t.Fatalf("got %v, want ErrNotificationNotFound", err)
	}
}
