// Copyright 2015 The Chromium 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 'dart:async';
import 'dart:convert';

import 'package:linter/src/rules/pub/package_names.dart' as package_names; // ignore: implementation_imports
import 'package:linter/src/utils.dart' as linter_utils; // ignore: implementation_imports
import 'package:yaml/yaml.dart' as yaml;

import '../android/android.dart' as android;
import '../android/android_sdk.dart' as android_sdk;
import '../android/gradle.dart' as gradle;
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/net.dart';
import '../base/os.dart';
import '../base/utils.dart';
import '../cache.dart';
import '../dart/pub.dart';
import '../doctor.dart';
import '../globals.dart';
import '../project.dart';
import '../runner/flutter_command.dart';
import '../template.dart';
import '../version.dart';

enum _ProjectType {
  /// This is the default project with the user-managed host code.
  /// It is different than the "module" template in that it exposes and doesn't
  /// manage the platform code.
  app,
  /// The is a project that has managed platform host code. It is an application with
  /// ephemeral .ios and .android directories that can be updated automatically.
  module,
  /// This is a Flutter Dart package project. It doesn't have any native
  /// components, only Dart.
  package,
  /// This is a native plugin project.
  plugin,
}

_ProjectType _stringToProjectType(String value) {
  _ProjectType result;
  for (_ProjectType type in _ProjectType.values) {
    if (value == getEnumName(type)) {
      result = type;
      break;
    }
  }
  return result;
}

class CreateCommand extends FlutterCommand {
  CreateCommand() {
    argParser.addFlag('pub',
      defaultsTo: true,
      help: 'Whether to run "flutter packages get" after the project has been created.'
    );
    argParser.addFlag('offline',
      defaultsTo: false,
      help: 'When "flutter packages get" is run by the create command, this indicates '
        'whether to run it in offline mode or not. In offline mode, it will need to '
        'have all dependencies already available in the pub cache to succeed.'
    );
    argParser.addFlag(
      'with-driver-test',
      negatable: true,
      defaultsTo: false,
      help: "Also add a flutter_driver dependency and generate a sample 'flutter drive' test."
    );
    argParser.addOption(
      'template',
      abbr: 't',
      allowed: _ProjectType.values.map<String>((_ProjectType type) => getEnumName(type)),
      help: 'Specify the type of project to create.',
      valueHelp: 'type',
      allowedHelp: <String, String>{
        getEnumName(_ProjectType.app): '(default) Generate a Flutter application.',
        getEnumName(_ProjectType.package): 'Generate a shareable Flutter project containing modular '
            'Dart code.',
        getEnumName(_ProjectType.plugin): 'Generate a shareable Flutter project containing an API '
            'in Dart code with a platform-specific implementation for Android, for iOS code, or '
            'for both.',
      },
      defaultsTo: null,
    );
    argParser.addOption(
      'sample',
      abbr: 's',
      help: 'Specifies the Flutter code sample to use as the main.dart for an application. Implies '
        '--template=app.',
      defaultsTo: null,
      valueHelp: 'the sample ID of the desired sample from the API documentation website (http://docs.flutter.io)'
    );
    argParser.addFlag(
      'overwrite',
      negatable: true,
      defaultsTo: false,
      help: 'When performing operations, overwrite existing files.',
    );
    argParser.addOption(
      'description',
      defaultsTo: 'A new Flutter project.',
      help: 'The description to use for your new Flutter project. This string ends up in the pubspec.yaml file.'
    );
    argParser.addOption(
      'org',
      defaultsTo: 'com.example',
      help: 'The organization responsible for your new Flutter project, in reverse domain name notation. '
            'This string is used in Java package names and as prefix in the iOS bundle identifier.'
    );
    argParser.addOption(
      'project-name',
      defaultsTo: null,
      help: 'The project name for this new Flutter project. This must be a valid dart package name.'
    );
    argParser.addOption(
      'ios-language',
      abbr: 'i',
      defaultsTo: 'objc',
      allowed: <String>['objc', 'swift'],
    );
    argParser.addOption(
      'android-language',
      abbr: 'a',
      defaultsTo: 'java',
      allowed: <String>['java', 'kotlin'],
    );
  }

