// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:args/args.dart';
import 'package:unified_analytics/unified_analytics.dart';

import '../base/common.dart';
import '../base/os.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../build_system/build_system.dart';
import '../build_system/targets/localizations.dart';
import '../cache.dart';
import '../dart/generate_synthetic_packages.dart';
import '../dart/pub.dart';
import '../flutter_plugins.dart';
import '../globals.dart' as globals;
import '../plugins.dart';
import '../project.dart';
import '../reporting/reporting.dart';
import '../runner/flutter_command.dart';

/// The function signature of the [print] function.
typedef PrintFn = void Function(Object?);

class PackagesCommand extends FlutterCommand {
  PackagesCommand({
    PrintFn usagePrintFn = print,
  }) : _usagePrintFn = usagePrintFn
  {
    addSubcommand(PackagesGetCommand('get', "Get the current package's dependencies.", PubContext.pubGet));
    addSubcommand(PackagesGetCommand('upgrade', "Upgrade the current package's dependencies to latest versions.", PubContext.pubUpgrade));
    addSubcommand(PackagesGetCommand('add', 'Add a dependency to pubspec.yaml.', PubContext.pubAdd));
    addSubcommand(PackagesGetCommand('remove', 'Removes a dependency from the current package.', PubContext.pubRemove));
    addSubcommand(PackagesTestCommand());
    addSubcommand(PackagesForwardCommand('publish', 'Publish the current package to pub.dartlang.org.', requiresPubspec: true));
    addSubcommand(PackagesForwardCommand('downgrade', 'Downgrade packages in a Flutter project.', requiresPubspec: true));
    addSubcommand(PackagesForwardCommand('deps', 'Print package dependencies.')); // path to package can be specified with --directory argument
    addSubcommand(PackagesForwardCommand('run', 'Run an executable from a package.', requiresPubspec: true));
    addSubcommand(PackagesForwardCommand('cache', 'Work with the Pub system cache.'));
    addSubcommand(PackagesForwardCommand('version', 'Print Pub version.'));
    addSubcommand(PackagesForwardCommand('uploader', 'Manage uploaders for a package on pub.dev.'));
    addSubcommand(PackagesForwardCommand('login', 'Log into pub.dev.'));
    addSubcommand(PackagesForwardCommand('logout', 'Log out of pub.dev.'));
    addSubcommand(PackagesForwardCommand('global', 'Work with Pub global packages.'));
    addSubcommand(PackagesForwardCommand('outdated', 'Analyze dependencies to find which ones can be upgraded.', requiresPubspec: true));
    addSubcommand(PackagesForwardCommand('token', 'Manage authentication tokens for hosted pub repositories.'));
    addSubcommand(PackagesPassthroughCommand());
  }

  final PrintFn _usagePrintFn;

  @override
  final String name = 'pub';

  @override
  List<String> get aliases => const <String>['packages'];

  @override
  final String description = 'Commands for managing Flutter packages.';

  @override
  String get category => FlutterCommandCategory.project;

  @override
  Future<FlutterCommandResult> runCommand() async => FlutterCommandResult.fail();

  @override
  void printUsage() => _usagePrintFn(usage);
}

class PackagesTestCommand extends FlutterCommand {
  PackagesTestCommand() {
    requiresPubspecYaml();
  }

  @override
  String get name => 'test';

  @override
  String get description {
    return 'Run the "test" package.\n'
           'This is similar to "flutter test", but instead of hosting the tests in the '
           'flutter environment it hosts the tests in a pure Dart environment. The main '
           'differences are that the "dart:ui" library is not available and that tests '
           'run faster. This is helpful for testing libraries that do not depend on any '
           'packages from the Flutter SDK. It is equivalent to "pub run test".';
  }

  @override
  String get invocation {
    return '${runner!.executableName} pub test [<tests...>]';
  }

  @override
  Future<FlutterCommandResult> runCommand() async {
    await pub.batch(<String>['run', 'test', ...argResults!.rest], context: PubContext.runTest);
    return FlutterCommandResult.success();
  }
}

