package account

import (
	"context"
	crand "crypto/rand"
	"crypto/sha256"
	"database/sql"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"math/big"
	"net/mail"
	"strings"
	"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"
)

const (
	// emailCodeTTL bounds how long an issued confirm-code stays valid.
	emailCodeTTL = 15 * time.Minute
	// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
	emailCodeMaxAttempts = 5
	// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
	linkTokenBytes = 32
	// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
	// is appended). The SPA is served under /app/ behind a hash router.
	emailConfirmPath = "/app/#/confirm/"
)

// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login), link/confirm the
// address on the current account (link), or replace the account's confirmed email with
// a new address (change). Account deletion adds a further purpose in a later stage.
const (
	purposeLogin  = "login"
	purposeLink   = "link"
	purposeChange = "change"
	purposeDelete = "delete"
)

// Errors returned by the email confirm-code flow.
var (
	// ErrInvalidEmail is returned for an unparseable email address.
	ErrInvalidEmail = errors.New("account: invalid email address")
	// ErrEmailTaken is returned when the email is already confirmed by another
	// account; binding it would be a merge, which the link/merge flow owns.
	ErrEmailTaken = errors.New("account: email already confirmed by another account")
	// ErrAlreadyConfirmed is returned when the email is already confirmed by the
	// requesting account.
	ErrAlreadyConfirmed = errors.New("account: email already confirmed for this account")
	// ErrNoPendingCode is returned when no live confirm-code exists to verify.
	ErrNoPendingCode = errors.New("account: no pending confirmation code")
	// ErrCodeExpired is returned when the confirm-code has passed its TTL.
	ErrCodeExpired = errors.New("account: confirmation code expired")
	// ErrTooManyAttempts is returned when the code is locked after too many tries.
	ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
	// ErrCodeMismatch is returned when the submitted code does not match.
	ErrCodeMismatch = errors.New("account: confirmation code does not match")
	// ErrTooManyRequests is returned when confirm-code sends to an address are being
	// requested too frequently (the resend cooldown or the rolling-hour cap).
	ErrTooManyRequests = errors.New("account: too many code requests")
	// ErrNoEmail is returned when an email-code step-up is requested for an account that
	// holds no confirmed email (the caller must use the typed-phrase path instead).
	ErrNoEmail = errors.New("account: no confirmed email")
)

// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
// Mailer and verifies it, binding a confirmed email identity to the requesting
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
// matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
	store   *Store
	mailer  Mailer
	baseURL string
	limiter *SendLimiter
	now     func() time.Time
}

// NewEmailService constructs an EmailService over store, sending via mailer. baseURL
// is the canonical public origin (scheme + host) used to build the one-tap confirm
// deeplink and the email footer landing link; an empty baseURL omits the deeplink
// (development / log mailer).
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
	return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
}

// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
// not throttled — production wires a limiter; tests leave it off.
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }

// allowSend reports whether a confirm-code send to email is permitted now, recording
// it when so. A nil limiter permits every send.
func (s *EmailService) allowSend(email string) bool {
	return s.limiter == nil || s.limiter.Allow(email)
}

// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. omitLink drops the
// one-tap deeplink from the email (account deletion, or a login requested from an installed
// PWA). Only the SHA-256 hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
	code, codeHash, err := generateCode()
	if err != nil {
		return err
	}
	token, tokenHash, err := generateLinkToken()
	if err != nil {
		return err
	}
	if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
		return err
	}
	// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
	// open in a separate browser whose minted session cannot reach the PWA), or for account
	// deletion (a prefetch or stray click must not delete an account — the delete code is entered
	// in the app only, and ConfirmByToken refuses a delete token).
	deeplink := s.confirmURL(token, locale)
	if purpose == purposeDelete || omitLink {
		deeplink = ""
	}
	msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
	if err != nil {
		return err
	}
	msg.To = email
	return s.mailer.Send(ctx, msg)
}

// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
// when no public base URL is configured. The locale rides the fragment as ?lang so the
// confirm screen (opened in a browser with no session) renders in the email's language.
func (s *EmailService) confirmURL(token, locale string) string {
	if s.baseURL == "" {
		return ""
	}
	return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
}

// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
	acc, err := s.store.GetByID(ctx, accountID)
	if err != nil {
		return "en"
	}
	return acc.PreferredLanguage
}

