// Copyright 2018 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 'package:file/file.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:process/process.dart';
import 'package:vm_service_lib/vm_service_lib.dart';
import 'package:vm_service_lib/vm_service_lib_io.dart';

import '../src/common.dart';

// Set this to true for debugging to get JSON written to stdout.
const bool _printDebugOutputToStdOut = false;
const Duration defaultTimeout = Duration(seconds: 40);
const Duration appStartTimeout = Duration(seconds: 120);
const Duration quitTimeout = Duration(seconds: 10);

abstract class FlutterTestDriver {
  FlutterTestDriver(this._projectFolder, {String logPrefix}):
    _logPrefix = logPrefix != null ? '$logPrefix: ' : '';

  final Directory _projectFolder;
  final String _logPrefix;
  Process _proc;
  int _procPid;
  final StreamController<String> _stdout = StreamController<String>.broadcast();
  final StreamController<String> _stderr = StreamController<String>.broadcast();
  final StreamController<String> _allMessages = StreamController<String>.broadcast();
  final StringBuffer _errorBuffer = StringBuffer();
  String _lastResponse;
  Uri _vmServiceWsUri;
  bool _hasExited = false;

  VmService _vmService;
  String get lastErrorInfo => _errorBuffer.toString();
  Stream<String> get stdout => _stdout.stream;
  int get vmServicePort => _vmServiceWsUri.port;
  bool get hasExited => _hasExited;

  String _debugPrint(String msg) {
    const int maxLength = 500;
    final String truncatedMsg =
        msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg;
    _allMessages.add(truncatedMsg);
    if (_printDebugOutputToStdOut) {
      print('$_logPrefix$truncatedMsg');
    }
    return msg;
  }

  Future<void> _setupProcess(
    List<String> args, {
    bool withDebugger = false,
    bool pauseOnExceptions = false,
    File pidFile,
  }) async {
    final String flutterBin = fs.path.join(getFlutterRoot(), 'bin', 'flutter');
    if (withDebugger) {
        args.add('--start-paused');
    }
    if (pidFile != null) {
        args.addAll(<String>['--pid-file', pidFile.path]);
    }
    _debugPrint('Spawning flutter $args in ${_projectFolder.path}');

    const ProcessManager _processManager = LocalProcessManager();
    _proc = await _processManager.start(
        <String>[flutterBin]
            .followedBy(args)
            .toList(),
        workingDirectory: _projectFolder.path,
        environment: <String, String>{'FLUTTER_TEST': 'true'});

    // This class doesn't use the result of the future. It's made available
    // via a getter for external uses.
    _proc.exitCode.then((int code) { // ignore: unawaited_futures
      _debugPrint('Process exited ($code)');
      _hasExited = true;
    });
    transformToLines(_proc.stdout).listen((String line) => _stdout.add(line));
    transformToLines(_proc.stderr).listen((String line) => _stderr.add(line));

    // Capture stderr to a buffer so we can show it all if any requests fail.
    _stderr.stream.listen(_errorBuffer.writeln);

    // This is just debug printing to aid running/debugging tests locally.
    _stdout.stream.listen(_debugPrint);
    _stderr.stream.listen(_debugPrint);
  }

  Future<int> quit() => _killGracefully();

  Future<int> _killGracefully() async {
    if (_procPid == null)
      return -1;
    _debugPrint('Sending SIGTERM to $_procPid..');
    Process.killPid(_procPid);
    return _proc.exitCode.timeout(quitTimeout, onTimeout: _killForcefully);
  }

  Future<int> _killForcefully() {
    _debugPrint('Sending SIGKILL to $_procPid..');
    Process.killPid(_procPid, ProcessSignal.SIGKILL);
    return _proc.exitCode;
  }

  String _flutterIsolateId;
  Future<String> _getFlutterIsolateId() async {
    // Currently these tests only have a single isolate. If this
    // ceases to be the case, this code will need changing.
    if (_flutterIsolateId == null) {
      final VM vm = await _vmService.getVM();
      _flutterIsolateId = vm.isolates.first.id;
    }
    return _flutterIsolateId;
  }

  Future<Isolate> _getFlutterIsolate() async {
    final Isolate isolate = await _vmService.getIsolate(await _getFlutterIsolateId());
    return isolate;
  }

  Future<void> addBreakpoint(Uri uri, int line) async {
    _debugPrint('Sending breakpoint for $uri:$line');
    await _vmService.addBreakpointWithScriptUri(
        await _getFlutterIsolateId(), uri.toString(), line);
  }

