package diplomail_test

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

	"galaxy/backend/internal/config"
	"galaxy/backend/internal/diplomail"
	"galaxy/backend/internal/diplomail/translator"
	backendpg "galaxy/backend/internal/postgres"
	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"
)

const (
	testImage     = "postgres:16-alpine"
	testUser      = "galaxy"
	testPassword  = "galaxy"
	testDatabase  = "galaxy_backend"
	testSchema    = "backend"
	testStartup   = 90 * time.Second
	testOpTimeout = 10 * time.Second
)

// startPostgres mirrors the harness used by `lobby_e2e_test.go`. It
// spins up a postgres:16-alpine container, applies the embedded
// migrations, and returns a ready-to-use `*sql.DB`. The container is
// torn down via t.Cleanup.
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, testImage,
		tcpostgres.WithDatabase(testDatabase),
		tcpostgres.WithUsername(testUser),
		tcpostgres.WithPassword(testPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(testStartup),
		),
	)
	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)
	}
	scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
	if err != nil {
		t.Fatalf("scope dsn: %v", err)
	}
	cfg := pgshared.DefaultConfig()
	cfg.PrimaryDSN = scopedDSN
	cfg.OperationTimeout = testOpTimeout
	db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
	if err != nil {
		t.Fatalf("open primary: %v", err)
	}
	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Errorf("close db: %v", err)
		}
	})
	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
}

// recordingPublisher captures every emitted DiplomailNotification so
// the test can assert push fan-out without booting the real
// notification pipeline.
type recordingPublisher struct {
	mu       sync.Mutex
	captured []diplomail.DiplomailNotification
}

func (p *recordingPublisher) PublishDiplomailEvent(_ context.Context, ev diplomail.DiplomailNotification) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.captured = append(p.captured, ev)
	return nil
}

func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification {
	p.mu.Lock()
	defer p.mu.Unlock()
	out := make([]diplomail.DiplomailNotification, len(p.captured))
	copy(out, p.captured)
	return out
}

// staticMembershipLookup serves an in-memory fixture. The test seeds
// memberships up-front and the lookup is keyed on (gameID, userID).
// Inactive rows (status != "active") are encoded by populating
// `inactive` instead of `rows`.
type staticMembershipLookup struct {
	rows     map[[2]uuid.UUID]diplomail.ActiveMembership
	inactive map[[2]uuid.UUID]diplomail.MemberSnapshot
}

func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
	if l == nil || l.rows == nil {
		return diplomail.ActiveMembership{}, diplomail.ErrNotFound
	}
	row, ok := l.rows[[2]uuid.UUID{gameID, userID}]
	if !ok {
		return diplomail.ActiveMembership{}, diplomail.ErrNotFound
	}
	return row, nil
}

func (l *staticMembershipLookup) GetMembershipAnyStatus(_ context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
	if l == nil {
		return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
	}
	if row, ok := l.rows[[2]uuid.UUID{gameID, userID}]; ok {
		return diplomail.MemberSnapshot{
			UserID:   row.UserID,
			GameID:   row.GameID,
			GameName: row.GameName,
			UserName: row.UserName,
			RaceName: row.RaceName,
			Status:   "active",
		}, nil
	}
	if row, ok := l.inactive[[2]uuid.UUID{gameID, userID}]; ok {
		return row, nil
	}
	return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}

func (l *staticMembershipLookup) ListMembers(_ context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
	if l == nil {
		return nil, nil
	}
	var out []diplomail.MemberSnapshot
	for key, row := range l.rows {
		if key[0] != gameID {
			continue
		}
		out = append(out, diplomail.MemberSnapshot{
			UserID:   row.UserID,
			GameID:   row.GameID,
			GameName: row.GameName,
			UserName: row.UserName,
			RaceName: row.RaceName,
			Status:   "active",
		})
	}
	if scope == diplomail.RecipientScopeActiveAndRemoved || scope == diplomail.RecipientScopeAllMembers {
		for key, row := range l.inactive {
			if key[0] != gameID {
				continue
			}
			if scope == diplomail.RecipientScopeActiveAndRemoved && row.Status != "removed" {
				continue
			}
			out = append(out, row)
		}
	}
	return out, nil
}

