package lobby

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	"galaxy/backend/internal/postgres/jet/backend/model"
	"galaxy/backend/internal/postgres/jet/backend/table"

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

// Constraint names mirror the names declared in
// `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as
// constants keeps error classification robust against typos.
const (
	constraintMembershipsGameUserUnique = "memberships_game_user_unique"
	constraintApplicationsActiveUnique  = "applications_active_per_user_game_uidx"
	constraintInvitesCodeUnique         = "invites_code_uidx"
	constraintRaceNamesPK               = "race_names_pkey"
	constraintRaceNamesRegisteredUnique = "race_names_registered_uidx"
)

// Store is the Postgres-backed query surface for the lobby package. All
// queries are built through go-jet against the generated table bindings
// under `backend/internal/postgres/jet/backend/table`.
type Store struct {
	db *sql.DB
}

// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }

// gameColumns is the canonical projection for game reads.
func gameColumns() postgres.ColumnList {
	g := table.Games
	return postgres.ColumnList{
		g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description,
		g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers,
		g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion,
		g.RuntimeSnapshot, g.CreatedAt, g.UpdatedAt, g.StartedAt, g.FinishedAt,
	}
}

// applicationColumns is the canonical projection for application reads.
func applicationColumns() postgres.ColumnList {
	a := table.Applications
	return postgres.ColumnList{
		a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status,
		a.CreatedAt, a.DecidedAt,
	}
}

// inviteColumns is the canonical projection for invite reads.
func inviteColumns() postgres.ColumnList {
	i := table.Invites
	return postgres.ColumnList{
		i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code, i.Status,
		i.RaceName, i.CreatedAt, i.ExpiresAt, i.DecidedAt,
	}
}

// membershipColumns is the canonical projection for membership reads.
func membershipColumns() postgres.ColumnList {
	m := table.Memberships
	return postgres.ColumnList{
		m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status,
		m.JoinedAt, m.RemovedAt,
	}
}

// raceNameColumns is the canonical projection for race-name reads.
func raceNameColumns() postgres.ColumnList {
	r := table.RaceNames
	return postgres.ColumnList{
		r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID,
		r.ReservedAt, r.ExpiresAt, r.RegisteredAt,
	}
}

// gameInsert is the parameter struct for InsertGame.
type gameInsert struct {
	GameID              uuid.UUID
	OwnerUserID         *uuid.UUID
	Visibility          string
	GameName            string
	Description         string
	MinPlayers          int32
	MaxPlayers          int32
	StartGapHours       int32
	StartGapPlayers     int32
	EnrollmentEndsAt    time.Time
	TurnSchedule        string
	TargetEngineVersion string
}

// InsertGame persists a brand-new draft game record together with an
// empty runtime snapshot.
func (s *Store) InsertGame(ctx context.Context, in gameInsert) (GameRecord, error) {
	emptySnapshot, err := json.Marshal(RuntimeSnapshot{})
	if err != nil {
		return GameRecord{}, fmt.Errorf("lobby store: marshal empty snapshot: %w", err)
	}
	g := table.Games
	stmt := g.INSERT(
		g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description,
		g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers,
		g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion,
		g.RuntimeSnapshot,
	).VALUES(
		in.GameID, ownerArg(in.OwnerUserID), in.Visibility, GameStatusDraft,
		in.GameName, in.Description,
		in.MinPlayers, in.MaxPlayers, in.StartGapHours, in.StartGapPlayers,
		in.EnrollmentEndsAt, in.TurnSchedule, in.TargetEngineVersion,
		string(emptySnapshot),
	).RETURNING(gameColumns())

	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return GameRecord{}, fmt.Errorf("lobby store: insert game: %w", err)
	}
	return modelToGameRecord(row)
}

// LoadGame returns the game record for gameID. Returns ErrNotFound when
// no row matches.
func (s *Store) LoadGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
	g := table.Games
	stmt := postgres.SELECT(gameColumns()).
		FROM(g).
		WHERE(g.GameID.EQ(postgres.UUID(gameID))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return GameRecord{}, ErrNotFound
		}
		return GameRecord{}, fmt.Errorf("lobby store: load game %s: %w", gameID, err)
	}
	return modelToGameRecord(row)
}

// ListPublicGames returns the requested page of public games together
// with the total count for pagination.
func (s *Store) ListPublicGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) {
	g := table.Games
	totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
		FROM(g).
		WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic)))
	var totalDest struct {
		Count int64 `alias:"count"`
	}
	if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
		return nil, 0, fmt.Errorf("lobby store: count public games: %w", err)
	}
	offset := (page - 1) * pageSize
	listStmt := postgres.SELECT(gameColumns()).
		FROM(g).
		WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic))).
		ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()).
		LIMIT(int64(pageSize)).OFFSET(int64(offset))
	var rows []model.Games
	if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, 0, fmt.Errorf("lobby store: list public games: %w", err)
	}
	games, err := modelsToGameRecords(rows)
	if err != nil {
		return nil, 0, err
	}
	return games, int(totalDest.Count), nil
}

// ListAllGames returns every game row, used by Cache.Warm at startup.
func (s *Store) ListAllGames(ctx context.Context) ([]GameRecord, error) {
	stmt := postgres.SELECT(gameColumns()).FROM(table.Games)
	var rows []model.Games
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list all games: %w", err)
	}
	return modelsToGameRecords(rows)
}

