package lobby

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"slices"
	"time"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/gamelimits"
	"scrabble/backend/internal/notify"
	"scrabble/backend/internal/postgres/jet/backend/model"
	"scrabble/backend/internal/postgres/jet/backend/table"
)

// invitationTTL is how long an unanswered invitation stays open before it lazily
// expires.
const invitationTTL = 7 * 24 * time.Hour

// Invitation statuses.
const (
	invitationPending   = "pending"
	invitationDeclined  = "declined"
	invitationCancelled = "cancelled"
	invitationExpired   = "expired"
	invitationStarted   = "started"
)

// Invitee responses.
const (
	inviteePending  = "pending"
	inviteeAccepted = "accepted"
	inviteeDeclined = "declined"
)

// InvitationSettings are the game settings an inviter chooses. A zero TurnTimeout
// defaults to game.DefaultTurnTimeout; the zero DropoutTiles removes a leaver's
// tiles from play.
type InvitationSettings struct {
	Variant        engine.Variant
	TurnTimeout    time.Duration
	HintsAllowed   bool
	HintsPerPlayer int
	DropoutTiles   engine.DropoutTiles
	// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
	MultipleWordsPerTurn bool
}

// Invitee is one invited player's seat and response.
type Invitee struct {
	AccountID uuid.UUID
	Seat      int
	Response  string
}

// Invitation is a friend-game invitation with its invitees.
type Invitation struct {
	ID        uuid.UUID
	InviterID uuid.UUID
	Settings  InvitationSettings
	Status    string
	GameID    *uuid.UUID
	ExpiresAt time.Time
	CreatedAt time.Time
	Invitees  []Invitee
}

// InvitationService creates and resolves friend-game invitations, starting the
// game through a GameCreator once every invitee has accepted.
type InvitationService struct {
	store    *Store
	games    GameCreator
	accounts *account.Store
	blocker  Blocker
	pub      notify.Publisher
	now      func() time.Time
}

// NewInvitationService constructs an InvitationService. store owns the invitation
// tables; games starts the accepted game; accounts validates invitees; blocker
// refuses invitations across a block.
func NewInvitationService(store *Store, games GameCreator, accounts *account.Store, blocker Blocker) *InvitationService {
	return &InvitationService{
		store:    store,
		games:    games,
		accounts: accounts,
		blocker:  blocker,
		pub:      notify.Nop{},
		now:      func() time.Time { return time.Now().UTC() },
	}
}

// SetNotifier installs the live-event publisher used to nudge invitees' lobby
// badges when an invitation arrives and to tell all seats when the game starts. It
// must be called during startup wiring; the default is notify.Nop (no live events,
// invitees still see the invitation on the next lobby poll).
func (svc *InvitationService) SetNotifier(p notify.Publisher) {
	if p != nil {
		svc.pub = p
	}
}

// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
// itself so the client adds it to its lobby list without a refetch.
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
	if len(inviteeIDs) == 0 {
		return
	}
	summary := svc.invitationSummary(ctx, inv)
	intents := make([]notify.Intent, 0, len(inviteeIDs))
	for _, id := range inviteeIDs {
		intents = append(intents, notify.NotificationInvitation(id, summary))
	}
	svc.pub.Publish(intents...)
}

// emitGameStarted publishes the game_started notification to each seated player, carrying their
// initial view of the started game so the client seeds its game cache without a refetch. A
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
	intents := make([]notify.Intent, 0, len(seats))
	for _, id := range seats {
		state, err := svc.games.InitialState(ctx, g.ID, id)
		if err != nil {
			continue
		}
		intents = append(intents, notify.NotificationGameStarted(id, state))
	}
	svc.pub.Publish(intents...)
}

