// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: implementation_imports

import 'dart:async';
import 'dart:typed_data';

import 'package:async/async.dart';
import 'package:http_multi_server/http_multi_server.dart';
import 'package:meta/meta.dart';
import 'package:package_config/discovery.dart';
import 'package:package_config/packages.dart';
import 'package:path/path.dart' as p; // ignore: package_path_import
import 'package:pool/pool.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_packages_handler/shelf_packages_handler.dart';
import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test_api/src/backend/runtime.dart';
import 'package:test_api/src/backend/suite_platform.dart';
import 'package:test_api/src/util/stack_trace_mapper.dart';
import 'package:test_core/src/runner/configuration.dart';
import 'package:test_core/src/runner/environment.dart';
import 'package:test_core/src/runner/platform.dart';
import 'package:test_core/src/runner/plugin/platform_helpers.dart';
import 'package:test_core/src/runner/runner_suite.dart';
import 'package:test_core/src/runner/suite.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide StackTrace;

import '../artifacts.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../build_info.dart';
import '../cache.dart';
import '../convert.dart';
import '../dart/package_map.dart';
import '../globals.dart' as globals;
import '../project.dart';
import '../web/chrome.dart';

import 'test_compiler.dart';
import 'test_config.dart';

class FlutterWebPlatform extends PlatformPlugin {
  FlutterWebPlatform._(this._server, this._config, this._root, {
    FlutterProject flutterProject,
    String shellPath,
    this.updateGoldens,
  }) {
    final shelf.Cascade cascade = shelf.Cascade()
        .add(_webSocketHandler.handler)
        .add(packagesDirHandler())
        .add(_jsHandler.handler)
        .add(createStaticHandler(
          globals.fs.path.join(Cache.flutterRoot, 'packages', 'flutter_tools'),
          serveFilesOutsidePath: true,
        ))
        .add(createStaticHandler(
          _config.suiteDefaults.precompiledPath,
          serveFilesOutsidePath: true,
        ))
        .add(_handleStaticArtifact)
        .add(_goldenFileHandler)
        .add(_wrapperHandler)
        .add(createStaticHandler(
          p.join(p.current, 'test'),
          serveFilesOutsidePath: true,
        ))
        .add(_packageFilesHandler);
    _server.mount(cascade.handler);

    _testGoldenComparator = TestGoldenComparator(
      shellPath,
      () => TestCompiler(BuildMode.debug, false, flutterProject),
    );
  }

  static Future<FlutterWebPlatform> start(String root, {
    FlutterProject flutterProject,
    String shellPath,
    bool updateGoldens = false,
    bool pauseAfterLoad = false,
  }) async {
    final shelf_io.IOServer server =
        shelf_io.IOServer(await HttpMultiServer.loopback(0));
    return FlutterWebPlatform._(
      server,
      Configuration.current.change(pauseAfterLoad: pauseAfterLoad),
      root,
      flutterProject: flutterProject,
      shellPath: shellPath,
      updateGoldens: updateGoldens,
    );
  }

  final Future<Packages> _packagesFuture = loadPackagesFile(Uri.base.resolve('.packages'));

  final PackageMap _flutterToolsPackageMap = PackageMap(p.join(
    Cache.flutterRoot,
    'packages',
    'flutter_tools',
    '.packages',
  ));

  /// Uri of the test package.
  Uri get testUri => _flutterToolsPackageMap.map['test'];

  /// The test runner configuration.
  final Configuration _config;

  @visibleForTesting
  Configuration get config => _config;

  /// The underlying server.
  final shelf.Server _server;

  @visibleForTesting
  shelf.Server get server => _server;

  /// The URL for this server.
  Uri get url => _server.url;

  /// The ahem text file.
  File get ahem => globals.fs.file(globals.fs.path.join(
        Cache.flutterRoot,
        'packages',
        'flutter_tools',
        'static',
        'Ahem.ttf',
      ));

