package service_test

import (
	"context"
	"fmt"
	"testing"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/integration"
	roomoutbox "hyapp/services/room-service/internal/room/outbox"
	roomservice "hyapp/services/room-service/internal/room/service"
	"hyapp/services/room-service/internal/router"
	"hyapp/services/room-service/internal/testutil/mysqltest"
)

type fakeRTCUserRemover struct {
	calls  []rtcRemoveCall
	roomID string
	userID int64
}

type rtcRemoveCall struct {
	roomID string
	userID int64
}

func (r *fakeRTCUserRemover) RemoveUserByStrRoomID(_ context.Context, roomID string, userID int64) error {
	r.roomID = roomID
	r.userID = userID
	r.calls = append(r.calls, rtcRemoveCall{roomID: roomID, userID: userID})
	return nil
}

func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	rtc := &fakeRTCUserRemover{}
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-kick-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
		RTCUserRemover: rtc,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-kick-flow"
	ownerID := int64(7101)
	viewerID := int64(7201)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Kick Flow",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "kick-flow",
	}); err != nil {
		t.Fatalf("create kick room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join kick target failed: %v", err)
	}

	kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: viewerID,
	})
	if err != nil {
		t.Fatalf("kick user failed: %v", err)
	}
	if !kickResp.GetRtcKicked() || kickResp.GetRtcKickError() != "" || rtc.roomID != roomID || rtc.userID != viewerID {
		t.Fatalf("kick must remove RTC user after Room Cell commit: resp=%+v rtc=%+v", kickResp, rtc)
	}
	if !containsInt64(kickResp.GetRoom().GetBanUserIds(), viewerID) || findRoomUser(kickResp.GetRoom(), viewerID) != nil {
		t.Fatalf("kick response must ban and remove target presence: %+v", kickResp.GetRoom())
	}

	presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
		AppCode: appcode.Default,
		RoomId:  roomID,
		UserId:  viewerID,
	})
	if err != nil {
		t.Fatalf("verify room presence after kick failed: %v", err)
	}
	if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
		t.Fatalf("kicked user must not pass IM presence guard: %+v", presenceResp)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err == nil {
		t.Fatalf("kicked user must not join before unban")
	}
}

func TestKickUserDurationExpiresAndAllowsJoin(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC)}
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-kick-duration-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
		Clock:          now,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-kick-duration"
	ownerID := int64(7301)
	viewerID := int64(7401)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Kick Duration",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "kick-duration",
	}); err != nil {
		t.Fatalf("create duration room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join duration target failed: %v", err)
	}

	kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: viewerID,
		DurationMs:   1000,
	})
	if err != nil {
		t.Fatalf("duration kick failed: %v", err)
	}
	if len(kickResp.GetRoom().GetBanStates()) != 1 || kickResp.GetRoom().GetBanStates()[0].GetExpiresAtMs() != now.Now().Add(time.Second).UnixMilli() {
		t.Fatalf("duration kick must persist ban expiration: %+v", kickResp.GetRoom().GetBanStates())
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err == nil {
		t.Fatalf("target must not join before kick duration expires")
	}

	presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
		AppCode: appcode.Default,
		RoomId:  roomID,
		UserId:  viewerID,
	})
	if err != nil {
		t.Fatalf("verify presence before expiration failed: %v", err)
	}
	if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
		t.Fatalf("active duration ban must reject IM guard: %+v", presenceResp)
	}

	now.now = now.now.Add(2 * time.Second)
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("target must join after kick duration expires: %v", err)
	}
}

