// user_device_admin_token_test 验证认证成功后的设备档案聚合和后台按用户重签 access token 的语义。
package auth_test

import (
	"context"
	"testing"
	"time"

	"hyapp/pkg/xerr"
	authservice "hyapp/services/user-service/internal/service/auth"
	"hyapp/services/user-service/internal/testutil/mysqltest"
)

type userDeviceRow struct {
	Platform      string
	DeviceModel   string
	Manufacturer  string
	OSVersion     string
	IMEI          string
	AppVersion    string
	BuildNumber   string
	FirstSeenAtMs int64
	LastSeenAtMs  int64
}

func queryUserDevice(t *testing.T, repository *mysqltest.Repository, userID int64, deviceID string) userDeviceRow {
	t.Helper()
	var row userDeviceRow
	if err := repository.RawDB().QueryRowContext(context.Background(), `
		SELECT platform, device_model, manufacturer, os_version, imei, app_version, build_number, first_seen_at_ms, last_seen_at_ms
		FROM user_devices
		WHERE app_code = 'lalu' AND user_id = ? AND device_id = ?
	`, userID, deviceID).Scan(&row.Platform, &row.DeviceModel, &row.Manufacturer, &row.OSVersion, &row.IMEI, &row.AppVersion, &row.BuildNumber, &row.FirstSeenAtMs, &row.LastSeenAtMs); err != nil {
		t.Fatalf("query user_devices failed: %v", err)
	}
	return row
}

func countUserDevices(t *testing.T, repository *mysqltest.Repository, userID int64) int {
	t.Helper()
	var count int
	if err := repository.RawDB().QueryRowContext(context.Background(), `
		SELECT COUNT(*) FROM user_devices WHERE app_code = 'lalu' AND user_id = ?
	`, userID).Scan(&count); err != nil {
		t.Fatalf("count user_devices failed: %v", err)
	}
	return count
}

func countLoginAudit(t *testing.T, repository *mysqltest.Repository, userID int64) int {
	t.Helper()
	var count int
	if err := repository.RawDB().QueryRowContext(context.Background(), `
		SELECT COUNT(*) FROM login_audit WHERE app_code = 'lalu' AND user_id = ?
	`, userID).Scan(&count); err != nil {
		t.Fatalf("count login_audit failed: %v", err)
	}
	return count
}

func TestThirdPartyLoginUpsertsUserDeviceWithNonEmptyWins(t *testing.T) {
	// 首次注册写全量设备资料；后续登录只带部分字段时，空值不能抹掉已上报的型号/厂商，last_seen 必须前移。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{910001, 910002}, []string{"7100001", "7100002"})

	// 与真实链路一致：gateway 把版本头同时写入 Meta 和注册字段（LoginThirdParty 会用注册字段覆盖 Meta）。
	registration := thirdPartyRegistration("android")
	registration.AppVersion = "1.2.0"
	registration.BuildNumber = "120"
	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-upsert", registration, authservice.Meta{
		AppCode:            "lalu",
		RequestID:          "req-device-register",
		Platform:           "android",
		AppVersion:         "1.2.0",
		BuildNumber:        "120",
		DeviceModel:        "Redmi Note 8 Pro",
		DeviceManufacturer: "Xiaomi",
		OSVersion:          "13",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	row := queryUserDevice(t, repository, token.UserID, registration.DeviceID)
	if row.DeviceModel != "Redmi Note 8 Pro" || row.Manufacturer != "Xiaomi" || row.OSVersion != "13" || row.Platform != "android" {
		t.Fatalf("registered device profile mismatch: %+v", row)
	}
	if row.AppVersion != "1.2.0" || row.BuildNumber != "120" || row.IMEI != "" {
		t.Fatalf("registered device version snapshot mismatch: %+v", row)
	}
	if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 1000 {
		t.Fatalf("registered device seen timestamps mismatch: %+v", row)
	}

	// 二次登录只携带新版本号，设备头缺失：型号/厂商/系统版本保持旧值，版本与 last_seen 前移，first_seen 不变。
	now = time.UnixMilli(9000)
	reloginRegistration := registration
	reloginRegistration.AppVersion = "1.3.0"
	reloginRegistration.BuildNumber = "130"
	if _, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-upsert", reloginRegistration, authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-device-relogin",
		Platform:    "android",
		AppVersion:  "1.3.0",
		BuildNumber: "130",
	}); err != nil {
		t.Fatalf("second LoginThirdParty failed: %v", err)
	}

	row = queryUserDevice(t, repository, token.UserID, registration.DeviceID)
	if row.DeviceModel != "Redmi Note 8 Pro" || row.Manufacturer != "Xiaomi" || row.OSVersion != "13" {
		t.Fatalf("non-empty wins violated: %+v", row)
	}
	if row.AppVersion != "1.3.0" || row.BuildNumber != "130" {
		t.Fatalf("app version must follow latest login: %+v", row)
	}
	if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 9000 {
		t.Fatalf("seen timestamps mismatch after relogin: %+v", row)
	}
	if count := countUserDevices(t, repository, token.UserID); count != 1 {
		t.Fatalf("same device must stay one row, got %d", count)
	}
}