// ListAdminGames returns the requested page of every game (admin view)
// together with the total count.
func (s *Store) ListAdminGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) {
	g := table.Games
	totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(g)
	var totalDest struct {
		Count int64 `alias:"count"`
	}
	if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
		return nil, 0, fmt.Errorf("lobby store: count games: %w", err)
	}
	offset := (page - 1) * pageSize
	listStmt := postgres.SELECT(gameColumns()).
		FROM(g).
		ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()).
		LIMIT(int64(pageSize)).OFFSET(int64(offset))
	var rows []model.Games
	if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, 0, fmt.Errorf("lobby store: list admin games: %w", err)
	}
	games, err := modelsToGameRecords(rows)
	if err != nil {
		return nil, 0, err
	}
	return games, int(totalDest.Count), nil
}

// ListMyGames returns every game where userID has an active membership,
// ordered by created_at DESC.
func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) {
	g := table.Games
	m := table.Memberships
	stmt := postgres.SELECT(gameColumns()).
		FROM(g.INNER_JOIN(m, m.GameID.EQ(g.GameID))).
		WHERE(
			m.UserID.EQ(postgres.UUID(userID)).
				AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
		).
		ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC())
	var rows []model.Games
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list my games: %w", err)
	}
	return modelsToGameRecords(rows)
}

// DeleteGame removes the row at gameID. Cascades through every
// referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal
// games on every backend boot so the developer's lobby never piles
// up cancelled tiles.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
	g := table.Games
	stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("lobby store: delete game %s: %w", gameID, err)
	}
	return nil
}

// gameUpdate is the parameter struct for UpdateGame. Nil pointers leave
// the corresponding column alone.
type gameUpdate struct {
	GameName            *string
	Description         *string
	EnrollmentEndsAt    *time.Time
	TurnSchedule        *string
	TargetEngineVersion *string
	MinPlayers          *int32
	MaxPlayers          *int32
	StartGapHours       *int32
	StartGapPlayers     *int32
}

func (u gameUpdate) empty() bool {
	return u.GameName == nil && u.Description == nil && u.EnrollmentEndsAt == nil &&
		u.TurnSchedule == nil && u.TargetEngineVersion == nil &&
		u.MinPlayers == nil && u.MaxPlayers == nil &&
		u.StartGapHours == nil && u.StartGapPlayers == nil
}

// UpdateGame patches the supplied columns and bumps updated_at. Returns
// ErrNotFound when no row matches.
func (s *Store) UpdateGame(ctx context.Context, gameID uuid.UUID, patch gameUpdate, now time.Time) (GameRecord, error) {
	if patch.empty() {
		return s.LoadGame(ctx, gameID)
	}
	g := table.Games
	rest := []any{}
	if patch.GameName != nil {
		rest = append(rest, g.GameName.SET(postgres.String(*patch.GameName)))
	}
	if patch.Description != nil {
		rest = append(rest, g.Description.SET(postgres.String(*patch.Description)))
	}
	if patch.EnrollmentEndsAt != nil {
		rest = append(rest, g.EnrollmentEndsAt.SET(postgres.TimestampzT(*patch.EnrollmentEndsAt)))
	}
	if patch.TurnSchedule != nil {
		rest = append(rest, g.TurnSchedule.SET(postgres.String(*patch.TurnSchedule)))
	}
	if patch.TargetEngineVersion != nil {
		rest = append(rest, g.TargetEngineVersion.SET(postgres.String(*patch.TargetEngineVersion)))
	}
	if patch.MinPlayers != nil {
		rest = append(rest, g.MinPlayers.SET(postgres.Int(int64(*patch.MinPlayers))))
	}
	if patch.MaxPlayers != nil {
		rest = append(rest, g.MaxPlayers.SET(postgres.Int(int64(*patch.MaxPlayers))))
	}
	if patch.StartGapHours != nil {
		rest = append(rest, g.StartGapHours.SET(postgres.Int(int64(*patch.StartGapHours))))
	}
	if patch.StartGapPlayers != nil {
		rest = append(rest, g.StartGapPlayers.SET(postgres.Int(int64(*patch.StartGapPlayers))))
	}
	stmt := g.UPDATE().
		SET(g.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
		WHERE(g.GameID.EQ(postgres.UUID(gameID))).
		RETURNING(gameColumns())

	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return GameRecord{}, ErrNotFound
		}
		return GameRecord{}, fmt.Errorf("lobby store: update game %s: %w", gameID, err)
	}
	return modelToGameRecord(row)
}

// statusUpdate carries the parameters for UpdateGameStatus. SetStarted
// /ClearStarted/SetFinished are mutually-exclusive flags driving the
// timestamp columns.
type statusUpdate struct {
	NewStatus    string
	UpdatedAt    time.Time
	SetStarted   bool
	StartedAt    time.Time
	SetFinished  bool
	FinishedAt   time.Time
	ClearStarted bool
}

// UpdateGameStatus transitions status and (optionally) updates the
// started_at / finished_at columns. Returns the refreshed row.
func (s *Store) UpdateGameStatus(ctx context.Context, gameID uuid.UUID, in statusUpdate) (GameRecord, error) {
	g := table.Games
	rest := []any{}
	switch {
	case in.SetStarted:
		rest = append(rest, g.StartedAt.SET(postgres.TimestampzT(in.StartedAt)))
	case in.ClearStarted:
		rest = append(rest, g.StartedAt.SET(postgres.TimestampzExp(postgres.NULL)))
	}
	if in.SetFinished {
		rest = append(rest, g.FinishedAt.SET(postgres.TimestampzT(in.FinishedAt)))
	}
	stmt := g.UPDATE().
		SET(
			g.Status.SET(postgres.String(in.NewStatus)),
			append([]any{g.UpdatedAt.SET(postgres.TimestampzT(in.UpdatedAt))}, rest...)...,
		).
		WHERE(g.GameID.EQ(postgres.UUID(gameID))).
		RETURNING(gameColumns())

	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return GameRecord{}, ErrNotFound
		}
		return GameRecord{}, fmt.Errorf("lobby store: update game status %s: %w", gameID, err)
	}
	return modelToGameRecord(row)
}

