// 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.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:matcher/matcher.dart';
import 'package:meta/meta.dart';
import 'package:vm_service_client/vm_service_client.dart';
import 'package:webdriver/sync_io.dart' as sync_io;
import 'package:webdriver/support/async.dart';

import '../common/error.dart';
import '../common/message.dart';
import 'driver.dart';
import 'timeline.dart';

/// An implementation of the Flutter Driver using the WebDriver.
///
/// Example of how to test WebFlutterDriver:
///   1. Have Selenium server (https://bit.ly/2TlkRyu) and WebDriver binary (https://chromedriver.chromium.org/downloads) downloaded and placed under the same folder
///   2. Launch WebDriver Server: java -jar selenium-server-standalone-3.141.59.jar
///   3. Launch Flutter Web application: flutter run -v -d chrome --target=test_driver/scroll_perf_web.dart
///   4. Run test script: flutter drive --target=test_driver/scroll_perf_web.dart -v --use-existing-app=/application address/
class WebFlutterDriver extends FlutterDriver {
  /// Creates a driver that uses a connection provided by the given
  /// [_connection].
  WebFlutterDriver.connectedTo(this._connection) :
        _startTime = DateTime.now();

  final FlutterWebConnection _connection;
  DateTime _startTime;

  /// Start time for tracing
  @visibleForTesting
  DateTime get startTime => _startTime;

  @override
  VMIsolate get appIsolate => throw UnsupportedError('WebFlutterDriver does not support appIsolate');

  @override
  VMServiceClient get serviceClient => throw UnsupportedError('WebFlutterDriver does not support serviceClient');

  /// Creates a driver that uses a connection provided by the given
  /// [hostUrl] which would fallback to environment variable VM_SERVICE_URL.
  /// Driver also depends on environment variables DRIVER_SESSION_ID,
  /// BROWSER_SUPPORTS_TIMELINE, DRIVER_SESSION_URI, DRIVER_SESSION_SPEC
  /// and DRIVER_SESSION_CAPABILITIES for configurations.
  static Future<FlutterDriver> connectWeb(
      {String hostUrl, Duration timeout}) async {
    hostUrl ??= Platform.environment['VM_SERVICE_URL'];
    final Map<String, dynamic> settings = <String, dynamic>{
      'support-timeline-action': Platform.environment['SUPPORT_TIMELINE_ACTION'] == 'true',
      'session-id': Platform.environment['DRIVER_SESSION_ID'],
      'session-uri': Platform.environment['DRIVER_SESSION_URI'],
      'session-spec': Platform.environment['DRIVER_SESSION_SPEC'],
      'session-capabilities': Platform.environment['DRIVER_SESSION_CAPABILITIES'],
    };
    final FlutterWebConnection connection = await FlutterWebConnection.connect
      (hostUrl, settings, timeout: timeout);
    return WebFlutterDriver.connectedTo(connection);
  }

  @override
  Future<Map<String, dynamic>> sendCommand(Command command) async {
    Map<String, dynamic> response;
    final Map<String, String> serialized = command.serialize();
    try {
      final dynamic data = await _connection.sendCommand('window.\$flutterDriver(\'${jsonEncode(serialized)}\')', command.timeout);
      response = data != null ? json.decode(data as String) as Map<String, dynamic> : <String, dynamic>{};
    } catch (error, stackTrace) {
      throw DriverError('Failed to respond to $command due to remote error\n : \$flutterDriver(\'${jsonEncode(serialized)}\')',
          error,
          stackTrace
      );
    }
    if (response['isError'] == true)
      throw DriverError('Error in Flutter application: ${response['response']}');
    return response['response'] as Map<String, dynamic>;
  }

  @override
  Future<void> close() => _connection.close();

  @override
  Future<void> waitUntilFirstFrameRasterized() async {
    throw UnimplementedError();
  }

  @override
  Future<List<int>> screenshot() async {
    await Future<void>.delayed(const Duration(seconds: 2));

    return _connection.screenshot();
  }

  @override
  Future<void> startTracing({
    List<TimelineStream> streams = const <TimelineStream>[TimelineStream.all],
    Duration timeout = kUnusuallyLongTimeout,
  }) async {
    _checkBrowserSupportsTimeline();
  }

  @override
  Future<Timeline> stopTracingAndDownloadTimeline({Duration timeout = kUnusuallyLongTimeout}) async {
    _checkBrowserSupportsTimeline();

    final List<Map<String, dynamic>> events = <Map<String, dynamic>>[];
    for (final sync_io.LogEntry entry in _connection.logs) {
      if (_startTime.isBefore(entry.timestamp)) {
        final Map<String, dynamic> data = jsonDecode(entry.message)['message'] as Map<String, dynamic>;
        if (data['method'] == 'Tracing.dataCollected') {
          // 'ts' data collected from Chrome is in double format, conversion needed
          try {
            data['params']['ts'] =
                double.parse(data['params']['ts'].toString()).toInt();
          } on FormatException catch (_) {
            // data is corrupted, skip
            continue;
          }
          events.add(data['params'] as Map<String, dynamic>);
        }
      }
    }
    final Map<String, dynamic> json = <String, dynamic>{
      'traceEvents': events,
    };
    return Timeline.fromJson(json);
  }

