// Package auth_test 验证 user-service 认证用例的登录、注册、密码、refresh 和靓号登录语义。
package auth_test

import (
	"context"
	"strings"
	"sync"
	"testing"
	"time"

	"hyapp/pkg/xerr"
	authdomain "hyapp/services/user-service/internal/domain/auth"
	userdomain "hyapp/services/user-service/internal/domain/user"
	authservice "hyapp/services/user-service/internal/service/auth"
	userservice "hyapp/services/user-service/internal/service/user"
	"hyapp/services/user-service/internal/testutil/mysqltest"
)

type memoryDecisionCache struct {
	mu      sync.Mutex
	ip      map[string]authservice.IPDecision
	revoked map[string]string
}

func newMemoryDecisionCache() *memoryDecisionCache {
	return &memoryDecisionCache{ip: make(map[string]authservice.IPDecision), revoked: make(map[string]string)}
}

func (c *memoryDecisionCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (authservice.IPDecision, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	decision, ok := c.ip[appCode+":"+clientIPHash]
	return decision, ok, nil
}

func (c *memoryDecisionCache) SetIPDecision(_ context.Context, appCode string, clientIPHash string, decision authservice.IPDecision, _ time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.ip[appCode+":"+clientIPHash] = decision
	return nil
}

func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode string, sessionID string, reason string, _ time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.revoked[appCode+":"+sessionID] = reason
	return nil
}

type sequenceIDGenerator struct {
	// values 是测试预设 user_id 序列。
	values []int64
	// index 指向当前要返回的 user_id。
	index int
}

func (g *sequenceIDGenerator) NewInt64() int64 {
	// 到达末尾后保持返回最后一个值，便于测试重试耗尽路径。
	value := g.values[g.index]
	if g.index < len(g.values)-1 {
		g.index++
	}

	return value
}

type sequenceDisplayUserIDAllocator struct {
	// values 是测试预设 display_user_id 候选序列。
	values []string
}

func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string {
	if attempt >= len(a.values) {
		// 超出候选数量后复用最后一个值，便于构造持续冲突。
		return a.values[len(a.values)-1]
	}

	return a.values[attempt]
}

func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
	base := []userservice.Option{
		userservice.WithIdentityRepository(repository),
		userservice.WithCountryRegionRepository(repository),
		userservice.WithCountryAdminRepository(repository),
		userservice.WithRegionAdminRepository(repository),
		userservice.WithRegionRebuildRepository(repository),
		userservice.WithDeviceRepository(repository),
	}
	return userservice.New(repository, append(base, options...)...)
}

func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int64, displayIDs []string, extra ...authservice.Option) *authservice.Service {
	// 测试服务固定 JWT、发号器、短号候选和时钟，保证 token/session 语义可断言。
	options := []authservice.Option{
		authservice.WithAuthRepository(repository),
		authservice.WithUserRepository(repository),
		authservice.WithIdentityRepository(repository),
		authservice.WithCountryRegionRepository(repository),
		authservice.WithIDGenerator(&sequenceIDGenerator{values: ids}),
		authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}),
		authservice.WithClock(func() time.Time { return *now }),
		authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})),
	}
	options = append(options, extra...)
	return authservice.New(authservice.Config{
		Issuer:             "hyapp-test",
		AccessTokenTTLSec:  1800,
		RefreshTokenTTLSec: 2592000,
		SigningAlg:         "HS256",
		SigningSecret:      "test-secret",
	}, options...)
}

func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
	// 三方登录是注册入口，测试默认同时提供设备和平台，避免生成缺失注册上下文的用户。
	return authdomain.ThirdPartyRegistration{DeviceID: "dev-" + platform, Platform: platform}
}

func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
	t.Helper()
	if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
		t.Fatalf("resolve country %s failed: %v", code, err)
	} else if ok {
		return country
	}
	return repository.PutCountry(userdomain.Country{
		CountryName:        code + " Name",
		CountryCode:        code,
		CountryDisplayName: code + " Display",
		Enabled:            true,
	})
}

func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region {
	t.Helper()
	return repository.PutRegion(userdomain.Region{
		RegionCode:      code,
		Name:            code + " Region",
		Status:          userdomain.RegionStatusActive,
		Countries:       countries,
		CreatedByUserID: 1,
		UpdatedByUserID: 1,
	})
}

func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region {
	t.Helper()
	region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country)
	if err != nil {
		t.Fatalf("resolve region for country %s failed: %v", country, err)
	}
	if !ok {
		t.Fatalf("country %s should have seeded region mapping", country)
	}

	return region
}

