import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  chromium,
  type BrowserContext,
  type Page
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';

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

interface AvatarUploadPayload {
  buffer: Buffer;
  dataUrl: string;
  mimeType: string;
  name: string;
}

interface PersistentClient {
  context: BrowserContext;
  page: Page;
  user: TestUser;
  userDataDir: string;
}

interface ProfileMetadata {
  description?: string;
  displayName: string;
}

const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([
  0x21,
  0xF9,
  0x04
]);
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
  0x21,
  0xFF,
  0x0B,
  0x4E,
  0x45,
  0x54,
  0x53,
  0x43,
  0x41,
  0x50,
  0x45,
  0x32,
  0x2E,
  0x30,
  0x03,
  0x01,
  0x00,
  0x00,
  0x00
]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General';
const AVATAR_SYNC_TIMEOUT_MS = 45_000;

test.describe('Profile avatar sync', () => {
  test.describe.configure({ timeout: 240_000 });

  test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
    const suffix = uniqueName('avatar');
    const serverName = `Avatar Sync Server ${suffix}`;
    const messageText = `Avatar sync message ${suffix}`;
    const avatarA = buildAnimatedGifUpload('alpha');
    const avatarB = buildAnimatedGifUpload('beta');
    const aliceUser: TestUser = {
      username: `alice_${suffix}`,
      displayName: 'Alice',
      password: 'TestPass123!'
    };
    const bobUser: TestUser = {
      username: `bob_${suffix}`,
      displayName: 'Bob',
      password: 'TestPass123!'
    };
    const carolUser: TestUser = {
      username: `carol_${suffix}`,
      displayName: 'Carol',
      password: 'TestPass123!'
    };
    const clients: PersistentClient[] = [];

    try {
      const alice = await createPersistentClient(aliceUser, testServer.port);
      const bob = await createPersistentClient(bobUser, testServer.port);

      clients.push(alice, bob);

      await test.step('Alice and Bob register, create a server, and join the same room', async () => {
        await registerUser(alice);
        await registerUser(bob);

        const aliceSearchPage = new ServerSearchPage(alice.page);

        await aliceSearchPage.createServer(serverName, {
          description: 'Avatar synchronization E2E coverage'
        });

        await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });

        await joinServerFromSearch(bob.page, serverName);
        await waitForRoomReady(alice.page);
        await waitForRoomReady(bob.page);
        await waitForConnectedPeerCount(alice.page, 1);
        await waitForConnectedPeerCount(bob.page, 1);
        await expectUserRowVisible(bob.page, aliceUser.displayName);
      });

      const roomUrl = alice.page.url();

      await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
        await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);

        await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
        await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
      });

      await test.step('Alice sees the updated avatar in voice controls', async () => {
        await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
        await joinVoiceChannel(alice.page, VOICE_CHANNEL);
        await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
      });

      const carol = await createPersistentClient(carolUser, testServer.port);

      clients.push(carol);

      await test.step('Carol joins after the first change and sees the updated avatar', async () => {
        await registerUser(carol);
        await joinServerFromSearch(carol.page, serverName);
        await waitForRoomReady(carol.page);
        await waitForConnectedPeerCount(alice.page, 2);
        await waitForConnectedPeerCount(carol.page, 1);

        await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
      });

      await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
        const aliceMessagesPage = new ChatMessagesPage(alice.page);

        await aliceMessagesPage.sendMessage(messageText);

        await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
        await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
        await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
      });

      await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
        await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);

        await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
        await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
        await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
        await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
        await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
        await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
        await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
      });

      await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
        await restartPersistentClient(bob, testServer.port);
        await openRoomAfterRestart(bob, roomUrl);
        await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
        await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);

        await restartPersistentClient(carol, testServer.port);
        await openRoomAfterRestart(carol, roomUrl);
        await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
        await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);

        await restartPersistentClient(alice, testServer.port);
        await openRoomAfterRestart(alice, roomUrl);
        await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
        await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
      });
    } finally {
      await Promise.all(clients.map(async (client) => {
        await closePersistentClient(client);
        await rm(client.userDataDir, { recursive: true, force: true });
      }));
    }
  });
});