class PackagesForwardCommand extends FlutterCommand {
  PackagesForwardCommand(this._commandName, this._description, {bool requiresPubspec = false}) {
    if (requiresPubspec) {
      requiresPubspecYaml();
    }
  }

  PubContext context = PubContext.pubForward;

  @override
  ArgParser argParser = ArgParser.allowAnything();

  final String _commandName;
  final String _description;

  @override
  String get name => _commandName;

  @override
  String get description {
    return '$_description\n'
           'This runs the "pub" tool in a Flutter context.';
  }

  @override
  String get invocation {
    return '${runner!.executableName} pub $_commandName [<arguments...>]';
  }

  @override
  Future<FlutterCommandResult> runCommand() async {
    final List<String> subArgs = argResults!.rest.toList()
      ..removeWhere((String arg) => arg == '--');
    await pub.interactively(
      <String>[ _commandName, ...subArgs],
      context: context,
      command: _commandName,
    );
    return FlutterCommandResult.success();
  }
}

class PackagesPassthroughCommand extends FlutterCommand {
  @override
  ArgParser argParser = ArgParser.allowAnything();

  @override
  String get name => 'pub';

  @override
  String get description {
    return 'Pass the remaining arguments to Dart\'s "pub" tool.\n'
           'This runs the "pub" tool in a Flutter context.';
  }

  @override
  String get invocation {
    return '${runner!.executableName} packages pub [<arguments...>]';
  }

  static final PubContext _context = PubContext.pubPassThrough;

  @override
  Future<FlutterCommandResult> runCommand() async {
    await pub.interactively(
      command: 'pub',
      argResults!.rest,
      context: _context,
    );
    return FlutterCommandResult.success();
  }
}

/// Represents the pub sub-commands that makes package-resolutions.
class PackagesGetCommand extends FlutterCommand {
  PackagesGetCommand(this._commandName, this._description, this._context);

  @override
  ArgParser argParser = ArgParser.allowAnything();

  final String _commandName;
  final String _description;
  final PubContext _context;

  FlutterProject? _rootProject;

  @override
  String get name => _commandName;

  @override
  String get description {
    return '$_description\n'
           'This runs the "pub" tool in a Flutter context.';
  }

  @override
  String get invocation {
    return '${runner!.executableName} pub $_commandName [<arguments...>]';
  }

  /// An [ArgParser] that accepts all options and flags that the
  ///
  /// `pub get`
  /// `pub upgrade`
  /// `pub downgrade`
  /// `pub add`
  /// `pub remove`
  ///
  /// commands accept.
  ArgParser get _permissiveArgParser {
    final ArgParser argParser = ArgParser();
    argParser.addOption('directory', abbr: 'C');
    argParser.addFlag('offline');
    argParser.addFlag('dry-run', abbr: 'n');
    argParser.addFlag('help', abbr: 'h');
    argParser.addFlag('enforce-lockfile');
    argParser.addFlag('precompile');
    argParser.addFlag('major-versions');
    argParser.addFlag('null-safety');
    argParser.addFlag('example', defaultsTo: true);
    argParser.addOption('sdk');
    argParser.addOption('path');
    argParser.addOption('hosted-url');
    argParser.addOption('git-url');
    argParser.addOption('git-ref');
    argParser.addOption('git-path');
    argParser.addFlag('dev');
    argParser.addFlag('verbose', abbr: 'v');
    return argParser;
  }

