// Package account owns durable internal accounts and their platform/email
// identities. First contact from a platform auto-provisions an account bound to
// that identity. An ephemeral guest is also a durable account row (the sessions
// and game_players foreign keys both require one) but carries no identity and is
// flagged is_guest, which excludes it from statistics, friends and history.
package account

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"math/rand/v2"
	"strings"
	"time"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/lib/pq"

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

// Identity kinds recognised by the backend. Telegram and VK are platform identities,
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
// each pooled robot opponent is a durable account bound to one robot identity.
const (
	KindTelegram = "telegram"
	KindVK       = "vk"
	KindEmail    = "email"
	KindRobot    = "robot"
)

// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
const uniqueViolation = "23505"

// ErrNotFound is returned when no account matches the lookup.
var ErrNotFound = errors.New("account: not found")

// Account is a durable internal account. AwayStart and AwayEnd bound the daily
// local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.)
type Account struct {
	ID                  uuid.UUID
	DisplayName         string
	PreferredLanguage   string
	TimeZone            string
	AwayStart           time.Time
	AwayEnd             time.Time
	BlockChat           bool
	BlockFriendRequests bool
	// VariantPreferences is the set of game variants (engine.Variant stable labels:
	// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
	// into. It gates the New Game picker, the matchmaker and the friend-invite the
	// player creates; an invited friend may still accept any variant. A newly registered
	// account defaults to DefaultVariantPreferences and a guest to
	// GuestVariantPreferences. Never empty — enforced on update and by a DB check.
	VariantPreferences []string
	// IsGuest marks an ephemeral guest account: a durable row with no identity,
	// excluded from statistics, friends and history.
	IsGuest bool
	// NotificationsInAppOnly confines notifications to the in-app live stream when
	// true (the default): the platform side-service skips out-of-app push for the
	// account.
	NotificationsInAppOnly bool
	// MergedInto is the primary account a retired (merged) secondary points at, or
	// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
	// foreign keys of a shared finished game stay valid.
	MergedInto uuid.UUID
	// Deleted marks a tombstoned account (accounts.deleted_at set): its credentials are
	// journalled and freed and its live surfaces anonymised, but the row survives for the
	// no-cascade foreign keys. Nobody is behind it any more, so it takes part in no social
	// exchange (docs/ARCHITECTURE.md §9.1).
	Deleted bool
	// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
	// zero time for an unflagged account, otherwise when the gateway-reported
	// rate-limiter rejections first crossed the sustained threshold. An
	// operator clears it in the admin console; it never gates any request.
	FlaggedHighRateAt time.Time
	CreatedAt         time.Time
	UpdatedAt         time.Time
}

// Identity is one of an account's platform/email identities, surfaced on the
// admin account-detail view. ExternalID is the platform user id (or the email
// address for an email identity); Confirmed tracks the email confirm-code flow.
type Identity struct {
	Kind       string
	ExternalID string
	Confirmed  bool
	CreatedAt  time.Time
}

// Store is the Postgres-backed query surface for accounts and identities.
type Store struct {
	db      *sql.DB
	metrics *accountMetrics
	// suspensions caches each account's current manual block, read by the suspension gate on
	// every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go.
	suspensions *suspensionCache
}

// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
// SetMetrics installs the real one during startup wiring.
func NewStore(db *sql.DB) *Store {
	return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()}
}

// ProvisionByIdentity returns the account bound to (kind, externalID), creating
// a fresh durable account and identity when none exists yet. It is safe under
// concurrent callers: a losing race on the identity's unique constraint is
// resolved by re-reading the winner's account. A platform identity is recorded
// as confirmed; an email identity starts unconfirmed.
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
	return s.provision(ctx, kind, externalID, provisionSeed{})
}