test.describe('Profile metadata sync', () => {
  test.describe.configure({ timeout: 240_000 });

  test('syncs display name and description changes for online and late-joining users and persists after restart', async ({ testServer }) => {
    const suffix = uniqueName('profile');
    const serverName = `Profile Sync Server ${suffix}`;
    const messageText = `Profile sync message ${suffix}`;
    const firstProfile: ProfileMetadata = {
      displayName: `Alice One ${suffix}`,
      description: `First synced profile description ${suffix}`
    };
    const secondProfile: ProfileMetadata = {
      displayName: `Alice Two ${suffix}`,
      description: `Second synced profile description ${suffix}`
    };
    const aliceUser: TestUser = {
      username: `alice_${suffix}`,
      displayName: 'Alice',
      password: 'TestPass123!'
    };
    const bobUser: TestUser = {
      username: `bob_${suffix}`,
      displayName: 'Bob',
      password: 'TestPass123!'
    };
    const carolUser: TestUser = {
      username: `carol_${suffix}`,
      displayName: 'Carol',
      password: 'TestPass123!'
    };
    const clients: PersistentClient[] = [];

    try {
      const alice = await createPersistentClient(aliceUser, testServer.port);
      const bob = await createPersistentClient(bobUser, testServer.port);

      clients.push(alice, bob);

      await test.step('Alice and Bob register, create a server, and join the same room', async () => {
        await registerUser(alice);
        await registerUser(bob);

        const aliceSearchPage = new ServerSearchPage(alice.page);

        await aliceSearchPage.createServer(serverName, {
          description: 'Profile synchronization E2E coverage'
        });

        await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });

        await joinServerFromSearch(bob.page, serverName);
        await waitForRoomReady(alice.page);
        await waitForRoomReady(bob.page);
        await waitForConnectedPeerCount(alice.page, 1);
        await waitForConnectedPeerCount(bob.page, 1);
        await expectUserRowVisible(bob.page, aliceUser.displayName);
      });

      const roomUrl = alice.page.url();

      await test.step('Alice updates her profile while Bob is online and Bob sees it live', async () => {
        await updateProfileFromRoomSidebar(alice.page, {
          displayName: aliceUser.displayName
        }, firstProfile);

        await expectUserRowVisible(alice.page, firstProfile.displayName);
        await expectUserRowVisible(bob.page, firstProfile.displayName);
        await expectProfileCardDetails(bob.page, firstProfile);
      });

      const carol = await createPersistentClient(carolUser, testServer.port);

      clients.push(carol);

      await test.step('Carol joins after the first change and sees the updated profile', async () => {
        await registerUser(carol);
        await joinServerFromSearch(carol.page, serverName);
        await waitForRoomReady(carol.page);
        await waitForConnectedPeerCount(alice.page, 2);
        await waitForConnectedPeerCount(carol.page, 1);

        await expectUserRowVisible(carol.page, firstProfile.displayName);
        await expectProfileCardDetails(carol.page, firstProfile);
      });

      await test.step('Alice changes her profile again and new chat messages use the latest display name', async () => {
        await updateProfileFromRoomSidebar(alice.page, firstProfile, secondProfile);

        await expectUserRowVisible(alice.page, secondProfile.displayName);
        await expectUserRowVisible(bob.page, secondProfile.displayName);
        await expectUserRowVisible(carol.page, secondProfile.displayName);
        await expectProfileCardDetails(bob.page, secondProfile);
        await expectProfileCardDetails(carol.page, secondProfile);

        const aliceMessagesPage = new ChatMessagesPage(alice.page);

        await aliceMessagesPage.sendMessage(messageText);

        await expectChatMessageSenderName(alice.page, messageText, secondProfile.displayName);
        await expectChatMessageSenderName(bob.page, messageText, secondProfile.displayName);
        await expectChatMessageSenderName(carol.page, messageText, secondProfile.displayName);
      });

      await test.step('Bob, Carol, and Alice keep the latest profile after a full app restart', async () => {
        await restartPersistentClient(bob, testServer.port);
        await openRoomAfterRestart(bob, roomUrl);
        await expectUserRowVisible(bob.page, secondProfile.displayName);
        await expectProfileCardDetails(bob.page, secondProfile);

        await restartPersistentClient(carol, testServer.port);
        await openRoomAfterRestart(carol, roomUrl);
        await expectUserRowVisible(carol.page, secondProfile.displayName);
        await expectProfileCardDetails(carol.page, secondProfile);

        await restartPersistentClient(alice, testServer.port);
        await openRoomAfterRestart(alice, roomUrl);
        await expectUserRowVisible(alice.page, secondProfile.displayName);
        await expectProfileCardDetails(alice.page, secondProfile);
      });
    } finally {
      await Promise.all(clients.map(async (client) => {
        await closePersistentClient(client);
        await rm(client.userDataDir, { recursive: true, force: true });
      }));
    }
  });
});

async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
  const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
  const session = await launchPersistentSession(userDataDir, testServerPort);

  return {
    context: session.context,
    page: session.page,
    user,
    userDataDir
  };
}

async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
  await closePersistentClient(client);

  const session = await launchPersistentSession(client.userDataDir, testServerPort);

  client.context = session.context;
  client.page = session.page;
}

