/**
 * 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
 */

// Heavy use of nested promises but without sacrificing error propagation.
/* eslint-disable promise/no-nesting */

import path from 'path';
import fs from 'fs-extra';
import {promisify} from 'util';
import {PluginManager as PM} from 'live-plugin-manager';
import decompress from 'decompress';
import decompressTargz from 'decompress-targz';
import decompressUnzip from 'decompress-unzip';
import tmp from 'tmp';
import {InstalledPluginDetails} from 'flipper-common';
import {getInstalledPluginDetails, isPluginDir} from './getPluginDetails';
import {
  getPluginVersionInstallationDir,
  getPluginInstallationDir,
  pluginInstallationDir,
  legacyPluginInstallationDir,
} from './pluginPaths';
import pfilter from 'p-filter';
import pmap from 'p-map';
import semver from 'semver';
import {notNull} from './typeUtils';

function providePluginManagerNoDependencies(): PM {
  return new PM({ignoredDependencies: [/.*/]});
}

async function installPluginFromTempDir(
  sourceDir: string,
): Promise<InstalledPluginDetails> {
  const pluginDetails = await getInstalledPluginDetails(sourceDir);
  const {name, version} = pluginDetails;
  const backupDir = path.join(await promisify(tmp.dir)(), `${name}-${version}`);
  const destinationDir = getPluginVersionInstallationDir(name, version);

  if (pluginDetails.specVersion == 1) {
    throw new Error(
      `Cannot install plugin ${pluginDetails.name} because it is packaged using the unsupported format v1. Please encourage the plugin author to update to v2, following the instructions on https://fbflipper.com/docs/extending/js-setup#migration-to-the-new-plugin-specification`,
    );
  }

  try {
    // Moving the existing destination dir to backup
    if (await fs.pathExists(destinationDir)) {
      await fs.move(destinationDir, backupDir, {overwrite: true});
    }
    await fs.move(sourceDir, destinationDir);
  } catch (err) {
    // Restore previous version from backup if installation failed
    await fs.remove(destinationDir);
    if (await fs.pathExists(backupDir)) {
      await fs.move(backupDir, destinationDir, {overwrite: true});
    }
    throw err;
  }
  return await getInstalledPluginDetails(destinationDir);
}

async function getPluginRootDir(dir: string) {
  // npm packages are tar.gz archives containing folder 'package' inside
  const packageDir = path.join(dir, 'package');
  const isNpmPackage = await fs.pathExists(packageDir);

  // vsix packages are zip archives containing folder 'extension' inside
  const extensionDir = path.join(dir, 'extension');
  const isVsix = await fs.pathExists(extensionDir);

  if (!isNpmPackage && !isVsix) {
    throw new Error(
      'Package format is invalid: directory "package" or "extensions" not found in the archive root',
    );
  }

  return isNpmPackage ? packageDir : extensionDir;
}

export async function getInstalledPlugin(
  name: string,
  version: string,
): Promise<InstalledPluginDetails | null> {
  const dir = getPluginVersionInstallationDir(name, version);
  if (!(await fs.pathExists(dir))) {
    return null;
  }
  return await getInstalledPluginDetails(dir);
}

export async function installPluginFromNpm(name: string) {
  const tmpDir = await promisify(tmp.dir)();
  try {
    await fs.ensureDir(tmpDir);
    const plugManNoDep = providePluginManagerNoDependencies();
    plugManNoDep.options.pluginsPath = tmpDir;
    const pluginInfo = await plugManNoDep.install(name);
    return await installPluginFromTempDir(pluginInfo.location);
  } finally {
    await fs.remove(tmpDir);
  }
}

export async function installPluginFromFileOrBuffer(
  packagePath: string | Buffer,
): Promise<InstalledPluginDetails> {
  const tmpDir = await promisify(tmp.dir)();
  try {
    const files = await decompress(packagePath, tmpDir, {
      plugins: [decompressTargz(), decompressUnzip()],
    });
    if (!files.length) {
      throw new Error('The package is not in tar.gz format or is empty');
    }
    const pluginDir = await getPluginRootDir(tmpDir);
    return await installPluginFromTempDir(pluginDir);
  } finally {
    await fs.remove(tmpDir);
  }
}

export async function removePlugin(name: string): Promise<void> {
  await fs.remove(getPluginInstallationDir(name));
}

export async function removePlugins(names: Array<string>): Promise<void> {
  await pmap(names, (name) => removePlugin(name));
}

export async function getAllInstalledPluginVersions(): Promise<
  InstalledPluginDetails[]
> {
  const pluginDirs = await getInstalledPluginVersionDirs();
  const versionDirs = pluginDirs.map(([_, versionDirs]) => versionDirs).flat();
  return await pmap(versionDirs, (versionDir) =>
    getInstalledPluginDetails(versionDir).catch((err) => {
      console.error(`Failed to load plugin details from ${versionDir}`, err);
      return null;
    }),
  ).then((versionDetails) => versionDetails.filter(notNull));
}