func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
	// 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})

	thirdToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
		t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
	}
	if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
	}

	if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		// 未设置密码前不能通过短号密码登录，且错误必须统一 AUTH_FAILED。
		t.Fatalf("expected AUTH_FAILED before password is set, got %v", err)
	}

	if err := svc.SetPassword(ctx, thirdToken.UserID, "secret-pass", authservice.Meta{RequestID: "req-set-password"}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	if err := svc.SetPassword(ctx, thirdToken.UserID, "another-pass", authservice.Meta{}); !xerr.IsCode(err, xerr.PasswordAlreadySet) {
		// v1 只允许首次设置密码，暂不提供重置密码流程。
		t.Fatalf("expected PASSWORD_ALREADY_SET, got %v", err)
	}

	loginToken, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginPassword failed: %v", err)
	}
	if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != "163000" || loginToken.RefreshToken == thirdToken.RefreshToken {
		t.Fatalf("unexpected login token: %+v", loginToken)
	}

	if _, err := svc.LoginPassword(ctx, "163000", "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		t.Fatalf("expected AUTH_FAILED for wrong password, got %v", err)
	}

	now = now.Add(time.Second)
	// refresh 成功后必须返回新的 refresh token，并吊销旧 token。
	refreshToken, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{RequestID: "req-refresh"})
	if err != nil {
		t.Fatalf("RefreshToken failed: %v", err)
	}
	if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != "163000" {
		t.Fatalf("refresh token must rotate")
	}

	if _, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected old refresh token revoked, got %v", err)
	}

	revoked, err := svc.Logout(ctx, refreshToken.SessionID, refreshToken.RefreshToken, authservice.Meta{RequestID: "req-logout"})
	if err != nil || !revoked {
		t.Fatalf("Logout failed: revoked=%v err=%v", revoked, err)
	}
	if _, err := svc.RefreshToken(ctx, refreshToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected logout refresh token revoked, got %v", err)
	}
}

func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
	// gateway profile gate 依赖 access token 快照；refresh 必须在用户完成注册后带出最新状态。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("new token should require onboarding: %+v", token)
	}

	now = time.UnixMilli(2000)
	if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
		t.Fatalf("CompleteOnboarding failed: %v", err)
	}

	now = time.UnixMilli(3000)
	refreshed, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{RequestID: "req-refresh"})
	if err != nil {
		t.Fatalf("RefreshToken failed: %v", err)
	}
	if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
		t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed)
	}
}

func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-blocked", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-blocked",
		ClientIP:    "203.0.113.77",
		CountryByIP: "CN",
		Platform:    "ios",
		Language:    "en-US",
		Timezone:    "America/Los_Angeles",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{
		WorkerID:  "risk-test",
		LockTTL:   time.Second,
		BatchSize: 10,
	})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-revoked"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected revoked refresh session, got %v", err)
	}
	if got := cache.revoked["lalu:"+token.SessionID]; got != "GEO_POLICY_BLOCKED" {
		t.Fatalf("revoked denylist mismatch: %q", got)
	}
	decision, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.77")]
	if !ok || decision.Decision != "blocked" || decision.Country != "CN" {
		t.Fatalf("blocked IP cache mismatch: ok=%v decision=%+v", ok, decision)
	}

	var result string
	var blocked bool
	var blockReason string
	err = repository.RawDB().QueryRowContext(ctx, `
		SELECT result, blocked, block_reason
		FROM login_audit
		WHERE app_code = 'lalu' AND request_id = 'req-risk-blocked' AND user_id = ?
	`, token.UserID).Scan(&result, &blocked, &blockReason)
	if err != nil {
		t.Fatalf("query login audit failed: %v", err)
	}
	if result != "revoked" || !blocked || blockReason != "ip_async" {
		t.Fatalf("login audit risk mark mismatch: result=%s blocked=%v reason=%s", result, blocked, blockReason)
	}
}

func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
	// CompleteOnboarding 后只需要替换 access token；refresh token 原文不可从数据库反查，也不应被静默轮换。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	now = time.UnixMilli(2000)
	if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil {
		t.Fatalf("CompleteOnboarding failed: %v", err)
	}

	now = time.UnixMilli(3000)
	reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"})
	if err != nil {
		t.Fatalf("IssueAccessTokenForSession failed: %v", err)
	}
	if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" {
		t.Fatalf("reissued token must carry completed onboarding: %+v", reissued)
	}
	if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
		t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
	}
}

func TestDisabledUserCannotLogin(t *testing.T) {
	// 用户状态被禁用后，即使密码正确也不能继续签发 token。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := svc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	user.Status = userdomain.StatusDisabled
	// 直接改 MySQL 测试库中的用户状态，模拟运营禁用用户。
	repository.PutUser(user)

	_, err = svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if !xerr.IsCode(err, xerr.UserDisabled) {
		t.Fatalf("expected USER_DISABLED, got %v", err)
	}
}