async function closePersistentClient(client: PersistentClient): Promise<void> {
  try {
    await client.context.close();
  } catch {
    // Ignore repeated cleanup attempts during finally.
  }
}

async function launchPersistentSession(
  userDataDir: string,
  testServerPort: number
): Promise<{ context: BrowserContext; page: Page }> {
  const context = await chromium.launchPersistentContext(userDataDir, {
    args: CLIENT_LAUNCH_ARGS,
    baseURL: 'http://localhost:4200',
    permissions: ['microphone', 'camera']
  });

  await installTestServerEndpoint(context, testServerPort);
  await installWebRTCTracking(context);

  const page = context.pages()[0] ?? await context.newPage();

  return { context, page };
}

async function registerUser(client: PersistentClient): Promise<void> {
  const registerPage = new RegisterPage(client.page);

  await retryTransientNavigation(() => registerPage.goto());
  await registerPage.register(client.user.username, client.user.displayName, client.user.password);
  await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}

async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
  const searchPage = new ServerSearchPage(page);

  await searchPage.joinServerFromSearch(serverName);
  await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}

async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
  const chatRoom = new ChatRoomPage(page);
  const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });

  if (await existingVoiceChannel.count() > 0) {
    return;
  }

  await chatRoom.openCreateVoiceChannelDialog();
  await chatRoom.createChannel(channelName);
  await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}

async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
  const chatRoom = new ChatRoomPage(page);

  await chatRoom.joinVoiceChannel(channelName);
  await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
}

async function uploadAvatarFromRoomSidebar(
  page: Page,
  displayName: string,
  avatar: AvatarUploadPayload
): Promise<void> {
  const currentUserRow = getUserRow(page, displayName);
  const profileFileInput = page.locator('app-profile-card input[type="file"]');
  const applyButton = page.getByRole('button', { name: 'Apply picture' });

  await expect(currentUserRow).toBeVisible({ timeout: 15_000 });

  if (await profileFileInput.count() === 0) {
    await currentUserRow.click();
    await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
  }

  await profileFileInput.setInputFiles({
    name: avatar.name,
    mimeType: avatar.mimeType,
    buffer: avatar.buffer
  });

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

async function updateProfileFromRoomSidebar(
  page: Page,
  currentProfile: ProfileMetadata,
  nextProfile: ProfileMetadata
): Promise<void> {
  const profileCard = await openProfileCardFromUserRow(page, currentProfile.displayName);
  const displayNameButton = profileCard.getByRole('button', { name: currentProfile.displayName, exact: true });

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

  const displayNameInput = profileCard.locator('input[type="text"]').first();

  await expect(displayNameInput).toBeVisible({ timeout: 10_000 });
  await displayNameInput.fill(nextProfile.displayName);
  await displayNameInput.blur();

  await expect(profileCard.locator('input[type="text"]')).toHaveCount(0, { timeout: 10_000 });

  const currentDescriptionText = currentProfile.description || 'Add a description';

  await profileCard.getByText(currentDescriptionText, { exact: true }).click();

  const descriptionInput = profileCard.locator('textarea').first();

  await expect(descriptionInput).toBeVisible({ timeout: 10_000 });
  await descriptionInput.fill(nextProfile.description || '');
  await descriptionInput.blur();

  await expect(profileCard.locator('textarea')).toHaveCount(0, { timeout: 10_000 });
  await expect(profileCard.getByText(nextProfile.displayName, { exact: true })).toBeVisible({ timeout: 10_000 });

  if (nextProfile.description) {
    await expect(profileCard.getByText(nextProfile.description, { exact: true })).toBeVisible({ timeout: 10_000 });
  }
}

async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
  await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));

  if (client.page.url().includes('/login')) {
    const loginPage = new LoginPage(client.page);

    await loginPage.login(client.user.username, client.user.password);
    await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
    await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
  }

  await waitForRoomReady(client.page);
}

async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await navigate();
    } catch (error) {
      lastError = error;

      const message = error instanceof Error ? error.message : String(error);
      const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');

      if (!isTransientNavigationError || attempt === attempts) {
        throw error;
      }
    }
  }

  throw lastError instanceof Error
    ? lastError
    : new Error(`Navigation failed after ${attempts} attempts`);
}

async function waitForRoomReady(page: Page): Promise<void> {
  const messagesPage = new ChatMessagesPage(page);

  await messagesPage.waitForReady();
  await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}

