// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import pTimeout from 'p-timeout';
import createDebug from 'debug';
import {
  type Device,
  type Group,
  PrimaryDevice,
  Proto,
  StorageState,
  EMPTY_DATA_MESSAGE,
} from '@signalapp/mock-server';
import { assert } from 'chai';
import type { Locator, Page } from 'playwright';
import { expect } from 'playwright/test';
import { strictAssert } from '../util/assert.std.ts';
import { SECOND } from '../util/durations/constants.std.ts';
import { toNumber } from '../util/toNumber.std.ts';

const debug = createDebug('mock:test:helpers');

export function bufferToUuid(buffer: Buffer<ArrayBuffer>): string {
  const hex = buffer.toString('hex');

  return [
    hex.substring(0, 8),
    hex.substring(8, 12),
    hex.substring(12, 16),
    hex.substring(16, 20),
    hex.substring(20),
  ].join('-');
}

function isProfileKeyUpdate(flags: number | null | undefined): boolean {
  if (flags == null) {
    return false;
  }
  // oxlint-disable-next-line no-bitwise
  return (flags & Proto.DataMessage.Flags.PROFILE_KEY_UPDATE) !== 0;
}

export async function waitForNonProfileKeyUpdateMessage(
  device: PrimaryDevice,
  { maxAttempts = 5 }: { maxAttempts?: number } = {}
): Promise<Awaited<ReturnType<PrimaryDevice['waitForMessage']>>> {
  for (let i = 0; i < maxAttempts; i += 1) {
    // oxlint-disable-next-line no-await-in-loop
    const message = await device.waitForMessage();
    if (isProfileKeyUpdate(message.dataMessage.flags)) {
      debug('Skipping profile key update');
      continue;
    }
    return message;
  }
  throw new Error(`No message with body after ${maxAttempts} attempts`);
}

export async function typeIntoInput(
  input: Locator,
  additionalText: string,
  previousText: string
): Promise<void> {
  const updatedText = `${previousText}${additionalText}`;

  // Check if the element is an `input` or `[contenteditable]`
  let isInputElement = true;
  try {
    // Discard value, we're using a matcher later to assert the previous value
    await input.inputValue();
  } catch (e) {
    isInputElement = false;
  }

  if (isInputElement) {
    await expect(input).toHaveValue(previousText);
  } else {
    await expect(input).toHaveText(previousText);
  }

  await input.fill(updatedText);

  // Wait to ensure that the input (and react state controlling it) has actually
  // updated with the right value
  if (isInputElement) {
    await expect(input).toHaveValue(updatedText);
  } else {
    await expect(input).toHaveText(updatedText);
  }
}

export async function expectItemsWithText(
  items: Locator,
  expected: ReadonlyArray<string | RegExp>
): Promise<void> {
  // Wait for each message to appear in case they're not all there yet
  for (const [index, message] of expected.entries()) {
    const nth = items.nth(index);
    // oxlint-disable-next-line no-await-in-loop
    await nth.waitFor();
    // oxlint-disable-next-line no-await-in-loop
    const text = await nth.innerText();
    const log = `Expect item at index ${index} to match`;
    if (typeof message === 'string') {
      assert.strictEqual(text, message, log);
    } else {
      assert.match(text, message, log);
    }
  }

  const innerTexts = await items.allInnerTexts();
  assert.deepEqual(
    innerTexts.length,
    expected.length,
    `Expect correct number of items\nActual:\n${innerTexts
      .map(text => `  - "${text}"\n`)
      .join('')}\nExpected:\n${expected
      .map(text => `  - ${text.toString()}\n`)
      .join('')}`
  );
}

export async function expectSystemMessages(
  context: Page | Locator,
  expected: ReadonlyArray<string | RegExp>
): Promise<void> {
  await expectItemsWithText(
    context.locator('.SystemMessage__contents'),
    expected
  );
}

function getDevice(author: PrimaryDevice | Device): Device {
  return author instanceof PrimaryDevice ? author.device : author;
}

type GroupInfo = {
  group: Group;
  members: Array<PrimaryDevice>;
};

