// Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
// wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of account linking & merge
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
// one layer up (the link service), since the in-memory session cache lives there.
package accountmerge

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

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

	"scrabble/backend/internal/postgres/jet/backend/model"
	"scrabble/backend/internal/postgres/jet/backend/table"
)

// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
// without taking a dependency on the game package.
const statusActive = "active"

// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
// collision (both accounts held the same kind). It mirrors the account package's retain
// reasons, kept local to avoid importing that package's unexported constants.
const retainReasonMerge = "merge"

// Friendship statuses, highest precedence first, mirroring internal/social.
const (
	friendAccepted = "accepted"
	friendPending  = "pending"
	friendDeclined = "declined"
)

// ErrActiveGameConflict is returned when the primary and secondary accounts share
// an active game: merging would seat one player against themselves, so the caller
// must wait for the game to finish.
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")

// ErrSameAccount is returned when primary and secondary are the same account.
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")

// Merger merges accounts over a Postgres handle.
type Merger struct {
	db  *sql.DB
	now func() time.Time
	// payments, when set, merges the two accounts' chip segments and benefits by origin inside
	// the merge transaction (SetPayments). Nil leaves payments untouched (tests that do not
	// exercise the wallet).
	payments PaymentsMerger
}

// PaymentsMerger is the payments surface the account merge enlists: fold the secondary's
// segments and benefits into the primary within the merge transaction, then invalidate the
// affected read caches after the commit. *payments.Service satisfies it.
type PaymentsMerger interface {
	MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error
	Invalidate(ids ...uuid.UUID)
}

// SetPayments installs the payments merge hook. It must be called during startup wiring; the
// default (nil) merges no wallet state.
func (m *Merger) SetPayments(p PaymentsMerger) { m.payments = p }

// NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger {
	return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
}

// Merge retires secondary into primary atomically. The secondary is kept as a
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
// finished game stay valid; its seat in such a game is left untouched. The merge
// is refused with ErrActiveGameConflict when the two share an active game.
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
	if primary == secondary {
		return ErrSameAccount
	}
	now := m.now()
	if err := withTx(ctx, m.db, func(tx *sql.Tx) error {
		if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
			return err
		}
		if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
			return err
		}
		if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
			return err
		}
		if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
			return fmt.Errorf("accountmerge: identities: %w", err)
		}
		if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
			return fmt.Errorf("accountmerge: chat: %w", err)
		}
		if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
			return fmt.Errorf("accountmerge: complaints: %w", err)
		}
		if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
			return err
		}
		if err := deleteEphemerals(ctx, tx, secondary); err != nil {
			return err
		}
		if m.payments != nil {
			if err := m.payments.MergeTx(ctx, tx, primary, secondary); err != nil {
				return fmt.Errorf("accountmerge: payments: %w", err)
			}
		}
		return tombstone(ctx, tx, primary, secondary, now)
	}); err != nil {
		return err
	}
	// The payments read cache is invalidated only after the merge commits, so a read racing the
	// transaction cannot re-cache pre-merge state (both accounts' rows are moved or dropped).
	if m.payments != nil {
		m.payments.Invalidate(primary, secondary)
	}
	return nil
}

// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
// are both seated in the same active game.
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	pri, err := activeGameIDs(ctx, tx, primary)
	if err != nil {
		return err
	}
	if len(pri) == 0 {
		return nil
	}
	sec, err := activeGameIDs(ctx, tx, secondary)
	if err != nil {
		return err
	}
	have := make(map[uuid.UUID]struct{}, len(pri))
	for _, id := range pri {
		have[id] = struct{}{}
	}
	for _, id := range sec {
		if _, ok := have[id]; ok {
			return ErrActiveGameConflict
		}
	}
	return nil
}

// activeGameIDs lists the active games accountID is seated in.
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
	stmt := postgres.SELECT(table.GamePlayers.GameID).
		FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
		WHERE(
			table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.Games.Status.EQ(postgres.String(statusActive))),
		)
	var rows []model.GamePlayers
	if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return nil, nil
		}
		return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
	}
	out := make([]uuid.UUID, 0, len(rows))
	for _, r := range rows {
		out = append(out, r.GameID)
	}
	return out, nil
}

// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
	var sec model.AccountStats
	err := postgres.SELECT(table.AccountStats.AllColumns).
		FROM(table.AccountStats).
		WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
		QueryContext(ctx, tx, &sec)
	if errors.Is(err, qrm.ErrNoRows) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("accountmerge: load secondary stats: %w", err)
	}

	ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
		VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
	if _, err := ensure.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
	}
	var pri model.AccountStats
	if err := postgres.SELECT(table.AccountStats.AllColumns).
		FROM(table.AccountStats).
		WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
		FOR(postgres.UPDATE()).
		QueryContext(ctx, tx, &pri); err != nil {
		return fmt.Errorf("accountmerge: lock primary stats: %w", err)
	}

	upd := table.AccountStats.UPDATE(
		table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
		table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
		table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
	).SET(
		postgres.Int(int64(pri.Wins+sec.Wins)),
		postgres.Int(int64(pri.Losses+sec.Losses)),
		postgres.Int(int64(pri.Draws+sec.Draws)),
		postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
		postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
		postgres.Int(int64(pri.Moves+sec.Moves)),
		postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
		postgres.TimestampzT(now),
	).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: update primary stats: %w", err)
	}

	del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
	if _, err := del.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
	}
	return nil
}

// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
// they would linger on a dead account and never reach the merged statistics screen.
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
	var srows []model.AccountBestMove
	err := postgres.SELECT(table.AccountBestMove.AllColumns).
		FROM(table.AccountBestMove).
		WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
		QueryContext(ctx, tx, &srows)
	if err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
	}
	for _, s := range srows {
		ins := table.AccountBestMove.
			INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
				table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
			VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
			ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
			DO_UPDATE(postgres.SET(
				table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
				table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
				table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
			).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
		if _, err := ins.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
		}
	}
	del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
	if _, err := del.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
	}
	return nil
}

// mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former
// hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits
// merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy
// accounts.hint_balance / paid_account columns are deprecated and no longer read or written.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error {
	upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt).
		SET(postgres.TimestampzT(now)).
		WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: touch primary account: %w", err)
	}
	return nil
}

// transferGamePlayers moves secondary's seats to primary, except in a game primary
// already sits in (a shared finished game — active is barred by the guard), where
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	var prows []model.GamePlayers
	if err := postgres.SELECT(table.GamePlayers.GameID).
		FROM(table.GamePlayers).
		WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
		QueryContext(ctx, tx, &prows); err != nil {
		if !errors.Is(err, qrm.ErrNoRows) {
			return fmt.Errorf("accountmerge: primary seats: %w", err)
		}
	}
	cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
	if len(prows) > 0 {
		ids := make([]postgres.Expression, len(prows))
		for i, r := range prows {
			ids[i] = postgres.UUID(r.GameID)
		}
		cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
	}
	upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: transfer seats: %w", err)
	}
	return nil
}

// reassignColumn blanket-reassigns a no-collision account column from secondary to
// primary (identities, chat sender, complaint complainant).
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
	upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
		WHERE(col.EQ(postgres.UUID(secondary)))
	_, err := upd.ExecContext(ctx, tx)
	return err
}

// dedupeIdentities resolves a same-kind identity collision before the blanket identity
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
// its own and the secondary's is journaled to retained_identities (reason=merge) and
// removed. Without this the blanket reassign would leave the survivor with two identities
// of one kind (there is no per-account-kind unique on identities), which the profile and
// the retention dossier both treat as singular. Non-colliding identities are untouched and
// move with the blanket reassign.
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	var prows []model.Identities
	if err := postgres.SELECT(table.Identities.Kind).
		FROM(table.Identities).
		WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
		QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
	}
	occupied := make(map[string]struct{}, len(prows))
	for _, r := range prows {
		occupied[r.Kind] = struct{}{}
	}
	if len(occupied) == 0 {
		return nil
	}
	var srows []model.Identities
	if err := postgres.SELECT(
		table.Identities.Kind, table.Identities.ExternalID,
		table.Identities.Confirmed, table.Identities.CreatedAt,
	).FROM(table.Identities).
		WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
		QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountmerge: secondary identities: %w", err)
	}
	for _, s := range srows {
		if _, dup := occupied[s.Kind]; !dup {
			continue
		}
		if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
			return err
		}
		del := table.Identities.DELETE().WHERE(
			table.Identities.AccountID.EQ(postgres.UUID(secondary)).
				AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
				AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
		)
		if _, err := del.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
		}
	}
	return nil
}

// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
	rid, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("accountmerge: new retained id: %w", err)
	}
	ins := table.RetainedIdentities.INSERT(
		table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
		table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
		table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
		table.RetainedIdentities.Reason,
	).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
	if _, err := ins.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
	}
	return nil
}

// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
	switch status {
	case friendAccepted:
		return 3
	case friendPending:
		return 2
	case friendDeclined:
		return 1
	default:
		return 0
	}
}

