package user_test

import (
	"context"
	"database/sql"
	"net/url"
	"strings"
	"testing"
	"time"

	backendpg "galaxy/backend/internal/postgres"
	"galaxy/backend/internal/user"
	pgshared "galaxy/postgres"

	"github.com/google/uuid"
	testcontainers "github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

const (
	testImage     = "postgres:16-alpine"
	testUser      = "galaxy"
	testPassword  = "galaxy"
	testDatabase  = "galaxy_backend"
	testSchema    = "backend"
	testStartup   = 90 * time.Second
	testOpTimeout = 10 * time.Second
)

// startPostgres spins up a Postgres testcontainer with the backend schema
// migrated up. The returned db is closed and the container terminated by
// t.Cleanup hooks; tests should not close them explicitly.
func startPostgres(t *testing.T) *sql.DB {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
	t.Cleanup(cancel)

	pgContainer, err := tcpostgres.Run(ctx, testImage,
		tcpostgres.WithDatabase(testDatabase),
		tcpostgres.WithUsername(testUser),
		tcpostgres.WithPassword(testPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(testStartup),
		),
	)
	if err != nil {
		t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
	}
	t.Cleanup(func() {
		if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
			t.Errorf("terminate postgres container: %v", termErr)
		}
	})

	baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("connection string: %v", err)
	}
	scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
	if err != nil {
		t.Fatalf("scope dsn: %v", err)
	}

	cfg := pgshared.DefaultConfig()
	cfg.PrimaryDSN = scopedDSN
	cfg.OperationTimeout = testOpTimeout

	db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
	if err != nil {
		t.Fatalf("open primary: %v", err)
	}
	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Errorf("close db: %v", err)
		}
	})

	if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
		t.Fatalf("ping: %v", err)
	}
	if err := backendpg.ApplyMigrations(ctx, db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	return db
}

func dsnWithSearchPath(baseDSN, schema string) (string, error) {
	parsed, err := url.Parse(baseDSN)
	if err != nil {
		return "", err
	}
	values := parsed.Query()
	values.Set("search_path", schema)
	if values.Get("sslmode") == "" {
		values.Set("sslmode", "disable")
	}
	parsed.RawQuery = values.Encode()
	return parsed.String(), nil
}

// newServiceForTest builds a *user.Service with a real Postgres pool
// and an empty cache. The cascade dependencies are left nil — the
// tests in this file exercise EnsureByEmail and the lookup paths that
// do not need them. Tests that drive sanctions/limits/soft-delete
// build their own Deps inline.
func newServiceForTest(db *sql.DB, now func() time.Time) *user.Service {
	return user.NewService(user.Deps{

		Store:              user.NewStore(db),
		Cache:              user.NewCache(),
		UserNameMaxRetries: 10,
		Now:                now,
	})
}

func TestEnsureByEmailCreatesNewAccount(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	ctx := context.Background()
	uid, err := svc.EnsureByEmail(ctx, "Pilot@Example.Test", "ru", "Europe/Kaliningrad", "RU")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	if uid == uuid.Nil {
		t.Fatalf("EnsureByEmail returned uuid.Nil")
	}

	var (
		gotEmail   string
		gotName    string
		gotLang    string
		gotTZ      string
		gotCountry *string
		gotDeleted *time.Time
		permanent  bool
	)
	err = db.QueryRowContext(ctx, `
		SELECT email, user_name, preferred_language, time_zone, declared_country, deleted_at, permanent_block
		FROM backend.accounts WHERE user_id = $1
	`, uid).Scan(&gotEmail, &gotName, &gotLang, &gotTZ, &gotCountry, &gotDeleted, &permanent)
	if err != nil {
		t.Fatalf("post-insert SELECT: %v", err)
	}
	if gotEmail != "pilot@example.test" {
		t.Fatalf("email = %q, want lower-cased %q", gotEmail, "pilot@example.test")
	}
	if !strings.HasPrefix(gotName, "Player-") || len(gotName) != len("Player-")+8 {
		t.Fatalf("user_name = %q, want Player-XXXXXXXX (8 chars)", gotName)
	}
	if gotLang != "ru" {
		t.Fatalf("preferred_language = %q, want %q", gotLang, "ru")
	}
	if gotTZ != "Europe/Kaliningrad" {
		t.Fatalf("time_zone = %q, want %q", gotTZ, "Europe/Kaliningrad")
	}
	if gotCountry == nil || *gotCountry != "RU" {
		t.Fatalf("declared_country = %v, want %q", gotCountry, "RU")
	}
	if gotDeleted != nil {
		t.Fatalf("deleted_at = %v, want NULL", gotDeleted)
	}
	if permanent {
		t.Fatalf("permanent_block = true, want false")
	}
}