  @override
  Future<Timeline> traceAction(Future<dynamic> Function() action, {
    List<TimelineStream> streams = const <TimelineStream>[TimelineStream.all],
    bool retainPriorEvents = false,
  }) async {
    _checkBrowserSupportsTimeline();
    if (!retainPriorEvents) {
      await clearTimeline();
    }
    await startTracing(streams: streams);
    await action();

    return stopTracingAndDownloadTimeline();
  }

  @override
  Future<void> clearTimeline({Duration timeout = kUnusuallyLongTimeout}) async {
    _checkBrowserSupportsTimeline();

    // Reset start time
    _startTime = DateTime.now();
  }

  /// Checks whether browser supports Timeline related operations
  void _checkBrowserSupportsTimeline() {
    if (_connection.supportsTimelineAction) {
      throw UnsupportedError('Timeline action is not supported by current testing browser');
    }
  }
}

/// Encapsulates connection information to an instance of a Flutter Web application.
class FlutterWebConnection {
  /// Creates a FlutterWebConnection with WebDriver
  /// and whether the WebDriver supports timeline action
  FlutterWebConnection(this._driver, this._supportsTimelineAction);

  final sync_io.WebDriver _driver;


  bool _supportsTimelineAction;
  /// Whether the connected WebDriver supports timeline action for Flutter Web Driver
  // ignore: unnecessary_getters_setters
  bool get supportsTimelineAction => _supportsTimelineAction;

  /// Setter for _supportsTimelineAction
  @visibleForTesting
  // ignore: unnecessary_getters_setters
  set supportsTimelineAction(bool value) {
    _supportsTimelineAction = value;
  }

  /// Starts WebDriver with the given [capabilities] and
  /// establishes the connection to Flutter Web application.
  static Future<FlutterWebConnection> connect(
      String url,
      Map<String, dynamic> settings,
      {Duration timeout}) async {
    // Use sync WebDriver because async version will create a 15 seconds
    // overhead when quitting.
    final sync_io.WebDriver driver = sync_io.fromExistingSession(
        settings['session-id'].toString(),
        uri: Uri.parse(settings['session-uri'].toString()),
        spec: _convertToSpec(settings['session-spec'].toString().toLowerCase()),
        capabilities: jsonDecode(settings['session-capabilities'].toString()) as Map<String, dynamic>);
    driver.get(url);

    await waitUntilExtensionInstalled(driver, timeout);
    return FlutterWebConnection(driver, settings['support-timeline-action'] as bool);
  }

  /// Sends command via WebDriver to Flutter web application
  Future<dynamic> sendCommand(String script, Duration duration) async {
    dynamic result;
    try {
      _driver.execute(script, <void>[]);
    } catch (_) {
      // In case there is an exception, do nothing
    }

    try {
      result = await waitFor<dynamic>(() => _driver.execute('r'
          'eturn \$flutterDriverResult', <String>[]),
          matcher: isNotNull,
          timeout: duration ?? const Duration(days: 30));
    } catch (_) {
      // Returns null if exception thrown.
      return null;
    } finally {
      // Resets the result.
      _driver.execute('''
        \$flutterDriverResult = null
      ''', <void>[]);
    }
    return result;
  }

  /// Gets performance log from WebDriver.
  List<sync_io.LogEntry> get logs => _driver.logs.get(sync_io.LogType.performance);

  /// Takes screenshot via WebDriver.
  List<int> screenshot()  => _driver.captureScreenshotAsList();

  /// Closes the WebDriver.
  Future<void> close() async {
    _driver.quit(closeSession: false);
  }
}

/// Waits until extension is installed.
Future<void> waitUntilExtensionInstalled(sync_io.WebDriver driver, Duration timeout) async {
  await waitFor<void>(() =>
      driver.execute('return typeof(window.\$flutterDriver)', <String>[]),
      matcher: 'function',
      timeout: timeout ?? const Duration(days: 365));
}

sync_io.WebDriverSpec _convertToSpec(String specString) {
  switch (specString.toLowerCase()) {
    case 'webdriverspec.w3c':
      return sync_io.WebDriverSpec.W3c;
    case 'webdriverspec.jsonwire':
      return sync_io.WebDriverSpec.JsonWire;
    default:
      return sync_io.WebDriverSpec.Auto;
  }
}
