// Copyright 2017 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 'package:meta/meta.dart';

import '../application_package.dart';
import '../artifacts.dart';
import '../base/common.dart';
import '../base/context.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/process_manager.dart';
import '../base/time.dart';
import '../build_info.dart';
import '../device.dart';
import '../globals.dart';
import '../project.dart';
import '../vmservice.dart';

import 'amber_ctl.dart';
import 'application_package.dart';
import 'fuchsia_build.dart';
import 'fuchsia_pm.dart';
import 'fuchsia_sdk.dart';
import 'fuchsia_workflow.dart';
import 'tiles_ctl.dart';

/// The [FuchsiaDeviceTools] instance.
FuchsiaDeviceTools get fuchsiaDeviceTools => context.get<FuchsiaDeviceTools>();

/// Fuchsia device-side tools.
class FuchsiaDeviceTools {
  FuchsiaAmberCtl _amberCtl;
  FuchsiaAmberCtl get amberCtl => _amberCtl ??= FuchsiaAmberCtl();

  FuchsiaTilesCtl _tilesCtl;
  FuchsiaTilesCtl get tilesCtl => _tilesCtl ??= FuchsiaTilesCtl();
}

final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;

// Enables testing the fuchsia isolate discovery
Future<VMService> _kDefaultFuchsiaIsolateDiscoveryConnector(Uri uri) {
  return VMService.connect(uri);
}

/// Read the log for a particular device.
class _FuchsiaLogReader extends DeviceLogReader {
  _FuchsiaLogReader(this._device, [this._app]);

  // \S matches non-whitespace characters.
  static final RegExp _flutterLogOutput = RegExp(r'INFO: \S+\(flutter\): ');

  final FuchsiaDevice _device;
  final ApplicationPackage _app;

  @override
  String get name => _device.name;

  Stream<String> _logLines;
  @override
  Stream<String> get logLines {
    final Stream<String> logStream = fuchsiaSdk.syslogs(_device.id);
    _logLines ??= _processLogs(logStream);
    return _logLines;
  }

  Stream<String> _processLogs(Stream<String> lines) {
    if (lines == null) {
      return null;
    }
    // Get the starting time of the log processor to filter logs from before
    // the process attached.
    final DateTime startTime = systemClock.now();
    // Determine if line comes from flutter, and optionally whether it matches
    // the correct fuchsia module.
    final RegExp matchRegExp = _app == null
        ? _flutterLogOutput
        : RegExp('INFO: ${_app.name}(\.cmx)?\\(flutter\\): ');
    return Stream<String>.eventTransformed(
      lines,
      (Sink<String> outout) => _FuchsiaLogSink(outout, matchRegExp, startTime),
    );
  }

  @override
  String toString() => name;
}

class _FuchsiaLogSink implements EventSink<String> {
  _FuchsiaLogSink(this._outputSink, this._matchRegExp, this._startTime);

  static final RegExp _utcDateOutput = RegExp(r'\d+\-\d+\-\d+ \d+:\d+:\d+');
  final EventSink<String> _outputSink;
  final RegExp _matchRegExp;
  final DateTime _startTime;

  @override
  void add(String line) {
    if (!_matchRegExp.hasMatch(line)) {
      return;
    }
    final String rawDate = _utcDateOutput.firstMatch(line)?.group(0);
    if (rawDate == null) {
      return;
    }
    final DateTime logTime = DateTime.parse(rawDate);
    if (logTime.millisecondsSinceEpoch < _startTime.millisecondsSinceEpoch) {
      return;
    }
    _outputSink.add(
        '[${logTime.toLocal()}] Flutter: ${line.split(_matchRegExp).last}');
  }

  @override
  void addError(Object error, [StackTrace stackTrace]) {
    _outputSink.addError(error, stackTrace);
  }

  @override
  void close() {
    _outputSink.close();
  }
}

class FuchsiaDevices extends PollingDeviceDiscovery {
  FuchsiaDevices() : super('Fuchsia devices');

  @override
  bool get supportsPlatform => platform.isLinux || platform.isMacOS;

