import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
  closeOpenDataChannels,
  dumpRtcDiagnostics,
  installAutoResumeAudioContext,
  installWebRTCTracking,
  waitForAllPeerAudioFlow,
  waitForAudioFlow,
  waitForAudioStatsPresent,
  waitForConnectedPeerCount,
  waitForOpenDataChannelCount,
  waitForInboundVideoFlow,
  waitForOutboundVideoFlow,
  waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';

interface DirectCallScenario {
  alice: Client;
  bob: Client;
  charlie?: Client;
  aliceUserId: string;
  bobUserId: string;
  charlieUserId?: string;
}

interface AudioFlowDelta {
  outboundBytesDelta: number;
  inboundBytesDelta: number;
  outboundPacketsDelta: number;
  inboundPacketsDelta: number;
}

interface VideoFlowDelta {
  outboundBytesDelta: number;
  inboundBytesDelta: number;
  outboundPacketsDelta: number;
  inboundPacketsDelta: number;
}

const USER_PASSWORD = 'TestPass123!';

test.describe('Direct private calls', () => {
  test.describe.configure({ timeout: 240_000 });

  test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
    const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
    const callMessage = `Call chat ${uniqueName('msg')}`;
    const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
    const groupMessage = `Group call chat ${uniqueName('msg')}`;

    await test.step('Alice starts a call from the search people card', async () => {
      await disableLastViewedChatResume(scenario.alice.page);
      await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
      await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });

      const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();

      await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
      await bobPeopleCard.hover();
      await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
      await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
      await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
      await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
    });

    await test.step('Alice starts sharing before Bob joins', async () => {
      await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
      await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
      await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
    });

    await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
      await expect
        .poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
          timeout: 20_000,
          intervals: [500, 1_000]
        })
        .toBeGreaterThan(0);

      await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
      await expect
        .poll(async () => await getCallNotificationCount(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBeGreaterThan(0);

      await answerIncomingCall(scenario.bob.page);
      await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });

      await expect
        .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(0);

      await expect
        .poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBeGreaterThan(0);
    });

    await test.step('WebRTC connects and late-join screen share is visible', async () => {
      await waitForPeerConnected(scenario.alice.page, 45_000);
      await waitForPeerConnected(scenario.bob.page, 45_000);
      await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
      await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
      await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
      await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);

      const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
      const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);

      if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
        console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
        console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
      }

      expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
      expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
      await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
    });

    await test.step('Audio flows in both directions', async () => {
      await waitForAudioStatsPresent(scenario.alice.page, 30_000);
      await waitForAudioStatsPresent(scenario.bob.page, 30_000);

      const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
      const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);

      if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
        console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
        console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
      }

      expectAudioFlow(aliceDelta, 'Alice direct call');
      expectAudioFlow(bobDelta, 'Bob direct call');
    });

    await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
      if (!scenario.charlie || !scenario.charlieUserId) {
        throw new Error('Expected direct-call scenario to include Charlie.');
      }

      const charlie = scenario.charlie;

      await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
      await scenario.alice.page.getByTestId('dm-input').press('Enter');
      await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });

      await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
      await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();

      await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
        timeout: 20_000
      });

      await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
      await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });

      await expect
        .poll(async () => await getCallAudioPlayCount(charlie.page), {
          timeout: 20_000,
          intervals: [500, 1_000]
        })
        .toBeGreaterThan(0);

      await answerIncomingCall(charlie.page);
      await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);

      await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
      await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
      await waitForConnectedPeerCount(charlie.page, 2, 45_000);
      await Promise.all([
        waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
        waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
        waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
      ]);

      await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
      await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
      await expectParticipantConnected(charlie.page, scenario.aliceUserId);
      await expectParticipantConnected(charlie.page, scenario.bobUserId);
      await expectParticipantConnected(charlie.page, scenario.charlieUserId);

      await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
      await scenario.alice.page.getByTestId('dm-input').press('Enter');
      await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
      await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
    });

    await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
      await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
      await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });

      await expect
        .poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
          timeout: 30_000,
          intervals: [500, 1_000]
        })
        .toBeGreaterThanOrEqual(2);

      await scenario.bob.page
        .getByTestId('private-call-stream-grid')
        .locator('app-voice-workspace-stream-tile')
        .first()
        .click();

      await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
      await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });

      await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');

      await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
      await expect
        .poll(async () => await hasFullscreenElement(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(true);

      await exitFullscreen(scenario.bob.page);

      await expect
        .poll(async () => await hasFullscreenElement(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(false);

      await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
      await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
    });

    await test.step('Both clients show their own speaking indicator', async () => {
      await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
        timeout: 20_000
      });

      await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
        timeout: 20_000
      });
    });

    await test.step('Private call layout does not require vertical scrolling', async () => {
      await expect
        .poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(true);

      await expect
        .poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(true);

      await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
      await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
      await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
      await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);

      const originalWidth = await privateCallChatWidth(scenario.alice.page);
      const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
      const box = await resizer.boundingBox();

      expect(box, 'private call chat resizer should be measurable').not.toBeNull();

      if (box) {
        await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
        await scenario.alice.page.mouse.down();
        await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
        await scenario.alice.page.mouse.up();
      }

      await expect
        .poll(async () => await privateCallChatWidth(scenario.alice.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBeGreaterThan(originalWidth + 40);
    });

    await test.step('Embedded call chat syncs and does not expose another call button', async () => {
      await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
      await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);

      await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
      await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });

      await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
      await scenario.alice.page.getByTestId('dm-input').press('Enter');
      await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
    });

    await test.step('Group chat call button rings every other participant', async () => {
      if (!scenario.charlie) {
        throw new Error('Expected direct-call scenario to include Charlie.');
      }

      await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
      await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
      await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
      await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
      await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
      await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });

      const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
      const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);

      await scenario.alice.page
        .locator('app-dm-chat header')
        .getByRole('button', { name: /Call/i })
        .click();

      await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });

      await expect
        .poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
          timeout: 20_000,
          intervals: [500, 1_000]
        })
        .toBeGreaterThan(bobPlayCountBeforeGroupCall);

      await expect
        .poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
          timeout: 20_000,
          intervals: [500, 1_000]
        })
        .toBeGreaterThan(charliePlayCountBeforeGroupCall);

      await answerIncomingCall(scenario.bob.page);
      await expect
        .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(0);

      await answerIncomingCall(scenario.charlie.page);
      await expect
        .poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(0);
    });
  });

  test('keeps private-call audio flowing after the data channel closes', async ({ createClient }) => {
    const scenario = await createDirectCallScenario(createClient);

    await test.step('Alice starts a private call and Bob joins', async () => {
      await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
      await answerIncomingCall(scenario.bob.page);

      await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
      await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
      await waitForOpenDataChannelCount(scenario.alice.page, 1, 45_000);
      await waitForOpenDataChannelCount(scenario.bob.page, 1, 45_000);
      await waitForAllPeerAudioFlow(scenario.alice.page, 1, 45_000);
      await waitForAllPeerAudioFlow(scenario.bob.page, 1, 45_000);
    });

    await test.step('Data-channel recovery keeps the call audible', async () => {
      const closed = await closeOpenDataChannels(scenario.alice.page);

      expect(closed).toBeGreaterThan(0);
      await waitForOpenDataChannelCount(scenario.alice.page, 1, 60_000);
      await waitForOpenDataChannelCount(scenario.bob.page, 1, 60_000);
      await waitForAllPeerAudioFlow(scenario.alice.page, 1, 60_000);
      await waitForAllPeerAudioFlow(scenario.bob.page, 1, 60_000);
    });
  });

  test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
    const scenario = await createDirectCallScenario(createClient);

    await test.step('Unknown call routes render an inert empty state', async () => {
      await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
      await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
      await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
    });

    await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
      await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
      await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });

      await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
      await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
      await expect(incomingCallDialog(scenario.bob.page)).toHaveCount(0, { timeout: 20_000 });
      await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
      await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
      await expect
        .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(0);
    });

    await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
      await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
      await answerIncomingCall(scenario.bob.page);

      await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
      await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
      await expect
        .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
          timeout: 10_000,
          intervals: [250, 500]
        })
        .toBe(0);
    });
  });
});

