// 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' show JSON;
import 'dart:io';

import 'package:path/path.dart' as path;

import '../application_package.dart';
import '../base/common.dart';
import '../base/context.dart';
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
import '../flx.dart' as flx;
import '../globals.dart';
import '../toolchain.dart';
import 'mac.dart';

const String _xcrunPath = '/usr/bin/xcrun';

const String _simulatorPath =
  '/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/MacOS/Simulator';

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

  bool get supportsPlatform => Platform.isMacOS;
  List<Device> pollingGetDevices() => IOSSimulatorUtils.instance.getAttachedDevices();
}

class IOSSimulatorUtils {
  /// Returns [IOSSimulatorUtils] active in the current app context (i.e. zone).
  static IOSSimulatorUtils get instance {
    return context[IOSSimulatorUtils] ?? (context[IOSSimulatorUtils] = new IOSSimulatorUtils());
  }

  List<IOSSimulator> getAttachedDevices() {
    if (!XCode.instance.isInstalledAndMeetsVersionCheck)
      return <IOSSimulator>[];

    return SimControl.instance.getConnectedDevices().map((SimDevice device) {
      return new IOSSimulator(device.udid, name: device.name);
    }).toList();
  }
}

/// A wrapper around the `simctl` command line tool.
class SimControl {
  /// Returns [SimControl] active in the current app context (i.e. zone).
  static SimControl get instance => context[SimControl] ?? (context[SimControl] = new SimControl());

  Future<bool> boot({String deviceId}) async {
    if (_isAnyConnected())
      return true;

    if (deviceId == null) {
      runDetached([_simulatorPath]);
      Future<bool> checkConnection([int attempts = 20]) async {
        if (attempts == 0) {
          printStatus('Timed out waiting for iOS Simulator to boot.');
          return false;
        }
        if (!_isAnyConnected()) {
          printStatus('Waiting for iOS Simulator to boot...');
          return await new Future.delayed(new Duration(milliseconds: 500),
            () => checkConnection(attempts - 1)
          );
        }
        return true;
      }
      return await checkConnection();
    } else {
      try {
        runCheckedSync([_xcrunPath, 'simctl', 'boot', deviceId]);
        return true;
      } catch (e) {
        printError('Unable to boot iOS Simulator $deviceId: ', e);
        return false;
      }
    }

    return false;
  }

  /// Returns a list of all available devices, both potential and connected.
  List<SimDevice> getDevices() {
    // {
    //   "devices" : {
    //     "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [
    //       {
    //         "state" : "Shutdown",
    //         "availability" : " (unavailable, runtime profile not found)",
    //         "name" : "iPhone 4s",
    //         "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B"
    //       },
    //       ...

    List<String> args = <String>['simctl', 'list', '--json', 'devices'];
    printTrace('$_xcrunPath ${args.join(' ')}');
    ProcessResult results = Process.runSync(_xcrunPath, args);
    if (results.exitCode != 0) {
      printError('Error executing simctl: ${results.exitCode}\n${results.stderr}');
      return <SimDevice>[];
    }

    List<SimDevice> devices = <SimDevice>[];

    Map<String, Map<String, dynamic>> data = JSON.decode(results.stdout);
    Map<String, dynamic> devicesSection = data['devices'];

    for (String deviceCategory in devicesSection.keys) {
      List<dynamic> devicesData = devicesSection[deviceCategory];

      for (Map<String, String> data in devicesData) {
        devices.add(new SimDevice(deviceCategory, data));
      }
    }

    return devices;
  }

  /// Returns all the connected simulator devices.
  List<SimDevice> getConnectedDevices() {
    return getDevices().where((SimDevice device) => device.isBooted).toList();
  }

  StreamController<List<SimDevice>> _trackDevicesControler;

  /// Listens to changes in the set of connected devices. The implementation
  /// currently uses polling. Callers should be careful to call cancel() on any
  /// stream subscription when finished.
  ///
  /// TODO(devoncarew): We could investigate using the usbmuxd protocol directly.
  Stream<List<SimDevice>> trackDevices() {
    if (_trackDevicesControler == null) {
      Timer timer;
      Set<String> deviceIds = new Set<String>();

      _trackDevicesControler = new StreamController.broadcast(
        onListen: () {
          timer = new Timer.periodic(new Duration(seconds: 4), (Timer timer) {
            List<SimDevice> devices = getConnectedDevices();

            if (_updateDeviceIds(devices, deviceIds)) {
              _trackDevicesControler.add(devices);
            }
          });
        }, onCancel: () {
          timer?.cancel();
          deviceIds.clear();
        }
      );
    }

    return _trackDevicesControler.stream;
  }

  /// Update the cached set of device IDs and return whether there were any changes.
  bool _updateDeviceIds(List<SimDevice> devices, Set<String> deviceIds) {
    Set<String> newIds = new Set<String>.from(devices.map((SimDevice device) => device.udid));

    bool changed = false;

    for (String id in newIds) {
      if (!deviceIds.contains(id))
        changed = true;
    }

    for (String id in deviceIds) {
      if (!newIds.contains(id))
        changed = true;
    }

    deviceIds.clear();
    deviceIds.addAll(newIds);

    return changed;
  }