  @override
  final String name = 'create';

  @override
  final String description = 'Create a new Flutter project.\n\n'
    'If run on a project that already exists, this will repair the project, recreating any files that are missing.';

  @override
  String get invocation => '${runner.executableName} $name <output directory>';

  // If it has a .metadata file with the project_type in it, use that.
  // If it has an android dir and an android/app dir, it's a legacy app
  // If it has an ios dir and an ios/Flutter dir, it's a legacy app
  // Otherwise, we don't presume to know what type of project it could be, since
  // many of the files could be missing, and we can't really tell definitively.
  _ProjectType _determineTemplateType(Directory projectDir) {
    yaml.YamlMap loadMetadata(Directory projectDir) {
      if (!projectDir.existsSync())
        return null;
      final File metadataFile = fs.file(fs.path.join(projectDir.absolute.path, '.metadata'));
      if (!metadataFile.existsSync())
        return null;
      return yaml.loadYaml(metadataFile.readAsStringSync());
    }

    bool exists(List<String> path) {
      return fs.directory(fs.path.joinAll(<String>[projectDir.absolute.path] + path)).existsSync();
    }

    // If it exists, the project type in the metadata is definitive.
    final yaml.YamlMap metadata = loadMetadata(projectDir);
    if (metadata != null && metadata['project_type'] != null) {
      return _stringToProjectType(metadata['project_type']);
    }

    // There either wasn't any metadata, or it didn't contain the project type,
    // so try and figure out what type of project it is from the existing
    // directory structure.
    if (exists(<String>['android', 'app'])
        || exists(<String>['ios', 'Runner'])
        || exists(<String>['ios', 'Flutter'])) {
      return _ProjectType.app;
    }
    // Since we can't really be definitive on nearly-empty directories, err on
    // the side of prudence and just say we don't know.
    return null;
  }

  Future<String> _fetchSampleFromServer(String sampleId) async {
    // Sanity check the sampleId
    if (sampleId.contains(RegExp(r'[^-\w\.]'))) {
      throwToolExit('Sample ID "$sampleId" contains invalid characters. Check the ID in the '
        'documentation and try again.');
    }

    final String host = FlutterVersion.instance.channel == 'stable'
        ? 'docs.flutter.io'
        : 'master-docs-flutter-io.firebaseapp.com';
    return utf8.decode(await fetchUrl(Uri.https(host, 'snippets/$sampleId.dart')));
  }

