/* @flow */

import path from 'path';
import EventEmitter from 'events';

import {assert} from 'chai';
import {describe, it, beforeEach, afterEach} from 'mocha';
import deepcopy from 'deepcopy';
import fs from 'fs-extra';
import * as sinon from 'sinon';
import WebSocket from 'ws';

import getValidatedManifest from '../../../src/util/manifest.js';
import {
  basicManifest,
  StubChildProcess,
} from '../helpers.js';
import {
  ChromiumExtensionRunner,
  DEFAULT_CHROME_FLAGS,
} from '../../../src/extension-runners/chromium.js';
import type {
  ChromiumExtensionRunnerParams,
} from '../../../src/extension-runners/chromium';
import {
  consoleStream, // instance is imported to inspect logged messages
} from '../../../src/util/logger.js';
import { TempDir, withTempDir } from '../../../src/util/temp-dir.js';
import fileExists from '../../../src/util/file-exists.js';
import isDirectory from '../../../src/util/is-directory.js';

function prepareExtensionRunnerParams({params} = {}) {
  const fakeChromeInstance = {
    process: new StubChildProcess(),
    kill: sinon.spy(async () => {}),
  };
  const runnerParams: ChromiumExtensionRunnerParams = {
    extensions: [{
      sourceDir: '/fake/sourceDir',
      manifestData: deepcopy(basicManifest),
    }],
    keepProfileChanges: false,
    startUrl: undefined,
    chromiumLaunch: sinon.spy(async () => {
      return fakeChromeInstance;
    }),
    desktopNotifications: sinon.spy(() => {}),
    ...(params || {}),
  };

  return {params: runnerParams, fakeChromeInstance};
}