// seedAccount inserts a minimal accounts row so memberships and mail
// recipient FKs are satisfiable.
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
	t.Helper()
	_, err := db.ExecContext(context.Background(), `
		INSERT INTO backend.accounts (
			user_id, email, user_name, preferred_language, time_zone
		) VALUES ($1, $2, $3, 'en', 'UTC')
	`, userID, userID.String()+"@test.local", "user-"+userID.String()[:8])
	if err != nil {
		t.Fatalf("seed account %s: %v", userID, err)
	}
}

// seedGame inserts a minimal games row so the diplomail_messages.game_id
// FK is satisfiable.
func seedGame(t *testing.T, db *sql.DB, gameID uuid.UUID, name string) {
	t.Helper()
	_, err := db.ExecContext(context.Background(), `
		INSERT INTO backend.games (
			game_id, visibility, status, game_name,
			min_players, max_players, start_gap_hours, start_gap_players,
			enrollment_ends_at, turn_schedule, target_engine_version,
			runtime_snapshot
		) VALUES (
			$1, 'private', 'enrollment_open', $2,
			1, 4, 1, 1,
			now() + interval '1 day', '0 0 * * *', '1.0.0',
			'{}'::jsonb
		)
	`, gameID, name)
	if err != nil {
		t.Fatalf("seed game %s: %v", gameID, err)
	}
}

func TestDiplomailPersonalFlow(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	other := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedAccount(t, db, other)
	seedGame(t, db, gameID, "Stage A Test Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, sender}: {
				UserID: sender, GameID: gameID, GameName: "Stage A Test Game",
				UserName: "sender", RaceName: "Senders",
			},
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Stage A Test Game",
				UserName: "recipient", RaceName: "Receivers",
			},
		},
	}
	publisher := &recordingPublisher{}

	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	// 1. SendPersonal happy path.
	msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Subject:         "Trade proposal",
		Body:            "Care to talk gas mining?",
		SenderIP:        "203.0.113.4",
	})
	if err != nil {
		t.Fatalf("send personal: %v", err)
	}
	if msg.Kind != diplomail.KindPersonal {
		t.Fatalf("kind = %q, want personal", msg.Kind)
	}
	if rcpt.UserID != recipient {
		t.Fatalf("recipient.UserID = %s, want %s", rcpt.UserID, recipient)
	}
	if rcpt.ReadAt != nil {
		t.Fatalf("freshly sent message should be unread, read_at=%v", rcpt.ReadAt)
	}
	if got := publisher.snapshot(); len(got) != 1 {
		t.Fatalf("publisher captured %d events, want 1", len(got))
	} else if got[0].Recipient != recipient {
		t.Fatalf("push recipient = %s, want %s", got[0].Recipient, recipient)
	}

	// 2. ListInbox shows the message for the recipient.
	inbox, err := svc.ListInbox(ctx, gameID, recipient, "")
	if err != nil {
		t.Fatalf("list inbox: %v", err)
	}
	if len(inbox) != 1 || inbox[0].MessageID != msg.MessageID {
		t.Fatalf("inbox = %+v, want one matching entry", inbox)
	}

	// 3. ListSent surfaces the message for the sender.
	sent, err := svc.ListSent(ctx, gameID, sender)
	if err != nil {
		t.Fatalf("list sent: %v", err)
	}
	if len(sent) != 1 || sent[0].MessageID != msg.MessageID {
		t.Fatalf("sent = %+v, want one matching entry", sent)
	}

	// 4. Non-recipient reads are 404.
	if _, err := svc.GetMessage(ctx, other, msg.MessageID, ""); !errors.Is(err, diplomail.ErrNotFound) {
		t.Fatalf("non-recipient get: %v, want ErrNotFound", err)
	}

	// 5. Delete before read is a conflict.
	if _, err := svc.DeleteMessage(ctx, recipient, msg.MessageID); !errors.Is(err, diplomail.ErrConflict) {
		t.Fatalf("delete before read: %v, want ErrConflict", err)
	}

	// 6. MarkRead sets read_at; second call is a no-op.
	read, err := svc.MarkRead(ctx, recipient, msg.MessageID)
	if err != nil {
		t.Fatalf("mark read: %v", err)
	}
	if read.ReadAt == nil {
		t.Fatalf("mark read returned no read_at")
	}
	again, err := svc.MarkRead(ctx, recipient, msg.MessageID)
	if err != nil {
		t.Fatalf("mark read idempotent: %v", err)
	}
	if !again.ReadAt.Equal(*read.ReadAt) {
		t.Fatalf("mark read idempotent shifted read_at: %v -> %v", read.ReadAt, again.ReadAt)
	}

	// 7. Unread counts go to zero after the read.
	counts, err := svc.UnreadCountsForUser(ctx, recipient)
	if err != nil {
		t.Fatalf("unread counts: %v", err)
	}
	if len(counts) != 0 {
		t.Fatalf("unread counts = %+v, want empty after read", counts)
	}

	// 8. Soft delete now succeeds.
	deleted, err := svc.DeleteMessage(ctx, recipient, msg.MessageID)
	if err != nil {
		t.Fatalf("delete after read: %v", err)
	}
	if deleted.DeletedAt == nil {
		t.Fatalf("delete after read returned no deleted_at")
	}

	// 9. Inbox now excludes the soft-deleted message.
	inbox, err = svc.ListInbox(ctx, gameID, recipient, "")
	if err != nil {
		t.Fatalf("list inbox after delete: %v", err)
	}
	if len(inbox) != 0 {
		t.Fatalf("inbox after delete = %+v, want empty", inbox)
	}
}

