// 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' show JSON;

import '../android/android_sdk.dart';
import '../android/gradle.dart';
import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/process.dart';
import '../base/process_manager.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../devfs.dart';
import '../flx.dart' as flx;
import '../globals.dart';
import '../resident_runner.dart';
import '../services.dart';
import 'build.dart';
import 'build_aot.dart';

export '../android/android_device.dart' show AndroidDevice;

const String _kDefaultAndroidManifestPath = 'android/AndroidManifest.xml';
const String _kDefaultResourcesPath = 'android/res';
const String _kDefaultAssetsPath = 'android/assets';

const String _kFlutterManifestPath = 'pubspec.yaml';
const String _kPackagesStatusPath = '.packages';

// Alias of the key provided in the Chromium debug keystore
const String _kDebugKeystoreKeyAlias = "chromiumdebugkey";

// Password for the Chromium debug keystore
const String _kDebugKeystorePassword = "chromium";

// Default APK output fs.path.
String get _defaultOutputPath => fs.path.join(getAndroidBuildDirectory(), 'app.apk');

/// Copies files into a new directory structure.
class _AssetBuilder {
  final Directory outDir;

  Directory _assetDir;

  _AssetBuilder(this.outDir, String assetDirName) {
    _assetDir = fs.directory('${outDir.path}/$assetDirName');
    _assetDir.createSync(recursive:  true);
  }

  void add(File asset, String relativePath) {
    String destPath = fs.path.join(_assetDir.path, relativePath);
    ensureDirectoryExists(destPath);
    asset.copySync(destPath);
  }

  Directory get directory => _assetDir;
}

/// Builds an APK package using Android SDK tools.
class _ApkBuilder {
  final AndroidSdkVersion sdk;

  File _androidJar;
  File _aapt;
  File _dx;
  File _zipalign;
  File _apksigner;

  _ApkBuilder(this.sdk) {
    _androidJar = fs.file(sdk.androidJarPath);
    _aapt = fs.file(sdk.aaptPath);
    _dx = fs.file(sdk.dxPath);
    _zipalign = fs.file(sdk.zipalignPath);
    _apksigner = fs.file(sdk.apksignerPath);
  }

  String checkDependencies() {
    if (!_androidJar.existsSync())
      return 'Cannot find android.jar at ${_androidJar.path}';
    if (!processManager.canRun(_aapt.path))
      return 'Cannot find aapt at ${_aapt.path}';
    if (!processManager.canRun(_dx.path))
      return 'Cannot find dx at ${_dx.path}';
    if (!processManager.canRun(_zipalign.path))
      return 'Cannot find zipalign at ${_zipalign.path}';
    if (!processManager.canRun(_apksigner.path))
      return 'Cannot find apksigner at ${_apksigner.path}';
    return null;
  }

  void compileClassesDex(File classesDex, List<File> jars) {
    List<String> packageArgs = <String>[_dx.path,
      '--dex',
      '--force-jumbo',
      '--output', classesDex.path
    ];

    packageArgs.addAll(jars.map((File f) => f.path));

    runCheckedSync(packageArgs);
  }

  void package(File outputApk, File androidManifest, Directory assets, Directory artifacts, Directory resources, BuildMode buildMode) {
    List<String> packageArgs = <String>[_aapt.path,
      'package',
      '-M', androidManifest.path,
      '-A', assets.path,
      '-I', _androidJar.path,
      '-F', outputApk.path,
    ];
    if (buildMode == BuildMode.debug)
      packageArgs.add('--debug-mode');
    if (resources != null)
      packageArgs.addAll(<String>['-S', resources.absolute.path]);
    packageArgs.add(artifacts.path);
    runCheckedSync(packageArgs);
  }

  void sign(File keystore, String keystorePassword, String keyAlias, String keyPassword, File outputApk) {
    assert(_apksigner != null);
    runCheckedSync(<String>[_apksigner.path, 'sign',
      '--ks', keystore.path,
      '--ks-key-alias', keyAlias,
      '--ks-pass', 'pass:$keystorePassword',
      '--key-pass', 'pass:$keyPassword',
      outputApk.path,
    ]);
  }

  void align(File unalignedApk, File outputApk) {
    runCheckedSync(<String>[_zipalign.path, '-f', '4', unalignedApk.path, outputApk.path]);
  }
}

class _ApkComponents {
  File manifest;
  File icuData;
  List<File> jars;
  List<Map<String, String>> services = <Map<String, String>>[];
  File libSkyShell;
  File debugKeystore;
  Directory resources;
  Map<String, File> extraFiles;
}