// UpdateGameRuntimeSnapshot replaces the JSON-encoded runtime snapshot
// for gameID. Used by `OnRuntimeSnapshot` and the per-event hooks.
func (s *Store) UpdateGameRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snapshot RuntimeSnapshot, now time.Time) (GameRecord, error) {
	encoded, err := json.Marshal(snapshot)
	if err != nil {
		return GameRecord{}, fmt.Errorf("lobby store: marshal snapshot: %w", err)
	}
	g := table.Games
	stmt := g.UPDATE().
		SET(
			g.RuntimeSnapshot.SET(postgres.StringExp(postgres.CAST(postgres.String(string(encoded))).AS("jsonb"))),
			g.UpdatedAt.SET(postgres.TimestampzT(now)),
		).
		WHERE(g.GameID.EQ(postgres.UUID(gameID))).
		RETURNING(gameColumns())
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return GameRecord{}, ErrNotFound
		}
		return GameRecord{}, fmt.Errorf("lobby store: update runtime snapshot %s: %w", gameID, err)
	}
	return modelToGameRecord(row)
}

// CountActiveMemberships returns the number of memberships in `active`
// status for gameID. Drives `approved_count >= min_players` checks.
func (s *Store) CountActiveMemberships(ctx context.Context, gameID uuid.UUID) (int, error) {
	m := table.Memberships
	stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
		FROM(m).
		WHERE(
			m.GameID.EQ(postgres.UUID(gameID)).
				AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
		)
	var dest struct {
		Count int64 `alias:"count"`
	}
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("lobby store: count active memberships %s: %w", gameID, err)
	}
	return int(dest.Count), nil
}

// applicationInsert carries the parameters for InsertApplication.
type applicationInsert struct {
	ApplicationID   uuid.UUID
	GameID          uuid.UUID
	ApplicantUserID uuid.UUID
	RaceName        string
}

// InsertApplication creates a fresh `pending` application. Returns
// ErrConflict on the partial UNIQUE violation against the per-user
// per-game active constraint.
func (s *Store) InsertApplication(ctx context.Context, in applicationInsert) (Application, error) {
	a := table.Applications
	stmt := a.INSERT(
		a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status,
	).VALUES(
		in.ApplicationID, in.GameID, in.ApplicantUserID, in.RaceName, ApplicationStatusPending,
	).RETURNING(applicationColumns())

	var row model.Applications
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if isUniqueViolation(err, constraintApplicationsActiveUnique) {
			return Application{}, fmt.Errorf("%w: application already exists for this user", ErrConflict)
		}
		return Application{}, fmt.Errorf("lobby store: insert application: %w", err)
	}
	return modelToApplication(row), nil
}

// LoadApplication returns the application for applicationID.
func (s *Store) LoadApplication(ctx context.Context, applicationID uuid.UUID) (Application, error) {
	a := table.Applications
	stmt := postgres.SELECT(applicationColumns()).
		FROM(a).
		WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))).
		LIMIT(1)
	var row model.Applications
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Application{}, ErrNotFound
		}
		return Application{}, fmt.Errorf("lobby store: load application %s: %w", applicationID, err)
	}
	return modelToApplication(row), nil
}

// UpdateApplicationStatus patches status and decided_at; returns the
// refreshed row.
func (s *Store) UpdateApplicationStatus(ctx context.Context, applicationID uuid.UUID, status string, decidedAt time.Time) (Application, error) {
	a := table.Applications
	stmt := a.UPDATE().
		SET(
			a.Status.SET(postgres.String(status)),
			a.DecidedAt.SET(postgres.TimestampzT(decidedAt)),
		).
		WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))).
		RETURNING(applicationColumns())
	var row model.Applications
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Application{}, ErrNotFound
		}
		return Application{}, fmt.Errorf("lobby store: update application status %s: %w", applicationID, err)
	}
	return modelToApplication(row), nil
}

// ListApplicationsForGame returns every application for gameID ordered
// by created_at ASC.
func (s *Store) ListApplicationsForGame(ctx context.Context, gameID uuid.UUID) ([]Application, error) {
	a := table.Applications
	stmt := postgres.SELECT(applicationColumns()).
		FROM(a).
		WHERE(a.GameID.EQ(postgres.UUID(gameID))).
		ORDER_BY(a.CreatedAt.ASC())
	var rows []model.Applications
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list applications for game %s: %w", gameID, err)
	}
	out := make([]Application, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToApplication(row))
	}
	return out, nil
}

// ListMyApplications returns every application owned by userID.
func (s *Store) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) {
	a := table.Applications
	stmt := postgres.SELECT(applicationColumns()).
		FROM(a).
		WHERE(a.ApplicantUserID.EQ(postgres.UUID(userID))).
		ORDER_BY(a.CreatedAt.DESC())
	var rows []model.Applications
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list my applications: %w", err)
	}
	out := make([]Application, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToApplication(row))
	}
	return out, nil
}