func TestDiplomailRejectsNonActiveSender(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedGame(t, db, gameID, "Solo Test Game")

	// Only the recipient is on the active roster.
	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Solo Test Game",
				UserName: "recipient", RaceName: "Receivers",
			},
		},
	}

	svc := diplomail.NewService(diplomail.Deps{
		Store:       diplomail.NewStore(db),
		Memberships: lookup,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Subject:         "Hi",
		Body:            "Trade?",
	})
	if !errors.Is(err, diplomail.ErrForbidden) {
		t.Fatalf("send from non-member: %v, want ErrForbidden", err)
	}
}

func TestDiplomailAdminBroadcast(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	owner := uuid.New()
	alice := uuid.New()
	bob := uuid.New()
	kickedCharlie := uuid.New()
	seedAccount(t, db, owner)
	seedAccount(t, db, alice)
	seedAccount(t, db, bob)
	seedAccount(t, db, kickedCharlie)
	seedGame(t, db, gameID, "Broadcast Test Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, alice}: {
				UserID: alice, GameID: gameID, GameName: "Broadcast Test Game",
				UserName: "alice", RaceName: "AliceRace",
			},
			{gameID, bob}: {
				UserID: bob, GameID: gameID, GameName: "Broadcast Test Game",
				UserName: "bob", RaceName: "BobRace",
			},
		},
		inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
			{gameID, kickedCharlie}: {
				UserID: kickedCharlie, GameID: gameID, GameName: "Broadcast Test Game",
				UserName: "charlie", RaceName: "CharlieRace", Status: "removed",
			},
		},
	}
	publisher := &recordingPublisher{}

	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	ownerID := owner
	msg, recipients, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
		GameID:         gameID,
		CallerKind:     diplomail.CallerKindOwner,
		CallerUserID:   &ownerID,
		CallerUsername: "owner",
		RecipientScope: diplomail.RecipientScopeActive,
		Subject:        "All hands",
		Body:           "Welcome to round two.",
		SenderIP:       "203.0.113.7",
	})
	if err != nil {
		t.Fatalf("admin broadcast: %v", err)
	}
	if msg.Kind != diplomail.KindAdmin || msg.SenderKind != diplomail.SenderKindPlayer {
		t.Fatalf("kind=%q sender_kind=%q, want admin/player", msg.Kind, msg.SenderKind)
	}
	if len(recipients) != 2 {
		t.Fatalf("broadcast hit %d recipients, want 2 (alice+bob, kicked charlie excluded by active scope)", len(recipients))
	}
	if got := publisher.snapshot(); len(got) != 2 {
		t.Fatalf("publisher captured %d events, want 2", len(got))
	}

	// active_and_removed should include the kicked recipient too.
	msg2, recipients2, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
		GameID:         gameID,
		CallerKind:     diplomail.CallerKindAdmin,
		CallerUsername: "site-admin",
		RecipientScope: diplomail.RecipientScopeActiveAndRemoved,
		Body:           "Post-game retrospective.",
	})
	if err != nil {
		t.Fatalf("admin broadcast active_and_removed: %v", err)
	}
	if msg2.SenderKind != diplomail.SenderKindAdmin {
		t.Fatalf("sender_kind=%q, want admin", msg2.SenderKind)
	}
	if len(recipients2) != 3 {
		t.Fatalf("active_and_removed broadcast hit %d, want 3", len(recipients2))
	}

	// Kicked charlie sees the admin message but not the personal mail
	// that alice might have sent before the kick (none here — the
	// store path itself is exercised; the soft-access filter belongs
	// to a separate test below).
	charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie, "")
	if err != nil {
		t.Fatalf("kicked inbox: %v", err)
	}
	if len(charlieInbox) != 1 {
		t.Fatalf("kicked inbox = %d entries, want 1 (only the active_and_removed broadcast)", len(charlieInbox))
	}
}