  @override
  bool get canListAnything => fuchsiaWorkflow.canListDevices;

  @override
  Future<List<Device>> pollingGetDevices() async {
    if (!fuchsiaWorkflow.canListDevices) {
      return <Device>[];
    }
    final String text = await fuchsiaSdk.listDevices();
    if (text == null || text.isEmpty) {
      return <Device>[];
    }
    final List<FuchsiaDevice> devices = parseListDevices(text);
    return devices;
  }

  @override
  Future<List<String>> getDiagnostics() async => const <String>[];
}

@visibleForTesting
List<FuchsiaDevice> parseListDevices(String text) {
  final List<FuchsiaDevice> devices = <FuchsiaDevice>[];
  for (String rawLine in text.trim().split('\n')) {
    final String line = rawLine.trim();
    // ['ip', 'device name']
    final List<String> words = line.split(' ');
    if (words.length < 2) {
      continue;
    }
    final String name = words[1];
    final String id = words[0];
    devices.add(FuchsiaDevice(id, name: name));
  }
  return devices;
}

class FuchsiaDevice extends Device {
  FuchsiaDevice(String id, {this.name}) : super(
      id,
      platformType: PlatformType.fuchsia,
      category: null,
      ephemeral: false,
  );

  @override
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => false;

  @override
  bool get supportsFlutterExit => false;

  @override
  final String name;

  @override
  Future<bool> get isLocalEmulator async => false;

  @override
  Future<String> get emulatorId async => null;

  @override
  bool get supportsStartPaused => false;

  @override
  Future<bool> isAppInstalled(ApplicationPackage app) async => false;

  @override
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;

  @override
  Future<bool> installApp(ApplicationPackage app) => Future<bool>.value(false);

  @override
  Future<bool> uninstallApp(ApplicationPackage app) async => false;

  @override
  bool isSupported() => true;

  @override
  Future<LaunchResult> startApp(
    covariant FuchsiaApp package, {
    String mainPath,
    String route,
    DebuggingOptions debuggingOptions,
    Map<String, dynamic> platformArgs,
    bool prebuiltApplication = false,
    bool ipv6 = false,
  }) async {
    if (!prebuiltApplication) {
      await buildFuchsia(fuchsiaProject: FlutterProject.current().fuchsia,
                         target: mainPath,
                         buildInfo: debuggingOptions.buildInfo);
    }
    // Stop the app if it's currently running.
    await stopApp(package);
    // Find out who the device thinks we are.
    final String host = await fuchsiaSdk.fuchsiaDevFinder.resolve(name);
    if (host == null) {
      printError('Failed to resolve host for Fuchsia device');
      return LaunchResult.failed();
    }
    final int port = await os.findFreePort();
    if (port == 0) {
      printError('Failed to find a free port');
      return LaunchResult.failed();
    }
    final Directory packageRepo =
        fs.directory(fs.path.join(getFuchsiaBuildDirectory(), '.pkg-repo'));
    packageRepo.createSync(recursive: true);

    final String appName = FlutterProject.current().manifest.appName;

    final Status status = logger.startProgress(
      'Starting Fuchsia application...',
      timeout: null,
    );
    FuchsiaPackageServer fuchsiaPackageServer;
    bool serverRegistered = false;
    try {
      // Ask amber to pre-fetch some things we'll need before setting up our own
      // package server. This is to avoid relying on amber correctly using
      // multiple package servers, support for which is in flux.
      if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles')) {
        printError('Failed to get amber to prefetch tiles');
        return LaunchResult.failed();
      }
      if (!await fuchsiaDeviceTools.amberCtl.getUp(this, 'tiles_ctl')) {
        printError('Failed to get amber to prefetch tiles_ctl');
        return LaunchResult.failed();
      }

      // Start up a package server.
      const String packageServerName = 'flutter_tool';
      fuchsiaPackageServer = FuchsiaPackageServer(
          packageRepo.path, packageServerName, host, port);
      if (!await fuchsiaPackageServer.start()) {
        printError('Failed to start the Fuchsia package server');
        return LaunchResult.failed();
      }
      final File farArchive = package.farArchive(
          debuggingOptions.buildInfo.mode);
      if (!await fuchsiaPackageServer.addPackage(farArchive)) {
        printError('Failed to add package to the package server');
        return LaunchResult.failed();
      }

      // Teach the package controller about the package server.
      if (!await fuchsiaDeviceTools.amberCtl.addRepoCfg(this, fuchsiaPackageServer)) {
        printError('Failed to teach amber about the package server');
        return LaunchResult.failed();
      }
      serverRegistered = true;

      // Tell the package controller to prefetch the app.
      if (!await fuchsiaDeviceTools.amberCtl.pkgCtlResolve(
          this, fuchsiaPackageServer, appName)) {
        printError('Failed to get pkgctl to prefetch the package');
        return LaunchResult.failed();
      }

      // Ensure tiles_ctl is started, and start the app.
      if (!await FuchsiaTilesCtl.ensureStarted(this)) {
        printError('Failed to ensure that tiles is started on the device');
        return LaunchResult.failed();
      }

      // Instruct tiles_ctl to start the app.
      final String fuchsiaUrl =
          'fuchsia-pkg://$packageServerName/$appName#meta/$appName.cmx';
      if (!await fuchsiaDeviceTools.tilesCtl.add(this, fuchsiaUrl, <String>[])) {
        printError('Failed to add the app to tiles');
        return LaunchResult.failed();
      }
    } finally {
      // Try to un-teach the package controller about the package server if
      // needed.
      if (serverRegistered) {
        await fuchsiaDeviceTools.amberCtl.pkgCtlRepoRemove(this, fuchsiaPackageServer);
      }
      // Shutdown the package server and delete the package repo;
      fuchsiaPackageServer?.stop();
      packageRepo.deleteSync(recursive: true);
      status.cancel();
    }

