//go:build integration

package inttest

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

	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/session"
)

// TestListForAccount checks the lobby "my games" query: it returns exactly the
// games the account is seated in (each with its seats), and nothing for an outsider.
func TestListForAccount(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	me, opp := provisionAccount(t), provisionAccount(t)

	g1, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
	})
	if err != nil {
		t.Fatalf("create g1: %v", err)
	}
	g2, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2,
	})
	if err != nil {
		t.Fatalf("create g2: %v", err)
	}

	games, err := svc.ListForAccount(ctx, me)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(games) != 2 {
		t.Fatalf("got %d games, want 2", len(games))
	}
	seen := map[uuid.UUID]bool{}
	for _, g := range games {
		seen[g.ID] = true
		if len(g.Seats) != 2 {
			t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats))
		}
		seated := false
		for _, s := range g.Seats {
			if s.AccountID == me {
				seated = true
			}
		}
		if !seated {
			t.Errorf("account not found among seats of returned game %s", g.ID)
		}
	}
	if !seen[g1.ID] || !seen[g2.ID] {
		t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID)
	}

	other := provisionAccount(t)
	og, err := svc.ListForAccount(ctx, other)
	if err != nil {
		t.Fatalf("list other: %v", err)
	}
	if len(og) != 0 {
		t.Errorf("outsider sees %d games, want 0", len(og))
	}
}

// TestGameLifecycleAndStats drives a greedy two-player game to its natural end
// through the service and checks the finish state and statistics.
func TestGameLifecycleAndStats(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)

	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
		HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if g.Status != game.StatusActive || g.Players != 2 {
		t.Fatalf("unexpected new game: %+v", g)
	}

	mirror := newMirror(t, seed, 2)
	var last game.MoveResult
	for i := 0; i < 300 && !mirror.Over(); i++ {
		cur := seats[mirror.ToMove()]
		if hint, ok := mirror.HintView(); ok {
			last, err = svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles)
			if err != nil {
				t.Fatalf("submit play: %v", err)
			}
			if _, err := mirror.SubmitPlay(hint.Tiles); err != nil {
				t.Fatalf("mirror play: %v", err)
			}
		} else {
			last, err = svc.Pass(ctx, g.ID, cur)
			if err != nil {
				t.Fatalf("pass: %v", err)
			}
			if _, err := mirror.Pass(); err != nil {
				t.Fatalf("mirror pass: %v", err)
			}
		}
	}
	if !mirror.Over() {
		t.Fatal("greedy game did not finish")
	}
	if last.Game.Status != game.StatusFinished || last.Game.EndReason == "" {
		t.Fatalf("final game not finished: %+v", last.Game)
	}

	w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
	w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
	if !ok0 || !ok1 {
		t.Fatal("both players must have a stats row")
	}
	if mg0 <= 0 || mg1 <= 0 {
		t.Errorf("max game points must be positive: %d, %d", mg0, mg1)
	}
	decisive := (w0 == 1 && l1 == 1) || (w1 == 1 && l0 == 1)
	draw := d0 == 1 && d1 == 1 && w0 == 0 && w1 == 0
	if !decisive && !draw {
		t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
	}

	// Each player who made a scoring play gets exactly one per-variant best move (only
	// scrabble_en was played here); its score equals the aggregate max_word_points and it
	// carries the decoded word as tiles.
	accounts := account.NewStore(testDB)
	for _, p := range []struct {
		id      uuid.UUID
		maxWord int
	}{{seats[0], mw0}, {seats[1], mw1}} {
		if p.maxWord == 0 {
			continue // a player who only passed has no best move
		}
		st, err := accounts.GetStats(ctx, p.id)
		if err != nil {
			t.Fatalf("get stats: %v", err)
		}
		// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
		// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
		if st.Moves <= 0 {
			t.Errorf("moves = %d, want > 0", st.Moves)
		}
		if st.HintsUsed != 0 {
			t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
		}
		if len(st.BestMoves) != 1 {
			t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
		}
		bm := st.BestMoves[0]
		if bm.Variant != engine.VariantEnglish.String() {
			t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
		}
		if bm.Score != p.maxWord {
			t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
		}
		if len(bm.Tiles) == 0 {
			t.Error("best move word is empty")
		}
		for _, tl := range bm.Tiles {
			if tl.Letter == "" {
				t.Error("best move tile has empty letter")
			}
			if tl.Blank && tl.Value != 0 {
				t.Errorf("blank tile must score 0, got %d", tl.Value)
			}
		}
	}
}

