// Copyright 2016 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 'dart:io';

import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
import 'package:path/path.dart' as path;
import 'package:web_socket_channel/io.dart';

import '../android/android_sdk.dart';
import '../application_package.dart';
import '../base/common.dart';
import '../base/os.dart';
import '../base/process.dart';
import '../base/utils.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../flx.dart' as flx;
import '../globals.dart';
import '../service_protocol.dart';
import '../toolchain.dart';
import 'adb.dart';
import 'android.dart';

const String _defaultAdbPath = 'adb';

// Path where the FLX bundle will be copied on the device.
const String _deviceBundlePath = '/data/local/tmp/dev.flx';

// Path where the snapshot will be copied on the device.
const String _deviceSnapshotPath = '/data/local/tmp/dev_snapshot.bin';

class AndroidDevices extends PollingDeviceDiscovery {
  AndroidDevices() : super('AndroidDevices');

  @override
  bool get supportsPlatform => true;

  @override
  List<Device> pollingGetDevices() => getAdbDevices();
}

class AndroidDevice extends Device {
  AndroidDevice(
    String id, {
    this.productID,
    this.modelID,
    this.deviceCodeName
  }) : super(id);

  final String productID;
  final String modelID;
  final String deviceCodeName;

  bool _isLocalEmulator;

  @override
  bool get isLocalEmulator {
    if (_isLocalEmulator == null) {
      // http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...)
      String value = runCheckedSync(adbCommandForDevice(['shell', 'getprop', 'ro.product.cpu.abi']));
      _isLocalEmulator = value.startsWith('x86');
    }

    return _isLocalEmulator;
  }

  _AdbLogReader _logReader;
  _AndroidDevicePortForwarder _portForwarder;

  List<String> adbCommandForDevice(List<String> args) {
    return <String>[androidSdk.adbPath, '-s', id]..addAll(args);
  }