// inviteInsert carries the parameters for InsertInvite.
type inviteInsert struct {
	InviteID      uuid.UUID
	GameID        uuid.UUID
	InviterUserID uuid.UUID
	InvitedUserID *uuid.UUID
	Code          string
	RaceName      string
	ExpiresAt     time.Time
}

// InsertInvite creates a fresh `pending` invite.
func (s *Store) InsertInvite(ctx context.Context, in inviteInsert) (Invite, error) {
	i := table.Invites
	stmt := i.INSERT(
		i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code,
		i.Status, i.RaceName, i.ExpiresAt,
	).VALUES(
		in.InviteID, in.GameID, in.InviterUserID, invitedArg(in.InvitedUserID), codeArg(in.Code),
		InviteStatusPending, in.RaceName, in.ExpiresAt,
	).RETURNING(inviteColumns())

	var row model.Invites
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if isUniqueViolation(err, constraintInvitesCodeUnique) {
			return Invite{}, fmt.Errorf("%w: invite code collision", ErrConflict)
		}
		return Invite{}, fmt.Errorf("lobby store: insert invite: %w", err)
	}
	return modelToInvite(row), nil
}

// LoadInvite returns the invite for inviteID.
func (s *Store) LoadInvite(ctx context.Context, inviteID uuid.UUID) (Invite, error) {
	i := table.Invites
	stmt := postgres.SELECT(inviteColumns()).
		FROM(i).
		WHERE(i.InviteID.EQ(postgres.UUID(inviteID))).
		LIMIT(1)
	var row model.Invites
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Invite{}, ErrNotFound
		}
		return Invite{}, fmt.Errorf("lobby store: load invite %s: %w", inviteID, err)
	}
	return modelToInvite(row), nil
}

// UpdateInviteStatus patches status and decided_at.
func (s *Store) UpdateInviteStatus(ctx context.Context, inviteID uuid.UUID, status string, decidedAt time.Time) (Invite, error) {
	i := table.Invites
	stmt := i.UPDATE().
		SET(
			i.Status.SET(postgres.String(status)),
			i.DecidedAt.SET(postgres.TimestampzT(decidedAt)),
		).
		WHERE(i.InviteID.EQ(postgres.UUID(inviteID))).
		RETURNING(inviteColumns())
	var row model.Invites
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Invite{}, ErrNotFound
		}
		return Invite{}, fmt.Errorf("lobby store: update invite status %s: %w", inviteID, err)
	}
	return modelToInvite(row), nil
}

// ListInvitesForGame returns every invite for gameID ordered by
// created_at ASC.
func (s *Store) ListInvitesForGame(ctx context.Context, gameID uuid.UUID) ([]Invite, error) {
	i := table.Invites
	stmt := postgres.SELECT(inviteColumns()).
		FROM(i).
		WHERE(i.GameID.EQ(postgres.UUID(gameID))).
		ORDER_BY(i.CreatedAt.ASC())
	var rows []model.Invites
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list invites for game %s: %w", gameID, err)
	}
	out := make([]Invite, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToInvite(row))
	}
	return out, nil
}

// ListMyInvites returns every invite for which userID is the recipient.
func (s *Store) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) {
	i := table.Invites
	stmt := postgres.SELECT(inviteColumns()).
		FROM(i).
		WHERE(i.InvitedUserID.EQ(postgres.UUID(userID))).
		ORDER_BY(i.CreatedAt.DESC())
	var rows []model.Invites
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list my invites: %w", err)
	}
	out := make([]Invite, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToInvite(row))
	}
	return out, nil
}

// membershipInsert carries the parameters for InsertMembership.
type membershipInsert struct {
	MembershipID uuid.UUID
	GameID       uuid.UUID
	UserID       uuid.UUID
	RaceName     string
	CanonicalKey CanonicalKey
}

// InsertMembership creates an `active` membership row. Returns
// ErrConflict on the per-game UNIQUE collision (user already a member).
func (s *Store) InsertMembership(ctx context.Context, in membershipInsert) (Membership, error) {
	m := table.Memberships
	stmt := m.INSERT(
		m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status,
	).VALUES(
		in.MembershipID, in.GameID, in.UserID, in.RaceName, string(in.CanonicalKey), MembershipStatusActive,
	).RETURNING(membershipColumns())

	var row model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if isUniqueViolation(err, constraintMembershipsGameUserUnique) {
			return Membership{}, fmt.Errorf("%w: user already a member of this game", ErrConflict)
		}
		return Membership{}, fmt.Errorf("lobby store: insert membership: %w", err)
	}
	return modelToMembership(row), nil
}

// LoadMembership returns the membership for membershipID.
func (s *Store) LoadMembership(ctx context.Context, membershipID uuid.UUID) (Membership, error) {
	m := table.Memberships
	stmt := postgres.SELECT(membershipColumns()).
		FROM(m).
		WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))).
		LIMIT(1)
	var row model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Membership{}, ErrNotFound
		}
		return Membership{}, fmt.Errorf("lobby store: load membership %s: %w", membershipID, err)
	}
	return modelToMembership(row), nil
}