  Future<Isolate> waitForPause() async {
    _debugPrint('Waiting for isolate to pause');
    final String flutterIsolate = await _getFlutterIsolateId();

    Future<Isolate> waitForPause() async {
      final Completer<Event> pauseEvent = Completer<Event>();

      // Start listening for pause events.
      final StreamSubscription<Event> pauseSub = _vmService.onDebugEvent
          .where((Event event) =>
              event.isolate.id == flutterIsolate &&
              event.kind.startsWith('Pause'))
          .listen(pauseEvent.complete);

      // But also check if the isolate was already paused (only after we've set
      // up the sub) to avoid races. If it was paused, we don't need to wait
      // for the event.
      final Isolate isolate = await _vmService.getIsolate(flutterIsolate);
      if (!isolate.pauseEvent.kind.startsWith('Pause')) {
        await pauseEvent.future;
      }

      // Cancel the sub on either of the above.
      await pauseSub.cancel();

      return _getFlutterIsolate();
    }

    return _timeoutWithMessages<Isolate>(waitForPause,
        message: 'Isolate did not pause');
  }

  Future<bool> isAtAsyncSuspension() async {
    final Isolate isolate = await _getFlutterIsolate();
    return isolate.pauseEvent.atAsyncSuspension == true;
  }

  Future<Isolate> resume({bool wait = true}) => _resume(wait: wait);
  Future<Isolate> stepOver({bool wait = true}) => _resume(step: StepOption.kOver, wait: wait);
  Future<Isolate> stepOverAsync({ bool wait = true }) => _resume(step: StepOption.kOverAsyncSuspension, wait: wait);
  Future<Isolate> stepOverOrOverAsyncSuspension({ bool wait = true }) async {
    return (await isAtAsyncSuspension()) ? stepOverAsync(wait: wait) : stepOver(wait: wait);
  }
  Future<Isolate> stepInto({bool wait = true}) => _resume(step: StepOption.kInto, wait: wait);
  Future<Isolate> stepOut({bool wait = true}) => _resume(step: StepOption.kOut, wait: wait);

  Future<Isolate> _resume({String step, bool wait = true}) async {
    _debugPrint('Sending resume ($step)');
    await _timeoutWithMessages<dynamic>(() async => _vmService.resume(await _getFlutterIsolateId(), step: step),
        message: 'Isolate did not respond to resume ($step)');
    return wait ? waitForPause() : null;
  }

  Future<InstanceRef> evaluateInFrame(String expression) async {
    return _timeoutWithMessages<InstanceRef>(
        () async => await _vmService.evaluateInFrame(await _getFlutterIsolateId(), 0, expression),
        message: 'Timed out evaluating expression ($expression)');
  }

  Future<InstanceRef> evaluate(String targetId, String expression) async {
    return _timeoutWithMessages<InstanceRef>(
        () async => await _vmService.evaluate(await _getFlutterIsolateId(), targetId, expression),
        message: 'Timed out evaluating expression ($expression for $targetId)');
  }

  Future<Frame> getTopStackFrame() async {
    final String flutterIsolateId = await _getFlutterIsolateId();
    final Stack stack = await _vmService.getStack(flutterIsolateId);
    if (stack.frames.isEmpty) {
      throw Exception('Stack is empty');
    }
    return stack.frames.first;
  }

  Future<SourcePosition> getSourceLocation() async {
    final String flutterIsolateId = await _getFlutterIsolateId();
    final Frame frame = await getTopStackFrame();
    final Script script = await _vmService.getObject(flutterIsolateId, frame.location.script.id);
    return _lookupTokenPos(script.tokenPosTable, frame.location.tokenPos);
  }

  SourcePosition _lookupTokenPos(List<List<int>> table, int tokenPos) {
    for (List<int> row in table) {
      final int lineNumber = row[0];
      int index = 1;

      for (index = 1; index < row.length - 1; index += 2) {
        if (row[index] == tokenPos) {
          return SourcePosition(lineNumber, row[index + 1]);
        }
      }
    }

    return null;
  }

  Future<Map<String, dynamic>> _waitFor({
    String event,
    int id,
    Duration timeout,
    bool ignoreAppStopEvent = false,
  }) async {
    final Completer<Map<String, dynamic>> response = Completer<Map<String, dynamic>>();
    StreamSubscription<String> sub;
    sub = _stdout.stream.listen((String line) async {
      final dynamic json = parseFlutterResponse(line);
      _lastResponse = line;
      if (json == null) {
        return;
      } else if (
          (event != null && json['event'] == event)
          || (id != null && json['id'] == id)) {
        await sub.cancel();
        response.complete(json);
      } else if (!ignoreAppStopEvent && json['event'] == 'app.stop') {
        await sub.cancel();
        final StringBuffer error = StringBuffer();
        error.write('Received app.stop event while waiting for ');
        error.write('${event != null ? '$event event' : 'response to request $id.'}.\n\n');
        if (json['params'] != null && json['params']['error'] != null) {
          error.write('${json['params']['error']}\n\n');
        }
        if (json['params'] != null && json['params']['trace'] != null) {
          error.write('${json['params']['trace']}\n\n');
        }
        response.completeError(error.toString());
      }
    });

    return _timeoutWithMessages<Map<String, dynamic>>(() => response.future,
            timeout: timeout,
            message: event != null
                ? 'Did not receive expected $event event.'
                : 'Did not receive response to request "$id".')
        .whenComplete(() => sub.cancel());
  }