  bool _isValidAdbVersion(String adbVersion) {
    // Sample output: 'Android Debug Bridge version 1.0.31'
    Match versionFields = new RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion);
    if (versionFields != null) {
      int majorVersion = int.parse(versionFields[1]);
      int minorVersion = int.parse(versionFields[2]);
      int patchVersion = int.parse(versionFields[3]);
      if (majorVersion > 1) {
        return true;
      }
      if (majorVersion == 1 && minorVersion > 0) {
        return true;
      }
      if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 32) {
        return true;
      }
      return false;
    }
    printError(
        'Unrecognized adb version string $adbVersion. Skipping version check.');
    return true;
  }

  bool _checkForSupportedAdbVersion() {
    if (androidSdk == null)
      return false;

    try {
      String adbVersion = runCheckedSync(<String>[androidSdk.adbPath, 'version']);
      if (_isValidAdbVersion(adbVersion))
        return true;
      printError('The ADB at "${androidSdk.adbPath}" is too old; please install version 1.0.32 or later.');
    } catch (error, trace) {
      printError('Error running ADB: $error', trace);
    }

    return false;
  }

  bool _checkForSupportedAndroidVersion() {
    try {
      // If the server is automatically restarted, then we get irrelevant
      // output lines like this, which we want to ignore:
      //   adb server is out of date.  killing..
      //   * daemon started successfully *
      runCheckedSync(<String>[androidSdk.adbPath, 'start-server']);

      // Sample output: '22'
      String sdkVersion = runCheckedSync(
        adbCommandForDevice(<String>['shell', 'getprop', 'ro.build.version.sdk'])
      ).trimRight();

      int sdkVersionParsed = int.parse(sdkVersion, onError: (String source) => null);
      if (sdkVersionParsed == null) {
        printError('Unexpected response from getprop: "$sdkVersion"');
        return false;
      }

      if (sdkVersionParsed < minApiLevel) {
        printError(
          'The Android version ($sdkVersion) on the target device is too old. Please '
          'use a $minVersionName (version $minApiLevel / $minVersionText) device or later.');
        return false;
      }

      return true;
    } catch (e) {
      printError('Unexpected failure from adb: $e');
      return false;
    }
  }

  String _getDeviceSha1Path(ApplicationPackage app) {
    return '/data/local/tmp/sky.${app.id}.sha1';
  }

  String _getDeviceApkSha1(ApplicationPackage app) {
    return runCheckedSync(adbCommandForDevice(<String>['shell', 'cat', _getDeviceSha1Path(app)]));
  }

  String _getSourceSha1(ApplicationPackage app) {
    File shaFile = new File('${app.localPath}.sha1');
    return shaFile.existsSync() ? shaFile.readAsStringSync() : '';
  }

  @override
  String get name => modelID;

  @override
  bool isAppInstalled(ApplicationPackage app) {
    // This call takes 400ms - 600ms.
    if (runCheckedSync(adbCommandForDevice(['shell', 'pm', 'path', app.id])).isEmpty)
      return false;

    // Check the application SHA.
    return _getDeviceApkSha1(app) == _getSourceSha1(app);
  }

  @override
  bool installApp(ApplicationPackage app) {
    if (!FileSystemEntity.isFileSync(app.localPath)) {
      printError('"${app.localPath}" does not exist.');
      return false;
    }

    if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion())
      return false;

    printStatus('Installing ${app.name} on device.');
    runCheckedSync(adbCommandForDevice(<String>['install', '-r', app.localPath]));
    runCheckedSync(adbCommandForDevice(<String>['shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]));
    return true;
  }

  Future<Null> _forwardPort(String service, int devicePort, int port) async {
    bool portWasZero = (port == null) || (port == 0);

    try {
      // Set up port forwarding for observatory.
      port = await portForwarder.forward(devicePort,
                                         hostPort: port);
      if (portWasZero)
        printStatus('$service listening on http://127.0.0.1:$port');
    } catch (e) {
      printError('Unable to forward port $port: $e');
    }
  }

  Future<bool> startBundle(AndroidApk apk, String bundlePath, {
    bool checked: true,
    bool traceStartup: false,
    String route,
    bool clearLogs: false,
    bool startPaused: false,
    int observatoryPort: observatoryDefaultPort,
    int diagnosticPort: diagnosticDefaultPort
  }) async {
    printTrace('$this startBundle');

    if (!FileSystemEntity.isFileSync(bundlePath)) {
      printError('Cannot find $bundlePath');
      return false;
    }

    if (clearLogs)
      this.clearLogs();

    runCheckedSync(adbCommandForDevice(<String>['push', bundlePath, _deviceBundlePath]));

    ServiceProtocolDiscovery observatoryDiscovery =
        new ServiceProtocolDiscovery(logReader, ServiceProtocolDiscovery.kObservatoryService);
    ServiceProtocolDiscovery diagnosticDiscovery =
        new ServiceProtocolDiscovery(logReader, ServiceProtocolDiscovery.kDiagnosticService);

    // We take this future here but do not wait for completion until *after* we
    // start the bundle.
    Future<List<int>> scrapeServicePorts = Future.wait(
        <Future<int>>[observatoryDiscovery.nextPort(), diagnosticDiscovery.nextPort()]);

    List<String> cmd = adbCommandForDevice(<String>[
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
      '-d', _deviceBundlePath,
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
      '--ez', 'enable-background-compilation', 'true',
    ]);
    if (checked)
      cmd.addAll(<String>['--ez', 'enable-checked-mode', 'true']);
    if (traceStartup)
      cmd.addAll(<String>['--ez', 'trace-startup', 'true']);
    if (startPaused)
      cmd.addAll(<String>['--ez', 'start-paused', 'true']);
    if (route != null)
      cmd.addAll(<String>['--es', 'route', route]);
    cmd.add(apk.launchActivity);
    String result = runCheckedSync(cmd);
    // This invocation returns 0 even when it fails.
    if (result.contains('Error: ')) {
      printError(result.trim());
      return false;
    }

    // Wait for the service protocol port here. This will complete once the
    // device has printed "Observatory is listening on...".
    printTrace('Waiting for observatory port to be available...');

    try {
      List<int> devicePorts = await scrapeServicePorts.timeout(new Duration(seconds: 12));
      int observatoryDevicePort = devicePorts[0];
      int diagnosticDevicePort = devicePorts[1];
      printTrace('observatory port = $observatoryDevicePort');
      await _forwardPort(ServiceProtocolDiscovery.kObservatoryService,
          observatoryDevicePort, observatoryPort);
      await _forwardPort(ServiceProtocolDiscovery.kDiagnosticService,
          diagnosticDevicePort, diagnosticPort);
      return true;
    } catch (error) {
      if (error is TimeoutException)
        printError('Timed out while waiting for a debug connection.');
      else
        printError('Error waiting for a debug connection: $error');

      return false;
    }
  }

  @override
  Future<bool> startApp(
    ApplicationPackage package,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
    bool clearLogs: false,
    bool startPaused: false,
    int observatoryPort: observatoryDefaultPort,
    int diagnosticPort: diagnosticDefaultPort,
    Map<String, dynamic> platformArgs
  }) async {
    if (!_checkForSupportedAdbVersion() || !_checkForSupportedAndroidVersion())
      return false;

    String localBundlePath = await flx.buildFlx(
      toolchain,
      mainPath: mainPath,
      includeRobotoFonts: false
    );

    printTrace('Starting bundle for $this.');

    if (await startBundle(
      package,
      localBundlePath,
      checked: checked,
      traceStartup: platformArgs['trace-startup'],
      route: route,
      clearLogs: clearLogs,
      startPaused: startPaused,
      observatoryPort: observatoryPort,
      diagnosticPort: diagnosticPort
    )) {
      return true;
    } else {
      return false;
    }
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) {
    List<String> command = adbCommandForDevice(<String>['shell', 'am', 'force-stop', app.id]);
    return runCommandAndStreamOutput(command).then((int exitCode) => exitCode == 0);
  }

  @override
  TargetPlatform get platform => isLocalEmulator ? TargetPlatform.android_x64 : TargetPlatform.android_arm;

  @override
  void clearLogs() {
    runSync(adbCommandForDevice(<String>['logcat', '-c']));
  }

  @override
  DeviceLogReader get logReader {
    if (_logReader == null)
      _logReader = new _AdbLogReader(this);
    return _logReader;
  }

  @override
  DevicePortForwarder get portForwarder {
    if (_portForwarder == null)
      _portForwarder = new _AndroidDevicePortForwarder(this);

    return _portForwarder;
  }

  /// Return the most recent timestamp in the Android log or `null` if there is
  /// no available timestamp. The format can be passed to logcat's -T option.
  String get lastLogcatTimestamp {
    String output = runCheckedSync(adbCommandForDevice(<String>[
      'logcat', '-v', 'time', '-t', '1'
    ]));

    RegExp timeRegExp = new RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}', multiLine: true);
    Match timeMatch = timeRegExp.firstMatch(output);
    return timeMatch?.group(0);
  }

  Future<rpc.Client> _connectToObservatory(int observatoryPort) async {
    Uri uri = new Uri(scheme: 'ws', host: '127.0.0.1', port: observatoryPort, path: 'ws');
    WebSocket ws = await WebSocket.connect(uri.toString());
    rpc.Client client = new rpc.Client(new IOWebSocketChannel(ws));
    client.listen();
    return client;
  }

  Future<Null> startTracing(AndroidApk apk, int observatoryPort) async {
    rpc.Client client;
    try {
      client = await _connectToObservatory(observatoryPort);
    } catch (e) {
      printError('Error connecting to observatory: $e');
      return;
    }

    await client.sendRequest('_setVMTimelineFlags',
        {'recordedStreams': ['Compiler', 'Dart', 'Embedder', 'GC']}
    );
    await client.sendRequest('_clearVMTimeline');
  }

  Future<String> stopTracing(AndroidApk apk, int observatoryPort, String outPath) async {
    rpc.Client client;
    try {
      client = await _connectToObservatory(observatoryPort);
    } catch (e) {
      printError('Error connecting to observatory: $e');
      return null;
    }

    await client.sendRequest('_setVMTimelineFlags', {'recordedStreams': '[]'});

    File localFile;
    if (outPath != null) {
      localFile = new File(outPath);
    } else {
      localFile = getUniqueFile(Directory.current, 'trace', 'json');
    }

    Map<String, dynamic> response = await client.sendRequest('_getVMTimeline');
    List<dynamic> traceEvents = response['traceEvents'];

    IOSink sink = localFile.openWrite();
    Stream<Object> streamIn = new Stream<Object>.fromIterable(<Object>[traceEvents]);
    Stream<List<int>> streamOut = new JsonUtf8Encoder().bind(streamIn);
    await sink.addStream(streamOut);
    await sink.close();

    return path.basename(localFile.path);
  }

  @override
  bool isSupported() => true;

  Future<bool> refreshSnapshot(String activity, String snapshotPath) async {
    if (!FileSystemEntity.isFileSync(snapshotPath)) {
      printError('Cannot find $snapshotPath');
      return false;
    }

    runCheckedSync(adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath]));

    List<String> cmd = adbCommandForDevice(<String>[
      'shell', 'am', 'start',
      '-a', 'android.intent.action.RUN',
      '-d', _deviceBundlePath,
      '-f', '0x20000000',  // FLAG_ACTIVITY_SINGLE_TOP
      '--es', 'snapshot', _deviceSnapshotPath,
      activity,
    ]);
    runCheckedSync(cmd);
    return true;
  }

  @override
  bool get supportsScreenshot => true;

  @override
  Future<bool> takeScreenshot(File outputFile) {
    const String remotePath = '/data/local/tmp/flutter_screenshot.png';

    runCheckedSync(adbCommandForDevice(<String>['shell', 'screencap', '-p', remotePath]));
    runCheckedSync(adbCommandForDevice(<String>['pull', remotePath, outputFile.path]));
    runCheckedSync(adbCommandForDevice(<String>['shell', 'rm', remotePath]));

    return new Future<bool>.value(true);
  }
}

