// 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 '../application_package.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/os.dart';
import '../base/platform.dart' as p;
import '../base/process.dart';
import '../base/process_manager.dart';
import '../build_info.dart';
import '../device.dart';
import '../doctor.dart';
import '../globals.dart';
import '../protocol_discovery.dart';
import 'mac.dart';

const String _ideviceinstallerInstructions =
    'To work with iOS devices, please install ideviceinstaller.\n'
    'If you use homebrew, you can install it with "\$ brew install ideviceinstaller".';

const Duration kPortForwardTimeout = const Duration(seconds: 10);

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

  @override
  bool get supportsPlatform => p.platform.isMacOS;

  @override
  List<Device> pollingGetDevices() => IOSDevice.getAttachedDevices();
}

class IOSDevice extends Device {
  IOSDevice(String id, { this.name }) : super(id) {
    _installerPath = _checkForCommand('ideviceinstaller');
    _listerPath = _checkForCommand('idevice_id');
    _informerPath = _checkForCommand('ideviceinfo');
    _iproxyPath = _checkForCommand('iproxy');
    _debuggerPath = _checkForCommand('idevicedebug');
    _loggerPath = _checkForCommand('idevicesyslog');
    _pusherPath = _checkForCommand(
        'ios-deploy',
        'To copy files to iOS devices, please install ios-deploy. '
        'You can do this using homebrew as follows:\n'
        '\$ brew tap flutter/flutter\n'
        '\$ brew install ios-deploy');
  }

  String _installerPath;
  String get installerPath => _installerPath;

  String _listerPath;
  String get listerPath => _listerPath;

  String _informerPath;
  String get informerPath => _informerPath;

  String _iproxyPath;
  String get iproxyPath => _iproxyPath;

  String _debuggerPath;
  String get debuggerPath => _debuggerPath;

  String _loggerPath;
  String get loggerPath => _loggerPath;

  String _pusherPath;
  String get pusherPath => _pusherPath;

  @override
  bool get supportsHotMode => true;

  @override
  final String name;

  Map<ApplicationPackage, _IOSDeviceLogReader> _logReaders;

  _IOSDevicePortForwarder _portForwarder;

  @override
  bool get isLocalEmulator => false;

  @override
  bool get supportsStartPaused => false;

  static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
    if (!doctor.iosWorkflow.hasIDeviceId)
      return <IOSDevice>[];

