package appuser

import (
	"context"
	"errors"
	"testing"

	userv1 "hyapp.local/api/proto/user/v1"

	"github.com/DATA-DOG/go-sqlmock"
	mysqlDriver "github.com/go-sql-driver/mysql"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func TestFillLoginSnapshotsSplitsIPAndVersionScopes(t *testing.T) {
	// IP 口径包含 access_resign（在线用户 25s 心跳行提供最新 IP），版本口径必须排除它（心跳行版本恒为空）。
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock failed: %v", err)
	}
	defer db.Close()
	service := &Service{userDB: db}

	columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
	// 第一条查询（IP 口径）带 access_resign 参数；第二条（版本口径）只有三种真实登录类型。
	mock.ExpectQuery(`FROM \(`).
		WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh", "lalu", int64(42), "access_resign").
		WillReturnRows(sqlmock.NewRows(columns).AddRow("203.0.113.9", "SA", "", "", int64(9000)))
	mock.ExpectQuery(`FROM \(`).
		WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh").
		WillReturnRows(sqlmock.NewRows(columns).AddRow("198.51.100.7", "SA", "1.4.2", "142", int64(7000)))

	item := AppUser{}
	if err := service.fillLoginSnapshots(context.Background(), &item, 42); err != nil {
		t.Fatalf("fillLoginSnapshots failed: %v", err)
	}
	if item.LastLoginIP != "203.0.113.9" || item.LastLoginIPCountryCode != "SA" || item.LastLoginIPAtMs != 9000 {
		t.Fatalf("last login ip snapshot mismatch: %+v", item)
	}
	if item.AppVersion != "1.4.2" || item.BuildNumber != "142" || item.AppVersionAtMs != 7000 {
		t.Fatalf("app version snapshot mismatch: %+v", item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet expectations: %v", err)
	}
}

func TestFillLoginSnapshotsToleratesUserWithoutAudit(t *testing.T) {
	// 老用户或审计被清理时详情页仍要能打开，各字段落零值而不是 500。
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock failed: %v", err)
	}
	defer db.Close()
	service := &Service{userDB: db}

	columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
	mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
	mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))

	item := AppUser{}
	if err := service.fillLoginSnapshots(context.Background(), &item, 43); err != nil {
		t.Fatalf("fillLoginSnapshots failed: %v", err)
	}
	if item.LastLoginIP != "" || item.LastLoginIPAtMs != 0 || item.AppVersion != "" || item.AppVersionAtMs != 0 {
		t.Fatalf("empty audit must produce zero values: %+v", item)
	}
}

func TestFillDevicesToleratesMissingTable(t *testing.T) {
	// user_devices 由人工执行 DDL 引入；表未创建（Error 1146）时详情页按空列表展示，不能整页失败。
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock failed: %v", err)
	}
	defer db.Close()
	service := &Service{userDB: db}

	mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1146, Message: "Table 'hyapp_user.user_devices' doesn't exist"})

	item := AppUser{}
	if err := service.fillDevices(context.Background(), &item, 42); err != nil {
		t.Fatalf("fillDevices must tolerate missing table: %v", err)
	}
	if item.Devices == nil || len(item.Devices) != 0 {
		t.Fatalf("missing table must yield empty device list: %#v", item.Devices)
	}

	// 其他数据库错误必须继续上抛，不能被 1146 分支吞掉。
	mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1054, Message: "Unknown column"})
	if err := service.fillDevices(context.Background(), &item, 42); err == nil {
		t.Fatalf("non-1146 errors must propagate")
	}
}

func TestFillDevicesScansRowsInLastSeenOrder(t *testing.T) {
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock failed: %v", err)
	}
	defer db.Close()
	service := &Service{userDB: db}

	rows := sqlmock.NewRows([]string{"device_id", "platform", "device_model", "manufacturer", "os_version", "imei", "app_version", "build_number", "first_seen_at_ms", "last_seen_at_ms"}).
		AddRow("android_abc", "android", "Redmi Note 8 Pro", "Xiaomi", "13", "", "1.4.2", "142", int64(1000), int64(9000)).
		AddRow("ios_def", "ios", "iPhone17,2", "Apple", "18.1", "", "1.4.0", "140", int64(500), int64(8000))
	mock.ExpectQuery(`FROM user_devices`).WithArgs("lalu", int64(42)).WillReturnRows(rows)

	item := AppUser{}
	if err := service.fillDevices(context.Background(), &item, 42); err != nil {
		t.Fatalf("fillDevices failed: %v", err)
	}
	if len(item.Devices) != 2 || item.Devices[0].DeviceID != "android_abc" || item.Devices[1].Manufacturer != "Apple" {
		t.Fatalf("device rows mismatch: %#v", item.Devices)
	}
	if item.Devices[0].LastSeenAtMs != 9000 || item.Devices[0].FirstSeenAtMs != 1000 {
		t.Fatalf("device timestamps mismatch: %#v", item.Devices[0])
	}
}

type fakeTokenIssuerClient struct {
	response *userv1.AdminIssueUserAccessTokenResponse
	err      error
	lastReq  *userv1.AdminIssueUserAccessTokenRequest
}

func (f *fakeTokenIssuerClient) AdminIssueUserAccessToken(_ context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
	f.lastReq = req
	if f.err != nil {
		return nil, f.err
	}
	return f.response, nil
}

func TestIssueAccessTokenMapsResponseAndAppCode(t *testing.T) {
	fake := &fakeTokenIssuerClient{response: &userv1.AdminIssueUserAccessTokenResponse{
		AccessToken:              "jwt-token",
		TokenType:                "Bearer",
		ExpiresInSec:             3600,
		SessionId:                "sess_1",
		DeviceId:                 "android_abc",
		SessionLastHeartbeatAtMs: 123456,
	}}
	service := &Service{tokenIssuerClient: fake}

	result, err := service.IssueAccessToken(context.Background(), 42)
	if err != nil {
		t.Fatalf("IssueAccessToken failed: %v", err)
	}
	if result.AccessToken != "jwt-token" || result.TokenType != "Bearer" || result.ExpiresInSec != 3600 ||
		result.SessionID != "sess_1" || result.DeviceID != "android_abc" || result.SessionLastHeartbeatAtMs != 123456 {
		t.Fatalf("token result mismatch: %+v", result)
	}
	// app_code 必须同时写入显式字段和 meta，user-service 端优先消费显式字段。
	if fake.lastReq.GetUserId() != 42 || fake.lastReq.GetAppCode() != "lalu" || fake.lastReq.GetMeta().GetAppCode() != "lalu" {
		t.Fatalf("request app scope mismatch: %+v", fake.lastReq)
	}
}

func TestIssueAccessTokenPropagatesNotFound(t *testing.T) {
	// 无活跃会话时 user-service 返回 NotFound；service 层原样透传，由 handler 映射成 404 中文文案。
	fake := &fakeTokenIssuerClient{err: status.Error(codes.NotFound, "active session not found")}
	service := &Service{tokenIssuerClient: fake}

	_, err := service.IssueAccessToken(context.Background(), 42)
	if grpcStatus, ok := status.FromError(err); !ok || grpcStatus.Code() != codes.NotFound {
		t.Fatalf("expected NotFound passthrough, got %v", err)
	}

	var missing *Service = &Service{}
	if _, err := missing.IssueAccessToken(context.Background(), 42); err == nil || errors.Is(err, nil) {
		t.Fatalf("nil token client must fail closed")
	}
}
