import {
  expect,
  type APIRequestContext,
  type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
  dumpRtcDiagnostics,
  getConnectedPeerCount,
  installWebRTCTracking,
  waitForAllPeerAudioFlow,
  waitForAudioStatsPresent,
  waitForConnectedPeerCount,
  waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import {
  authHeaders,
  readAuthTokenFromPage,
  registerTestUser
} from '../../helpers/auth-api';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';

// ── Signal endpoint identifiers ──────────────────────────────────────
const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a';
const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b';
// ── Room / channel names ─────────────────────────────────────────────
const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`;
const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`;
const VOICE_CHANNEL = 'General';
// ── User constants ───────────────────────────────────────────────────
const USER_PASSWORD = 'TestPass123!';
const USER_COUNT = 8;
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
const STABILITY_WINDOW_MS = 20_000;

// ── User signal configuration groups ─────────────────────────────────
//
// Group A (users 0-1): Both signal servers in network config (normal)
// Group B (users 2-3): Only primary signal - secondary NOT in config.
//                       They join the secondary room via invite link,
//                       which auto-adds the endpoint.
// Group C (users 4-5): Both signals initially, but secondary is removed
//                       after registration. They still see the room from
//                       search because the primary signal can discover it
//                       via findServerAcrossActiveEndpoints fallback.
// Group D (users 6-7): Only secondary signal in config. They join the
//                       primary room via invite link.

type SignalGroup = 'both' | 'primary-only' | 'both-then-remove-secondary' | 'secondary-only';

interface TestUser {
  username: string;
  displayName: string;
  password: string;
  group: SignalGroup;
}

type TestClient = Client & { user: TestUser };

function endpointsForGroup(
  group: SignalGroup,
  primaryUrl: string,
  secondaryUrl: string
): SeededEndpointInput[] {
  switch (group) {
    case 'both':
      return [
        {
          id: PRIMARY_SIGNAL_ID,
          name: 'E2E Signal A',
          url: primaryUrl,
          isActive: true,
          status: 'online'
        },
        {
          id: SECONDARY_SIGNAL_ID,
          name: 'E2E Signal B',
          url: secondaryUrl,
          isActive: true,
          status: 'online'
        }
      ];
    case 'primary-only':
      return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }];
    case 'both-then-remove-secondary':
      // Seed both initially; test will remove secondary after registration.
      return [
        {
          id: PRIMARY_SIGNAL_ID,
          name: 'E2E Signal A',
          url: primaryUrl,
          isActive: true,
          status: 'online'
        },
        {
          id: SECONDARY_SIGNAL_ID,
          name: 'E2E Signal B',
          url: secondaryUrl,
          isActive: true,
          status: 'online'
        }
      ];
    case 'secondary-only':
      return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }];
  }
}