List<AndroidDevice> getAdbDevices() {
  String adbPath = getAdbPath(androidSdk);
  if (adbPath == null)
    return <AndroidDevice>[];

  List<AndroidDevice> devices = [];

  List<String> output = runSync(<String>[adbPath, 'devices', '-l']).trim().split('\n');

  // 015d172c98400a03       device usb:340787200X product:nakasi model:Nexus_7 device:grouper
  RegExp deviceRegExLong = new RegExp(
      r'^(\S+)\s+device\s+.*product:(\S+)\s+model:(\S+)\s+device:(\S+)$');

  // 0149947A0D01500C       device usb:340787200X
  // emulator-5612          host features:shell_2
  RegExp deviceRegExShort = new RegExp(r'^(\S+)\s+(\S+)\s+\S+$');

  for (String line in output) {
    // Skip lines like: * daemon started successfully *
    if (line.startsWith('* daemon '))
      continue;

    if (line.startsWith('List of devices'))
      continue;

    if (deviceRegExLong.hasMatch(line)) {
      Match match = deviceRegExLong.firstMatch(line);
      String deviceID = match[1];
      String productID = match[2];
      String modelID = match[3];
      String deviceCodeName = match[4];

      if (modelID != null)
        modelID = cleanAdbDeviceName(modelID);

      devices.add(new AndroidDevice(
        deviceID,
        productID: productID,
        modelID: modelID,
        deviceCodeName: deviceCodeName
      ));
    } else if (deviceRegExShort.hasMatch(line)) {
      Match match = deviceRegExShort.firstMatch(line);
      String deviceID = match[1];
      String deviceState = match[2];

      if (deviceState == 'unauthorized') {
        printError(
          'Device $deviceID is not authorized.\n'
          'You might need to check your device for an authorization dialog.'
        );
      } else if (deviceState == 'offline') {
        printError('Device $deviceID is offline.');
      } else {
        devices.add(new AndroidDevice(deviceID));
      }
    } else {
      printError(
        'Unexpected failure parsing device information from adb output:\n'
        '$line\n'
        'Please report a bug at https://github.com/flutter/flutter/issues/new');
    }
  }

  return devices;
}