// emitInvitationUpdate publishes a changed invitation to every participant (the inviter and all
// invitees), so each one's lobby patches its invitations list without a refetch: a still-pending
// invitation is upserted (an updated response) and a terminal one (started, declined, cancelled,
// expired) removed. It is in-app only — notify.NotifyInvitationUpdate carries no out-of-app push —
// so a withdrawal or decline never becomes a platform notification.
func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invitation) {
	summary := svc.invitationSummary(ctx, inv)
	recipients := make([]uuid.UUID, 0, len(inv.Invitees)+1)
	recipients = append(recipients, inv.InviterID)
	for _, iv := range inv.Invitees {
		recipients = append(recipients, iv.AccountID)
	}
	intents := make([]notify.Intent, 0, len(recipients))
	for _, id := range recipients {
		// An invitee who has blocked the inviter never sees this invitation, so they must not
		// receive its updates either (an update would otherwise re-add it to their lobby). The
		// inviter always gets updates; on a lookup error suppress rather than risk a leak.
		if id != inv.InviterID {
			if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
				continue
			}
		}
		intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
	}
	svc.pub.Publish(intents...)
}

// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
// resolving the inviter's and invitees' display names from the account store.
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
	name := func(id uuid.UUID) string {
		if acc, err := svc.accounts.GetByID(ctx, id); err == nil {
			return acc.DisplayName
		}
		return ""
	}
	invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees))
	for _, iv := range inv.Invitees {
		invitees = append(invitees, notify.InvitationInvitee{
			AccountID:   iv.AccountID.String(),
			DisplayName: name(iv.AccountID),
			Seat:        iv.Seat,
			Response:    iv.Response,
		})
	}
	gameID := ""
	if inv.GameID != nil {
		gameID = inv.GameID.String()
	}
	return notify.InvitationSummary{
		ID:                   inv.ID.String(),
		Inviter:              notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
		Invitees:             invitees,
		Variant:              inv.Settings.Variant.String(),
		TurnTimeoutSecs:      int(inv.Settings.TurnTimeout / time.Second),
		HintsAllowed:         inv.Settings.HintsAllowed,
		HintsPerPlayer:       inv.Settings.HintsPerPlayer,
		MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
		DropoutTiles:         inv.Settings.DropoutTiles.String(),
		Status:               inv.Status,
		GameID:               gameID,
		ExpiresAtUnix:        inv.ExpiresAt.Unix(),
	}
}

// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
// invitees distinct and not the inviter, every invitee an existing account with no
// block standing between them, and the settings acceptable.
func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uuid.UUID, inviteeIDs []uuid.UUID, settings InvitationSettings) (Invitation, error) {
	if n := len(inviteeIDs) + 1; n < 2 || n > 4 {
		return Invitation{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidInvitation, n)
	}
	if settings.HintsPerPlayer < 0 {
		return Invitation{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidInvitation)
	}
	if settings.TurnTimeout == 0 {
		settings.TurnTimeout = game.DefaultTurnTimeout
	}
	if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
		return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
	}
	// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
	// this is the server source of truth.
	if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
		return Invitation{}, err
	} else if acc.IsGuest {
		return Invitation{}, ErrGuestForbidden
	}
	// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
	// reached. The guest branch above short-circuits before here, so this is the durable cap.
	if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
		return Invitation{}, err
	} else if atLimit {
		return Invitation{}, game.ErrGameLimitReached
	}
	seen := map[uuid.UUID]bool{inviterID: true}
	// suppressed collects invitees who have blocked the inviter: the invitation is still
	// created and persisted for them, but they are never notified and never see it (their
	// friendship with the inviter survived the block, so they can still appear in the
	// inviter's picker — they must not learn of the block).
	suppressed := make(map[uuid.UUID]bool)
	for _, id := range inviteeIDs {
		if seen[id] {
			return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
		}
		seen[id] = true
		if _, err := svc.accounts.GetByID(ctx, id); err != nil {
			if errors.Is(err, account.ErrNotFound) {
				return Invitation{}, fmt.Errorf("%w: invitee %s not found", ErrInvalidInvitation, id)
			}
			return Invitation{}, err
		}
		// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
		// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
		iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
		if err != nil {
			return Invitation{}, err
		}
		if iBlock {
			return Invitation{}, ErrInvitationBlocked
		}
		theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
		if err != nil {
			return Invitation{}, err
		}
		if theyBlock {
			suppressed[id] = true
		}
	}

	id, err := uuid.NewV7()
	if err != nil {
		return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
	}
	ins := invitationInsert{
		id:                   id,
		inviterID:            inviterID,
		variant:              settings.Variant.String(),
		turnTimeoutSecs:      int(settings.TurnTimeout / time.Second),
		hintsAllowed:         settings.HintsAllowed,
		hintsPerPlayer:       settings.HintsPerPlayer,
		dropoutTiles:         settings.DropoutTiles.String(),
		multipleWordsPerTurn: settings.MultipleWordsPerTurn,
		expiresAt:            svc.now().Add(invitationTTL),
	}
	if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
		return Invitation{}, err
	}
	inv, err := svc.store.loadInvitation(ctx, id)
	if err != nil {
		return Invitation{}, err
	}
	// Notify every invitee except those who have blocked the inviter (their copy stays
	// stored but unseen — the store-but-hide that keeps the block invisible).
	notifyIDs := inviteeIDs
	if len(suppressed) > 0 {
		notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
		for _, id := range inviteeIDs {
			if !suppressed[id] {
				notifyIDs = append(notifyIDs, id)
			}
		}
	}
	svc.emitInvitation(ctx, inv, notifyIDs)
	return inv, nil
}

