import { execSync } from 'child_process';
import { Module } from 'module';

import type { Tree } from 'nx/src/generators/tree';

import type { GeneratorCallback } from 'nx/src/config/misc-interfaces';
import { clean, coerce, gt } from 'semver';

import { installPackagesTask } from '../tasks/install-packages-task';
import { requireNx } from '../../nx';
import { dirSync } from 'tmp';
import { join } from 'path';
import type { PackageManager } from 'nx/src/utils/package-manager';
import { writeFileSync } from 'fs';

const {
  readJson,
  updateJson,
  getPackageManagerCommand,
  workspaceRoot,
  detectPackageManager,
  createTempNpmDirectory,
  getPackageManagerVersion,
} = requireNx();

const UNIDENTIFIED_VERSION = 'UNIDENTIFIED_VERSION';
const NON_SEMVER_TAGS = {
  '*': 2,
  [UNIDENTIFIED_VERSION]: 2,
  next: 1,
  latest: 0,
  previous: -1,
  legacy: -2,
};

function filterExistingDependencies(
  dependencies: Record<string, string>,
  existingAltDependencies: Record<string, string>
) {
  if (!existingAltDependencies) {
    return dependencies;
  }

  return Object.keys(dependencies ?? {})
    .filter((d) => !existingAltDependencies[d])
    .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}

function cleanSemver(version: string) {
  return clean(version) ?? coerce(version);
}

function isIncomingVersionGreater(
  incomingVersion: string,
  existingVersion: string
) {
  // if version is in the format of "latest", "next" or similar - keep it, otherwise try to parse it
  const incomingVersionCompareBy =
    incomingVersion in NON_SEMVER_TAGS
      ? incomingVersion
      : cleanSemver(incomingVersion)?.toString() ?? UNIDENTIFIED_VERSION;
  const existingVersionCompareBy =
    existingVersion in NON_SEMVER_TAGS
      ? existingVersion
      : cleanSemver(existingVersion)?.toString() ?? UNIDENTIFIED_VERSION;

  if (
    incomingVersionCompareBy in NON_SEMVER_TAGS &&
    existingVersionCompareBy in NON_SEMVER_TAGS
  ) {
    return (
      NON_SEMVER_TAGS[incomingVersionCompareBy] >
      NON_SEMVER_TAGS[existingVersionCompareBy]
    );
  }

  if (
    incomingVersionCompareBy in NON_SEMVER_TAGS ||
    existingVersionCompareBy in NON_SEMVER_TAGS
  ) {
    return true;
  }

  return gt(cleanSemver(incomingVersion), cleanSemver(existingVersion));
}

function updateExistingAltDependenciesVersion(
  dependencies: Record<string, string>,
  existingAltDependencies: Record<string, string>
) {
  return Object.keys(existingAltDependencies || {})
    .filter((d) => {
      if (!dependencies[d]) {
        return false;
      }

      const incomingVersion = dependencies[d];
      const existingVersion = existingAltDependencies[d];
      return isIncomingVersionGreater(incomingVersion, existingVersion);
    })
    .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}

function updateExistingDependenciesVersion(
  dependencies: Record<string, string>,
  existingDependencies: Record<string, string> = {}
) {
  return Object.keys(dependencies)
    .filter((d) => {
      if (!existingDependencies[d]) {
        return true;
      }

      const incomingVersion = dependencies[d];
      const existingVersion = existingDependencies[d];

      return isIncomingVersionGreater(incomingVersion, existingVersion);
    })
    .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {});
}

/**
 * Add Dependencies and Dev Dependencies to package.json
 *
 * For example:
 * ```typescript
 * addDependenciesToPackageJson(tree, { react: 'latest' }, { jest: 'latest' })
 * ```
 * This will **add** `react` and `jest` to the dependencies and devDependencies sections of package.json respectively.
 *
 * @param tree Tree representing file system to modify
 * @param dependencies Dependencies to be added to the dependencies section of package.json
 * @param devDependencies Dependencies to be added to the devDependencies section of package.json
 * @param packageJsonPath Path to package.json
 * @returns Callback to install dependencies only if necessary, no-op otherwise
 */