func TestEnsureByEmailIdempotentOnSecondCall(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	ctx := context.Background()
	first, err := svc.EnsureByEmail(ctx, "alice@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("first EnsureByEmail: %v", err)
	}

	second, err := svc.EnsureByEmail(ctx, "alice@example.test", "ru", "Asia/Tokyo", "JP")
	if err != nil {
		t.Fatalf("second EnsureByEmail: %v", err)
	}
	if first != second {
		t.Fatalf("user_id changed on second call: first=%s second=%s", first, second)
	}

	// The second call's "would-be" values must be ignored — the row keeps
	// the values from the first call.
	var lang, tz string
	var country *string
	err = db.QueryRowContext(ctx, `
		SELECT preferred_language, time_zone, declared_country
		FROM backend.accounts WHERE user_id = $1
	`, first).Scan(&lang, &tz, &country)
	if err != nil {
		t.Fatalf("post-second SELECT: %v", err)
	}
	if lang != "en" || tz != "UTC" || country != nil {
		t.Fatalf("existing account mutated: lang=%q tz=%q country=%v",
			lang, tz, country)
	}
}

func TestEnsureByEmailEmptyDeclaredCountryWritesNull(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(),
		"bob@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}

	var country *string
	err = db.QueryRowContext(context.Background(),
		`SELECT declared_country FROM backend.accounts WHERE user_id = $1`,
		uid).Scan(&country)
	if err != nil {
		t.Fatalf("SELECT: %v", err)
	}
	if country != nil {
		t.Fatalf("declared_country = %q, want NULL", *country)
	}
}

func TestEnsureByEmailRejectsEmpty(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	if _, err := svc.EnsureByEmail(context.Background(),
		"   ", "en", "UTC", ""); err == nil {
		t.Fatalf("expected error for blank email")
	}
}

func TestEnsureByEmailInstallsDefaultEntitlement(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "kira@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	account, err := svc.GetAccount(context.Background(), uid)
	if err != nil {
		t.Fatalf("GetAccount: %v", err)
	}
	if account.Entitlement.Tier != user.TierFree {
		t.Fatalf("default tier = %q, want %q", account.Entitlement.Tier, user.TierFree)
	}
	if account.Entitlement.IsPaid {
		t.Fatalf("default is_paid = true, want false")
	}
	if account.Entitlement.MaxRegisteredRaceNames != 1 {
		t.Fatalf("default max_registered_race_names = %d, want 1", account.Entitlement.MaxRegisteredRaceNames)
	}
	if account.Entitlement.Source != "system" {
		t.Fatalf("default source = %q, want \"system\"", account.Entitlement.Source)
	}
	if account.Entitlement.Actor.Type != "system" {
		t.Fatalf("default actor.type = %q, want \"system\"", account.Entitlement.Actor.Type)
	}
}

func TestGetAccountReturnsErrAccountNotFoundForMissing(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	if _, err := svc.GetAccount(context.Background(), uuid.New()); err == nil {
		t.Fatalf("expected error for missing user")
	}
}

func TestResolveByEmailFindsLiveAccount(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "carol@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	resolved, err := svc.ResolveByEmail(context.Background(), "Carol@Example.Test")
	if err != nil {
		t.Fatalf("ResolveByEmail: %v", err)
	}
	if resolved != uid {
		t.Fatalf("ResolveByEmail = %s, want %s", resolved, uid)
	}
}

func TestResolveByEmailReturnsErrAccountNotFoundForMissing(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)
	if _, err := svc.ResolveByEmail(context.Background(), "ghost@example.test"); err == nil {
		t.Fatalf("expected error for missing email")
	}
}

