// 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:ui' as ui show window;

import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:quiver/testing/async.dart';
import 'package:quiver/time.dart';

import 'instrumentation.dart';

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

class _SteppedWidgetFlutterBinding extends WidgetsFlutterBinding { // TODO(ianh): refactor so we're not extending a concrete binding
  _SteppedWidgetFlutterBinding(this.async);

  final FakeAsync async;

  /// Creates and initializes the binding. This constructor is
  /// idempotent; calling it a second time will just return the
  /// previously-created instance.
  static WidgetsBinding ensureInitialized(FakeAsync async) {
    if (WidgetsBinding.instance == null)
      new _SteppedWidgetFlutterBinding(async);
    return WidgetsBinding.instance;
  }

  EnginePhase phase = EnginePhase.sendSemanticsTree;

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

  // Cloned from RendererBinding.beginFrame() but with early-exit semantics.
  void _beginFrame() {
    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) {
    super.dispatchEvent(event, result);
    async.flushMicrotasks();
  }
}

/// Helper class for flutter tests providing fake async.
///
/// This class extends Instrumentation to also abstract away the beginFrame
/// and async/clock access to allow writing tests which depend on the passage
/// of time without actually moving the clock forward.
class ElementTreeTester extends Instrumentation {
  ElementTreeTester._(FakeAsync async)
    : async = async,
      clock = async.getClock(new DateTime.utc(2015, 1, 1)),
      super(binding: _SteppedWidgetFlutterBinding.ensureInitialized(async)) {
    timeDilation = 1.0;
    ui.window.onBeginFrame = null;
    debugPrint = _synchronousDebugPrint;
  }

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


  final FakeAsync async;
  final Clock clock;

  /// Calls [runApp] with the given widget, then triggers a frame sequence and
  /// flushes microtasks, by calling [pump] with the same duration (if any).
  /// The supplied [EnginePhase] is the final phase reached during the pump pass;
  /// if not supplied, the whole pass is executed.
  void pumpWidget(Widget widget, [ Duration duration, EnginePhase phase ]) {
    runApp(widget);
    pump(duration, phase);
  }

  /// 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 phase ]) {
    if (duration != null)
      async.elapse(duration);
    if (binding is _SteppedWidgetFlutterBinding) {
      // Some tests call WidgetsFlutterBinding.ensureInitialized() manually, so
      // we can't actually be sure we have a stepped binding.
      _SteppedWidgetFlutterBinding steppedBinding = binding;
      steppedBinding.phase = phase ?? EnginePhase.sendSemanticsTree;
    } else {
      // Can't step to a given phase in that case
      assert(phase == null);
    }
    binding.handleBeginFrame(new Duration(
      milliseconds: clock.now().millisecondsSinceEpoch)
    );
    async.flushMicrotasks();
  }

  /// Artificially calls dispatchLocaleChanged on the Widget binding,
  /// then flushes microtasks.
  void setLocale(String languageCode, String countryCode) {
    Locale locale = new Locale(languageCode, countryCode);
    binding.dispatchLocaleChanged(locale);
    async.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() {
    dynamic result = _pendingException;
    _pendingException = null;
    return result;
  }
  dynamic _pendingException;
}

void testElementTree(callback(ElementTreeTester tester)) {
  new FakeAsync().run((FakeAsync async) {
    FlutterExceptionHandler oldHandler = FlutterError.onError;
    ElementTreeTester tester = new ElementTreeTester._(async);
    try {
      FlutterError.onError = (FlutterErrorDetails details) {
        if (tester._pendingException != null) {
          FlutterError.dumpErrorToConsole(tester._pendingException);
          FlutterError.dumpErrorToConsole(details.exception);
          tester._pendingException = 'An uncaught exception was thrown.';
          throw details.exception;
        }
        tester._pendingException = details;
      };
      runApp(new Container(key: new UniqueKey())); // Reset the tree to a known state.
      callback(tester);
      runApp(new Container(key: new UniqueKey())); // Unmount any remaining widgets.
      async.flushMicrotasks();
      assert(SchedulerBinding.instance.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 async.periodicTimerCount == 0;
      });
      assert(() {
        'A Timer is still running even after the widget tree was disposed.';
        return async.nonPeriodicTimerCount == 0;
      });
      assert(async.microtaskCount == 0); // Shouldn't be possible.
      if (tester._pendingException != null)
        throw 'An exception (shown above) was thrown during the test.';
    } finally {
      FlutterError.onError = oldHandler;
      if (tester._pendingException != null) {
        FlutterError.dumpErrorToConsole(tester._pendingException);
        tester._pendingException = null;
      }
    }
  });
}