/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 */

import fs from 'fs-extra';
import path from 'path';
import tmp from 'tmp';
import {promisify} from 'util';
import {default as axios} from 'axios';
import {
  DownloadablePluginDetails,
  ExecuteMessage,
  FlipperServerForServerAddOn,
  InstalledPluginDetails,
  PluginSource,
  ServerAddOnStartDetails,
} from 'flipper-common';

import {loadDynamicPlugins} from './loadDynamicPlugins';
import {
  cleanupOldInstalledPluginVersions,
  getInstalledPluginDetails,
  getInstalledPlugins,
  getPluginVersionInstallationDir,
  getPluginDirNameFromPackageName,
  installPluginFromFileOrBuffer,
  removePlugins,
  getUpdatablePlugins,
  getInstalledPlugin,
  installPluginFromNpm,
} from 'flipper-plugin-lib';
import {ServerAddOnManager} from './ServerAddManager';
import {loadMarketplacePlugins} from './loadMarketplacePlugins';

const maxInstalledPluginVersionsToKeep = 2;

// Adapter which forces node.js implementation for axios instead of browser implementation
// used by default in Electron. Node.js implementation is better, because it
// supports streams which can be used for direct downloading to disk.
const axiosHttpAdapter = require('axios/lib/adapters/http'); // eslint-disable-line import/no-commonjs

const getTempDirName = promisify(tmp.dir) as (
  options?: tmp.DirOptions,
) => Promise<string>;

const isExecuteMessage = (message: object): message is ExecuteMessage =>
  (message as ExecuteMessage).method === 'execute';

export class PluginManager {
  public readonly serverAddOns = new Map<string, ServerAddOnManager>();

  constructor(private readonly flipperServer: FlipperServerForServerAddOn) {}