// staticEntitlement satisfies diplomail.EntitlementReader by reading
// a fixed map keyed on user_id.
type staticEntitlement struct {
	paid map[uuid.UUID]bool
}

func (s *staticEntitlement) IsPaidTier(_ context.Context, userID uuid.UUID) (bool, error) {
	if s == nil {
		return false, nil
	}
	return s.paid[userID], nil
}

// staticGameLookup satisfies diplomail.GameLookup by walking a fixed
// list of GameSnapshot fixtures. Tests prepend rows via the New
// helper.
type staticGameLookup struct {
	games map[uuid.UUID]diplomail.GameSnapshot
}

func (l *staticGameLookup) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) {
	if l == nil {
		return nil, nil
	}
	out := make([]diplomail.GameSnapshot, 0, len(l.games))
	for _, g := range l.games {
		switch g.Status {
		case "running", "paused", "ready_to_start", "starting":
			out = append(out, g)
		}
	}
	return out, nil
}

func (l *staticGameLookup) ListFinishedGamesBefore(_ context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) {
	if l == nil {
		return nil, nil
	}
	out := make([]diplomail.GameSnapshot, 0, len(l.games))
	for _, g := range l.games {
		if g.Status != "finished" && g.Status != "cancelled" {
			continue
		}
		if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
			continue
		}
		out = append(out, g)
	}
	return out, nil
}

func (l *staticGameLookup) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) {
	if l == nil {
		return diplomail.GameSnapshot{}, diplomail.ErrNotFound
	}
	g, ok := l.games[gameID]
	if !ok {
		return diplomail.GameSnapshot{}, diplomail.ErrNotFound
	}
	return g, nil
}