    List<IOSDevice> devices = <IOSDevice>[];
    for (String id in _getAttachedDeviceIDs(mockIOS)) {
      String name = IOSDevice._getDeviceInfo(id, 'DeviceName', mockIOS);
      devices.add(new IOSDevice(id, name: name));
    }
    return devices;
  }

  static Iterable<String> _getAttachedDeviceIDs([IOSDevice mockIOS]) {
    String listerPath = (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id');
    try {
      String output = runSync(<String>[listerPath, '-l']);
      return output.trim().split('\n').where((String s) => s != null && s.isNotEmpty);
    } catch (e) {
      return <String>[];
    }
  }

  static String _getDeviceInfo(String deviceID, String infoKey, [IOSDevice mockIOS]) {
    String informerPath = (mockIOS != null)
        ? mockIOS.informerPath
        : _checkForCommand('ideviceinfo');
    return runSync(<String>[informerPath, '-k', infoKey, '-u', deviceID]).trim();
  }

  static final Map<String, String> _commandMap = <String, String>{};
  static String _checkForCommand(
    String command, [
    String macInstructions = _ideviceinstallerInstructions
  ]) {
    return _commandMap.putIfAbsent(command, () {
      try {
        command = runCheckedSync(<String>['which', command]).trim();
      } catch (e) {
        if (p.platform.isMacOS) {
          printError('$command not found. $macInstructions');
        } else {
          printError('Cannot control iOS devices or simulators. $command is not available on your platform.');
        }
      }
      return command;
    });
  }

  @override
  bool isAppInstalled(ApplicationPackage app) {
    try {
      String apps = runCheckedSync(<String>[installerPath, '--list-apps']);
      if (new RegExp(app.id, multiLine: true).hasMatch(apps)) {
        return true;
      }
    } catch (e) {
      return false;
    }
    return false;
  }

  @override
  bool installApp(ApplicationPackage app) {
    IOSApp iosApp = app;
    Directory bundle = fs.directory(iosApp.deviceBundlePath);
    if (!bundle.existsSync()) {
      printError("Could not find application bundle at ${bundle.path}; have you run 'flutter build ios'?");
      return false;
    }

    try {
      runCheckedSync(<String>[installerPath, '-i', iosApp.deviceBundlePath]);
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  bool uninstallApp(ApplicationPackage app) {
    try {
      runCheckedSync(<String>[installerPath, '-U', app.id]);
      return true;
    } catch (e) {
      return false;
    }
  }

  @override
  bool isSupported() => true;

  @override
  Future<LaunchResult> startApp(
    ApplicationPackage app,
    BuildMode mode, {
    String mainPath,
    String route,
    DebuggingOptions debuggingOptions,
    Map<String, dynamic> platformArgs,
    bool prebuiltApplication: false,
    bool applicationNeedsRebuild: false,
  }) async {
    if (!prebuiltApplication) {
      // TODO(chinmaygarde): Use checked, mainPath, route.
      // TODO(devoncarew): Handle startPaused, debugPort.
      printTrace('Building ${app.name} for $id');

      // Step 1: Build the precompiled/DBC application if necessary.
      XcodeBuildResult buildResult = await buildXcodeProject(app: app, mode: mode, target: mainPath, buildForDevice: true);
      if (!buildResult.success) {
        printError('Could not build the precompiled application for the device.');
        diagnoseXcodeBuildFailure(buildResult);
        printError('');
        return new LaunchResult.failed();
      }
    } else {
      if (!installApp(app))
        return new LaunchResult.failed();
    }

    // Step 2: Check that the application exists at the specified path.
    IOSApp iosApp = app;
    Directory bundle = fs.directory(iosApp.deviceBundlePath);
    if (!bundle.existsSync()) {
      printError('Could not find the built application bundle at ${bundle.path}.');
      return new LaunchResult.failed();
    }

    // Step 3: Attempt to install the application on the device.
    List<String> launchArguments = <String>["--enable-dart-profiling"];

    if (debuggingOptions.startPaused)
      launchArguments.add("--start-paused");

    if (debuggingOptions.debuggingEnabled) {
      launchArguments.add("--enable-checked-mode");

      // Note: We do NOT need to set the observatory port since this is going to
      // be setup on the device. Let it pick a port automatically. We will check
      // the port picked and scrape that later.
    }

    if (platformArgs['trace-startup'] ?? false)
      launchArguments.add('--trace-startup');

    List<String> launchCommand = <String>[
      '/usr/bin/env',
      'ios-deploy',
      '--id',
      id,
      '--bundle',
      bundle.path,
      '--justlaunch',
    ];

    if (launchArguments.length > 0) {
      launchCommand.add('--args');
      launchCommand.add('${launchArguments.join(" ")}');
    }

    int installationResult = -1;
    Uri localObsUri;
    Uri localDiagUri;

    if (!debuggingOptions.debuggingEnabled) {
      // If debugging is not enabled, just launch the application and continue.
      printTrace("Debugging is not enabled");
      installationResult = await runCommandAndStreamOutput(launchCommand, trace: true);
    } else {
      // Debugging is enabled, look for the observatory and diagnostic server
      // ports post launch.
      printTrace("Debugging is enabled, connecting to observatory and the diagnostic server");

      // TODO(danrubel): The Android device class does something similar to this code below.
      // The various Device subclasses should be refactored and common code moved into the superclass.
      ProtocolDiscovery observatoryDiscovery = new ProtocolDiscovery.observatory(
        getLogReader(app: app), portForwarder: portForwarder, hostPort: debuggingOptions.observatoryPort);
      ProtocolDiscovery diagnosticDiscovery = new ProtocolDiscovery.diagnosticService(
        getLogReader(app: app), portForwarder: portForwarder, hostPort: debuggingOptions.diagnosticPort);

      Future<Uri> forwardObsUri = observatoryDiscovery.nextUri();
      Future<Uri> forwardDiagUri;
      if (debuggingOptions.buildMode == BuildMode.debug) {
        forwardDiagUri = diagnosticDiscovery.nextUri();
      } else {
        forwardDiagUri = new Future<Uri>.value(null);
      }

      Future<int> launch = runCommandAndStreamOutput(launchCommand, trace: true);

      List<Uri> uris = await launch.then<List<Uri>>((int result) async {
        installationResult = result;

        if (result != 0) {
          printTrace("Failed to launch the application on device.");
          return <Uri>[null, null];
        }

        printTrace("Application launched on the device. Attempting to forward ports.");
        return await Future.wait(<Future<Uri>>[forwardObsUri, forwardDiagUri]);
      }).whenComplete(() {
        observatoryDiscovery.cancel();
        diagnosticDiscovery.cancel();
      });

      localObsUri = uris[0];
      localDiagUri = uris[1];
    }

    if (installationResult != 0) {
      printError('Could not install ${bundle.path} on $id.');
      printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
      printError('  open ios/Runner.xcodeproj');
      printError('');
      return new LaunchResult.failed();
    }

    return new LaunchResult.succeeded(observatoryUri: localObsUri, diagnosticUri: localDiagUri);
  }

  @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 (p.platform.isMacOS) {
      runSync(<String>[
        pusherPath,
        '-t',
        '1',
        '--bundle_id',
        app.id,
        '--upload',
        localFile,
        '--to',
        targetFile
      ]);
      return true;
    } else {
      return false;
    }
  }

  @override
  TargetPlatform get platform => TargetPlatform.ios;

  @override
  String get sdkNameAndVersion => 'iOS $_sdkVersion ($_buildVersion)';

  String get _sdkVersion => _getDeviceInfo(id, 'ProductVersion');

  String get _buildVersion => _getDeviceInfo(id, 'BuildVersion');

  @override
  DeviceLogReader getLogReader({ApplicationPackage app}) {
    _logReaders ??= <ApplicationPackage, _IOSDeviceLogReader>{};
    return _logReaders.putIfAbsent(app, () => new _IOSDeviceLogReader(this, app));
  }

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

    return _portForwarder;
  }

  @override
  void clearLogs() {
  }

  @override
  bool get supportsScreenshot => false;

  @override
  Future<Null> takeScreenshot(File outputFile) {
    // We could use idevicescreenshot here (installed along with the brew
    // ideviceinstaller tools). It however requires a developer disk image on
    // the device.
    return new Future<Null>.error('Taking screenshots is not supported on iOS devices. Consider using a simulator instead.');
  }
}