// UpdateMembershipStatus patches status (and removed_at when removing or
// blocking).
func (s *Store) UpdateMembershipStatus(ctx context.Context, membershipID uuid.UUID, status string, removedAt time.Time) (Membership, error) {
	m := table.Memberships
	var removedExpr postgres.TimestampzExpression
	if status != MembershipStatusActive {
		removedExpr = postgres.TimestampzT(removedAt)
	} else {
		removedExpr = postgres.TimestampzExp(postgres.NULL)
	}
	stmt := m.UPDATE().
		SET(
			m.Status.SET(postgres.String(status)),
			m.RemovedAt.SET(removedExpr),
		).
		WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))).
		RETURNING(membershipColumns())
	var row model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Membership{}, ErrNotFound
		}
		return Membership{}, fmt.Errorf("lobby store: update membership status %s: %w", membershipID, err)
	}
	return modelToMembership(row), nil
}

// ListMembershipsForGame returns every membership row for gameID
// ordered by joined_at ASC.
func (s *Store) ListMembershipsForGame(ctx context.Context, gameID uuid.UUID) ([]Membership, error) {
	m := table.Memberships
	stmt := postgres.SELECT(membershipColumns()).
		FROM(m).
		WHERE(m.GameID.EQ(postgres.UUID(gameID))).
		ORDER_BY(m.JoinedAt.ASC())
	var rows []model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list memberships for game %s: %w", gameID, err)
	}
	out := make([]Membership, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToMembership(row))
	}
	return out, nil
}

// ListAllMemberships returns every membership row, used by Cache.Warm.
func (s *Store) ListAllMemberships(ctx context.Context) ([]Membership, error) {
	stmt := postgres.SELECT(membershipColumns()).FROM(table.Memberships)
	var rows []model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list all memberships: %w", err)
	}
	out := make([]Membership, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToMembership(row))
	}
	return out, nil
}

// raceNameInsert carries the parameters for InsertRaceName.
type raceNameInsert struct {
	Name         string
	Canonical    CanonicalKey
	Status       string
	OwnerUserID  uuid.UUID
	GameID       uuid.UUID
	SourceGameID *uuid.UUID
	ReservedAt   *time.Time
	ExpiresAt    *time.Time
	RegisteredAt *time.Time
}

// InsertRaceName creates a fresh row in `race_names`. Returns
// ErrConflict on either UNIQUE violation (registered uniqueness or
// composite PK).
func (s *Store) InsertRaceName(ctx context.Context, in raceNameInsert) (RaceNameEntry, error) {
	r := table.RaceNames
	stmt := r.INSERT(
		r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID,
		r.ReservedAt, r.ExpiresAt, r.RegisteredAt,
	).VALUES(
		in.Name, string(in.Canonical), in.Status, in.OwnerUserID,
		in.GameID, sourceGameArg(in.SourceGameID),
		timePtrArg(in.ReservedAt), timePtrArg(in.ExpiresAt), timePtrArg(in.RegisteredAt),
	).RETURNING(raceNameColumns())

	var row model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		switch {
		case isUniqueViolation(err, constraintRaceNamesPK):
			return RaceNameEntry{}, fmt.Errorf("%w: race name already bound to this game", ErrRaceNameTaken)
		case isUniqueViolation(err, constraintRaceNamesRegisteredUnique):
			return RaceNameEntry{}, fmt.Errorf("%w: race name is already registered", ErrRaceNameTaken)
		}
		return RaceNameEntry{}, fmt.Errorf("lobby store: insert race_name: %w", err)
	}
	return modelToRaceName(row), nil
}

// FindRaceNameByCanonical returns every row matching canonical.
func (s *Store) FindRaceNameByCanonical(ctx context.Context, canonical CanonicalKey) ([]RaceNameEntry, error) {
	r := table.RaceNames
	stmt := postgres.SELECT(raceNameColumns()).
		FROM(r).
		WHERE(r.Canonical.EQ(postgres.String(string(canonical))))
	var rows []model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: find race name by canonical: %w", err)
	}
	out := make([]RaceNameEntry, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToRaceName(row))
	}
	return out, nil
}

// FindRaceNameByCanonicalAndGame returns the row matching canonical
// inside game (or the registered sentinel game).
func (s *Store) FindRaceNameByCanonicalAndGame(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) (RaceNameEntry, error) {
	r := table.RaceNames
	stmt := postgres.SELECT(raceNameColumns()).
		FROM(r).
		WHERE(
			r.Canonical.EQ(postgres.String(string(canonical))).
				AND(r.GameID.EQ(postgres.UUID(gameID))),
		).
		LIMIT(1)
	var row model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return RaceNameEntry{}, ErrNotFound
		}
		return RaceNameEntry{}, fmt.Errorf("lobby store: find race name: %w", err)
	}
	return modelToRaceName(row), nil
}

// ListRaceNamesForUser returns every race-name row owned by userID
// across all statuses.
func (s *Store) ListRaceNamesForUser(ctx context.Context, userID uuid.UUID) ([]RaceNameEntry, error) {
	r := table.RaceNames
	stmt := postgres.SELECT(raceNameColumns()).
		FROM(r).
		WHERE(r.OwnerUserID.EQ(postgres.UUID(userID))).
		ORDER_BY(r.Status.ASC(), r.Canonical.ASC())
	var rows []model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list race names for user %s: %w", userID, err)
	}
	out := make([]RaceNameEntry, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToRaceName(row))
	}
	return out, nil
}

// ListAllRaceNames returns every race-name row, used by Cache.Warm.
func (s *Store) ListAllRaceNames(ctx context.Context) ([]RaceNameEntry, error) {
	stmt := postgres.SELECT(raceNameColumns()).FROM(table.RaceNames)
	var rows []model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list all race names: %w", err)
	}
	out := make([]RaceNameEntry, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToRaceName(row))
	}
	return out, nil
}