func TestDiplomailPaidTierBroadcast(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	paidPlayer := uuid.New()
	freePlayer := uuid.New()
	alice := uuid.New()
	bob := uuid.New()
	seedAccount(t, db, paidPlayer)
	seedAccount(t, db, freePlayer)
	seedAccount(t, db, alice)
	seedAccount(t, db, bob)
	seedGame(t, db, gameID, "Paid Broadcast Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, paidPlayer}: {
				UserID: paidPlayer, GameID: gameID, GameName: "Paid Broadcast Game",
				UserName: "paid", RaceName: "PaidRace",
			},
			{gameID, freePlayer}: {
				UserID: freePlayer, GameID: gameID, GameName: "Paid Broadcast Game",
				UserName: "free", RaceName: "FreeRace",
			},
			{gameID, alice}: {
				UserID: alice, GameID: gameID, GameName: "Paid Broadcast Game",
				UserName: "alice", RaceName: "AliceRace",
			},
			{gameID, bob}: {
				UserID: bob, GameID: gameID, GameName: "Paid Broadcast Game",
				UserName: "bob", RaceName: "BobRace",
			},
		},
	}
	publisher := &recordingPublisher{}
	entitlements := &staticEntitlement{paid: map[uuid.UUID]bool{paidPlayer: true}}

	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Entitlements: entitlements,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	// Paid sender: broadcast succeeds.
	msg, recipients, err := svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
		GameID:       gameID,
		SenderUserID: paidPlayer,
		Subject:      "Alliance",
		Body:         "Let us form a coalition.",
	})
	if err != nil {
		t.Fatalf("paid broadcast: %v", err)
	}
	if msg.Kind != diplomail.KindPersonal || msg.BroadcastScope != diplomail.BroadcastScopeGameBroadcast {
		t.Fatalf("kind=%q scope=%q, want personal/game_broadcast", msg.Kind, msg.BroadcastScope)
	}
	if len(recipients) != 3 {
		t.Fatalf("broadcast recipients=%d, want 3 (everyone but sender)", len(recipients))
	}

	// Free-tier sender: 403.
	_, _, err = svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
		GameID:       gameID,
		SenderUserID: freePlayer,
		Body:         "Should not be allowed.",
	})
	if !errors.Is(err, diplomail.ErrForbidden) {
		t.Fatalf("free broadcast: %v, want ErrForbidden", err)
	}
}

func TestDiplomailMultiGameBroadcastAndCleanup(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	game1 := uuid.New()
	game2 := uuid.New()
	finished := uuid.New()
	alice := uuid.New()
	bob := uuid.New()
	carol := uuid.New()
	for _, id := range []uuid.UUID{alice, bob, carol} {
		seedAccount(t, db, id)
	}
	seedGame(t, db, game1, "Active Game 1")
	seedGame(t, db, game2, "Active Game 2")
	seedGame(t, db, finished, "Finished Game")
	// Mark `finished` terminal with a long-past finished_at.
	if _, err := db.ExecContext(ctx, `
		UPDATE backend.games
		   SET status='finished', finished_at = now() - interval '3 years'
		 WHERE game_id = $1
	`, finished); err != nil {
		t.Fatalf("backdate finished: %v", err)
	}

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{game1, alice}: {UserID: alice, GameID: game1, GameName: "Active Game 1", UserName: "alice", RaceName: "AliceRace"},
			{game1, bob}:   {UserID: bob, GameID: game1, GameName: "Active Game 1", UserName: "bob", RaceName: "BobRace"},
			{game2, carol}: {UserID: carol, GameID: game2, GameName: "Active Game 2", UserName: "carol", RaceName: "CarolRace"},
			{finished, alice}: {UserID: alice, GameID: finished, GameName: "Finished Game", UserName: "alice", RaceName: "AliceRace"},
		},
	}
	publisher := &recordingPublisher{}
	finAt := time.Now().UTC().AddDate(-3, 0, 0)
	games := &staticGameLookup{games: map[uuid.UUID]diplomail.GameSnapshot{
		game1:    {GameID: game1, GameName: "Active Game 1", Status: "running"},
		game2:    {GameID: game2, GameName: "Active Game 2", Status: "running"},
		finished: {GameID: finished, GameName: "Finished Game", Status: "finished", FinishedAt: &finAt},
	}}

	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Games:        games,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	// First, drop a personal message into the finished game so cleanup
	// has something to remove.
	if _, _, err := svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
		GameID:          finished,
		CallerKind:      diplomail.CallerKindAdmin,
		CallerUsername:  "ops",
		RecipientUserID: alice,
		Body:            "Audit ping",
	}); err != nil {
		t.Fatalf("seed finished-game mail: %v", err)
	}

	// Multi-game broadcast across all running games.
	msgs, total, err := svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{
		CallerUsername: "ops",
		Scope:          diplomail.MultiGameScopeAllRunning,
		RecipientScope: diplomail.RecipientScopeActive,
		Subject:        "Maintenance",
		Body:           "Brief turn-engine restart in 10 minutes.",
	})
	if err != nil {
		t.Fatalf("multi-game broadcast: %v", err)
	}
	if len(msgs) != 2 {
		t.Fatalf("multi-game messages=%d, want 2 (game1 + game2)", len(msgs))
	}
	if total != 3 {
		t.Fatalf("multi-game recipient count=%d, want 3 (alice+bob in g1, carol in g2)", total)
	}

	// Bulk cleanup with 1-year cutoff should sweep the finished game.
	result, err := svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: 1})
	if err != nil {
		t.Fatalf("bulk cleanup: %v", err)
	}
	if len(result.GameIDs) != 1 || result.GameIDs[0] != finished {
		t.Fatalf("cleanup game_ids=%v, want [%s]", result.GameIDs, finished)
	}
	if result.MessagesDeleted < 1 {
		t.Fatalf("cleanup messages_deleted=%d, want >=1", result.MessagesDeleted)
	}

	// Admin listing sees the multi-game messages.
	page, err := svc.ListMessagesForAdmin(ctx, diplomail.AdminMessageListing{Page: 1, PageSize: 50})
	if err != nil {
		t.Fatalf("list admin messages: %v", err)
	}
	if page.Total < 2 {
		t.Fatalf("list total=%d, want >=2 after cleanup", page.Total)
	}
}