  @override
  Future<FlutterCommandResult> runCommand() async {
    if (argResults.rest.isEmpty)
      throwToolExit('No option specified for the output directory.\n$usage', exitCode: 2);

    if (argResults.rest.length > 1) {
      String message = 'Multiple output directories specified.';
      for (String arg in argResults.rest) {
        if (arg.startsWith('-')) {
          message += '\nTry moving $arg to be immediately following $name';
          break;
        }
      }
      throwToolExit(message, exitCode: 2);
    }

    if (Cache.flutterRoot == null)
      throwToolExit('Neither the --flutter-root command line flag nor the FLUTTER_ROOT environment '
        'variable was specified. Unable to find package:flutter.', exitCode: 2);

    await Cache.instance.updateAll();

    final String flutterRoot = fs.path.absolute(Cache.flutterRoot);

    final String flutterPackagesDirectory = fs.path.join(flutterRoot, 'packages');
    final String flutterPackagePath = fs.path.join(flutterPackagesDirectory, 'flutter');
    if (!fs.isFileSync(fs.path.join(flutterPackagePath, 'pubspec.yaml')))
      throwToolExit('Unable to find package:flutter in $flutterPackagePath', exitCode: 2);

    final String flutterDriverPackagePath = fs.path.join(flutterRoot, 'packages', 'flutter_driver');
    if (!fs.isFileSync(fs.path.join(flutterDriverPackagePath, 'pubspec.yaml')))
      throwToolExit('Unable to find package:flutter_driver in $flutterDriverPackagePath', exitCode: 2);

    final Directory projectDir = fs.directory(argResults.rest.first);
    final String projectDirPath = fs.path.normalize(projectDir.absolute.path);

    String sampleCode;
    if (argResults['sample'] != null) {
      if (argResults['template'] != null &&
        _stringToProjectType(argResults['template'] ?? 'app') != _ProjectType.app) {
        throwToolExit('Cannot specify --sample with a project type other than '
          '"${getEnumName(_ProjectType.app)}"');
      }
      // Fetch the sample from the server.
      sampleCode = await _fetchSampleFromServer(argResults['sample']);
    }

    _ProjectType template;
    _ProjectType detectedProjectType;
    final bool metadataExists = projectDir.absolute.childFile('.metadata').existsSync();
    if (argResults['template'] != null) {
      template = _stringToProjectType(argResults['template']);
    } else {
      if (projectDir.existsSync() && projectDir.listSync().isNotEmpty) {
        detectedProjectType = _determineTemplateType(projectDir);
        if (detectedProjectType == null && metadataExists) {
          // We can only be definitive that this is the wrong type if the .metadata file
          // exists and contains a type that we don't understand, or doesn't contain a type.
          throwToolExit('Sorry, unable to detect the type of project to recreate. '
              'Try creating a fresh project and migrating your existing code to '
              'the new project manually.');
        }
      }
    }
    template ??= detectedProjectType ?? _ProjectType.app;
    if (detectedProjectType != null && template != detectedProjectType && metadataExists) {
      // We can only be definitive that this is the wrong type if the .metadata file
      // exists and contains a type that doesn't match.
      throwToolExit("The requested template type '${getEnumName(template)}' doesn't match the "
          "existing template type of '${getEnumName(detectedProjectType)}'.");
    }

    final bool generateModule = template == _ProjectType.module;
    final bool generatePlugin = template == _ProjectType.plugin;
    final bool generatePackage = template == _ProjectType.package;

    String organization = argResults['org'];
    if (!argResults.wasParsed('org')) {
      final FlutterProject project = await FlutterProject.fromDirectory(projectDir);
      final Set<String> existingOrganizations = project.organizationNames;
      if (existingOrganizations.length == 1) {
        organization = existingOrganizations.first;
      } else if (1 < existingOrganizations.length) {
        throwToolExit(
          'Ambiguous organization in existing files: $existingOrganizations. '
          'The --org command line argument must be specified to recreate project.'
        );
      }
    }

    String error = _validateProjectDir(projectDirPath, flutterRoot: flutterRoot, overwrite: argResults['overwrite']);
    if (error != null)
      throwToolExit(error);

    final String projectName = argResults['project-name'] ?? fs.path.basename(projectDirPath);
    error = _validateProjectName(projectName);
    if (error != null)
      throwToolExit(error);

    final Map<String, dynamic> templateContext = _templateContext(
      organization: organization,
      projectName: projectName,
      projectDescription: argResults['description'],
      flutterRoot: flutterRoot,
      renderDriverTest: argResults['with-driver-test'],
      withPluginHook: generatePlugin,
      androidLanguage: argResults['android-language'],
      iosLanguage: argResults['ios-language'],
    );

    final String relativeDirPath = fs.path.relative(projectDirPath);
    if (!projectDir.existsSync() || projectDir.listSync().isEmpty) {
      printStatus('Creating project $relativeDirPath...');
    } else {
      if (sampleCode != null && !argResults['overwrite']) {
        throwToolExit('Will not overwrite existing project in $relativeDirPath: '
          'must specify --overwrite for samples to overwrite.');
      }
      printStatus('Recreating project $relativeDirPath...');
    }

    final Directory relativeDir = fs.directory(projectDirPath);
    int generatedFileCount = 0;
    switch (template) {
      case _ProjectType.app:
        generatedFileCount += await _generateApp(relativeDir, templateContext, overwrite: argResults['overwrite']);
        break;
      case _ProjectType.module:
        generatedFileCount += await _generateModule(relativeDir, templateContext, overwrite: argResults['overwrite']);
        break;
      case _ProjectType.package:
        generatedFileCount += await _generatePackage(relativeDir, templateContext, overwrite: argResults['overwrite']);
        break;
      case _ProjectType.plugin:
        generatedFileCount += await _generatePlugin(relativeDir, templateContext, overwrite: argResults['overwrite']);
        break;
    }
    if (sampleCode != null) {
      generatedFileCount += await _applySample(relativeDir, sampleCode);
    }
    printStatus('Wrote $generatedFileCount files.');
    printStatus('\nAll done!');
    final String application = sampleCode != null ? 'sample application' : 'application';
    if (generatePackage) {
      final String relativeMainPath = fs.path.normalize(fs.path.join(
        relativeDirPath,
        'lib',
        '${templateContext['projectName']}.dart',
      ));
      printStatus('Your package code is in $relativeMainPath');
    } else if (generateModule) {
      final String relativeMainPath = fs.path.normalize(fs.path.join(
          relativeDirPath,
          'lib',
          'main.dart',
      ));
      printStatus('Your module code is in $relativeMainPath.');
    } else {
      // Run doctor; tell the user the next steps.
      final FlutterProject project = await FlutterProject.fromPath(projectDirPath);
      final FlutterProject app = project.hasExampleApp ? project.example : project;
      final String relativeAppPath = fs.path.normalize(fs.path.relative(app.directory.path));
      final String relativeAppMain = fs.path.join(relativeAppPath, 'lib', 'main.dart');
      final String relativePluginPath = fs.path.normalize(fs.path.relative(projectDirPath));
      final String relativePluginMain = fs.path.join(relativePluginPath, 'lib', '$projectName.dart');
      if (doctor.canLaunchAnything) {
        // Let them know a summary of the state of their tooling.
        await doctor.summary();

        printStatus('''
In order to run your $application, type:

  \$ cd $relativeAppPath
  \$ flutter run

Your $application code is in $relativeAppMain.
''');
        if (generatePlugin) {
          printStatus('''
Your plugin code is in $relativePluginMain.

Host platform code is in the "android" and "ios" directories under $relativePluginPath.
To edit platform code in an IDE see https://flutter.io/developing-packages/#edit-plugin-package.
''');
        }
      } else {
        printStatus("You'll need to install additional components before you can run "
            'your Flutter app:');
        printStatus('');

        // Give the user more detailed analysis.
        await doctor.diagnose();
        printStatus('');
        printStatus("After installing components, run 'flutter doctor' in order to "
            're-validate your setup.');
        printStatus("When complete, type 'flutter run' from the '$relativeAppPath' "
            'directory in order to launch your app.');
        printStatus('Your $application code is in $relativeAppMain');
      }
    }

    return null;
  }