export async function getInstalledPlugins(): Promise<InstalledPluginDetails[]> {
  const versionDirs = await getInstalledPluginVersionDirs();
  return pmap(
    versionDirs
      .filter(([_, versionDirs]) => versionDirs.length > 0)
      .map(([_, versionDirs]) => versionDirs[0]),
    (latestVersionDir) =>
      getInstalledPluginDetails(latestVersionDir).catch((err) => {
        console.error(`Failed to load plugin from ${latestVersionDir}`, err);
        return null;
      }),
  ).then((plugins) => plugins.filter(notNull));
}

export async function cleanupOldInstalledPluginVersions(
  maxNumberOfVersionsToKeep: number,
): Promise<void> {
  const versionDirs = await getInstalledPluginVersionDirs();
  const versionDirsToDelete = versionDirs
    .map(([_, versionDirs]) => versionDirs.slice(maxNumberOfVersionsToKeep))
    .flat();
  await pmap(versionDirsToDelete, (versionDirToDelete) =>
    fs.remove(versionDirToDelete).catch(() => {}),
  );
}

// Before that we installed all plugins to "thirdparty" folder and only kept
// a single version for each of them. Now we install plugins to "installed-plugins"
// folder and keep multiple versions. This function checks if the legacy folder exists and
// moves all the plugins installed there to the new folder.
export async function moveInstalledPluginsFromLegacyDir() {
  if (await fs.pathExists(legacyPluginInstallationDir)) {
    await fs
      .readdir(legacyPluginInstallationDir)
      .then((dirs) =>
        dirs.map((dir) => path.join(legacyPluginInstallationDir, dir)),
      )
      .then((dirs) =>
        pfilter(dirs, (dir) =>
          fs
            .lstat(dir)
            .then((lstat) => lstat.isDirectory())
            .catch(() => false),
        ),
      )
      .then((dirs) =>
        pmap(dirs, (dir) =>
          getInstalledPluginDetails(dir).catch(async (err) => {
            console.error(
              `Failed to load plugin from ${dir} on moving legacy plugins. Removing it.`,
              err,
            );
            await fs.remove(dir);
            return null;
          }),
        ),
      )
      .then((plugins) =>
        pmap(plugins.filter(notNull), (plugin) =>
          fs.move(
            plugin.dir,
            getPluginVersionInstallationDir(plugin.name, plugin.version),
            {overwrite: true},
          ),
        ),
      );
    await fs.remove(legacyPluginInstallationDir);
  }
}

type InstalledPluginVersionDirs = [string, string[]][];

async function getInstalledPluginVersionDirs(): Promise<InstalledPluginVersionDirs> {
  if (!(await fs.pathExists(pluginInstallationDir))) {
    return [];
  }
  return await fs
    .readdir(pluginInstallationDir)
    .then((dirs) => dirs.map((dir) => path.join(pluginInstallationDir, dir)))
    .then((dirs) =>
      pfilter(dirs, (dir) =>
        fs
          .lstat(dir)
          .then((lstat) => lstat.isDirectory())
          .catch(() => false),
      ),
    )
    .then((dirs) =>
      pmap(dirs, (dir) =>
        fs
          .readdir(dir)
          .then((versionDirs) => versionDirs.filter((d) => semver.valid(d)))
          .then((versionDirs) =>
            versionDirs.sort((v1, v2) => semver.compare(v2, v1, true)),
          )
          .then((versionDirs) =>
            versionDirs.map((versionDir) => path.join(dir, versionDir)),
          )
          .then((versionDirs) =>
            pfilter(versionDirs, (versionDir) =>
              fs
                .lstat(versionDir)
                .then((lstat) => lstat.isDirectory())
                .catch(() => false),
            ),
          ),
      ).then((allDirs) =>
        allDirs.reduce<InstalledPluginVersionDirs>((agg, versionDirs, i) => {
          agg.push([dirs[i], versionDirs]);
          return agg;
        }, []),
      ),
    );
}

export async function getAllInstalledPluginsInDir(
  dir: string,
  recursive: boolean = false,
): Promise<InstalledPluginDetails[]> {
  const plugins: InstalledPluginDetails[] = [];
  if (!((await fs.pathExists(dir)) && (await fs.stat(dir)).isDirectory())) {
    console.log('defaultPlugins dir not found');
    return plugins;
  }
  const items = await fs.readdir(dir);
  await pmap(items, async (item) => {
    const fullPath = path.join(dir, item);
    if (await isPluginDir(fullPath)) {
      try {
        plugins.push(await getInstalledPluginDetails(fullPath));
      } catch (err) {
        console.error(`Failed to load plugin from ${fullPath}`);
      }
    } else if (recursive) {
      plugins.push(...(await getAllInstalledPluginsInDir(fullPath, recursive)));
    }
  });
  return plugins;
}