// TestReplayEquivalence plays a few moves through one service, then proves a
// second service with a cold cache rebuilds the identical hidden state from the
// journal.
func TestReplayEquivalence(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	mirror := newMirror(t, seed, 2)
	for i := 0; i < 6 && !mirror.Over(); i++ {
		cur := seats[mirror.ToMove()]
		if hint, ok := mirror.HintView(); ok {
			if _, err := svc.SubmitPlay(ctx, g.ID, cur, hint.Tiles); err != nil {
				t.Fatalf("submit: %v", err)
			}
			mirror.SubmitPlay(hint.Tiles)
		} else {
			if _, err := svc.Pass(ctx, g.ID, cur); err != nil {
				t.Fatalf("pass: %v", err)
			}
			mirror.Pass()
		}
	}

	warm, err := svc.GameState(ctx, g.ID, seats[0])
	if err != nil {
		t.Fatalf("warm state: %v", err)
	}
	cold, err := newGameService().GameState(ctx, g.ID, seats[0]) // fresh cache → replay
	if err != nil {
		t.Fatalf("cold state: %v", err)
	}
	if warm.BagLen != cold.BagLen {
		t.Errorf("bag len warm %d != cold %d", warm.BagLen, cold.BagLen)
	}
	if !equalStrings(warm.Rack, cold.Rack) {
		t.Errorf("rack warm %v != replayed %v", warm.Rack, cold.Rack)
	}
}

// TestResignWinnerAndStats checks that a resigner loses and keeps their score
// while the opponent wins, with statistics to match.
func TestResignWinnerAndStats(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	mirror := newMirror(t, seed, 2)
	hint, ok := mirror.HintView()
	if !ok {
		t.Fatal("no opening move")
	}
	played, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles) // p0 scores
	if err != nil {
		t.Fatalf("p0 play: %v", err)
	}
	score0 := played.Game.Seats[0].Score

	res, err := svc.Resign(ctx, g.ID, seats[1]) // p1 (trailing 0) resigns
	if err != nil {
		t.Fatalf("resign: %v", err)
	}
	if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
		t.Fatalf("after resign: %+v", res.Game)
	}
	if !res.Game.Seats[0].IsWinner || res.Game.Seats[1].IsWinner {
		t.Errorf("winner flags wrong: %+v", res.Game.Seats)
	}
	if res.Game.Seats[0].Score != score0 {
		t.Errorf("winner score changed on resign: %d -> %d", score0, res.Game.Seats[0].Score)
	}

	w0, l0, _, _, _, _ := readStats(t, seats[0])
	w1, l1, _, _, _, _ := readStats(t, seats[1])
	if w0 != 1 || l0 != 0 || w1 != 0 || l1 != 1 {
		t.Errorf("resign stats wrong: p0(%d/%d) p1(%d/%d)", w0, l0, w1, l1)
	}
}

// TestResignOnOpponentTurn checks a player can forfeit on the
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score.
func TestResignOnOpponentTurn(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	hint, ok := newMirror(t, seed, 2).HintView()
	if !ok {
		t.Fatal("no opening move")
	}
	if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil { // p0 scores, now p1's turn
		t.Fatalf("p0 play: %v", err)
	}

	res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
	if err != nil {
		t.Fatalf("off-turn resign = %v, want nil", err)
	}
	if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
		t.Fatalf("after off-turn resign: %+v", res.Game)
	}
	if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
		t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
	}
}

// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))

	// Disable the to-move account's away window: with the default 00:00–07:00
	// window the sweeper (correctly) declines to time out a player whose deadline
	// fell while they were asleep, which made this test fail whenever CI ran with
	// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
	// deterministic regardless of the time of day.
	setAway(t, seats[0], "UTC", "00:00", "00:00")

	// The sweep is global over the shared pool; assert the target game itself,
	// not the count, since other tests leave active games behind.
	if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
		t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
	}
	st, err := svc.History(ctx, g.ID)
	if err != nil {
		t.Fatalf("history: %v", err)
	}
	if st.Game.Status != game.StatusFinished || st.Game.EndReason != "timeout" {
		t.Fatalf("game not timed out: %+v", st.Game)
	}
	if !st.Game.Seats[1].IsWinner { // seat 0 was to move and timed out
		t.Errorf("opponent should win on timeout: %+v", st.Game.Seats)
	}
	w1, _, _, _, _, _ := readStats(t, seats[1])
	if w1 != 1 {
		t.Errorf("opponent wins = %d, want 1", w1)
	}
}

