// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { assert } from 'chai';
import type { PrimaryDevice } from '@signalapp/mock-server';
import { StorageState, ServiceIdKind } from '@signalapp/mock-server';
import createDebug from 'debug';
import * as durations from '../../util/durations/index.std.ts';
import { Bootstrap } from '../bootstrap.node.ts';
import type { App } from '../bootstrap.node.ts';
import { ReceiptType } from '../../types/Receipt.std.ts';
import {
  acceptConversation,
  typeIntoInput,
  waitForEnabledComposer,
} from '../helpers.node.ts';

export const debug = createDebug('mock:test:challenge:receipts');

describe('challenge/receipts', function (this: Mocha.Suite) {
  this.timeout(durations.MINUTE);

  let bootstrap: Bootstrap;
  let app: App;
  let contact: PrimaryDevice;
  let contactB: PrimaryDevice;

  beforeEach(async () => {
    bootstrap = new Bootstrap({
      contactCount: 0,
      contactsWithoutProfileKey: 40,
    });
    await bootstrap.init();
    app = await bootstrap.link();

    const { server, desktop, phone } = bootstrap;

    contact = await server.createPrimaryDevice({
      profileName: 'Jamie',
    });
    contactB = await server.createPrimaryDevice({
      profileName: 'Kim',
    });

    let state = StorageState.getEmpty();

    state = state.updateAccount({
      profileKey: phone.profileKey.serialize(),
      givenName: phone.profileName,
      readReceipts: true,
    });

    state = state.addContact(
      contact,
      {
        whitelisted: true,
        e164: contact.device.number,
        identityKey: contact.getPublicKey(ServiceIdKind.PNI).serialize(),
        pniBinary: contact.device.pniRawUuid,
        givenName: 'Jamie',
      },
      ServiceIdKind.PNI
    );
    state = state.addContact(
      contactB,
      {
        whitelisted: true,
        e164: contactB.device.number,
        identityKey: contactB.getPublicKey(ServiceIdKind.PNI).serialize(),
        pniBinary: contactB.device.pniRawUuid,
        givenName: 'Kim',
      },
      ServiceIdKind.PNI
    );

    // Just to make PNI Contact visible in the left pane
    state = state.pin(contact, ServiceIdKind.PNI);
    state = state.pin(contactB, ServiceIdKind.PNI);

    const ourKey = await desktop.popSingleUseKey();
    await contact.addSingleUseKey(desktop, ourKey);

    const ourKeyB = await desktop.popSingleUseKey();
    await contactB.addSingleUseKey(desktop, ourKeyB);

    await phone.setStorageState(state);
  });

  afterEach(async function (this: Mocha.Context) {
    await bootstrap.maybeSaveLogs(this.currentTest, app);
    await app.close();
    await bootstrap.teardown();
  });

  it('should wait for the challenge to be handled', async () => {
    const { server, desktop } = bootstrap;

    debug(
      `Rate limiting (desktop: ${desktop.aci}) -> (contact: ${contact.device.aci})`
    );
    server.rateLimit({ source: desktop.aci, target: contact.device.aci });

    const timestamp = bootstrap.getTimestamp();

    debug('Sending a message from contact');
    await contact.sendText(desktop, 'Hello there!', {
      timestamp,
    });

    const window = await app.getWindow();
    const leftPane = window.locator('#LeftPane');

    debug(`Opening conversation with contact (${contact.device.aci})`);
    await leftPane.locator(`[data-testid="${contact.device.aci}"]`).click();

    debug('Accept conversation from contact - does not trigger captcha!');
    await acceptConversation(window);

    debug('Sending a message back to user - will trigger captcha!');
    {
      const input = await waitForEnabledComposer(window);
      await typeIntoInput(input, 'Hi, good to hear from you!', '');
      await input.press('Enter');
    }

    debug('Waiting for challenge');
    const request = await app.waitForChallenge();

    debug('Solving challenge');
    await app.solveChallenge({
      seq: request.seq,
      data: { captcha: 'anything' },
    });

    const requests = server.stopRateLimiting({
      source: desktop.aci,
      target: contact.device.aci,
    });

    debug(`Rate-limited requests: ${requests}`);
    assert.strictEqual(requests, 1, 'rate limit requests');

    debug('Waiting for outgoing read receipt');
    const receipts = await app.waitForReceipts();

    assert.strictEqual(receipts.type, ReceiptType.Read);
    assert.strictEqual(receipts.timestamps.length, 1, 'receipts');
    assert.strictEqual(receipts.timestamps[0], timestamp);
  });

  it('should send non-bubble in ConvoA when ConvoB completes challenge', async () => {
    const { server, desktop } = bootstrap;

    debug(
      `Rate limiting (desktop: ${desktop.aci}) -> (ContactA: ${contact.device.aci})`
    );
    server.rateLimit({ source: desktop.aci, target: contact.device.aci });
    debug(
      `Rate limiting (desktop: ${desktop.aci}) -> (ContactB: ${contactB.device.aci})`
    );
    server.rateLimit({ source: desktop.aci, target: contactB.device.aci });

    const window = await app.getWindow();
    const leftPane = window.locator('#LeftPane');

    debug('Sending a message from ContactA');
    const timestampA = bootstrap.getTimestamp();
    await contact.sendText(desktop, 'Hello there!', {
      timestamp: timestampA,
    });

    debug(`Opening conversation with ContactA (${contact.device.aci})`);
    await leftPane.locator(`[data-testid="${contact.device.aci}"]`).click();

    debug('Accept conversation from ContactA - does not trigger captcha!');
    await acceptConversation(window);

    debug('Sending a message from ContactB');
    const timestampB = bootstrap.getTimestamp();
    await contactB.sendText(desktop, 'Hey there!', {
      timestamp: timestampB,
    });

    debug(`Opening conversation with ContactB (${contact.device.aci})`);
    await leftPane.locator(`[data-testid="${contactB.device.aci}"]`).click();

    debug('Accept conversation from ContactB - does not trigger captcha!');
    await acceptConversation(window);

    debug('Sending a message back to ContactB - will trigger captcha!');
    {
      const input = await waitForEnabledComposer(window);
      await typeIntoInput(input, 'Hi, good to hear from you!', '');
      await input.press('Enter');
    }

    debug('Waiting for challenge');
    const request = await app.waitForChallenge();

    debug('Solving challenge');
    await app.solveChallenge({
      seq: request.seq,
      data: { captcha: 'anything' },
    });

    const requestsA = server.stopRateLimiting({
      source: desktop.aci,
      target: contact.device.aci,
    });
    const requestsB = server.stopRateLimiting({
      source: desktop.aci,
      target: contactB.device.aci,
    });

    debug(`Rate-limited requests to A: ${requestsA}`);
    assert.strictEqual(requestsA, 1, 'rate limit requests');

    debug(`Rate-limited requests to B: ${requestsA}`);
    assert.strictEqual(requestsB, 1, 'rate limit requests');

    debug('Waiting for outgoing read receipt #1');
    const receipts1 = await app.waitForReceipts();

    assert.strictEqual(receipts1.type, ReceiptType.Read);
    assert.strictEqual(receipts1.timestamps.length, 1, 'receipts');
    if (
      !receipts1.timestamps.includes(timestampA) &&
      !receipts1.timestamps.includes(timestampB)
    ) {
      throw new Error(
        'receipts1: Failed to find both timestampA and timestampB'
      );
    }

    debug('Waiting for outgoing read receipt #2');
    const receipts2 = await app.waitForReceipts();

    assert.strictEqual(receipts2.type, ReceiptType.Read);
    assert.strictEqual(receipts2.timestamps.length, 1, 'receipts');
    if (
      !receipts2.timestamps.includes(timestampA) &&
      !receipts2.timestamps.includes(timestampB)
    ) {
      throw new Error(
        'receipts2: Failed to find both timestampA and timestampB'
      );
    }
  });

  it('if server rejects our captcha, should show a toast and defer challenge based on error code', async () => {
    const { server, desktop } = bootstrap;

    debug(
      `Rate limiting (desktop: ${desktop.aci}) -> (contact: ${contact.device.aci})`
    );
    server.rateLimit({ source: desktop.aci, target: contact.device.aci });
    server.rateLimit({ source: desktop.aci, target: contactB.device.aci });

    const timestamp = bootstrap.getTimestamp();

    debug('Sending a message from contact');
    await contact.sendText(desktop, 'Hello there!', {
      timestamp,
    });

    const window = await app.getWindow();
    const leftPane = window.locator('#LeftPane');

    debug(`Opening conversation with contact (${contact.device.aci})`);
    await leftPane.locator(`[data-testid="${contact.device.aci}"]`).click();

    debug('Accept conversation from contact - does not trigger captcha!');
    await acceptConversation(window);

    debug('Sending a message back to user - will trigger captcha!');
    {
      const input = await waitForEnabledComposer(window);
      await typeIntoInput(input, 'Hi, good to hear from you!', '');
      await input.press('Enter');
    }

    /** First, challenge returns 428 (try again) */
    debug('Waiting for challenge');
    const firstChallengeRequest = await app.waitForChallenge();
    const challengeDialog = await window
      .getByTestId('CaptchaDialog.pending')
      .elementHandle();

    assert.exists(challengeDialog);
    server.respondToChallengesWith(428);

    debug('Solving challenge');
    await app.solveChallenge({
      seq: firstChallengeRequest.seq,
      data: { captcha: 'anything' },
    });

    debug('Waiting for verification failure toast');
    const failedChallengeToastLocator = window.locator(
      '.Toast__content >> "Verification failed. Please retry later."'
    );
    await failedChallengeToastLocator.isVisible();
    // The existing dialog is removed, but then the conversations will retry their sends,
    // which will result in another one
    await challengeDialog.isHidden();

    /** Second, challenge returns 413 (rate limit) */
    debug(
      'Waiting for second challenge, should be triggered quickly with the sends being retried'
    );
    const secondChallengeRequest = await app.waitForChallenge();

    server.respondToChallengesWith(413);

    debug('Solving challenge');
    await app.solveChallenge({
      seq: secondChallengeRequest.seq,
      data: { captcha: 'anything' },
    });

    debug('Waiting for verification failure toast');
    await failedChallengeToastLocator.isVisible();

    debug('Sending another message - this time it should not trigger captcha!');
    {
      const input = await waitForEnabledComposer(window);
      await typeIntoInput(input, 'How have you been lately?', '');
      await input.press('Enter');
    }

    debug('Sending a message from Contact B');
    await contactB.sendText(desktop, 'Wanna buy a cow?', {
      timestamp,
    });

    debug(`Opening conversation with Contact B (${contactB.device.aci})`);
    await leftPane.locator(`[data-testid="${contactB.device.aci}"]`).click();

    debug('Accept conversation from Contact B - does not trigger captcha!');
    await acceptConversation(window);

    debug(
      'Sending to Contact B - we should not pop captcha because we are waiting!'
    );
    {
      const input = await waitForEnabledComposer(window);
      await typeIntoInput(input, 'You the cow guy from craigslist?', '');
      await input.press('Enter');
    }

    debug('Checking for no other captcha dialogs');
    assert.equal(
      await app.getPendingEventCount('captchaDialog'),
      2,
      'Just two captcha dialogs, the first one, and the one after the 428'
    );

    const requests = server.stopRateLimiting({
      source: desktop.aci,
      target: contact.device.aci,
    });

    debug(`Rate-limited requests: ${requests}`);
    assert.strictEqual(requests, 2, 'rate limit requests');

    const requestsContactB = server.stopRateLimiting({
      source: desktop.aci,
      target: contactB.device.aci,
    });

    debug(`Rate-limited requests to Contact B: ${requests}`);
    assert.strictEqual(requestsContactB, 1, 'Contact B rate limit requests');
  });
});