  Future<T> _timeoutWithMessages<T>(Future<T> Function() f, {Duration timeout, String message}) {
    // Capture output to a buffer so if we don't get the response we want we can show
    // the output that did arrive in the timeout error.
    final StringBuffer messages = StringBuffer();
    final DateTime start = DateTime.now();
    void logMessage(String m) {
      final int ms = DateTime.now().difference(start).inMilliseconds;
      messages.writeln('[+ ${ms.toString().padLeft(5)}] $m');
    }
    final StreamSubscription<String> sub = _allMessages.stream.listen(logMessage);

    return f().timeout(timeout ?? defaultTimeout, onTimeout: () {
      logMessage('<timed out>');
      throw '$message';
    }).catchError((dynamic error) {
      throw '$error\nReceived:\n${messages.toString()}';
    }).whenComplete(() => sub.cancel());
  }
}

class FlutterRunTestDriver extends FlutterTestDriver {
  FlutterRunTestDriver(Directory _projectFolder, {String logPrefix}):
    super(_projectFolder, logPrefix: logPrefix);

  String _currentRunningAppId;

   Future<void> run({
    bool withDebugger = false,
    bool pauseOnExceptions = false,
    File pidFile,
  }) async {
    await _setupProcess(<String>[
        'run',
        '--machine',
        '-d',
        'flutter-tester',
    ], withDebugger: withDebugger, pauseOnExceptions: pauseOnExceptions, pidFile: pidFile);
  }

  Future<void> attach(
    int port, {
    bool withDebugger = false,
    bool pauseOnExceptions = false,
    File pidFile,
  }) async {
    await _setupProcess(<String>[
        'attach',
        '--machine',
        '-d',
        'flutter-tester',
        '--debug-port',
        '$port',
    ], withDebugger: withDebugger, pauseOnExceptions: pauseOnExceptions, pidFile: pidFile);
  }

  @override
  Future<void> _setupProcess(
    List<String> args, {
    bool withDebugger = false,
    bool pauseOnExceptions = false,
    File pidFile,
  }) async {
    await super._setupProcess(
      args,
      withDebugger: withDebugger,
      pauseOnExceptions: pauseOnExceptions,
      pidFile: pidFile,
    );

    // Stash the PID so that we can terminate the VM more reliably than using
    // _proc.kill() (because _proc is a shell, because `flutter` is a shell
    // script).
    final Map<String, dynamic> connected = await _waitFor(event: 'daemon.connected');
    _procPid = connected['params']['pid'];

    // Set this up now, but we don't wait it yet. We want to make sure we don't
    // miss it while waiting for debugPort below.
    final Future<Map<String, dynamic>> started = _waitFor(event: 'app.started',
        timeout: appStartTimeout);

    if (withDebugger) {
      final Map<String, dynamic> debugPort = await _waitFor(event: 'app.debugPort',
          timeout: appStartTimeout);
      final String wsUriString = debugPort['params']['wsUri'];
      _vmServiceWsUri = Uri.parse(wsUriString);
      _vmService =
          await vmServiceConnectUri(_vmServiceWsUri.toString());
      _vmService.onSend.listen((String s) => _debugPrint('==> $s'));
      _vmService.onReceive.listen((String s) => _debugPrint('<== $s'));
      await Future.wait(<Future<Success>>[
        _vmService.streamListen('Isolate'),
        _vmService.streamListen('Debug'),
      ]);

      // On hot restarts, the isolate ID we have for the Flutter thread will
      // exit so we need to invalidate our cached ID.
      _vmService.onIsolateEvent.listen((Event event) {
        if (event.kind == EventKind.kIsolateExit && event.isolate.id == _flutterIsolateId) {
          _flutterIsolateId = null;
        }
      });

      // Because we start paused, resume so the app is in a "running" state as
      // expected by tests. Tests will reload/restart as required if they need
      // to hit breakpoints, etc.
      await waitForPause();
      if (pauseOnExceptions) {
        await _vmService.setExceptionPauseMode(await _getFlutterIsolateId(), ExceptionPauseMode.kUnhandled);
      }
      await resume(wait: false);
    }

    // Now await the started event; if it had already happened the future will
    // have already completed.
    _currentRunningAppId = (await started)['params']['appId'];
  }