func TestRefreshTokenUpsertsUserDeviceFromMeta(t *testing.T) {
	// refresh 是登录态存续主路径：升级新客户端后设备头应通过 refresh 补齐设备档案。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{910003}, []string{"7100003"})

	registration := thirdPartyRegistration("ios")
	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-refresh", registration, authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(5000)
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, registration.DeviceID, authservice.Meta{
		AppCode:            "lalu",
		RequestID:          "req-refresh",
		Platform:           "ios",
		AppVersion:         "2.0.0",
		DeviceModel:        "iPhone17,2",
		DeviceManufacturer: "Apple",
		OSVersion:          "18.1",
	}); err != nil {
		t.Fatalf("RefreshToken failed: %v", err)
	}

	row := queryUserDevice(t, repository, token.UserID, registration.DeviceID)
	if row.DeviceModel != "iPhone17,2" || row.Manufacturer != "Apple" || row.OSVersion != "18.1" || row.AppVersion != "2.0.0" {
		t.Fatalf("refresh must backfill device profile: %+v", row)
	}
	if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 5000 {
		t.Fatalf("refresh seen timestamps mismatch: %+v", row)
	}
}

func TestAdminIssueUserAccessTokenUsesNewestSessionWithoutAuditOrSessionMutation(t *testing.T) {
	// 后台重签必须选最新 active session、不写 login_audit、不更新 session 行——
	// 否则会污染“最近活跃/最近登录”口径，让运营误判用户在线。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{910004}, []string{"7100004"})

	registration := thirdPartyRegistration("android")
	first, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-token", registration, authservice.Meta{AppCode: "lalu", RequestID: "req-login-1"})
	if err != nil {
		t.Fatalf("first LoginThirdParty failed: %v", err)
	}
	// 同一用户第二台设备再登录，形成两个 active session；最新 created_at_ms 的必须胜出。
	now = time.UnixMilli(2000)
	secondRegistration := registration
	secondRegistration.DeviceID = "dev-android-second"
	second, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-token", secondRegistration, authservice.Meta{AppCode: "lalu", RequestID: "req-login-2"})
	if err != nil {
		t.Fatalf("second LoginThirdParty failed: %v", err)
	}
	if first.SessionID == second.SessionID {
		t.Fatalf("expected two distinct sessions")
	}

	auditBefore := countLoginAudit(t, repository, first.UserID)
	var updatedBefore int64
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT updated_at_ms FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ?
	`, second.SessionID).Scan(&updatedBefore); err != nil {
		t.Fatalf("query session updated_at failed: %v", err)
	}

	now = time.UnixMilli(60000)
	token, session, err := authSvc.AdminIssueUserAccessToken(ctx, first.UserID, authservice.Meta{AppCode: "lalu", RequestID: "req-admin-token"})
	if err != nil {
		t.Fatalf("AdminIssueUserAccessToken failed: %v", err)
	}
	if token.SessionID != second.SessionID || session.SessionID != second.SessionID {
		t.Fatalf("admin token must bind newest active session: token=%s session=%s want=%s", token.SessionID, session.SessionID, second.SessionID)
	}
	if session.DeviceID != secondRegistration.DeviceID {
		t.Fatalf("admin token session device mismatch: %+v", session)
	}
	if token.AccessToken == "" || token.RefreshToken != "" || token.TokenType != "Bearer" {
		t.Fatalf("admin token must be access-only Bearer: %+v", token)
	}

	if auditAfter := countLoginAudit(t, repository, first.UserID); auditAfter != auditBefore {
		t.Fatalf("admin issue must not write login_audit: before=%d after=%d", auditBefore, auditAfter)
	}
	var updatedAfter int64
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT updated_at_ms FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ?
	`, second.SessionID).Scan(&updatedAfter); err != nil {
		t.Fatalf("query session updated_at failed: %v", err)
	}
	if updatedAfter != updatedBefore {
		t.Fatalf("admin issue must not mutate session row: before=%d after=%d", updatedBefore, updatedAfter)
	}
}

func TestAdminIssueUserAccessTokenReturnsNotFoundWithoutActiveSession(t *testing.T) {
	// 无活跃会话时必须返回 NotFound：token 是无状态 JWT，没有 session 就没有可重签对象，后台要能明确展示。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{910005}, []string{"7100005"})

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-no-session", thirdPartyRegistration("android"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if _, err := authSvc.Logout(ctx, token.SessionID, "", authservice.Meta{AppCode: "lalu", RequestID: "req-logout"}); err != nil {
		t.Fatalf("Logout failed: %v", err)
	}

	if _, _, err := authSvc.AdminIssueUserAccessToken(ctx, token.UserID, authservice.Meta{AppCode: "lalu", RequestID: "req-admin-token"}); !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("expected NotFound without active session, got %v", err)
	}
}