// RespondInvitation records accountID's accept or decline of an invitation. A
// decline cancels the whole invitation; the accept that completes the set starts
// the game and marks the invitation started.
func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationID, accountID uuid.UUID, accept bool) (Invitation, error) {
	res, err := svc.store.respondTx(ctx, invitationID, accountID, accept, svc.now())
	if err != nil {
		return Invitation{}, err
	}
	if accept && res.allAccepted {
		if err := svc.startGame(ctx, invitationID); err != nil {
			return Invitation{}, err
		}
	}
	inv, err := svc.store.loadInvitation(ctx, invitationID)
	if err != nil {
		return Invitation{}, err
	}
	// Tell every participant the invitation changed, so their lobby patches its list from any
	// screen: an updated response is upserted, a terminal one (declined, or started after the final
	// accept) removed. startGame already emitted the started-game notification separately.
	svc.emitInvitationUpdate(ctx, inv)
	return inv, nil
}

// startGame creates the game for a fully-accepted invitation and marks it started.
func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.UUID) error {
	inv, err := svc.store.loadInvitation(ctx, invitationID)
	if err != nil {
		return err
	}
	seats := make([]uuid.UUID, len(inv.Invitees)+1)
	seats[0] = inv.InviterID
	for _, iv := range inv.Invitees {
		if iv.Seat < 1 || iv.Seat >= len(seats) {
			return fmt.Errorf("lobby: invitation %s has out-of-range seat %d", invitationID, iv.Seat)
		}
		seats[iv.Seat] = iv.AccountID
	}
	g, err := svc.games.Create(ctx, game.CreateParams{
		Variant:              inv.Settings.Variant,
		Seats:                seats,
		TurnTimeout:          inv.Settings.TurnTimeout,
		HintsAllowed:         inv.Settings.HintsAllowed,
		HintsPerPlayer:       inv.Settings.HintsPerPlayer,
		DropoutTiles:         inv.Settings.DropoutTiles,
		MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
		Kind:                 gamelimits.KindFriends,
	})
	if err != nil {
		return err
	}
	if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
		return err
	}
	svc.emitGameStarted(ctx, g, seats)
	return nil
}

// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
	if err := svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()); err != nil {
		return err
	}
	// The invitation is now cancelled: tell every participant so an invitee's lobby drops the card
	// from any screen. Best-effort — a failed reload never fails the cancel.
	if inv, err := svc.store.loadInvitation(ctx, invitationID); err == nil {
		svc.emitInvitationUpdate(ctx, inv)
	}
	return nil
}

// GetInvitation loads an invitation with its invitees.
func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uuid.UUID) (Invitation, error) {
	return svc.store.loadInvitation(ctx, invitationID)
}

// ListInvitations returns the open (pending, not yet expired) invitations that
// touch accountID, whether as the inviter or an invitee, newest first. Expired
// invitations are hidden here (lazy expiry); the row's transition to 'expired'
// happens on the next response or cancel.
func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uuid.UUID) ([]Invitation, error) {
	ids, err := svc.store.listInvitationIDs(ctx, accountID, svc.now())
	if err != nil {
		return nil, err
	}
	out := make([]Invitation, 0, len(ids))
	for _, id := range ids {
		inv, err := svc.store.loadInvitation(ctx, id)
		if err != nil {
			return nil, err
		}
		// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
		// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
		if inv.InviterID != accountID {
			blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
			if err != nil {
				return nil, err
			}
			if blocked {
				continue
			}
		}
		out = append(out, inv)
	}
	return out, nil
}

// invitationInsert carries the immutable fields of a new invitation.
type invitationInsert struct {
	id              uuid.UUID
	inviterID       uuid.UUID
	variant         string
	turnTimeoutSecs int
	hintsAllowed    bool
	hintsPerPlayer  int
	dropoutTiles    string
	expiresAt       time.Time
	// multipleWordsPerTurn false selects the single-word rule.
	multipleWordsPerTurn bool
}

// respondResult reports the state after an invitee response.
type respondResult struct {
	allAccepted bool
}

// insertInvitation inserts the invitation and one invitee row per id (seats 1..N).
func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, inviteeIDs []uuid.UUID) error {
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		ii := table.GameInvitations.INSERT(
			table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
			table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
			table.GameInvitations.DropoutTiles, table.GameInvitations.MultipleWordsPerTurn, table.GameInvitations.ExpiresAt,
		).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.expiresAt)
		if _, err := ii.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("insert invitation: %w", err)
		}
		for i, id := range inviteeIDs {
			pi := table.GameInvitationInvitees.INSERT(
				table.GameInvitationInvitees.InvitationID, table.GameInvitationInvitees.AccountID, table.GameInvitationInvitees.Seat,
			).VALUES(ins.id, id, i+1)
			if _, err := pi.ExecContext(ctx, tx); err != nil {
				return fmt.Errorf("insert invitee %d: %w", i+1, err)
			}
		}
		return nil
	})
}

// loadInvitation reads an invitation and its invitees ordered by seat.
func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, error) {
	isel := postgres.SELECT(table.GameInvitations.AllColumns).
		FROM(table.GameInvitations).
		WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.GameInvitations
	if err := isel.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Invitation{}, ErrInvitationNotFound
		}
		return Invitation{}, fmt.Errorf("lobby: load invitation %s: %w", id, err)
	}
	variant, err := engine.ParseVariant(row.Variant)
	if err != nil {
		return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
	}
	dropout, err := engine.ParseDropoutTiles(row.DropoutTiles)
	if err != nil {
		return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
	}
	inv := Invitation{
		ID:        row.InvitationID,
		InviterID: row.InviterID,
		Settings: InvitationSettings{
			Variant:              variant,
			TurnTimeout:          time.Duration(row.TurnTimeoutSecs) * time.Second,
			HintsAllowed:         row.HintsAllowed,
			HintsPerPlayer:       int(row.HintsPerPlayer),
			DropoutTiles:         dropout,
			MultipleWordsPerTurn: row.MultipleWordsPerTurn,
		},
		Status:    row.Status,
		GameID:    row.GameID,
		ExpiresAt: row.ExpiresAt,
		CreatedAt: row.CreatedAt,
	}
	psel := postgres.SELECT(table.GameInvitationInvitees.AllColumns).
		FROM(table.GameInvitationInvitees).
		WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(id))).
		ORDER_BY(table.GameInvitationInvitees.Seat.ASC())
	var prows []model.GameInvitationInvitees
	if err := psel.QueryContext(ctx, s.db, &prows); err != nil {
		return Invitation{}, fmt.Errorf("lobby: load invitees %s: %w", id, err)
	}
	for _, p := range prows {
		inv.Invitees = append(inv.Invitees, Invitee{AccountID: p.AccountID, Seat: int(p.Seat), Response: p.Response})
	}
	return inv, nil
}