test.describe('Mixed signal-config voice', () => {
  test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
    createClient,
    request,
    testServer
  }) => {
    test.setTimeout(720_000);

    const secondaryServer = await startTestServer();

    try {
      const users = buildUsers();
      const clients: TestClient[] = [];

      // ── Create clients with per-group endpoint configs ───────────
      for (const user of users) {
        const client = await createClient();
        const groupEndpoints = endpointsForGroup(user.group, testServer.url, secondaryServer.url);

        await installTestServerEndpoints(client.context, groupEndpoints);
        await installDeterministicVoiceSettings(client.page);
        await installWebRTCTracking(client.page);

        clients.push({ ...client, user });
      }

      // ── Register ─────────────────────────────────────────────────
      await test.step('Register each user on their configured signal endpoint', async () => {
        for (const client of clients) {
          const registerPage = new RegisterPage(client.page);
          const registrationEndpointId =
            client.user.group === 'secondary-only' ? SECONDARY_SIGNAL_ID : PRIMARY_SIGNAL_ID;

          await registerPage.goto();
          await registerPage.serverSelect.selectOption(registrationEndpointId);
          await registerPage.register(client.user.username, client.user.displayName, client.user.password);
          await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
        }
      });

      let secondaryRoomId = '';

      // ── Create rooms ────────────────────────────────────────────
      await test.step('Create voice room on primary and chat room on secondary', async () => {
        // Use a "both" user (client 0) to create both rooms
        const searchPage = new ServerSearchPage(clients[0].page);
        const secondarySession = await registerTestUser(
          request,
          secondaryServer.url,
          clients[0].user.username,
          clients[0].user.password,
          clients[0].user.displayName
        );

        await clients[0].page.evaluate(({ serverUrl, token, expiresAt }) => {
          const storageKey = 'metoyou.authTokens';
          const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
          const normalizedUrl = serverUrl.trim().replace(/\/+$/, '');

          store[normalizedUrl] = { token, expiresAt };
          localStorage.setItem(storageKey, JSON.stringify(store));
        }, {
          serverUrl: secondaryServer.url,
          token: secondarySession.token,
          expiresAt: secondarySession.expiresAt
        });

        await searchPage.createServer(VOICE_ROOM_NAME, {
          description: 'Voice room on primary signal',
          sourceId: PRIMARY_SIGNAL_ID
        });

        await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });

        const secondaryRoom = await createServerViaApi(
          request,
          secondaryServer.url,
          secondarySession,
          SECONDARY_ROOM_NAME
        );

        secondaryRoomId = secondaryRoom.id;
      });

      // ── Create invite links ─────────────────────────────────────
      //
      // Group B (primary-only) needs invite to secondary room.
      // Group D (secondary-only) needs invite to primary room.
      let primaryRoomInviteUrl: string;
      let secondaryRoomInviteUrl: string;

      await test.step('Create invite links for cross-signal rooms', async () => {
        // Navigate to voice room to get its ID
        await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
        const primaryRoomId = await getCurrentRoomId(clients[0].page);
        // Create invite for primary room (voice) via API
        const primaryToken = await readAuthTokenFromPage(clients[0].page, testServer.url);

        if (!primaryToken) {
          throw new Error('Missing session token for primary signal invite creation');
        }

        const primaryInvite = await createInviteViaApi(
          testServer.url,
          primaryRoomId,
          primaryToken,
          clients[0].user.displayName
        );

        primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;

        // Create invite for secondary room (chat) via API
        const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);

        if (!secondaryToken) {
          throw new Error('Missing session token for secondary signal invite creation');
        }

        const secondaryInvite = await createInviteViaApi(
          secondaryServer.url,
          secondaryRoomId,
          secondaryToken,
          clients[0].user.displayName
        );

        secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`;
      });

      // ── Remove secondary endpoint for group C ───────────────────
      await test.step('Remove secondary signal from group C users', async () => {
        for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) {
          await client.page.evaluate((primaryEndpoint) => {
            localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint]));
          }, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' });
        }
      });

      // ── Join rooms ──────────────────────────────────────────────
      await test.step('All users join the voice room (some via search, some via invite)', async () => {
        for (const client of clients.slice(1)) {
          if (client.user.group === 'secondary-only') {
            // Group D: no primary signal -> join voice room via invite
            await client.page.goto(primaryRoomInviteUrl);
            await waitForInviteJoin(client.page);
          } else {
            // Groups A, B, C: have primary signal -> join via search
            await joinRoomFromSearch(client.page, VOICE_ROOM_NAME);
          }
        }

        // Navigate client 0 back to voice room
        await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
      });

      await test.step('All users also join the secondary chat room', async () => {
        for (const client of clients.slice(1)) {
          if (client.user.group === 'primary-only') {
            // Group B: no secondary signal -> join chat room via invite
            await client.page.goto(secondaryRoomInviteUrl);
            await waitForInviteJoin(client.page);
          } else if (client.user.group === 'secondary-only') {
            // Group D: has secondary -> join via search
            await openSearchView(client.page);
            await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
          } else {
            // Groups A, C: can search
            await openSearchView(client.page);
            await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME);
          }
        }

        // Ensure everyone navigates back to voice room
        for (const client of clients) {
          await openSavedRoomByName(client.page, VOICE_ROOM_NAME);
        }
      });

      // ── Voice channel ───────────────────────────────────────────
      await test.step('Create voice channel and join all 8 users', async () => {
        const hostRoom = new ChatRoomPage(clients[0].page);

        await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);

        for (const client of clients) {
          await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
        }

        for (const client of clients) {
          await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
        }
      });

      // ── Audio mesh ──────────────────────────────────────────────
      await test.step('All users discover peers and audio flows pairwise', async () => {
        await Promise.all(clients.map((client) =>
          waitForPeerConnected(client.page, 45_000)
        ));

        await Promise.all(clients.map((client) =>
          waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
        ));

        await Promise.all(clients.map((client) =>
          waitForAudioStatsPresent(client.page, 30_000)
        ));

        await clients[0].page.waitForTimeout(5_000);

        await Promise.all(clients.map((client) =>
          waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
        ));
      });

      // ── Voice workspace roster ──────────────────────────────────
      await test.step('Voice workspace shows all 8 users on every client', async () => {
        for (const client of clients) {
          const room = new ChatRoomPage(client.page);

          await openVoiceWorkspace(client.page);
          await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
          await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
          await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
        }
      });

      // ── Stability + concurrent chat ─────────────────────────────
      await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => {
        // Pick 2 users from different groups to navigate away and chat
        const chatters = [clients[2], clients[6]]; // group C + group D
        const stayers = clients.filter((clientItem) => !chatters.includes(clientItem));

        // Chatters navigate to secondary room and send messages
        for (const chatter of chatters) {
          await openSavedRoomByName(chatter.page, SECONDARY_ROOM_NAME);
          await expect(chatter.page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 10_000 });
        }

        const chatPage0 = new ChatMessagesPage(chatters[0].page);
        const chatPage1 = new ChatMessagesPage(chatters[1].page);

        await chatPage0.sendMessage(`Hello from ${chatters[0].user.displayName} while in voice!`);
        await chatPage1.sendMessage(`Reply from ${chatters[1].user.displayName} also in voice!`);

        // Verify messages arrive
        await expect(
          chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`)
        ).toBeVisible({ timeout: 15_000 });

        await expect(
          chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`)
        ).toBeVisible({ timeout: 15_000 });

        // Meanwhile stability loop on all clients (including chatters - voice still active)
        const deadline = Date.now() + STABILITY_WINDOW_MS;

        while (Date.now() < deadline) {
          for (const client of stayers) {
            await expect.poll(async () => await getConnectedPeerCount(client.page), {
              timeout: 10_000,
              intervals: [500, 1_000]
            }).toBe(EXPECTED_REMOTE_PEERS);
          }

          // Check chatters still have voice peers even while viewing another room
          for (const chatter of chatters) {
            await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
              timeout: 10_000,
              intervals: [500, 1_000]
            }).toBe(EXPECTED_REMOTE_PEERS);
          }

          if (Date.now() < deadline) {
            await clients[0].page.waitForTimeout(5_000);
          }
        }

        // Navigate chatters back to voice room
        for (const chatter of chatters) {
          await openSavedRoomByName(chatter.page, VOICE_ROOM_NAME);
        }

        // Verify audio still flowing after stability window
        for (const client of clients) {
          try {
            await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
          } catch (error) {
            console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
            throw error;
          }
        }
      });

      // ── Mute ────────────────────────────────────────────────────
      await test.step('Mute state propagates for every user across all clients', async () => {
        for (const client of clients) {
          const room = new ChatRoomPage(client.page);

          await room.muteButton.click();
          await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
            isMuted: true,
            isDeafened: false
          });

          await room.muteButton.click();
          await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
            isMuted: false,
            isDeafened: false
          });
        }
      });

      await test.step('Audio still flows on all peers after mute cycling', async () => {
        for (const client of clients) {
          try {
            await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
          } catch (error) {
            console.log(`[${client.user.displayName} post-mute RTC]\n${await dumpRtcDiagnostics(client.page)}`);
            throw error;
          }
        }
      });

      // ── Deafen ──────────────────────────────────────────────────
      await test.step('Deafen state propagates for every user across all clients', async () => {
        for (const client of clients) {
          const room = new ChatRoomPage(client.page);

          await room.deafenButton.click();
          await client.page.waitForTimeout(500);
          await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
            isMuted: true,
            isDeafened: true
          });

          await room.deafenButton.click();
          await client.page.waitForTimeout(500);
          // Un-deafen does NOT restore mute - user stays muted
          await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
            isMuted: true,
            isDeafened: false
          });
        }
      });

      await test.step('Unmute all users and verify audio flows end-to-end', async () => {
        for (const client of clients) {
          const room = new ChatRoomPage(client.page);

          await room.muteButton.click();
          await waitForVoiceStateAcrossPages(clients, client.user.displayName, {
            isMuted: false,
            isDeafened: false
          });
        }

        for (const client of clients) {
          try {
            await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 30_000);
          } catch (error) {
            console.log(`[${client.user.displayName} final RTC]\n${await dumpRtcDiagnostics(client.page)}`);
            throw error;
          }
        }
      });
    } finally {
      await secondaryServer.stop();
    }
  });
});

// ── User builders ────────────────────────────────────────────────────

function buildUsers(): TestUser[] {
  const groups: SignalGroup[] = [
    'both',
    'both', // 0-1
    'primary-only',
    'primary-only', // 2-3
    'both-then-remove-secondary',
    'both-then-remove-secondary', // 4-5
    'secondary-only',
    'secondary-only' // 6-7
  ];

  return groups.map((group, index) => ({
    username: `mixed_sig_${Date.now()}_${index + 1}`,
    displayName: `Mixed User ${index + 1}`,
    password: USER_PASSWORD,
    group
  }));
}

// ── API helpers ──────────────────────────────────────────────────────

async function createServerViaApi(
  request: APIRequestContext,
  serverBaseUrl: string,
  owner: { id: string; token: string },
  serverName: string
): Promise<{ id: string }> {
  const response = await request.post(`${serverBaseUrl}/api/servers`, {
    headers: authHeaders(owner.token),
    data: {
      channels: [
        {
          id: 'general-text',
          name: 'general',
          position: 0,
          type: 'text'
        }
      ],
      description: `E2E room on ${serverBaseUrl}`,
      id: `mixed-signal-${Date.now()}-${Math.random()
        .toString(36)
        .slice(2, 8)}`,
      isPrivate: false,
      name: serverName,
      ownerId: owner.id,
      ownerPublicKey: 'mixed-signal-owner-public-key',
      tags: ['e2e']
    }
  });

  if (!response.ok()) {
    throw new Error(`Failed to create server via API: ${response.status()} ${await response.text()}`);
  }

  return await response.json() as { id: string };
}

async function createInviteViaApi(
  serverBaseUrl: string,
  roomId: string,
  authToken: string,
  displayName: string
): Promise<{ id: string }> {
  const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${authToken}`
    },
    body: JSON.stringify({
      requesterDisplayName: displayName
    })
  });

  if (!response.ok) {
    throw new Error(`Failed to create invite: ${response.status} ${await response.text()}`);
  }

  return await response.json() as { id: string };
}