// ProvisionEmail returns the account owning the email identity externalID, creating
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
// seeded into its time zone, language seeded from the client's UI language, and its
// display name seeded from the email's local part (so it is not left nameless). Like
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
// returning user's saved zone, language and name are never overwritten. The email account is
// created here (the code-request step), not at the later login, so this is where its
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
// freeing the reserved address, and confirming the code clears the guest flag.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
	return s.provision(ctx, KindEmail, externalID, provisionSeed{
		displayName:       emailDisplayName(externalID),
		timeZone:          seedZone(browserTZ),
		preferredLanguage: supportedLanguage(language),
		isGuest:           true,
	})
}

// emailDisplayName derives a display name from an email address — the local part
// before '@', trimmed and capped to the column width — so a new email account is not
// left nameless. It is only the first-contact seed; the user can rename it later.
func emailDisplayName(email string) string {
	local, _, _ := strings.Cut(email, "@")
	local = strings.TrimSpace(local)
	if r := []rune(local); len(r) > maxDisplayName {
		local = strings.TrimRight(string(r[:maxDisplayName]), " ")
	}
	return local
}

// supportedLanguage returns code normalised to a supported UI language ("en" or
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
// accepts region-tagged codes ("ru-RU").
func supportedLanguage(code string) string {
	if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" {
		return lang
	}
	return ""
}

// ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
// who ignores the request. Robot names are system-generated, not player-edited, so they
// bypass the editable display-name validation and may carry forms the editor rejects (an
// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
// display name and both block flags.
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
	acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
	if err != nil {
		return Account{}, err
	}
	if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
		return acc, nil
	}
	stmt := table.Accounts.UPDATE(
		table.Accounts.DisplayName, table.Accounts.BlockChat,
		table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
	).SET(
		postgres.String(displayName), postgres.Bool(true),
		postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
	).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
		RETURNING(table.Accounts.AllColumns)

	var row model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
	}
	return modelToAccount(row), nil
}

// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
// reporting whether this call created it (first contact). On first contact only, it
// seeds the new account's preferred language from the Telegram client languageCode
// (when it maps to a supported language) and its display name sanitized from firstName
// (falling back to username, then to a generated placeholder when neither yields any
// letters); an already-existing account is returned unchanged, so a later profile edit
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) {
	// Pre-check whether the identity already exists so the caller can act on first
	// contact. A race with a concurrent create only over- or under-reports created for
	// that one call, which the idempotent chat-access re-evaluation tolerates.
	_, err := s.findByIdentity(ctx, KindTelegram, externalID)
	created := errors.Is(err, ErrNotFound)
	if err != nil && !created {
		return Account{}, false, err
	}
	seed := telegramSeed(languageCode, username, firstName)
	seed.timeZone = seedZone(browserTZ)
	acc, err := s.provision(ctx, KindTelegram, externalID, seed)
	return acc, created, err
}

// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
// whether this call created it (first contact). On first contact only, it seeds the new
// account's preferred language from the VK languageCode (vk_language, when it maps to a
// supported language) and its display name sanitized from displayName — the name read
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
// falling back to a generated placeholder when it yields no letters; an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
	// Pre-check whether the identity already exists so the caller can act on first
	// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
	// that one call.
	_, err := s.findByIdentity(ctx, KindVK, externalID)
	created := errors.Is(err, ErrNotFound)
	if err != nil && !created {
		return Account{}, false, err
	}
	seed := vkSeed(languageCode, displayName)
	seed.timeZone = seedZone(browserTZ)
	acc, err := s.provision(ctx, KindVK, externalID, seed)
	return acc, created, err
}

// provision finds the account for (kind, externalID) or creates it with seed,
// collapsing a concurrent-create race on the identity unique constraint into a
// re-read of the winner's account.
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
	acc, err := s.findByIdentity(ctx, kind, externalID)
	if err == nil {
		return acc, nil
	}
	if !errors.Is(err, ErrNotFound) {
		return Account{}, err
	}

	acc, err = s.create(ctx, kind, externalID, seed)
	if err != nil {
		if isUniqueViolation(err) {
			// A concurrent caller created the identity first; return theirs.
			return s.findByIdentity(ctx, kind, externalID)
		}
		return Account{}, err
	}
	return acc, nil
}

