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

import assert from 'assert';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import createDebug from 'debug';

import type { Device, PrimaryDevice } from '@signalapp/mock-server';
import { Server, loadCertificates } from '@signalapp/mock-server';
import { App } from './playwright';
import * as durations from '../util/durations';

const debug = createDebug('mock:bootstrap');

const ELECTRON = path.join(
  __dirname,
  '..',
  '..',
  'node_modules',
  '.bin',
  'electron'
);
const CI_SCRIPT = path.join(__dirname, '..', '..', 'ci.js');

const CLOSE_TIMEOUT = 10 * 1000;

const CONTACT_FIRST_NAMES = [
  'Alice',
  'Bob',
  'Charlie',
  'Paul',
  'Steve',
  'William',
];
const CONTACT_LAST_NAMES = [
  'Smith',
  'Brown',
  'Jones',
  'Miller',
  'Davis',
  'Lopez',
  'Gonazales',
];

const CONTACT_NAMES = new Array<string>();
for (const firstName of CONTACT_FIRST_NAMES) {
  for (const lastName of CONTACT_LAST_NAMES) {
    CONTACT_NAMES.push(`${firstName} ${lastName}`);
  }
}

const MAX_CONTACTS = CONTACT_NAMES.length;

export type BootstrapOptions = Readonly<{
  extraConfig?: Record<string, unknown>;
  benchmark?: boolean;

  linkedDevices?: number;
  contactCount?: number;
}>;

type BootstrapInternalOptions = Pick<BootstrapOptions, 'extraConfig'> &
  Readonly<{
    benchmark: boolean;
    linkedDevices: number;
    contactCount: number;
  }>;

//
// Bootstrap is a class that prepares mock server and desktop for running
// tests/benchmarks.
//
// In general, the usage pattern is:
//
//   const bootstrap = new Bootstrap();
//   await bootstrap.init();
//   const app = await bootstrap.link();
//   await bootstrap.teardown();
//
// Once initialized `bootstrap` variable will have following useful properties:
//
// - `server` - a mock server instance
// - `desktop` - a linked device representing currently running desktop instance
// - `phone` - a primary device representing desktop's primary
// - `contacts` - a list of primary devices for contacts that are synced over
//   through contact sync
//
// `bootstrap.getTimestamp()` could be used to generate consecutive timestamp
// for sending messages.
//
// All phone numbers and uuids for all contacts and ourselves are random and not
// the same between different test runs.
//
export class Bootstrap {
  public readonly server = new Server();

  private readonly options: BootstrapInternalOptions;
  private privContacts?: ReadonlyArray<PrimaryDevice>;
  private privPhone?: PrimaryDevice;
  private privDesktop?: Device;
  private storagePath?: string;
  private timestamp: number = Date.now() - durations.MONTH;

  constructor(options: BootstrapOptions = {}) {
    this.options = {
      linkedDevices: 5,
      contactCount: MAX_CONTACTS,
      benchmark: false,

      ...options,
    };

    assert(this.options.contactCount <= MAX_CONTACTS);
  }

  public async init(): Promise<void> {
    debug('initializing');

    await this.server.listen(0);

    const { port } = this.server.address();
    debug('started server on port=%d', port);

    const contactNames = CONTACT_NAMES.slice(0, this.options.contactCount);

    this.privContacts = await Promise.all(
      contactNames.map(async profileName => {
        const primary = await this.server.createPrimaryDevice({ profileName });

        for (let i = 0; i < this.options.linkedDevices; i += 1) {
          // eslint-disable-next-line no-await-in-loop
          await this.server.createSecondaryDevice(primary);
        }

        return primary;
      })
    );

    this.privPhone = await this.server.createPrimaryDevice({
      profileName: 'Mock',
      contacts: this.contacts,
    });

    this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-'));

    debug('setting storage path=%j', this.storagePath);
  }

  public get logsDir(): string {
    assert(
      this.storagePath !== undefined,
      'Bootstrap has to be initialized first, see: bootstrap.init()'
    );

    return path.join(this.storagePath, 'logs');
  }

  public async teardown(): Promise<void> {
    debug('tearing down');

    await Promise.race([
      this.storagePath
        ? fs.rm(this.storagePath, { recursive: true })
        : Promise.resolve(),
      this.server.close(),
      new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT).unref()),
    ]);
  }

  public async link(): Promise<App> {
    debug('linking');

    const app = await this.startApp();

    const provision = await this.server.waitForProvision();

    const provisionURL = await app.waitForProvisionURL();

    this.privDesktop = await provision.complete({
      provisionURL,
      primaryDevice: this.phone,
    });

    debug('new desktop device %j', this.desktop.debugId);

    const desktopKey = await this.desktop.popSingleUseKey();
    await this.phone.addSingleUseKey(this.desktop, desktopKey);

    for (const contact of this.contacts) {
      // eslint-disable-next-line no-await-in-loop
      const contactKey = await this.desktop.popSingleUseKey();
      // eslint-disable-next-line no-await-in-loop
      await contact.addSingleUseKey(this.desktop, contactKey);
    }

    await this.phone.waitForSync(this.desktop);
    this.phone.resetSyncState(this.desktop);

    debug('synced with %j', this.desktop.debugId);

    return app;
  }

  public async linkAndClose(): Promise<void> {
    const app = await this.link();

    debug('closing the app after link');
    await app.close();
  }

  public async startApp(): Promise<App> {
    assert(
      this.storagePath !== undefined,
      'Bootstrap has to be initialized first, see: bootstrap.init()'
    );

    debug('starting the app');

    const { port } = this.server.address();

    const app = new App({
      main: ELECTRON,
      args: [CI_SCRIPT],
      config: await this.generateConfig(port),
    });

    await app.start();

    return app;
  }

  public getTimestamp(): number {
    const result = this.timestamp;
    this.timestamp += 1;
    return result;
  }

  //
  // Getters
  //

  public get phone(): PrimaryDevice {
    assert(
      this.privPhone,
      'Bootstrap has to be initialized first, see: bootstrap.init()'
    );
    return this.privPhone;
  }

  public get desktop(): Device {
    assert(
      this.privDesktop,
      'Bootstrap has to be linked first, see: bootstrap.link()'
    );
    return this.privDesktop;
  }

  public get contacts(): ReadonlyArray<PrimaryDevice> {
    assert(
      this.privContacts,
      'Bootstrap has to be initialized first, see: bootstrap.init()'
    );
    return this.privContacts;
  }

  //
  // Private
  //

  private async generateConfig(port: number): Promise<string> {
    const url = `https://127.0.0.1:${port}`;
    return JSON.stringify({
      ...(await loadCertificates()),

      forcePreloadBundle: this.options.benchmark,
      enableCI: true,

      buildExpiration: Date.now() + durations.MONTH,
      storagePath: this.storagePath,
      storageProfile: 'mock',
      serverUrl: url,
      storageUrl: url,
      directoryUrl: url,
      cdn: {
        '0': url,
        '2': url,
      },
      updatesEnabled: false,

      ...this.options.extraConfig,
    });
  }
}