/// A log reader that logs from `adb logcat`.
class _AdbLogReader extends DeviceLogReader {
  _AdbLogReader(this.device);

  final AndroidDevice device;

  final StreamController<String> _linesStreamController =
      new StreamController<String>.broadcast();

  Process _process;
  StreamSubscription<String> _stdoutSubscription;
  StreamSubscription<String> _stderrSubscription;

  @override
  Stream<String> get lines => _linesStreamController.stream;

  @override
  String get name => device.name;

  @override
  bool get isReading => _process != null;

  @override
  Future<int> get finished => _process != null ? _process.exitCode : new Future<int>.value(0);

  @override
  Future<Null> start() async {
    if (_process != null)
      throw new StateError('_AdbLogReader must be stopped before it can be started.');

    // Start the adb logcat process.
    List<String> args = <String>['logcat', '-v', 'tag'];
    String lastTimestamp = device.lastLogcatTimestamp;
    if (lastTimestamp != null)
      args.addAll(<String>['-T', lastTimestamp]);
    args.addAll(<String>[
      '-s', 'flutter:V', 'FlutterMain:V', 'FlutterView:V', 'AndroidRuntime:W', 'ActivityManager:W', 'System.err:W', '*:F'
    ]);
    _process = await runCommand(device.adbCommandForDevice(args));
    _stdoutSubscription =
        _process.stdout.transform(UTF8.decoder)
                       .transform(const LineSplitter()).listen(_onLine);
    _stderrSubscription =
        _process.stderr.transform(UTF8.decoder)
                       .transform(const LineSplitter()).listen(_onLine);
    _process.exitCode.then(_onExit);
  }