export function addDependenciesToPackageJson(
  tree: Tree,
  dependencies: Record<string, string>,
  devDependencies: Record<string, string>,
  packageJsonPath: string = 'package.json'
): GeneratorCallback {
  const currentPackageJson = readJson(tree, packageJsonPath);

  /** Dependencies to install that are not met in dev dependencies */
  let filteredDependencies = filterExistingDependencies(
    dependencies,
    currentPackageJson.devDependencies
  );
  /** Dev dependencies to install that are not met in dependencies */
  let filteredDevDependencies = filterExistingDependencies(
    devDependencies,
    currentPackageJson.dependencies
  );

  // filtered dependencies should consist of:
  // - dependencies of the same type that are not present
  // - dependencies of the same type that have greater version
  // - specified dependencies of the other type that have greater version and are already installed as current type
  filteredDependencies = {
    ...updateExistingDependenciesVersion(
      filteredDependencies,
      currentPackageJson.dependencies
    ),
    ...updateExistingAltDependenciesVersion(
      devDependencies,
      currentPackageJson.dependencies
    ),
  };
  filteredDevDependencies = {
    ...updateExistingDependenciesVersion(
      filteredDevDependencies,
      currentPackageJson.devDependencies
    ),
    ...updateExistingAltDependenciesVersion(
      dependencies,
      currentPackageJson.devDependencies
    ),
  };

  filteredDependencies = removeLowerVersions(
    filteredDependencies,
    currentPackageJson.dependencies
  );
  filteredDevDependencies = removeLowerVersions(
    filteredDevDependencies,
    currentPackageJson.devDependencies
  );

  if (
    requiresAddingOfPackages(
      currentPackageJson,
      filteredDependencies,
      filteredDevDependencies
    )
  ) {
    updateJson(tree, packageJsonPath, (json) => {
      json.dependencies = {
        ...(json.dependencies || {}),
        ...filteredDependencies,
      };

      json.devDependencies = {
        ...(json.devDependencies || {}),
        ...filteredDevDependencies,
      };

      json.dependencies = sortObjectByKeys(json.dependencies);
      json.devDependencies = sortObjectByKeys(json.devDependencies);

      return json;
    });

    return (): void => {
      installPackagesTask(tree);
    };
  }
  return () => {};
}

/**
 * @returns The the incoming dependencies that are higher than the existing verions
 **/
function removeLowerVersions(
  incomingDeps: Record<string, string>,
  existingDeps: Record<string, string>
) {
  return Object.keys(incomingDeps).reduce((acc, d) => {
    if (
      existingDeps?.[d] &&
      !isIncomingVersionGreater(incomingDeps[d], existingDeps[d])
    ) {
      return acc;
    }
    return { ...acc, [d]: incomingDeps[d] };
  }, {});
}

/**
 * Remove Dependencies and Dev Dependencies from package.json
 *
 * For example:
 * ```typescript
 * removeDependenciesFromPackageJson(tree, ['react'], ['jest'])
 * ```
 * This will **remove** `react` and `jest` from the dependencies and devDependencies sections of package.json respectively.
 *
 * @param dependencies Dependencies to be removed from the dependencies section of package.json
 * @param devDependencies Dependencies to be removed from the devDependencies section of package.json
 * @returns Callback to uninstall dependencies only if necessary. undefined is returned if changes are not necessary.
 */
export function removeDependenciesFromPackageJson(
  tree: Tree,
  dependencies: string[],
  devDependencies: string[],
  packageJsonPath: string = 'package.json'
): GeneratorCallback {
  const currentPackageJson = readJson(tree, packageJsonPath);

  if (
    requiresRemovingOfPackages(
      currentPackageJson,
      dependencies,
      devDependencies
    )
  ) {
    updateJson(tree, packageJsonPath, (json) => {
      for (const dep of dependencies) {
        delete json.dependencies[dep];
      }
      for (const devDep of devDependencies) {
        delete json.devDependencies[devDep];
      }
      json.dependencies = sortObjectByKeys(json.dependencies);
      json.devDependencies = sortObjectByKeys(json.devDependencies);

      return json;
    });
  }
  return (): void => {
    installPackagesTask(tree);
  };
}