  bool _isAnyConnected() => getConnectedDevices().isNotEmpty;

  void install(String deviceId, String appPath) {
    runCheckedSync([_xcrunPath, 'simctl', 'install', deviceId, appPath]);
  }

  void launch(String deviceId, String appIdentifier, [List<String> launchArgs]) {
    List<String> args = [_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier];
    if (launchArgs != null)
      args.addAll(launchArgs);
    runCheckedSync(args);
  }
}

class SimDevice {
  SimDevice(this.category, this.data);

  final String category;
  final Map<String, String> data;

  String get state => data['state'];
  String get availability => data['availability'];
  String get name => data['name'];
  String get udid => data['udid'];

  bool get isBooted => state == 'Booted';
}

class IOSSimulator extends Device {
  IOSSimulator(String id, { this.name }) : super(id);

  final String name;

  bool get isLocalEmulator => true;

  String get xcrunPath => path.join('/usr', 'bin', 'xcrun');

  String _getSimulatorPath() {
    return path.join(homeDirectory, 'Library', 'Developer', 'CoreSimulator', 'Devices', id);
  }

  String _getSimulatorAppHomeDirectory(ApplicationPackage app) {
    String simulatorPath = _getSimulatorPath();
    if (simulatorPath == null)
      return null;
    return path.join(simulatorPath, 'data');
  }