// CountRegisteredRaceNamesByUser returns the number of registered rows
// owned by userID. Drives the entitlement quota check at register-time.
func (s *Store) CountRegisteredRaceNamesByUser(ctx context.Context, userID uuid.UUID) (int, error) {
	r := table.RaceNames
	stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
		FROM(r).
		WHERE(
			r.OwnerUserID.EQ(postgres.UUID(userID)).
				AND(r.Status.EQ(postgres.String(RaceNameStatusRegistered))),
		)
	var dest struct {
		Count int64 `alias:"count"`
	}
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("lobby store: count registered race names: %w", err)
	}
	return int(dest.Count), nil
}

// DeleteRaceName removes the row at (canonical, gameID).
func (s *Store) DeleteRaceName(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) error {
	r := table.RaceNames
	stmt := r.DELETE().
		WHERE(
			r.Canonical.EQ(postgres.String(string(canonical))).
				AND(r.GameID.EQ(postgres.UUID(gameID))),
		)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("lobby store: delete race name: %w", err)
	}
	return nil
}

// PromotePendingToRegistered promotes a pending row to registered in one
// transaction: deletes the (canonical, originGameID) reservation/pending
// row and inserts the registered row keyed by the sentinel game_id.
func (s *Store) PromotePendingToRegistered(ctx context.Context, canonical CanonicalKey, ownerUserID, originGameID uuid.UUID, name string, now time.Time) (RaceNameEntry, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return RaceNameEntry{}, fmt.Errorf("lobby store: begin promote tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	r := table.RaceNames
	deleteStmt := r.DELETE().
		WHERE(
			r.Canonical.EQ(postgres.String(string(canonical))).
				AND(r.GameID.EQ(postgres.UUID(originGameID))).
				AND(r.OwnerUserID.EQ(postgres.UUID(ownerUserID))).
				AND(r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration))),
		)
	res, err := deleteStmt.ExecContext(ctx, tx)
	if err != nil {
		return RaceNameEntry{}, fmt.Errorf("lobby store: delete pending: %w", err)
	}
	if affected, _ := res.RowsAffected(); affected == 0 {
		return RaceNameEntry{}, ErrNotFound
	}
	insertStmt := r.INSERT(
		r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID, r.RegisteredAt,
	).VALUES(
		name, string(canonical), RaceNameStatusRegistered, ownerUserID,
		raceNameRegisteredGameSentinel, originGameID, now,
	).RETURNING(raceNameColumns())

	var row model.RaceNames
	if err := insertStmt.QueryContext(ctx, tx, &row); err != nil {
		if isUniqueViolation(err, constraintRaceNamesRegisteredUnique) {
			return RaceNameEntry{}, fmt.Errorf("%w: race name already registered", ErrRaceNameTaken)
		}
		return RaceNameEntry{}, fmt.Errorf("lobby store: insert registered: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return RaceNameEntry{}, fmt.Errorf("lobby store: commit promote tx: %w", err)
	}
	return modelToRaceName(row), nil
}

// ListPendingRegistrationsExpired returns every pending_registration
// row with expires_at <= now. The sweeper consumes the result.
func (s *Store) ListPendingRegistrationsExpired(ctx context.Context, now time.Time) ([]RaceNameEntry, error) {
	r := table.RaceNames
	stmt := postgres.SELECT(raceNameColumns()).
		FROM(r).
		WHERE(
			r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration)).
				AND(r.ExpiresAt.IS_NOT_NULL()).
				AND(r.ExpiresAt.LT_EQ(postgres.TimestampzT(now))),
		)
	var rows []model.RaceNames
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list expired pending: %w", err)
	}
	out := make([]RaceNameEntry, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToRaceName(row))
	}
	return out, nil
}

// ListEnrollmentExpiredGames returns every game in `enrollment_open`
// status whose enrollment_ends_at has passed `now`. The sweeper uses
// the result to drive the auto-close transition.
func (s *Store) ListEnrollmentExpiredGames(ctx context.Context, now time.Time) ([]GameRecord, error) {
	g := table.Games
	stmt := postgres.SELECT(gameColumns()).
		FROM(g).
		WHERE(
			g.Status.EQ(postgres.String(GameStatusEnrollmentOpen)).
				AND(g.EnrollmentEndsAt.LT_EQ(postgres.TimestampzT(now))),
		)
	var rows []model.Games
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("lobby store: list expired enrollment games: %w", err)
	}
	return modelsToGameRecords(rows)
}

// CascadeUserSnapshot returns every state needed by `OnUserBlocked` /
// `OnUserDeleted` in a single read so the cascade transaction does not
// need additional round-trips.
type CascadeUserSnapshot struct {
	OwnedGameIDs        []uuid.UUID
	ActiveMembershipIDs []uuid.UUID
	PendingApplications []uuid.UUID
	IncomingInvites     []uuid.UUID
	OutgoingInvites     []uuid.UUID
	RaceNameKeys        []raceNameRef
}

type raceNameRef struct {
	Canonical CanonicalKey
	GameID    uuid.UUID
}