func TestDiplomailLifecycleMembershipKick(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	kicked := uuid.New()
	seedAccount(t, db, kicked)
	seedGame(t, db, gameID, "Lifecycle Test Game")

	lookup := &staticMembershipLookup{
		inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
			{gameID, kicked}: {
				UserID: kicked, GameID: gameID, GameName: "Lifecycle Test Game",
				UserName: "kicked", RaceName: "KickedRace", Status: "blocked",
			},
		},
	}
	publisher := &recordingPublisher{}

	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	target := kicked
	if err := svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
		GameID:     gameID,
		Kind:       diplomail.LifecycleKindMembershipBlocked,
		Actor:      "an administrator",
		Reason:     "rule violation",
		TargetUser: &target,
	}); err != nil {
		t.Fatalf("publish lifecycle: %v", err)
	}
	if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked {
		t.Fatalf("publisher captured %+v, want one event addressed to kicked", got)
	}
	inbox, err := svc.ListInbox(ctx, gameID, kicked, "")
	if err != nil {
		t.Fatalf("kicked inbox: %v", err)
	}
	if len(inbox) != 1 {
		t.Fatalf("kicked inbox = %d, want 1 system message", len(inbox))
	}
	if inbox[0].Kind != diplomail.KindAdmin || inbox[0].SenderKind != diplomail.SenderKindSystem {
		t.Fatalf("kind=%q sender_kind=%q, want admin/system", inbox[0].Kind, inbox[0].SenderKind)
	}
}

// TestDiplomailWorkerTickOnEmptyQueueIsNoop confirms the async
// worker tolerates an empty pending queue: no error, no panic, no
// publisher events. Belt-and-suspenders for the case where backend
// starts, mounts the worker as an `app.Component`, and ticks before
// any user has sent mail.
func TestDiplomailWorkerTickOnEmptyQueueIsNoop(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	publisher := &recordingPublisher{}
	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  &staticMembershipLookup{},
		Notification: publisher,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})
	worker := diplomail.NewWorker(svc)
	for i := 0; i < 3; i++ {
		if err := worker.Tick(ctx); err != nil {
			t.Fatalf("tick %d on empty queue: %v", i, err)
		}
	}
	if got := publisher.snapshot(); len(got) != 0 {
		t.Fatalf("publisher fired %d events on empty queue", len(got))
	}
}

