// 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/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:quiver/testing/async.dart';
import 'package:quiver/time.dart';

/// Enumeration of possible phases to reach in
/// [WidgetTester.pumpWidget] and [TestWidgetsFlutterBinding.pump].
// TODO(ianh): Merge with identical code in the rendering test code.
enum EnginePhase {
  layout,
  compositingBits,
  paint,
  composite,
  flushSemantics,
  sendSemanticsTree
}

class TestWidgetsFlutterBinding extends BindingBase with SchedulerBinding, GestureBinding, ServicesBinding, RendererBinding, WidgetsBinding {
  /// Creates and initializes the binding. This constructor is
  /// idempotent; calling it a second time will just return the
  /// previously-created instance.
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      new TestWidgetsFlutterBinding();
    assert(WidgetsBinding.instance is TestWidgetsFlutterBinding);
    return WidgetsBinding.instance;
  }

  @override
  void initInstances() {
    timeDilation = 1.0; // just in case the developer has artificially changed it for development
    debugPrint = _synchronousDebugPrint; // TODO(ianh): don't do this when running as 'flutter run'
    super.initInstances();
  }

  void _synchronousDebugPrint(String message, { int wrapWidth }) {
    if (wrapWidth != null) {
      print(message.split('\n').expand((String line) => debugWordWrap(line, wrapWidth)).join('\n'));
    } else {
      print(message);
    }
  }

  FakeAsync get fakeAsync => _fakeAsync;
  bool get inTest => fakeAsync != null;

  FakeAsync _fakeAsync;
  Clock _clock;

  EnginePhase phase = EnginePhase.sendSemanticsTree;

  // Pump the rendering pipeline up to the given phase.
  @override
  void beginFrame() {
    assert(inTest);
    buildOwner.buildDirtyElements();
    _beginFrame();
    buildOwner.finalizeTree();
  }

  // Cloned from RendererBinding.beginFrame() but with early-exit semantics.
  void _beginFrame() {
    assert(inTest);
    assert(renderView != null);
    pipelineOwner.flushLayout();
    if (phase == EnginePhase.layout)
      return;
    pipelineOwner.flushCompositingBits();
    if (phase == EnginePhase.compositingBits)
      return;
    pipelineOwner.flushPaint();
    if (phase == EnginePhase.paint)
      return;
    renderView.compositeFrame(); // this sends the bits to the GPU
    if (phase == EnginePhase.composite)
      return;
    if (SemanticsNode.hasListeners) {
      pipelineOwner.flushSemantics();
      if (phase == EnginePhase.flushSemantics)
        return;
      SemanticsNode.sendSemanticsTree();
    }
  }

  @override
  void dispatchEvent(PointerEvent event, HitTestResult result) {
    assert(inTest);
    super.dispatchEvent(event, result);
    fakeAsync.flushMicrotasks();
  }

  /// Triggers a frame sequence (build/layout/paint/etc),
  /// then flushes microtasks.
  ///
  /// If duration is set, then advances the clock by that much first.
  /// Doing this flushes microtasks.
  ///
  /// The supplied EnginePhase is the final phase reached during the pump pass;
  /// if not supplied, the whole pass is executed.
  void pump([ Duration duration, EnginePhase newPhase = EnginePhase.sendSemanticsTree ]) {
    assert(inTest);
    assert(_clock != null);
    if (duration != null)
      fakeAsync.elapse(duration);
    phase = newPhase;
    handleBeginFrame(new Duration(
      milliseconds: _clock.now().millisecondsSinceEpoch
    ));
    fakeAsync.flushMicrotasks();
  }

  /// Artificially calls dispatchLocaleChanged on the Widget binding,
  /// then flushes microtasks.
  void setLocale(String languageCode, String countryCode) {
    assert(inTest);
    Locale locale = new Locale(languageCode, countryCode);
    dispatchLocaleChanged(locale);
    fakeAsync.flushMicrotasks();
  }

  /// Returns the exception most recently caught by the Flutter framework.
  ///
  /// Call this if you expect an exception during a test. If an exception is
  /// thrown and this is not called, then the exception is rethrown when
  /// the [testWidgets] call completes.
  ///
  /// If two exceptions are thrown in a row without the first one being
  /// acknowledged with a call to this method, then when the second exception is
  /// thrown, they are both dumped to the console and then the second is
  /// rethrown from the exception handler. This will likely result in the
  /// framework entering a highly unstable state and everything collapsing.
  ///
  /// It's safe to call this when there's no pending exception; it will return
  /// null in that case.
  dynamic takeException() {
    assert(inTest);
    dynamic result = _pendingException?.exception;
    _pendingException = null;
    return result;
  }
  FlutterErrorDetails _pendingException;
  FlutterExceptionHandler _oldHandler;
  int _exceptionCount;

  /// Called by the [testWidgets] function before a test is executed.
  void preTest() {
    assert(fakeAsync == null);
    assert(_clock == null);
    _fakeAsync = new FakeAsync();
    _clock = fakeAsync.getClock(new DateTime.utc(2015, 1, 1));
    _oldHandler = FlutterError.onError;
    _exceptionCount = 0; // number of un-taken exceptions
    FlutterError.onError = (FlutterErrorDetails details) {
      if (_pendingException != null) {
        if (_exceptionCount == 0) {
          _exceptionCount = 2;
          FlutterError.dumpErrorToConsole(_pendingException, forceReport: true);
        } else {
          _exceptionCount += 1;
        }
        FlutterError.dumpErrorToConsole(details, forceReport: true);
        _pendingException = new FlutterErrorDetails(
          exception: 'Multiple exceptions ($_exceptionCount) were detected during the running of the current test, and at least one was unexpected.',
          library: 'Flutter test framework'
        );
      } else {
        _pendingException = details;
      }
    };
  }

  /// Invoke the callback inside a [FakeAsync] scope on which [pump] can
  /// advance time.
  ///
  /// Returns a future which completes when the test has run.
  ///
  /// Called by the [testWidgets] and [benchmarkWidgets] functions to
  /// run a test.
  Future<Null> runTest(Future<Null> callback()) {
    assert(inTest);
    Future<Null> callbackResult;
    fakeAsync.run((FakeAsync fakeAsync) {
      assert(fakeAsync == this.fakeAsync);
      callbackResult = _runTest(callback);
      fakeAsync.flushMicrotasks();
      assert(inTest);
    });
    // callbackResult is a Future that was created in the Zone of the fakeAsync.
    // This means that if we call .then() on it (as the test framework is about to),
    // it will register a microtask to handle the future _in the fake async zone_.
    // To avoid this, we wrap it in a Future that we've created _outside_ the fake
    // async zone.
    return new Future<Null>.value(callbackResult);
  }

  Future<Null> _runTest(Future<Null> callback()) async {
    assert(inTest);

    runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state.
    pump();

    // run the test
    try {
      await callback();
      fakeAsync.flushMicrotasks();
    } catch (exception, stack) {
      // call onError handler above
      FlutterError.reportError(new FlutterErrorDetails(
        exception: exception,
        stack: stack,
        library: 'Flutter test framework'
      ));
    }

    runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets.
    pump();

    // verify invariants
    assert(debugAssertNoTransientCallbacks(
      'An animation is still running even after the widget tree was disposed.'
    ));
    assert(() {
      'A Timer is still running even after the widget tree was disposed.';
      return fakeAsync.periodicTimerCount == 0;
    });
    assert(() {
      'A Timer is still running even after the widget tree was disposed.';
      return fakeAsync.nonPeriodicTimerCount == 0;
    });
    assert(fakeAsync.microtaskCount == 0); // Shouldn't be possible.

    // check for unexpected exceptions
    if (_pendingException != null) {
      if (_exceptionCount > 1)
        throw 'Test failed. See exception logs above.';
      throw 'Test failed. See exception log below.';
    }

    assert(inTest);
    return null;
  }

  /// Called by the [testWidgets] function after a test is executed.
  void postTest() {
    assert(_fakeAsync != null);
    assert(_clock != null);
    FlutterError.onError = _oldHandler;
    if (_pendingException != null)
      FlutterError.dumpErrorToConsole(_pendingException, forceReport: true);
    _pendingException = null;
    _exceptionCount = null;
    _clock = null;
    _fakeAsync = null;
  }

}