  Future<int> _generateModule(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
        ? argResults['description']
        : 'A new flutter module project.';
    templateContext['description'] = description;
    generatedCount += _renderTemplate(fs.path.join('module', 'common'), directory, templateContext, overwrite: overwrite);
    if (argResults['pub']) {
      await pubGet(
        context: PubContext.create,
        directory: directory.path,
        offline: argResults['offline'],
      );
      final FlutterProject project = await FlutterProject.fromDirectory(directory);
      await project.ensureReadyForPlatformSpecificTooling();
    }
    return generatedCount;
  }

  Future<int> _generatePackage(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
        ? argResults['description']
        : 'A new Flutter package project.';
    templateContext['description'] = description;
    generatedCount += _renderTemplate('package', directory, templateContext, overwrite: overwrite);
    if (argResults['pub']) {
      await pubGet(
        context: PubContext.createPackage,
        directory: directory.path,
        offline: argResults['offline'],
      );
    }
    return generatedCount;
  }

  Future<int> _generatePlugin(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
    int generatedCount = 0;
    final String description = argResults.wasParsed('description')
        ? argResults['description']
        : 'A new flutter plugin project.';
    templateContext['description'] = description;
    generatedCount += _renderTemplate('plugin', directory, templateContext, overwrite: overwrite);
    if (argResults['pub']) {
      await pubGet(
        context: PubContext.createPlugin,
        directory: directory.path,
        offline: argResults['offline'],
      );
    }
    final FlutterProject project = await FlutterProject.fromDirectory(directory);
    gradle.updateLocalProperties(project: project, requireAndroidSdk: false);

    final String projectName = templateContext['projectName'];
    final String organization = templateContext['organization'];
    final String androidPluginIdentifier = templateContext['androidIdentifier'];
    final String exampleProjectName = projectName + '_example';
    templateContext['projectName'] = exampleProjectName;
    templateContext['androidIdentifier'] = _createAndroidIdentifier(organization, exampleProjectName);
    templateContext['iosIdentifier'] = _createUTIIdentifier(organization, exampleProjectName);
    templateContext['description'] = 'Demonstrates how to use the $projectName plugin.';
    templateContext['pluginProjectName'] = projectName;
    templateContext['androidPluginIdentifier'] = androidPluginIdentifier;

    generatedCount += await _generateApp(project.example.directory, templateContext, overwrite: overwrite);
    return generatedCount;
  }