// listInvitationIDs returns the ids of every pending, still-live invitation that
// accountID is part of (as inviter or invitee), newest first. It runs two queries
// (one per role) and merges them, avoiding a correlated subquery.
func (s *Store) listInvitationIDs(ctx context.Context, accountID uuid.UUID, now time.Time) ([]uuid.UUID, error) {
	live := table.GameInvitations.Status.EQ(postgres.String(invitationPending)).
		AND(table.GameInvitations.ExpiresAt.GT(postgres.TimestampzT(now)))

	var asInviter []model.GameInvitations
	q1 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
		FROM(table.GameInvitations).
		WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(accountID)).AND(live))
	if err := q1.QueryContext(ctx, s.db, &asInviter); err != nil {
		return nil, fmt.Errorf("lobby: list invitations as inviter: %w", err)
	}

	var asInvitee []model.GameInvitations
	q2 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt).
		FROM(table.GameInvitations.INNER_JOIN(
			table.GameInvitationInvitees,
			table.GameInvitationInvitees.InvitationID.EQ(table.GameInvitations.InvitationID),
		)).
		WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID)).AND(live))
	if err := q2.QueryContext(ctx, s.db, &asInvitee); err != nil {
		return nil, fmt.Errorf("lobby: list invitations as invitee: %w", err)
	}

	seen := make(map[uuid.UUID]bool, len(asInviter)+len(asInvitee))
	merged := make([]model.GameInvitations, 0, len(asInviter)+len(asInvitee))
	for _, r := range append(asInviter, asInvitee...) {
		if seen[r.InvitationID] {
			continue
		}
		seen[r.InvitationID] = true
		merged = append(merged, r)
	}
	slices.SortFunc(merged, func(a, b model.GameInvitations) int {
		return b.CreatedAt.Compare(a.CreatedAt)
	})
	out := make([]uuid.UUID, len(merged))
	for i, r := range merged {
		out[i] = r.InvitationID
	}
	return out, nil
}

// respondTx applies an invitee's response inside a row-locked transaction so
// concurrent responses serialise and exactly one accept can complete the set.
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
	var res respondResult
	err := withTx(ctx, s.db, func(tx *sql.Tx) error {
		isel := postgres.SELECT(table.GameInvitations.Status, table.GameInvitations.ExpiresAt).
			FROM(table.GameInvitations).
			WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID))).
			FOR(postgres.UPDATE())
		var inv model.GameInvitations
		if err := isel.QueryContext(ctx, tx, &inv); err != nil {
			if errors.Is(err, qrm.ErrNoRows) {
				return ErrInvitationNotFound
			}
			return fmt.Errorf("lock invitation: %w", err)
		}
		if inv.Status == invitationPending && now.After(inv.ExpiresAt) {
			if err := setInvitationStatus(ctx, tx, invitationID, invitationExpired, now); err != nil {
				return err
			}
			return ErrInvitationExpired
		}
		if inv.Status != invitationPending {
			return ErrInvitationNotPending
		}

		psel := postgres.SELECT(table.GameInvitationInvitees.Response).
			FROM(table.GameInvitationInvitees).
			WHERE(
				table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
					AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
			).LIMIT(1)
		var invitee model.GameInvitationInvitees
		if err := psel.QueryContext(ctx, tx, &invitee); err != nil {
			if errors.Is(err, qrm.ErrNoRows) {
				return ErrNotInvited
			}
			return fmt.Errorf("load invitee: %w", err)
		}
		if invitee.Response != inviteePending {
			return ErrAlreadyResponded
		}

		if !accept {
			if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeDeclined, now); err != nil {
				return err
			}
			return setInvitationStatus(ctx, tx, invitationID, invitationDeclined, now)
		}

		if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeAccepted, now); err != nil {
			return err
		}
		remaining, err := unacceptedInvitees(ctx, tx, invitationID)
		if err != nil {
			return err
		}
		res.allAccepted = remaining == 0
		return nil
	})
	return res, err
}