func TestListRoomBannedUsersReturnsActiveOnlyForManagers(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := &fixedRoomRocketClock{now: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-banned-users-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
		Clock:          now,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-banned-users"
	ownerID := int64(7501)
	expiredID := int64(7502)
	activeID := int64(7503)
	normalID := int64(7504)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Banned Users",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "banned-users",
	}); err != nil {
		t.Fatalf("create banned users room failed: %v", err)
	}
	for _, userID := range []int64{expiredID, activeID, normalID} {
		if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
			Meta: roomservice.NewRequestMeta(roomID, userID),
			Role: "audience",
		}); err != nil {
			t.Fatalf("join fixture user %d failed: %v", userID, err)
		}
	}

	if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: expiredID,
		DurationMs:   1000,
	}); err != nil {
		t.Fatalf("kick expired fixture user failed: %v", err)
	}
	now.now = now.now.Add(2 * time.Second)
	activeCreatedAt := now.Now().UnixMilli()
	activeUnbanAt := now.Now().Add(10 * time.Second).UnixMilli()
	if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
		Meta:         roomservice.NewRequestMeta(roomID, ownerID),
		TargetUserId: activeID,
		DurationMs:   10_000,
	}); err != nil {
		t.Fatalf("kick active fixture user failed: %v", err)
	}

	if _, err := svc.ListRoomBannedUsers(ctx, &roomv1.ListRoomBannedUsersRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: normalID,
		Page:         1,
		PageSize:     10,
	}); !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("non-manager list banned users must be denied: %v", err)
	}

	resp, err := svc.ListRoomBannedUsers(ctx, &roomv1.ListRoomBannedUsersRequest{
		Meta:         &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
		RoomId:       roomID,
		ViewerUserId: ownerID,
		Page:         1,
		PageSize:     10,
	})
	if err != nil {
		t.Fatalf("owner list banned users failed: %v", err)
	}
	if resp.GetTotal() != 1 || len(resp.GetItems()) != 1 {
		t.Fatalf("only active ban must be returned: %+v", resp)
	}
	item := resp.GetItems()[0]
	if item.GetUserId() != activeID || item.GetActorUserId() != ownerID || item.GetCreatedAtMs() != activeCreatedAt || item.GetUnbanAtMs() != activeUnbanAt || item.GetRemainingMs() != 10_000 || item.GetPermanent() {
		t.Fatalf("active ban item mismatch: %+v", item)
	}
}

func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	rtc := &fakeRTCUserRemover{}
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-close-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
		RTCUserRemover: rtc,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-close-flow"
	ownerID := int64(8101)
	viewerID := int64(8201)
	adminID := int64(1)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Close Flow",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "close-flow",
	}); err != nil {
		t.Fatalf("create close room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join close target failed: %v", err)
	}

	resp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId:   "req-close-admin",
			CommandId:   "cmd-close-admin",
			ActorUserId: adminID,
			RoomId:      roomID,
			AppCode:     appcode.Default,
			SentAtMs:    time.Now().UnixMilli(),
		},
		Reason: "admin_closed",
	})
	if err != nil {
		t.Fatalf("admin close room failed: %v", err)
	}
	if resp.GetRoom().GetStatus() != "closed" || len(resp.GetRoom().GetOnlineUsers()) != 0 {
		t.Fatalf("admin close response must close room and clear users: %+v", resp.GetRoom())
	}
	if !hasRTCRemoveCall(rtc.calls, roomID, ownerID) || !hasRTCRemoveCall(rtc.calls, roomID, viewerID) {
		t.Fatalf("admin close must remove all current RTC users: %+v", rtc.calls)
	}

	records, err := repository.ListPendingOutbox(ctx, 20)
	if err != nil {
		t.Fatalf("list pending outbox failed: %v", err)
	}
	if !hasOutboxEvent(records, "RoomClosed", 0) ||
		!hasOutboxEvent(records, "RoomUserKicked", ownerID) ||
		!hasOutboxEvent(records, "RoomUserKicked", viewerID) {
		t.Fatalf("admin close must write room closed and per-user kick events: %+v", outboxSummary(records))
	}

	reopenResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId:   "req-reopen-admin",
			CommandId:   "cmd-reopen-admin",
			ActorUserId: adminID,
			RoomId:      roomID,
			AppCode:     appcode.Default,
			SentAtMs:    time.Now().UnixMilli(),
		},
		Reason: "admin_reopen",
	})
	if err != nil {
		t.Fatalf("admin reopen room failed: %v", err)
	}
	if reopenResp.GetRoom().GetStatus() != "active" {
		t.Fatalf("admin reopen response must make room active: %+v", reopenResp.GetRoom())
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("viewer must join after admin reopen: %v", err)
	}
}

