// Package user owns the platform's account identity records inside the
// `backend.accounts` table together with the entitlement, sanction,
// limit and soft-delete surfaces documented in `backend/PLAN.md` §5.2.
//
// The implementation expanded the surface introduced by currently: the package
// now exposes account read/mutation flows, admin-side overrides
// (sanctions, limits, entitlements), in-process soft-delete cascades
// across `lobby`, `notification`, `geo`, and a write-through
// entitlement-snapshot cache that mirrors the
// `backend/internal/auth.Cache` pattern.
//
// External dependencies that have not landed yet (lobby in 5.4,
// notification in 5.7) are injected through the LobbyCascade and
// NotificationCascade interfaces; the package ships no-op
// implementations that satisfy those contracts until the real services
// arrive.
package user

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgconn"
	"go.uber.org/zap"
)

// Constraint names mirror the names declared in
// `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as
// constants avoids string-typo surprises at runtime when error
// classification asks Postgres which UNIQUE was violated.
const (
	constraintAccountsEmailUnique = "accounts_email_unique"
	constraintAccountsUserNameUnique = "accounts_user_name_unique"
)

// pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres when
// a UNIQUE constraint is violated. The pgx driver surfaces the value on
// `*pgconn.PgError`.
const pgErrCodeUniqueViolation = "23505"

// userNameCharset is the alphabet of the placeholder `Player-XXXXXXXX`
// suffix. Mixed-case letters plus digits gives 62^8 ≈ 2.18×10¹⁴
// possibilities, which makes 10 collision retries an enormous safety
// margin even at MVP scale.
const userNameCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

// userNameSuffixLen is the length of the random suffix appended after
// `Player-`.
const userNameSuffixLen = 8

// Deps aggregates every collaborator the user Service depends on.
// Constructing the Service through Deps (rather than positional args)
// keeps wiring patches small when new dependencies are added.
//
// Store must be non-nil. Cache, Lobby, Notification, Geo and
// SessionRevoker are tested-in-isolation interfaces; production wires
// the matching real implementations through `cmd/backend/main.go`.
type Deps struct {
	Store *Store
	Cache *Cache
	Lobby LobbyCascade
	Notification NotificationCascade
	Geo GeoCascade
	SessionRevoker SessionRevoker

	// UserNameMaxRetries caps the retry budget for synthesising a unique
	// placeholder `accounts.user_name` at registration. A zero or
	// negative value falls back to 1.
	UserNameMaxRetries int

	// Logger is named under "user" by NewService. Nil falls back to
	// zap.NewNop.
	Logger *zap.Logger

	// Now overrides time.Now for deterministic tests. A nil Now defaults
	// to time.Now in NewService.
	Now func() time.Time
}

// Service is the user-domain entry point. Concurrency safety is
// delegated to Postgres for persisted state and to the embedded Cache
// for the in-memory entitlement snapshot projection.
type Service struct {
	deps Deps
}

// NewService constructs a Service from deps. A nil Now defaults to
// time.Now; a nil Logger defaults to zap.NewNop. DB and Store must be
// supplied — calling Service methods with nil values will panic at
// first use, matching how main.go signals missing wiring.
func NewService(deps Deps) *Service {
	if deps.Now == nil {
		deps.Now = time.Now
	}
	if deps.Logger == nil {
		deps.Logger = zap.NewNop()
	}
	deps.Logger = deps.Logger.Named("user")
	if deps.UserNameMaxRetries <= 0 {
		deps.UserNameMaxRetries = 1
	}
	return &Service{deps: deps}
}

// EnsureByEmail returns the user_id of the live account whose email
// matches the supplied (lower-cased, trimmed) value, creating a new
// account if none exists.
//
// For new accounts the function uses the supplied "would-be" values:
// preferredLanguage is written as-is, timeZone is written as-is, and
// declaredCountry is written as NULL when empty. Existing accounts keep
// every stored value; only their user_id is returned.
//
// EnsureByEmail is idempotent on email under concurrent calls. The
// implementation uses ON CONFLICT (email) DO NOTHING RETURNING so a
// concurrent inserter does not double-create. Synthetic user_name
// collisions are retried with a fresh suffix up to UserNameMaxRetries
// times.
//
// On a successful new-account insert the function additionally
// materialises the default `free` entitlement snapshot inside the same
// transaction so no account exists without a snapshot, and refreshes
// the in-memory cache with the freshly persisted snapshot.
func (s *Service) EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error) {
	normalised := strings.ToLower(strings.TrimSpace(email))
	if normalised == "" {
		return uuid.Nil, errors.New("ensure account by email: email is empty")
	}

	if userID, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised); err != nil {
		return uuid.Nil, fmt.Errorf("ensure account by email: lookup: %w", err)
	} else if ok {
		return userID, nil
	}

	return s.insertNew(ctx, normalised, preferredLanguage, timeZone, declaredCountry)
}

func (s *Service) insertNew(ctx context.Context, email, prefLang, tz, country string) (uuid.UUID, error) {
	for attempt := 0; attempt < s.deps.UserNameMaxRetries; attempt++ {
		userName, err := generatePlayerName()
		if err != nil {
			return uuid.Nil, fmt.Errorf("ensure account by email: generate user_name: %w", err)
		}

		userID := uuid.New()
		now := s.deps.Now().UTC()
		snapshot := defaultFreeSnapshot(userID, now)

		insertedID, err := s.deps.Store.InsertAccountWithSnapshot(ctx, accountInsert{
			UserID: userID,
			Email: email,
			UserName: userName,
			PreferredLanguage: prefLang,
			TimeZone: tz,
			DeclaredCountry: country,
		}, snapshot)
		switch {
		case err == nil:
			s.deps.Cache.Add(snapshot)
			return insertedID, nil
		case errors.Is(err, errEmailRace):
			existing, ok, lerr := s.deps.Store.LookupAccountIDByEmail(ctx, email)
			if lerr != nil {
				return uuid.Nil, fmt.Errorf("ensure account by email: lookup after race: %w", lerr)
			}
			if !ok {
				return uuid.Nil, fmt.Errorf("ensure account by email: email exists yet lookup empty (likely soft-deleted)")
			}
			return existing, nil
		case isUniqueViolation(err, constraintAccountsUserNameUnique):
			continue
		default:
			return uuid.Nil, fmt.Errorf("ensure account by email: insert: %w", err)
		}
	}
	return uuid.Nil, fmt.Errorf("ensure account by email: user_name collisions exceeded %d retries", s.deps.UserNameMaxRetries)
}

// generatePlayerName produces a `Player-XXXXXXXX` placeholder where the
// suffix is eight cryptographically-random alphanumeric characters. The
// modulo-bias of `byte%62` is acceptable here: collision avoidance is
// the only invariant — the placeholder never carries cryptographic
// significance and a future stage may surface a separate "claim
// user_name" flow.
func generatePlayerName() (string, error) {
	suffix := make([]byte, userNameSuffixLen)
	if _, err := rand.Read(suffix); err != nil {
		return "", err
	}
	for i := range suffix {
		suffix[i] = userNameCharset[int(suffix[i])%len(userNameCharset)]
	}
	var sb strings.Builder
	sb.Grow(len("Player-") + userNameSuffixLen)
	sb.WriteString("Player-")
	sb.Write(suffix)
	return sb.String(), nil
}

func isUniqueViolation(err error, constraintName string) bool {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return false
	}
	if pgErr.Code != pgErrCodeUniqueViolation {
		return false
	}
	if constraintName == "" {
		return true
	}
	return pgErr.ConstraintName == constraintName
}