    if (!debuggingOptions.buildInfo.isDebug &&
        !debuggingOptions.buildInfo.isProfile) {
      return LaunchResult.succeeded();
    }

    // In a debug or profile build, try to find the observatory uri.
    final FuchsiaIsolateDiscoveryProtocol discovery =
        getIsolateDiscoveryProtocol(appName);
    try {
      final Uri observatoryUri = await discovery.uri;
      return LaunchResult.succeeded(observatoryUri: observatoryUri);
    } finally {
      discovery.dispose();
    }
  }

  @override
  Future<bool> stopApp(covariant FuchsiaApp app) async {
    final int appKey = await FuchsiaTilesCtl.findAppKey(this, app.id);
    if (appKey != -1) {
      if (!await fuchsiaDeviceTools.tilesCtl.remove(this, appKey)) {
        printError('tiles_ctl remove on ${app.id} failed.');
        return false;
      }
    }
    return true;
  }

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.fuchsia;

  @override
  Future<String> get sdkNameAndVersion async {
    const String versionPath = '/pkgfs/packages/build-info/0/data/version';
    final RunResult catResult = await shell('cat $versionPath');
    if (catResult.exitCode != 0) {
      printTrace('Failed to cat $versionPath: ${catResult.stderr}');
      return 'Fuchsia';
    }
    final String version = catResult.stdout.trim();
    if (version.isEmpty) {
      printTrace('$versionPath was empty');
      return 'Fuchsia';
    }
    return 'Fuchsia $version';
  }

  @override
  DeviceLogReader getLogReader({ApplicationPackage app}) =>
      _logReader ??= _FuchsiaLogReader(this, app);
  _FuchsiaLogReader _logReader;

  @override
  DevicePortForwarder get portForwarder =>
      _portForwarder ??= _FuchsiaPortForwarder(this);
  _FuchsiaPortForwarder _portForwarder;

  @override
  void clearLogs() {}

  @override
  OverrideArtifacts get artifactOverrides {
    return _artifactOverrides ??= OverrideArtifacts(
      parent: Artifacts.instance,
      platformKernelDill: fuchsiaArtifacts.platformKernelDill,
      flutterPatchedSdk: fuchsiaArtifacts.flutterPatchedSdk,
    );
  }
  OverrideArtifacts _artifactOverrides;

  @override
  bool get supportsScreenshot => false;

  bool get ipv6 {
    // Workaround for https://github.com/dart-lang/sdk/issues/29456
    final String fragment = id.split('%').first;
    try {
      Uri.parseIPv6Address(fragment);
      return true;
    } on FormatException {
      return false;
    }
  }

  /// List the ports currently running a dart observatory.
  Future<List<int>> servicePorts() async {
    const String findCommand = 'find /hub -name vmservice-port';
    final RunResult findResult = await shell(findCommand);
    if (findResult.exitCode != 0) {
      throwToolExit("'$findCommand' on device $id failed. stderr: '${findResult.stderr}'");
      return null;
    }
    final String findOutput = findResult.stdout;
    if (findOutput.trim() == '') {
      throwToolExit(
          'No Dart Observatories found. Are you running a debug build?');
      return null;
    }
    final List<int> ports = <int>[];
    for (String path in findOutput.split('\n')) {
      if (path == '') {
        continue;
      }
      final String lsCommand = 'ls $path';
      final RunResult lsResult = await shell(lsCommand);
      if (lsResult.exitCode != 0) {
        throwToolExit("'$lsCommand' on device $id failed");
        return null;
      }
      final String lsOutput = lsResult.stdout;
      for (String line in lsOutput.split('\n')) {
        if (line == '') {
          continue;
        }
        final int port = int.tryParse(line);
        if (port != null) {
          ports.add(port);
        }
      }
    }
    return ports;
  }

  /// Run `command` on the Fuchsia device shell.
  Future<RunResult> shell(String command) async {
    if (fuchsiaArtifacts.sshConfig == null) {
      throwToolExit('Cannot interact with device. No ssh config.\n'
                    'Try setting FUCHSIA_SSH_CONFIG or FUCHSIA_BUILD_DIR.');
    }
    return await runAsync(<String>[
      'ssh',
      '-F',
      fuchsiaArtifacts.sshConfig.absolute.path,
      id,
      command
    ]);
  }

  /// Finds the first port running a VM matching `isolateName` from the
  /// provided set of `ports`.
  ///
  /// Returns null if no isolate port can be found.
  ///
  // TODO(jonahwilliams): replacing this with the hub will require an update
  // to the flutter_runner.
  Future<int> findIsolatePort(String isolateName, List<int> ports) async {
    for (int port in ports) {
      try {
        // Note: The square-bracket enclosure for using the IPv6 loopback
        // didn't appear to work, but when assigning to the IPv4 loopback device,
        // netstat shows that the local port is actually being used on the IPv6
        // loopback (::1).
        final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$port');
        final VMService vmService = await VMService.connect(uri);
        await vmService.getVM();
        await vmService.refreshViews();
        for (FlutterView flutterView in vmService.vm.views) {
          if (flutterView.uiIsolate == null) {
            continue;
          }
          final Uri address = flutterView.owner.vmService.httpAddress;
          if (flutterView.uiIsolate.name.contains(isolateName)) {
            return address.port;
          }
        }
      } on SocketException catch (err) {
        printTrace('Failed to connect to $port: $err');
      }
    }
    throwToolExit('No ports found running $isolateName');
    return null;
  }

  FuchsiaIsolateDiscoveryProtocol getIsolateDiscoveryProtocol(
          String isolateName) =>
      FuchsiaIsolateDiscoveryProtocol(this, isolateName);

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.fuchsia.existsSync();
  }
}