function sortObjectByKeys<T>(obj: T): T {
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
    return obj;
  }
  return Object.keys(obj)
    .sort()
    .reduce((result, key) => {
      return {
        ...result,
        [key]: obj[key],
      };
    }, {}) as T;
}

/**
 * Verifies whether the given packageJson dependencies require an update
 * given the deps & devDeps passed in
 */
function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean {
  let needsDepsUpdate = false;
  let needsDevDepsUpdate = false;

  packageJsonFile.dependencies = packageJsonFile.dependencies || {};
  packageJsonFile.devDependencies = packageJsonFile.devDependencies || {};

  if (Object.keys(deps).length > 0) {
    needsDepsUpdate = Object.keys(deps).some((entry) => {
      const incomingVersion = deps[entry];
      if (packageJsonFile.dependencies[entry]) {
        const existingVersion = packageJsonFile.dependencies[entry];
        return isIncomingVersionGreater(incomingVersion, existingVersion);
      }

      if (packageJsonFile.devDependencies[entry]) {
        const existingVersion = packageJsonFile.devDependencies[entry];
        return isIncomingVersionGreater(incomingVersion, existingVersion);
      }

      return true;
    });
  }

  if (Object.keys(devDeps).length > 0) {
    needsDevDepsUpdate = Object.keys(devDeps).some((entry) => {
      const incomingVersion = devDeps[entry];
      if (packageJsonFile.devDependencies[entry]) {
        const existingVersion = packageJsonFile.devDependencies[entry];
        return isIncomingVersionGreater(incomingVersion, existingVersion);
      }
      if (packageJsonFile.dependencies[entry]) {
        const existingVersion = packageJsonFile.dependencies[entry];

        return isIncomingVersionGreater(incomingVersion, existingVersion);
      }

      return true;
    });
  }

  return needsDepsUpdate || needsDevDepsUpdate;
}

/**
 * Verifies whether the given packageJson dependencies require an update
 * given the deps & devDeps passed in
 */
function requiresRemovingOfPackages(
  packageJsonFile,
  deps: string[],
  devDeps: string[]
): boolean {
  let needsDepsUpdate = false;
  let needsDevDepsUpdate = false;

  packageJsonFile.dependencies = packageJsonFile.dependencies || {};
  packageJsonFile.devDependencies = packageJsonFile.devDependencies || {};

  if (deps.length > 0) {
    needsDepsUpdate = deps.some((entry) => packageJsonFile.dependencies[entry]);
  }

  if (devDeps.length > 0) {
    needsDevDepsUpdate = devDeps.some(
      (entry) => packageJsonFile.devDependencies[entry]
    );
  }

  return needsDepsUpdate || needsDevDepsUpdate;
}

const packageMapCache = new Map<string, any>();

/**
 * @typedef EnsurePackageOptions
 * @type {object}
 * @property {boolean} dev indicate if the package is a dev dependency
 * @property {throwOnMissing} boolean throws an error when the package is missing
 */

/**
 * @deprecated Use the other function signature without a Tree
 *
 * Use a package that has not been installed as a dependency.
 *
 * For example:
 * ```typescript
 * ensurePackage(tree, '@nx/jest', nxVersion)
 * ```
 * This install the @nx/jest@<nxVersion> and return the module
 * When running with --dryRun, the function will throw when dependencies are missing.
 * Returns null for ESM dependencies. Import them with a dynamic import instead.
 *
 * @param tree the file system tree
 * @param pkg the package to check (e.g. @nx/jest)
 * @param requiredVersion the version or semver range to check (e.g. ~1.0.0, >=1.0.0 <2.0.0)
 * @param {EnsurePackageOptions} options?
 */
export function ensurePackage(
  tree: Tree,
  pkg: string,
  requiredVersion: string,
  options?: { dev?: boolean; throwOnMissing?: boolean }
): void;

/**
 * Ensure that dependencies and devDependencies from package.json are installed at the required versions.
 * Returns null for ESM dependencies. Import them with a dynamic import instead.
 *
 * For example:
 * ```typescript
 * ensurePackage('@nx/jest', nxVersion)
 * ```
 *
 * @param pkg the package to install and require
 * @param version the version to install if the package doesn't exist already
 */
