// 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 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/src/instrumentation.dart';
import 'package:flutter_test/src/test_pointer.dart';

import 'error.dart';
import 'find.dart';
import 'gesture.dart';
import 'health.dart';
import 'message.dart';

const String _extensionMethodName = 'driver';
const String _extensionMethod = 'ext.flutter.$_extensionMethodName';
const Duration _kDefaultTimeout = const Duration(seconds: 5);

class _DriverBinding extends WidgetsFlutterBinding { // TODO(ianh): refactor so we're not extending a concrete binding
  @override
  void initServiceExtensions() {
    super.initServiceExtensions();
    FlutterDriverExtension extension = new FlutterDriverExtension();
    registerServiceExtension(
      name: _extensionMethodName,
      callback: extension.call
    );
  }
}

/// Enables Flutter Driver VM service extension.
///
/// This extension is required for tests that use `package:flutter_driver` to
/// drive applications from a separate process.
///
/// Call this function prior to running your application, e.g. before you call
/// `runApp`.
void enableFlutterDriverExtension() {
  assert(WidgetsBinding.instance == null);
  new _DriverBinding();
  assert(WidgetsBinding.instance is _DriverBinding);
}

/// Handles a command and returns a result.
typedef Future<Result> CommandHandlerCallback(Command c);

/// Deserializes JSON map to a command object.
typedef Command CommandDeserializerCallback(Map<String, String> params);

/// Runs the finder and returns the [Element] found, or `null`.
typedef Future<Element> FinderCallback(SerializableFinder finder);

class FlutterDriverExtension {
  static final Logger _log = new Logger('FlutterDriverExtension');

  FlutterDriverExtension() {
    _commandHandlers.addAll(<String, CommandHandlerCallback>{
      'get_health': getHealth,
      'tap': tap,
      'get_text': getText,
      'scroll': scroll,
      'waitFor': waitFor,
    });

    _commandDeserializers.addAll(<String, CommandDeserializerCallback>{
      'get_health': GetHealth.deserialize,
      'tap': Tap.deserialize,
      'get_text': GetText.deserialize,
      'scroll': Scroll.deserialize,
      'waitFor': WaitFor.deserialize,
    });

    _finders.addAll(<String, FinderCallback>{
      'ByValueKey': _findByValueKey,
      'ByTooltipMessage': _findByTooltipMessage,
      'ByText': _findByText,
    });
  }

  final Instrumentation prober = new Instrumentation();
  final Map<String, CommandHandlerCallback> _commandHandlers = <String, CommandHandlerCallback>{};
  final Map<String, CommandDeserializerCallback> _commandDeserializers = <String, CommandDeserializerCallback>{};
  final Map<String, FinderCallback> _finders = <String, FinderCallback>{};

  Future<Map<String, dynamic>> call(Map<String, String> params) async {
    try {
      String commandKind = params['command'];
      CommandHandlerCallback commandHandler = _commandHandlers[commandKind];
      CommandDeserializerCallback commandDeserializer =
          _commandDeserializers[commandKind];
      if (commandHandler == null || commandDeserializer == null)
        throw 'Extension $_extensionMethod does not support command $commandKind';
      Command command = commandDeserializer(params);
      return (await commandHandler(command)).toJson();
    } catch (error, stackTrace) {
      _log.error('Uncaught extension error: $error\n$stackTrace');
      rethrow;
    }
  }

  Stream<Duration> _onFrameReadyStream;
  Stream<Duration> get _onFrameReady {
    if (_onFrameReadyStream == null) {
      // Lazy-initialize the frame callback because the renderer is not yet
      // available at the time the extension is registered.
      StreamController<Duration> frameReadyController = new StreamController<Duration>.broadcast(sync: true);
      SchedulerBinding.instance.addPersistentFrameCallback((Duration timestamp) {
        frameReadyController.add(timestamp);
      });
      _onFrameReadyStream = frameReadyController.stream;
    }
    return _onFrameReadyStream;
  }

  Future<Health> getHealth(GetHealth command) async => new Health(HealthStatus.ok);

  /// Runs [locator] repeatedly until it finds an [Element] or times out.
  Future<Element> _waitForElement(String descriptionGetter(), Element locator()) async {
    // Short-circuit if the element is already on the UI
    Element element = locator();
    if (element != null) {
      return element;
    }

    // No element yet, so we retry on frames rendered in the future.
    Completer<Element> completer = new Completer<Element>();
    StreamSubscription<Duration> subscription;

    Timer timeout = new Timer(_kDefaultTimeout, () {
      subscription.cancel();
      completer.completeError('Timed out waiting for ${descriptionGetter()}');
    });

    subscription = _onFrameReady.listen((Duration duration) {
      Element element = locator();
      if (element != null) {
        subscription.cancel();
        timeout.cancel();
        completer.complete(element);
      }
    });

    return completer.future;
  }

  Future<Element> _findByValueKey(ByValueKey byKey) async {
    return _waitForElement(
      () => 'element with key "${byKey.keyValue}" of type ${byKey.keyValueType}',
      () {
        return prober.findElementByKey(new ValueKey<dynamic>(byKey.keyValue));
      }
    );
  }

  Future<Element> _findByTooltipMessage(ByTooltipMessage byTooltipMessage) async {
    return _waitForElement(
      () => 'tooltip with message "${byTooltipMessage.text}" on it',
      () {
        return prober.findElement((Element element) {
          Widget widget = element.widget;

          if (widget is Tooltip)
            return widget.message == byTooltipMessage.text;

          return false;
        });
      }
    );
  }

  Future<Element> _findByText(ByText byText) async {
    return await _waitForElement(
      () => 'text "${byText.text}"',
      () {
        return prober.findText(byText.text);
      });
  }

  Future<Element> _runFinder(SerializableFinder finder) {
    FinderCallback cb = _finders[finder.finderType];

    if (cb == null)
      throw 'Unsupported finder type: ${finder.finderType}';

    return cb(finder);
  }

  Future<TapResult> tap(Tap command) async {
    Element target = await _runFinder(command.finder);
    prober.tap(target);
    return new TapResult();
  }

  Future<WaitForResult> waitFor(WaitFor command) async {
    if (await _runFinder(command.finder) != null)
      return new WaitForResult();
    else
      return null;
  }

  Future<ScrollResult> scroll(Scroll command) async {
    Element target = await _runFinder(command.finder);
    final int totalMoves = command.duration.inMicroseconds * command.frequency ~/ Duration.MICROSECONDS_PER_SECOND;
    Offset delta = new Offset(command.dx, command.dy) / totalMoves.toDouble();
    Duration pause = command.duration ~/ totalMoves;
    Point startLocation = prober.getCenter(target);
    Point currentLocation = startLocation;
    TestPointer pointer = new TestPointer(1);
    HitTestResult hitTest = new HitTestResult();

    prober.binding.hitTest(hitTest, startLocation);
    prober.binding.dispatchEvent(pointer.down(startLocation), hitTest);
    await new Future<Null>.value();  // so that down and move don't happen in the same microtask
    for (int moves = 0; moves < totalMoves; moves++) {
      currentLocation = currentLocation + delta;
      prober.binding.dispatchEvent(pointer.move(currentLocation), hitTest);
      await new Future<Null>.delayed(pause);
    }
    prober.binding.dispatchEvent(pointer.up(), hitTest);

    return new ScrollResult();
  }

  Future<GetTextResult> getText(GetText command) async {
    Element target = await _runFinder(command.finder);
    // TODO(yjbanov): support more ways to read text
    Text text = target.widget;
    return new GetTextResult(text.data);
  }
}