package service_test

import (
	"context"
	"testing"
	"time"

	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/integration"
	roomservice "hyapp/services/room-service/internal/room/service"
	"hyapp/services/room-service/internal/router"
	"hyapp/services/room-service/internal/testutil/mysqltest"
)

func newHeartbeatRecoveryService(t *testing.T, repository *mysqltest.Repository, nodeID string, clk *fixedRoomRocketClock) *roomservice.Service {
	t.Helper()
	cfg := roomservice.Config{
		NodeID:         nodeID,
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
	}
	if clk != nil {
		cfg.Clock = clk
	}
	return roomservice.New(cfg, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}

func createHeartbeatRecoveryRoom(t *testing.T, svc *roomservice.Service, roomID string, ownerID int64, shortID string) {
	t.Helper()
	if _, err := svc.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Heartbeat Recovery",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     shortID,
	}); err != nil {
		t.Fatalf("create heartbeat recovery room fixture failed: %v", err)
	}
}

func TestRoomHeartbeatRecoversClearedPresence(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := newHeartbeatRecoveryService(t, repository, "node-hb-recover-test", nil)

	roomID := "room-hb-recover"
	ownerID := int64(7501)
	viewerID := int64(7601)
	createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-recover")
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join viewer failed: %v", err)
	}
	// LeaveRoom 与 stale sweep 走同一条 presence 删除路径，等价模拟"服务端单方面清掉 presence"。
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	}); err != nil {
		t.Fatalf("leave viewer failed: %v", err)
	}

	outboxBefore := repository.CountRowsByRoom("room_outbox", roomID)
	heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	})
	if err != nil {
		t.Fatalf("heartbeat must recover cleared presence: %v", err)
	}
	recovered := findRoomUser(heartbeatResp.GetRoom(), viewerID)
	if recovered == nil || recovered.GetRole() != "audience" {
		t.Fatalf("recovered presence must be back online as audience: %+v", heartbeatResp.GetRoom())
	}
	if outboxAfter := repository.CountRowsByRoom("room_outbox", roomID); outboxAfter != outboxBefore {
		// 恢复必须静默：不发 RoomUserJoined 等 durable outbox 事件，避免重复入房横幅与统计重复计数。
		t.Fatalf("silent recovery must not emit outbox events: before=%d after=%d", outboxBefore, outboxAfter)
	}

	if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
		Meta:   roomservice.NewRequestMeta(roomID, viewerID),
		SeatNo: 1,
	}); err != nil {
		t.Fatalf("mic up must succeed right after heartbeat recovery: %v", err)
	}
}

func TestRoomHeartbeatKeepsRejectingKickedUser(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-guard-test", nil)

	roomID := "room-hb-kick-guard"
	ownerID := int64(7701)
	viewerID := int64(7801)
	createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-guard")
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join viewer failed: %v", err)
	}
	if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: viewerID,
	}); err != nil {
		t.Fatalf("kick viewer failed: %v", err)
	}

	_, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	})
	if err == nil || xerr.CodeOf(err) != xerr.PermissionDenied {
		t.Fatalf("kicked user heartbeat must stay PermissionDenied, got: %v", err)
	}

	presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
		AppCode: appcode.Default,
		RoomId:  roomID,
		UserId:  viewerID,
	})
	if err != nil {
		t.Fatalf("verify presence after rejected heartbeat failed: %v", err)
	}
	if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
		t.Fatalf("kicked user must not regain presence through heartbeat: %+v", presenceResp)
	}
}

func TestRoomHeartbeatRecoveryAfterKickExpires(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	clk := &fixedRoomRocketClock{now: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)}
	svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-expire-test", clk)

	roomID := "room-hb-kick-expire"
	ownerID := int64(7901)
	viewerID := int64(8001)
	createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-expire")
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join viewer failed: %v", err)
	}
	if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: viewerID,
		DurationMs:   1000,
	}); err != nil {
		t.Fatalf("duration kick failed: %v", err)
	}
	if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	}); err == nil || xerr.CodeOf(err) != xerr.PermissionDenied {
		t.Fatalf("heartbeat inside ban window must stay PermissionDenied, got: %v", err)
	}

	clk.now = clk.now.Add(2 * time.Second)
	heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	})
	if err != nil {
		t.Fatalf("heartbeat after ban expiry must recover presence: %v", err)
	}
	if findRoomUser(heartbeatResp.GetRoom(), viewerID) == nil {
		t.Fatalf("expired ban user must be back online: %+v", heartbeatResp.GetRoom())
	}
}

func TestRoomHeartbeatDoesNotBypassRoomPassword(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := newHeartbeatRecoveryService(t, repository, "node-hb-password-test", nil)

	roomID := "room-hb-password"
	ownerID := int64(8101)
	viewerID := int64(8201)
	createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-password")
	if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{
		Meta:     roomservice.NewRequestMeta(roomID, ownerID),
		Locked:   true,
		Password: "4321",
	}); err != nil {
		t.Fatalf("set room password failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta:     roomservice.NewRequestMeta(roomID, viewerID),
		Role:     "audience",
		Password: "4321",
	}); err != nil {
		t.Fatalf("join locked room with password failed: %v", err)
	}
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	}); err != nil {
		t.Fatalf("leave viewer failed: %v", err)
	}

	// 心跳不携带密码，非房主不得借心跳绕过锁房，必须维持原 NotFound 契约走完整 JoinRoom。
	if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
	}); err == nil || xerr.CodeOf(err) != xerr.NotFound {
		t.Fatalf("locked room heartbeat recovery must stay NotFound for non-owner, got: %v", err)
	}

	// 房主不受锁房密码约束（与 JoinRoom 一致），恢复后角色收敛为 owner。
	if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, ownerID),
	}); err != nil {
		t.Fatalf("leave owner failed: %v", err)
	}
	heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
		Meta: roomservice.NewRequestMeta(roomID, ownerID),
	})
	if err != nil {
		t.Fatalf("owner heartbeat recovery in locked room failed: %v", err)
	}
	owner := findRoomUser(heartbeatResp.GetRoom(), ownerID)
	if owner == nil || owner.GetRole() != "owner" {
		t.Fatalf("owner recovery must keep owner role: %+v", heartbeatResp.GetRoom())
	}
}
