/**
 * @license
 * Copyright Google Inc. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */
import { analytics, tags } from '@angular-devkit/core';
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
import { dirname, join } from 'path';
import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver';
import { PackageManager } from '../lib/config/schema';
import { isPackageNameSafeForAnalytics } from '../models/analytics';
import { Arguments } from '../models/interface';
import { RunSchematicOptions, SchematicCommand } from '../models/schematic-command';
import { installPackage, installTempPackage } from '../tasks/install-package';
import { colors } from '../utilities/color';
import { getPackageManager } from '../utilities/package-manager';
import {
  NgAddSaveDepedency,
  PackageManifest,
  fetchPackageManifest,
  fetchPackageMetadata,
} from '../utilities/package-metadata';
import { Schema as AddCommandSchema } from './add';

const npa = require('npm-package-arg');

export class AddCommand extends SchematicCommand<AddCommandSchema> {
  readonly allowPrivateSchematics = true;

  async initialize(options: AddCommandSchema & Arguments) {
    if (options.registry) {
      return super.initialize({ ...options, packageRegistry: options.registry });
    } else {
      return super.initialize(options);
    }
  }

  async run(options: AddCommandSchema & Arguments) {
    if (!options.collection) {
      this.logger.fatal(
        `The "ng add" command requires a name argument to be specified eg. ` +
          `${colors.yellow('ng add [name] ')}. For more details, use "ng help".`,
      );

      return 1;
    }

    let packageIdentifier;
    try {
      packageIdentifier = npa(options.collection);
    } catch (e) {
      this.logger.error(e.message);

      return 1;
    }

    if (packageIdentifier.registry && this.isPackageInstalled(packageIdentifier.name)) {
      let validVersion = false;
      const installedVersion = await this.findProjectVersion(packageIdentifier.name);
      if (installedVersion) {
        if (packageIdentifier.type === 'range') {
          validVersion = satisfies(installedVersion, packageIdentifier.fetchSpec);
        } else if (packageIdentifier.type === 'version') {
          const v1 = valid(packageIdentifier.fetchSpec);
          const v2 = valid(installedVersion);
          validVersion = v1 !== null && v1 === v2;
        } else if (!packageIdentifier.rawSpec) {
          validVersion = true;
        }
      }

      if (validVersion) {
        // Already installed so just run schematic
        this.logger.info('Skipping installation: Package already installed');

        return this.executeSchematic(packageIdentifier.name, options['--']);
      }
    }

    const packageManager = await getPackageManager(this.workspace.root);
    const usingYarn = packageManager === PackageManager.Yarn;

    if (packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) {
      // only package name provided; search for viable version
      // plus special cases for packages that did not have peer deps setup
      let packageMetadata;
      try {
        packageMetadata = await fetchPackageMetadata(packageIdentifier.name, this.logger, {
          registry: options.registry,
          usingYarn,
          verbose: options.verbose,
        });
      } catch (e) {
        this.logger.error('Unable to fetch package metadata: ' + e.message);

        return 1;
      }

      const latestManifest = packageMetadata.tags['latest'];
      if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) {
        if (latestManifest.name === '@angular/pwa') {
          const version = await this.findProjectVersion('@angular/cli');
          const semverOptions = { includePrerelease: true };

          if (
            version &&
            ((validRange(version) && intersects(version, '7', semverOptions)) ||
              (valid(version) && satisfies(version, '7', semverOptions)))
          ) {
            packageIdentifier = npa.resolve('@angular/pwa', '0.12');
          }
        }
      } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) {
        // 'latest' is invalid so search for most recent matching package
        const versionManifests = Object.values(packageMetadata.versions).filter(
          (value: PackageManifest) => !prerelease(value.version),
        ) as PackageManifest[];

        versionManifests.sort((a, b) => rcompare(a.version, b.version, true));

        let newIdentifier;
        for (const versionManifest of versionManifests) {
          if (!(await this.hasMismatchedPeer(versionManifest))) {
            newIdentifier = npa.resolve(packageIdentifier.name, versionManifest.version);
            break;
          }
        }

        if (!newIdentifier) {
          this.logger.warn("Unable to find compatible package.  Using 'latest'.");
        } else {
          packageIdentifier = newIdentifier;
        }
      }
    }

    let collectionName = packageIdentifier.name;
    let savePackage: NgAddSaveDepedency | undefined;

    try {
      const manifest = await fetchPackageManifest(packageIdentifier, this.logger, {
        registry: options.registry,
        verbose: options.verbose,
        usingYarn,
      });

      savePackage = manifest['ng-add']?.save;
      collectionName = manifest.name;

      if (await this.hasMismatchedPeer(manifest)) {
        this.logger.warn(
          'Package has unmet peer dependencies. Adding the package may not succeed.',
        );
      }
    } catch (e) {
      this.logger.error('Unable to fetch package manifest: ' + e.message);

      return 1;
    }

    if (savePackage === false) {
      // Temporary packages are located in a different directory
      // Hence we need to resolve them using the temp path
      const tempPath = installTempPackage(
        packageIdentifier.raw,
        this.logger,
        packageManager,
        options.registry ? [`--registry="${options.registry}"`] : undefined,
      );
      const resolvedCollectionPath = require.resolve(
        join(collectionName, 'package.json'),
        {
          paths: [tempPath],
        },
      );

      collectionName = dirname(resolvedCollectionPath);
    } else {
      installPackage(
        packageIdentifier.raw,
        this.logger,
        packageManager,
        savePackage,
        options.registry ? [`--registry="${options.registry}"`] : undefined,
      );
    }

    return this.executeSchematic(collectionName, options['--']);
  }

  async reportAnalytics(
    paths: string[],
    options: AddCommandSchema & Arguments,
    dimensions: (boolean | number | string)[] = [],
    metrics: (boolean | number | string)[] = [],
  ): Promise<void> {
    const collection = options.collection;

    // Add the collection if it's safe listed.
    if (collection && isPackageNameSafeForAnalytics(collection)) {
      dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection] = collection;
    } else {
      delete dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection];
    }

    return super.reportAnalytics(paths, options, dimensions, metrics);
  }

  private isPackageInstalled(name: string): boolean {
    try {
      require.resolve(join(name, 'package.json'), { paths: [this.workspace.root] });

      return true;
    } catch (e) {
      if (e.code !== 'MODULE_NOT_FOUND') {
        throw e;
      }
    }

    return false;
  }

  private async executeSchematic(
    collectionName: string,
    options: string[] = [],
  ): Promise<number | void> {
    const runOptions: RunSchematicOptions = {
      schematicOptions: options,
      collectionName,
      schematicName: 'ng-add',
      dryRun: false,
      force: false,
    };

    try {
      return await this.runSchematic(runOptions);
    } catch (e) {
      if (e instanceof NodePackageDoesNotSupportSchematics) {
        this.logger.error(tags.oneLine`
          The package that you are trying to add does not support schematics. You can try using
          a different version of the package or contact the package author to add ng-add support.
        `);

        return 1;
      }

      throw e;
    }
  }

  private async findProjectVersion(name: string): Promise<string | null> {
    let installedPackage;
    try {
      installedPackage = require.resolve(join(name, 'package.json'), {
        paths: [this.workspace.root],
      });
    } catch {}

    if (installedPackage) {
      try {
        const installed = await fetchPackageManifest(dirname(installedPackage), this.logger);

        return installed.version;
      } catch {}
    }

    let projectManifest;
    try {
      projectManifest = await fetchPackageManifest(this.workspace.root, this.logger);
    } catch {}

    if (projectManifest) {
      const version = projectManifest.dependencies[name] || projectManifest.devDependencies[name];
      if (version) {
        return version;
      }
    }

    return null;
  }

  private async hasMismatchedPeer(manifest: PackageManifest): Promise<boolean> {
    for (const peer in manifest.peerDependencies) {
      let peerIdentifier;
      try {
        peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]);
      } catch {
        this.logger.warn(`Invalid peer dependency ${peer} found in package.`);
        continue;
      }

      if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') {
        try {
          const version = await this.findProjectVersion(peer);
          if (!version) {
            continue;
          }

          const options = { includePrerelease: true };

          if (
            !intersects(version, peerIdentifier.rawSpec, options) &&
            !satisfies(version, peerIdentifier.rawSpec, options)
          ) {
            return true;
          }
        } catch {
          // Not found or invalid so ignore
          continue;
        }
      } else {
        // type === 'tag' | 'file' | 'directory' | 'remote' | 'git'
        // Cannot accurately compare these as the tag/location may have changed since install
      }
    }

    return false;
  }
}