class ApkKeystoreInfo {
  ApkKeystoreInfo({ this.keystore, this.password, this.keyAlias, this.keyPassword }) {
    assert(keystore != null);
  }

  final String keystore;
  final String password;
  final String keyAlias;
  final String keyPassword;
}

class BuildApkCommand extends BuildSubCommand {
  BuildApkCommand() {
    usesTargetOption();
    addBuildModeFlags();
    usesPubOption();

    argParser.addOption('manifest',
      abbr: 'm',
      defaultsTo: _kDefaultAndroidManifestPath,
      help: 'Android manifest XML file.');
    argParser.addOption('resources',
      abbr: 'r',
      help: 'Resources directory fs.path.');
    argParser.addOption('output-file',
      abbr: 'o',
      defaultsTo: _defaultOutputPath,
      help: 'Output APK file.');
    argParser.addOption('flx',
      abbr: 'f',
      help: 'Path to the FLX file. If this is not provided, an FLX will be built.');
    argParser.addOption('target-arch',
      defaultsTo: 'arm',
      allowed: <String>['arm', 'x86', 'x64'],
      help: 'Architecture of the target device.');
    argParser.addOption('aot-path',
      help: 'Path to the ahead-of-time compiled snapshot directory.\n'
            'If this is not provided, an AOT snapshot will be built.');
    argParser.addOption('keystore',
      help: 'Path to the keystore used to sign the app.');
    argParser.addOption('keystore-password',
      help: 'Password used to access the keystore.');
    argParser.addOption('keystore-key-alias',
      help: 'Alias of the entry within the keystore.');
    argParser.addOption('keystore-key-password',
      help: 'Password for the entry within the keystore.');
  }

  @override
  final String name = 'apk';

  @override
  final String description = 'Build an Android APK file from your app.\n\n'
    'This command can build debug and release versions of your application. \'debug\' builds support\n'
    'debugging and a quick development cycle. \'release\' builds don\'t support debugging and are\n'
    'suitable for deploying to app stores.';

  TargetPlatform _getTargetPlatform(String targetArch) {
    switch (targetArch) {
      case 'arm':
        return TargetPlatform.android_arm;
      case 'x86':
        return TargetPlatform.android_x86;
      case 'x64':
        return TargetPlatform.android_x64;
      default:
        throw new Exception('Unrecognized target architecture: $targetArch');
    }
  }

  @override
  Future<Null> runCommand() async {
    await super.runCommand();

    TargetPlatform targetPlatform = _getTargetPlatform(argResults['target-arch']);
    BuildMode buildMode = getBuildMode();
    if (targetPlatform != TargetPlatform.android_arm && buildMode != BuildMode.debug)
      throwToolExit('Profile and release builds are only supported on ARM targets.');

    if (isProjectUsingGradle()) {
      await buildAndroidWithGradle(
        targetPlatform,
        buildMode,
        target: targetFile
      );
    } else {
      await buildAndroid(
        targetPlatform,
        buildMode,
        force: true,
        manifest: argResults['manifest'],
        resources: argResults['resources'],
        outputFile: argResults['output-file'],
        target: targetFile,
        flxPath: argResults['flx'],
        aotPath: argResults['aot-path'],
        keystore: (argResults['keystore'] ?? '').isEmpty ? null : new ApkKeystoreInfo(
          keystore: argResults['keystore'],
          password: argResults['keystore-password'],
          keyAlias: argResults['keystore-key-alias'],
          keyPassword: argResults['keystore-key-password']
        )
      );
    }
  }
}

// Return the directory name within the APK that is used for native code libraries
// on the given platform.
String getAbiDirectory(TargetPlatform platform) {
  switch (platform) {
    case TargetPlatform.android_arm:
      return 'armeabi-v7a';
    case TargetPlatform.android_x64:
      return 'x86_64';
    case TargetPlatform.android_x86:
      return 'x86';
    default:
      throw new Exception('Unsupported platform.');
  }
}