class FuchsiaIsolateDiscoveryProtocol {
  FuchsiaIsolateDiscoveryProtocol(
    this._device,
    this._isolateName, [
    this._vmServiceConnector = _kDefaultFuchsiaIsolateDiscoveryConnector,
    this._pollOnce = false,
  ]);

  static const Duration _pollDuration = Duration(seconds: 10);
  final Map<int, VMService> _ports = <int, VMService>{};
  final FuchsiaDevice _device;
  final String _isolateName;
  final Completer<Uri> _foundUri = Completer<Uri>();
  final Future<VMService> Function(Uri) _vmServiceConnector;
  // whether to only poll once.
  final bool _pollOnce;
  Timer _pollingTimer;
  Status _status;

  FutureOr<Uri> get uri {
    if (_uri != null) {
      return _uri;
    }
    _status ??= logger.startProgress(
      'Waiting for a connection from $_isolateName on ${_device.name}...',
      timeout: null, // could take an arbitrary amount of time
    );
    _pollingTimer ??= Timer(_pollDuration, _findIsolate);
    return _foundUri.future.then((Uri uri) {
      _uri = uri;
      return uri;
    });
  }

  Uri _uri;

  void dispose() {
    if (!_foundUri.isCompleted) {
      _status?.cancel();
      _status = null;
      _pollingTimer?.cancel();
      _pollingTimer = null;
      _foundUri.completeError(Exception('Did not complete'));
    }
  }