class _IOSDeviceLogReader extends DeviceLogReader {
  RegExp _lineRegex;

  _IOSDeviceLogReader(this.device, ApplicationPackage app) {
    _linesController = new StreamController<String>.broadcast(
      onListen: _start,
      onCancel: _stop
    );

    // Match for lines for the runner in syslog.
    //
    // iOS 9 format:  Runner[297] <Notice>:
    // iOS 10 format: Runner(libsystem_asl.dylib)[297] <Notice>:
    String appName = app == null ? '' : app.name.replaceAll('.app', '');
    _lineRegex = new RegExp(appName + r'(\(.*\))?\[[\d]+\] <[A-Za-z]+>: ');
  }

  final IOSDevice device;

  StreamController<String> _linesController;
  Process _process;

  @override
  Stream<String> get logLines => _linesController.stream;

  @override
  String get name => device.name;

  void _start() {
    runCommand(<String>[device.loggerPath]).then<Null>((Process process) {
      _process = process;
      _process.stdout.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
      _process.stderr.transform(UTF8.decoder).transform(const LineSplitter()).listen(_onLine);
      _process.exitCode.whenComplete(() {
        if (_linesController.hasListener)
          _linesController.close();
      });
    });
  }

  void _onLine(String line) {
    Match match = _lineRegex.firstMatch(line);

    if (match != null) {
      // Only display the log line after the initial device and executable information.
      _linesController.add(line.substring(match.end));
    }
  }

  void _stop() {
    _process?.kill();
  }
}

class _IOSDevicePortForwarder extends DevicePortForwarder {
  _IOSDevicePortForwarder(this.device) : _forwardedPorts = new List<ForwardedPort>();

  final IOSDevice device;

  final List<ForwardedPort> _forwardedPorts;

  @override
  List<ForwardedPort> get forwardedPorts => _forwardedPorts;

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

    // Usage: iproxy LOCAL_TCP_PORT DEVICE_TCP_PORT UDID
    Process process = await runCommand(<String>[
      device.iproxyPath,
      hostPort.toString(),
      devicePort.toString(),
      device.id,
    ]);

    ForwardedPort forwardedPort = new ForwardedPort.withContext(hostPort,
        devicePort, process);

    printTrace("Forwarded port $forwardedPort");

    _forwardedPorts.add(forwardedPort);

    return hostPort;
  }

  @override
  Future<Null> unforward(ForwardedPort forwardedPort) async {
    if (!_forwardedPorts.remove(forwardedPort)) {
      // Not in list. Nothing to remove.
      return null;
    }

    printTrace("Unforwarding port $forwardedPort");

    Process process = forwardedPort.context;

    if (process != null) {
      processManager.killPid(process.pid);
    } else {
      printError("Forwarded port did not have a valid process");
    }

    return null;
  }
}