  @override
  Future<Null> stop() async {
    if (_process == null)
      throw new StateError('_AdbLogReader must be started before it can be stopped.');

    _stdoutSubscription?.cancel();
    _stdoutSubscription = null;
    _stderrSubscription?.cancel();
    _stderrSubscription = null;
    await _process.kill();
    _process = null;
  }

  void _onExit(int exitCode) {
    _stdoutSubscription?.cancel();
    _stdoutSubscription = null;
    _stderrSubscription?.cancel();
    _stderrSubscription = null;
    _process = null;
  }

  void _onLine(String line) {
    // Filter out some noisy ActivityManager notifications.
    if (line.startsWith('W/ActivityManager: getRunningAppProcesses'))
      return;

    _linesStreamController.add(line);
  }

  @override
  int get hashCode => name.hashCode;

  @override
  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    if (other is! _AdbLogReader)
      return false;
    return other.device.id == device.id;
  }
}

class _AndroidDevicePortForwarder extends DevicePortForwarder {
  _AndroidDevicePortForwarder(this.device);

  final AndroidDevice device;

  static int _extractPort(String portString) {
    return int.parse(portString.trim(), onError: (_) => null);
  }

  @override
  List<ForwardedPort> get forwardedPorts {
    final List<ForwardedPort> ports = <ForwardedPort>[];

    String stdout = runCheckedSync(device.adbCommandForDevice(
      <String>['forward', '--list']
    ));

    List<String> lines = LineSplitter.split(stdout).toList();
    for (String line in lines) {
      if (line.startsWith(device.id)) {
        List<String> splitLine = line.split("tcp:");

        // Sanity check splitLine.
        if (splitLine.length != 3)
          continue;

        // Attempt to extract ports.
        int hostPort = _extractPort(splitLine[1]);
        int devicePort = _extractPort(splitLine[2]);

        // Failed, skip.
        if ((hostPort == null) || (devicePort == null))
          continue;

        ports.add(new ForwardedPort(hostPort, devicePort));
      }
    }

    return ports;
  }

  @override
  Future<int> forward(int devicePort, { int hostPort }) async {
    if ((hostPort == null) || (hostPort == 0)) {
      // Auto select host port.
      hostPort = await findAvailablePort();
    }

    runCheckedSync(device.adbCommandForDevice(
      <String>['forward', 'tcp:$hostPort', 'tcp:$devicePort']
    ));

    return hostPort;
  }

  @override
  Future<Null> unforward(ForwardedPort forwardedPort) async {
    runCheckedSync(device.adbCommandForDevice(
      <String>['forward', '--remove', 'tcp:${forwardedPort.hostPort}']
    ));
  }
}