Future<_ApkComponents> _findApkComponents(
  TargetPlatform platform,
  BuildMode buildMode,
  String manifest,
  String resources,
  Map<String, File> extraFiles
) async {
  _ApkComponents components = new _ApkComponents();
  components.manifest = fs.file(manifest);
  components.resources = resources == null ? null : fs.directory(resources);
  components.extraFiles = extraFiles != null ? extraFiles : <String, File>{};
  components.icuData = fs.file(artifacts.getArtifactPath(Artifact.icudtlDat, platform, buildMode));
  components.jars = <File>[
    fs.file(artifacts.getArtifactPath(Artifact.classesDexJar, platform, buildMode))
  ];
  components.libSkyShell = fs.file(artifacts.getArtifactPath(Artifact.libskyShellSo, platform, buildMode));
  components.debugKeystore = fs.file(artifacts.getArtifactPath(Artifact.chromiumDebugKeyStore, platform, buildMode));

  await parseServiceConfigs(components.services, jars: components.jars);

  List<File> allFiles = <File>[
    components.manifest, components.icuData, components.libSkyShell, components.debugKeystore
  ]..addAll(components.jars)
   ..addAll(components.extraFiles.values);

  for (File file in allFiles) {
    if (!file.existsSync()) {
      printError('Cannot locate file: ${file.path}');
      return null;
    }
  }

  return components;
}

int _buildApk(
  TargetPlatform platform,
  BuildMode buildMode,
  _ApkComponents components,
  String flxPath,
  ApkKeystoreInfo keystore,
  String outputFile
) {
  assert(platform != null);
  assert(buildMode != null);

  Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_tools');

  printTrace('Building APK; buildMode: ${getModeName(buildMode)}.');

  try {
    _ApkBuilder builder = new _ApkBuilder(androidSdk.latestVersion);
    String error = builder.checkDependencies();
    if (error != null) {
      printError(error);
      return 1;
    }

    File classesDex = fs.file('${tempDir.path}/classes.dex');
    builder.compileClassesDex(classesDex, components.jars);

    File servicesConfig =
        generateServiceDefinitions(tempDir.path, components.services);

    _AssetBuilder assetBuilder = new _AssetBuilder(tempDir, 'assets');
    assetBuilder.add(components.icuData, 'icudtl.dat');
    assetBuilder.add(fs.file(flxPath), 'app.flx');
    assetBuilder.add(servicesConfig, 'services.json');

    _AssetBuilder artifactBuilder = new _AssetBuilder(tempDir, 'artifacts');
    artifactBuilder.add(classesDex, 'classes.dex');
    String abiDir = getAbiDirectory(platform);
    artifactBuilder.add(components.libSkyShell, 'lib/$abiDir/libsky_shell.so');

    for (String relativePath in components.extraFiles.keys)
      artifactBuilder.add(components.extraFiles[relativePath], relativePath);

    File unalignedApk = fs.file('${tempDir.path}/app.apk.unaligned');
    builder.package(
      unalignedApk, components.manifest, assetBuilder.directory,
      artifactBuilder.directory, components.resources, buildMode
    );
    File finalApk = fs.file(outputFile);
    ensureDirectoryExists(finalApk.path);
    builder.align(unalignedApk, finalApk);

    int signResult = _signApk(builder, components, finalApk, keystore, buildMode);
    if (signResult != 0)
      return signResult;

    printTrace('calculateSha: $outputFile');
    File apkShaFile = fs.file('$outputFile.sha1');
    apkShaFile.writeAsStringSync(calculateSha(finalApk));

    return 0;
  } finally {
    tempDir.deleteSync(recursive: true);
  }
}

int _signApk(
  _ApkBuilder builder,
  _ApkComponents components,
  File apk,
  ApkKeystoreInfo keystoreInfo,
  BuildMode buildMode,
) {
  File keystore;
  String keystorePassword;
  String keyAlias;
  String keyPassword;

  if (keystoreInfo == null) {
    if (buildMode == BuildMode.release) {
      printStatus('Warning! Signing the APK using the debug keystore.');
      printStatus('You will need a real keystore to distribute your application.');
    } else {
      printTrace('Signing the APK using the debug keystore.');
    }
    keystore = components.debugKeystore;
    keystorePassword = _kDebugKeystorePassword;
    keyAlias = _kDebugKeystoreKeyAlias;
    keyPassword = _kDebugKeystorePassword;
  } else {
    keystore = fs.file(keystoreInfo.keystore);
    keystorePassword = keystoreInfo.password ?? '';
    keyAlias = keystoreInfo.keyAlias ?? '';
    if (keystorePassword.isEmpty || keyAlias.isEmpty) {
      printError('You must provide a keystore password and a key alias.');
      return 1;
    }
    keyPassword = keystoreInfo.keyPassword ?? '';
    if (keyPassword.isEmpty)
      keyPassword = keystorePassword;
  }

  builder.sign(keystore, keystorePassword, keyAlias, keyPassword, apk);

  return 0;
}