describe('util/extension-runners/chromium', async () => {

  it('uses the expected chrome flags', () => {
    // Flags from chrome-launcher v0.14.0
    const expectedFlags = [
      '--disable-features=Translate',
      '--disable-component-extensions-with-background-pages',
      '--disable-background-networking',
      '--disable-component-update',
      '--disable-client-side-phishing-detection',
      '--disable-sync',
      '--metrics-recording-only',
      '--disable-default-apps',
      '--no-default-browser-check',
      '--no-first-run',
      '--disable-backgrounding-occluded-windows',
      '--disable-renderer-backgrounding',
      '--disable-background-timer-throttling',
      '--disable-ipc-flooding-protection',
      '--password-store=basic',
      '--use-mock-keychain',
      '--force-fieldtrials=*BackgroundTracing/default/',
    ];

    assert.deepEqual(DEFAULT_CHROME_FLAGS, expectedFlags);
  });

  it('installs and runs the extension', async () => {
    const {params, fakeChromeInstance} = prepareExtensionRunnerParams();
    const runnerInstance = new ChromiumExtensionRunner(params);
    assert.equal(runnerInstance.getName(), 'Chromium');

    await runnerInstance.run();

    const {reloadManagerExtension} = runnerInstance;

    sinon.assert.calledOnce(params.chromiumLaunch);
    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      ignoreDefaultFlags: true,
      enableExtensions: true,
      chromePath: undefined,
      chromeFlags: [
        ...DEFAULT_CHROME_FLAGS,
        `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
      ],
      startingUrl: undefined,
    });

    await runnerInstance.exit();
    sinon.assert.calledOnce(fakeChromeInstance.kill);
  });

  it('installs a "reload manager" companion extension', async () => {
    const {params} = prepareExtensionRunnerParams();
    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const {reloadManagerExtension} = runnerInstance;

    assert.equal(await fs.exists(reloadManagerExtension), true);
    const managerExtManifest = await getValidatedManifest(
      reloadManagerExtension);
    assert.deepEqual(managerExtManifest.permissions, ['management', 'tabs']);

    await runnerInstance.exit();
  });


  it('controls the "reload manager" from a websocket server', async () => {
    const {params} = prepareExtensionRunnerParams();
    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    // $FlowIgnore: allow to call addess even wss property can be undefined.
    const wssInfo = runnerInstance.wss.address();
    const wsURL = `ws://${wssInfo.address}:${wssInfo.port}`;
    const wsClient = new WebSocket(wsURL);

    await new Promise((resolve) => wsClient.on('open', resolve));

    // Clear console stream from previous messages and start recording
    consoleStream.stopCapturing();
    consoleStream.flushCapturedLogs();
    consoleStream.startCapturing();
    // Make verbose to capture debug logs.
    consoleStream.makeVerbose();

    // Emit a fake socket object as a new wss connection.

    const fakeSocket = new EventEmitter();
    sinon.spy(fakeSocket, 'on');
    runnerInstance.wss?.emit('connection', fakeSocket);
    // $FlowIgnore: ignore method-unbinding, sinon just checks the spy properties.
    sinon.assert.calledOnce(fakeSocket.on);

    fakeSocket.emit('error', new Error('Fake wss socket ERROR'));

    // Retrieve captures logs and stop capturing.
    const {capturedMessages} = consoleStream;
    consoleStream.stopCapturing();

    assert.ok(capturedMessages.some(
      (message) => (
        message.match('[debug]') &&
        message.match('Fake wss socket ERROR')
      )));

    const reload = (client, resolve, data) => {
      client.send(JSON.stringify({ type: 'webExtReloadExtensionComplete' }));
      resolve(data);
    };

    const waitForReloadAll = new Promise((resolve) =>
      wsClient.on('message', (data) => reload(wsClient, resolve, data)));
    await runnerInstance.reloadAllExtensions();
    assert.deepEqual(JSON.parse(await waitForReloadAll),
                     {type: 'webExtReloadAllExtensions'});

    // TODO(rpl): change this once we improve the manager extension to be able
    // to reload a single extension.
    const waitForReloadOne = new Promise((resolve) =>
      wsClient.on('message', (data) => reload(wsClient, resolve, data)));
    await runnerInstance.reloadExtensionBySourceDir('/fake/sourceDir');
    assert.deepEqual(JSON.parse(await waitForReloadOne),
                     {type: 'webExtReloadAllExtensions'});

    // Verify that if one websocket connection gets closed, a second websocket
    // connection still receives the control messages.
    const wsClient2 = new WebSocket(wsURL);
    await new Promise((resolve) => wsClient2.on('open', resolve));
    wsClient.close();

    const waitForReloadClient2 = new Promise((resolve) =>
      wsClient2.on('message', (data) => reload(wsClient2, resolve, data)));

    await runnerInstance.reloadAllExtensions();
    assert.deepEqual(JSON.parse(await waitForReloadClient2),
                     {type: 'webExtReloadAllExtensions'});

    const waitForReloadAllAgain = new Promise((resolve) =>
      wsClient2.on('message', (data) => reload(wsClient2, resolve, data)));
    await runnerInstance.reloadAllExtensions();
    assert.deepEqual(JSON.parse(await waitForReloadAllAgain),
                     {type: 'webExtReloadAllExtensions'});

    await runnerInstance.exit();
  });

  it('exits if the chrome instance is shutting down', async () => {
    const {params, fakeChromeInstance} = prepareExtensionRunnerParams();
    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const onceExiting = new Promise((resolve) =>
      runnerInstance.registerCleanup(resolve));

    fakeChromeInstance.process.emit('close');

    await onceExiting;
  });

  it('calls all cleanup callbacks on exit', async () => {
    const {params} = prepareExtensionRunnerParams();
    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    runnerInstance.registerCleanup(function fnThrowsError() {
      throw new Error('fake cleanup exception');
    });

    const onceExiting = new Promise((resolve) =>
      runnerInstance.registerCleanup(resolve));

    await runnerInstance.exit();
    await onceExiting;
  });

  it('does not call exit if chrome instance exits while shutting down',
     async () => {
       const {params, fakeChromeInstance} = prepareExtensionRunnerParams();
       const runnerInstance = new ChromiumExtensionRunner(params);
       await runnerInstance.run();

       sinon.spy(runnerInstance, 'exit');

       const exitDone = runnerInstance.exit();
       fakeChromeInstance.process.emit('close');

       await exitDone;

       // $FlowIgnore: ignore method-unbinding, sinon just checks the spy properties.
       sinon.assert.calledOnce(runnerInstance.exit);
     });

  it('awaits for the async setup to complete before exiting', async () => {
    const {params} = prepareExtensionRunnerParams({
      params: {
        chromiumLaunch: sinon.spy(async () => {
          throw new Error('Fake chromiumLaunch ERROR');
        }),
      },
    });
    const runnerInstance = new ChromiumExtensionRunner(params);
    assert.equal(runnerInstance.getName(), 'Chromium');

    await assert.isRejected(
      runnerInstance.run(),
      /Fake chromiumLaunch ERROR/
    );

    // Clear console stream from previous messages and start recording
    consoleStream.stopCapturing();
    consoleStream.flushCapturedLogs();
    consoleStream.startCapturing();
    // Make verbose to capture debug logs.
    consoleStream.makeVerbose();

    // Call exit and then verify that it caught the chromiumLaunch rejection
    // and logged it as a debug log.
    await runnerInstance.exit();

    // Retrieve captures logs and stop capturing.
    const {capturedMessages} = consoleStream;
    consoleStream.stopCapturing();

    assert.ok(capturedMessages.some(
      (message) => (
        message.match('[debug]') &&
        message.match('ignored setup error on chromium runner shutdown') &&
        message.match('Fake chromiumLaunch ERROR')
      )));
  });

  it('does use a custom chromium binary when passed', async () => {
    const {params} = prepareExtensionRunnerParams({
      params: {chromiumBinary: '/my/custom/chrome-bin'},
    });

    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const {reloadManagerExtension} = runnerInstance;

    sinon.assert.calledOnce(params.chromiumLaunch);
    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      ignoreDefaultFlags: true,
      enableExtensions: true,
      chromePath: '/my/custom/chrome-bin',
      chromeFlags: [
        ...DEFAULT_CHROME_FLAGS,
        `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
      ],
      startingUrl: undefined,
    });

    await runnerInstance.exit();
  });

  it('does pass multiple starting urls to chrome', async () => {
    const {params} = prepareExtensionRunnerParams({
      params: {startUrl: ['url1', 'url2', 'url3']},
    });

    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const {reloadManagerExtension} = runnerInstance;

    sinon.assert.calledOnce(params.chromiumLaunch);
    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      ignoreDefaultFlags: true,
      enableExtensions: true,
      chromePath: undefined,
      chromeFlags: [
        ...DEFAULT_CHROME_FLAGS,
        `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
        'url2',
        'url3',
      ],
      startingUrl: 'url1',
    });

    await runnerInstance.exit();
  });

  it('does pass additional args to chrome', async () => {
    const {params} = prepareExtensionRunnerParams({
      params: {
        args: ['--arg1', 'arg2', '--arg3'],
        startUrl: ['url1', 'url2', 'url3'],
      },
    });

    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const {reloadManagerExtension} = runnerInstance;

    sinon.assert.calledOnce(params.chromiumLaunch);
    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      ignoreDefaultFlags: true,
      enableExtensions: true,
      chromePath: undefined,
      chromeFlags: [
        ...DEFAULT_CHROME_FLAGS,
        `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
        '--arg1',
        'arg2',
        '--arg3',
        'url2',
        'url3',
      ],
      startingUrl: 'url1',
    });

    await runnerInstance.exit();
  });

  it('does use a random user-data-dir', async () => {

    const {params} = prepareExtensionRunnerParams({
      params: {},
    });

    const spy = sinon.spy(TempDir.prototype, 'path');

    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const usedTempPath = spy.returnValues[2];

    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      userDataDir: usedTempPath,
    });

    await runnerInstance.exit();
    spy.restore();
  });

  it('does pass a user-data-dir flag to chrome', async () => withTempDir(
    async (tmpDir) => {

      const {params} = prepareExtensionRunnerParams({
        params: {
          chromiumProfile: tmpDir.path(),
        },
      });

      const spy = sinon.spy(TempDir.prototype, 'path');

      const runnerInstance = new ChromiumExtensionRunner(params);
      await runnerInstance.run();

      const usedTempPath = spy.returnValues[2];

      const {reloadManagerExtension} = runnerInstance;

      sinon.assert.calledOnce(params.chromiumLaunch);
      sinon.assert.calledWithMatch(params.chromiumLaunch, {
        ignoreDefaultFlags: true,
        enableExtensions: true,
        chromePath: undefined,
        userDataDir: usedTempPath,
        chromeFlags: [
          ...DEFAULT_CHROME_FLAGS,
          `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
        ],
        startingUrl: undefined,
      });

      await runnerInstance.exit();
      spy.restore();
    })
  );

  it('does pass existing user-data-dir and profile-directory flag' +
    ' to chrome', async () => withTempDir(
    async (tmpDir) => {
      const tmpPath = tmpDir.path();
      await fs.mkdirs(path.join(tmpPath, 'userDataDir/Default'));
      await fs.outputFile(path.join(tmpPath, 'userDataDir/Local State'), '');
      await fs.mkdirs(path.join(tmpPath, 'userDataDir/profile'));
      await fs.outputFile(path.join(
        tmpPath, 'userDataDir/profile/Secure Preferences'), '');

      const {params} = prepareExtensionRunnerParams({
        params: {
          chromiumProfile: path.join(tmpPath, 'userDataDir/profile'),
          keepProfileChanges: true,
        },
      });

      const runnerInstance = new ChromiumExtensionRunner(params);
      await runnerInstance.run();

      const {reloadManagerExtension} = runnerInstance;

      sinon.assert.calledOnce(params.chromiumLaunch);
      sinon.assert.calledWithMatch(params.chromiumLaunch, {
        ignoreDefaultFlags: true,
        enableExtensions: true,
        chromePath: undefined,
        userDataDir: path.join(tmpPath, 'userDataDir'),
        chromeFlags: [
          ...DEFAULT_CHROME_FLAGS,
          `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
          '--profile-directory=profile',
        ],
        startingUrl: undefined,
      });

      await runnerInstance.exit();

    })
  );

  it('does support some special chars in profile-directory flag',
     async () => withTempDir(
       async (tmpDir) => {
         const tmpPath = tmpDir.path();
         // supported to test: [ _-]
         // not supported by Chromium: [ßäé]
         const profileDirName = ' profile _-\' ';
         await fs.mkdirs(path.join(tmpPath, 'userDataDir/Default'));
         await fs.outputFile(path.join(tmpPath, 'userDataDir/Local State'), '');
         await fs.mkdirs(path.join(tmpPath, 'userDataDir', profileDirName));
         await fs.outputFile(path.join(
           tmpPath, 'userDataDir', profileDirName, 'Secure Preferences'), '');

         const {params} = prepareExtensionRunnerParams({
           params: {
             chromiumProfile: path.join(tmpPath, 'userDataDir', profileDirName),
             keepProfileChanges: true,
           },
         });

         const runnerInstance = new ChromiumExtensionRunner(params);
         await runnerInstance.run();

         const {reloadManagerExtension} = runnerInstance;

         sinon.assert.calledOnce(params.chromiumLaunch);
         sinon.assert.calledWithMatch(params.chromiumLaunch, {
           ignoreDefaultFlags: true,
           enableExtensions: true,
           chromePath: undefined,
           userDataDir: path.join(tmpPath, 'userDataDir'),
           chromeFlags: [
             ...DEFAULT_CHROME_FLAGS,
             `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
             `--profile-directory=${profileDirName}`,
           ],
           startingUrl: undefined,
         });

         await runnerInstance.exit();

       })
  );

  it('does recognize a UserData dir', async () => withTempDir(
    async (tmpDir) => {

      const tmpPath = tmpDir.path();
      await fs.mkdirs(path.join(tmpPath, 'Default'));
      await fs.outputFile(path.join(tmpPath, 'Local State'), '');

      assert.isTrue(await ChromiumExtensionRunner.isUserDataDir(tmpPath));

    }),
  );

  it('does reject a UserData dir with Local State dir', async () => withTempDir(
    async (tmpDir) => {

      const tmpPath = tmpDir.path();
      await fs.mkdirs(path.join(tmpPath, 'Default'));
      // Local State should be a file
      await fs.mkdirs(path.join(tmpPath, 'Local State'));

      assert.isFalse(await ChromiumExtensionRunner.isUserDataDir(tmpPath));

    }),
  );

  it('does reject a UserData dir with Default file', async () => withTempDir(
    async (tmpDir) => {

      const tmpPath = tmpDir.path();
      await fs.mkdirs(path.join(tmpPath, 'Local State'));
      // Default should be a directory
      await fs.outputFile(path.join(tmpPath, 'Default'), '');

      assert.isFalse(await ChromiumExtensionRunner.isUserDataDir(tmpPath));

    }),
  );

  it('throws an error on profile in invalid user-data-dir',
     async () => withTempDir(async (tmpDir) => {
       const tmpPath = tmpDir.path();
       await fs.mkdirs(
         path.join(tmpPath, 'userDataDir/profile'));
       // the userDataDir is missing a file Local State to be validated as such
       await fs.outputFile(path.join(
         tmpPath, 'userDataDir/profile/Secure Preferences'), '');

       const {params} = prepareExtensionRunnerParams({
         params: {
           chromiumProfile: path.join(tmpPath, 'userDataDir/profile'),
           keepProfileChanges: true,
         },
       });

       const runnerInstance = new ChromiumExtensionRunner(params);

       await assert.isRejected(runnerInstance.run(), /not in a user-data-dir/);

       await runnerInstance.exit();

     })
  );

  it('does copy the profile and pass user-data-dir and profile-directory' +
    ' flags', async () => withTempDir(async (tmpDir) => {

    const tmpPath = tmpDir.path();
    await fs.mkdirs(
      path.join(tmpPath, 'userDataDir/profile'));
    await fs.outputFile(path.join(
      tmpPath, 'userDataDir/profile/Secure Preferences'), '');

    const {params} = prepareExtensionRunnerParams({
      params: {
        chromiumProfile: path.join(tmpPath, 'userDataDir/profile'),
      },
    });

    const spy = sinon.spy(TempDir.prototype, 'path');

    const runnerInstance = new ChromiumExtensionRunner(params);
    await runnerInstance.run();

    const usedTempPath = spy.returnValues[2];

    const {reloadManagerExtension} = runnerInstance;

    sinon.assert.calledOnce(params.chromiumLaunch);
    sinon.assert.calledWithMatch(params.chromiumLaunch, {
      ignoreDefaultFlags: true,
      enableExtensions: true,
      chromePath: undefined,
      userDataDir: usedTempPath,
      chromeFlags: [
        ...DEFAULT_CHROME_FLAGS,
        `--load-extension=${reloadManagerExtension},/fake/sourceDir`,
        '--profile-directory=profile',
      ],
      startingUrl: undefined,
    });

    assert.isTrue(await isDirectory(path.join(usedTempPath, 'profile')));
    assert.isTrue(await fileExists(path.join(
      usedTempPath, 'profile/Secure Preferences')));

    await runnerInstance.exit();
    spy.restore();
  })
  );

  describe('reloadAllExtensions', () => {
    let runnerInstance: ChromiumExtensionRunner;
    let wsClient: WebSocket;

    beforeEach(async () => {
      const {params} = prepareExtensionRunnerParams();
      runnerInstance = new ChromiumExtensionRunner(params);
      await runnerInstance.run();
    });

    const connectClient = async () => {
      if (!runnerInstance.wss) {
        throw new Error('WebSocker server is not running');
      }
      // $FlowIgnore: if runnerInstance.wss would be unexpectedly undefined the test case will fail.
      const wssInfo = runnerInstance.wss.address();
      const wsURL = `ws://${wssInfo.address}:${wssInfo.port}`;
      wsClient = new WebSocket(wsURL);
      await new Promise((resolve) => wsClient.on('open', resolve));
    };

    afterEach(async () => {
      if (wsClient && (wsClient.readyState === WebSocket.OPEN)) {
        wsClient.close();
        // $FlowIgnore: allow to nullify wsClient even if wsClient signature doesn't allow it.
        wsClient = null;
      }
      await runnerInstance.exit();
    });

    it('does not resolve before complete message from client', async () => {
      let reloadMessage = false;
      await connectClient();

      wsClient.on('message', (message) => {
        const msg = JSON.parse(message);

        if (msg.type === 'webExtReloadAllExtensions') {
          assert.equal(reloadMessage, false);

          setTimeout(() => {
            const respondMsg = JSON.stringify({
              type: 'webExtReloadExtensionComplete',
            });
            wsClient.send(respondMsg);
            reloadMessage = true;
          }, 333);
        }
      });

      await runnerInstance.reloadAllExtensions();
      assert.equal(reloadMessage, true);
    });

    it('resolve when any client send complete message', async () => {
      await connectClient();
      wsClient.on('message', () => {
        const msg = JSON.stringify({type: 'webExtReloadExtensionComplete'});
        wsClient.send(msg);
      });
      await runnerInstance.reloadAllExtensions();
    });

    it('resolve when all client disconnect', async () => {
      await connectClient();
      await new Promise((resolve) => {
        wsClient.on('close', () => {
          resolve(runnerInstance.reloadAllExtensions());
        });
        wsClient.close();
      });
      // $FlowIgnore: allow to nullify wsClient even if wsClient signature doesn't allow it.
      wsClient = null;
    });

    it('resolve when not client connected', async () => {
      await runnerInstance.reloadAllExtensions();
    });
  });
});