async function getCurrentRoomId(page: Page): Promise<string> {
  return await page.evaluate(() => {
    interface RoomShape { id: string }
    interface AngularDebugApi {
      getComponent: (element: Element) => Record<string, unknown>;
    }

    const host = document.querySelector('app-rooms-side-panel');
    const debugApi = (window as { ng?: AngularDebugApi }).ng;

    if (!host || !debugApi?.getComponent) {
      throw new Error('Angular debug API unavailable');
    }

    const component = debugApi.getComponent(host);
    const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.();

    if (!currentRoom?.id) {
      throw new Error('No current room');
    }

    return currentRoom.id;
  });
}

// ── Navigation helpers ───────────────────────────────────────────────

async function installDeterministicVoiceSettings(page: Page): Promise<void> {
  await page.addInitScript(() => {
    localStorage.setItem('metoyou_voice_settings', JSON.stringify({
      inputVolume: 100,
      outputVolume: 100,
      audioBitrate: 96,
      latencyProfile: 'balanced',
      includeSystemAudio: false,
      noiseReduction: false,
      screenShareQuality: 'balanced',
      askScreenShareQuality: false
    }));
  });
}

async function openSearchView(page: Page): Promise<void> {
  await page.goto('/servers', { waitUntil: 'domcontentloaded' });
  await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 20_000 });
}