func TestUpdateProfileWritesDisplayName(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "dan@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	displayName := "Daniel"
	account, err := svc.UpdateProfile(context.Background(), uid, user.UpdateProfileInput{DisplayName: &displayName})
	if err != nil {
		t.Fatalf("UpdateProfile: %v", err)
	}
	if account.DisplayName != "Daniel" {
		t.Fatalf("display_name = %q, want %q", account.DisplayName, "Daniel")
	}
}

func TestUpdateSettingsRejectsInvalidTimezone(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "eve@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	bogus := "Mars/Olympus"
	_, err = svc.UpdateSettings(context.Background(), uid, user.UpdateSettingsInput{TimeZone: &bogus})
	if err == nil {
		t.Fatalf("expected error for invalid time_zone")
	}
}

func TestApplyEntitlementMonthlyComputesEndsAt(t *testing.T) {
	db := startPostgres(t)
	frozen := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
	svc := newServiceForTest(db, func() time.Time { return frozen })

	uid, err := svc.EnsureByEmail(context.Background(), "fox@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	account, err := svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
		UserID: uid,
		Tier:   user.TierMonthly,
		Source: "admin",
		Actor:  user.ActorRef{Type: "admin", ID: "operator"},
	})
	if err != nil {
		t.Fatalf("ApplyEntitlement: %v", err)
	}
	if account.Entitlement.Tier != user.TierMonthly {
		t.Fatalf("tier = %q, want %q", account.Entitlement.Tier, user.TierMonthly)
	}
	if !account.Entitlement.IsPaid {
		t.Fatalf("monthly tier returned is_paid=false")
	}
	if account.Entitlement.EndsAt == nil {
		t.Fatalf("monthly tier returned ends_at = nil")
	}
	got := account.Entitlement.EndsAt.UTC()
	want := frozen.Add(30 * 24 * time.Hour)
	if !got.Equal(want) {
		t.Fatalf("ends_at = %s, want %s", got, want)
	}
}

func TestApplyEntitlementRejectsUnknownTier(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "gail@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	_, err = svc.ApplyEntitlement(context.Background(), user.ApplyEntitlementInput{
		UserID: uid,
		Tier:   "platinum",
		Source: "admin",
		Actor:  user.ActorRef{Type: "admin", ID: "operator"},
	})
	if err == nil {
		t.Fatalf("expected ErrInvalidTier")
	}
}

func TestApplySanctionPermanentBlockFlipsFlagAndCallsRevoker(t *testing.T) {
	db := startPostgres(t)
	revoker := &recordingRevoker{}
	lobby := &recordingLobbyCascade{}
	svc := user.NewService(user.Deps{

		Store:              user.NewStore(db),
		Cache:              user.NewCache(),
		Lobby:              lobby,
		SessionRevoker:     revoker,
		UserNameMaxRetries: 10,
		Now:                time.Now,
	})

	uid, err := svc.EnsureByEmail(context.Background(), "han@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	if _, err := svc.ApplySanction(context.Background(), user.ApplySanctionInput{
		UserID:       uid,
		SanctionCode: user.SanctionCodePermanentBlock,
		Scope:        "platform",
		ReasonCode:   "tos_violation",
		Actor:        user.ActorRef{Type: "admin", ID: "operator"},
	}); err != nil {
		t.Fatalf("ApplySanction: %v", err)
	}
	if revoker.calls != 1 || revoker.lastUser != uid {
		t.Fatalf("revoker calls=%d lastUser=%s, want 1 / %s", revoker.calls, revoker.lastUser, uid)
	}
	if lobby.blockedCalls != 1 || lobby.lastBlockedUser != uid {
		t.Fatalf("lobby blocked calls=%d lastUser=%s, want 1 / %s", lobby.blockedCalls, lobby.lastBlockedUser, uid)
	}
	var permanent bool
	if err := db.QueryRowContext(context.Background(),
		`SELECT permanent_block FROM backend.accounts WHERE user_id = $1`, uid).Scan(&permanent); err != nil {
		t.Fatalf("SELECT permanent_block: %v", err)
	}
	if !permanent {
		t.Fatalf("permanent_block = false after permanent_block sanction")
	}
}