async function waitForConnectedPeerCount(page: Page, count: number, timeout = 30_000): Promise<void> {
  await page.waitForFunction((expectedCount) => {
    const connections = (window as {
      __rtcConnections?: RTCPeerConnection[];
    }).__rtcConnections ?? [];

    return connections.filter((connection) => connection.connectionState === 'connected').length >= expectedCount;
  }, count, { timeout });
}

async function openProfileCardFromUserRow(page: Page, displayName: string) {
  await closeProfileCard(page);

  const row = getUserRow(page, displayName);

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

  const profileCard = page.locator('app-profile-card');

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

  return profileCard;
}

async function closeProfileCard(page: Page): Promise<void> {
  const profileCard = page.locator('app-profile-card');

  if (await profileCard.count() === 0) {
    return;
  }

  try {
    await expect(profileCard).toBeVisible({ timeout: 1_000 });
  } catch {
    return;
  }

  await page.mouse.click(8, 8);
  await expect(profileCard).toHaveCount(0, { timeout: 10_000 });
}

function getUserRow(page: Page, displayName: string) {
  const usersSidePanel = page.locator('app-rooms-side-panel').last();

  return usersSidePanel.locator('[role="button"]').filter({
    has: page.getByText(displayName, { exact: true })
  })
    .first();
}

async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
  await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}

async function expectProfileCardDetails(page: Page, profile: ProfileMetadata): Promise<void> {
  const profileCard = await openProfileCardFromUserRow(page, profile.displayName);

  await expect(profileCard.getByText(profile.displayName, { exact: true })).toBeVisible({ timeout: 20_000 });

  if (profile.description) {
    await expect(profileCard.getByText(profile.description, { exact: true })).toBeVisible({ timeout: 20_000 });
  }

  await closeProfileCard(page);
}

async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
  const row = getUserRow(page, displayName);

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

  await expect.poll(async () => {
    const image = row.locator('img').first();

    if (await image.count() === 0) {
      return null;
    }

    return image.getAttribute('src');
  }, {
    timeout: AVATAR_SYNC_TIMEOUT_MS,
    message: `${displayName} avatar src should update`
  }).toBe(expectedDataUrl);

  await expect.poll(async () => {
    const image = row.locator('img').first();

    if (await image.count() === 0) {
      return false;
    }

    return image.evaluate((element) => {
      const img = element as HTMLImageElement;

      return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
    });
  }, {
    timeout: AVATAR_SYNC_TIMEOUT_MS,
    message: `${displayName} avatar image should load`
  }).toBe(true);
}

async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
  const messagesPage = new ChatMessagesPage(page);
  const messageItem = messagesPage.getMessageItemByText(messageText);

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

  await expect.poll(async () => {
    const image = messageItem.locator('app-user-avatar img').first();

    if (await image.count() === 0) {
      return null;
    }

    return image.getAttribute('src');
  }, {
    timeout: AVATAR_SYNC_TIMEOUT_MS,
    message: `Chat message avatar for "${messageText}" should update`
  }).toBe(expectedDataUrl);
}

async function expectChatMessageSenderName(page: Page, messageText: string, expectedDisplayName: string): Promise<void> {
  const messagesPage = new ChatMessagesPage(page);
  const messageItem = messagesPage.getMessageItemByText(messageText);

  await expect(messageItem).toBeVisible({ timeout: 20_000 });
  await expect(messageItem.getByText(expectedDisplayName, { exact: true })).toBeVisible({ timeout: 20_000 });
}

async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
  const voiceControls = page.locator('app-voice-controls');

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

  await expect.poll(async () => {
    const image = voiceControls.locator('app-user-avatar img').first();

    if (await image.count() === 0) {
      return null;
    }

    return image.getAttribute('src');
  }, {
    timeout: AVATAR_SYNC_TIMEOUT_MS,
    message: 'Voice controls avatar should update'
  }).toBe(expectedDataUrl);
}

function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
  const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
  const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);

  if (frameStart < 0) {
    throw new Error('Failed to locate GIF frame marker for animated avatar payload');
  }

  const header = baseGif.subarray(0, frameStart);
  const frame = baseGif.subarray(frameStart, baseGif.length - 1);
  const commentData = Buffer.from(label, 'ascii');
  const commentExtension = Buffer.concat([
    Buffer.from([
      0x21,
      0xFE,
      commentData.length
    ]),
    commentData,
    Buffer.from([0x00])
  ]);
  const buffer = Buffer.concat([
    header,
    NETSCAPE_LOOP_EXTENSION,
    commentExtension,
    frame,
    frame,
    Buffer.from([0x3B])
  ]);
  const base64 = buffer.toString('base64');

  return {
    buffer,
    dataUrl: `data:image/gif;base64,${base64}`,
    mimeType: 'image/gif',
    name: `animated-avatar-${label}.gif`
  };
}

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