func TestThirdPartyLoginOrRegister(t *testing.T) {
	// 首次三方登录创建用户，再次同 provider subject 登录应复用原用户。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"})

	firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-first"})
	if err != nil {
		t.Fatalf("first LoginThirdParty failed: %v", err)
	}
	if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" {
		t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
	}

	secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-second"})
	if err != nil {
		t.Fatalf("second LoginThirdParty failed: %v", err)
	}
	if isNewUser || secondToken.UserID != firstToken.UserID {
		t.Fatalf("expected existing third-party user, token=%+v isNew=%v", secondToken, isNewUser)
	}

	if _, _, err := svc.LoginThirdParty(ctx, "unknown", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		// 未允许的 provider 统一认证失败。
		t.Fatalf("expected AUTH_FAILED for unknown provider, got %v", err)
	}
}

func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
	// 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"})
	if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
		AppCode:                  "lalu",
		UserID:                   800001,
		DefaultDisplayUserID:     "188001",
		CurrentDisplayUserID:     "188001",
		CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
		Status:                   userdomain.StatusActive,
		OnboardingStatus:         userdomain.OnboardingStatusProfileRequired,
		CreatedAtMs:              now.UnixMilli(),
		UpdatedAtMs:              now.UnixMilli(),
	}, userdomain.Identity{
		AppCode:              "lalu",
		UserID:               800001,
		DisplayUserID:        "188001",
		DefaultDisplayUserID: "188001",
		DisplayUserIDKind:    userdomain.DisplayUserIDKindDefault,
		Status:               userdomain.DisplayUserIDStatusActive,
	}); err != nil {
		t.Fatalf("seed inviter failed: %v", err)
	}
	inviter, err := repository.GetUser(ctx, 800001)
	if err != nil {
		t.Fatalf("get inviter failed: %v", err)
	}
	registration := authdomain.ThirdPartyRegistration{
		Username:       " hy ",
		Gender:         "male",
		Country:        "CN",
		InviteCode:     inviter.InviteOverview.MyInviteCode,
		IP:             "198.51.100.9",
		DeviceID:       " dev-1 ",
		Device:         "iPhone 15",
		OSVersion:      " iOS 18.1 ",
		Avatar:         "https://cdn.example/avatar.png",
		BirthDate:      "2000-01-02",
		AppVersion:     "1.2.3",
		BuildNumber:    " 123 ",
		Source:         "campaign-a",
		InstallChannel: " app_store ",
		Campaign:       " spring_launch ",
		Platform:       "IOS",
		Language:       "zh-CN",
		Timezone:       " Asia/Shanghai ",
	}

	token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-profile", registration, authservice.Meta{ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIP: "cn"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNewUser {
		t.Fatalf("first third-party login must create user")
	}

	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != inviter.InviteOverview.MyInviteCode {
		t.Fatalf("profile fields mismatch: %+v", user)
	}
	if user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "CN" {
		t.Fatalf("gateway observed source fields mismatch: %+v", user)
	}
	if user.RegisterDeviceID != "dev-1" || user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" {
		t.Fatalf("registration source fields mismatch: %+v", user)
	}
	if user.Avatar == "" || user.BirthDate != "2000-01-02" || user.RegisterSource != "campaign-a" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Language != "zh-CN" || user.Timezone != "Asia/Shanghai" {
		t.Fatalf("registration profile fields mismatch: %+v", user)
	}
}

func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {
	// 首次注册按国家映射写 users.region_id；已有三方登录不因请求体国家变化改用户资料。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	region := mustResolveRegionByCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"})

	firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
		DeviceID: "ios",
		Platform: "ios",
		Country:  " sg ",
	}, authservice.Meta{RequestID: "req-region-first"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNew {
		t.Fatalf("first third-party login must create user")
	}
	user, err := repository.GetUser(ctx, firstToken.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode {
		t.Fatalf("region assignment mismatch: %+v", user)
	}

	_, isNew, err = svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
		DeviceID: "ios",
		Platform: "ios",
		Country:  "ZZ",
	}, authservice.Meta{RequestID: "req-region-second"})
	if err != nil {
		t.Fatalf("existing LoginThirdParty failed: %v", err)
	}
	if isNew {
		t.Fatalf("existing third-party login must not create user")
	}
	user, err = repository.GetUser(ctx, firstToken.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Country != "SG" || user.RegionID != region.RegionID {
		t.Fatalf("existing login must not rewrite country or region: %+v", user)
	}
}