// Returns true if the apk is out of date and needs to be rebuilt.
bool _needsRebuild(
  String apkPath,
  String manifest,
  TargetPlatform platform,
  BuildMode buildMode,
  Map<String, File> extraFiles
) {
  FileStat apkStat = fs.statSync(apkPath);
  // Note: This list of dependencies is imperfect, but will do for now. We
  // purposely don't include the .dart files, because we can load those
  // over the network without needing to rebuild (at least on Android).
  List<String> dependencies = <String>[
    manifest,
    _kFlutterManifestPath,
    _kPackagesStatusPath
  ];
  dependencies.addAll(extraFiles.values.map((File file) => file.path));
  Iterable<FileStat> dependenciesStat =
    dependencies.map((String path) => fs.statSync(path));

  if (apkStat.type == FileSystemEntityType.NOT_FOUND)
    return true;

  for (FileStat dep in dependenciesStat) {
    if (dep.modified == null || dep.modified.isAfter(apkStat.modified))
      return true;
  }

  if (!fs.isFileSync('$apkPath.sha1'))
    return true;

  String lastBuildType = _readBuildMeta(fs.path.dirname(apkPath))['targetBuildType'];
  String targetBuildType = _getTargetBuildTypeToken(platform, buildMode, fs.file(apkPath));
  if (lastBuildType != targetBuildType)
    return true;

  return false;
}

Future<Null> buildAndroid(
  TargetPlatform platform,
  BuildMode buildMode, {
  bool force: false,
  String manifest: _kDefaultAndroidManifestPath,
  String resources,
  String outputFile,
  String target,
  String flxPath,
  String aotPath,
  DevFSContent kernelContent,
  ApkKeystoreInfo keystore,
  bool applicationNeedsRebuild: false
}) async {
  outputFile ??= _defaultOutputPath;

  // Validate that we can find an android sdk.
  if (androidSdk == null)
    throwToolExit('No Android SDK found. Try setting the ANDROID_HOME environment variable.');

  List<String> validationResult = androidSdk.validateSdkWellFormed();
  if (validationResult.isNotEmpty) {
    validationResult.forEach(printError);
    throwToolExit('Try re-installing or updating your Android SDK.');
  }

  Map<String, File> extraFiles = <String, File>{};
  if (fs.isDirectorySync(_kDefaultAssetsPath)) {
    Directory assetsDir = fs.directory(_kDefaultAssetsPath);
    for (FileSystemEntity entity in assetsDir.listSync(recursive: true)) {
      if (entity is File) {
        String targetPath = entity.path.substring(assetsDir.path.length);
        extraFiles["assets/$targetPath"] = entity;
      }
    }
  }

  final bool needRebuild =
      applicationNeedsRebuild ||
          _needsRebuild(outputFile, manifest, platform, buildMode, extraFiles);

  // In debug (JIT) mode, the snapshot lives in the FLX, and we can skip the APK
  // rebuild if none of the resources in the APK are stale.
  // In AOT modes, the snapshot lives in the APK, so the APK must be rebuilt.
  if (!isAotBuildMode(buildMode) && !force && !needRebuild) {
    printTrace('APK up to date; skipping build step.');
    return;
  }

  if (resources != null) {
    if (!fs.isDirectorySync(resources))
      throwToolExit('Resources directory "$resources" not found.');
  } else {
    if (fs.isDirectorySync(_kDefaultResourcesPath))
      resources = _kDefaultResourcesPath;
  }

  _ApkComponents components = await _findApkComponents(platform, buildMode, manifest, resources, extraFiles);

  if (components == null)
    throwToolExit('Failure building APK: unable to find components.');

  String typeName = artifacts.getEngineType(platform, buildMode);
  Status status = logger.startProgress('Building APK in ${getModeName(buildMode)} mode  ($typeName)...',
      expectSlowOperation: true);

  if (flxPath != null && flxPath.isNotEmpty) {
    if (!fs.isFileSync(flxPath)) {
      throwToolExit('FLX does not exist: $flxPath\n'
        '(Omit the --flx option to build the FLX automatically)');
    }
  } else {
    // Build the FLX.
    flxPath = await flx.buildFlx(
      mainPath: findMainDartFile(target),
      kernelContent: kernelContent,
      precompiledSnapshot: isAotBuildMode(buildMode),
      includeRobotoFonts: false);

    if (flxPath == null)
      throwToolExit(null);
  }

  // Build an AOT snapshot if needed.
  if (isAotBuildMode(buildMode) && aotPath == null) {
    aotPath = await buildAotSnapshot(findMainDartFile(target), platform, buildMode);
    if (aotPath == null)
      throwToolExit('Failed to build AOT snapshot');
  }

  if (aotPath != null) {
    if (!isAotBuildMode(buildMode))
      throwToolExit('AOT snapshot can not be used in build mode $buildMode');
    if (!fs.isDirectorySync(aotPath))
      throwToolExit('AOT snapshot does not exist: $aotPath');
    for (String aotFilename in kAotSnapshotFiles) {
      String aotFilePath = fs.path.join(aotPath, aotFilename);
      if (!fs.isFileSync(aotFilePath))
        throwToolExit('Missing AOT snapshot file: $aotFilePath');
      components.extraFiles['assets/$aotFilename'] = fs.file(aotFilePath);
    }
  }

  int result = _buildApk(platform, buildMode, components, flxPath, keystore, outputFile);
  status.stop();

  if (result != 0)
    throwToolExit('Build APK failed ($result)', exitCode: result);

  File apkFile = fs.file(outputFile);
  printTrace('Built $outputFile (${getSizeAsMB(apkFile.lengthSync())}).');

  _writeBuildMetaEntry(
    fs.path.dirname(outputFile),
    'targetBuildType',
    _getTargetBuildTypeToken(platform, buildMode, fs.file(outputFile))
  );
}