// provisionSeed carries the optional create-time profile seed for a brand-new
// account (first contact). Empty fields fall back to the accounts table defaults,
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
// and an empty time zone keeps the 'UTC' default.
type provisionSeed struct {
	preferredLanguage string
	displayName       string
	timeZone          string
	// isGuest creates the account flagged is_guest. It is set for an email-login
	// account, which stays a guest until the address is confirmed (so an abandoned,
	// never-confirmed login is reaped and its address freed); confirming clears the
	// flag. Platform identities (telegram/vk) are durable from creation.
	isGuest bool
}

// seedZone returns browserTZ when it is a well-formed zone to persist at account
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
// falls back to the accounts table's 'UTC' default. The client reports the device's
// detected offset deterministically; a bad value is dropped rather than guessed at.
func seedZone(browserTZ string) string {
	if validZone(browserTZ) {
		return browserTZ
	}
	return ""
}

// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
// name (firstName, sanitized to the editable format) → the @username taken verbatim
// (already a valid handle, only trimmed and length-capped, never character-stripped)
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
// only when firstName has no usable letters and no username is set.
func telegramSeed(languageCode, username, firstName string) provisionSeed {
	var seed provisionSeed
	if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
		seed.preferredLanguage = lang
	}
	name := sanitizeDisplayName(firstName)
	if name == "" {
		// The real name yielded nothing usable: fall back to the @username verbatim
		// (Telegram guarantees a valid handle), only trimmed and capped to the column
		// width — never character-stripped like the real name.
		name = strings.TrimSpace(username)
		if r := []rune(name); len(r) > maxDisplayName {
			name = strings.TrimRight(string(r[:maxDisplayName]), " ")
		}
	}
	if name == "" {
		name = placeholderDisplayName(seed.preferredLanguage)
	}
	seed.displayName = name
	return seed
}

// vkSeed derives the create-time seed from VK launch fields: a supported preferred
// language from languageCode (vk_language, normally a 2-letter code) and a display name
// from displayName (sanitized to the editable format), falling back to a generated
// placeholder in the seeded language when the name yields no usable letters. Unlike
// telegramSeed there is no @username fallback — VK provides only the name.
func vkSeed(languageCode, displayName string) provisionSeed {
	var seed provisionSeed
	if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
		seed.preferredLanguage = lang
	}
	name := sanitizeDisplayName(displayName)
	if name == "" {
		name = placeholderDisplayName(seed.preferredLanguage)
	}
	seed.displayName = name
	return seed
}

// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
	stmt := postgres.SELECT(table.Accounts.AllColumns).
		FROM(table.Accounts).
		WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
		LIMIT(1)

	var row model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Account{}, ErrNotFound
		}
		return Account{}, fmt.Errorf("account: get by id %s: %w", id, err)
	}
	return modelToAccount(row), nil
}

// IdentityExternalID returns the external_id of the account's identity of the
// given kind, or ErrNotFound when the account has no such identity. The Telegram
// side-service uses it (through the gateway push-target lookup) to address an
// out-of-app notification to a recipient's Telegram chat.
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
	stmt := postgres.SELECT(table.Identities.ExternalID).
		FROM(table.Identities).
		WHERE(
			table.Identities.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.Identities.Kind.EQ(postgres.String(kind))),
		).
		LIMIT(1)

	var row model.Identities
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return "", ErrNotFound
		}
		return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
	}
	return row.ExternalID, nil
}

// Identities returns the account's platform/email identities, oldest first, for
// the admin account-detail view.
func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) {
	stmt := postgres.SELECT(table.Identities.AllColumns).
		FROM(table.Identities).
		WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
		ORDER_BY(table.Identities.CreatedAt.ASC())
	var rows []model.Identities
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("account: list identities %s: %w", accountID, err)
	}
	out := make([]Identity, 0, len(rows))
	for _, r := range rows {
		out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt})
	}
	return out, nil
}

// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
	_, ok, err := s.ConfirmedEmail(ctx, accountID)
	return ok, err
}

// ConfirmedEmail returns the account's confirmed email address, the oldest one first when several
// exist. Besides being the direct-rail recovery anchor (D36) it is where the rail's fiscal receipt
// is delivered, so the purchase path reads the address itself rather than only its presence.
func (s *Store) ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
	ids, err := s.Identities(ctx, accountID)
	if err != nil {
		return "", false, err
	}
	for _, id := range ids {
		if id.Kind == "email" && id.Confirmed {
			return id.ExternalID, true, nil
		}
	}
	return "", false, nil
}

// ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
	stmt := postgres.SELECT(table.Accounts.AllColumns).
		FROM(table.Accounts).
		ORDER_BY(table.Accounts.CreatedAt.DESC()).
		LIMIT(int64(limit)).
		OFFSET(int64(offset))
	var rows []model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("account: list accounts: %w", err)
	}
	out := make([]Account, 0, len(rows))
	for _, r := range rows {
		out = append(out, modelToAccount(r))
	}
	return out, nil
}

// CountAccounts returns the total number of accounts, for admin-list pagination.
func (s *Store) CountAccounts(ctx context.Context) (int, error) {
	stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")).
		FROM(table.Accounts)
	var dest struct{ Count int64 }
	if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
		return 0, fmt.Errorf("account: count accounts: %w", err)
	}
	return int(dest.Count), nil
}

// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
// write access) from an unregistered one (left muted).
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
	return s.findByIdentity(ctx, kind, externalID)
}

// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
	stmt := postgres.SELECT(table.Accounts.AllColumns).
		FROM(table.Accounts.INNER_JOIN(
			table.Identities,
			table.Identities.AccountID.EQ(table.Accounts.AccountID),
		)).
		WHERE(
			table.Identities.Kind.EQ(postgres.String(kind)).
				AND(table.Identities.ExternalID.EQ(postgres.String(externalID))),
		).
		LIMIT(1)

	var row model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Account{}, ErrNotFound
		}
		return Account{}, fmt.Errorf("account: find by identity (%s, %s): %w", kind, externalID, err)
	}
	return modelToAccount(row), nil
}

// create inserts a new account (seeded from seed) and its first identity inside
// one transaction and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
	accountID, err := uuid.NewV7()
	if err != nil {
		return Account{}, fmt.Errorf("account: new account id: %w", err)
	}
	identityID, err := uuid.NewV7()
	if err != nil {
		return Account{}, fmt.Errorf("account: new identity id: %w", err)
	}

	var created Account
	err = withTx(ctx, s.db, func(tx *sql.Tx) error {
		// Seed the new row's display name, language and time zone (first contact); an
		// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
		// relied on, so their behaviour is unchanged. time_zone is written explicitly (the
		// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
		// creation while an unseeded one stays UTC.
		lang := seed.preferredLanguage
		if lang == "" {
			lang = "en"
		}
		tz := seed.timeZone
		if tz == "" {
			tz = "UTC"
		}
		insertAccount := table.Accounts.
			INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
			VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
			RETURNING(table.Accounts.AllColumns)

		var row model.Accounts
		if err := insertAccount.QueryContext(ctx, tx, &row); err != nil {
			return err
		}

		insertIdentity := table.Identities.INSERT(
			table.Identities.IdentityID,
			table.Identities.AccountID,
			table.Identities.Kind,
			table.Identities.ExternalID,
			table.Identities.Confirmed,
		).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
		if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
			return err
		}

		created = modelToAccount(row)
		return nil
	})
	if err != nil {
		return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
	}
	// Count genuinely new durable accounts; robots are a fixed provisioned pool,
	// not users, so they are excluded.
	if kind != KindRobot {
		s.metrics.recordCreated(ctx, kind)
	}
	return created, nil
}

// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
	return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}

// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
// foreign-key the accounts table) while being excluded from statistics, friends
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) {
	accountID, err := uuid.NewV7()
	if err != nil {
		return Account{}, fmt.Errorf("account: new guest id: %w", err)
	}
	tz := seedZone(browserTZ)
	if tz == "" {
		tz = "UTC"
	}
	// Seed the interface language from the client's detected locale (validated to a supported
	// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
	// is right from first contact rather than the 'en' column default until the client's later
	// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
	lang := supportedLanguage(language)
	if lang == "" {
		lang = "en"
	}
	// A guest is narrower than a registered player: Эрудит alone, where the column default
	// gives a registered account both Russian-alphabet games (DefaultVariantPreferences).
	// The set is written explicitly for exactly that reason, and ClearGuest widens it to the
	// default when the guest later registers.
	stmt := table.Accounts.
		INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage, table.Accounts.VariantPreferences).
		VALUES(accountID, guestDisplayName(), true, tz, lang,
			postgres.Raw("#guest_variants::text[]", map[string]interface{}{"#guest_variants": pq.StringArray(GuestVariantPreferences)})).
		RETURNING(table.Accounts.AllColumns)

	var row model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Account{}, fmt.Errorf("account: provision guest: %w", err)
	}
	s.metrics.recordCreated(ctx, kindGuest)
	return modelToAccount(row), nil
}

// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
// a profile edit, so updated_at is untouched; it never gates any request.
// It reports whether the flag was newly set.
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
	stmt := table.Accounts.
		UPDATE(table.Accounts.FlaggedHighRateAt).
		SET(postgres.TimestampzT(at.UTC())).
		WHERE(
			table.Accounts.AccountID.EQ(postgres.UUID(id)).
				AND(table.Accounts.FlaggedHighRateAt.IS_NULL()),
		)
	res, err := stmt.ExecContext(ctx, s.db)
	if err != nil {
		return false, fmt.Errorf("account: flag high rate %s: %w", id, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err)
	}
	return n > 0, nil
}

// ClearHighRateFlag removes the high-rate marker — the operator's reversible
// action in the admin console. Clearing an unflagged account is a no-op.
func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
	stmt := table.Accounts.
		UPDATE(table.Accounts.FlaggedHighRateAt).
		SET(postgres.NULL).
		WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
	}
	return nil
}

// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
	var mergedInto uuid.UUID
	if row.MergedInto != nil {
		mergedInto = *row.MergedInto
	}
	var flaggedHighRateAt time.Time
	if row.FlaggedHighRateAt != nil {
		flaggedHighRateAt = *row.FlaggedHighRateAt
	}
	return Account{
		ID:                     row.AccountID,
		DisplayName:            row.DisplayName,
		PreferredLanguage:      row.PreferredLanguage,
		VariantPreferences:     []string(row.VariantPreferences),
		TimeZone:               row.TimeZone,
		AwayStart:              row.AwayStart,
		AwayEnd:                row.AwayEnd,
		BlockChat:              row.BlockChat,
		BlockFriendRequests:    row.BlockFriendRequests,
		IsGuest:                row.IsGuest,
		NotificationsInAppOnly: row.NotificationsInAppOnly,
		MergedInto:             mergedInto,
		Deleted:                row.DeletedAt != nil,
		FlaggedHighRateAt:      flaggedHighRateAt,
		CreatedAt:              row.CreatedAt,
		UpdatedAt:              row.UpdatedAt,
	}
}

// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
// violation, used to collapse a concurrent-provision race into a re-read.
func isUniqueViolation(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}

// 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("begin tx: %w", err)
	}
	if err := fn(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit tx: %w", err)
	}
	return nil
}