// RequestCode issues a fresh confirm-code for email to accountID and mails it,
// replacing any prior pending code for the same account and address. It returns
// ErrInvalidEmail, ErrEmailTaken or ErrAlreadyConfirmed without sending.
func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, email string) error {
	addr, err := normalizeEmail(email)
	if err != nil {
		return err
	}
	if !s.allowSend(addr) {
		return ErrTooManyRequests
	}
	owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
	if err != nil {
		return err
	}
	if ok {
		if owner == accountID {
			return ErrAlreadyConfirmed
		}
		return ErrEmailTaken
	}
	return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
}

// ConfirmCode verifies code for accountID and email. On success it attaches a
// confirmed email identity and returns the account. It returns ErrNoPendingCode,
// ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch (counting the attempt), or
// ErrEmailTaken if the address was confirmed elsewhere in the meantime.
func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, email, code string) (Account, error) {
	addr, err := normalizeEmail(email)
	if err != nil {
		return Account{}, err
	}
	conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
	if err != nil {
		return Account{}, err
	}
	if s.now().After(conf.expiresAt) {
		return Account{}, ErrCodeExpired
	}
	if conf.attempts >= emailCodeMaxAttempts {
		return Account{}, ErrTooManyAttempts
	}
	if hashCode(code) != conf.codeHash {
		if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
			return Account{}, err
		}
		return Account{}, ErrCodeMismatch
	}
	if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
		return Account{}, err
	}
	// Binding the first confirmed email promotes a guest to a durable account, matching the
	// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
	if err := s.store.ClearGuest(ctx, accountID); err != nil {
		return Account{}, err
	}
	return s.store.GetByID(ctx, accountID)
}

// RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
// durable once the code is confirmed. It is the unauthenticated email-login entry
// point and, unlike RequestCode, does not refuse an already-confirmed email — that is
// the ordinary returning-user login. The code is mailed to the address, so only its
// real owner can complete the login. On first contact browserTZ (the client's
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. When pwa is set (the request came from an installed PWA) the login email omits the
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
	addr, err := normalizeEmail(email)
	if err != nil {
		return uuid.UUID{}, err
	}
	if !s.allowSend(addr) {
		return uuid.UUID{}, ErrTooManyRequests
	}
	acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
	if err != nil {
		return uuid.UUID{}, err
	}
	if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
		return uuid.UUID{}, err
	}
	return acc.ID, nil
}

// LoginWithCode verifies a login code for email and returns the owning account,
// marking the email identity confirmed on first success (idempotent for a
// returning user). It mirrors ConfirmCode's checks but updates the existing
// identity rather than inserting one, since RequestLoginCode already provisioned
// it. It returns ErrNotFound when no account owns the email.
func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) {
	addr, err := normalizeEmail(email)
	if err != nil {
		return Account{}, err
	}
	acc, err := s.store.findByIdentity(ctx, KindEmail, addr)
	if err != nil {
		return Account{}, err
	}
	conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr)
	if err != nil {
		return Account{}, err
	}
	if s.now().After(conf.expiresAt) {
		return Account{}, ErrCodeExpired
	}
	if conf.attempts >= emailCodeMaxAttempts {
		return Account{}, ErrTooManyAttempts
	}
	if hashCode(code) != conf.codeHash {
		if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
			return Account{}, err
		}
		return Account{}, ErrCodeMismatch
	}
	if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
		return Account{}, err
	}
	if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
		return Account{}, err
	}
	return s.store.GetByID(ctx, acc.ID)
}

// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
// transport layer must finish. Purpose is the pending row's purpose. For a login,
// Account is the account to sign in. For a link, Account is the account the email was
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
// already owns the address, so the caller drives the interactive merge instead of a
// plain link — the token is left unconsumed for that merge step.
type LinkConfirmation struct {
	Purpose    string
	Account    uuid.UUID
	NeedsMerge bool
	MergeOwner uuid.UUID
}

// IsLogin reports whether the confirmation is a login (the caller mints a session)
// rather than a link (attach the identity, or drive a merge).
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }

// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
// confirms the email identity, clears the guest flag and returns the account to sign
// in. A link attaches the confirmed email to the pending account when the address is
// free, or reports NeedsMerge when another account already owns it (leaving the token
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
// is high-entropy, so there is no wrong-attempt counter.
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
	pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
	if err != nil {
		return LinkConfirmation{}, err
	}
	if s.now().After(pend.expiresAt) {
		return LinkConfirmation{}, ErrCodeExpired
	}
	switch pend.purpose {
	case purposeLogin:
		if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
			return LinkConfirmation{}, err
		}
		if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
			return LinkConfirmation{}, err
		}
		return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
	case purposeLink:
		owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
		if err != nil {
			return LinkConfirmation{}, err
		}
		if ok {
			if owner == pend.accountID {
				if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
					return LinkConfirmation{}, err
				}
				return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
			}
			return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
		}
		if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
			return LinkConfirmation{}, err
		}
		// Binding the first email promotes a guest to a durable account, matching the
		// code-based link flow (which clears the guest flag in the link service).
		if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
			return LinkConfirmation{}, err
		}
		return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
	case purposeChange:
		owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
		if err != nil {
			return LinkConfirmation{}, err
		}
		if ok && owner != pend.accountID {
			// The new address is confirmed by a different account: refuse without
			// disclosing it (anti-enumeration). Unlike a link, a change never merges.
			return LinkConfirmation{}, ErrEmailTaken
		}
		if ok && owner == pend.accountID {
			if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
				return LinkConfirmation{}, err
			}
			return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
		}
		if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
			return LinkConfirmation{}, err
		}
		return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
	case purposeDelete:
		// Deletion is confirmed in the app with the code, never via a one-tap link.
		return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
	default:
		return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
	}
}

// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
	id        uuid.UUID
	codeHash  string
	expiresAt time.Time
	attempts  int
}

// confirmedEmailAccount returns the account that holds a confirmed email identity
// for email and true, or (zero, false) when none does.
func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.UUID, bool, error) {
	stmt := postgres.SELECT(table.Identities.AccountID).
		FROM(table.Identities).
		WHERE(
			table.Identities.Kind.EQ(postgres.String(KindEmail)).
				AND(table.Identities.ExternalID.EQ(postgres.String(email))).
				AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
		).LIMIT(1)
	var row model.Identities
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return uuid.UUID{}, false, nil
		}
		return uuid.UUID{}, false, fmt.Errorf("account: confirmed email owner %s: %w", email, err)
	}
	return row.AccountID, true, nil
}

// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
// when it holds none. It backs the deletion step-up, which mails a code to the account's
// own address.
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, 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(KindEmail))).
				AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
		).LIMIT(1)
	var row model.Identities
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return "", false, nil
		}
		return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
	}
	return row.ExternalID, true, nil
}

// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
	id, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("account: new confirmation id: %w", err)
	}
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		del := table.EmailConfirmations.DELETE().WHERE(
			table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
				AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
		)
		if _, err := del.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("clear pending confirmations: %w", err)
		}
		ins := table.EmailConfirmations.INSERT(
			table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
			table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
			table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
		).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
		if _, err := ins.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("insert confirmation: %w", err)
		}
		return nil
	})
}

// latestPendingConfirmation loads the newest unconsumed confirm-code for
// (accountID, email), or ErrNoPendingCode.
func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UUID, email string) (emailConfirmation, error) {
	stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
		FROM(table.EmailConfirmations).
		WHERE(
			table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
				AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
		).ORDER_BY(table.EmailConfirmations.CreatedAt.DESC()).LIMIT(1)
	var row model.EmailConfirmations
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return emailConfirmation{}, ErrNoPendingCode
		}
		return emailConfirmation{}, fmt.Errorf("account: load confirmation: %w", err)
	}
	return emailConfirmation{
		id:        row.ConfirmationID,
		codeHash:  row.CodeHash,
		expiresAt: row.ExpiresAt,
		attempts:  int(row.Attempts),
	}, nil
}

// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
// domain form.
type pendingConfirmation struct {
	id        uuid.UUID
	accountID uuid.UUID
	email     string
	purpose   string
	expiresAt time.Time
}

// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
// a partial-unique index guarantees at most one match.
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
	stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
		FROM(table.EmailConfirmations).
		WHERE(
			table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
				AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
		).LIMIT(1)
	var row model.EmailConfirmations
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return pendingConfirmation{}, ErrNoPendingCode
		}
		return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
	}
	return pendingConfirmation{
		id:        row.ConfirmationID,
		accountID: row.AccountID,
		email:     row.Email,
		purpose:   row.Purpose,
		expiresAt: row.ExpiresAt,
	}, nil
}

// consumeConfirmation marks a confirmation consumed without writing an identity, used
// for the idempotent already-linked deeplink path.
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
	upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
		SET(postgres.TimestampzT(now)).
		WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
	if _, err := upd.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: consume confirmation: %w", err)
	}
	return nil
}

// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
	stmt := table.EmailConfirmations.
		UPDATE(table.EmailConfirmations.Attempts).
		SET(table.EmailConfirmations.Attempts.ADD(postgres.Int(1))).
		WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: bump confirmation attempts: %w", err)
	}
	return nil
}