Future<Null> buildAndroidWithGradle(
  TargetPlatform platform,
  BuildMode buildMode, {
  bool force: false,
  String target
}) async {
  if (platform != TargetPlatform.android_arm && buildMode != BuildMode.debug) {
    throwToolExit('Profile and release builds are only supported on ARM targets.');
  }
  // Validate that we can find an android sdk.
  if (androidSdk == null)
    throwToolExit('No Android SDK found. Try setting the ANDROID_HOME environment variable.');

  List<String> validationResult = androidSdk.validateSdkWellFormed(requireApkSigner: false);
  if (validationResult.isNotEmpty) {
    validationResult.forEach(printError);
    throwToolExit('Try re-installing or updating your Android SDK.');
  }

  return buildGradleProject(buildMode, target);
}

Future<Null> buildApk(
  TargetPlatform platform, {
  String target,
  BuildMode buildMode: BuildMode.debug,
  DevFSContent kernelContent,
  bool applicationNeedsRebuild: false,
}) async {
  if (isProjectUsingGradle()) {
    return await buildAndroidWithGradle(
      platform,
      buildMode,
      force: false,
      target: target
    );
  } else {
    if (!fs.isFileSync(_kDefaultAndroidManifestPath))
      throwToolExit('Cannot build APK: missing $_kDefaultAndroidManifestPath.');

    return await buildAndroid(
      platform,
      buildMode,
      force: false,
      target: target,
      kernelContent: kernelContent,
      applicationNeedsRebuild: applicationNeedsRebuild,
    );
  }
}

Map<String, dynamic> _readBuildMeta(String buildDirectoryPath) {
  File buildMetaFile = fs.file(fs.path.join(buildDirectoryPath, 'build_meta.json'));
  if (buildMetaFile.existsSync())
    return JSON.decode(buildMetaFile.readAsStringSync());
  return <String, dynamic>{};
}

void _writeBuildMetaEntry(String buildDirectoryPath, String key, dynamic value) {
  Map<String, dynamic> meta = _readBuildMeta(buildDirectoryPath);
  meta[key] = value;
  File buildMetaFile = fs.file(fs.path.join(buildDirectoryPath, 'build_meta.json'));
  buildMetaFile.writeAsStringSync(toPrettyJson(meta));
}

String _getTargetBuildTypeToken(TargetPlatform platform, BuildMode buildMode, File outputBinary) {
  String buildType = getNameForTargetPlatform(platform) + '-' + getModeName(buildMode);
  if (artifacts is LocalEngineArtifacts) {
    LocalEngineArtifacts localEngineArtifacts = artifacts;
    buildType += ' [${localEngineArtifacts.engineOutPath}]';
  }
  if (outputBinary.existsSync())
    buildType += ' [${outputBinary.lastModifiedSync().millisecondsSinceEpoch}]';
  return buildType;
}