async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
  await page.goto('/servers', { waitUntil: 'domcontentloaded' });
  const searchInput = page.getByPlaceholder('Search servers...');

  await expect(searchInput).toBeVisible({ timeout: 20_000 });
  await searchInput.fill(roomName);

  const roomCard = page.locator('div[title]', { hasText: roomName }).first();

  await expect(roomCard).toBeVisible({ timeout: 20_000 });
  await roomCard.dblclick();
  await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
  await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
  await waitForCurrentRoomName(page, roomName);
}

async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
  const roomButton = page.locator(`button[title="${roomName}"]`);

  await expect(roomButton).toBeVisible({ timeout: 20_000 });
  await roomButton.click();
  await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
  await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
  await waitForCurrentRoomName(page, roomName);
}

async function waitForInviteJoin(page: Page): Promise<void> {
  // Invite page loads -> auto-joins -> redirects to room
  await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
  await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}

async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
  await page.waitForFunction(
    (expectedRoomName) => {
      interface RoomShape { name?: string }
      interface AngularDebugApi {
        getComponent: (element: Element) => Record<string, unknown>;
      }

      const host = document.querySelector('app-rooms-side-panel');
      const debugApi = (window as { ng?: AngularDebugApi }).ng;

      if (!host || !debugApi?.getComponent) {
        return false;
      }

      const component = debugApi.getComponent(host);
      const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;

      return currentRoom?.name === expectedRoomName;
    },
    roomName,
    { timeout }
  );
}