// TestDiplomailAsyncTranslationDelivery covers the Stage E flow:
//  1. SendPersonal where recipient.preferred_language != body_lang
//     materialises a recipient with `AvailableAt == nil`; the inbox
//     is empty until the worker runs.
//  2. After one Worker.Tick(), the translation cache row exists,
//     `AvailableAt` is populated, and the push event fires.
//  3. The inbox now surfaces the message together with the cached
//     translation under `Translation`.
func TestDiplomailAsyncTranslationDelivery(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedGame(t, db, gameID, "Async Translation Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, sender}: {
				UserID: sender, GameID: gameID, GameName: "Async Translation Game",
				UserName: "sender", RaceName: "SenderRace",
				PreferredLanguage: "en",
			},
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Async Translation Game",
				UserName: "recipient", RaceName: "RecipientRace",
				PreferredLanguage: "ru",
			},
		},
	}
	publisher := &recordingPublisher{}
	stub := &staticTranslator{engine: translator.LibreTranslateEngine}
	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Detector:     detectorFn(func(_ string) string { return "en" }),
		Translator:   stub,
		Config: config.DiplomailConfig{
			MaxBodyBytes:          4096,
			MaxSubjectBytes:       256,
			TranslatorMaxAttempts: 5,
			WorkerInterval:        time.Second,
		},
	})

	msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Subject:         "Hello",
		Body:            "Trade proposal.",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}
	if rcpt.AvailableAt != nil {
		t.Fatalf("recipient marked available_at on send: %v (want NULL — pending translation)", rcpt.AvailableAt)
	}
	if got := publisher.snapshot(); len(got) != 0 {
		t.Fatalf("push fired before worker delivered: %d events", len(got))
	}
	inbox, err := svc.ListInbox(ctx, gameID, recipient, "")
	if err != nil {
		t.Fatalf("inbox before worker: %v", err)
	}
	if len(inbox) != 0 {
		t.Fatalf("inbox before worker = %d, want empty", len(inbox))
	}

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

	if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != recipient {
		t.Fatalf("publisher after tick = %+v", got)
	}
	inboxAfter, err := svc.ListInbox(ctx, gameID, recipient, "ru")
	if err != nil {
		t.Fatalf("inbox after worker: %v", err)
	}
	if len(inboxAfter) != 1 {
		t.Fatalf("inbox after worker = %d, want 1", len(inboxAfter))
	}
	if inboxAfter[0].Translation == nil {
		t.Fatalf("translation missing on inbox entry")
	}
	if inboxAfter[0].Translation.TranslatedBody != "[ru] Trade proposal." {
		t.Fatalf("translated body = %q", inboxAfter[0].Translation.TranslatedBody)
	}
	_ = msg
}

// TestDiplomailAsyncFallbackOnUnsupportedPair covers the terminal
// "translation unavailable" path: the translator returns
// ErrUnsupportedLanguagePair, so the worker delivers the recipient
// with no cached translation. The user sees the original body.
func TestDiplomailAsyncFallbackOnUnsupportedPair(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedGame(t, db, gameID, "Unsupported Pair Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, sender}: {
				UserID: sender, GameID: gameID, GameName: "Unsupported Pair Game",
				UserName: "sender", PreferredLanguage: "en",
			},
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Unsupported Pair Game",
				UserName: "recipient", PreferredLanguage: "xx",
			},
		},
	}
	publisher := &recordingPublisher{}
	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Detector:     detectorFn(func(_ string) string { return "en" }),
		Translator:   &erroringTranslator{err: translator.ErrUnsupportedLanguagePair},
		Config: config.DiplomailConfig{
			MaxBodyBytes:          4096,
			MaxSubjectBytes:       256,
			TranslatorMaxAttempts: 5,
		},
	})

	if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Body:            "Hello there.",
	}); err != nil {
		t.Fatalf("send: %v", err)
	}
	worker := diplomail.NewWorker(svc)
	if err := worker.Tick(ctx); err != nil {
		t.Fatalf("worker tick: %v", err)
	}
	inbox, err := svc.ListInbox(ctx, gameID, recipient, "xx")
	if err != nil {
		t.Fatalf("inbox: %v", err)
	}
	if len(inbox) != 1 {
		t.Fatalf("inbox after fallback = %d, want 1", len(inbox))
	}
	if inbox[0].Translation != nil {
		t.Fatalf("translation should be nil after fallback, got %+v", inbox[0].Translation)
	}
}