// markStarted stamps a fully-accepted invitation as started, only while it is
// still pending, and reports whether it did.
func (s *Store) markStarted(ctx context.Context, invitationID, gameID uuid.UUID, now time.Time) (bool, error) {
	stmt := table.GameInvitations.
		UPDATE(table.GameInvitations.Status, table.GameInvitations.GameID, table.GameInvitations.UpdatedAt).
		SET(postgres.String(invitationStarted), postgres.UUID(gameID), postgres.TimestampzT(now)).
		WHERE(
			table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
				AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
		)
	res, err := stmt.ExecContext(ctx, s.db)
	if err != nil {
		return false, fmt.Errorf("lobby: mark started: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("lobby: mark started rows: %w", err)
	}
	return n > 0, nil
}

// cancelInvitation withdraws a pending invitation on behalf of its inviter.
func (s *Store) cancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID, now time.Time) error {
	stmt := table.GameInvitations.
		UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
		SET(postgres.String(invitationCancelled), postgres.TimestampzT(now)).
		WHERE(
			table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
				AND(table.GameInvitations.InviterID.EQ(postgres.UUID(inviterID))).
				AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
		)
	res, err := stmt.ExecContext(ctx, s.db)
	if err != nil {
		return fmt.Errorf("lobby: cancel invitation: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("lobby: cancel invitation rows: %w", err)
	}
	if n == 0 {
		// Either the invitation is gone, not the caller's, or no longer pending.
		inv, err := s.loadInvitation(ctx, invitationID)
		if err != nil {
			return err
		}
		if inv.InviterID != inviterID {
			return ErrNotInviter
		}
		return ErrInvitationNotPending
	}
	return nil
}

// unacceptedInvitees counts the invitees of an invitation not yet accepted.
func unacceptedInvitees(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID) (int, error) {
	stmt := postgres.SELECT(table.GameInvitationInvitees.Response).
		FROM(table.GameInvitationInvitees).
		WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)))
	var rows []model.GameInvitationInvitees
	if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
		return 0, fmt.Errorf("count invitees: %w", err)
	}
	remaining := 0
	for _, r := range rows {
		if r.Response != inviteeAccepted {
			remaining++
		}
	}
	return remaining, nil
}

// setInvitationStatus updates an invitation's status and updated_at.
func setInvitationStatus(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID, status string, now time.Time) error {
	stmt := table.GameInvitations.
		UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
		SET(postgres.String(status), postgres.TimestampzT(now)).
		WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)))
	if _, err := stmt.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("set invitation status: %w", err)
	}
	return nil
}

// setInviteeResponse updates one invitee's response and responded_at.
func setInviteeResponse(ctx context.Context, tx *sql.Tx, invitationID, accountID uuid.UUID, response string, now time.Time) error {
	stmt := table.GameInvitationInvitees.
		UPDATE(table.GameInvitationInvitees.Response, table.GameInvitationInvitees.RespondedAt).
		SET(postgres.String(response), postgres.TimestampzT(now)).
		WHERE(
			table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
				AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
		)
	if _, err := stmt.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("set invitee response: %w", err)
	}
	return nil
}