export function ensurePackage<T extends any = any>(
  pkg: string,
  version: string
): T;
export function ensurePackage<T extends any = any>(
  pkgOrTree: string | Tree,
  requiredVersionOrPackage: string,
  maybeRequiredVersion?: string,
  _?: never
): T {
  let pkg: string;
  let requiredVersion: string;
  if (typeof pkgOrTree === 'string') {
    pkg = pkgOrTree;
    requiredVersion = requiredVersionOrPackage;
  } else {
    // Old Signature
    pkg = requiredVersionOrPackage;
    requiredVersion = maybeRequiredVersion;
  }

  if (packageMapCache.has(pkg)) {
    return packageMapCache.get(pkg) as T;
  }

  try {
    return require(pkg);
  } catch (e) {
    if (e.code === 'ERR_REQUIRE_ESM') {
      // The package is installed, but is an ESM package.
      // The consumer of this function can import it as needed.
      return null;
    } else if (e.code !== 'MODULE_NOT_FOUND') {
      throw e;
    }
  }

  if (process.env.NX_DRY_RUN && process.env.NX_DRY_RUN !== 'false') {
    throw new Error(
      'NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the "Run" button.\n'
    );
  }

  const { dir: tempDir } = createTempNpmDirectory?.() ?? {
    dir: dirSync().name,
  };

  console.log(`Fetching ${pkg}...`);
  const packageManager = detectPackageManager();
  const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true';
  generatePackageManagerFiles(tempDir, packageManager);
  const preInstallCommand = getPackageManagerCommand(packageManager).preInstall;
  if (preInstallCommand) {
    // ensure package.json and repo in tmp folder is set to a proper package manager state
    execSync(preInstallCommand, {
      cwd: tempDir,
      stdio: isVerbose ? 'inherit' : 'ignore',
    });
  }
  let addCommand = getPackageManagerCommand(packageManager).addDev;
  if (packageManager === 'pnpm') {
    addCommand = 'pnpm add -D'; // we need to ensure that we are not using workspace command
  }

  execSync(`${addCommand} ${pkg}@${requiredVersion}`, {
    cwd: tempDir,
    stdio: isVerbose ? 'inherit' : 'ignore',
  });

  addToNodePath(join(workspaceRoot, 'node_modules'));
  addToNodePath(join(tempDir, 'node_modules'));

  // Re-initialize the added paths into require
  (Module as any)._initPaths();

  try {
    const result = require(require.resolve(pkg, {
      paths: [tempDir],
    }));

    packageMapCache.set(pkg, result);

    return result;
  } catch (e) {
    if (e.code === 'ERR_REQUIRE_ESM') {
      // The package is installed, but is an ESM package.
      // The consumer of this function can import it as needed.
      packageMapCache.set(pkg, null);
      return null;
    }
    throw e;
  }
}

/**
 * Generates necessary files needed for the package manager to work
 * and for the node_modules to be accessible.
 */
function generatePackageManagerFiles(
  root: string,
  packageManager: PackageManager = detectPackageManager()
) {
  const [pmMajor] = getPackageManagerVersion(packageManager).split('.');
  switch (packageManager) {
    case 'yarn':
      if (+pmMajor >= 2) {
        writeFileSync(
          join(root, '.yarnrc.yml'),
          'nodeLinker: node-modules\nenableScripts: false'
        );
      }
      break;
  }
}

function addToNodePath(dir: string) {
  // NODE_PATH is a delimited list of paths.
  // The delimiter is different for windows.
  const delimiter = require('os').platform() === 'win32' ? ';' : ':';

  const paths = process.env.NODE_PATH
    ? process.env.NODE_PATH.split(delimiter)
    : [];

  // The path is already in the node path
  if (paths.includes(dir)) {
    return;
  }

  // Add the tmp path
  paths.push(dir);

  // Update the env variable.
  process.env.NODE_PATH = paths.join(delimiter);
}

function getPackageVersion(pkg: string): string {
  return require(join(pkg, 'package.json')).version;
}

/**
 * @description The version of Nx used by the workspace. Returns null if no version is found.
 */
export const NX_VERSION = getPackageVersion('nx');