type erroringTranslator struct {
	err error
}

func (e *erroringTranslator) Translate(_ context.Context, _, _, _, _ string) (translator.Result, error) {
	return translator.Result{}, e.err
}

// staticTranslator returns deterministic renderings so the
// translation-cache test can assert against known output.
type staticTranslator struct {
	engine string
}

func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) {
	return translator.Result{
		Subject: "[" + dstLang + "] " + subject,
		Body:    "[" + dstLang + "] " + body,
		Engine:  s.engine,
	}, nil
}

func TestDiplomailTranslationCache(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedGame(t, db, gameID, "Translation Test Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, sender}: {
				UserID: sender, GameID: gameID, GameName: "Translation Test Game",
				UserName: "sender", RaceName: "SendersRace",
			},
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Translation Test Game",
				UserName: "recipient", RaceName: "ReceiversRace",
			},
		},
	}
	publisher := &recordingPublisher{}

	englishDetector := detectorFn(func(_ string) string { return "en" })
	svc := diplomail.NewService(diplomail.Deps{
		Store:        diplomail.NewStore(db),
		Memberships:  lookup,
		Notification: publisher,
		Detector:     englishDetector,
		Translator:   &staticTranslator{engine: "stub"},
		Config: config.DiplomailConfig{
			MaxBodyBytes:    4096,
			MaxSubjectBytes: 256,
		},
	})

	msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Subject:         "Hello",
		Body:            "Please share the latest map snapshot.",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}
	if msg.BodyLang != "en" {
		t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang)
	}

	// First read materialises a cached translation.
	entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
	if err != nil {
		t.Fatalf("get message: %v", err)
	}
	if entry.Translation == nil {
		t.Fatalf("translation missing on first read")
	}
	if entry.Translation.TargetLang != "ru" {
		t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang)
	}
	if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." {
		t.Fatalf("translated body = %q", entry.Translation.TranslatedBody)
	}

	// Second read returns the same cached row (no re-translation).
	entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
	if err != nil {
		t.Fatalf("get message twice: %v", err)
	}
	if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID {
		t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID)
	}

	// Same language as body: no translation.
	entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en")
	if err != nil {
		t.Fatalf("get message same lang: %v", err)
	}
	if entrySame.Translation != nil {
		t.Fatalf("translation populated when target == body_lang")
	}
}

// detectorFn lets the test inject deterministic detection without
// dragging the whatlanggo profile into the test fixtures.
type detectorFn func(string) string

func (f detectorFn) Detect(s string) string { return f(s) }

func TestDiplomailRejectsOverlongBody(t *testing.T) {
	db := startPostgres(t)
	ctx := context.Background()

	gameID := uuid.New()
	sender := uuid.New()
	recipient := uuid.New()
	seedAccount(t, db, sender)
	seedAccount(t, db, recipient)
	seedGame(t, db, gameID, "Length Test Game")

	lookup := &staticMembershipLookup{
		rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
			{gameID, sender}: {
				UserID: sender, GameID: gameID, GameName: "Length Test Game",
				UserName: "sender", RaceName: "Senders",
			},
			{gameID, recipient}: {
				UserID: recipient, GameID: gameID, GameName: "Length Test Game",
				UserName: "recipient", RaceName: "Receivers",
			},
		},
	}

	svc := diplomail.NewService(diplomail.Deps{
		Store:       diplomail.NewStore(db),
		Memberships: lookup,
		Config: config.DiplomailConfig{
			MaxBodyBytes:    32,
			MaxSubjectBytes: 256,
		},
	})

	bigBody := make([]byte, 64)
	for i := range bigBody {
		bigBody[i] = 'a'
	}
	_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
		GameID:          gameID,
		SenderUserID:    sender,
		RecipientUserID: recipient,
		Body:            string(bigBody),
	})
	if !errors.Is(err, diplomail.ErrInvalidInput) {
		t.Fatalf("send overlong: %v, want ErrInvalidInput", err)
	}
}