  @override
  Future<FlutterCommandResult> runCommand() async {
    List<String> rest = argResults!.rest;
    bool isHelp = false;
    bool example = true;
    bool exampleWasParsed = false;
    String? directoryOption;
    bool dryRun = false;
    try {
      final ArgResults results = _permissiveArgParser.parse(rest);
      isHelp = results['help'] as bool;
      directoryOption = results['directory'] as String?;
      example = results['example'] as bool;
      exampleWasParsed = results.wasParsed('example');
      dryRun = results['dry-run'] as bool;
    } on ArgParserException {
      // Let pub give the error message.
    }
    String? target;
    FlutterProject? rootProject;

    if (!isHelp) {
      if (directoryOption == null &&
          rest.length == 1 &&
          // Anything that looks like an argument should not be interpreted as
          // a directory.
          !rest.single.startsWith('-') &&
          ((rest.single.contains('/') || rest.single.contains(r'\')) ||
            name == 'get')) {
        // For historical reasons, if there is one argument to the command and it contains
        // a multiple-component path (i.e. contains a slash) then we use that to determine
        // to which project we're applying the command.
        target = findProjectRoot(globals.fs, rest.single);

        globals.printWarning('''
  Using a naked argument for directory is deprecated and will stop working in a future Flutter release.

  Use --directory instead.''');
        if (target == null) {
          throwToolExit('Expected to find project root in ${rest.single}.');
        }
        rest = <String>[];
      } else {
        target = findProjectRoot(globals.fs, directoryOption);
        if (target == null) {
          if (directoryOption == null) {
            throwToolExit('Expected to find project root in current working directory.');
          } else {
            throwToolExit('Expected to find project root in $directoryOption.');
          }
        }
      }

      rootProject = FlutterProject.fromDirectory(globals.fs.directory(target));
      _rootProject = rootProject;

      if (rootProject.manifest.generateSyntheticPackage) {
        final Environment environment = Environment(
          artifacts: globals.artifacts!,
          logger: globals.logger,
          cacheDir: globals.cache.getRoot(),
          engineVersion: globals.flutterVersion.engineRevision,
          fileSystem: globals.fs,
          flutterRootDir: globals.fs.directory(Cache.flutterRoot),
          outputDir: globals.fs.directory(getBuildDirectory()),
          processManager: globals.processManager,
          platform: globals.platform,
          usage: globals.flutterUsage,
          analytics: analytics,
          projectDir: rootProject.directory,
          generateDartPluginRegistry: true,
        );

        await generateLocalizationsSyntheticPackage(
          environment: environment,
          buildSystem: globals.buildSystem,
          buildTargets: globals.buildTargets,
        );
      } else if (rootProject.directory.childFile('l10n.yaml').existsSync()) {
        final Environment environment = Environment(
          artifacts: globals.artifacts!,
          logger: globals.logger,
          cacheDir: globals.cache.getRoot(),
          engineVersion: globals.flutterVersion.engineRevision,
          fileSystem: globals.fs,
          flutterRootDir: globals.fs.directory(Cache.flutterRoot),
          outputDir: globals.fs.directory(getBuildDirectory()),
          processManager: globals.processManager,
          platform: globals.platform,
          usage: globals.flutterUsage,
          analytics: analytics,
          projectDir: rootProject.directory,
          generateDartPluginRegistry: true,
        );
        final BuildResult result = await globals.buildSystem.build(
          const GenerateLocalizationsTarget(),
          environment,
        );
        if (result.hasException) {
          throwToolExit(
            'Generating synthetic localizations package failed with ${result.exceptions.length} ${pluralize('error', result.exceptions.length)}:'
            '\n\n'
            '${result.exceptions.values.map<Object?>((ExceptionMeasurement e) => e.exception).join('\n\n')}',
          );
        }
      }
    }
    final String? relativeTarget = target == null ? null : globals.fs.path.relative(target);

    final List<String> subArgs = rest.toList()..removeWhere((String arg) => arg == '--');
    final Stopwatch timer = Stopwatch()..start();
    try {
      await pub.interactively(
        <String>[
          name,
          ...subArgs,
          // `dart pub get` and friends defaults to `--no-example`.
          if (!exampleWasParsed && target != null) '--example',
          if (directoryOption == null && relativeTarget != null) ...<String>['--directory', relativeTarget],
        ],
        project: rootProject,
        context: _context,
        command: name,
        touchesPackageConfig: !(isHelp || dryRun),
      );
      final Duration elapsedDuration = timer.elapsed;
      globals.flutterUsage.sendTiming('pub', 'get', elapsedDuration, label: 'success');
      analytics.send(Event.timing(
        workflow: 'pub',
        variableName: 'get',
        elapsedMilliseconds: elapsedDuration.inMilliseconds,
        label: 'success'
      ));
    // Not limiting to catching Exception because the exception is rethrown.
    } catch (_) { // ignore: avoid_catches_without_on_clauses
      final Duration elapsedDuration = timer.elapsed;
      globals.flutterUsage.sendTiming('pub', 'get', elapsedDuration, label: 'failure');
      analytics.send(Event.timing(
        workflow: 'pub',
        variableName: 'get',
        elapsedMilliseconds: elapsedDuration.inMilliseconds,
        label: 'failure'
      ));
      rethrow;
    }

    if (rootProject != null) {
      // We need to regenerate the platform specific tooling for both the project
      // itself and example(if present).
      await rootProject.regeneratePlatformSpecificTooling();
      if (example && rootProject.hasExampleApp && rootProject.example.pubspecFile.existsSync()) {
        final FlutterProject exampleProject = rootProject.example;
        await exampleProject.regeneratePlatformSpecificTooling();
      }
    }

    return FlutterCommandResult.success();
  }

  late final Future<List<Plugin>> _pluginsFound = (() async {
    final FlutterProject? rootProject = _rootProject;
    if (rootProject == null) {
      return <Plugin>[];
    }

    return findPlugins(rootProject, throwOnError: false);
  })();

  late final String? _androidEmbeddingVersion = (() {
    final FlutterProject? rootProject = _rootProject;
    if (rootProject == null) {
      return null;
    }

    return rootProject.android.getEmbeddingVersion().toString().split('.').last;
  })();

  /// The pub packages usage values are incorrect since these are calculated/sent
  /// before pub get completes. This needs to be performed after dependency resolution.
  @override
  Future<CustomDimensions> get usageValues async {
    final FlutterProject? rootProject = _rootProject;
    if (rootProject == null) {
      return const CustomDimensions();
    }

    int numberPlugins;
    // Do not send plugin analytics if pub has not run before.
    final bool hasPlugins = rootProject.flutterPluginsDependenciesFile.existsSync()
      && rootProject.packageConfigFile.existsSync();
    if (hasPlugins) {
      // Do not fail pub get if package config files are invalid before pub has
      // had a chance to run.
      final List<Plugin> plugins = await _pluginsFound;
      numberPlugins = plugins.length;
    } else {
      numberPlugins = 0;
    }

    return CustomDimensions(
      commandPackagesNumberPlugins: numberPlugins,
      commandPackagesProjectModule: rootProject.isModule,
      commandPackagesAndroidEmbeddingVersion: _androidEmbeddingVersion,
    );
  }

  /// The pub packages usage values are incorrect since these are calculated/sent
  /// before pub get completes. This needs to be performed after dependency resolution.
  @override
  Future<Event> unifiedAnalyticsUsageValues(String commandPath) async {
    final FlutterProject? rootProject = _rootProject;
    if (rootProject == null) {
      return Event.commandUsageValues(workflow: commandPath, commandHasTerminal: hasTerminal);
    }

    final int numberPlugins;
    // Do not send plugin analytics if pub has not run before.
    final bool hasPlugins = rootProject.flutterPluginsDependenciesFile.existsSync()
      && rootProject.packageConfigFile.existsSync();
    if (hasPlugins) {
      // Do not fail pub get if package config files are invalid before pub has
      // had a chance to run.
      final List<Plugin> plugins = await _pluginsFound;
      numberPlugins = plugins.length;
    } else {
      numberPlugins = 0;
    }

    return Event.commandUsageValues(
      workflow: commandPath,
      commandHasTerminal: hasTerminal,
      packagesNumberPlugins: numberPlugins,
      packagesProjectModule: rootProject.isModule,
      packagesAndroidEmbeddingVersion: _androidEmbeddingVersion,
    );
  }
}