  /// The require js binary.
  File get requireJs => globals.fs.file(globals.fs.path.join(
        globals.artifacts.getArtifactPath(Artifact.engineDartSdkPath),
        'lib',
        'dev_compiler',
        'kernel',
        'amd',
        'require.js',
      ));

  /// The ddc to dart stack trace mapper.
  File get stackTraceMapper => globals.fs.file(globals.fs.path.join(
        globals.artifacts.getArtifactPath(Artifact.engineDartSdkPath),
        'lib',
        'dev_compiler',
        'web',
        'dart_stack_trace_mapper.js',
      ));

  /// The precompiled dart sdk.
  File get dartSdk => globals.fs.file(globals.fs.path.join(
        globals.artifacts.getArtifactPath(Artifact.flutterWebSdk),
        'kernel',
        'amd',
        'dart_sdk.js',
      ));

  /// The precompiled test javascript.
  File get testDartJs => globals.fs.file(globals.fs.path.join(
        testUri.toFilePath(),
        'dart.js',
      ));

  File get testHostDartJs => globals.fs.file(globals.fs.path.join(
        testUri.toFilePath(),
        'src',
        'runner',
        'browser',
        'static',
        'host.dart.js',
      ));

  Future<shelf.Response> _handleStaticArtifact(shelf.Request request) async {
    if (request.requestedUri.path.contains('require.js')) {
      return shelf.Response.ok(
        requireJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('ahem.ttf')) {
      return shelf.Response.ok(ahem.openRead());
    } else if (request.requestedUri.path.contains('dart_sdk.js')) {
      return shelf.Response.ok(
        dartSdk.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path
        .contains('stack_trace_mapper.dart.js')) {
      return shelf.Response.ok(
        stackTraceMapper.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('static/dart.js')) {
      return shelf.Response.ok(
        testDartJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else if (request.requestedUri.path.contains('host.dart.js')) {
      return shelf.Response.ok(
        testHostDartJs.openRead(),
        headers: <String, String>{'Content-Type': 'text/javascript'},
      );
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

  FutureOr<shelf.Response> _packageFilesHandler(shelf.Request request) async {
    if (request.requestedUri.pathSegments.first == 'packages') {
      final Packages packages = await _packagesFuture;
      final Uri fileUri = packages.resolve(Uri(
        scheme: 'package',
        pathSegments: request.requestedUri.pathSegments.skip(1),
      ));
      final String dirname = p.dirname(fileUri.toFilePath());
      final String basename = p.basename(fileUri.toFilePath());
      final shelf.Handler handler = createStaticHandler(dirname);
      final shelf.Request modifiedRequest = shelf.Request(
        request.method,
        request.requestedUri.replace(path: basename),
        protocolVersion: request.protocolVersion,
        headers: request.headers,
        handlerPath: request.handlerPath,
        url: request.url.replace(path: basename),
        encoding: request.encoding,
        context: request.context,
      );
      return handler(modifiedRequest);
    }
    return shelf.Response.notFound('Not Found');
  }

  final bool updateGoldens;
  TestGoldenComparator _testGoldenComparator;

  Future<shelf.Response> _goldenFileHandler(shelf.Request request) async {
    if (request.url.path.contains('flutter_goldens')) {
      final Map<String, Object> body = json.decode(await request.readAsString()) as Map<String, Object>;
      final Uri goldenKey = Uri.parse(body['key'] as String);
      final Uri testUri = Uri.parse(body['testUri'] as String);
      final num width = body['width'] as num;
      final num height = body['height'] as num;
      Uint8List bytes;

      try {
        final Runtime browser = Runtime.chrome;
        final BrowserManager browserManager = await _browserManagerFor(browser);
        final ChromeTab chromeTab = await browserManager._browser.chromeConnection.getTab((ChromeTab tab) {
          return tab.url.contains(browserManager._browser.url);
        });
        final WipConnection connection = await chromeTab.connect();
        final WipResponse response = await connection.sendCommand('Page.captureScreenshot', <String, Object>{
          // Clip the screenshot to include only the element.
          // Prior to taking a screenshot, we are calling `window.render()` in
          // `_matchers_web.dart` to only render the element on screen. That
          // will make sure that the element will always be displayed on the
          // origin of the screen.
          'clip': <String, Object>{
            'x': 0.0,
            'y': 0.0,
            'width': width.toDouble(),
            'height': height.toDouble(),
            'scale': 1.0,
          }
        });
        bytes = base64.decode(response.result['data'] as String);
      } on WipError catch (ex) {
        globals.printError('Caught WIPError: $ex');
        return shelf.Response.ok('WIP error: $ex');
      } on FormatException catch (ex) {
        globals.printError('Caught FormatException: $ex');
        return shelf.Response.ok('Caught exception: $ex');
      }

      if (bytes == null) {
        return shelf.Response.ok('Unknown error, bytes is null');
      }

      final String errorMessage = await _testGoldenComparator.compareGoldens(testUri, bytes, goldenKey, updateGoldens);
      return shelf.Response.ok(errorMessage ?? 'true');
    } else {
      return shelf.Response.notFound('Not Found');
    }
  }

  final OneOffHandler _webSocketHandler = OneOffHandler();
  final PathHandler _jsHandler = PathHandler();
  final AsyncMemoizer<void> _closeMemo = AsyncMemoizer<void>();
  final String _root;

  bool get _closed => _closeMemo.hasRun;

  // A map from browser identifiers to futures that will complete to the
  // [BrowserManager]s for those browsers, or `null` if they failed to load.
  final Map<Runtime, Future<BrowserManager>> _browserManagers =
      <Runtime, Future<BrowserManager>>{};

  // Mappers for Dartifying stack traces, indexed by test path.
  final Map<String, StackTraceMapper> _mappers = <String, StackTraceMapper>{};

  // A handler that serves wrapper files used to bootstrap tests.
  shelf.Response _wrapperHandler(shelf.Request request) {
    final String path = globals.fs.path.fromUri(request.url);
    if (path.endsWith('.html')) {
      final String test = globals.fs.path.withoutExtension(path) + '.dart';
      final String scriptBase = htmlEscape.convert(globals.fs.path.basename(test));
      final String link = '<link rel="x-dart-test" href="$scriptBase">';
      return shelf.Response.ok('''
        <!DOCTYPE html>
        <html>
        <head>
          <title>${htmlEscape.convert(test)} Test</title>
          $link
          <script src="static/dart.js"></script>
        </head>
        </html>
      ''', headers: <String, String>{'Content-Type': 'text/html'});
    }
    globals.printTrace('Did not find anything for request: ${request.url}');
    return shelf.Response.notFound('Not found.');
  }

  @override
  Future<RunnerSuite> load(
    String path,
    SuitePlatform platform,
    SuiteConfiguration suiteConfig,
    Object message,
  ) async {
    if (_closed) {
      return null;
    }
    final Runtime browser = platform.runtime;
    final BrowserManager browserManager = await _browserManagerFor(browser);
    if (_closed || browserManager == null) {
      return null;
    }

    final Uri suiteUrl = url.resolveUri(globals.fs.path.toUri(globals.fs.path.withoutExtension(
            globals.fs.path.relative(path, from: globals.fs.path.join(_root, 'test'))) +
        '.html'));
    final RunnerSuite suite = await browserManager
        .load(path, suiteUrl, suiteConfig, message, mapper: _mappers[path]);
    if (_closed) {
      return null;
    }
    return suite;
  }

  @override
  StreamChannel<dynamic> loadChannel(String path, SuitePlatform platform) =>
      throw UnimplementedError();

  /// Returns the [BrowserManager] for [runtime], which should be a browser.
  ///
  /// If no browser manager is running yet, starts one.
  Future<BrowserManager> _browserManagerFor(Runtime browser) {
    final Future<BrowserManager> managerFuture = _browserManagers[browser];
    if (managerFuture != null) {
      return managerFuture;
    }
    final Completer<WebSocketChannel> completer =
        Completer<WebSocketChannel>.sync();
    final String path =
        _webSocketHandler.create(webSocketHandler(completer.complete));
    final Uri webSocketUrl = url.replace(scheme: 'ws').resolve(path);
    final Uri hostUrl = url
      .resolve('static/index.html')
      .replace(queryParameters: <String, String>{
        'managerUrl': webSocketUrl.toString(),
        'debug': _config.pauseAfterLoad.toString(),
      });

    globals.printTrace('Serving tests at $hostUrl');

    final Future<BrowserManager> future = BrowserManager.start(
      browser,
      hostUrl,
      completer.future,
      headless: !_config.pauseAfterLoad,
    );

    // Store null values for browsers that error out so we know not to load them
    // again.
    _browserManagers[browser] = future.catchError((dynamic _) => null);

    return future;
  }

  @override
  Future<void> closeEphemeral() {
    final List<Future<BrowserManager>> managers =
        _browserManagers.values.toList();
    _browserManagers.clear();
    return Future.wait(managers.map((Future<BrowserManager> manager) async {
      final BrowserManager result = await manager;
      if (result == null) {
        return;
      }
      await result.close();
    }));
  }

  @override
  Future<void> close() => _closeMemo.runOnce(() async {
    final List<Future<dynamic>> futures = _browserManagers.values
      .map<Future<dynamic>>((Future<BrowserManager> future) async {
        final BrowserManager result = await future;
        if (result == null) {
          return;
        }
        await result.close();
      })
      .toList();
    futures.add(_server.close());
    futures.add(_testGoldenComparator.close());
    await Future.wait<void>(futures);
  });
}

class OneOffHandler {
  /// A map from URL paths to handlers.
  final Map<String, shelf.Handler> _handlers = <String, shelf.Handler>{};

  /// The counter of handlers that have been activated.
  int _counter = 0;

  /// The actual [shelf.Handler] that dispatches requests.
  shelf.Handler get handler => _onRequest;

  /// Creates a new one-off handler that forwards to [handler].
  ///
  /// Returns a string that's the URL path for hitting this handler, relative to
  /// the URL for the one-off handler itself.
  ///
  /// [handler] will be unmounted as soon as it receives a request.
  String create(shelf.Handler handler) {
    final String path = _counter.toString();
    _handlers[path] = handler;
    _counter++;
    return path;
  }

  /// Dispatches [request] to the appropriate handler.
  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
    final List<String> components = p.url.split(request.url.path);
    if (components.isEmpty) {
      return shelf.Response.notFound(null);
    }
    final String path = components.removeAt(0);
    final FutureOr<shelf.Response> Function(shelf.Request) handler =
        _handlers.remove(path);
    if (handler == null) {
      return shelf.Response.notFound(null);
    }
    return handler(request.change(path: path));
  }
}

class PathHandler {
  /// A trie of path components to handlers.
  final _Node _paths = _Node();

  /// The shelf handler.
  shelf.Handler get handler => _onRequest;

  /// Returns middleware that nests all requests beneath the URL prefix
  /// [beneath].
  static shelf.Middleware nestedIn(String beneath) {
    return (FutureOr<shelf.Response> Function(shelf.Request) handler) {
      final PathHandler pathHandler = PathHandler()..add(beneath, handler);
      return pathHandler.handler;
    };
  }

  /// Routes requests at or under [path] to [handler].
  ///
  /// If [path] is a parent or child directory of another path in this handler,
  /// the longest matching prefix wins.
  void add(String path, shelf.Handler handler) {
    _Node node = _paths;
    for (final String component in p.url.split(path)) {
      node = node.children.putIfAbsent(component, () => _Node());
    }
    node.handler = handler;
  }

  FutureOr<shelf.Response> _onRequest(shelf.Request request) {
    shelf.Handler handler;
    int handlerIndex;
    _Node node = _paths;
    final List<String> components = p.url.split(request.url.path);
    for (int i = 0; i < components.length; i++) {
      node = node.children[components[i]];
      if (node == null) {
        break;
      }
      if (node.handler == null) {
        continue;
      }
      handler = node.handler;
      handlerIndex = i;
    }

    if (handler == null) {
      return shelf.Response.notFound('Not found.');
    }

    return handler(
        request.change(path: p.url.joinAll(components.take(handlerIndex + 1))));
  }
}

/// A trie node.
class _Node {
  shelf.Handler handler;
  final Map<String, _Node> children = <String, _Node>{};
}

class BrowserManager {
  /// Creates a new BrowserManager that communicates with [browser] over
  /// [webSocket].
  BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) {
    // The duration should be short enough that the debugging console is open as
    // soon as the user is done setting breakpoints, but long enough that a test
    // doing a lot of synchronous work doesn't trigger a false positive.
    //
    // Start this canceled because we don't want it to start ticking until we
    // get some response from the iframe.
    _timer = RestartableTimer(const Duration(seconds: 3), () {
      for (final RunnerSuiteController controller in _controllers) {
        controller.setDebugging(true);
      }
    })
      ..cancel();

    // Whenever we get a message, no matter which child channel it's for, we know
    // the browser is still running code which means the user isn't debugging.
    _channel = MultiChannel<dynamic>(
      webSocket.cast<String>().transform(jsonDocument).changeStream((Stream<Object> stream) {
        return stream.map((Object message) {
          if (!_closed) {
            _timer.reset();
          }
          for (final RunnerSuiteController controller in _controllers) {
            controller.setDebugging(false);
          }

          return message;
        });
      }),
    );

    _environment = _loadBrowserEnvironment();
    _channel.stream.listen(_onMessage, onDone: close);
  }

  /// The browser instance that this is connected to via [_channel].
  final Chrome _browser;

  // TODO(nweiz): Consider removing the duplication between this and
  // [_browser.name].
  /// The [Runtime] for [_browser].
  final Runtime _runtime;

  /// The channel used to communicate with the browser.
  ///
  /// This is connected to a page running `static/host.dart`.
  MultiChannel<dynamic> _channel;

  /// A pool that ensures that limits the number of initial connections the
  /// manager will wait for at once.
  ///
  /// This isn't the *total* number of connections; any number of iframes may be
  /// loaded in the same browser. However, the browser can only load so many at
  /// once, and we want a timeout in case they fail so we only wait for so many
  /// at once.
  // The number 1 is chosen to disallow multiple iframes in the same browser. This
  // is because in some environments, such as Cirrus CI, tests end up stuck and
  // time out eventually. The exact reason for timeouts is unknown, but the
  // hypothesis is that we were the first ones to attempt to run DDK-compiled
  // tests concurrently in the browser. DDK is known to produce an order of
  // magnitude bigger and somewhat slower code, which may overload the browser.
  final Pool _pool = Pool(1);

  /// The ID of the next suite to be loaded.
  ///
  /// This is used to ensure that the suites can be referred to consistently
  /// across the client and server.
  int _suiteID = 0;

  /// Whether the channel to the browser has closed.
  bool _closed = false;

  /// The completer for [_BrowserEnvironment.displayPause].
  ///
  /// This will be `null` as long as the browser isn't displaying a pause
  /// screen.
  CancelableCompleter<dynamic> _pauseCompleter;

  /// The controller for [_BrowserEnvironment.onRestart].
  final StreamController<dynamic> _onRestartController =
      StreamController<dynamic>.broadcast();

  /// The environment to attach to each suite.
  Future<_BrowserEnvironment> _environment;

  /// Controllers for every suite in this browser.
  ///
  /// These are used to mark suites as debugging or not based on the browser's
  /// pings.
  final Set<RunnerSuiteController> _controllers = <RunnerSuiteController>{};

  // A timer that's reset whenever we receive a message from the browser.
  //
  // Because the browser stops running code when the user is actively debugging,
  // this lets us detect whether they're debugging reasonably accurately.
  RestartableTimer _timer;

  final AsyncMemoizer<dynamic> _closeMemoizer = AsyncMemoizer<dynamic>();

  /// Starts the browser identified by [runtime] and has it connect to [url].
  ///
  /// [url] should serve a page that establishes a WebSocket connection with
  /// this process. That connection, once established, should be emitted via
  /// [future]. If [debug] is true, starts the browser in debug mode, with its
  /// debugger interfaces on and detected.
  ///
  /// The browser will start in headless mode if [headless] is true.
  ///
  /// The [settings] indicate how to invoke this browser's executable.
  ///
  /// Returns the browser manager, or throws an [ApplicationException] if a
  /// connection fails to be established.
  static Future<BrowserManager> start(
    Runtime runtime,
    Uri url,
    Future<WebSocketChannel> future, {
    bool debug = false,
    bool headless = true,
  }) async {
    final Chrome chrome =
        await chromeLauncher.launch(url.toString(), headless: headless);

    final Completer<BrowserManager> completer = Completer<BrowserManager>();

    unawaited(chrome.onExit.then((void _) {
      throwToolExit('${runtime.name} exited before connecting.');
    }).catchError((dynamic error, StackTrace stackTrace) {
      if (completer.isCompleted) {
        return;
      }
      completer.completeError(error, stackTrace);
    }));
    unawaited(future.then((WebSocketChannel webSocket) {
      if (completer.isCompleted) {
        return;
      }
      completer.complete(BrowserManager._(chrome, runtime, webSocket));
    }).catchError((dynamic error, StackTrace stackTrace) {
      chrome.close();
      if (completer.isCompleted) {
        return;
      }
      completer.completeError(error, stackTrace);
    }));

    return completer.future.timeout(const Duration(seconds: 30), onTimeout: () {
      chrome.close();
      throwToolExit('Timed out waiting for ${runtime.name} to connect.');
      return;
    });
  }

  /// Loads [_BrowserEnvironment].
  Future<_BrowserEnvironment> _loadBrowserEnvironment() async {
    return _BrowserEnvironment(
        this, null, _browser.remoteDebuggerUri, _onRestartController.stream);
  }

  /// Tells the browser to load a test suite from the URL [url].
  ///
  /// [url] should be an HTML page with a reference to the JS-compiled test
  /// suite. [path] is the path of the original test suite file, which is used
  /// for reporting. [suiteConfig] is the configuration for the test suite.
  ///
  /// If [mapper] is passed, it's used to map stack traces for errors coming
  /// from this test suite.
  Future<RunnerSuite> load(
    String path,
    Uri url,
    SuiteConfiguration suiteConfig,
    Object message, {
    StackTraceMapper mapper,
  }) async {
    url = url.replace(fragment: Uri.encodeFull(jsonEncode(<String, Object>{
      'metadata': suiteConfig.metadata.serialize(),
      'browser': _runtime.identifier,
    })));

    final int suiteID = _suiteID++;
    RunnerSuiteController controller;
    void closeIframe() {
      if (_closed) {
        return;
      }
      _controllers.remove(controller);
      _channel.sink
          .add(<String, Object>{'command': 'closeSuite', 'id': suiteID});
    }

    // The virtual channel will be closed when the suite is closed, in which
    // case we should unload the iframe.
    final VirtualChannel<dynamic> virtualChannel = _channel.virtualChannel();
    final int suiteChannelID = virtualChannel.id;
    final StreamChannel<dynamic> suiteChannel = virtualChannel.transformStream(
      StreamTransformer<dynamic, dynamic>.fromHandlers(handleDone: (EventSink<dynamic> sink) {
        closeIframe();
        sink.close();
      }),
    );

    return await _pool.withResource<RunnerSuite>(() async {
      _channel.sink.add(<String, Object>{
        'command': 'loadSuite',
        'url': url.toString(),
        'id': suiteID,
        'channel': suiteChannelID,
      });

      try {
        controller = deserializeSuite(path, SuitePlatform(Runtime.chrome),
            suiteConfig, await _environment, suiteChannel, message);
        controller.channel('test.browser.mapper').sink.add(mapper?.serialize());

        _controllers.add(controller);
        return await controller.suite;
      } catch (_) {
        closeIframe();
        rethrow;
      }
    });
  }

  /// An implementation of [Environment.displayPause].
  CancelableOperation<dynamic> _displayPause() {
    if (_pauseCompleter != null) {
      return _pauseCompleter.operation;
    }
    _pauseCompleter = CancelableCompleter<dynamic>(onCancel: () {
      _channel.sink.add(<String, String>{'command': 'resume'});
      _pauseCompleter = null;
    });
    _pauseCompleter.operation.value.whenComplete(() {
      _pauseCompleter = null;
    });
    _channel.sink.add(<String, String>{'command': 'displayPause'});

    return _pauseCompleter.operation;
  }

  /// The callback for handling messages received from the host page.
  void _onMessage(dynamic message) {
    switch (message['command'] as String) {
      case 'ping':
        break;
      case 'restart':
        _onRestartController.add(null);
        break;
      case 'resume':
        if (_pauseCompleter != null) {
          _pauseCompleter.complete();
        }
        break;
      default:
        // Unreachable.
        assert(false);
        break;
    }
  }

  /// Closes the manager and releases any resources it owns, including closing
  /// the browser.
  Future<dynamic> close() {
    return _closeMemoizer.runOnce(() {
      _closed = true;
      _timer.cancel();
      if (_pauseCompleter != null) {
        _pauseCompleter.complete();
      }
      _pauseCompleter = null;
      _controllers.clear();
      return _browser.close();
    });
  }
}

/// An implementation of [Environment] for the browser.
///
/// All methods forward directly to [BrowserManager].
class _BrowserEnvironment implements Environment {
  _BrowserEnvironment(
    this._manager,
    this.observatoryUrl,
    this.remoteDebuggerUrl,
    this.onRestart,
  );

  final BrowserManager _manager;

  @override
  final bool supportsDebugging = true;

  @override
  final Uri observatoryUrl;

  @override
  final Uri remoteDebuggerUrl;

  @override
  final Stream<dynamic> onRestart;

  @override
  CancelableOperation<dynamic> displayPause() => _manager._displayPause();
}

/// Helper class to start golden file comparison in a separate process.
///
/// Golden file comparator is configured using flutter_test_config.dart and that
/// file can contain arbitrary Dart code that depends on dart:ui. Thus it has to
/// be executed in a `flutter_tester` environment. This helper class generates a
/// Dart file configured with flutter_test_config.dart to perform the comparison
/// of golden files.
class TestGoldenComparator {
  /// Creates a [TestGoldenComparator] instance.
  TestGoldenComparator(this.shellPath, this.compilerFactory)
      : tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_web_platform.');

  final String shellPath;
  final Directory tempDir;
  final TestCompiler Function() compilerFactory;

  TestCompiler _compiler;
  TestGoldenComparatorProcess _previousComparator;
  Uri _previousTestUri;

  Future<void> close() async {
    tempDir.deleteSync(recursive: true);
    await _compiler?.dispose();
    await _previousComparator?.close();
  }

  /// Start golden comparator in a separate process. Start one file per test file
  /// to reduce the overhead of starting `flutter_tester`.
  Future<TestGoldenComparatorProcess> _processForTestFile(Uri testUri) async {
    if (testUri == _previousTestUri) {
      return _previousComparator;
    }

    final String bootstrap = TestGoldenComparatorProcess.generateBootstrap(testUri);
    final Process process = await _startProcess(bootstrap);
    unawaited(_previousComparator?.close());
    _previousComparator = TestGoldenComparatorProcess(process);
    _previousTestUri = testUri;

    return _previousComparator;
  }

  Future<Process> _startProcess(String testBootstrap) async {
    // Prepare the Dart file that will talk to us and start the test.
    final File listenerFile = (await tempDir.createTemp('listener')).childFile('listener.dart');
    await listenerFile.writeAsString(testBootstrap);

    // Lazily create the compiler
    _compiler = _compiler ?? compilerFactory();
    final String output = await _compiler.compile(listenerFile.path);
    final List<String> command = <String>[
      shellPath,
      '--disable-observatory',
      '--non-interactive',
      '--packages=${PackageMap.globalPackagesPath}',
      output,
    ];

    final Map<String, String> environment = <String, String>{
      // Chrome is the only supported browser currently.
      'FLUTTER_TEST_BROWSER': 'chrome',
    };
    return globals.processManager.start(command, environment: environment);
  }

  Future<String> compareGoldens(Uri testUri, Uint8List bytes, Uri goldenKey, bool updateGoldens) async {
    final File imageFile = await (await tempDir.createTemp('image')).childFile('image').writeAsBytes(bytes);

    final TestGoldenComparatorProcess process = await _processForTestFile(testUri);
    process.sendCommand(imageFile, goldenKey, updateGoldens);

    final Map<String, dynamic> result = await process.getResponse().timeout(const Duration(seconds: 20));

    if (result == null) {
      return 'unknown error';
    } else {
      return (result['success'] as bool) ? null : ((result['message'] as String) ?? 'does not match');
    }
  }
}

/// Represents a `flutter_tester` process started for golden comparison. Also
/// handles communication with the child process.
class TestGoldenComparatorProcess {
  /// Creates a [TestGoldenComparatorProcess] backed by [process].
  TestGoldenComparatorProcess(this.process) {
    // Pipe stdout and stderr to printTrace and printError.
    // Also parse stdout as a stream of JSON objects.
    streamIterator = StreamIterator<Map<String, dynamic>>(
      process.stdout
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter())
        .where((String line) {
          globals.printTrace('<<< $line');
          return line.isNotEmpty && line[0] == '{';
        })
        .map<dynamic>(jsonDecode)
        .cast<Map<String, dynamic>>());

    process.stderr
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter())
        .forEach((String line) {
          globals.printError('<<< $line');
        });
  }

  final Process process;
  StreamIterator<Map<String, dynamic>> streamIterator;

  Future<void> close() async {
    await process.stdin.close();
    process.kill();
  }

  void sendCommand(File imageFile, Uri goldenKey, bool updateGoldens) {
    final Object command = jsonEncode(<String, dynamic>{
      'imageFile': imageFile.path,
      'key': goldenKey.toString(),
      'update': updateGoldens,
    });
    globals.printTrace('Preparing to send command: $command');
    process.stdin.writeln(command);
  }

  Future<Map<String, dynamic>> getResponse() async {
    final bool available = await streamIterator.moveNext();
    assert(available);
    return streamIterator.current;
  }

  static String generateBootstrap(Uri testUri) {
    final File testConfigFile = findTestConfigFile(globals.fs.file(testUri));
    // Generate comparator process for the file.
    return '''
import 'dart:convert'; // ignore: dart_convert_import
import 'dart:io'; // ignore: dart_io_import

import 'package:flutter_test/flutter_test.dart';

${testConfigFile != null ? "import '${Uri.file(testConfigFile.path)}' as test_config;" : ""}

void main() async {
  LocalFileComparator comparator = LocalFileComparator(Uri.parse('$testUri'));
  goldenFileComparator = comparator;

  ${testConfigFile != null ? 'test_config.main(() async {' : ''}
  final commands = stdin
    .transform<String>(utf8.decoder)
    .transform<String>(const LineSplitter())
    .map<Object>(jsonDecode);
  await for (final Object command in commands) {
    if (command is Map<String, dynamic>) {
      File imageFile = File(command['imageFile']);
      Uri goldenKey = Uri.parse(command['key']);
      bool update = command['update'];

      final bytes = await File(imageFile.path).readAsBytes();
      if (update) {
        await goldenFileComparator.update(goldenKey, bytes);
        print(jsonEncode({'success': true}));
      } else {
        try {
          bool success = await goldenFileComparator.compare(bytes, goldenKey);
          print(jsonEncode({'success': success}));
        } catch (ex) {
          print(jsonEncode({'success': false, 'message': '\$ex'}));
        }
      }
    } else {
      print('object type is not right');
    }
  }
  ${testConfigFile != null ? '});' : ''}
}
    ''';
  }
}