  @override
  bool installApp(ApplicationPackage app) {
    try {
      SimControl.instance.install(id, app.localPath);
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  bool isSupported() {
    if (!Platform.isMacOS) {
      _supportMessage = "Not supported on a non Mac host";
      return false;
    }

    // Step 1: Check if the device is part of a blacklisted category.
    //         We do not support WatchOS or tvOS devices.

    RegExp blacklist = new RegExp(r'Apple (TV|Watch)', caseSensitive: false);

    if (blacklist.hasMatch(name)) {
      _supportMessage = "Flutter does not support either the Apple TV or Watch. Choose an iPhone 5s or above.";
      return false;
    }

    // Step 2: Check if the device must be rejected because of its version.
    //         There is an artitifical check on older simulators where arm64
    //         targetted applications cannot be run (even though the
    //         Flutter runner on the simulator is completely different).

    RegExp versionExp = new RegExp(r'iPhone ([0-9])+');
    Match match = versionExp.firstMatch(name);

    if (match == null) {
      // Not an iPhone. All available non-iPhone simulators are compatible.
      return true;
    }

    if (int.parse(match.group(1)) > 5) {
      // iPhones 6 and above are always fine.
      return true;
    }

    // The 's' subtype of 5 is compatible.
    if (name.contains('iPhone 5s')) {
      return true;
    }

    _supportMessage = "The simulator version is too old. Choose an iPhone 5s or above.";
    return false;
  }

  String _supportMessage;

  @override
  String supportMessage() {
    if (isSupported()) {
      return "Supported";
    }

    return _supportMessage != null ? _supportMessage : "Unknown";
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
      return FileSystemEntity.isDirectorySync(simulatorHomeDirectory);
    } catch (e) {
      return false;
    }
  }

  @override
  Future<bool> startApp(
    ApplicationPackage app,
    Toolchain toolchain, {
    String mainPath,
    String route,
    bool checked: true,
    bool clearLogs: false,
    bool startPaused: false,
    int debugPort: observatoryDefaultPort,
    Map<String, dynamic> platformArgs
  }) async {
    printTrace('Building ${app.name} for $id.');

    if (clearLogs)
      this.clearLogs();

    if (!(await _setupUpdatedApplicationBundle(app, toolchain)))
      return false;

    // Prepare launch arguments.
    List<String> args = <String>[
      "--flx=${path.absolute(path.join('build', 'app.flx'))}",
      "--dart-main=${path.absolute(mainPath)}",
      "--package-root=${path.absolute('packages')}",
    ];

    if (checked)
      args.add("--enable-checked-mode");

    if (startPaused)
      args.add("--start-paused");

    if (debugPort != observatoryDefaultPort)
      args.add("--observatory-port=$debugPort");

    // Launch the updated application in the simulator.
    try {
      SimControl.instance.launch(id, app.id, args);
    } catch (error) {
      printError('$error');
      return false;
    }

    printTrace('Successfully started ${app.name} on $id.');

    return true;
  }

  bool _applicationIsInstalledAndRunning(ApplicationPackage app) {
    bool isInstalled = exitsHappy([
      'xcrun',
      'simctl',
      'get_app_container',
      'booted',
      app.id,
    ]);

    bool isRunning = exitsHappy([
      '/usr/bin/killall',
      'Runner',
    ]);

    return isInstalled && isRunning;
  }

  Future<bool> _setupUpdatedApplicationBundle(ApplicationPackage app, Toolchain toolchain) async {
    bool sideloadResult = await _sideloadUpdatedAssetsForInstalledApplicationBundle(app, toolchain);

    if (!sideloadResult)
      return false;

    if (!_applicationIsInstalledAndRunning(app))
      return _buildAndInstallApplicationBundle(app);

    return true;
  }

  Future<bool> _buildAndInstallApplicationBundle(ApplicationPackage app) async {
    // Step 1: Build the Xcode project.
    bool buildResult = await buildIOSXcodeProject(app, buildForDevice: false);
    if (!buildResult) {
      printError('Could not build the application for the simulator.');
      return false;
    }

    // Step 2: Assert that the Xcode project was successfully built.
    Directory bundle = new Directory(path.join(app.localPath, 'build', 'Release-iphonesimulator', 'Runner.app'));
    bool bundleExists = await bundle.exists();
    if (!bundleExists) {
      printError('Could not find the built application bundle at ${bundle.path}.');
      return false;
    }

    // Step 3: Install the updated bundle to the simulator.
    SimControl.instance.install(id, path.absolute(bundle.path));
    return true;
  }

  Future<bool> _sideloadUpdatedAssetsForInstalledApplicationBundle(
      ApplicationPackage app, Toolchain toolchain) async {
    return (await flx.build(toolchain, precompiledSnapshot: true)) == 0;
  }

  @override
  Future<bool> stopApp(ApplicationPackage app) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  Future<bool> pushFile(
      ApplicationPackage app, String localFile, String targetFile) async {
    if (Platform.isMacOS) {
      String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app);
      runCheckedSync(<String>['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]);
      return true;
    }
    return false;
  }

  String get logFilePath {
    return path.join(homeDirectory, 'Library', 'Logs', 'CoreSimulator', id, 'system.log');
  }

  @override
  TargetPlatform get platform => TargetPlatform.iOSSimulator;

  DeviceLogReader createLogReader() => new _IOSSimulatorLogReader(this);

  void clearLogs() {
    File logFile = new File(logFilePath);
    if (logFile.existsSync()) {
      RandomAccessFile randomFile = logFile.openSync(mode: FileMode.WRITE);
      randomFile.truncateSync(0);
      randomFile.closeSync();
    }
  }

  void ensureLogsExists() {
    File logFile = new File(logFilePath);
    if (!logFile.existsSync())
      logFile.writeAsBytesSync(<int>[]);
  }
}

class _IOSSimulatorLogReader extends DeviceLogReader {
  _IOSSimulatorLogReader(this.device);

  final IOSSimulator device;

  bool _lastWasFiltered = false;

  String get name => device.name;

  Future<int> logs({ bool clear: false, bool showPrefix: false }) async {
    if (clear)
      device.clearLogs();

    device.ensureLogsExists();

    // Match the log prefix (in order to shorten it):
    //   'Jan 29 01:31:44 devoncarew-macbookpro3 SpringBoard[96648]: ...'
    RegExp mapRegex = new RegExp(r'\S+ +\S+ +\S+ \S+ (.+)\[\d+\]\)?: (.*)$');
    // Jan 31 19:23:28 --- last message repeated 1 time ---
    RegExp lastMessageRegex = new RegExp(r'\S+ +\S+ +\S+ --- (.*) ---$');

    // This filter matches many Flutter lines in the log:
    // new RegExp(r'(FlutterRunner|flutter.runner.Runner|$id)'), but it misses
    // a fair number, including ones that would be useful in diagnosing crashes.
    // For now, we're not filtering the log file (but do clear it with each run).

    Future<int> result = runCommandAndStreamOutput(
      <String>['tail', '-n', '+0', '-F', device.logFilePath],
      prefix: showPrefix ? '[$name] ' : '',
      mapFunction: (String string) {
        Match match = mapRegex.matchAsPrefix(string);
        if (match != null) {
          _lastWasFiltered = true;

          // Filter out some messages that clearly aren't related to Flutter.
          if (string.contains(': could not find icon for representation -> com.apple.'))
            return null;
          String category = match.group(1);
          String content = match.group(2);
          if (category == 'Game Center' || category == 'itunesstored' || category == 'nanoregistrylaunchd' ||
              category == 'mstreamd' || category == 'syncdefaultsd' || category == 'companionappd' ||
              category == 'searchd')
            return null;

          _lastWasFiltered = false;

          if (category == 'Runner')
            return content;
          return '$category: $content';
        }
        match = lastMessageRegex.matchAsPrefix(string);
        if (match != null && !_lastWasFiltered)
          return '(${match.group(1)})';
        return string;
      }
    );

    // Track system.log crashes.
    // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]...
    runCommandAndStreamOutput(
      <String>['tail', '-F', '/private/var/log/system.log'],
      prefix: showPrefix ? '[$name] ' : '',
      filter: new RegExp(r' FlutterRunner\[\d+\] '),
      mapFunction: (String string) {
        Match match = mapRegex.matchAsPrefix(string);
        return match == null ? string : '${match.group(1)}: ${match.group(2)}';
      }
    );

    return await result;
  }

  int get hashCode => device.logFilePath.hashCode;

  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
    if (other is! _IOSSimulatorLogReader)
      return false;
    return other.device.logFilePath == device.logFilePath;
  }
}