  async start() {
    // This needn't happen immediately and is (light) I/O work.
    setTimeout(() => {
      cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep).catch(
        (err) =>
          console.error('Failed to clean up old installed plugins:', err),
      );
    }, 100);
  }

  loadDynamicPlugins = loadDynamicPlugins;
  getInstalledPlugins = getInstalledPlugins;
  removePlugins = removePlugins;
  getUpdatablePlugins = getUpdatablePlugins;
  getInstalledPlugin = getInstalledPlugin;
  installPluginFromFileOrBuffer = installPluginFromFileOrBuffer;
  installPluginFromNpm = installPluginFromNpm;

  async loadSource(path: string): Promise<PluginSource> {
    const js = await fs.readFile(path, 'utf8');

    /**
     * Check if the plugin includes a bundled css. If so,
     * load its content too.
     */
    let css = undefined;
    const idx = path.lastIndexOf('.');
    const cssPath = path.substring(0, idx < 0 ? path.length : idx) + '.css';
    try {
      await fs.promises.access(cssPath);

      const buffer = await fs.promises.readFile(cssPath, {encoding: 'utf-8'});
      css = buffer.toString();
    } catch (e) {}

    return {
      js,
      css,
    };
  }

  async loadMarketplacePlugins() {
    console.info('Load available plugins from marketplace');
    return loadMarketplacePlugins(this.flipperServer, '');
  }

  async installPluginForMarketplace(name: string) {
    console.info(`Install plugin '${name}' from marketplace`);

    const plugins = await this.loadMarketplacePlugins();
    const plugin = plugins.find((p) => p.id === name);
    if (plugin) {
      console.info(`Plugin '${name}' is available, attempt to install`);
      try {
        return await this.downloadPlugin(plugin);
      } catch (e) {
        console.warn(`Unable to install plugin '${name}'. Error:`, e);
      }
    } else {
      console.info('Plugin not available in marketplace');
    }

    throw new Error(`Unable to install plugin '${name}' from marketplace`);
  }

  async downloadPlugin(
    plugin: DownloadablePluginDetails,
  ): Promise<InstalledPluginDetails> {
    const {name, title, version, downloadUrl} = plugin;
    const installationDir = getPluginVersionInstallationDir(name, version);
    console.log(
      `Downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
    );
    const tmpDir = await getTempDirName();
    const tmpFile = path.join(
      tmpDir,
      `${getPluginDirNameFromPackageName(name)}-${version}.tgz`,
    );
    try {
      const cancelationSource = axios.CancelToken.source();
      if (await fs.pathExists(installationDir)) {
        console.log(
          `Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}"`,
        );
        return await getInstalledPluginDetails(installationDir);
      } else {
        await fs.ensureDir(tmpDir);
        let percentCompleted = 0;
        const response = await axios.get(plugin.downloadUrl, {
          adapter: axiosHttpAdapter,
          cancelToken: cancelationSource.token,
          responseType: 'stream',
          headers: {
            'Sec-Fetch-Site': 'none',
            'Sec-Fetch-Mode': 'navigate',
          },
          onDownloadProgress: async (progressEvent) => {
            const newPercentCompleted = !progressEvent.total
              ? 0
              : Math.round((progressEvent.loaded * 100) / progressEvent.total);
            if (newPercentCompleted - percentCompleted >= 20) {
              percentCompleted = newPercentCompleted;
              console.log(
                `Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`,
              );
            }
          },
        });
        function parseHeaderValue(header: string) {
          const values = header.split(';');
          // remove white space
          return values.map((value) => value.trim());
        }

        if (
          !parseHeaderValue(response.headers['content-type']).includes(
            'application/octet-stream',
          )
        ) {
          throw new Error(
            `It looks like you are not on VPN/Lighthouse. Unexpected content type received: ${response.headers['content-type']}.`,
          );
        }
        const responseStream = response.data as fs.ReadStream;
        const writeStream = responseStream.pipe(
          fs.createWriteStream(tmpFile, {autoClose: true}),
        );
        await new Promise((resolve, reject) =>
          writeStream.once('finish', resolve).once('error', reject),
        );
        return await installPluginFromFileOrBuffer(tmpFile);
      }
    } catch (error) {
      console.warn(
        `Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
        error,
      );
      throw error;
    } finally {
      await fs.remove(tmpDir);
    }
  }

  getServerAddOnForMessage(message: object) {
    if (!isExecuteMessage(message)) {
      throw new Error(
        `PluginManager.getServerAddOnForMessage supports only "execute" messages. Received ${JSON.stringify(
          message,
        )}`,
      );
    }
    return this.serverAddOns.get(message.params.api);
  }

  async startServerAddOn(
    pluginName: string,
    details: ServerAddOnStartDetails,
    owner: string,
  ): Promise<void> {
    console.debug('PluginManager.startServerAddOn', pluginName);
    const existingServerAddOn = this.serverAddOns.get(pluginName);

    if (existingServerAddOn) {
      if (existingServerAddOn.state.is('stopping')) {
        console.debug(
          'PluginManager.startServerAddOn -> currently stropping',
          pluginName,
          owner,
          existingServerAddOn.state.currentState,
        );
        await existingServerAddOn.state.wait(['inactive', 'zombie']);
        return this.startServerAddOn(pluginName, details, owner);
      }

      console.debug(
        'PluginManager.startServerAddOn -> already started',
        pluginName,
        owner,
        existingServerAddOn.state.currentState,
      );
      await existingServerAddOn.addOwner(owner);
      return;
    }

    const newServerAddOn = new ServerAddOnManager(
      pluginName,
      details,
      owner,
      this.flipperServer,
    );
    this.serverAddOns.set(pluginName, newServerAddOn);

    newServerAddOn.state.once(['fatal', 'zombie', 'inactive'], () => {
      this.serverAddOns.delete(pluginName);
    });

    await newServerAddOn.state.wait(['active', 'fatal']);

    if (newServerAddOn.state.is('fatal')) {
      this.serverAddOns.delete(pluginName);
      throw newServerAddOn.state.error;
    }
  }

  async stopServerAddOn(pluginName: string, owner: string): Promise<void> {
    console.debug('PluginManager.stopServerAddOn', pluginName);
    const serverAddOn = this.serverAddOns.get(pluginName);

    if (!serverAddOn) {
      console.warn('PluginManager.stopServerAddOn -> not started', pluginName);
      return;
    }

    try {
      await serverAddOn.removeOwner(owner);
    } catch (e) {
      console.error(
        'PluginManager.stopServerAddOn -> error',
        pluginName,
        owner,
        e,
      );
      this.serverAddOns.delete(pluginName);
      throw e;
    }
  }

  stopAllServerAddOns(owner: string) {
    console.debug('PluginManager.stopAllServerAddOns', owner);
    this.serverAddOns.forEach(async (serverAddOnPromise) => {
      try {
        const serverAddOn = await serverAddOnPromise;
        serverAddOn.removeOwner(owner);
      } catch (e) {
        // It is OK to use a debug level here because any failure would be logged in "stopServerAddOn"
        console.debug(
          'PluginManager.stopAllServerAddOns -> failed to remove owner',
          owner,
          e,
        );
      }
    });
  }
}