func TestAdminBanRoomPersistsAcrossRecoveryAndRequiresDedicatedUnban(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	rtc := &fakeRTCUserRemover{}
	svc := roomservice.New(roomservice.Config{
		NodeID: "node-ban-room-test", LeaseTTL: 10 * time.Second, RankLimit: 20,
		SnapshotEveryN: 100, RTCUserRemover: rtc,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-admin-banned"
	ownerID := int64(8501)
	viewerID := int64(8502)
	adminID := int64(8503)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice",
		VisibleRegionId: 8101, RoomName: "Admin Banned", RoomAvatar: testRoomCoverURL, RoomShortId: "admin-banned",
	}); err != nil {
		t.Fatalf("create banned room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience"}); err != nil {
		t.Fatalf("join banned room fixture failed: %v", err)
	}
	banResp, err := svc.AdminBanRoom(ctx, &roomv1.AdminBanRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId: "req-admin-ban", CommandId: "cmd-admin-ban", ActorUserId: adminID,
			RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
		},
		Reason: "policy violation", AdminId: uint64(adminID), AdminName: "external-manager",
	})
	if err != nil {
		t.Fatalf("admin ban room failed: %v", err)
	}
	if banResp.GetRoom().GetStatus() != "banned" || len(banResp.GetRoom().GetOnlineUsers()) != 0 {
		t.Fatalf("ban must persist banned state and clear presence: %+v", banResp.GetRoom())
	}
	if !hasRTCRemoveCall(rtc.calls, roomID, ownerID) || !hasRTCRemoveCall(rtc.calls, roomID, viewerID) {
		t.Fatalf("ban must remove every current RTC user: %+v", rtc.calls)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, ownerID), Role: "owner"}); !xerr.IsCode(err, xerr.RoomClosed) {
		t.Fatalf("owner cannot reactivate a banned room: %v", err)
	}
	closed := "closed"
	if _, err := svc.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId: "req-generic-unban", CommandId: "cmd-generic-unban", ActorUserId: adminID,
			RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
		},
		Status: &closed, CloseReason: "bypass attempt", AdminId: uint64(adminID),
	}); !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("generic room update must not bypass dedicated unban: %v", err)
	}

	// A fresh Service must replay admin_ban_room from the durable command log before
	// accepting the dedicated unban; this covers restart recovery, not just memory state.
	recovered := roomservice.New(roomservice.Config{
		NodeID: "node-ban-room-recovery-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
	if _, err := recovered.AdminUnbanRoom(ctx, &roomv1.AdminUnbanRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId: "req-admin-unban-empty", CommandId: "cmd-admin-unban-empty", ActorUserId: adminID,
			RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
		},
		AdminId: uint64(adminID), AdminName: "external-manager",
	}); !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("dedicated unban must require an audit reason: %v", err)
	}
	unbanResp, err := recovered.AdminUnbanRoom(ctx, &roomv1.AdminUnbanRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId: "req-admin-unban", CommandId: "cmd-admin-unban", ActorUserId: adminID,
			RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
		},
		Reason: "appeal approved", AdminId: uint64(adminID), AdminName: "external-manager",
	})
	if err != nil {
		t.Fatalf("dedicated unban after recovery failed: %v", err)
	}
	if unbanResp.GetRoom().GetStatus() != "closed" {
		t.Fatalf("unban must return room to closed, not active: %+v", unbanResp.GetRoom())
	}
	ownerJoin, err := recovered.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, ownerID), Role: "owner"})
	if err != nil || ownerJoin.GetRoom().GetStatus() != "active" {
		t.Fatalf("owner should reactivate only after manual unban: room=%+v err=%v", ownerJoin.GetRoom(), err)
	}
	records, err := repository.ListPendingOutbox(ctx, 100)
	if err != nil || !hasOutboxEvent(records, "RoomBanned", 0) || !hasOutboxEvent(records, "RoomUnbanned", 0) {
		t.Fatalf("ban lifecycle must publish durable facts: records=%+v err=%v", outboxSummary(records), err)
	}
}

func TestAdminDeleteRoomHardDeletesRoomStateAndAllowsRecreate(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-admin-delete-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-admin-delete-flow"
	ownerID := int64(8301)
	viewerID := int64(8302)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Delete Flow",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "delete-flow",
	}); err != nil {
		t.Fatalf("create delete room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("join delete fixture viewer failed: %v", err)
	}

	resp, err := svc.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId:   "req-admin-delete-room",
			CommandId:   "cmd-admin-delete-room",
			ActorUserId: 1,
			RoomId:      roomID,
			AppCode:     appcode.Default,
			SentAtMs:    time.Now().UnixMilli(),
		},
		AdminId:   1,
		AdminName: "admin",
	})
	if err != nil {
		t.Fatalf("admin delete room failed: %v", err)
	}
	if !resp.GetResult().GetApplied() || resp.GetRoom().GetStatus() != "deleted" {
		t.Fatalf("delete response must report one applied delete mutation: %+v", resp)
	}
	if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists {
		t.Fatalf("rooms metadata must be physically deleted, exists=%v err=%v", exists, err)
	}
	for _, table := range []string{"room_list_entries", "room_snapshots", "room_command_log", "room_user_presence", "room_user_feed_entries"} {
		if count := repository.CountRowsByRoom(table, roomID); count != 0 {
			t.Fatalf("%s rows must be deleted, count=%d", table, count)
		}
	}
	listResp, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
		Meta:        &roomv1.RequestMeta{AppCode: appcode.Default},
		OwnerUserId: ownerID,
		Page:        1,
		PageSize:    20,
	})
	if err != nil {
		t.Fatalf("list rooms after delete failed: %v", err)
	}
	if listResp.GetTotal() != 0 || len(listResp.GetRooms()) != 0 {
		t.Fatalf("deleted room must disappear from admin list: %+v", listResp)
	}

	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Recreated Flow",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "delete-flow",
	}); err != nil {
		t.Fatalf("owner must be able to recreate room after hard delete: %v", err)
	}
}