func TestApplyLimitWritesActiveRow(t *testing.T) {
	db := startPostgres(t)
	svc := newServiceForTest(db, time.Now)

	uid, err := svc.EnsureByEmail(context.Background(), "iris@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail: %v", err)
	}
	if _, err := svc.ApplyLimit(context.Background(), user.ApplyLimitInput{
		UserID:     uid,
		LimitCode:  "max_active_games",
		Value:      3,
		ReasonCode: "manual_review",
		Actor:      user.ActorRef{Type: "admin", ID: "operator"},
	}); err != nil {
		t.Fatalf("ApplyLimit: %v", err)
	}
	var value int32
	if err := db.QueryRowContext(context.Background(),
		`SELECT value FROM backend.limit_active WHERE user_id = $1 AND limit_code = 'max_active_games'`, uid,
	).Scan(&value); err != nil {
		t.Fatalf("SELECT limit_active.value: %v", err)
	}
	if value != 3 {
		t.Fatalf("limit_active.value = %d, want 3", value)
	}
}

func TestListAccountsExcludesSoftDeleted(t *testing.T) {
	db := startPostgres(t)
	revoker := &recordingRevoker{}
	svc := user.NewService(user.Deps{

		Store:              user.NewStore(db),
		Cache:              user.NewCache(),
		Lobby:              &recordingLobbyCascade{},
		Notification:       &recordingNotificationCascade{},
		SessionRevoker:     revoker,
		UserNameMaxRetries: 10,
		Now:                time.Now,
	})

	live, err := svc.EnsureByEmail(context.Background(), "live@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail live: %v", err)
	}
	gone, err := svc.EnsureByEmail(context.Background(), "gone@example.test", "en", "UTC", "")
	if err != nil {
		t.Fatalf("EnsureByEmail gone: %v", err)
	}
	if err := svc.SoftDelete(context.Background(), gone, user.ActorRef{Type: "user", ID: gone.String()}); err != nil {
		t.Fatalf("SoftDelete: %v", err)
	}

	page, err := svc.ListAccounts(context.Background(), 1, 50)
	if err != nil {
		t.Fatalf("ListAccounts: %v", err)
	}
	for _, item := range page.Items {
		if item.UserID == gone {
			t.Fatalf("ListAccounts returned a soft-deleted account: %+v", item)
		}
		if item.DeletedAt != nil {
			t.Fatalf("ListAccounts returned an account with non-nil DeletedAt: %+v", item)
		}
	}
	found := false
	for _, item := range page.Items {
		if item.UserID == live {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("ListAccounts did not include the live account")
	}
}

// recordingRevoker is a SessionRevoker spy that captures every call
// for assertion. It is shared across tests in this package.
type recordingRevoker struct {
	calls     int
	lastUser  uuid.UUID
	lastActor user.SessionRevokeActor
}

func (r *recordingRevoker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error {
	r.calls++
	r.lastUser = userID
	r.lastActor = actor
	return nil
}

// recordingLobbyCascade captures the OnUserDeleted / OnUserBlocked
// calls so soft-delete and permanent-block tests can assert ordering
// and frequency.
type recordingLobbyCascade struct {
	deletedCalls    int
	blockedCalls    int
	lastDeletedUser uuid.UUID
	lastBlockedUser uuid.UUID
}

func (c *recordingLobbyCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
	c.deletedCalls++
	c.lastDeletedUser = userID
	return nil
}

func (c *recordingLobbyCascade) OnUserBlocked(_ context.Context, userID uuid.UUID) error {
	c.blockedCalls++
	c.lastBlockedUser = userID
	return nil
}

// recordingNotificationCascade captures OnUserDeleted invocations.
type recordingNotificationCascade struct {
	calls    int
	lastUser uuid.UUID
}

func (c *recordingNotificationCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
	c.calls++
	c.lastUser = userID
	return nil
}

// recordingGeoCascade captures OnUserDeleted invocations.
type recordingGeoCascade struct {
	calls    int
	lastUser uuid.UUID
}

func (c *recordingGeoCascade) OnUserDeleted(_ context.Context, userID uuid.UUID) error {
	c.calls++
	c.lastUser = userID
	return nil
}

// silence unused-import warnings on database/sql when tests only need
// it through fixture helpers.
var _ = sql.LevelDefault