// mergeFriendships repoints secondary's friendships to primary, dropping the direct
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
// counterparty by keeping the higher-precedence status (accepted > pending >
// declined). Each account has at most one edge per unordered pair, so the per-other
// decision is unambiguous.
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	if err := deletePair(ctx, tx, table.Friendships.DELETE(),
		table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
		return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
	}

	priByOther := map[uuid.UUID]string{}
	var prows []model.Friendships
	if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
		return fmt.Errorf("accountmerge: primary friendships: %w", err)
	}
	for _, r := range prows {
		priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
	}

	var srows []model.Friendships
	if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
		return fmt.Errorf("accountmerge: secondary friendships: %w", err)
	}
	for _, r := range srows {
		other := otherOf(r.RequesterID, r.AddresseeID, secondary)
		if priStatus, ok := priByOther[other]; ok {
			if friendRank(r.Status) <= friendRank(priStatus) {
				if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
					table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
					return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
				}
				continue
			}
			if err := deletePair(ctx, tx, table.Friendships.DELETE(),
				table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
				return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
			}
		}
		if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
			r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
			return fmt.Errorf("accountmerge: repoint friendship: %w", err)
		}
	}
	return nil
}

// mergeBlocks repoints secondary's blocks to primary, dropping the direct
// primary-secondary block (a self-block) and de-duplicating a counterparty already
// blocked by primary in either direction (a block is undirected for suppression).
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	if err := deletePair(ctx, tx, table.Blocks.DELETE(),
		table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
		return fmt.Errorf("accountmerge: drop self-block: %w", err)
	}

	priOthers := map[uuid.UUID]struct{}{}
	var prows []model.Blocks
	if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
		return fmt.Errorf("accountmerge: primary blocks: %w", err)
	}
	for _, r := range prows {
		priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
	}

	var srows []model.Blocks
	if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
		return fmt.Errorf("accountmerge: secondary blocks: %w", err)
	}
	for _, r := range srows {
		if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
			if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
				table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
				return fmt.Errorf("accountmerge: drop dup block: %w", err)
			}
			continue
		}
		if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
			r.BlockerID, r.BlockedID, primary, secondary); err != nil {
			return fmt.Errorf("accountmerge: repoint block: %w", err)
		}
	}
	return nil
}

// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
// their invitees) and repoints its invitee rows to primary, dropping a row where
// primary is already an invitee of the same invitation.
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	delInv := table.GameInvitations.DELETE().
		WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
	if _, err := delInv.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
	}

	priInv := map[uuid.UUID]struct{}{}
	var prows []model.GameInvitationInvitees
	if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
		FROM(table.GameInvitationInvitees).
		WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
		QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountmerge: primary invitees: %w", err)
	}
	for _, r := range prows {
		priInv[r.InvitationID] = struct{}{}
	}

	var srows []model.GameInvitationInvitees
	if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
		FROM(table.GameInvitationInvitees).
		WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
		QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountmerge: secondary invitees: %w", err)
	}
	for _, r := range srows {
		where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
			AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
		if _, dup := priInv[r.InvitationID]; dup {
			if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
				return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
			}
			continue
		}
		upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
			SET(postgres.UUID(primary)).WHERE(where)
		if _, err := upd.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("accountmerge: repoint invitee: %w", err)
		}
	}
	return nil
}

// deleteEphemerals drops the secondary's pending email confirmations and friend
// codes (short-lived, single-use; not worth carrying over).
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
	if _, err := table.EmailConfirmations.DELETE().
		WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: delete confirmations: %w", err)
	}
	if _, err := table.FriendCodes.DELETE().
		WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: delete friend codes: %w", err)
	}
	return nil
}

// tombstone marks secondary retired, pointing at primary for audit.
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
	upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
		SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
		WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
	}
	return nil
}

// otherOf returns the endpoint of a two-account edge that is not self.
func otherOf(a, b, self uuid.UUID) uuid.UUID {
	if a == self {
		return b
	}
	return a
}

// selectEdges loads the rows of a symmetric two-column edge table touching account.
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
	err := postgres.SELECT(cols).
		FROM(tbl).
		WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
		QueryContext(ctx, tx, dest)
	if errors.Is(err, qrm.ErrNoRows) {
		return nil
	}
	return err
}

// deletePair deletes the directed-or-reverse edge between a and b.
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
	cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
		OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
	_, err := del.WHERE(cond).ExecContext(ctx, tx)
	return err
}

// deleteEdge deletes the single edge identified by its (left, right) primary key.
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
	cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
	_, err := del.WHERE(cond).ExecContext(ctx, tx)
	return err
}

// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
// the edge's direction.
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
	var col postgres.ColumnString
	var where postgres.BoolExpression
	if l == secondary {
		col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
	} else {
		col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
	}
	_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
	return err
}

// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("accountmerge: begin tx: %w", err)
	}
	if err := fn(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("accountmerge: commit tx: %w", err)
	}
	return nil
}