function maybeWrapInSyncMessage({
  isSync,
  to,
  sentTo,
  dataMessage,
}: {
  isSync: boolean;
  to: PrimaryDevice | Device;
  sentTo?: Array<PrimaryDevice | Device>;
  dataMessage: Proto.DataMessage.Params;
}): Proto.Content.Params {
  return isSync
    ? {
        content: {
          syncMessage: {
            content: {
              sent: {
                destinationServiceIdBinary: getDevice(to).aciBinary,
                message: dataMessage,
                timestamp: dataMessage.timestamp,
                unidentifiedStatus: (sentTo ?? [to]).map(contact => ({
                  destinationServiceIdBinary: getDevice(contact).aciBinary,
                  destination: getDevice(contact).number,
                  unidentified: null,
                  destinationPniIdentityKey: null,
                  destinationServiceId: null,
                })),
                destinationE164: null,
                expirationStartTimestamp: null,
                isRecipientUpdate: null,
                storyMessage: null,
                storyMessageRecipients: null,
                editMessage: null,
                destinationServiceId: null,
              },
            },
            read: null,
            stickerPackOperation: null,
            viewed: null,
            padding: null,
          },
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      }
    : {
        content: {
          dataMessage,
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      };
}

function isToGroup(to: Device | PrimaryDevice | GroupInfo): to is GroupInfo {
  return 'group' in to;
}

export function sendTextMessage({
  from,
  to,
  text,
  attachments,
  sticker,
  preview,
  quote,
  desktop,
  timestamp = Date.now(),
}: {
  from: PrimaryDevice;
  to: PrimaryDevice | Device | GroupInfo;
  text: string | undefined;
  attachments?: Array<Proto.AttachmentPointer.Params>;
  sticker?: Proto.DataMessage.Sticker.Params;
  preview?: Proto.Preview.Params;
  quote?: Proto.DataMessage.Quote.Params;
  desktop: Device;
  timestamp?: number;
}): Promise<void> {
  const isSync = from.secondaryDevices.includes(desktop);
  const toDevice = isSync || isToGroup(to) ? desktop : getDevice(to);
  const groupInfo = isToGroup(to) ? to : undefined;
  return from.sendRaw(
    toDevice,
    maybeWrapInSyncMessage({
      isSync,
      to: to as PrimaryDevice,
      dataMessage: {
        ...EMPTY_DATA_MESSAGE,
        body: text ?? null,
        attachments: attachments ?? null,
        sticker: sticker ?? null,
        preview: preview == null ? null : [preview],
        quote: quote ?? null,
        timestamp: BigInt(timestamp),
        groupV2: groupInfo
          ? {
              masterKey: groupInfo.group.masterKey,
              revision: groupInfo.group.revision,
              groupChange: null,
            }
          : null,
      },
      sentTo: groupInfo ? groupInfo.members : [to as PrimaryDevice | Device],
    }),
    { timestamp }
  );
}

export function sendReaction({
  from,
  to,
  targetAuthor,
  targetMessageTimestamp,
  emoji,
  reactionTimestamp = Date.now(),
  desktop,
}: {
  from: PrimaryDevice;
  to: PrimaryDevice | Device;
  targetAuthor: PrimaryDevice | Device;
  targetMessageTimestamp: number;
  emoji: string;
  reactionTimestamp?: number;
  desktop: Device;
}): Promise<void> {
  const isSync = from.secondaryDevices.includes(desktop);
  return from.sendRaw(
    isSync ? desktop : getDevice(to),
    maybeWrapInSyncMessage({
      isSync,
      to,
      dataMessage: {
        ...EMPTY_DATA_MESSAGE,
        timestamp: BigInt(reactionTimestamp),
        reaction: {
          emoji,
          targetAuthorAciBinary: getDevice(targetAuthor).aciRawUuid,
          targetSentTimestamp: BigInt(targetMessageTimestamp),
          remove: null,
          targetAuthorAci: null,
        },
      },
    }),
    {
      timestamp: reactionTimestamp,
    }
  );
}

async function getStorageState(phone: PrimaryDevice) {
  return (await phone.getStorageState()) ?? StorageState.getEmpty();
}

export async function createGroup(
  phone: PrimaryDevice,
  otherMembers: Array<PrimaryDevice>,
  groupTitle: string
): Promise<Group> {
  const group = await phone.createGroup({
    title: groupTitle,
    members: [phone, ...otherMembers],
  });
  let state = await getStorageState(phone);

  state = state
    .addGroup(group, {
      whitelisted: true,
    })
    .pinGroup(group);

  // Finally whitelist and pin contacts
  for (const member of otherMembers) {
    state = state.addContact(member, {
      whitelisted: true,
      e164: member.device.number,
      identityKey: member.publicKey.serialize(),
      profileKey: member.profileKey.serialize(),
      givenName: member.profileName,
    });
  }
  await phone.setStorageState(state);
  return group;
}
export function getLeftPane(page: Page): Locator {
  return page.locator('#LeftPane');
}

export async function clickOnConversationWithAci(
  page: Page,
  aci: string
): Promise<void> {
  const leftPane = getLeftPane(page);
  await leftPane.getByTestId(aci).click();
}

export async function clickOnConversation(
  page: Page,
  contact: PrimaryDevice
): Promise<void> {
  await clickOnConversationWithAci(page, contact.device.aci);
}

export async function pinContact(
  phone: PrimaryDevice,
  contact: PrimaryDevice
): Promise<void> {
  const state = await getStorageState(phone);
  state.pin(contact);
  await phone.setStorageState(state);
}

export async function acceptConversation(page: Page): Promise<void> {
  await page
    .locator('.module-message-request-actions button >> "Accept"')
    .click();

  const confirmationButton = page
    .locator('.MessageRequestActionsConfirmation')
    .getByRole('button', { name: 'Accept' });

  await confirmationButton.waitFor({
    timeout: 500,
  });

  if (await confirmationButton.isVisible()) {
    await confirmationButton.click();
  }
}

export function getTimeline(page: Page): Locator {
  return page.locator('.module-timeline__messages__container');
}

export function getMessageInTimelineByTimestamp(
  page: Page,
  timestamp: number
): Locator {
  return getTimeline(page).getByTestId(`${timestamp}`);
}

export function getTimelineMessageWithText(page: Page, text: string): Locator {
  return getTimeline(page).locator('.module-message').filter({ hasText: text });
}

export async function composerAttachFiles(
  page: Page,
  filePaths: ReadonlyArray<string>
): Promise<void> {
  const AttachmentInput = page.getByTestId('attachfile-input');

  const AttachmentsList = page.locator('.module-attachments');
  const AttachmentsListImageLoaded = AttachmentsList.locator(
    '.module-image--loaded'
  );

  debug('setting input files');
  await page
    .getByRole('button', {
      name: 'Add attachment or poll',
    })
    .click();
  await page.getByRole('menuitem', { name: 'File' }).click();
  await AttachmentInput.setInputFiles(filePaths);

  debug(`waiting for ${filePaths.length} items`);
  await AttachmentsListImageLoaded.nth(filePaths.length - 1).waitFor();

  await Promise.all(
    filePaths.map(async (_, index) => {
      debug(`waiting for ${index} image to render in attachments list`);
      await AttachmentsListImageLoaded.nth(index).waitFor({
        state: 'visible',
      });
    })
  );
}

export async function sendMessageWithAttachments(
  page: Page,
  receiver: PrimaryDevice,
  text: string,
  filePaths: Array<string>
): Promise<{
  attachments: Array<Proto.AttachmentPointer.Params>;
  timestamp: number;
}> {
  await composerAttachFiles(page, filePaths);

  debug('sending message');
  const input = await waitForEnabledComposer(page);
  await typeIntoInput(input, text, '');
  await input.press('Enter');

  const Message = getTimelineMessageWithText(page, text);
  const MessageImageLoaded = Message.locator('.module-image__image');

  await Message.waitFor();

  await Promise.all(
    filePaths.map(async (_, index) => {
      debug(`waiting for ${index} image to render in timeline`);
      await MessageImageLoaded.nth(index).waitFor({
        state: 'visible',
      });
    })
  );

  debug('get received message data');

  return pTimeout(
    (async () => {
      // oxlint-disable-next-line no-constant-condition
      while (true) {
        // oxlint-disable-next-line no-await-in-loop
        const receivedMessage = await receiver.waitForMessage();
        const attachments = receivedMessage.dataMessage.attachments ?? [];
        if (
          attachments.length === filePaths.length &&
          receivedMessage.body === text
        ) {
          strictAssert(
            receivedMessage.dataMessage.timestamp,
            'timestamp exists'
          );
          return {
            attachments,
            timestamp: toNumber(receivedMessage.dataMessage.timestamp),
          };
        }
      }
    })(),
    {
      milliseconds: 10 * SECOND,
      message: 'Timed out waiting to detect message send with attached files',
    }
  );
}

export async function waitForEnabledComposer(page: Page): Promise<Locator> {
  const composeArea = page.locator(
    '.composition-area-wrapper, .Inbox__conversation .ConversationView'
  );
  const composeContainer = composeArea.locator(
    '[data-testid=CompositionInput][data-enabled=true]'
  );
  await composeContainer.waitFor();

  return composeContainer.locator('.ql-editor');
}

export async function createCallLink(
  page: Page,
  {
    name,
    isAdminApprovalRequired,
  }: { name: string; isAdminApprovalRequired?: boolean | undefined }
): Promise<string | undefined> {
  await page.locator('[data-testid="NavTabsItem--Calls"]').click();
  await page.locator('.NavSidebar__HeaderTitle').getByText('Calls').waitFor();

  await page
    .locator('.CallsList__ItemTile')
    .getByText('Create a Call Link')
    .click();

  const editModal = page.locator('.CallLinkEditModal');
  await editModal.waitFor();

  if (isAdminApprovalRequired !== undefined) {
    const restrictionsInput = editModal.getByLabel('Require admin approval');
    if (isAdminApprovalRequired) {
      await expect(restrictionsInput).toHaveJSProperty('value', '0');
      await restrictionsInput.selectOption({ label: 'On' });
      await expect(restrictionsInput).toHaveJSProperty('value', '1');
    } else {
      await expect(restrictionsInput).toHaveJSProperty('value', '0');
    }
  }

  await editModal.locator('button', { hasText: 'Add call name' }).click();

  const addNameModal = page.locator('.CallLinkAddNameModal');
  await addNameModal.waitFor();

  const nameInput = addNameModal.getByLabel('Call name');
  await nameInput.fill(name);

  const saveBtn = addNameModal.getByText('Save');
  await saveBtn.click();

  await editModal.waitFor();

  const doneBtn = editModal.getByText('Done');
  await doneBtn.click();

  const callLinkTitle = page.locator('.CallsList__ItemTile').getByText(name);

  const callLinkItem = page.locator('.CallsList__Item', {
    has: callLinkTitle,
  });
  const testId = await callLinkItem.getAttribute('data-testid');
  return testId || undefined;
}