  Future<int> _generateApp(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
    int generatedCount = 0;
    generatedCount += _renderTemplate('app', directory, templateContext, overwrite: overwrite);
    final FlutterProject project = await FlutterProject.fromDirectory(directory);
    generatedCount += _injectGradleWrapper(project);

    if (argResults['with-driver-test']) {
      final Directory testDirectory = directory.childDirectory('test_driver');
      generatedCount += _renderTemplate('driver', testDirectory, templateContext, overwrite: overwrite);
    }

    if (argResults['pub']) {
      await pubGet(context: PubContext.create, directory: directory.path, offline: argResults['offline']);
      await project.ensureReadyForPlatformSpecificTooling();
    }

    gradle.updateLocalProperties(project: project, requireAndroidSdk: false);

    return generatedCount;
  }

  // Takes an application template and replaces the main.dart with one from the
  // documentation website in sampleCode.  Returns the difference in the number
  // of files after applying the sample, since it also deletes the application's
  // test directory (since the template's test doesn't apply to the sample).
  Future<int> _applySample(Directory directory, String sampleCode) async {
    final File mainDartFile = directory.childDirectory('lib').childFile('main.dart');
    await mainDartFile.create(recursive: true);
    await mainDartFile.writeAsString(sampleCode);
    final Directory testDir = directory.childDirectory('test');
    final List<FileSystemEntity> files = testDir.listSync(recursive: true);
    await testDir.delete(recursive: true);
    return -files.length;
  }

  Map<String, dynamic> _templateContext({
    String organization,
    String projectName,
    String projectDescription,
    String androidLanguage,
    String iosLanguage,
    String flutterRoot,
    bool renderDriverTest = false,
    bool withPluginHook = false,
  }) {
    flutterRoot = fs.path.normalize(flutterRoot);

    final String pluginDartClass = _createPluginClassName(projectName);
    final String pluginClass = pluginDartClass.endsWith('Plugin')
        ? pluginDartClass
        : pluginDartClass + 'Plugin';

    return <String, dynamic>{
      'organization': organization,
      'projectName': projectName,
      'androidIdentifier': _createAndroidIdentifier(organization, projectName),
      'iosIdentifier': _createUTIIdentifier(organization, projectName),
      'description': projectDescription,
      'dartSdk': '$flutterRoot/bin/cache/dart-sdk',
      'androidMinApiLevel': android.minApiLevel,
      'androidSdkVersion': android_sdk.minimumAndroidSdkVersion,
      'androidFlutterJar': '$flutterRoot/bin/cache/artifacts/engine/android-arm/flutter.jar',
      'withDriverTest': renderDriverTest,
      'pluginClass': pluginClass,
      'pluginDartClass': pluginDartClass,
      'withPluginHook': withPluginHook,
      'androidLanguage': androidLanguage,
      'iosLanguage': iosLanguage,
      'flutterRevision': FlutterVersion.instance.frameworkRevision,
      'flutterChannel': FlutterVersion.instance.channel,
    };
  }