// LoadCascadeSnapshot reads the per-user state for the cascade flow.
func (s *Store) LoadCascadeSnapshot(ctx context.Context, userID uuid.UUID) (CascadeUserSnapshot, error) {
	var snap CascadeUserSnapshot

	gamesStmt := postgres.SELECT(table.Games.GameID).
		FROM(table.Games).
		WHERE(table.Games.OwnerUserID.EQ(postgres.UUID(userID)))
	if err := loadIDColumn(ctx, s.db, gamesStmt, &snap.OwnedGameIDs); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: owned games: %w", err)
	}

	memStmt := postgres.SELECT(table.Memberships.MembershipID).
		FROM(table.Memberships).
		WHERE(
			table.Memberships.UserID.EQ(postgres.UUID(userID)).
				AND(table.Memberships.Status.EQ(postgres.String(MembershipStatusActive))),
		)
	if err := loadIDColumn(ctx, s.db, memStmt, &snap.ActiveMembershipIDs); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: memberships: %w", err)
	}

	appStmt := postgres.SELECT(table.Applications.ApplicationID).
		FROM(table.Applications).
		WHERE(
			table.Applications.ApplicantUserID.EQ(postgres.UUID(userID)).
				AND(table.Applications.Status.EQ(postgres.String(ApplicationStatusPending))),
		)
	if err := loadIDColumn(ctx, s.db, appStmt, &snap.PendingApplications); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: applications: %w", err)
	}

	inStmt := postgres.SELECT(table.Invites.InviteID).
		FROM(table.Invites).
		WHERE(
			table.Invites.InvitedUserID.EQ(postgres.UUID(userID)).
				AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))),
		)
	if err := loadIDColumn(ctx, s.db, inStmt, &snap.IncomingInvites); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: incoming invites: %w", err)
	}

	outStmt := postgres.SELECT(table.Invites.InviteID).
		FROM(table.Invites).
		WHERE(
			table.Invites.InviterUserID.EQ(postgres.UUID(userID)).
				AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))),
		)
	if err := loadIDColumn(ctx, s.db, outStmt, &snap.OutgoingInvites); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: outgoing invites: %w", err)
	}

	rnStmt := postgres.SELECT(table.RaceNames.Canonical, table.RaceNames.GameID).
		FROM(table.RaceNames).
		WHERE(table.RaceNames.OwnerUserID.EQ(postgres.UUID(userID)))
	var rnRows []model.RaceNames
	if err := rnStmt.QueryContext(ctx, s.db, &rnRows); err != nil {
		return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: race names: %w", err)
	}
	for _, row := range rnRows {
		snap.RaceNameKeys = append(snap.RaceNameKeys, raceNameRef{
			Canonical: CanonicalKey(row.Canonical),
			GameID:    row.GameID,
		})
	}
	return snap, nil
}

// CascadeUser applies the cascade writes captured in snapshot inside a
// single transaction.
func (s *Store) CascadeUser(ctx context.Context, userID uuid.UUID, snap CascadeUserSnapshot, membershipStatus string, now time.Time) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("cascade user: begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if len(snap.ActiveMembershipIDs) > 0 {
		m := table.Memberships
		stmt := m.UPDATE().
			SET(
				m.Status.SET(postgres.String(membershipStatus)),
				m.RemovedAt.SET(postgres.TimestampzT(now)),
			).
			WHERE(
				m.UserID.EQ(postgres.UUID(userID)).
					AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
			)
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: update memberships: %w", err)
		}
	}
	if len(snap.PendingApplications) > 0 {
		a := table.Applications
		stmt := a.UPDATE().
			SET(
				a.Status.SET(postgres.String(ApplicationStatusRejected)),
				a.DecidedAt.SET(postgres.TimestampzT(now)),
			).
			WHERE(
				a.ApplicantUserID.EQ(postgres.UUID(userID)).
					AND(a.Status.EQ(postgres.String(ApplicationStatusPending))),
			)
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: reject applications: %w", err)
		}
	}
	if len(snap.IncomingInvites) > 0 {
		i := table.Invites
		stmt := i.UPDATE().
			SET(
				i.Status.SET(postgres.String(InviteStatusDeclined)),
				i.DecidedAt.SET(postgres.TimestampzT(now)),
			).
			WHERE(
				i.InvitedUserID.EQ(postgres.UUID(userID)).
					AND(i.Status.EQ(postgres.String(InviteStatusPending))),
			)
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: decline incoming invites: %w", err)
		}
	}
	if len(snap.OutgoingInvites) > 0 {
		i := table.Invites
		stmt := i.UPDATE().
			SET(
				i.Status.SET(postgres.String(InviteStatusRevoked)),
				i.DecidedAt.SET(postgres.TimestampzT(now)),
			).
			WHERE(
				i.InviterUserID.EQ(postgres.UUID(userID)).
					AND(i.Status.EQ(postgres.String(InviteStatusPending))),
			)
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: revoke outgoing invites: %w", err)
		}
	}
	if len(snap.RaceNameKeys) > 0 {
		r := table.RaceNames
		stmt := r.DELETE().
			WHERE(r.OwnerUserID.EQ(postgres.UUID(userID)))
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: delete race names: %w", err)
		}
	}
	if len(snap.OwnedGameIDs) > 0 {
		g := table.Games
		stmt := g.UPDATE().
			SET(
				g.Status.SET(postgres.String(GameStatusCancelled)),
				g.UpdatedAt.SET(postgres.TimestampzT(now)),
			).
			WHERE(
				g.OwnerUserID.EQ(postgres.UUID(userID)).
					AND(g.Status.IN(
						postgres.String(GameStatusDraft),
						postgres.String(GameStatusEnrollmentOpen),
						postgres.String(GameStatusReadyToStart),
						postgres.String(GameStatusStartFailed),
					)),
			)
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("cascade user: cancel owned games: %w", err)
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("cascade user: commit tx: %w", err)
	}
	return nil
}