async function createDirectCallScenario(
  createClient: () => Promise<Client>,
  options: { includeCharlie?: boolean } = {}
): Promise<DirectCallScenario> {
  const suffix = uniqueName('direct-call');
  const serverName = `Direct Call Server ${suffix}`;
  const alice = await createClient();
  const bob = await createClient();
  const charlie = options.includeCharlie ? await createClient() : undefined;

  await installDirectCallInstrumentation(alice.page);
  await installDirectCallInstrumentation(bob.page);

  if (charlie) {
    await installDirectCallInstrumentation(charlie.page);
  }

  await registerUser(alice.page, `alice_${suffix}`, 'Alice');
  await registerUser(bob.page, `bob_${suffix}`, 'Bob');

  if (charlie) {
    await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
  }

  const aliceUserId = await getCurrentUserId(alice.page);
  const aliceSearch = new ServerSearchPage(alice.page);

  await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
  await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
  await new ChatMessagesPage(alice.page).waitForReady();

  const bobSearch = new ServerSearchPage(bob.page);

  await bobSearch.joinServerFromSearch(serverName);
  await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
  await new ChatMessagesPage(bob.page).waitForReady();

  if (charlie) {
    const charlieSearch = new ServerSearchPage(charlie.page);

    await charlieSearch.joinServerFromSearch(serverName);
    await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
    await new ChatMessagesPage(charlie.page).waitForReady();
  }

  const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
  const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;

  await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });

  if (charlieRoomCard) {
    await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
  }

  const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
  const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
  const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
  const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');

  if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
    throw new Error('Expected direct-call scenario users to expose stable ids.');
  }

  return {
    alice,
    bob,
    charlie,
    aliceUserId,
    bobUserId,
    charlieUserId
  };
}