  int _renderTemplate(String templateName, Directory directory, Map<String, dynamic> context, {bool overwrite = false}) {
    final Template template = Template.fromName(templateName);
    return template.render(directory, context, overwriteExisting: overwrite);
  }

  int _injectGradleWrapper(FlutterProject project) {
    int filesCreated = 0;
    copyDirectorySync(
      cache.getArtifactDirectory('gradle_wrapper'),
      project.android.hostAppGradleRoot,
      (File sourceFile, File destinationFile) {
        filesCreated++;
        final String modes = sourceFile.statSync().modeString();
        if (modes != null && modes.contains('x')) {
          os.makeExecutable(destinationFile);
        }
      },
    );
    return filesCreated;
  }
}

String _createAndroidIdentifier(String organization, String name) {
  return '$organization.$name'.replaceAll('_', '');
}

String _createPluginClassName(String name) {
  final String camelizedName = camelCase(name);
  return camelizedName[0].toUpperCase() + camelizedName.substring(1);
}

String _createUTIIdentifier(String organization, String name) {
  // Create a UTI (https://en.wikipedia.org/wiki/Uniform_Type_Identifier) from a base name
  final RegExp disallowed = RegExp(r'[^a-zA-Z0-9\-\.\u0080-\uffff]+');
  name = camelCase(name).replaceAll(disallowed, '');
  name = name.isEmpty ? 'untitled' : name;
  return '$organization.$name';
}

final Set<String> _packageDependencies = Set<String>.from(<String>[
  'analyzer',
  'args',
  'async',
  'collection',
  'convert',
  'crypto',
  'flutter',
  'flutter_test',
  'front_end',
  'html',
  'http',
  'intl',
  'io',
  'isolate',
  'kernel',
  'logging',
  'matcher',
  'meta',
  'mime',
  'path',
  'plugin',
  'pool',
  'test',
  'utf',
  'watcher',
  'yaml'
]);

/// Return null if the project name is legal. Return a validation message if
/// we should disallow the project name.
String _validateProjectName(String projectName) {
  if (!linter_utils.isValidPackageName(projectName)) {
    final String packageNameDetails = package_names.PubPackageNames().details;
    return '"$projectName" is not a valid Dart package name.\n\n$packageNameDetails';
  }
  if (_packageDependencies.contains(projectName)) {
    return "Invalid project name: '$projectName' - this will conflict with Flutter "
      'package dependencies.';
  }
  return null;
}

/// Return null if the project directory is legal. Return a validation message
/// if we should disallow the directory name.
String _validateProjectDir(String dirPath, { String flutterRoot, bool overwrite = false }) {
  if (fs.path.isWithin(flutterRoot, dirPath)) {
    return 'Cannot create a project within the Flutter SDK. '
      "Target directory '$dirPath' is within the Flutter SDK at '$flutterRoot'.";
  }

  // If the destination directory is actually a file, then we refuse to
  // overwrite, on the theory that the user probably didn't expect it to exist.
  if (fs.isFileSync(dirPath)) {
    return "Invalid project name: '$dirPath' - refers to an existing file."
        '${overwrite ? ' Refusing to overwrite a file with a directory.' : ''}';
  }

  if (overwrite)
    return null;

  final FileSystemEntityType type = fs.typeSync(dirPath);

  if (type != FileSystemEntityType.notFound) {
    switch (type) {
      case FileSystemEntityType.file:
        // Do not overwrite files.
        return "Invalid project name: '$dirPath' - file exists.";
      case FileSystemEntityType.link:
        // Do not overwrite links.
        return "Invalid project name: '$dirPath' - refers to a link.";
    }
  }

  return null;
}