// TestTimeoutRespectsAwayWindow keeps a player who is asleep from being timed
// out until their away window ends.
func TestTimeoutRespectsAwayWindow(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	// Seat 0 (to move) sleeps the whole UTC day except a one-minute gap, so any
	// deadline lands inside the window.
	setAway(t, seats[0], "UTC", "00:00", "23:59")
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	// Deadline at 12:00 UTC, well inside the away window.
	turnStart := time.Date(2026, 6, 2, 11, 0, 0, 0, time.UTC)
	backdate(t, g.ID, turnStart)

	// A sweep whose clock sits inside the away window must leave the target game
	// active. (The sweep is global; assert the target, not the count.)
	if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 2, 12, 30, 0, 0, time.UTC)); err != nil {
		t.Fatalf("sweep inside away window: %v", err)
	}
	if status, _ := gameStatus(t, svc, g.ID); status != game.StatusActive {
		t.Fatalf("target timed out inside its away window (status %q)", status)
	}
	// Once the clock passes the window's end, it must time out.
	if _, err := svc.SweepTimeouts(ctx, time.Date(2026, 6, 3, 23, 59, 0, 0, time.UTC)); err != nil {
		t.Fatalf("sweep after away window: %v", err)
	}
	if status, reason := gameStatus(t, svc, g.ID); status != game.StatusFinished || reason != "timeout" {
		t.Fatalf("target not timed out after window: status %q reason %q", status, reason)
	}
}

// gameStatus returns a game's status and end reason via the service.
func gameStatus(t *testing.T, svc *game.Service, id uuid.UUID) (status, endReason string) {
	t.Helper()
	h, err := svc.History(context.Background(), id)
	if err != nil {
		t.Fatalf("game status: %v", err)
	}
	return h.Game.Status, h.Game.EndReason
}

// TestHintPolicy exercises the per-game allowance, the profile wallet and the
// disabled switch.
func TestHintPolicy(t *testing.T) {
	// A trusted platform context so the online hint wallet (payments) is reachable; a provisioned
	// account carries a telegram identity, so the telegram origin is the applicable one here.
	ctx := session.WithPlatform(context.Background(),
		session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid})
	svc := newGameService()
	pay := newPaymentsService()
	svc.SetHintWallet(pay) // share the handle so a grant invalidates the wallet the game reads
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
		HintsAllowed: true, HintsPerPlayer: 1, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
		t.Fatalf("first hint: %v", err)
	}
	// The allowance is spent before the wallet: with an empty wallet, the state now reports no
	// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
	st, err := svc.GameState(ctx, g.ID, seats[0])
	if err != nil {
		t.Fatalf("state: %v", err)
	}
	if st.HintsRemaining != 0 {
		t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
	}
	if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
		t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
	}
	// Grant 2 hints on the telegram origin through the service so its read cache is invalidated
	// (a raw insert would leave the cache warmed at zero by the allowance hint above).
	if err := pay.Grant(ctx, seats[0], payments.SourceTelegram, 2, 0, false); err != nil {
		t.Fatalf("grant hints: %v", err)
	}
	res, err := svc.Hint(ctx, g.ID, seats[0]) // spends the wallet
	if err != nil {
		t.Fatalf("wallet hint: %v", err)
	}
	// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
	// 2->1 and WalletBalance carries it (the client adopts it into the profile).
	if res.HintsRemaining != 0 || res.WalletBalance != 1 {
		t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
	}
	// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
	// that feeds the player's lifetime hint statistics, not just the allowance.
	st2, err := svc.GameState(ctx, g.ID, seats[0])
	if err != nil {
		t.Fatalf("state after wallet hint: %v", err)
	}
	if got := st2.Game.Seats[0].HintsUsed; got != 2 {
		t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
	}

	off, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
		HintsAllowed: false, HintsPerPlayer: 1, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create off: %v", err)
	}
	if _, err := svc.Hint(ctx, off.ID, seats[0]); !errors.Is(err, game.ErrHintsDisabled) {
		t.Fatalf("disabled hint = %v, want ErrHintsDisabled", err)
	}
}