async function installDirectCallInstrumentation(page: Page): Promise<void> {
  await installWebRTCTracking(page);
  await installAutoResumeAudioContext(page);
  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
      })
    );

    const OriginalAudio = window.Audio;
    const callAudioState = {
      activeLoops: 0,
      pauseCount: 0,
      playCount: 0
    };
    const callNotificationState = {
      count: 0,
      bodies: [] as string[],
      titles: [] as string[]
    };

    (window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
    (window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;

    function isCallAudio(audio: HTMLAudioElement): boolean {
      return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
    }

    (window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
      const audio = new OriginalAudio(src);
      const originalPlay = audio.play.bind(audio);
      const originalPause = audio.pause.bind(audio);

      audio.play = () => {
        if (isCallAudio(audio)) {
          callAudioState.playCount += 1;

          if (audio.loop) {
            callAudioState.activeLoops += 1;
          }
        }

        return originalPlay();
      };

      audio.pause = () => {
        if (isCallAudio(audio)) {
          callAudioState.pauseCount += 1;

          if (audio.loop && callAudioState.activeLoops > 0) {
            callAudioState.activeLoops -= 1;
          }
        }

        return originalPause();
      };

      return audio;
    } as typeof Audio;

    window.Audio.prototype = OriginalAudio.prototype;
    Object.setPrototypeOf(window.Audio, OriginalAudio);

    class MockNotification {
      static permission: NotificationPermission = 'granted';

      onclick: ((this: Notification, ev: Event) => unknown) | null = null;

      constructor(title: string, options?: NotificationOptions) {
        callNotificationState.count += 1;
        callNotificationState.titles.push(title);
        callNotificationState.bodies.push(options?.body ?? '');
      }

      static async requestPermission(): Promise<NotificationPermission> {
        return 'granted';
      }

      close(): void {}
    }

    (window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
  });
}

async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
  const registerPage = new RegisterPage(page);

  await registerPage.goto();
  await registerPage.register(username, displayName, USER_PASSWORD);
  await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
}

async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
  await disableLastViewedChatResume(page);
  await page.goto('/search', { waitUntil: 'domcontentloaded' });
  const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();

  await expect(peopleCard).toBeVisible({ timeout: 20_000 });
  await peopleCard.hover();
  await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
  await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
}

function incomingCallDialog(page: Page) {
  return page.getByRole('dialog', { name: /is calling/ });
}

async function answerIncomingCall(page: Page): Promise<void> {
  const dialog = incomingCallDialog(page);

  if (await dialog.isVisible({ timeout: 5_000 }).catch(() => false)) {
    await dialog.getByRole('button', { name: 'Answer' }).click();
  } else {
    await page.getByRole('button', { name: 'Open private call' }).last().click();
    await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });

    const joinButton = page.getByRole('button', { name: 'Join call' });

    if (await joinButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
      await joinButton.click();
    }
  }

  await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
  await expect(page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
}

async function getCurrentUserId(page: Page): Promise<string> {
  return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
}

async function getCallAudioPlayCount(page: Page): Promise<number> {
  return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
}

async function getCallAudioPauseCount(page: Page): Promise<number> {
  return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
}

async function getCallNotificationCount(page: Page): Promise<number> {
  return await page.evaluate(() => (
    window as Window & { __callNotificationState?: { count: number } }
  ).__callNotificationState?.count ?? 0);
}

async function getActiveCallAudioLoops(page: Page): Promise<number> {
  return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
}

async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
  if (!userId) {
    throw new Error('Expected a stable participant id.');
  }

  await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
}

async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
  for (let sample = 0; sample < 3; sample++) {
    const [
      senderAudio,
      receiverAudio,
      outboundVideo,
      inboundVideo
    ] = await Promise.all([
      waitForAudioFlow(senderPage, 30_000),
      waitForAudioFlow(receiverPage, 30_000),
      waitForOutboundVideoFlow(senderPage, 30_000),
      waitForInboundVideoFlow(receiverPage, 30_000)
    ]);

    expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
    expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
    expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
    expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
  }
}

async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
  return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
}

async function privateCallGridStreamCount(page: Page): Promise<number> {
  return await page
    .getByTestId('private-call-stream-grid')
    .locator('app-voice-workspace-stream-tile')
    .count();
}

async function privateCallChatWidth(page: Page): Promise<number> {
  return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
}

async function hasFullscreenElement(page: Page): Promise<boolean> {
  return await page.evaluate(() => document.fullscreenElement !== null);
}

async function exitFullscreen(page: Page): Promise<void> {
  await page.evaluate(async () => {
    if (document.fullscreenElement) {
      await document.exitFullscreen();
    }
  });
}

function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
  expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
  expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
}

function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
  expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
}

function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
  expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
}

function isAudioFlowing(delta: AudioFlowDelta): boolean {
  return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
    && (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
}

function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
  return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
}

function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
  return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
}

function uniqueName(prefix: string): string {
  return `${prefix}-${Date.now()}-${Math.random().toString(36)
    .slice(2, 8)}`;
}