  Future<void> _findIsolate() async {
    final List<int> ports = await _device.servicePorts();
    for (int port in ports) {
      VMService service;
      if (_ports.containsKey(port)) {
        service = _ports[port];
      } else {
        final int localPort = await _device.portForwarder.forward(port);
        try {
          final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$localPort');
          service = await _vmServiceConnector(uri);
          _ports[port] = service;
        } on SocketException catch (err) {
          printTrace('Failed to connect to $localPort: $err');
          continue;
        }
      }
      await service.getVM();
      await service.refreshViews();
      for (FlutterView flutterView in service.vm.views) {
        if (flutterView.uiIsolate == null) {
          continue;
        }
        final Uri address = flutterView.owner.vmService.httpAddress;
        if (flutterView.uiIsolate.name.contains(_isolateName)) {
          _foundUri.complete(_device.ipv6
              ? Uri.parse('http://[$_ipv6Loopback]:${address.port}/')
              : Uri.parse('http://$_ipv4Loopback:${address.port}/'));
          _status.stop();
          return;
        }
      }
    }
    if (_pollOnce) {
      _foundUri.completeError(Exception('Max iterations exceeded'));
      _status.stop();
      return;
    }
    _pollingTimer = Timer(_pollDuration, _findIsolate);
  }
}

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

  final FuchsiaDevice device;
  final Map<int, Process> _processes = <int, Process>{};

  @override
  Future<int> forward(int devicePort, {int hostPort}) async {
    hostPort ??= await os.findFreePort();
    if (hostPort == 0) {
      throwToolExit('Failed to forward port $devicePort. No free host-side ports');
    }
    // Note: the provided command works around a bug in -N, see US-515
    // for more explanation.
    final List<String> command = <String>[
      'ssh',
      '-6',
      '-F',
      fuchsiaArtifacts.sshConfig.absolute.path,
      '-nNT',
      '-vvv',
      '-f',
      '-L',
      '$hostPort:$_ipv4Loopback:$devicePort',
      device.id,
      'true',
    ];
    final Process process = await processManager.start(command);
    unawaited(process.exitCode.then((int exitCode) {
      if (exitCode != 0) {
        throwToolExit('Failed to forward port:$devicePort');
      }
    }));
    _processes[hostPort] = process;
    _forwardedPorts.add(ForwardedPort(hostPort, devicePort));
    return hostPort;
  }

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

  @override
  Future<void> unforward(ForwardedPort forwardedPort) async {
    _forwardedPorts.remove(forwardedPort);
    final Process process = _processes.remove(forwardedPort.hostPort);
    process?.kill();
    final List<String> command = <String>[
      'ssh',
      '-F',
      fuchsiaArtifacts.sshConfig.absolute.path,
      '-O',
      'cancel',
      '-vvv',
      '-L',
      '${forwardedPort.hostPort}:$_ipv4Loopback:${forwardedPort.devicePort}',
      device.id
    ];
    final ProcessResult result = await processManager.run(command);
    if (result.exitCode != 0) {
      throwToolExit(result.stderr);
    }
  }
}