func TestThirdPartyRegisterRejectsUnknownOrDisabledCountry(t *testing.T) {
	// 国家格式合法但未命中 active countries 时，也必须返回稳定 INVALID_ARGUMENT。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	country := seedCountry(t, repository, "MY")
	repository.SetCountryEnabled(country.CountryID, false)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900007, 900008}, []string{"100007", "100008"})

	for _, tc := range []struct {
		name    string
		country string
	}{
		{name: "unknown", country: "ZZ"},
		{name: "disabled", country: "MY"},
	} {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: tc.country}, authservice.Meta{})
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for %s country, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterValidatesRegistrationProfile(t *testing.T) {
	// 格式边界必须在 service 层返回稳定 INVALID_ARGUMENT，不能让 MySQL 报错泄漏为 upstream error。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900004}, []string{"100004"})

	cases := []struct {
		name         string
		registration authdomain.ThirdPartyRegistration
	}{
		{name: "birth_format", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "2000/01/02"}},
		{name: "birth_mysql_range", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "0001-01-01"}},
		{name: "platform_missing", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios"}},
		{name: "platform_invalid", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "web"}},
		{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: "U1"}},
		{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: "ftp://cdn.example/avatar.png"}},
		{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: "en_US"}},
		{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: "Mars/Phobos"}},
	}
	for _, tc := range cases {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, authservice.Meta{})
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for %s, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterValidatesSQLBackedLengths(t *testing.T) {
	// 每个写入 users VARCHAR 的注册字段都要在 service 层按 schema 长度拒绝，避免数据库错误变成 UPSTREAM_ERROR。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900005}, []string{"100005"})
	longURL := "https://cdn.example/" + strings.Repeat("a", 513)
	cases := []struct {
		name         string
		registration authdomain.ThirdPartyRegistration
		meta         authservice.Meta
	}{
		{name: "username", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Username: strings.Repeat("a", 65)}},
		{name: "gender", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Gender: strings.Repeat("a", 33)}},
		{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: strings.Repeat("a", 65)}},
		{name: "invite_code", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InviteCode: strings.Repeat("a", 65)}},
		{name: "register_ip", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{ClientIP: strings.Repeat("1", 65)}},
		{name: "register_user_agent", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{UserAgent: strings.Repeat("a", 513)}},
		{name: "device_id", registration: authdomain.ThirdPartyRegistration{DeviceID: strings.Repeat("a", 129), Platform: "ios"}},
		{name: "device", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Device: strings.Repeat("a", 129)}},
		{name: "os_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", OSVersion: strings.Repeat("a", 65)}},
		{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: longURL}},
		{name: "app_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", AppVersion: strings.Repeat("a", 65)}},
		{name: "build_number", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BuildNumber: strings.Repeat("a", 65)}},
		{name: "source", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Source: strings.Repeat("a", 65)}},
		{name: "install_channel", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InstallChannel: strings.Repeat("a", 65)}},
		{name: "campaign", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Campaign: strings.Repeat("a", 129)}},
		{name: "platform", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: strings.Repeat("a", 17)}},
		{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: strings.Repeat("a", 33)}},
		{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: strings.Repeat("a", 65)}},
	}
	for _, tc := range cases {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, tc.meta)
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for overlong %s, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) {
	// 默认短号候选冲突时，三方注册应在有限次数内重试下一个候选。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive})
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"})

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty should retry display_user_id conflict: %v", err)
	}

	userSvc := newUserService(repository)
	identity, err := userSvc.GetUserIdentity(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUserIdentity failed: %v", err)
	}
	if identity.DisplayUserID != "163001" {
		t.Fatalf("display_user_id retry mismatch: %+v", identity)
	}
}

func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
	// 密码登录必须使用当前 active display_user_id；靓号 active 时默认号不能登录。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	if _, _, err := userSvc.ApplyPrettyDisplayUserID(ctx, token.UserID, "888888", 1000, "receipt-1", "req-pretty"); err != nil {
		t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err)
	}

	if _, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		// 默认短号处于 held 状态时不能作为登录入口。
		t.Fatalf("default display_user_id must not login while pretty is active, got %v", err)
	}
	prettyToken, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("pretty display_user_id login failed: %v", err)
	}
	if prettyToken.DisplayUserID != "888888" || prettyToken.DefaultDisplayUserID != token.DefaultDisplayUserID || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
		t.Fatalf("unexpected pretty login token: %+v", prettyToken)
	}

	now = time.UnixMilli(2500)
	// 靓号过期后，密码登录默认号会触发懒恢复。
	defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("default display_user_id login should recover after pretty expires: %v", err)
	}
	if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) {
		t.Fatalf("unexpected recovered login token: %+v", defaultToken)
	}
	if _, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		t.Fatalf("expired pretty display_user_id must not login, got %v", err)
	}
}