// loadIDColumn runs stmt and accumulates the single uuid.UUID column it
// returns into out. The destination model is the JSON-tagged shim shared
// across the cascade snapshot loaders.
func loadIDColumn(ctx context.Context, db qrm.DB, stmt postgres.SelectStatement, out *[]uuid.UUID) error {
	var rows []idRow
	if err := stmt.QueryContext(ctx, db, &rows); err != nil {
		return err
	}
	for _, row := range rows {
		*out = append(*out, row.ID)
	}
	return nil
}

// idRow is the single-column scan destination for loadIDColumn. The
// alias tag matches the un-prefixed alias produced when SELECT only
// asks for one identifier-typed column.
type idRow struct {
	ID uuid.UUID `alias:"-"`
}

// =====================================================================
// Argument helpers (INSERT VALUES bindings)
// =====================================================================

func ownerArg(p *uuid.UUID) any {
	if p == nil {
		return nil
	}
	return *p
}

func invitedArg(p *uuid.UUID) any {
	if p == nil {
		return nil
	}
	return *p
}

func sourceGameArg(p *uuid.UUID) any {
	if p == nil {
		return nil
	}
	return *p
}

func codeArg(s string) any {
	if s == "" {
		return nil
	}
	return s
}

func timePtrArg(t *time.Time) any {
	if t == nil {
		return nil
	}
	return *t
}

// =====================================================================
// Model → domain converters
// =====================================================================

func modelToGameRecord(row model.Games) (GameRecord, error) {
	game := GameRecord{
		GameID:              row.GameID,
		Visibility:          row.Visibility,
		Status:              row.Status,
		GameName:            row.GameName,
		Description:         row.Description,
		MinPlayers:          row.MinPlayers,
		MaxPlayers:          row.MaxPlayers,
		StartGapHours:       row.StartGapHours,
		StartGapPlayers:     row.StartGapPlayers,
		EnrollmentEndsAt:    row.EnrollmentEndsAt,
		TurnSchedule:        row.TurnSchedule,
		TargetEngineVersion: row.TargetEngineVersion,
		CreatedAt:           row.CreatedAt,
		UpdatedAt:           row.UpdatedAt,
	}
	if row.OwnerUserID != nil {
		owner := *row.OwnerUserID
		game.OwnerUserID = &owner
	}
	if row.StartedAt != nil {
		t := *row.StartedAt
		game.StartedAt = &t
	}
	if row.FinishedAt != nil {
		t := *row.FinishedAt
		game.FinishedAt = &t
	}
	if row.RuntimeSnapshot != "" {
		var snap RuntimeSnapshot
		if err := json.Unmarshal([]byte(row.RuntimeSnapshot), &snap); err != nil {
			return GameRecord{}, fmt.Errorf("scan game: snapshot: %w", err)
		}
		game.RuntimeSnapshot = snap
	}
	return game, nil
}

func modelsToGameRecords(rows []model.Games) ([]GameRecord, error) {
	out := make([]GameRecord, 0, len(rows))
	for _, row := range rows {
		game, err := modelToGameRecord(row)
		if err != nil {
			return nil, err
		}
		out = append(out, game)
	}
	return out, nil
}

func modelToApplication(row model.Applications) Application {
	app := Application{
		ApplicationID:   row.ApplicationID,
		GameID:          row.GameID,
		ApplicantUserID: row.ApplicantUserID,
		RaceName:        row.RaceName,
		Status:          row.Status,
		CreatedAt:       row.CreatedAt,
	}
	if row.DecidedAt != nil {
		t := *row.DecidedAt
		app.DecidedAt = &t
	}
	return app
}

func modelToInvite(row model.Invites) Invite {
	invite := Invite{
		InviteID:      row.InviteID,
		GameID:        row.GameID,
		InviterUserID: row.InviterUserID,
		Status:        row.Status,
		RaceName:      row.RaceName,
		CreatedAt:     row.CreatedAt,
		ExpiresAt:     row.ExpiresAt,
	}
	if row.InvitedUserID != nil {
		invited := *row.InvitedUserID
		invite.InvitedUserID = &invited
	}
	if row.Code != nil {
		invite.Code = *row.Code
	}
	if row.DecidedAt != nil {
		t := *row.DecidedAt
		invite.DecidedAt = &t
	}
	return invite
}

func modelToMembership(row model.Memberships) Membership {
	m := Membership{
		MembershipID: row.MembershipID,
		GameID:       row.GameID,
		UserID:       row.UserID,
		RaceName:     row.RaceName,
		CanonicalKey: row.CanonicalKey,
		Status:       row.Status,
		JoinedAt:     row.JoinedAt,
	}
	if row.RemovedAt != nil {
		t := *row.RemovedAt
		m.RemovedAt = &t
	}
	return m
}

func modelToRaceName(row model.RaceNames) RaceNameEntry {
	entry := RaceNameEntry{
		Name:        row.Name,
		Canonical:   CanonicalKey(row.Canonical),
		Status:      row.Status,
		OwnerUserID: row.OwnerUserID,
		GameID:      row.GameID,
	}
	if row.SourceGameID != nil {
		src := *row.SourceGameID
		entry.SourceGameID = &src
	}
	if row.ReservedAt != nil {
		t := *row.ReservedAt
		entry.ReservedAt = &t
	}
	if row.ExpiresAt != nil {
		t := *row.ExpiresAt
		entry.ExpiresAt = &t
	}
	if row.RegisteredAt != nil {
		t := *row.RegisteredAt
		entry.RegisteredAt = &t
	}
	return entry
}