async function openVoiceWorkspace(page: Page): Promise<void> {
  if (await page.locator('app-voice-workspace').isVisible()
    .catch(() => false)) {
    return;
  }

  const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i })
    .first();

  await expect(viewButton).toBeVisible({ timeout: 10_000 });
  await viewButton.click();
}

// ── Voice helpers ────────────────────────────────────────────────────

async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise<void> {
  const room = new ChatRoomPage(page);

  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt++) {
    await room.joinVoiceChannel(channelName);

    try {
      await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
      await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
      return;
    } catch (error) {
      lastError = error;
      await page.waitForTimeout(1_000);
    }
  }

  const lastErrorMessage = lastError instanceof Error
    ? `Last error: ${lastError.message}`
    : 'Last error: unavailable';

  throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}

async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise<void> {
  await page.waitForFunction(
    (name) => {
      interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
      interface UserShape { voiceState?: VoiceStateShape }
      interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
      interface RoomShape { id: string; channels?: ChannelShape[] }
      interface AngularDebugApi {
        getComponent: (element: Element) => Record<string, unknown>;
      }

      const host = document.querySelector('app-rooms-side-panel');
      const debugApi = (window as { ng?: AngularDebugApi }).ng;

      if (!host || !debugApi?.getComponent) {
        return false;
      }

      const component = debugApi.getComponent(host);
      const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
      const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
      const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
      const voiceState = currentUser?.voiceState;

      return !!voiceChannel
        && voiceState?.isConnected === true
        && voiceState.roomId === voiceChannel.id
        && voiceState.serverId === currentRoom.id;
    },
    channelName,
    { timeout }
  );
}

// ── Roster / state helpers ───────────────────────────────────────────

async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
  await page.waitForFunction(
    (count) => {
      interface AngularDebugApi {
        getComponent: (element: Element) => Record<string, unknown>;
      }

      const host = document.querySelector('app-voice-workspace');
      const debugApi = (window as { ng?: AngularDebugApi }).ng;

      if (!host || !debugApi?.getComponent) {
        return false;
      }

      const component = debugApi.getComponent(host);
      const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];

      return connectedUsers.length === count;
    },
    expectedCount,
    { timeout: 45_000 }
  );
}

async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
  await page.waitForFunction(
    ({ expected, name }) => {
      interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
      interface RoomShape { channels?: ChannelShape[] }
      interface AngularDebugApi {
        getComponent: (element: Element) => Record<string, unknown>;
      }

      const host = document.querySelector('app-rooms-side-panel');
      const debugApi = (window as { ng?: AngularDebugApi }).ng;

      if (!host || !debugApi?.getComponent) {
        return false;
      }

      const component = debugApi.getComponent(host);
      const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
      const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;

      if (!channelId) {
        return false;
      }

      const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];

      return roster.length === expected;
    },
    { expected: expectedCount, name: channelName },
    { timeout: 30_000 }
  );
}

async function waitForVoiceStateAcrossPages(
  clients: readonly TestClient[],
  displayName: string,
  expectedState: { isMuted: boolean; isDeafened: boolean }
): Promise<void> {
  for (const client of clients) {
    await client.page.waitForFunction(
      ({ expectedDisplayName, expectedMuted, expectedDeafened }) => {
        interface VoiceStateShape { isMuted?: boolean; isDeafened?: boolean }
        interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
        interface UserShape { displayName: string; voiceState?: VoiceStateShape }
        interface RoomShape { channels?: ChannelShape[] }
        interface AngularDebugApi {
          getComponent: (element: Element) => Record<string, unknown>;
        }

        const host = document.querySelector('app-rooms-side-panel');
        const debugApi = (window as { ng?: AngularDebugApi }).ng;

        if (!host || !debugApi?.getComponent) {
          return false;
        }

        const component = debugApi.getComponent(host);
        const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
        const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice');

        if (!voiceChannel) {
          return false;
        }

        const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
        const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);

        return entry?.voiceState?.isMuted === expectedMuted
          && entry?.voiceState?.isDeafened === expectedDeafened;
      },
      {
        expectedDisplayName: displayName,
        expectedMuted: expectedState.isMuted,
        expectedDeafened: expectedState.isDeafened
      },
      { timeout: 30_000 }
    );
  }
}