  Future<void> hotRestart({bool pause = false}) => _restart(fullRestart: true, pause: pause);
  Future<void> hotReload() => _restart(fullRestart: false);

  Future<void> _restart({bool fullRestart = false, bool pause = false}) async {
    if (_currentRunningAppId == null)
      throw Exception('App has not started yet');

    final dynamic hotReloadResp = await _sendRequest(
        'app.restart',
        <String, dynamic>{'appId': _currentRunningAppId, 'fullRestart': fullRestart, 'pause': pause},
    );

    if (hotReloadResp == null || hotReloadResp['code'] != 0)
      _throwErrorResponse('Hot ${fullRestart ? 'restart' : 'reload'} request failed');
  }

  Future<int> detach() async {
    if (_vmService != null) {
      _debugPrint('Closing VM service');
      _vmService.dispose();
    }
    if (_currentRunningAppId != null) {
      _debugPrint('Detaching from app');
      await Future.any<void>(<Future<void>>[
        _proc.exitCode,
        _sendRequest(
          'app.detach',
          <String, dynamic>{'appId': _currentRunningAppId},
        ),
      ]).timeout(
        quitTimeout,
        onTimeout: () { _debugPrint('app.detach did not return within $quitTimeout'); },
      );
      _currentRunningAppId = null;
    }
    _debugPrint('Waiting for process to end');
    return _proc.exitCode.timeout(quitTimeout, onTimeout: _killGracefully);
  }

  Future<int> stop() async {
    if (_vmService != null) {
      _debugPrint('Closing VM service');
      _vmService.dispose();
    }
    if (_currentRunningAppId != null) {
      _debugPrint('Stopping app');
      await Future.any<void>(<Future<void>>[
        _proc.exitCode,
        _sendRequest(
          'app.stop',
          <String, dynamic>{'appId': _currentRunningAppId},
        ),
      ]).timeout(
        quitTimeout,
        onTimeout: () { _debugPrint('app.stop did not return within $quitTimeout'); },
      );
      _currentRunningAppId = null;
    }
    if (_proc != null) {
      _debugPrint('Waiting for process to end');
      return _proc.exitCode.timeout(quitTimeout, onTimeout: _killGracefully);
    }
    return 0;
  }

  Future<Isolate> breakAt(Uri uri, int line, { bool restart = false }) async {
    if (restart) {
      // For a hot restart, we need to send the breakpoints after the restart
      // so we need to pause during the restart to avoid races.
      await hotRestart(pause: true);
      await addBreakpoint(uri, line);
      return resume();
    } else {
      await addBreakpoint(uri, line);
      await hotReload();
      return waitForPause();
    }
  }

  int id = 1;
  Future<dynamic> _sendRequest(String method, dynamic params) async {
    final int requestId = id++;
    final Map<String, dynamic> request = <String, dynamic>{
      'id': requestId,
      'method': method,
      'params': params
    };
    final String jsonEncoded = json.encode(<Map<String, dynamic>>[request]);
    _debugPrint(jsonEncoded);

    // Set up the response future before we send the request to avoid any
    // races. If the method we're calling is app.stop then we tell waitFor not
    // to throw if it sees an app.stop event before the response to this request.
    final Future<Map<String, dynamic>> responseFuture = _waitFor(
      id: requestId,
      ignoreAppStopEvent: method == 'app.stop',
    );
    _proc.stdin.writeln(jsonEncoded);
    final Map<String, dynamic> response = await responseFuture;

    if (response['error'] != null || response['result'] == null)
      _throwErrorResponse('Unexpected error response');

    return response['result'];
  }

  void _throwErrorResponse(String msg) {
    throw '$msg\n\n$_lastResponse\n\n${_errorBuffer.toString()}'.trim();
  }
}

Stream<String> transformToLines(Stream<List<int>> byteStream) {
  return byteStream.transform<String>(utf8.decoder).transform<String>(const LineSplitter());
}

Map<String, dynamic> parseFlutterResponse(String line) {
  if (line.startsWith('[') && line.endsWith(']')) {
    try {
      final Map<String, dynamic> resp = json.decode(line)[0];
      return resp;
    } catch (e) {
      // Not valid JSON, so likely some other output that was surrounded by [brackets]
      return null;
    }
  }
  return null;
}

class SourcePosition {
  SourcePosition(this.line, this.column);

  final int line;
  final int column;
}