func TestClosedRoomOwnerJoinReactivatesButAudienceCannot(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	svc := roomservice.New(roomservice.Config{
		NodeID:         "node-owner-reactivate-test",
		LeaseTTL:       10 * time.Second,
		RankLimit:      20,
		SnapshotEveryN: 1,
	}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())

	roomID := "room-owner-reactivate"
	ownerID := int64(8401)
	viewerID := int64(8402)
	adminID := int64(1)
	if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
		Meta:            roomservice.NewRequestMeta(roomID, ownerID),
		SeatCount:       10,
		Mode:            "voice",
		VisibleRegionId: 8101,
		RoomName:        "Reactivate Flow",
		RoomAvatar:      testRoomCoverURL,
		RoomShortId:     "reactivate-flow",
	}); err != nil {
		t.Fatalf("create reactivation room fixture failed: %v", err)
	}
	if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
		Meta: &roomv1.RequestMeta{
			RequestId:   "req-close-reactivation-room",
			CommandId:   "cmd-close-reactivation-room",
			ActorUserId: adminID,
			RoomId:      roomID,
			AppCode:     appcode.Default,
			SentAtMs:    time.Now().UnixMilli(),
		},
		Reason: "admin_closed",
	}); err != nil {
		t.Fatalf("close room fixture failed: %v", err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); !xerr.IsCode(err, xerr.RoomClosed) {
		t.Fatalf("audience join closed room must be rejected as ROOM_CLOSED: %v", err)
	}

	ownerJoin, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, ownerID),
		Role: "audience",
	})
	if err != nil {
		t.Fatalf("owner join closed room must reactivate it: %v", err)
	}
	if ownerJoin.GetRoom().GetStatus() != "active" {
		t.Fatalf("owner join must return active room: %+v", ownerJoin.GetRoom())
	}
	if user := findRoomUser(ownerJoin.GetRoom(), ownerID); user == nil || user.GetRole() != "owner" {
		t.Fatalf("reactivated owner must be in room with owner role: %+v", ownerJoin.GetRoom().GetOnlineUsers())
	}
	meta, exists, err := repository.GetRoomMeta(ctx, roomID)
	if err != nil || !exists || meta.Status != "active" {
		t.Fatalf("owner reactivation must persist active status, exists=%v meta=%+v err=%v", exists, meta, err)
	}
	if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
		Meta: roomservice.NewRequestMeta(roomID, viewerID),
		Role: "audience",
	}); err != nil {
		t.Fatalf("audience must join after owner reactivates room: %v", err)
	}
}

func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
	for _, user := range snapshot.GetOnlineUsers() {
		if user.GetUserId() == userID {
			return user
		}
	}
	return nil
}

func containsInt64(values []int64, target int64) bool {
	for _, value := range values {
		if value == target {
			return true
		}
	}
	return false
}

func hasRTCRemoveCall(calls []rtcRemoveCall, roomID string, userID int64) bool {
	for _, call := range calls {
		if call.roomID == roomID && call.userID == userID {
			return true
		}
	}
	return false
}

func hasOutboxEvent(records []roomoutbox.Record, eventType string, targetUserID int64) bool {
	for _, record := range records {
		if record.EventType != eventType {
			continue
		}
		if targetUserID == 0 {
			return true
		}
		var body roomeventsv1.RoomUserKicked
		if record.Envelope == nil || proto.Unmarshal(record.Envelope.GetBody(), &body) != nil {
			continue
		}
		if body.GetTargetUserId() == targetUserID {
			return true
		}
	}
	return false
}

func outboxSummary(records []roomoutbox.Record) []string {
	summary := make([]string, 0, len(records))
	for _, record := range records {
		summary = append(summary, fmt.Sprintf("%s:%s", record.EventType, record.EventID))
	}
	return summary
}