// TestGameVariant covers the edge's lightweight variant lookup: it returns the
// created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
		t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
	}
	if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
		t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
	}
}

// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
func TestCheckWordAndComplaint(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	if ok, err := svc.CheckWord(ctx, g.ID, "CAT"); err != nil || !ok {
		t.Errorf("CheckWord cat = %v, %v; want true", ok, err)
	}
	if ok, err := svc.CheckWord(ctx, g.ID, "zzzzzz"); err != nil || ok {
		t.Errorf("CheckWord zzzzzz = %v, %v; want false", ok, err)
	}

	c, err := svc.FileComplaint(ctx, g.ID, seats[0], "ZZZZZZ", "should be a word")
	if err != nil {
		t.Fatalf("file complaint: %v", err)
	}
	if c.ID == uuid.Nil || c.Word != "zzzzzz" || c.WasValid || c.Status != game.StatusComplaintOpen {
		t.Errorf("unexpected complaint: %+v", c)
	}
}

// TestEvaluatePlayPreview previews a legal and an illegal play without committing.
func TestEvaluatePlayPreview(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	hint, _ := newMirror(t, seed, 2).HintView()

	eval, err := svc.EvaluatePlay(ctx, g.ID, seats[0], hint.Tiles)
	if err != nil {
		t.Fatalf("evaluate: %v", err)
	}
	if !eval.Valid || eval.Score <= 0 {
		t.Errorf("legal preview = %+v, want valid with score", eval)
	}
	if eval.Dir != hint.Dir.String() || len(eval.Words) == 0 {
		t.Errorf("legal preview dir/words = (%q, %v), want dir %q with the words formed", eval.Dir, eval.Words, hint.Dir.String())
	}
	// The same play must still be available afterwards (no commit).
	if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err != nil {
		t.Fatalf("submit after evaluate: %v", err)
	}

	bad, err := svc.EvaluatePlay(ctx, g.ID, seats[1], []engine.TileRecord{{Row: 0, Col: 0, Letter: "q"}})
	if err != nil {
		t.Fatalf("evaluate illegal: %v", err)
	}
	if bad.Valid {
		t.Error("disconnected play must be invalid")
	}

	// A non-seated account cannot preview: with the game warm in the live cache, the
	// membership check runs against the cached seat list (the hot path that skips GetGame).
	if _, err := svc.EvaluatePlay(ctx, g.ID, provisionAccount(t), hint.Tiles); !errors.Is(err, game.ErrNotAPlayer) {
		t.Errorf("evaluate by a non-player = %v, want ErrNotAPlayer", err)
	}
}

// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
// racing identical submissions win.
func TestConcurrentSubmitSerialized(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	hint, _ := newMirror(t, seed, 2).HintView()

	var wg sync.WaitGroup
	var mu sync.Mutex
	var ok int
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Tiles); err == nil {
				mu.Lock()
				ok++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if ok != 1 {
		t.Errorf("successful submits = %d, want exactly 1", ok)
	}
}

func backdate(t *testing.T, gameID uuid.UUID, at time.Time) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE backend.games SET turn_started_at = $1 WHERE game_id = $2`, at, gameID); err != nil {
		t.Fatalf("backdate: %v", err)
	}
}

func setAway(t *testing.T, id uuid.UUID, tz, start, end string) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE backend.accounts SET time_zone = $1, away_start = $2::time, away_end = $3::time WHERE account_id = $4`,
		tz, start, end, id); err != nil {
		t.Fatalf("set away: %v", err)
	}
}

func equalStrings(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
// is allowed only once the game is over, so an active game leaks nothing mid-play.
func TestExportGCGRefusesActiveGame(t *testing.T) {
	ctx := context.Background()
	gameID, _ := newGameWithSeats(t, 2)
	if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) {
		t.Fatalf("export of active game = %v, want ErrGameActive", err)
	}
}