// confirmEmailIdentity consumes the code and inserts a confirmed email identity,
// inside one transaction. A unique-constraint violation means the address was
// confirmed by another account first, surfaced as ErrEmailTaken.
func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
	identityID, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("account: new identity id: %w", err)
	}
	err = withTx(ctx, s.db, func(tx *sql.Tx) error {
		upd := table.EmailConfirmations.
			UPDATE(table.EmailConfirmations.ConsumedAt).
			SET(postgres.TimestampzT(now)).
			WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
		if _, err := upd.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("consume confirmation: %w", err)
		}
		ins := table.Identities.INSERT(
			table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
			table.Identities.ExternalID, table.Identities.Confirmed,
		).VALUES(identityID, accountID, KindEmail, email, true)
		if _, err := ins.ExecContext(ctx, tx); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		if isUniqueViolation(err) {
			return ErrEmailTaken
		}
		return fmt.Errorf("account: confirm email identity: %w", err)
	}
	return nil
}

// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
// one transaction. It backs the change-email flow. A unique-constraint violation — the
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
// the account holds no email identity yet the delete is a no-op, so this doubles as an
// attach.
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
	identityID, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("account: new identity id: %w", err)
	}
	err = withTx(ctx, s.db, func(tx *sql.Tx) error {
		upd := table.EmailConfirmations.
			UPDATE(table.EmailConfirmations.ConsumedAt).
			SET(postgres.TimestampzT(now)).
			WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
		if _, err := upd.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("consume confirmation: %w", err)
		}
		// Journal the outgoing email before replacing it, so the legal dossier keeps the
		// address the account used to hold (see retention.go).
		var old model.Identities
		sel := postgres.SELECT(
			table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
		).FROM(table.Identities).WHERE(
			table.Identities.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
		).LIMIT(1)
		switch err := sel.QueryContext(ctx, tx, &old); {
		case err == nil:
			if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
				return err
			}
		case errors.Is(err, qrm.ErrNoRows):
			// No prior email (this doubles as an attach); nothing to retain.
		default:
			return fmt.Errorf("load outgoing email identity: %w", err)
		}
		del := table.Identities.DELETE().WHERE(
			table.Identities.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
		)
		if _, err := del.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("delete old email identity: %w", err)
		}
		ins := table.Identities.INSERT(
			table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
			table.Identities.ExternalID, table.Identities.Confirmed,
		).VALUES(identityID, accountID, KindEmail, newEmail, true)
		if _, err := ins.ExecContext(ctx, tx); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		if isUniqueViolation(err) {
			return ErrEmailTaken
		}
		return fmt.Errorf("account: replace email identity: %w", err)
	}
	return nil
}

// confirmEmailLogin consumes the login code and marks the existing email
// identity confirmed, inside one transaction. The identity already exists (a
// login provisioned it), so this updates rather than inserts and is idempotent
// for a returning user whose identity is already confirmed.
func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
	return withTx(ctx, s.db, func(tx *sql.Tx) error {
		upd := table.EmailConfirmations.
			UPDATE(table.EmailConfirmations.ConsumedAt).
			SET(postgres.TimestampzT(now)).
			WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
		if _, err := upd.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("consume login code: %w", err)
		}
		confirm := table.Identities.
			UPDATE(table.Identities.Confirmed).
			SET(postgres.Bool(true)).
			WHERE(
				table.Identities.AccountID.EQ(postgres.UUID(accountID)).
					AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
					AND(table.Identities.ExternalID.EQ(postgres.String(email))),
			)
		if _, err := confirm.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("confirm email identity: %w", err)
		}
		return nil
	})
}

// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
func normalizeEmail(email string) (string, error) {
	addr, err := mail.ParseAddress(strings.TrimSpace(email))
	if err != nil {
		return "", fmt.Errorf("%w: %q", ErrInvalidEmail, email)
	}
	return strings.ToLower(addr.Address), nil
}

// generateCode returns a random 6-digit code and its SHA-256 hex hash.
func generateCode() (code, hash string, err error) {
	n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
	if err != nil {
		return "", "", fmt.Errorf("account: generate code: %w", err)
	}
	code = fmt.Sprintf("%06d", n.Int64())
	return code, hashCode(code), nil
}

// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
// travels only in the emailed link, mirroring the session-token model.
func generateLinkToken() (token, hash string, err error) {
	buf := make([]byte, linkTokenBytes)
	if _, err := crand.Read(buf); err != nil {
		return "", "", fmt.Errorf("account: generate link token: %w", err)
	}
	token = base64.RawURLEncoding.EncodeToString(buf)
	return token, hashCode(token), nil
}

// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
	sum := sha256.Sum256([]byte(code))
	return hex.EncodeToString(sum[:])
}
